dsh-plugin-admin 0.4.0 → 0.4.2

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 CHANGED
@@ -1,2299 +1,2339 @@
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, #222); max-height: calc(100vh - 140px); overflow: hidden; }',
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(200,200,210,0.2)); border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.3)); 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, #222); background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); }',
29
- '[data-dsh-admin-section] .tab.active { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); color: var(--dsw-alias-label-primary, #333); 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, rgba(200,200,210,0.4)); color: var(--dsw-alias-label-secondary, #555); }',
31
- '[data-dsh-admin-section] .tab.active .tab-count { background: var(--dsw-static-blue-500, #3b82f6); color: #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(200,200,210,0.4)); border-radius: 12px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); box-shadow: 0 1px 2px rgba(0,0,0,0.02); }',
33
- '[data-dsh-admin-section] .toolbar .input { height: 32px; padding: 0 12px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
34
- '[data-dsh-admin-section] .search-wrap { flex: 1.6; min-width: 0; position: relative; display: flex; align-items: center; height: 32px; }',
35
- '[data-dsh-admin-section] .search-wrap .input { flex: 1; min-width: 0; padding-left: 30px; font-size: 13px; }',
36
- '[data-dsh-admin-section] .session-search { flex: 1; height: 32px; }',
37
- '[data-dsh-admin-section] .session-search .input { height: 32px; padding: 0 12px; font-size: 13px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
38
- '[data-dsh-admin-section] .install-wrap { flex: 1; min-width: 0; display: flex; align-items: center; height: 32px; }',
39
- '[data-dsh-admin-section] .install-wrap .input { flex: 1; min-width: 0; font-size: 13px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
40
- '[data-dsh-admin-section] .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 12px; opacity: 0.7; pointer-events: none; z-index: 1; }',
41
- '[data-dsh-admin-section] .input { width: 100%; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); background: var(--dsw-alias-bg-base, transparent); color: inherit; font: inherit; outline: none; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }',
42
- '[data-dsh-admin-section] .input:hover { border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.6)); }',
43
- '[data-dsh-admin-section] .input:focus { border-color: var(--dsw-static-blue-500, #3b82f6); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16); }',
44
- '[data-dsh-admin-section] .filter-bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }',
45
- '[data-dsh-admin-section] .pill { font-size: 11px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.45)); background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary, #555); transition: all 0.12s; font-family: inherit; }',
46
- '[data-dsh-admin-section] .pill:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); }',
47
- '[data-dsh-admin-section] .pill.active { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.4)); color: var(--dsw-static-blue-500, #3b82f6); font-weight: 600; border-color: currentColor; }',
48
- '[data-dsh-admin-section] .btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 32px; padding: 0 14px; border-radius: 8px; font-size: 13px; 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, color 0.15s ease; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); background: var(--dsw-alias-interactive-bg-hover, rgba(240,240,245,0.6)); color: var(--dsw-alias-label-secondary, #555); white-space: nowrap; flex: none; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }',
49
- '[data-dsh-admin-section] .btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(220,220,230,0.7)); border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.65)); color: var(--dsw-alias-label-primary, #222); box-shadow: 0 2px 4px rgba(0,0,0,0.06); }',
50
- '[data-dsh-admin-section] .search-wrap .btn.sm { background: transparent; border: none; box-shadow: none; padding: 2px 6px; color: var(--dsw-alias-label-tertiary, #888); }',
51
- '[data-dsh-admin-section] .search-wrap .btn.sm:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.4)); color: var(--dsw-alias-label-primary, #222); }',
52
- '[data-dsh-admin-section] .btn:active:not(:disabled) { transform: translateY(1px); }',
53
- '[data-dsh-admin-section] .btn:focus-visible, [data-dsh-admin-section] .pill:focus-visible { outline: 2px solid var(--dsw-static-blue-500, #3b82f6); outline-offset: 2px; }',
54
- '[data-dsh-admin-section] .btn:disabled { opacity: 0.45; cursor: not-allowed; }',
55
- '[data-dsh-admin-section] .btn.primary { background: var(--dsw-static-blue-500, #3b82f6); color: #fff; border-color: transparent; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }',
56
- '[data-dsh-admin-section] .btn.primary:hover:not(:disabled) { background: var(--dsw-static-blue-400, #60a5fa); color: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.14); }',
57
- '[data-dsh-admin-section] .btn.primary:active:not(:disabled) { background: var(--dsw-static-blue-600, #2563eb); color: #fff; }',
58
- '[data-dsh-admin-section] .btn.danger { color: var(--dsw-alias-state-error-primary, #dc2626); border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); }',
59
- '[data-dsh-admin-section] .btn.danger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.1)); border-color: rgba(220,38,38,0.35); color: #b91c1c; }',
60
- '[data-dsh-admin-section] .btn.danger-solid { background: var(--dsw-alias-state-error-primary, #dc2626); color: #fff; border-color: transparent; }',
61
- '[data-dsh-admin-section] .btn.danger-solid:hover:not(:disabled) { background: #b91c1c; color: #fff; }',
62
- '[data-dsh-admin-section] .btn.danger-solid:active:not(:disabled) { background: #991b1b; color: #fff; }',
63
- '[data-dsh-admin-section] .btn.sm { height: 28px; padding: 0 10px; font-size: 12px; border-radius: 6px; }',
64
- '[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; }',
65
- '[data-dsh-admin-section] .list { display: flex; flex-direction: column; gap: 10px; flex: 1 1 auto; min-height: 0; max-height: 560px; overflow-y: auto; padding: 2px 4px 2px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)) transparent; }',
66
- '[data-dsh-admin-section] .list::-webkit-scrollbar { width: 6px; }',
67
- '[data-dsh-admin-section] .list::-webkit-scrollbar-thumb { border-radius: 99px; background: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); }',
68
- '[data-dsh-admin-section] .session-group { display: flex; flex-direction: column; gap: 6px; }',
69
- '[data-dsh-admin-section] .group-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px 8px 12px; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); box-shadow: 0 1px 2px rgba(0,0,0,0.02); margin-bottom: 4px; }',
70
- '[data-dsh-admin-section] .group-header:first-child { margin-top: 2px; }',
71
- '[data-dsh-admin-section] .group-title { display: inline-flex; align-items: center; gap: 6px; }',
72
- '[data-dsh-admin-section] .group-count { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); color: var(--dsw-alias-label-secondary, #555); font-weight: 500; }',
73
- '[data-dsh-admin-section] .group-header .btn.sm { margin-left: auto; }',
74
- '[data-dsh-admin-section] .group-header .btn.sm + .btn.sm { margin-left: 0; }',
75
- '[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; }',
76
- '[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(200,200,210,0.4)); background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); 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; }',
77
- '[data-dsh-admin-section] .card:hover { transform: translateY(-1px); border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.6)); box-shadow: 0 8px 20px rgba(0,0,0,0.06); }',
78
- '[data-dsh-admin-section] .card-header { display: flex; align-items: center; gap: 8px; min-width: 0; }',
79
- '[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; }',
80
- '[data-dsh-admin-section] .card-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; }',
81
- '[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(200,200,210,0.25)); border-left: 2px solid var(--dsw-static-blue-500, #3b82f6); font-size: 12px; line-height: 1.45; color: var(--dsw-alias-label-secondary, #555); margin-top: 1px; margin-bottom: 1px; }',
82
- '[data-dsh-admin-section] .summary-icon { font-size: 11px; line-height: 1.45; flex: none; opacity: 0.85; }',
83
- '[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; }',
84
- '[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; }',
85
- '[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; }',
86
- '[data-dsh-admin-section] .card-actions { display: flex; gap: 5px; align-items: center; flex: none; }',
87
- '[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(200,200,210,0.25)); color: var(--dsw-alias-label-secondary, #555); }',
88
- '[data-dsh-admin-section] .tag.plugin { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); }',
89
- '[data-dsh-admin-section] .tag.local { background: rgba(59, 130, 246, 0.12); color: var(--dsw-static-blue-500, #3b82f6); }',
90
- '[data-dsh-admin-section] .tag.update { background: rgba(245, 158, 11, 0.14); color: #d97706; font-weight: 600; border: 1px solid rgba(217, 119, 6, 0.25); }',
91
- '[data-dsh-admin-section] .tag.update-error { background: rgba(239, 68, 68, 0.1); color: var(--dsw-alias-state-error-primary, #ef4444); }',
92
- '[data-dsh-admin-section] .plugin-path { font-family: monospace; font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-secondary, #555); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 4px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.25)); }',
93
- '[data-dsh-admin-section] .tag.version { font-family: monospace; }',
94
- '[data-dsh-admin-section] .tag.live { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); font-weight: 600; }',
95
- '[data-dsh-admin-section] .tag.archived { background: rgba(245, 158, 11, 0.12); color: var(--dsw-alias-state-warn-label, #f59e0b); }',
96
- '[data-dsh-admin-section] .tag.turns { background: rgba(59, 130, 246, 0.12); color: var(--dsw-static-blue-500, #3b82f6); font-weight: 500; }',
97
- '[data-dsh-admin-section] .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #999); }',
98
- '[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; }',
99
- '[data-dsh-admin-section] .dot.archived { background: var(--dsw-alias-state-warn-label, #f59e0b); }',
100
- '[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); }',
101
- '[data-dsh-admin-section] .confirm-text { font-size: 11px; color: var(--dsw-alias-state-error-primary, #ef4444); font-weight: 500; }',
102
- '[data-dsh-admin-section] .confirm-actions { display: flex; gap: 5px; flex: none; }',
103
- '[data-dsh-admin-section] .busy-banner { display: flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 7px; background: rgba(59, 130, 246, 0.08); color: var(--dsw-static-blue-500, #3b82f6); font-size: 11px; }',
104
- '[data-dsh-admin-section] .update-strip { display: flex; align-items: center; gap: 8px; padding: 8px 11px; border-radius: 8px; font-size: 12px; font-weight: 500; line-height: 1.4; }',
105
- '[data-dsh-admin-section] .update-strip .btn.sm { margin-left: auto; flex: none; }',
106
- '[data-dsh-admin-section] .update-strip.checking { background: rgba(59, 130, 246, 0.08); color: var(--dsw-static-blue-500, #3b82f6); }',
107
- '[data-dsh-admin-section] .update-strip.ok { background: rgba(16, 185, 129, 0.1); color: var(--dsw-alias-state-success-primary, #10b981); }',
108
- '[data-dsh-admin-section] .update-strip.has-updates { background: rgba(245, 158, 11, 0.12); color: #d97706; border: 1px solid rgba(217, 119, 6, 0.25); }',
109
- '[data-dsh-admin-section] .update-strip.has-errors { background: rgba(239, 68, 68, 0.08); color: var(--dsw-alias-state-error-primary, #ef4444); }',
110
- '[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; }',
111
- '[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; }',
112
- '[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(200,200,210,0.3)); flex: none; }',
113
- '[data-dsh-admin-section] .footer .path { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
114
- '[data-dsh-admin-section] .footer .hint { font-style: normal; }',
115
- '[data-dsh-admin-section] .session-id-badge { font-family: monospace; font-size: 10px; opacity: 0.75; }',
116
- '[data-dsh-admin-section] .mcp-test-row { padding: 6px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.25)); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }',
117
- '[data-dsh-admin-section] .mcp-test { display: inline-flex; align-items: center; gap: 5px; }',
118
- '[data-dsh-admin-section] .mcp-test.mcp-test-list { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }',
119
- '[data-dsh-admin-section] .mcp-test-busy { color: var(--dsw-alias-label-secondary, #666); }',
120
- '[data-dsh-admin-section] .mcp-test-ok { color: #16a34a; }',
121
- '[data-dsh-admin-section] .mcp-test-fail { color: var(--dsw-alias-state-error-primary, #ef4444); }',
122
- '[data-dsh-admin-section] .mcp-test-warn { color: #d97706; opacity: 0.95; }',
123
- '[data-dsh-admin-section] .mcp-test-cached { font-size: 10px; color: var(--dsw-alias-label-tertiary, #999); }',
124
- '@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%; } }',
125
- '@keyframes dsh-toast-in { 0% { opacity: 0; transform: translateY(-12px) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }',
126
- '@keyframes dsh-toast-out { 0% { opacity: 1; transform: translateY(0) scale(1); } 100% { opacity: 0; transform: translateY(-8px) scale(0.96); } }',
127
- '.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; }',
128
- '.dsh-admin-toast.success { background: rgba(16, 185, 129, 0.95); color: #fff; }',
129
- '.dsh-admin-toast.error { background: rgba(239, 68, 68, 0.95); color: #fff; }',
130
- '.dsh-admin-toast.info { background: rgba(22, 119, 255, 0.95); color: #fff; }',
131
- '.dsh-admin-toast.leaving { animation: dsh-toast-out 0.2s ease-in forwards; }',
132
- ].join('\n')
133
-
134
- function injectStyles() {
135
- var tagId = 'dsh-plugin-admin/unified-section.css'
136
- if (document.querySelector('style[data-plugin-css="' + tagId + '"]') === null) {
137
- var tag = document.createElement('style')
138
- tag.dataset.plugin = 'dsh-plugin-admin'
139
- tag.dataset.pluginCss = tagId
140
- tag.textContent = CSS_TEXT
141
- document.head.appendChild(tag)
142
- }
143
- }
144
-
145
- function baseName(path) {
146
- if (path === null || path === '') return '(无工作目录)'
147
- var parts = path.replace(/\\/g, '/').split('/')
148
- var last = parts[parts.length - 1]
149
- return last === '' ? parts[parts.length - 2] || path : last
150
- }
151
-
152
- function formatDate(ms) {
153
- if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
154
- var d = new Date(ms)
155
- var pad = function (n) { return (n < 10 ? '0' : '') + String(n) }
156
- return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
157
- + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
158
- }
159
-
160
- function messageOf(error) {
161
- if (error !== null && typeof error === 'object' && typeof error.message === 'string') return error.message
162
- if (typeof error === 'string') return error
163
- return JSON.stringify(error)
164
- }
165
-
166
- /**
167
- * Show a floating toast notification at the top of the viewport.
168
- * Auto-dismisses after `duration` ms. Supports 'success', 'error', 'info'.
169
- */
170
- function showToast(type, text, duration) {
171
- if (typeof document === 'undefined') return
172
- duration = duration || 3000
173
- var toast = document.createElement('div')
174
- toast.className = 'dsh-admin-toast ' + type
175
- toast.textContent = text
176
- document.body.appendChild(toast)
177
-
178
- var timer = setTimeout(function () {
179
- toast.classList.add('leaving')
180
- setTimeout(function () {
181
- if (toast.parentNode) toast.parentNode.removeChild(toast)
182
- }, 200)
183
- }, duration)
184
-
185
- toast.addEventListener('click', function () {
186
- clearTimeout(timer)
187
- if (!toast.classList.contains('leaving')) {
188
- toast.classList.add('leaving')
189
- setTimeout(function () {
190
- if (toast.parentNode) toast.parentNode.removeChild(toast)
191
- }, 200)
192
- }
193
- })
194
- }
195
-
196
- /**
197
- * Copy text to the clipboard. Uses the async Clipboard API when available;
198
- * falls back to a hidden textarea + execCommand for older browsers or
199
- * non-secure contexts.
200
- */
201
- function copyTextToClipboard(text) {
202
- if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
203
- navigator.clipboard.writeText(text).then(function () {
204
- showToast('success', '📋 会话 ID 已复制')
205
- }, function () {
206
- fallbackCopy(text)
207
- })
208
- return
209
- }
210
- fallbackCopy(text)
211
- }
212
-
213
- function fallbackCopy(text) {
214
- try {
215
- var ta = document.createElement('textarea')
216
- ta.value = text
217
- ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
218
- document.body.appendChild(ta)
219
- ta.select()
220
- ta.setSelectionRange(0, text.length)
221
- document.execCommand('copy')
222
- document.body.removeChild(ta)
223
- } catch (e) {
224
- showToast('error', ' 复制失败:' + messageOf(e))
225
- }
226
- }
227
-
228
- /* ========================================================================== */
229
- /* Menu Popup Injection (Sessions & Workspaces) */
230
- /* ========================================================================== */
231
-
232
- /**
233
- * Inject plugin items into the existing three-dot menu popup. The workspace
234
- * bundle renders a Menu component (@deepseek-ai/dsh-client-ui-primitives)
235
- * with portal:true, so the popup lives as a div[role="menu"] inside
236
- * document.body with position:fixed. A MutationObserver detects when this
237
- * popup appears, identifies it as a session or workspace menu by checking
238
- * the existing button labels, and injects additional items.
239
- */
240
- function setupMenuInjection(call, refreshSessions) {
241
- if (typeof document === 'undefined') return function () {}
242
-
243
- /**
244
- * Extract the session title from the row's aria-label. The anchor button
245
- * aria-label is: 会话"<title>"的操作 (zh) / Session "<title>" actions (en).
246
- * Extracting from the DOM keeps the injection synchronous — no RPC needed
247
- * just to show the button, so the menu can't close before it appears.
248
- */
249
- function sessionTitleFromRow(row) {
250
- var btn = row.querySelector('button[aria-label*="会话" i][aria-label*="操作" i], button[aria-label*="session" i][aria-label*="actions" i]')
251
- if (btn === null) return null
252
- var label = btn.getAttribute('aria-label') || ''
253
- // zh: 会话"xxx"的操作 strip 会话"/"的操作
254
- var zh = /会话["“]([^"”]+)["”]/.exec(label)
255
- if (zh) return zh[1].trim()
256
- // en: Session "xxx" actions
257
- var en = /session\s*["“]([^"”]+)["”]/i.exec(label)
258
- if (en) return en[1].trim()
259
- // fallback: whole label minus known prefixes/suffixes
260
- return label.replace(/^会话/, '').replace(/^session/i, '').replace(/["“”]|的操作|actions$/gi, '').trim() || null
261
- }
262
-
263
- /** Find the treeitem row for the anchor button nearest the menu. */
264
- function rowForMenu(menuEl) {
265
- var menuRect = menuEl ? menuEl.getBoundingClientRect() : null
266
- var allRows = document.querySelectorAll('[role="treeitem"]')
267
- if (allRows.length === 0) return null
268
- var rows = []
269
- for (var i = 0; i < allRows.length; i++) {
270
- var r = allRows[i]
271
- // Only rows that contain a session/workspace action button
272
- 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]')) {
273
- rows.push(r)
274
- }
275
- }
276
- if (rows.length === 0) return null
277
-
278
- // The Menu component renders with side="bottom": menu top sits just
279
- // below the anchor button (button.bottom + 4). Match the row whose
280
- // center is closest to the MENU TOP, not the menu center — the menu
281
- // can be tall (several items), so its center drifts far from the
282
- // clicked row.
283
- if (menuRect === null || menuRect.height === 0) {
284
- // No reliable menu rect. With a single candidate row the choice is
285
- // unambiguous, so return it (jsdom/test environments have zero rects);
286
- // with several rows refuse to guess rather than act on the wrong
287
- // session.
288
- if (rows.length === 1) return rows[0]
289
- return null
290
- }
291
- var closest = rows[0]
292
- var closestDist = Infinity
293
- for (var j = 0; j < rows.length; j++) {
294
- var rect = rows[j].getBoundingClientRect()
295
- if (rect.height === 0) continue
296
- var rowCenter = rect.top + rect.height / 2
297
- var dist = Math.abs(rowCenter - menuRect.top)
298
- if (dist < closestDist) {
299
- closestDist = dist
300
- closest = rows[j]
301
- }
302
- }
303
- return closest
304
- }
305
-
306
- function injectItems(menuEl) {
307
- if (menuEl.querySelector('[data-dsh-admin-injected]')) return
308
-
309
- var text = menuEl.textContent || ''
310
- var isSession = text.indexOf('归档会话') !== -1 || text.indexOf('Archive session') !== -1
311
- var isWorkspace = !isSession && (text.indexOf('删除') !== -1 || text.indexOf('Delete workspace') !== -1)
312
- if (!isSession && !isWorkspace) return
313
-
314
- var viewport = menuEl.querySelector('[role="presentation"]')
315
- if (viewport === null) return
316
-
317
- var row = rowForMenu(menuEl)
318
- if (row === null) return
319
-
320
- if (isSession) {
321
- var title = sessionTitleFromRow(row)
322
- if (title === null) return
323
- // Resolve sessions by title lazily at click time (fresh RPC) so the
324
- // match is always current. Copy-id keeps the fuzzy matcher (a wrong
325
- // match only mis-copies an id); delete must never guess, so it
326
- // resolves by exact title only and refuses duplicate titles — the
327
- // panel's rows act on ids and can disambiguate.
328
- function fetchSessions(done) {
329
- call('sessionAdmin/list', {}).then(function (listResult) {
330
- done((listResult && listResult.ok && listResult.value && listResult.value.sessions) || [])
331
- }, function () { showToast('error', ' 无法加载会话列表') })
332
- }
333
- // Normalize a title for comparison: trim, collapse spaces, drop
334
- // trailing ellipsis and truncation artifacts.
335
- function norm(v) {
336
- return (v || '').replace(/\s+/g, ' ').replace(/\.{3,}\s*$/, '').trim().toLowerCase()
337
- }
338
-
339
- function resolveSessionFuzzy(cb) {
340
- fetchSessions(function (sessions) {
341
- var match = null
342
- var nt = norm(title)
343
- for (var i = 0; i < sessions.length; i++) {
344
- var s = sessions[i]
345
- var st = norm(s.title)
346
- if (st === nt) { match = s; break }
347
- // substring both ways (skip too-short needles)
348
- if (nt.length > 3 && st.indexOf(nt) !== -1) { match = s; break }
349
- if (st.length > 3 && nt.indexOf(st) !== -1) { match = s; break }
350
- // fall back to cwd basename match
351
- var cwdBase = s.cwd ? s.cwd.replace(/\\/g, '/').split('/').pop() : ''
352
- if (cwdBase && (cwdBase === nt || nt.indexOf(cwdBase) !== -1 || cwdBase.indexOf(nt) !== -1)) { match = s; break }
353
- }
354
- if (match === null) { showToast('error', '❌ 未找到匹配的会话'); return }
355
- cb(match)
356
- })
357
- }
358
-
359
- function resolveSessionExact(cb) {
360
- fetchSessions(function (sessions) {
361
- var nt = norm(title)
362
- var exact = []
363
- for (var i = 0; i < sessions.length; i++) {
364
- if (norm(sessions[i].title) === nt) exact.push(sessions[i])
365
- }
366
- if (exact.length === 0) { showToast('error', '❌ 未找到匹配的会话'); return }
367
- if (exact.length > 1) {
368
- showToast('error', '❌ 存在 ' + String(exact.length) + ' 个同名会话,无法确定要删除的目标;请在 设置 管理中心 会话管理 中按会话 ID 删除')
369
- return
370
- }
371
- cb(exact[0])
372
- })
373
- }
374
-
375
- // Copy the session id to the clipboard.
376
- appendMenuItem(viewport, '复制会话 ID', 'normal', function () {
377
- resolveSessionFuzzy(function (session) {
378
- copyTextToClipboard(session.id)
379
- })
380
- })
381
-
382
- // Delete the session. Online sessions are now closable through the
383
- // captured AgentHandle (closeSession disposes the live agent/session
384
- // first, so removing the log cannot resurrect it) — this stops a
385
- // running conversation in that session. The item arms on the first
386
- // click and only fires on a second click within 4s (confirmText below)
387
- // deletion is physical, so one misclick must never be enough.
388
- appendMenuItem(viewport, '删除会话', 'danger', function () {
389
- resolveSessionExact(function (match) {
390
- var method = match.live ? 'sessionAdmin/closeSession' : 'sessionAdmin/deleteSession'
391
- call(method, { sessionId: match.id }).then(function (result) {
392
- if (result && result.ok) {
393
- showToast('success', '🗑️ 会话已删除')
394
- // Nudge the sidebar so the removed session vanishes now
395
- // instead of lingering in 未分组 until reload.
396
- if (refreshSessions) refreshSessions()
397
- } else {
398
- showToast('error', '❌ 删除会话失败:' + messageOf(result && result.error))
399
- }
400
- }, function (err) { showToast('error', '❌ 删除会话失败:' + messageOf(err)) })
401
- })
402
- }, '⚠️ 再点一次确认删除(在线会话将先关停)')
403
- } else {
404
- // Workspace: reveal in file manager. Path is resolved lazily at click.
405
- var wsTitle = (row.querySelector('[class*="_title"]') || {}).textContent || ''
406
- wsTitle = wsTitle.trim()
407
- if (wsTitle === '') return
408
- appendMenuItem(viewport, '在资源管理器打开', 'normal', function () {
409
- call('sessionAdmin/list', {}).then(function (listResult) {
410
- var workspaces = (listResult && listResult.ok && listResult.value && listResult.value.workspaces) || []
411
- var wsPath = null
412
- for (var i = 0; i < workspaces.length; i++) {
413
- var w = workspaces[i]
414
- var wTitle = (w && w.title) || ''
415
- if (wTitle === wsTitle || (wsTitle !== '' && wTitle !== '' && (wsTitle.indexOf(wTitle) !== -1 || wTitle.indexOf(wsTitle) !== -1))) { wsPath = w && w.path; break }
416
- }
417
- if (wsPath === null) { showToast('error', '❌ 打开失败:未找到工作区路径'); return }
418
- call('fsAdmin/reveal', { path: wsPath }).then(function (result) {
419
- // Success needs no toast — the Explorer window opening is the
420
- // feedback itself. Only failures get a hint.
421
- if (!(result && result.ok)) showToast('error', '❌ 打开失败:' + messageOf(result && result.error))
422
- }, function (err) { showToast('error', '❌ 打开失败:' + messageOf(err)) })
423
- }, function () { showToast('error', '❌ 打开失败:无法加载工作区列表') })
424
- })
425
- }
426
- }
427
-
428
- function appendMenuItem(viewport, label, kind, onClick, confirmText) {
429
- // Separator (only if there are already items — always, to visually group)
430
- var sep = document.createElement('div')
431
- sep.setAttribute('role', 'separator')
432
- sep.setAttribute('data-dsh-admin-injected', '')
433
- sep.style.cssText = 'margin:3px 0;border-top:1px solid var(--dsw-alias-border-subtle,rgba(200,200,210,0.3))'
434
- viewport.appendChild(sep)
435
-
436
- var btn = document.createElement('button')
437
- btn.type = 'button'
438
- btn.setAttribute('role', 'menuitem')
439
- btn.setAttribute('data-dsh-admin-injected', '')
440
- btn.textContent = label
441
- var color = kind === 'danger' ? '#ef4444' : 'inherit'
442
- var hoverBg = kind === 'danger' ? 'rgba(239,68,68,0.12)' : 'rgba(200,200,210,0.3)'
443
- 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'
444
- btn.addEventListener('mouseenter', function () { btn.style.background = hoverBg })
445
- btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent' })
446
- // Optional two-step confirm (confirmText): the first click arms the
447
- // item and relabels it; only a second click within 4s runs onClick —
448
- // destructive items must never fire on a single misclick.
449
- var armed = false
450
- var disarmTimer = null
451
- btn.addEventListener('click', function (e) {
452
- e.stopPropagation()
453
- if (confirmText === undefined) { onClick(); return }
454
- if (!armed) {
455
- armed = true
456
- btn.textContent = confirmText
457
- disarmTimer = setTimeout(function () {
458
- armed = false
459
- btn.textContent = label
460
- }, 4000)
461
- return
462
- }
463
- clearTimeout(disarmTimer)
464
- armed = false
465
- btn.textContent = label
466
- onClick()
467
- })
468
- viewport.appendChild(btn)
469
- }
470
-
471
- var observer = new MutationObserver(function (mutations) {
472
- for (var i = 0; i < mutations.length; i++) {
473
- var added = mutations[i].addedNodes
474
- if (!added || added.length === 0) continue
475
- for (var j = 0; j < added.length; j++) {
476
- var node = added[j]
477
- if (node.nodeType !== 1) continue
478
- var el = node
479
- if (el.getAttribute && el.getAttribute('role') === 'menu') {
480
- injectItems(el)
481
- } else {
482
- var menu = el.querySelector && el.querySelector('[role="menu"]')
483
- if (menu) injectItems(menu)
484
- }
485
- }
486
- }
487
- })
488
-
489
- observer.observe(document.body, { childList: true, subtree: true })
490
-
491
- return function () {
492
- observer.disconnect()
493
- }
494
- }
495
-
496
- /**
497
- * Plugins management settings section (standalone, no internal tabs).
498
- */
499
- function PluginsSection(props) {
500
- // Lazy initializer: the seed object (and its localStorage read) must be
501
- // built once at mount, not re-parsed on every render.
502
- var pairPlugins = useState(function () {
503
- return {
504
- profileDir: '',
505
- plugins: [],
506
- busy: false,
507
- error: '',
508
- spec: '',
509
- confirming: null,
510
- note: '',
511
- output: '',
512
- filter: 'all',
513
- needle: '',
514
- checkingUpdates: false,
515
- updateChecked: false, // true once a check finished (even if no updates)
516
- // Seed from persisted reminders so the ⬆ 有新版本 badge is visible the
517
- // moment the panel mounts (before the fresh host check resolves) — the
518
- // reminder is only dropped once a check confirms the upgrade completed.
519
- updates: loadUpdateReminders(), // name -> { latest, updateAvailable, error?, at? }
520
- }
521
- })
522
- var pView = pairPlugins[0]
523
- var setPView = pairPlugins[1]
524
-
525
- var alive = useRef(false)
526
- // In-flight check guard as a ref, not state: the state field drives the
527
- // disabled button visuals, but gating ASYNC handlers on it races — a stale
528
- // closure reads TRUE after an earlier check already settled and silently
529
- // skips a required follow-up (the post-upgrade refresh), or FALSE mid-flight
530
- // and lets a second one through. Refs are immune to closure staleness.
531
- var checkingRef = useRef(false)
532
-
533
- function patchPlugin(partial) {
534
- setPView(function (cur) {
535
- var next = {}
536
- for (var k in cur) next[k] = cur[k]
537
- for (var pk in partial) next[pk] = partial[pk]
538
- return next
539
- })
540
- }
541
-
542
- function callRemote(method, args) {
543
- return props.call(method, args)
544
- }
545
-
546
- function reloadPlugins() {
547
- patchPlugin({ busy: true, error: '', note: '' })
548
- callRemote('pluginAdmin/list', {}).then(function (result) {
549
- if (!alive.current) return
550
- if (result.ok) {
551
- patchPlugin({
552
- busy: false,
553
- profileDir: (result.value && result.value.profileDir) || '',
554
- plugins: (result.value && result.value.plugins) || [],
555
- })
556
- } else {
557
- patchPlugin({ busy: false, error: '加载插件失败:' + messageOf(result.error) })
558
- }
559
- }, function (failure) {
560
- if (!alive.current) return
561
- patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
562
- })
563
- }
564
-
565
- /**
566
- * Query the npm registry for newer versions of registry-installed bundles
567
- * and stash per-plugin results. The resolution MERGES the fresh authoritative
568
- * results onto the previously persisted reminders:
569
- * - newer version confirmed -> (re)arm & persist the reminder
570
- * - confirmed up to date -> drop the reminder (更新完删除提醒)
571
- * - per-plugin check error -> keep the known reminder (a transient
572
- * registry failure must not erase it);
573
- * with no known reminder the failure
574
- * itself is recorded so cards/strip/
575
- * note can surface it
576
- * - no longer a registry install -> drop stale reminders
577
- * The AUTO check on panel open is cache-friendly; a manual check passes
578
- * force=true so the host bypasses its 5-minute TTL, re-queries the registry
579
- * and refreshes the cached content (检查更新 = 强制重新查,而不是回放缓存).
580
- * Runs concurrently with the list so the panel stays responsive; failures
581
- * are per-plugin, never fatal.
582
- * @param force - true to force the host to bypass its update cache.
583
- */
584
- function checkUpdates(force) {
585
- if (checkingRef.current) return
586
- checkingRef.current = true
587
- patchPlugin({ checkingUpdates: true, note: '' })
588
- callRemote('pluginAdmin/checkUpdates', { force: force === true }).then(function (result) {
589
- checkingRef.current = false
590
- if (!alive.current) return
591
- if (!result.ok) {
592
- patchPlugin({ checkingUpdates: false, error: '检查更新失败:' + messageOf(result.error) })
593
- // A failed call must not wipe previously known reminders — they stay
594
- // seeded from localStorage and keep rendering on the cards.
595
- return
596
- }
597
- var list = (result.value && result.value.updates) || []
598
- // Merge INSIDE the functional updater against the LATEST committed
599
- // state, never the render closure's snapshot: a reminder armed by an
600
- // in-flight check resolving alongside an upgrade commit must survive
601
- // (a stale-snapshot full replace erased it). The updater stays pure —
602
- // localStorage persistence is owned by the mirror effect below.
603
- setPView(function (cur) {
604
- var next = {}
605
- for (var k in cur) next[k] = cur[k]
606
- next.checkingUpdates = false
607
- next.updates = mergeUpdateReminders(cur.updates || {}, list)
608
- next.updateChecked = true
609
- next.note = updateCheckNote(next.updates, list.length)
610
- return next
611
- })
612
- }, function (failure) {
613
- checkingRef.current = false
614
- if (!alive.current) return
615
- patchPlugin({ checkingUpdates: false, error: '检查更新调用失败:' + messageOf(failure) })
616
- })
617
- }
618
-
619
- /** Upgrade one plugin to its latest version (registry install by name). */
620
- function upgradePlugin(name) {
621
- if (pView.busy) return
622
- var spec = name + '@latest'
623
- patchPlugin({ busy: true, error: '', confirming: null, note: '' })
624
- callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
625
- if (!alive.current) return
626
- if (result.ok) {
627
- // The upgrade consumed this reminder: strip it from the CURRENT state
628
- // inside the updater (更新完删除提醒) never from the click-time
629
- // closure snapshot, which may miss reminders an in-flight check armed
630
- // meanwhile (erasing those was exactly scenario 7's stale-snapshot
631
- // bug). localStorage follows via the shared mirror effect; the forced
632
- // refresh below confirms the new version is current.
633
- setPView(function (cur) {
634
- var next = {}
635
- for (var k in cur) next[k] = cur[k]
636
- next.busy = false
637
- next.note = '已更新 ' + name + '。更改在重启 dsh 后生效'
638
- next.output = (result.value && result.value.output) || ''
639
- next.profileDir = (result.value && result.value.profileDir) || ''
640
- next.plugins = (result.value && result.value.plugins) || []
641
- var remaining = {}
642
- for (var rk in cur.updates) {
643
- if (rk !== name) remaining[rk] = cur.updates[rk]
644
- }
645
- next.updates = remaining
646
- return next
647
- })
648
- // Refresh the update map so the upgraded entry stops flagging.
649
- // Force: the @latest spec installed the registry's CURRENT latest,
650
- // but the host TTL cache may still hold an older latest published
651
- // minutes ago — comparing against that would re-flag the freshly
652
- // upgraded plugin. Bypass the cache and resync its content instead.
653
- checkUpdates(true)
654
- return
655
- }
656
- patchPlugin({ busy: false, error: '更新失败:' + messageOf(result.error) })
657
- reloadPlugins()
658
- }, function (failure) {
659
- if (!alive.current) return
660
- patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
661
- })
662
- }
663
-
664
- function installPlugin() {
665
- if (pView.busy || pView.spec.trim() === '') return
666
- var spec = pView.spec.trim()
667
- patchPlugin({ busy: true, error: '', confirming: null, note: '' })
668
- callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
669
- if (!alive.current) return
670
- if (result.ok) {
671
- patchPlugin({
672
- busy: false,
673
- note: '安装完成。更改在重启 dsh 后生效',
674
- output: (result.value && result.value.output) || '',
675
- profileDir: (result.value && result.value.profileDir) || '',
676
- plugins: (result.value && result.value.plugins) || [],
677
- spec: '',
678
- })
679
- return
680
- }
681
- patchPlugin({ busy: false, error: '安装失败:' + messageOf(result.error) })
682
- reloadPlugins()
683
- }, function (failure) {
684
- if (!alive.current) return
685
- patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
686
- })
687
- }
688
-
689
- function removePlugin(name) {
690
- patchPlugin({ busy: true, error: '', confirming: null, note: '' })
691
- callRemote('pluginAdmin/remove', { name: name }).then(function (result) {
692
- if (!alive.current) return
693
- if (result.ok) {
694
- patchPlugin({
695
- busy: false,
696
- note: '卸载完成。更改在重启 dsh 后生效',
697
- output: (result.value && result.value.output) || '',
698
- profileDir: (result.value && result.value.profileDir) || '',
699
- plugins: (result.value && result.value.plugins) || [],
700
- })
701
- return
702
- }
703
- patchPlugin({ busy: false, error: '卸载失败:' + messageOf(result.error) })
704
- reloadPlugins()
705
- }, function (failure) {
706
- if (!alive.current) return
707
- patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
708
- })
709
- }
710
-
711
- useEffect(function () {
712
- alive.current = true
713
- reloadPlugins()
714
- // Auto-check for remote updates on mount (the panel loads lazily when
715
- // the 插件管理 tab is opened, so this runs once per open). The auto path
716
- // is cache-friendly: the host serves its 5-minute cache, so re-opening
717
- // the tab shortly after does not re-hit the registry. The manual
718
- // ⬆ 检查更新 button is the force path — it bypasses the cache and
719
- // refreshes its content.
720
- checkUpdates()
721
- return function () { alive.current = false }
722
- }, [])
723
-
724
- // Single persistence point: localStorage always mirrors the LATEST
725
- // committed reminders. Moving saves out of the async handlers (and out of
726
- // the functional updaters, which must stay pure) removes drift by
727
- // construction whatever merge won the last commit is what survives
728
- // dialog reopen and dsh restarts (best-effort; unavailable storage just
729
- // degrades to session-only badges).
730
- useEffect(function () {
731
- saveUpdateReminders(pView.updates || {})
732
- }, [pView.updates])
733
-
734
- return createElement('div', { 'data-dsh-admin-section': '' },
735
- renderPluginsView(pView, patchPlugin, installPlugin, removePlugin, checkUpdates, upgradePlugin))
736
- }
737
-
738
- /**
739
- * Sessions management settings section (standalone, no internal tabs).
740
- */
741
- function SessionsSection(props) {
742
- var pairSessions = useState({
743
- sessions: [],
744
- workspaces: [],
745
- busy: false,
746
- error: '',
747
- needle: '',
748
- confirming: null,
749
- filter: 'all',
750
- })
751
- var sView = pairSessions[0]
752
- var setSView = pairSessions[1]
753
-
754
- var alive = useRef(false)
755
-
756
- function patchSession(partial) {
757
- setSView(function (cur) {
758
- var next = {}
759
- for (var k in cur) next[k] = cur[k]
760
- for (var pk in partial) next[pk] = partial[pk]
761
- return next
762
- })
763
- }
764
-
765
- function callRemote(method, args) {
766
- return props.call(method, args)
767
- }
768
-
769
- function reloadSessions() {
770
- patchSession({ busy: true, error: '' })
771
- callRemote('sessionAdmin/list', {}).then(function (result) {
772
- if (!alive.current) return
773
- if (result.ok) {
774
- patchSession({
775
- busy: false,
776
- sessions: (result.value && result.value.sessions) || [],
777
- workspaces: (result.value && result.value.workspaces) || [],
778
- })
779
- } else {
780
- patchSession({ busy: false, error: '加载会话失败:' + messageOf(result.error) })
781
- }
782
- }, function (failure) {
783
- if (!alive.current) return
784
- patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
785
- })
786
- }
787
-
788
- function actSession(method, sessionId) {
789
- patchSession({ busy: true, error: '', confirming: null })
790
- callRemote('sessionAdmin/' + method, { sessionId: sessionId }).then(function (result) {
791
- if (!alive.current) return
792
- if (result.ok) {
793
- // Refresh this panel's own list, and nudge the sidebar (via the
794
- // sessions service) so a deleted session disappears immediately
795
- // instead of lingering until reload.
796
- if ((method === 'deleteSession' || method === 'closeSession') && props.refreshSessions) props.refreshSessions()
797
- return reloadSessions()
798
- }
799
- patchSession({ busy: false, error: '操作失败:' + messageOf(result.error) })
800
- }, function (failure) {
801
- if (!alive.current) return
802
- patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
803
- })
804
- }
805
-
806
- useEffect(function () {
807
- alive.current = true
808
- reloadSessions()
809
- return function () { alive.current = false }
810
- }, [])
811
-
812
- return createElement('div', { 'data-dsh-admin-section': '' },
813
- renderSessionsView(sView, patchSession, reloadSessions, actSession))
814
- }
815
-
816
- /**
817
- * MCP configuration settings section (standalone, no internal tabs).
818
- */
819
-
820
- /* ------------------------------------------------------------------------ */
821
- /* MCP test-result cache */
822
- /* */
823
- /* Successful connectivity probes are persisted to localStorage so the */
824
- /* panel shows the last known status after tab switches or reopening the */
825
- /* dialog instead of an empty state. Only OK results are cached (failures */
826
- /* are usually transient); a cached row is labelled with its probe time, */
827
- /* and the cache entry is invalidated when the config is saved or removed, */
828
- /* or pruned when the entry disappears from the host list. */
829
- /* ------------------------------------------------------------------------ */
830
-
831
- var MCP_TEST_CACHE_KEY = 'dsh-plugin-admin/mcp-test-results'
832
-
833
- /**
834
- * Touch localStorage safely: on some environments (jsdom with an opaque
835
- * origin, hardened browsers, privacy modes) merely READING window.localStorage
836
- * throws a SecurityError, so the access itself has to sit inside try/catch.
837
- * Returns null when storage is unavailable.
838
- */
839
- function safeLocalStorage() {
840
- try {
841
- if (typeof window === 'undefined' || !window.localStorage) return null
842
- return window.localStorage
843
- } catch (e) {
844
- return null
845
- }
846
- }
847
-
848
- function mcpTestStorage() {
849
- return safeLocalStorage()
850
- }
851
-
852
- function loadMcpTestCache() {
853
- var store = mcpTestStorage()
854
- if (store === null) return {}
855
- try {
856
- var parsed = JSON.parse(store.getItem(MCP_TEST_CACHE_KEY))
857
- if (parsed === null || typeof parsed !== 'object') return {}
858
- // Purge failure records persisted by older builds: only a durable OK is
859
- // worth restoring on mount; a stale ❌ from a past outage must not
860
- // outlive the session it happened in.
861
- var out = {}
862
- for (var id in parsed) {
863
- var entry = parsed[id]
864
- if (entry && entry.result && entry.result.ok === true) out[id] = entry
865
- }
866
- return out
867
- } catch (e) {
868
- return {}
869
- }
870
- }
871
-
872
- /** Mirror settled, successful probes into localStorage (best-effort). */
873
- function saveMcpTestCache(state) {
874
- var store = mcpTestStorage()
875
- if (store === null) return
876
- try {
877
- var out = {}
878
- for (var id in state) {
879
- var s = state[id]
880
- // Busy rows keep the previous cached result; failures stay ephemeral —
881
- // INCLUDING structured {ok:false} probes (timeout, connection refused),
882
- // which arrive as a truthy result object and need their own ok check.
883
- if (!s || s.busy || s.result === null || s.result === undefined
884
- || s.result.ok !== true) continue
885
- out[id] = { result: s.result, at: typeof s.at === 'number' ? s.at : Date.now() }
886
- }
887
- store.setItem(MCP_TEST_CACHE_KEY, JSON.stringify(out))
888
- } catch (e) {
889
- // Quota exceeded / storage blocked — the cache is optional, never fatal.
890
- }
891
- }
892
-
893
- /** Drop one entry's cached probe (config saved or entry removed). */
894
- function clearMcpTestCacheEntry(id) {
895
- var store = mcpTestStorage()
896
- if (store === null) return
897
- try {
898
- var state = loadMcpTestCache()
899
- if (state[id] === undefined) return
900
- delete state[id]
901
- store.setItem(MCP_TEST_CACHE_KEY, JSON.stringify(state))
902
- } catch (e) { /* ignore */ }
903
- }
904
-
905
- /** Drop test states whose entry no longer exists in the host list. */
906
- function pruneMcpTestState(map, entries) {
907
- var valid = {}
908
- for (var i = 0; i < entries.length; i++) valid[entries[i].id] = true
909
- var next = {}
910
- var changed = false
911
- for (var id in map) {
912
- if (valid[id]) next[id] = map[id]
913
- else changed = true
914
- }
915
- if (changed) saveMcpTestCache(next)
916
- return next
917
- }
918
-
919
- /** Compact timestamp for a cached probe ("14:32" today, "6-1 14:32" older). */
920
- function formatTestTime(ms) {
921
- if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
922
- var d = new Date(ms)
923
- var pad = function (n) { return (n < 10 ? '0' : '') + String(n) }
924
- var hm = pad(d.getHours()) + ':' + pad(d.getMinutes())
925
- var now = new Date()
926
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) return hm
927
- return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm
928
- }
929
-
930
- /* Update-reminder persistence */
931
- /* */
932
- /* A "⬆ 有新版本" reminder is a durable fact, not a transient fetch result: */
933
- /* once a newer version is detected it is kept in localStorage until a */
934
- /* check confirms the installed version is current again (i.e. the update */
935
- /* actually completed). This is what keeps the badge visible after closing */
936
- /* and reopening the 管理中心, across dsh restarts, and even when a later */
937
- /* re-check hits a transient network failure — and what clears it only once */
938
- /* the plugin is upgraded (更新完删除提醒). */
939
- /* ------------------------------------------------------------------------ */
940
-
941
- var UPDATE_REMINDER_KEY = 'dsh-plugin-admin/update-reminders'
942
-
943
- /** Shape-merge a reminder entry (keeps the original truthy fields). */
944
- function mergeReminder(entry, partial) {
945
- var next = {}
946
- for (var k in entry) next[k] = entry[k]
947
- for (var pk in partial) next[pk] = partial[pk]
948
- return next
949
- }
950
-
951
- /**
952
- * Load persisted update reminders as state entries:
953
- * { name: { latest, at, updateAvailable, error } }. Storage-unavailable
954
- * environments (opaque-origin jsdom, privacy modes) just get an empty map.
955
- */
956
- function loadUpdateReminders() {
957
- var store = safeLocalStorage()
958
- if (store === null) return {}
959
- try {
960
- var parsed = JSON.parse(store.getItem(UPDATE_REMINDER_KEY))
961
- var out = {}
962
- if (parsed !== null && typeof parsed === 'object') {
963
- for (var name in parsed) {
964
- var r = parsed[name]
965
- if (r && typeof r.latest === 'string' && r.latest.length > 0) {
966
- out[name] = {
967
- latest: r.latest,
968
- at: typeof r.at === 'number' ? r.at : 0,
969
- updateAvailable: true,
970
- error: '',
971
- }
972
- }
973
- }
974
- }
975
- return out
976
- } catch (e) {
977
- return {}
978
- }
979
- }
980
-
981
- /** Mirror the current reminder set into localStorage (best-effort). */
982
- function saveUpdateReminders(map) {
983
- var store = safeLocalStorage()
984
- if (store === null) return
985
- try {
986
- var out = {}
987
- for (var name in map) {
988
- var r = map[name]
989
- if (r && r.updateAvailable === true && typeof r.latest === 'string' && r.latest.length > 0) {
990
- out[name] = { latest: r.latest, at: typeof r.at === 'number' ? r.at : Date.now() }
991
- }
992
- }
993
- store.setItem(UPDATE_REMINDER_KEY, JSON.stringify(out))
994
- } catch (e) {
995
- // Quota exceeded / storage blocked — the reminder is optional, never fatal.
996
- }
997
- }
998
-
999
- /**
1000
- * Merge one check response into the CURRENT reminder map. Pure on purpose:
1001
- * it runs inside the setPView updater, so it reads only its arguments and
1002
- * must never touch localStorage or other component state. Per-entry semantics
1003
- * (list = fresh host results, one row per live registry install):
1004
- * - updateAvailable -> (re)arm the reminder
1005
- * - confirmed current -> drop any entry (更新完删除提醒)
1006
- * - query error -> keep a prior reminder untouched (error attached);
1007
- * without one, record an ephemeral failure
1008
- * ({updateAvailable:false}) so cards/strip/note can
1009
- * surface it — saveUpdateReminders skips those
1010
- * - entries absent from `list` are no longer live registry installs ->
1011
- * stale reminders dropped
1012
- * @param curMap - the CURRENT updates state ({ name -> entry }).
1013
- * @param list - per-plugin check results.
1014
- * @returns the complete next map.
1015
- */
1016
- function mergeUpdateReminders(curMap, list) {
1017
- var liveNames = {}
1018
- for (var a = 0; a < list.length; a++) liveNames[list[a].name] = true
1019
- var map = {}
1020
- for (var k in curMap) {
1021
- if (curMap[k] && liveNames[k]) map[k] = curMap[k]
1022
- }
1023
- for (var i = 0; i < list.length; i++) {
1024
- var u = list[i]
1025
- if (u.updateAvailable === true) {
1026
- map[u.name] = { latest: u.latest, updateAvailable: true, error: '', at: Date.now() }
1027
- } else if (u.error) {
1028
- // A prior reminder survives untouched with the transient error
1029
- // alongside; WITHOUT one the failure itself lands in the map so an
1030
- // all-failed first check cannot claim 全部为最新版本.
1031
- map[u.name] = map[u.name]
1032
- ? mergeReminder(map[u.name], { error: u.error || '' })
1033
- : { latest: '', updateAvailable: false, error: u.error || '', at: Date.now() }
1034
- } else if (u.latest !== null && u.latest !== undefined) {
1035
- delete map[u.name]
1036
- }
1037
- }
1038
- return map
1039
- }
1040
-
1041
- /** Aggregate summary line for a finished check (pure). */
1042
- function updateCheckNote(map, checkedCount) {
1043
- var updateCount = 0
1044
- var checkErrorCount = 0
1045
- for (var n in map) {
1046
- if (map[n].updateAvailable === true) updateCount++
1047
- if (map[n].error) checkErrorCount++
1048
- }
1049
- if (updateCount > 0) return '发现 ' + updateCount + ' 个插件有新版本'
1050
- if (checkErrorCount > 0) {
1051
- return '已检查 ' + checkedCount + ' 个插件(' + checkErrorCount + ' 个查询失败)'
1052
- + (checkedCount > checkErrorCount ? ',其余均为最新版本' : '')
1053
- }
1054
- return checkedCount > 0 ? '已检查 ' + checkedCount + ' 个插件,均为最新版本' : ''
1055
- }
1056
-
1057
- function McpSection(props) {
1058
- var pairMcp = useState({
1059
- mcpEntries: [],
1060
- mcpBusy: false,
1061
- mcpError: '',
1062
- mcpEditorOpen: false,
1063
- mcpDraft: null,
1064
- // entryId -> { busy?, result?, error?, at? }; previous successful probes
1065
- // are restored from localStorage so statuses survive panel remounts.
1066
- mcpTestState: loadMcpTestCache(),
1067
- })
1068
- var mView = pairMcp[0]
1069
- var setMView = pairMcp[1]
1070
-
1071
- var alive = useRef(false)
1072
-
1073
- function patchMcp(partial) {
1074
- setMView(function (cur) {
1075
- var next = {}
1076
- for (var k in cur) next[k] = cur[k]
1077
- for (var pk in partial) next[pk] = partial[pk]
1078
- return next
1079
- })
1080
- }
1081
-
1082
- // Merge a partial into the CURRENT mcpDraft via a functional update, so
1083
- // rapid successive field edits (React batching) never lose earlier input.
1084
- function patchDraft(partial) {
1085
- setMView(function (cur) {
1086
- var next = {}
1087
- for (var k in cur) next[k] = cur[k]
1088
- next.mcpDraft = mergeDraft(cur.mcpDraft, partial)
1089
- return next
1090
- })
1091
- }
1092
-
1093
- function callRemote(method, args) {
1094
- return props.call(method, args)
1095
- }
1096
-
1097
- function reloadMcp() {
1098
- patchMcp({ mcpBusy: true, mcpError: '' })
1099
- callRemote('mcpAdmin/list', {}).then(function (result) {
1100
- if (!alive.current) return
1101
- if (result.ok) {
1102
- var entries = (result.value && result.value.entries) || []
1103
- setMView(function (cur) {
1104
- var next = {}
1105
- for (var k in cur) next[k] = cur[k]
1106
- next.mcpBusy = false
1107
- next.mcpEntries = entries
1108
- // Drop cached probes whose entry no longer exists on the host.
1109
- next.mcpTestState = pruneMcpTestState(cur.mcpTestState, entries)
1110
- return next
1111
- })
1112
- } else {
1113
- patchMcp({ mcpBusy: false, mcpError: '加载 MCP 配置失败:' + messageOf(result.error) })
1114
- }
1115
- }, function (failure) {
1116
- if (!alive.current) return
1117
- patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1118
- })
1119
- }
1120
-
1121
- function openMcpEditor(entry) {
1122
- if (entry !== null && entry !== undefined && (entry.config === null || entry.config === undefined)) {
1123
- patchMcp({ mcpError: '该 MCP 配置无法安全解析,已禁止在此覆盖;请在 cordis.patch.yml 中手动编辑。' })
1124
- return
1125
- }
1126
- var source = entry !== null && entry !== undefined ? entry.config : null
1127
- var draft = source ? {
1128
- id: entry.id,
1129
- isNew: false,
1130
- serverName: source.serverName || entry.id,
1131
- transport: source.transport,
1132
- command: source.command || '',
1133
- url: source.url || '',
1134
- args: (source.args || []).join(' '),
1135
- argsOriginal: source.args || [],
1136
- argsChanged: false,
1137
- env: Object.keys(source.env || {}).map(function (key) { return key + '=' + source.env[key] }).join('\n'),
1138
- envOriginal: source.env || {},
1139
- envChanged: false,
1140
- cwd: source.cwd || '',
1141
- headers: Object.keys(source.headers || {}).map(function (k) { return k + '=' + source.headers[k] }).join('\n'),
1142
- headersOriginal: source.headers || {},
1143
- headersChanged: false,
1144
- toolCallTimeoutMs: source.toolCallTimeoutMs,
1145
- failOnStartupError: source.failOnStartupError === true,
1146
- reconnectEnabled: source.reconnect ? source.reconnect.enabled === true : false,
1147
- reconnectInitialDelayMs: source.reconnect ? source.reconnect.initialDelayMs : 1000,
1148
- reconnectMaxDelayMs: source.reconnect ? source.reconnect.maxDelayMs : 30000,
1149
- reconnectMaxAttempts: source.reconnect ? source.reconnect.maxAttempts : 10,
1150
- } : {
1151
- // New server: pre-fill a collision-free id so the user does not have to
1152
- // invent one; they can still edit it or regenerate it.
1153
- id: generateMcpId(mView.mcpEntries),
1154
- isNew: true,
1155
- serverName: '',
1156
- transport: 'stdio',
1157
- command: '',
1158
- url: '',
1159
- args: '',
1160
- argsOriginal: [],
1161
- argsChanged: false,
1162
- env: '',
1163
- envOriginal: {},
1164
- envChanged: false,
1165
- cwd: '',
1166
- headers: '',
1167
- headersOriginal: {},
1168
- headersChanged: false,
1169
- toolCallTimeoutMs: undefined,
1170
- failOnStartupError: false,
1171
- reconnectEnabled: false,
1172
- reconnectInitialDelayMs: 1000,
1173
- reconnectMaxDelayMs: 30000,
1174
- reconnectMaxAttempts: 10,
1175
- }
1176
- patchMcp({ mcpEditorOpen: true, mcpDraft: draft, mcpError: '' })
1177
- }
1178
-
1179
- function closeMcpEditor() {
1180
- patchMcp({ mcpEditorOpen: false, mcpDraft: null, mcpError: '' })
1181
- }
1182
-
1183
- function saveMcpDraft() {
1184
- var draft = mView.mcpDraft
1185
- if (draft === null) return
1186
- // A NEW entry must not collide with an existing id: the host's upsert
1187
- // locates its target block by id and would replace that entry in place,
1188
- // silently destroying its config. The edit form keeps its own id (the
1189
- // field is disabled), so this guard only ever fires on the add form.
1190
- if (draft.isNew) {
1191
- for (var ci = 0; ci < mView.mcpEntries.length; ci++) {
1192
- if (mView.mcpEntries[ci].id === draft.id) {
1193
- patchMcp({ mcpError: 'ID \'' + draft.id + '\' 已被其他 MCP 条目占用,请换一个再保存。' })
1194
- return
1195
- }
1196
- }
1197
- }
1198
- var config
1199
- if (draft.transport === 'streamable-http') {
1200
- config = { transport: 'streamable-http', serverName: draft.serverName, url: draft.url }
1201
- var headersStr = Object.keys(draft.headersOriginal || {}).map(function (k) { return k + '=' + draft.headersOriginal[k] }).join('\n')
1202
- var headers = (draft.headersChanged && draft.headers !== headersStr) ? {} : draft.headersOriginal
1203
- if (draft.headersChanged && draft.headers !== headersStr && draft.headers !== '') {
1204
- // Newline-only split: header values legitimately contain ';'/','
1205
- // (Cookie, Accept), which must never become pair separators.
1206
- var pairs = draft.headers.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
1207
- for (var i = 0; i < pairs.length; i++) {
1208
- var eq = pairs[i].indexOf('=')
1209
- if (eq > 0) headers[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
1210
- }
1211
- }
1212
- if (Object.keys(headers).length > 0) config.headers = headers
1213
- } else {
1214
- config = { transport: 'stdio', serverName: draft.serverName, command: draft.command }
1215
- var argsStr = (draft.argsOriginal || []).join(' ')
1216
- var args = (draft.argsChanged && draft.args !== argsStr) ? draft.args.split(/\s+/).filter(Boolean) : draft.argsOriginal
1217
- if (args.length > 0) config.args = args
1218
- var envStr = Object.keys(draft.envOriginal || {}).map(function (k) { return k + '=' + draft.envOriginal[k] }).join('\n')
1219
- var env = (draft.envChanged && draft.env !== envStr) ? {} : draft.envOriginal
1220
- if (draft.envChanged && draft.env !== envStr && draft.env !== '') {
1221
- // Newline-only split: values like PATH=C:\a;C:\b would lose their
1222
- // tail (and Windows paths carry ';' everywhere).
1223
- var pairs = draft.env.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
1224
- for (var i = 0; i < pairs.length; i++) {
1225
- var eq = pairs[i].indexOf('=')
1226
- if (eq > 0) env[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
1227
- }
1228
- }
1229
- if (Object.keys(env).length > 0) config.env = env
1230
- if (draft.cwd !== '') config.cwd = draft.cwd
1231
- }
1232
- if (draft.toolCallTimeoutMs !== undefined) config.toolCallTimeoutMs = draft.toolCallTimeoutMs
1233
- if (draft.failOnStartupError) config.failOnStartupError = true
1234
- if (draft.reconnectEnabled) {
1235
- config.reconnect = {
1236
- enabled: true,
1237
- initialDelayMs: Number(draft.reconnectInitialDelayMs),
1238
- maxDelayMs: Number(draft.reconnectMaxDelayMs),
1239
- maxAttempts: Number(draft.reconnectMaxAttempts),
1240
- }
1241
- }
1242
- patchMcp({ mcpBusy: true, mcpError: '' })
1243
- callRemote('mcpAdmin/upsert', { entry: { id: draft.id, config: config } }).then(function (result) {
1244
- if (!alive.current) return
1245
- if (result.ok) {
1246
- // The config changed, so any cached probe no longer describes this
1247
- // server drop it from storage and from the panel.
1248
- clearMcpTestCacheEntry(draft.id)
1249
- patchMcpTest(draft.id, { busy: false, result: null, error: null })
1250
- patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [], mcpEditorOpen: false, mcpDraft: null })
1251
- } else {
1252
- patchMcp({ mcpBusy: false, mcpError: '保存 MCP 配置失败:' + messageOf(result.error) })
1253
- }
1254
- }, function (failure) {
1255
- if (!alive.current) return
1256
- patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1257
- })
1258
- }
1259
-
1260
- function removeMcpEntry(id) {
1261
- patchMcp({ mcpBusy: true, mcpError: '' })
1262
- callRemote('mcpAdmin/remove', { id: id }).then(function (result) {
1263
- if (!alive.current) return
1264
- if (result.ok) {
1265
- // The entry is gone its cached probe must not linger in storage.
1266
- clearMcpTestCacheEntry(id)
1267
- patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [] })
1268
- } else {
1269
- patchMcp({ mcpBusy: false, mcpError: '移除 MCP 配置失败:' + messageOf(result.error) })
1270
- }
1271
- }, function (failure) {
1272
- if (!alive.current) return
1273
- patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1274
- })
1275
- }
1276
-
1277
- /**
1278
- * Set one entry's connectivity-test status against the CURRENT state.
1279
- * Settled successful probes are mirrored into localStorage so the last
1280
- * known status survives tab switches and reopening the settings dialog.
1281
- */
1282
- function patchMcpTest(id, partial) {
1283
- setMView(function (cur) {
1284
- var nextTestState = mergeTestState(cur.mcpTestState, id, partial)
1285
- saveMcpTestCache(nextTestState)
1286
- var next = {}
1287
- for (var k in cur) next[k] = cur[k]
1288
- next.mcpTestState = nextTestState
1289
- return next
1290
- })
1291
- }
1292
-
1293
- /** Run a host-side connectivity probe for one entry and stash the result. */
1294
- function testMcpEntry(id) {
1295
- patchMcpTest(id, { busy: true, result: null, error: null })
1296
- callRemote('mcpAdmin/test', { id: id }).then(function (result) {
1297
- if (!alive.current) return
1298
- if (result.ok && result.value !== null && typeof result.value === 'object') {
1299
- // `at` timestamps the probe; it is what the cached label renders.
1300
- patchMcpTest(id, { busy: false, result: result.value, at: Date.now() })
1301
- } else {
1302
- patchMcpTest(id, { busy: false, result: null, error: messageOf(result.error) })
1303
- }
1304
- }, function (failure) {
1305
- if (!alive.current) return
1306
- patchMcpTest(id, { busy: false, result: null, error: messageOf(failure) })
1307
- })
1308
- }
1309
-
1310
- useEffect(function () {
1311
- alive.current = true
1312
- reloadMcp()
1313
- return function () { alive.current = false }
1314
- }, [])
1315
-
1316
- return createElement('div', { 'data-dsh-admin-section': '' },
1317
- renderMcpSection(mView, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry))
1318
- }
1319
-
1320
- /** Unified settings page; only the selected panel mounts and loads. */
1321
- function AdminCenterSection(props) {
1322
- var pairTab = useState('plugins')
1323
- var activeTab = pairTab[0]
1324
- var setActiveTab = pairTab[1]
1325
- var tabs = [
1326
- { id: 'plugins', label: '插件管理', component: PluginsSection },
1327
- { id: 'sessions', label: '会话管理', component: SessionsSection },
1328
- { id: 'mcp', label: 'MCP 配置', component: McpSection },
1329
- ]
1330
- var selected = tabs.find(function (tab) { return tab.id === activeTab }) || tabs[0]
1331
-
1332
- return createElement('div', { 'data-dsh-admin-section': '' },
1333
- createElement('div', { className: 'tabs', role: 'tablist', 'aria-label': '管理中心功能' },
1334
- tabs.map(function (tab) {
1335
- return createElement('button', {
1336
- type: 'button',
1337
- key: tab.id,
1338
- className: 'tab' + (tab.id === selected.id ? ' active' : ''),
1339
- role: 'tab',
1340
- 'aria-selected': tab.id === selected.id,
1341
- onClick: function () { setActiveTab(tab.id) },
1342
- }, tab.label)
1343
- })
1344
- ),
1345
- createElement(selected.component, { key: selected.id, call: props.call, refreshSessions: props.refreshSessions })
1346
- )
1347
- }
1348
-
1349
- /* ========================================================================== */
1350
- /* Render Plugins View */
1351
- /* ========================================================================== */
1352
-
1353
- function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade) {
1354
- var elements = []
1355
-
1356
- // Toolbar: search (primary, wide) + install input + actions in one row.
1357
- elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
1358
- createElement('div', { className: 'search-wrap', key: 'search-wrap' },
1359
- createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
1360
- createElement('input', {
1361
- className: 'input',
1362
- key: 'search-input',
1363
- placeholder: '搜索插件(名称/版本/路径)...',
1364
- value: view.needle,
1365
- onChange: function (e) { patch({ needle: e.target.value }) },
1366
- }),
1367
- view.needle !== '' ? createElement('button', {
1368
- type: 'button',
1369
- key: 'btn-clear-search',
1370
- className: 'btn sm',
1371
- title: '清空搜索',
1372
- onClick: function () { patch({ needle: '' }) },
1373
- }, '✕') : null
1374
- ),
1375
- createElement('div', { className: 'input-wrap install-wrap', key: 'install-wrap' },
1376
- createElement('input', {
1377
- className: 'input',
1378
- placeholder: '安装包名/路径...',
1379
- value: view.spec,
1380
- disabled: view.busy,
1381
- onChange: function (e) { patch({ spec: e.target.value }) },
1382
- onKeyDown: function (e) { if (e.key === 'Enter') install() },
1383
- })
1384
- ),
1385
- createElement('button', {
1386
- type: 'button',
1387
- key: 'btn-install',
1388
- className: 'btn primary',
1389
- disabled: view.busy || view.spec.trim() === '',
1390
- onClick: install,
1391
- },
1392
- view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1393
- '安装'
1394
- ),
1395
- createElement('button', {
1396
- type: 'button',
1397
- key: 'btn-check-updates',
1398
- className: 'btn',
1399
- disabled: view.busy || view.checkingUpdates,
1400
- title: '强制绕过 5 分钟缓存,重新查询 registry 并刷新缓存',
1401
- onClick: function () { checkUpdates(true) },
1402
- },
1403
- view.checkingUpdates ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1404
- view.checkingUpdates ? '检查中...' : '⬆️ 检查更新'
1405
- ),
1406
- ))
1407
-
1408
- // Filter Pills
1409
- var totalPlugins = view.plugins.length
1410
- var thirdPartyCount = 0
1411
- var builtinCount = 0
1412
- for (var i = 0; i < view.plugins.length; i++) {
1413
- if (view.plugins[i].removable) thirdPartyCount++
1414
- else builtinCount++
1415
- }
1416
-
1417
- elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
1418
- createElement('button', {
1419
- type: 'button',
1420
- key: 'filter-all',
1421
- className: 'pill' + (view.filter === 'all' ? ' active' : ''),
1422
- onClick: function () { patch({ filter: 'all' }) },
1423
- }, '全部 (' + String(totalPlugins) + ')'),
1424
- createElement('button', {
1425
- type: 'button',
1426
- key: 'filter-plugin',
1427
- className: 'pill' + (view.filter === 'plugin' ? ' active' : ''),
1428
- onClick: function () { patch({ filter: 'plugin' }) },
1429
- }, '扩展插件 (' + String(thirdPartyCount) + ')'),
1430
- createElement('button', {
1431
- type: 'button',
1432
- key: 'filter-builtin',
1433
- className: 'pill' + (view.filter === 'builtin' ? ' active' : ''),
1434
- onClick: function () { patch({ filter: 'builtin' }) },
1435
- }, '系统内置 (' + String(builtinCount) + ')')
1436
- ))
1437
-
1438
- // Busy banner / Error
1439
- if (view.busy) {
1440
- elements.push(createElement('div', { className: 'busy-banner', key: 'busy' },
1441
- createElement('span', { className: 'spinner', key: 'spin' }),
1442
- '正在执行 pnpm 操作(可能需要数秒至数分钟,请勿关闭窗口)...'
1443
- ))
1444
- }
1445
- if (view.error !== '') {
1446
- elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
1447
- }
1448
-
1449
- // Update-check status strip: gives the auto-check a visible outcome instead
1450
- // of hiding it in the footer. Checking → spinner; done with updates →
1451
- // orange; done without updates green; per-plugin query failures surface
1452
- // on the strip rather than vanishing. Purely informational — the single
1453
- // re-check entry point is the toolbar「⬆️ 检查更新」button (which forces a
1454
- // fresh registry query), so no redundant button lives in the strip.
1455
- if (view.checkingUpdates) {
1456
- elements.push(createElement('div', { className: 'update-strip checking', key: 'update-checking' },
1457
- createElement('span', { className: 'spinner', key: 'spin' }),
1458
- '正在检查插件版本更新…'
1459
- ))
1460
- } else if (view.updateChecked) {
1461
- var upCount = 0
1462
- var errCount = 0
1463
- for (var un in view.updates) {
1464
- if (view.updates[un].updateAvailable === true) upCount++
1465
- if (view.updates[un].error) errCount++
1466
- }
1467
- var stripClass = upCount > 0 ? 'update-strip has-updates'
1468
- : (errCount > 0 ? 'update-strip has-errors' : 'update-strip ok')
1469
- var stripText = upCount > 0
1470
- ? '⬆️ 发现 ' + upCount + ' 个插件有新版本,可点击卡片上的「更新」升级'
1471
- : (errCount > 0
1472
- ? '⚠️ 有 ' + errCount + ' 个插件查询版本失败(网络或 registry 不可达)'
1473
- : ' 已自动检查版本更新,全部为最新版本')
1474
- elements.push(createElement('div', { className: stripClass, key: 'update-result' },
1475
- createElement('span', { key: 't' }, stripText)
1476
- ))
1477
- }
1478
-
1479
- // Filter and render rows
1480
- var needle = view.needle.trim().toLowerCase()
1481
- var filtered = filterPlugins(view.plugins, view.filter, needle)
1482
- var rows = []
1483
- for (var j = 0; j < filtered.length; j++) {
1484
- var p = filtered[j]
1485
- rows.push(renderPluginCard(p, view, remove, patch, upgrade))
1486
- }
1487
-
1488
- if (rows.length === 0) {
1489
- rows.push(createElement('div', { className: 'empty', key: 'empty' },
1490
- createElement('div', null, needle !== '' ? '🔍 无匹配的插件(试试其他关键词)' : '📦 暂无匹配的插件层')
1491
- ))
1492
- }
1493
-
1494
- elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
1495
-
1496
- // Footer
1497
- var hintText = view.note !== '' ? view.note : '更改在重启 dsh 后生效(关闭 dsh 进程后重新运行即可)'
1498
- var hintTitle = view.output !== '' ? 'pnpm 输出:\n' + view.output : undefined
1499
- elements.push(createElement('div', { className: 'footer', key: 'footer' },
1500
- createElement('span', { className: 'path', key: 'profile-path', title: view.profileDir }, view.profileDir || 'Profile: 默认'),
1501
- createElement('span', { className: 'hint', key: 'hint', title: hintTitle }, hintText)
1502
- ))
1503
-
1504
- return createElement('div', { key: 'plugins-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
1505
- }
1506
-
1507
-
1508
- function renderMcpSection(view, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry) {
1509
- var children = []
1510
- var header = createElement('div', { className: 'group-header', key: 'mcp-header', style: { marginTop: 0 } },
1511
- createElement('span', { className: 'group-title', key: 't' }, '🔌 MCP 配置'),
1512
- createElement('span', { className: 'group-count', key: 'c' }, String(view.mcpEntries.length) + ' 个'),
1513
- createElement('button', {
1514
- type: 'button',
1515
- key: 'btn-refresh-mcp',
1516
- className: 'btn',
1517
- disabled: view.mcpBusy,
1518
- onClick: reloadMcp,
1519
- }, '🔄 刷新'),
1520
- createElement('button', {
1521
- type: 'button',
1522
- key: 'btn-add-mcp',
1523
- className: 'btn primary',
1524
- disabled: view.mcpBusy,
1525
- onClick: function () { openMcpEditor(null) },
1526
- }, '➕ 添加服务器'),
1527
- )
1528
- children.push(header)
1529
-
1530
- if (view.mcpError !== '') {
1531
- children.push(createElement('div', { className: 'error', key: 'mcp-error' }, view.mcpError))
1532
- }
1533
-
1534
- var rows = []
1535
- for (var i = 0; i < view.mcpEntries.length; i++) {
1536
- var entry = view.mcpEntries[i]
1537
- var tag = createElement('span', { className: 'tag plugin', key: 'mcp-tag-' + entry.id },
1538
- 'MCP ' + String(entry.serverName || entry.id))
1539
- var testState = (view.mcpTestState && view.mcpTestState[entry.id]) || null
1540
- var testIndicator = null
1541
- if (testState !== null && testState.busy) {
1542
- testIndicator = createElement('span', { className: 'mcp-test mcp-test-busy', key: 'test-busy' },
1543
- createElement('span', { className: 'spinner', key: 'sp' }),
1544
- ' 检测中...')
1545
- } else if (testState !== null && testState.result !== null && testState.result !== undefined) {
1546
- var r = testState.result
1547
- var ok = r.ok === true
1548
- var label = ok ? '✅ 连通'
1549
- : ' 不通'
1550
- var detail = ok
1551
- ? (r.serverInfo ? (r.serverInfo.name || '') + (r.serverInfo.version ? ' v' + r.serverInfo.version : '') : '') +
1552
- (typeof r.toolCount === 'number' ? ' · ' + r.toolCount + ' 个工具' : '') +
1553
- (r.pingOk === false ? ' · 初始化成功但 ping 失败' : '')
1554
- : (r.error || '连接失败') + (r.ms !== undefined ? ' (' + r.ms + 'ms)' : '')
1555
- var detailChildren = [
1556
- createElement('span', { key: 'lbl' }, label + (detail !== '' ? ' ' + detail : '')),
1557
- ]
1558
- if (ok && Array.isArray(r.tools) && r.tools.length > 0) {
1559
- detailChildren.push(createElement('span', { key: 'tools-hint', style: { opacity: 0.75 } },
1560
- '工具:' + r.tools.join('')))
1561
- }
1562
- if (r.warning) {
1563
- detailChildren.push(createElement('span', { key: 'warn', className: 'mcp-test-warn', title: r.warning },
1564
- '⚠️ ' + r.warning))
1565
- }
1566
- // Cached probe: label when it was taken so a restored status is never
1567
- // mistaken for a just-run check. Clicking 🔌 测试 refreshes it.
1568
- if (typeof testState.at === 'number') {
1569
- detailChildren.push(createElement('span', {
1570
- key: 'cached-at',
1571
- className: 'mcp-test-cached',
1572
- title: '上次检测结果(本地缓存)。点击「🔌 测试」可重新检测。',
1573
- }, 缓存于 ' + formatTestTime(testState.at)))
1574
- }
1575
- testIndicator = createElement('div', {
1576
- className: 'mcp-test mcp-test-list ' + (ok ? 'mcp-test-ok' : 'mcp-test-fail'),
1577
- key: 'test-result',
1578
- title: detail,
1579
- }, detailChildren)
1580
- } else if (testState !== null && testState.error !== null && testState.error !== undefined) {
1581
- testIndicator = createElement('span', {
1582
- className: 'mcp-test mcp-test-fail',
1583
- key: 'test-error',
1584
- title: testState.error,
1585
- }, '❌ ' + testState.error)
1586
- }
1587
- var rowChildren = [
1588
- createElement('div', { className: 'card-header', key: 'h' },
1589
- createElement('span', { className: 'card-title-text', key: 'name' }, entry.id),
1590
- tag,
1591
- createElement('div', { className: 'card-actions', key: 'a' },
1592
- createElement('button', {
1593
- type: 'button',
1594
- className: 'btn sm',
1595
- disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1596
- title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : (testState !== null && testState.busy ? '检测中...' : (testState !== null && typeof testState.at === 'number' ? '重新检测该服务器的连通性(当前显示的是缓存结果)' : '测试该服务器的连通性')),
1597
- onClick: function (id) { return function () { testMcpEntry(id) } }(entry.id),
1598
- }, testState !== null && testState.busy ? '检测中' : '🔌 测试'),
1599
- createElement('button', {
1600
- type: 'button',
1601
- className: 'btn sm',
1602
- disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1603
- title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : undefined,
1604
- onClick: function (value) { return function () { openMcpEditor(value) } }(entry),
1605
- }, '编辑'),
1606
- createElement('button', {
1607
- type: 'button',
1608
- className: 'btn danger sm',
1609
- disabled: view.mcpBusy,
1610
- onClick: function (id) { return function () { removeMcpEntry(id) } }(entry.id),
1611
- }, '移除'),
1612
- ),
1613
- ),
1614
- ]
1615
- if (testIndicator !== null) {
1616
- rowChildren.push(createElement('div', { className: 'mcp-test-row', key: 'test-row' }, testIndicator))
1617
- }
1618
- rows.push(createElement('div', { className: 'card', key: 'mcp-' + entry.id }, rowChildren))
1619
- }
1620
- if (rows.length === 0) {
1621
- rows.push(createElement('div', { className: 'empty', key: 'mcp-empty' },
1622
- createElement('div', null, '🔌 暂无 MCP 服务器配置')
1623
- ))
1624
- }
1625
- children.push(createElement('div', { className: 'list', key: 'mcp-list' }, rows))
1626
-
1627
- // Editor form. patchDraft merges against the CURRENT draft in state so
1628
- // typing across fields never clobbers earlier edits (stale-closure guard).
1629
- if (view.mcpEditorOpen && view.mcpDraft !== null) {
1630
- var d = view.mcpDraft
1631
- var fieldStyle = { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px' }
1632
- var labelStyle = { fontSize: '11px', color: 'var(--dsw-alias-label-secondary, #666)' }
1633
- var editor = createElement('div', { className: 'card', key: 'mcp-editor' },
1634
- createElement('div', { className: 'card-header', key: 'h' },
1635
- createElement('span', { className: 'card-title-text', key: 't' }, d.isNew ? '添加 MCP 服务器' : '编辑 ' + d.id),
1636
- ),
1637
- createElement('div', { style: fieldStyle, key: 'f-id' },
1638
- createElement('label', { style: labelStyle }, 'ID(唯一标识,[A-Za-z0-9_-])'),
1639
- createElement('div', { style: { display: 'flex', gap: '6px' } },
1640
- createElement('input', {
1641
- className: 'input',
1642
- value: d.id,
1643
- disabled: !d.isNew,
1644
- onChange: function (e) { patchDraft({ id: e.target.value }) },
1645
- }),
1646
- d.isNew ? createElement('button', {
1647
- type: 'button',
1648
- className: 'btn sm',
1649
- title: '重新生成一个随机 ID',
1650
- onClick: function () { patchDraft({ id: generateMcpId(view.mcpEntries) }) },
1651
- }, '🎲') : null,
1652
- ),
1653
- ),
1654
- createElement('div', { style: fieldStyle, key: 'f-server' },
1655
- createElement('label', { style: labelStyle }, 'serverName(模型命名空间)'),
1656
- createElement('input', {
1657
- className: 'input',
1658
- value: d.serverName,
1659
- onChange: function (e) { patchDraft({ serverName: e.target.value }) },
1660
- }),
1661
- ),
1662
- createElement('div', { style: fieldStyle, key: 'f-transport' },
1663
- createElement('label', { style: labelStyle }, '传输方式'),
1664
- createElement('select', {
1665
- className: 'input',
1666
- value: d.transport,
1667
- onChange: function (e) { patchDraft({ transport: e.target.value }) },
1668
- },
1669
- createElement('option', { value: 'stdio' }, 'stdio(子进程)'),
1670
- createElement('option', { value: 'streamable-http' }, 'streamable-http(HTTP)'),
1671
- ),
1672
- ),
1673
- d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-cmd' },
1674
- createElement('label', { style: labelStyle }, 'command(启动命令)'),
1675
- createElement('input', {
1676
- className: 'input',
1677
- value: d.command,
1678
- placeholder: 'npx -y @modelcontextprotocol/server-github',
1679
- onChange: function (e) { patchDraft({ command: e.target.value }) },
1680
- }),
1681
- ) : createElement('div', { style: fieldStyle, key: 'f-url' },
1682
- createElement('label', { style: labelStyle }, 'url(MCP 端点)'),
1683
- createElement('input', {
1684
- className: 'input',
1685
- value: d.url,
1686
- placeholder: 'http://localhost:3000/mcp',
1687
- onChange: function (e) { patchDraft({ url: e.target.value }) },
1688
- }),
1689
- ),
1690
- d.transport === 'streamable-http' ? createElement('div', { style: fieldStyle, key: 'f-headers' },
1691
- createElement('label', { style: labelStyle }, 'headers(每行 KEY=VALUE,可选)'),
1692
- createElement('textarea', {
1693
- className: 'input',
1694
- style: { minHeight: '48px' },
1695
- value: d.headers,
1696
- onChange: function (e) { patchDraft({ headers: e.target.value, headersChanged: true }) },
1697
- }),
1698
- ) : null,
1699
- d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-args' },
1700
- createElement('label', { style: labelStyle }, 'args(空格分隔,可选)'),
1701
- createElement('input', {
1702
- className: 'input',
1703
- value: d.args,
1704
- onChange: function (e) { patchDraft({ args: e.target.value, argsChanged: true }) },
1705
- }),
1706
- ) : null,
1707
- d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-env' },
1708
- createElement('label', { style: labelStyle }, 'env(每行 KEY=VALUE,可选)'),
1709
- createElement('textarea', {
1710
- className: 'input',
1711
- style: { minHeight: '48px' },
1712
- value: d.env,
1713
- onChange: function (e) { patchDraft({ env: e.target.value, envChanged: true }) },
1714
- }),
1715
- ) : null,
1716
- createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 0', marginBottom: '4px', borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.3))', paddingTop: '8px' }, key: 'f-reconnect-header' },
1717
- createElement('input', {
1718
- type: 'checkbox',
1719
- id: 'reconnect-toggle',
1720
- checked: d.reconnectEnabled,
1721
- onChange: function (e) { patchDraft({ reconnectEnabled: e.target.checked }) },
1722
- style: { margin: '0' },
1723
- }),
1724
- createElement('label', { htmlFor: 'reconnect-toggle', style: { fontSize: '12px', fontWeight: 500, cursor: 'pointer', color: 'var(--dsw-alias-label-primary, #333)' } }, '启用自动重连'),
1725
- ),
1726
- d.reconnectEnabled ? createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '8px', paddingLeft: '4px' }, key: 'f-reconnect-fields' },
1727
- createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-id' },
1728
- createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'initialDelayMs'),
1729
- createElement('input', {
1730
- className: 'input',
1731
- type: 'number',
1732
- min: 0,
1733
- value: d.reconnectInitialDelayMs,
1734
- onChange: function (e) { patchDraft({ reconnectInitialDelayMs: Number(e.target.value) }) },
1735
- }),
1736
- ),
1737
- createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-md' },
1738
- createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxDelayMs'),
1739
- createElement('input', {
1740
- className: 'input',
1741
- type: 'number',
1742
- min: 0,
1743
- value: d.reconnectMaxDelayMs,
1744
- onChange: function (e) { patchDraft({ reconnectMaxDelayMs: Number(e.target.value) }) },
1745
- }),
1746
- ),
1747
- createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-ma' },
1748
- createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxAttempts'),
1749
- createElement('input', {
1750
- className: 'input',
1751
- type: 'number',
1752
- min: 0,
1753
- value: d.reconnectMaxAttempts,
1754
- onChange: function (e) { patchDraft({ reconnectMaxAttempts: Number(e.target.value) }) },
1755
- }),
1756
- ),
1757
- ) : null,
1758
- createElement('div', { className: 'card-actions', key: 'f-actions', style: { justifyContent: 'flex-end', gap: '6px' } },
1759
- createElement('button', {
1760
- type: 'button',
1761
- className: 'btn',
1762
- disabled: view.mcpBusy,
1763
- onClick: closeMcpEditor,
1764
- }, '取消'),
1765
- createElement('button', {
1766
- type: 'button',
1767
- className: 'btn primary',
1768
- disabled: view.mcpBusy || d.id.trim() === '' || d.serverName.trim() === '' ||
1769
- (d.transport === 'stdio' ? d.command.trim() === '' : d.url.trim() === ''),
1770
- onClick: saveMcpDraft,
1771
- }, view.mcpBusy ? '保存中...' : '保存'),
1772
- ),
1773
- )
1774
- children.push(editor)
1775
- }
1776
-
1777
- return createElement('div', { key: 'mcp-section', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, children)
1778
- }
1779
-
1780
- // Characters allowed in an MCP entry id: [A-Za-z0-9_-]. The random part uses
1781
- // alphanumerics only so the generated id reads cleanly and always matches the
1782
- // host-side validation regex.
1783
- var MCP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
1784
-
1785
- /**
1786
- * Generate a fresh MCP entry id that is not already used by any listed entry
1787
- * (an id collision would silently overwrite the existing entry on upsert).
1788
- */
1789
- function generateMcpId(entries) {
1790
- var taken = {}
1791
- for (var i = 0; i < entries.length; i++) taken[entries[i].id] = true
1792
- var candidate
1793
- do {
1794
- candidate = 'mcp-'
1795
- for (var j = 0; j < 8; j++) {
1796
- candidate += MCP_ID_ALPHABET.charAt(Math.floor(Math.random() * MCP_ID_ALPHABET.length))
1797
- }
1798
- } while (taken[candidate])
1799
- return candidate
1800
- }
1801
-
1802
- function mergeDraft(draft, partial) {
1803
- var next = {}
1804
- for (var k in draft) next[k] = draft[k]
1805
- for (var pk in partial) next[pk] = partial[pk]
1806
- return next
1807
- }
1808
-
1809
- /** Set one entry's test status inside the shared mcpTestState map. */
1810
- function mergeTestState(map, id, partial) {
1811
- var next = {}
1812
- for (var k in map) next[k] = map[k]
1813
- next[id] = {}
1814
- var cur = map[id] || {}
1815
- for (var ck in cur) next[id][ck] = cur[ck]
1816
- for (var pk in partial) next[id][pk] = partial[pk]
1817
- return next
1818
- }
1819
- function renderPluginCard(plugin, view, remove, patch, upgrade) {
1820
- var isConfirming = view.confirming === plugin.name
1821
-
1822
- var titleChildren = [
1823
- createElement('span', { className: 'card-title-text', key: 'name' }, plugin.name),
1824
- ]
1825
- if (plugin.version) {
1826
- titleChildren.push(createElement('span', { className: 'tag version', key: 'version' }, 'v' + plugin.version))
1827
- }
1828
- var isLocal = Boolean(plugin.localPath)
1829
- titleChildren.push(createElement('span', {
1830
- className: plugin.removable ? (isLocal ? 'tag local' : 'tag plugin') : 'tag',
1831
- key: 'type-tag',
1832
- },
1833
- plugin.removable ? (isLocal ? '本地安装' : '包安装') : '内置'
1834
- ))
1835
-
1836
- // Remote update badge: only for registry-installed bundles (removable and
1837
- // not a local path) after a check ran. `updates` is keyed by plugin name.
1838
- var updateInfo = view.updates && view.updates[plugin.name]
1839
- if (updateInfo && updateInfo.updateAvailable && updateInfo.latest) {
1840
- titleChildren.push(createElement('span', {
1841
- className: 'tag update',
1842
- key: 'update-badge',
1843
- title: '远程 registry 有新版本:v' + updateInfo.latest + '(当前 v' + plugin.version + ')'
1844
- + (typeof updateInfo.at === 'number' && updateInfo.at > 0 ? ',检测于 ' + formatTestTime(updateInfo.at) + ',更新后提醒自动消除' : ''),
1845
- }, '⬆ 有新版本 v' + updateInfo.latest))
1846
- } else if (updateInfo && updateInfo.error) {
1847
- titleChildren.push(createElement('span', {
1848
- className: 'tag update-error',
1849
- key: 'update-error',
1850
- title: updateInfo.error,
1851
- }, '⚠ 更新检查失败'))
1852
- }
1853
-
1854
- var headerChildren = [
1855
- createElement('div', { className: 'card-title', key: 'title' }, titleChildren),
1856
- ]
1857
-
1858
- if (plugin.removable && !isConfirming) {
1859
- var actionChildren = []
1860
- // 更新 button — only when an update is available and the plugin is a
1861
- // registry install (not local path).
1862
- if (updateInfo && updateInfo.updateAvailable && updateInfo.latest && !isLocal) {
1863
- actionChildren.push(createElement('button', {
1864
- type: 'button',
1865
- key: 'btn-upgrade',
1866
- className: 'btn sm',
1867
- disabled: view.busy,
1868
- title: '升级到 v' + updateInfo.latest + '(npm install ' + plugin.name + '@latest)',
1869
- onClick: function () { upgrade(plugin.name) },
1870
- }, '⬆ 更新'))
1871
- }
1872
- actionChildren.push(createElement('button', {
1873
- type: 'button',
1874
- key: 'btn-remove',
1875
- className: 'btn danger sm',
1876
- disabled: view.busy,
1877
- onClick: function () { patch({ confirming: plugin.name }) },
1878
- }, '卸载'))
1879
- headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' }, actionChildren))
1880
- }
1881
-
1882
- var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
1883
- var cardChildren = [header]
1884
-
1885
- if (plugin.localPath) {
1886
- cardChildren.push(createElement('div', { className: 'plugin-path', key: 'path', title: plugin.localPath },
1887
- '📂 ' + plugin.localPath
1888
- ))
1889
- }
1890
-
1891
- if (isConfirming) {
1892
- cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
1893
- createElement('span', { className: 'confirm-text', key: 'text' }, '⚠️ 确定要卸载该插件吗?'),
1894
- createElement('div', { className: 'confirm-actions', key: 'actions' },
1895
- createElement('button', {
1896
- type: 'button',
1897
- key: 'btn-confirm',
1898
- className: 'btn danger-solid sm',
1899
- disabled: view.busy,
1900
- onClick: function () { remove(plugin.name) },
1901
- }, '确认卸载'),
1902
- createElement('button', {
1903
- type: 'button',
1904
- key: 'btn-cancel',
1905
- className: 'btn sm',
1906
- disabled: view.busy,
1907
- onClick: function () { patch({ confirming: null }) },
1908
- }, '取消')
1909
- )
1910
- ))
1911
- }
1912
-
1913
- return createElement('div', { key: plugin.name, className: 'card' }, cardChildren)
1914
- }
1915
-
1916
- /* ========================================================================== */
1917
- /* Render Sessions View */
1918
- /* ========================================================================== */
1919
-
1920
- /**
1921
- * Live-session display hint. "Online" means the session is still mounted in
1922
- * the dsh host's in-memory SessionStore (created or opened at least once in
1923
- * this process) — NOT that a turn is running. Online sessions can now be
1924
- * closed directly: the admin panel's "关停并删除" disposes the captured agent
1925
- * handle (stopping any running turn), removes the session from the store,
1926
- * and then deletes the log — no restart needed.
1927
- */
1928
- var LIVE_HINT = '会话在线:仍挂载于 dsh host 内存(本进程内创建或打开过的会话保持在线,不代表正在运行);可直接"关停并删除"(会中断该会话正在进行的对话)'
1929
-
1930
- /**
1931
- * Canonical session status. Live takes precedence over archived (a session
1932
- * that is both live and in the archived set is shown as 会话在线); otherwise
1933
- * archived wins over ended. Used by both the filter pills and the pill
1934
- * counts so they can never disagree about membership.
1935
- */
1936
- function sessionStatus(s) {
1937
- if (s.live) return 'live'
1938
- if (s.archived) return 'archived'
1939
- return 'ended'
1940
- }
1941
-
1942
- function filterSessions(sessions, filter, needle) {
1943
- var result = []
1944
- for (var i = 0; i < sessions.length; i++) {
1945
- var s = sessions[i]
1946
- if (filter !== 'all' && sessionStatus(s) !== filter) continue
1947
- if (needle !== '') {
1948
- var hay = ((s.title || '') + ' ' + (s.summary || '') + ' ' + (s.cwd || '') + ' '
1949
- + (s.workspaceTitle || '') + ' ' + s.id).toLowerCase()
1950
- if (hay.indexOf(needle) === -1) continue
1951
- }
1952
- result.push(s)
1953
- }
1954
- return result
1955
- }
1956
-
1957
- /**
1958
- * Filter plugin layers by the type pill and a fuzzy name/version/path query.
1959
- * The needle matches case-insensitively against the package name, the
1960
- * installed version, and the local source path, so "tool" finds
1961
- * dsh-custom-tool and "0.2" finds version rows.
1962
- */
1963
- function filterPlugins(plugins, filter, needle) {
1964
- var result = []
1965
- for (var i = 0; i < plugins.length; i++) {
1966
- var p = plugins[i]
1967
- if (filter === 'plugin' && !p.removable) continue
1968
- if (filter === 'builtin' && p.removable) continue
1969
- if (needle !== '') {
1970
- var hay = ((p.name || '') + ' ' + (p.version || '') + ' ' + (p.localPath || '')).toLowerCase()
1971
- if (hay.indexOf(needle) === -1) continue
1972
- }
1973
- result.push(p)
1974
- }
1975
- return result
1976
- }/**
1977
- * Group sessions by their accounting workspace in registry order; sessions
1978
- * without a workspace trail under an "未分组" bucket. Empty workspaces are
1979
- * omitted so the user only sees groups that currently hold a session.
1980
- */
1981
- function buildSessionGroups(filtered, workspaceOrder) {
1982
- var byWs = new Map()
1983
- for (var i = 0; i < workspaceOrder.length; i++) {
1984
- var ws = workspaceOrder[i]
1985
- byWs.set(ws.workspaceId, {
1986
- workspaceId: ws.workspaceId, title: ws.title, path: ws.path, sessions: [],
1987
- })
1988
- }
1989
- var ungrouped = []
1990
- for (var j = 0; j < filtered.length; j++) {
1991
- var s = filtered[j]
1992
- var g = s.workspaceId ? byWs.get(s.workspaceId) : undefined
1993
- if (g !== undefined) g.sessions.push(s)
1994
- else ungrouped.push(s)
1995
- }
1996
- var groups = []
1997
- for (var k = 0; k < workspaceOrder.length; k++) {
1998
- var bucket = byWs.get(workspaceOrder[k].workspaceId)
1999
- if (bucket !== undefined && bucket.sessions.length > 0) groups.push(bucket)
2000
- }
2001
- if (ungrouped.length > 0) {
2002
- groups.push({ workspaceId: null, title: '未分组', path: null, sessions: ungrouped })
2003
- }
2004
- return groups
2005
- }
2006
-
2007
- function renderSessionsView(view, patch, reload, act) {
2008
- var elements = []
2009
-
2010
- // Toolbar: full-width search with inline icon; the refresh action sits at
2011
- // the row's end so the search field gets the whole width.
2012
- elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
2013
- createElement('div', { className: 'search-wrap session-search', key: 'wrap' },
2014
- createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
2015
- createElement('input', {
2016
- className: 'input',
2017
- placeholder: '搜索标题、内容摘要、目录或 Session ID...',
2018
- value: view.needle,
2019
- onChange: function (e) { patch({ needle: e.target.value }) },
2020
- }),
2021
- view.needle !== '' ? createElement('button', {
2022
- type: 'button',
2023
- key: 'btn-clear-search',
2024
- className: 'btn sm',
2025
- title: '清空搜索',
2026
- onClick: function () { patch({ needle: '' }) },
2027
- }, '✕') : null
2028
- ),
2029
- createElement('button', {
2030
- type: 'button',
2031
- key: 'btn-refresh',
2032
- className: 'btn',
2033
- disabled: view.busy,
2034
- onClick: reload,
2035
- },
2036
- view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
2037
- '🔄 刷新'
2038
- )
2039
- ))
2040
-
2041
- // Filter Pills
2042
- var totalSessions = view.sessions.length
2043
- var liveCount = 0
2044
- var archivedCount = 0
2045
- var endedCount = 0
2046
- for (var i = 0; i < view.sessions.length; i++) {
2047
- var s = view.sessions[i]
2048
- var st = sessionStatus(s)
2049
- if (st === 'live') liveCount++
2050
- else if (st === 'archived') archivedCount++
2051
- else endedCount++
2052
- }
2053
-
2054
- elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
2055
- createElement('button', {
2056
- type: 'button',
2057
- key: 'filter-all',
2058
- className: 'pill' + (view.filter === 'all' ? ' active' : ''),
2059
- onClick: function () { patch({ filter: 'all' }) },
2060
- }, '全部 (' + String(totalSessions) + ')'),
2061
- createElement('button', {
2062
- type: 'button',
2063
- key: 'filter-live',
2064
- className: 'pill' + (view.filter === 'live' ? ' active' : ''),
2065
- title: LIVE_HINT,
2066
- onClick: function () { patch({ filter: 'live' }) },
2067
- }, '在线 (' + String(liveCount) + ')'),
2068
- createElement('button', {
2069
- type: 'button',
2070
- key: 'filter-archived',
2071
- className: 'pill' + (view.filter === 'archived' ? ' active' : ''),
2072
- onClick: function () { patch({ filter: 'archived' }) },
2073
- }, '已归档 (' + String(archivedCount) + ')'),
2074
- createElement('button', {
2075
- type: 'button',
2076
- key: 'filter-ended',
2077
- className: 'pill' + (view.filter === 'ended' ? ' active' : ''),
2078
- onClick: function () { patch({ filter: 'ended' }) },
2079
- }, '已结束 (' + String(endedCount) + ')')
2080
- ))
2081
-
2082
- if (view.error !== '') {
2083
- elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
2084
- }
2085
-
2086
- // Filter then group by workspace (registry order); sessions without an
2087
- // accounting workspace trail under "未分组", matching the sidebar model.
2088
- var needle = view.needle.trim().toLowerCase()
2089
- var filtered = filterSessions(view.sessions, view.filter, needle)
2090
- var groups = buildSessionGroups(filtered, view.workspaces || [])
2091
- var rows = []
2092
- for (var gi = 0; gi < groups.length; gi++) {
2093
- var g = groups[gi]
2094
- var groupKey = g.workspaceId === null ? 'ungrouped' : 'ws-' + g.workspaceId
2095
- rows.push(createElement('div', { className: 'group-header', key: 'header-' + groupKey },
2096
- createElement('span', { className: 'group-title', key: 'title' },
2097
- (g.workspaceId === null ? '📂 ' : '📁 ') + (g.title || '未命名')
2098
- ),
2099
- createElement('span', { className: 'group-count', key: 'count' }, String(g.sessions.length) + ''),
2100
- g.path !== null
2101
- ? createElement('span', { className: 'group-path', key: 'path', title: g.path }, g.path)
2102
- : null
2103
- ))
2104
- for (var si = 0; si < g.sessions.length; si++) {
2105
- rows.push(renderSessionCard(g.sessions[si], view, act, patch))
2106
- }
2107
- }
2108
-
2109
- if (rows.length === 0) {
2110
- rows.push(createElement('div', { className: 'empty', key: 'empty' },
2111
- createElement('div', null, needle !== '' ? '🔍 无匹配的会话内容' : '💬 没有已持久化的会话')
2112
- ))
2113
- }
2114
-
2115
- elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
2116
-
2117
- // Footer
2118
- var summaryText = view.busy ? '加载中...'
2119
- : '共 ' + String(totalSessions) + ' 个会话,当前展示 ' + String(filtered.length) + ' 个'
2120
-
2121
- elements.push(createElement('div', { className: 'footer', key: 'footer' },
2122
- createElement('span', { key: 'summary' }, summaryText),
2123
- createElement('span', { className: 'hint', key: 'hint' }, '会话修改即时同步到侧边栏')
2124
- ))
2125
-
2126
- return createElement('div', { key: 'sessions-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
2127
- }
2128
-
2129
- function renderSessionCard(session, view, act, patch) {
2130
- var isConfirming = view.confirming === session.id
2131
- var dotClass = 'dot' + (session.live ? ' live' : session.archived ? ' archived' : '')
2132
- var dotTitle = session.live ? LIVE_HINT : session.archived ? '已归档' : '已结束'
2133
-
2134
- var actions = []
2135
- if (!isConfirming) {
2136
- if (!session.archived && !session.live) {
2137
- actions.push(createElement('button', {
2138
- type: 'button',
2139
- className: 'btn sm',
2140
- key: 'btn-archive',
2141
- disabled: view.busy,
2142
- onClick: function () { act('archive', session.id) },
2143
- }, '归档'))
2144
- }
2145
- if (session.archived) {
2146
- actions.push(createElement('button', {
2147
- type: 'button',
2148
- className: 'btn sm',
2149
- key: 'btn-unarchive',
2150
- disabled: view.busy,
2151
- onClick: function () { act('unarchive', session.id) },
2152
- }, '取消归档'))
2153
- }
2154
- if (!session.live) {
2155
- actions.push(createElement('button', {
2156
- type: 'button',
2157
- className: 'btn danger sm',
2158
- key: 'btn-delete',
2159
- disabled: view.busy,
2160
- onClick: function () { patch({ confirming: session.id }) },
2161
- }, '删除'))
2162
- } else {
2163
- // Online sessions can now be closed through the captured AgentHandle
2164
- // (closeSession disposes the live agent/session first, so the log can
2165
- // be removed without resurrection). This stops a running conversation,
2166
- // so it is a destructive action with its own confirm flow.
2167
- actions.push(createElement('button', {
2168
- type: 'button',
2169
- className: 'btn danger sm',
2170
- key: 'btn-close',
2171
- disabled: view.busy,
2172
- title: '关停该在线会话(停止其 agent 运行)并永久删除日志记录',
2173
- onClick: function () { patch({ confirming: session.id }) },
2174
- }, '关停并删除'))
2175
- }
2176
- }
2177
-
2178
- var titleChildren = [
2179
- createElement('span', { className: dotClass, title: dotTitle, key: 'dot' }),
2180
- createElement('div', { className: 'card-title', key: 'title' },
2181
- createElement('span', { className: 'card-title-text', key: 'name', title: session.title || session.cwd },
2182
- session.title || (session.cwd ? baseName(session.cwd) : '未命名会话')
2183
- ),
2184
- session.live ? createElement('span', { className: 'tag live', key: 'tag-live', title: LIVE_HINT }, '会话在线')
2185
- : session.archived ? createElement('span', { className: 'tag archived', key: 'tag-archived' }, '已归档')
2186
- : null,
2187
- session.messageCount > 0 ? createElement('span', { className: 'tag turns', key: 'tag-turns' }, String(session.messageCount) + ' 条消息') : null
2188
- ),
2189
- ]
2190
- var headerChildren
2191
- if (actions.length > 0) {
2192
- headerChildren = titleChildren.concat([createElement('div', { className: 'card-actions', key: 'actions' }, actions)])
2193
- } else {
2194
- headerChildren = titleChildren
2195
- }
2196
-
2197
- var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
2198
- var cardChildren = [header]
2199
-
2200
- // Content summary block
2201
- if (session.summary) {
2202
- cardChildren.push(createElement('div', { className: 'card-summary', key: 'summary', title: session.summary },
2203
- createElement('span', { className: 'summary-icon', key: 'icon' }, '💬'),
2204
- createElement('span', { className: 'summary-text', key: 'text' }, session.summary)
2205
- ))
2206
- } else if (session.summaryError) {
2207
- cardChildren.push(createElement('div', { className: 'error', key: 'summary-error' },
2208
- '摘要读取失败:' + session.summaryError
2209
- ))
2210
- }
2211
-
2212
- // Sub metadata line
2213
- var subChildren = [
2214
- createElement('span', { className: 'card-sub-item', title: session.cwd || '无工作目录', key: 'path' },
2215
- '📁 ' + (session.cwd ? baseName(session.cwd) + ' (' + session.cwd + ')' : '(无工作目录)')
2216
- ),
2217
- createElement('span', { key: 'sep1' }, '·'),
2218
- createElement('span', { className: 'card-sub-item', key: 'time' }, '🕒 ' + formatDate(session.createdAt)),
2219
- ]
2220
- if (session.id) {
2221
- subChildren.push(createElement('span', { key: 'sep2' }, '·'))
2222
- subChildren.push(createElement('span', { className: 'card-sub-item session-id-badge', key: 'id', title: '会话 ID: ' + session.id },
2223
- '🆔 ' + session.id.slice(0, 8)
2224
- ))
2225
- }
2226
-
2227
- var sub = createElement('div', { className: 'card-sub', key: 'sub' }, subChildren)
2228
- cardChildren.push(sub)
2229
-
2230
- if (isConfirming) {
2231
- cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
2232
- createElement('span', { className: 'confirm-text', key: 'text' },
2233
- session.live
2234
- ? '⚠️ 将关停该在线会话(正在运行则会中断)并永久删除记录与日志,确定?'
2235
- : '⚠️ 确定永久删除该会话记录及日志文件?'),
2236
- createElement('div', { className: 'confirm-actions', key: 'actions' },
2237
- createElement('button', {
2238
- type: 'button',
2239
- key: 'btn-confirm',
2240
- className: 'btn danger-solid sm',
2241
- disabled: view.busy,
2242
- onClick: function () { act(session.live ? 'closeSession' : 'deleteSession', session.id) },
2243
- }, '确认删除'),
2244
- createElement('button', {
2245
- type: 'button',
2246
- key: 'btn-cancel',
2247
- className: 'btn sm',
2248
- disabled: view.busy,
2249
- onClick: function () { patch({ confirming: null }) },
2250
- }, '取消')
2251
- )
2252
- ))
2253
- }
2254
-
2255
- return createElement('div', { key: session.id, className: 'card' }, cardChildren)
2256
- }
2257
-
2258
- /* ========================================================================== */
2259
- /* Plugin Entrypoint */
2260
- /* ========================================================================== */
2261
-
2262
- function apply(ctx) {
2263
- injectStyles()
2264
-
2265
- var call = function (method, args) {
2266
- return ctx.connection.rpc.call('/api', method, { args: args })
2267
- }
2268
-
2269
- // Sidebar right-click menus: delete session (after archive) on session
2270
- // rows, reveal in explorer on workspace rows. Mounted once for the page
2271
- // lifetime; dispose when the plugin unmounts.
2272
- // The sessions service lets us nudge the sidebar list after a delete so
2273
- // the removed session disappears immediately instead of lingering in
2274
- // "未分组" until the next reload.
2275
- var refreshSessions = null
2276
- try {
2277
- var sessionsSvc = ctx.get && ctx.get('sessions')
2278
- if (sessionsSvc && typeof sessionsSvc.refresh === 'function') {
2279
- refreshSessions = function () { sessionsSvc.refresh().catch(function () {}) }
2280
- }
2281
- } catch (e) { refreshSessions = null }
2282
- var disposeSidebar = ctx.effect(function () { return setupMenuInjection(call, refreshSessions) })
2283
-
2284
- // One management-center entry keeps related local administration together;
2285
- // its tab container mounts only the selected panel.
2286
- ctx.slots.inject('settings.section', function () {
2287
- return ctx.slots.register({
2288
- name: 'settings.section',
2289
- id: 'plugin-admin',
2290
- order: 25,
2291
- label: '管理中心',
2292
- inject: function () { return { call: call, refreshSessions: refreshSessions } },
2293
- }, AdminCenterSection)
2294
- })
2295
- }
2296
-
2297
- module.exports = { apply: apply, inject: ['slots', 'connection'] }
2298
- return module.exports
2299
- } });
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, #222); max-height: calc(100vh - 140px); overflow: hidden; }',
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(200,200,210,0.2)); border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.3)); 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, #222); background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); }',
29
+ '[data-dsh-admin-section] .tab.active { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); color: var(--dsw-alias-label-primary, #333); 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, rgba(200,200,210,0.4)); color: var(--dsw-alias-label-secondary, #555); }',
31
+ '[data-dsh-admin-section] .tab.active .tab-count { background: var(--dsw-static-blue-500, #3b82f6); color: #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(200,200,210,0.4)); border-radius: 12px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); box-shadow: 0 1px 2px rgba(0,0,0,0.02); }',
33
+ '[data-dsh-admin-section] .toolbar .input { height: 32px; padding: 0 12px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
34
+ '[data-dsh-admin-section] .search-wrap { flex: 1.6; min-width: 0; position: relative; display: flex; align-items: center; height: 32px; }',
35
+ '[data-dsh-admin-section] .search-wrap .input { flex: 1; min-width: 0; padding-left: 30px; font-size: 13px; }',
36
+ '[data-dsh-admin-section] .session-search { flex: 1; height: 32px; }',
37
+ '[data-dsh-admin-section] .session-search .input { height: 32px; padding: 0 12px; font-size: 13px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
38
+ '[data-dsh-admin-section] .install-wrap { flex: 1; min-width: 0; display: flex; align-items: center; height: 32px; }',
39
+ '[data-dsh-admin-section] .install-wrap .input { flex: 1; min-width: 0; font-size: 13px; border-radius: 8px; border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); }',
40
+ '[data-dsh-admin-section] .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 12px; opacity: 0.7; pointer-events: none; z-index: 1; }',
41
+ '[data-dsh-admin-section] .input { width: 100%; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); background: var(--dsw-alias-bg-base, transparent); color: inherit; font: inherit; outline: none; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }',
42
+ '[data-dsh-admin-section] .input:hover { border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.6)); }',
43
+ '[data-dsh-admin-section] .input:focus { border-color: var(--dsw-static-blue-500, #3b82f6); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16); }',
44
+ '[data-dsh-admin-section] .filter-bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }',
45
+ '[data-dsh-admin-section] .pill { font-size: 11px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.45)); background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary, #555); transition: all 0.12s; font-family: inherit; }',
46
+ '[data-dsh-admin-section] .pill:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); }',
47
+ '[data-dsh-admin-section] .pill.active { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.4)); color: var(--dsw-static-blue-500, #3b82f6); font-weight: 600; border-color: currentColor; }',
48
+ '[data-dsh-admin-section] .btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 32px; padding: 0 14px; border-radius: 8px; font-size: 13px; 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, color 0.15s ease; border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.5)); background: var(--dsw-alias-interactive-bg-hover, rgba(240,240,245,0.6)); color: var(--dsw-alias-label-secondary, #555); white-space: nowrap; flex: none; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }',
49
+ '[data-dsh-admin-section] .btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(220,220,230,0.7)); border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.65)); color: var(--dsw-alias-label-primary, #222); box-shadow: 0 2px 4px rgba(0,0,0,0.06); }',
50
+ '[data-dsh-admin-section] .search-wrap .btn.sm { background: transparent; border: none; box-shadow: none; padding: 2px 6px; color: var(--dsw-alias-label-tertiary, #888); }',
51
+ '[data-dsh-admin-section] .search-wrap .btn.sm:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.4)); color: var(--dsw-alias-label-primary, #222); }',
52
+ '[data-dsh-admin-section] .btn:active:not(:disabled) { transform: translateY(1px); }',
53
+ '[data-dsh-admin-section] .btn:focus-visible, [data-dsh-admin-section] .pill:focus-visible { outline: 2px solid var(--dsw-static-blue-500, #3b82f6); outline-offset: 2px; }',
54
+ '[data-dsh-admin-section] .btn:disabled { opacity: 0.45; cursor: not-allowed; }',
55
+ '[data-dsh-admin-section] .btn.primary { background: var(--dsw-static-blue-500, #3b82f6); color: #fff; border-color: transparent; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }',
56
+ '[data-dsh-admin-section] .btn.primary:hover:not(:disabled) { background: var(--dsw-static-blue-400, #60a5fa); color: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.14); }',
57
+ '[data-dsh-admin-section] .btn.primary:active:not(:disabled) { background: var(--dsw-static-blue-600, #2563eb); color: #fff; }',
58
+ '[data-dsh-admin-section] .btn.danger { color: var(--dsw-alias-state-error-primary, #dc2626); border-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); }',
59
+ '[data-dsh-admin-section] .btn.danger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.1)); border-color: rgba(220,38,38,0.35); color: #b91c1c; }',
60
+ '[data-dsh-admin-section] .btn.danger-solid { background: var(--dsw-alias-state-error-primary, #dc2626); color: #fff; border-color: transparent; }',
61
+ '[data-dsh-admin-section] .btn.danger-solid:hover:not(:disabled) { background: #b91c1c; color: #fff; }',
62
+ '[data-dsh-admin-section] .btn.danger-solid:active:not(:disabled) { background: #991b1b; color: #fff; }',
63
+ '[data-dsh-admin-section] .btn.sm { height: 28px; padding: 0 10px; font-size: 12px; border-radius: 6px; }',
64
+ '[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; }',
65
+ '[data-dsh-admin-section] .list { display: flex; flex-direction: column; gap: 10px; flex: 1 1 auto; min-height: 0; max-height: 560px; overflow-y: auto; padding: 2px 4px 2px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)) transparent; }',
66
+ '[data-dsh-admin-section] .list::-webkit-scrollbar { width: 6px; }',
67
+ '[data-dsh-admin-section] .list::-webkit-scrollbar-thumb { border-radius: 99px; background: var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); }',
68
+ '[data-dsh-admin-section] .session-group { display: flex; flex-direction: column; gap: 6px; }',
69
+ '[data-dsh-admin-section] .group-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px 8px 12px; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.4)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); box-shadow: 0 1px 2px rgba(0,0,0,0.02); margin-bottom: 4px; }',
70
+ '[data-dsh-admin-section] .group-header:first-child { margin-top: 2px; }',
71
+ '[data-dsh-admin-section] .group-title { display: inline-flex; align-items: center; gap: 6px; }',
72
+ '[data-dsh-admin-section] .group-count { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.3)); color: var(--dsw-alias-label-secondary, #555); font-weight: 500; }',
73
+ '[data-dsh-admin-section] .group-header .btn.sm { margin-left: auto; }',
74
+ '[data-dsh-admin-section] .group-header .btn.sm + .btn.sm { margin-left: 0; }',
75
+ '[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; }',
76
+ '[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(200,200,210,0.4)); background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, transparent)); 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; }',
77
+ '[data-dsh-admin-section] .card:hover { transform: translateY(-1px); border-color: var(--dsw-alias-label-tertiary, rgba(180,180,195,0.6)); box-shadow: 0 8px 20px rgba(0,0,0,0.06); }',
78
+ '[data-dsh-admin-section] .card-header { display: flex; align-items: center; gap: 8px; min-width: 0; }',
79
+ '[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; }',
80
+ '[data-dsh-admin-section] .card-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; }',
81
+ '[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(200,200,210,0.25)); border-left: 2px solid var(--dsw-static-blue-500, #3b82f6); font-size: 12px; line-height: 1.45; color: var(--dsw-alias-label-secondary, #555); margin-top: 1px; margin-bottom: 1px; }',
82
+ '[data-dsh-admin-section] .summary-icon { font-size: 11px; line-height: 1.45; flex: none; opacity: 0.85; }',
83
+ '[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; }',
84
+ '[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; }',
85
+ '[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; }',
86
+ '[data-dsh-admin-section] .card-actions { display: flex; gap: 5px; align-items: center; flex: none; }',
87
+ '[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(200,200,210,0.25)); color: var(--dsw-alias-label-secondary, #555); }',
88
+ '[data-dsh-admin-section] .tag.plugin { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); }',
89
+ '[data-dsh-admin-section] .tag.local { background: rgba(59, 130, 246, 0.12); color: var(--dsw-static-blue-500, #3b82f6); }',
90
+ '[data-dsh-admin-section] .tag.update { background: rgba(245, 158, 11, 0.14); color: #d97706; font-weight: 600; border: 1px solid rgba(217, 119, 6, 0.25); }',
91
+ '[data-dsh-admin-section] .tag.update-error { background: rgba(239, 68, 68, 0.1); color: var(--dsw-alias-state-error-primary, #ef4444); }',
92
+ '[data-dsh-admin-section] .plugin-path { font-family: monospace; font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-secondary, #555); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 4px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.25)); }',
93
+ '[data-dsh-admin-section] .tag.version { font-family: monospace; }',
94
+ '[data-dsh-admin-section] .tag.live { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); font-weight: 600; }',
95
+ '[data-dsh-admin-section] .tag.archived { background: rgba(245, 158, 11, 0.12); color: var(--dsw-alias-state-warn-label, #f59e0b); }',
96
+ '[data-dsh-admin-section] .tag.turns { background: rgba(59, 130, 246, 0.12); color: var(--dsw-static-blue-500, #3b82f6); font-weight: 500; }',
97
+ '[data-dsh-admin-section] .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #999); }',
98
+ '[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; }',
99
+ '[data-dsh-admin-section] .dot.archived { background: var(--dsw-alias-state-warn-label, #f59e0b); }',
100
+ '[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); }',
101
+ '[data-dsh-admin-section] .confirm-text { font-size: 11px; color: var(--dsw-alias-state-error-primary, #ef4444); font-weight: 500; }',
102
+ '[data-dsh-admin-section] .confirm-actions { display: flex; gap: 5px; flex: none; }',
103
+ '[data-dsh-admin-section] .busy-banner { display: flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 7px; background: rgba(59, 130, 246, 0.08); color: var(--dsw-static-blue-500, #3b82f6); font-size: 11px; }',
104
+ '[data-dsh-admin-section] .update-strip { display: flex; align-items: center; gap: 8px; padding: 8px 11px; border-radius: 8px; font-size: 12px; font-weight: 500; line-height: 1.4; }',
105
+ '[data-dsh-admin-section] .update-strip .btn.sm { margin-left: auto; flex: none; }',
106
+ '[data-dsh-admin-section] .update-strip.checking { background: rgba(59, 130, 246, 0.08); color: var(--dsw-static-blue-500, #3b82f6); }',
107
+ '[data-dsh-admin-section] .update-strip.ok { background: rgba(16, 185, 129, 0.1); color: var(--dsw-alias-state-success-primary, #10b981); }',
108
+ '[data-dsh-admin-section] .update-strip.has-updates { background: rgba(245, 158, 11, 0.12); color: #d97706; border: 1px solid rgba(217, 119, 6, 0.25); }',
109
+ '[data-dsh-admin-section] .update-strip.has-errors { background: rgba(239, 68, 68, 0.08); color: var(--dsw-alias-state-error-primary, #ef4444); }',
110
+ '[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; }',
111
+ '[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; }',
112
+ '[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(200,200,210,0.3)); flex: none; }',
113
+ '[data-dsh-admin-section] .footer .path { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
114
+ '[data-dsh-admin-section] .footer .hint { font-style: normal; }',
115
+ '[data-dsh-admin-section] .session-id-badge { font-family: monospace; font-size: 10px; opacity: 0.75; }',
116
+ '[data-dsh-admin-section] .mcp-test-row { padding: 6px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(200,200,210,0.25)); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }',
117
+ '[data-dsh-admin-section] .mcp-test { display: inline-flex; align-items: center; gap: 5px; }',
118
+ '[data-dsh-admin-section] .mcp-test.mcp-test-list { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }',
119
+ '[data-dsh-admin-section] .mcp-test-busy { color: var(--dsw-alias-label-secondary, #666); }',
120
+ '[data-dsh-admin-section] .mcp-test-ok { color: #16a34a; }',
121
+ '[data-dsh-admin-section] .mcp-test-fail { color: var(--dsw-alias-state-error-primary, #ef4444); }',
122
+ '[data-dsh-admin-section] .mcp-test-warn { color: #d97706; opacity: 0.95; }',
123
+ '[data-dsh-admin-section] .mcp-test-cached { font-size: 10px; color: var(--dsw-alias-label-tertiary, #999); }',
124
+ '@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%; } }',
125
+ '@keyframes dsh-toast-in { 0% { opacity: 0; transform: translateY(-12px) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }',
126
+ '@keyframes dsh-toast-out { 0% { opacity: 1; transform: translateY(0) scale(1); } 100% { opacity: 0; transform: translateY(-8px) scale(0.96); } }',
127
+ '.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; }',
128
+ '.dsh-admin-toast.success { background: rgba(16, 185, 129, 0.95); color: #fff; }',
129
+ '.dsh-admin-toast.error { background: rgba(239, 68, 68, 0.95); color: #fff; }',
130
+ '.dsh-admin-toast.info { background: rgba(22, 119, 255, 0.95); color: #fff; }',
131
+ '.dsh-admin-toast.leaving { animation: dsh-toast-out 0.2s ease-in forwards; }',
132
+ ].join('\n')
133
+
134
+ function injectStyles() {
135
+ var tagId = 'dsh-plugin-admin/unified-section.css'
136
+ if (document.querySelector('style[data-plugin-css="' + tagId + '"]') === null) {
137
+ var tag = document.createElement('style')
138
+ tag.dataset.plugin = 'dsh-plugin-admin'
139
+ tag.dataset.pluginCss = tagId
140
+ tag.textContent = CSS_TEXT
141
+ document.head.appendChild(tag)
142
+ }
143
+ }
144
+
145
+ function baseName(path) {
146
+ if (path === null || path === '') return '(无工作目录)'
147
+ var parts = path.replace(/\\/g, '/').split('/')
148
+ var last = parts[parts.length - 1]
149
+ return last === '' ? parts[parts.length - 2] || path : last
150
+ }
151
+
152
+ function formatDate(ms) {
153
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
154
+ var d = new Date(ms)
155
+ var pad = function (n) { return (n < 10 ? '0' : '') + String(n) }
156
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
157
+ + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
158
+ }
159
+
160
+ function messageOf(error) {
161
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string') return error.message
162
+ if (typeof error === 'string') return error
163
+ return JSON.stringify(error)
164
+ }
165
+
166
+ // Single-toast singleton: at most ONE floating toast exists at any time. A
167
+ // new showToast REPLACES the in-flight toast in place (same node, refreshed
168
+ // type/text, restarted countdown) instead of stacking another fixed-position
169
+ // node on top — rapid consecutive failures would otherwise overlap and hide
170
+ // the earlier message behind the latest. A toast mid-fade (leaving) is
171
+ // revived by a replacement rather than left to vanish.
172
+ var activeToast = null
173
+
174
+ /**
175
+ * Show a floating toast notification at the top of the viewport.
176
+ * Auto-dismisses after `duration` ms. Supports 'success', 'error', 'info'.
177
+ * Single-slot: a new toast replaces the current one (if any).
178
+ */
179
+ function showToast(type, text, duration) {
180
+ if (typeof document === 'undefined') return
181
+ duration = duration || 3000
182
+
183
+ function quit(state) {
184
+ if (state.leaving) return
185
+ state.leaving = true
186
+ clearTimeout(state.timer)
187
+ state.el.classList.add('leaving')
188
+ state.removalTimer = setTimeout(function () {
189
+ if (state.el.parentNode) state.el.parentNode.removeChild(state.el)
190
+ if (activeToast === state) activeToast = null
191
+ }, 200)
192
+ }
193
+
194
+ var current = activeToast
195
+ if (current !== null && current.el.parentNode !== null) {
196
+ // Replace: revive a mid-fade toast, swap class/text, restart the countdown.
197
+ clearTimeout(current.timer)
198
+ clearTimeout(current.removalTimer)
199
+ current.leaving = false
200
+ current.el.className = 'dsh-admin-toast ' + type
201
+ current.el.textContent = text
202
+ current.timer = setTimeout(function () { quit(current) }, duration)
203
+ return
204
+ }
205
+
206
+ var toast = document.createElement('div')
207
+ toast.className = 'dsh-admin-toast ' + type
208
+ toast.textContent = text
209
+ document.body.appendChild(toast)
210
+ var state = { el: toast, timer: null, removalTimer: null, leaving: false }
211
+ activeToast = state
212
+ toast.addEventListener('click', function () { quit(state) })
213
+ state.timer = setTimeout(function () { quit(state) }, duration)
214
+ }
215
+
216
+ /**
217
+ * Copy text to the clipboard. Uses the async Clipboard API when available;
218
+ * falls back to a hidden textarea + execCommand for older browsers or
219
+ * non-secure contexts.
220
+ */
221
+ function copyTextToClipboard(text) {
222
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
223
+ navigator.clipboard.writeText(text).then(function () {
224
+ showToast('success', '📋 会话 ID 已复制')
225
+ }, function () {
226
+ fallbackCopy(text)
227
+ })
228
+ return
229
+ }
230
+ fallbackCopy(text)
231
+ }
232
+
233
+ function fallbackCopy(text) {
234
+ try {
235
+ var ta = document.createElement('textarea')
236
+ ta.value = text
237
+ ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
238
+ document.body.appendChild(ta)
239
+ ta.select()
240
+ ta.setSelectionRange(0, text.length)
241
+ document.execCommand('copy')
242
+ document.body.removeChild(ta)
243
+ } catch (e) {
244
+ showToast('error', '❌ 复制失败:' + messageOf(e))
245
+ }
246
+ }
247
+
248
+ /* ========================================================================== */
249
+ /* Menu Popup Injection (Sessions & Workspaces) */
250
+ /* ========================================================================== */
251
+
252
+ /**
253
+ * Inject plugin items into the existing three-dot menu popup. The workspace
254
+ * bundle renders a Menu component (@deepseek-ai/dsh-client-ui-primitives)
255
+ * with portal:true, so the popup lives as a div[role="menu"] inside
256
+ * document.body with position:fixed. A MutationObserver detects when this
257
+ * popup appears, identifies it as a session or workspace menu by checking
258
+ * the existing button labels, and injects additional items.
259
+ */
260
+ function setupMenuInjection(call, refreshSessions) {
261
+ if (typeof document === 'undefined') return function () {}
262
+
263
+ /**
264
+ * Extract the session title from the row's aria-label. The anchor button
265
+ * aria-label is: 会话"<title>"的操作 (zh) / Session "<title>" actions (en).
266
+ * Extracting from the DOM keeps the injection synchronous — no RPC needed
267
+ * just to show the button, so the menu can't close before it appears.
268
+ */
269
+ function sessionTitleFromRow(row) {
270
+ var btn = row.querySelector('button[aria-label*="会话" i][aria-label*="操作" i], button[aria-label*="session" i][aria-label*="actions" i]')
271
+ if (btn === null) return null
272
+ var label = btn.getAttribute('aria-label') || ''
273
+ // zh: 会话"xxx"的操作 → strip 会话"/"的操作
274
+ var zh = /会话["“]([^"”]+)["”]/.exec(label)
275
+ if (zh) return zh[1].trim()
276
+ // en: Session "xxx" actions
277
+ var en = /session\s*["“]([^"”]+)["”]/i.exec(label)
278
+ if (en) return en[1].trim()
279
+ // fallback: whole label minus known prefixes/suffixes
280
+ return label.replace(/^会话/, '').replace(/^session/i, '').replace(/["“”]|的操作|actions$/gi, '').trim() || null
281
+ }
282
+
283
+ /** Find the treeitem row for the anchor button nearest the menu. */
284
+ function rowForMenu(menuEl) {
285
+ var menuRect = menuEl ? menuEl.getBoundingClientRect() : null
286
+ var allRows = document.querySelectorAll('[role="treeitem"]')
287
+ if (allRows.length === 0) return null
288
+ var rows = []
289
+ for (var i = 0; i < allRows.length; i++) {
290
+ var r = allRows[i]
291
+ // Only rows that contain a session/workspace action button
292
+ 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]')) {
293
+ rows.push(r)
294
+ }
295
+ }
296
+ if (rows.length === 0) return null
297
+
298
+ // The Menu component renders with side="bottom": menu top sits just
299
+ // below the anchor button (button.bottom + 4). Match the row whose
300
+ // center is closest to the MENU TOP, not the menu center — the menu
301
+ // can be tall (several items), so its center drifts far from the
302
+ // clicked row.
303
+ if (menuRect === null || menuRect.height === 0) {
304
+ // No reliable menu rect. With a single candidate row the choice is
305
+ // unambiguous, so return it (jsdom/test environments have zero rects);
306
+ // with several rows refuse to guess rather than act on the wrong
307
+ // session.
308
+ if (rows.length === 1) return rows[0]
309
+ return null
310
+ }
311
+ var closest = rows[0]
312
+ var closestDist = Infinity
313
+ for (var j = 0; j < rows.length; j++) {
314
+ var rect = rows[j].getBoundingClientRect()
315
+ if (rect.height === 0) continue
316
+ var rowCenter = rect.top + rect.height / 2
317
+ var dist = Math.abs(rowCenter - menuRect.top)
318
+ if (dist < closestDist) {
319
+ closestDist = dist
320
+ closest = rows[j]
321
+ }
322
+ }
323
+ return closest
324
+ }
325
+
326
+ function injectItems(menuEl) {
327
+ if (menuEl.querySelector('[data-dsh-admin-injected]')) return
328
+
329
+ var text = menuEl.textContent || ''
330
+ var isSession = text.indexOf('归档会话') !== -1 || text.indexOf('Archive session') !== -1
331
+ var isWorkspace = !isSession && (text.indexOf('删除') !== -1 || text.indexOf('Delete workspace') !== -1)
332
+ if (!isSession && !isWorkspace) return
333
+
334
+ var viewport = menuEl.querySelector('[role="presentation"]')
335
+ if (viewport === null) return
336
+
337
+ var row = rowForMenu(menuEl)
338
+ if (row === null) return
339
+
340
+ if (isSession) {
341
+ var title = sessionTitleFromRow(row)
342
+ if (title === null) return
343
+ // Resolve sessions by title lazily at click time (fresh RPC) so the
344
+ // match is always current. Copy-id keeps the fuzzy matcher (a wrong
345
+ // match only mis-copies an id); delete must never guess, so it
346
+ // resolves by exact title only and refuses duplicate titles — the
347
+ // panel's rows act on ids and can disambiguate.
348
+ function fetchSessions(done) {
349
+ call('sessionAdmin/list', {}).then(function (listResult) {
350
+ done((listResult && listResult.ok && listResult.value && listResult.value.sessions) || [])
351
+ }, function () { showToast('error', '❌ 无法加载会话列表') })
352
+ }
353
+ // Normalize a title for comparison: trim, collapse spaces, drop
354
+ // trailing ellipsis and truncation artifacts.
355
+ function norm(v) {
356
+ return (v || '').replace(/\s+/g, ' ').replace(/\.{3,}\s*$/, '').trim().toLowerCase()
357
+ }
358
+
359
+ function resolveSessionFuzzy(cb) {
360
+ fetchSessions(function (sessions) {
361
+ var match = null
362
+ var nt = norm(title)
363
+ for (var i = 0; i < sessions.length; i++) {
364
+ var s = sessions[i]
365
+ var st = norm(s.title)
366
+ if (st === nt) { match = s; break }
367
+ // substring both ways (skip too-short needles)
368
+ if (nt.length > 3 && st.indexOf(nt) !== -1) { match = s; break }
369
+ if (st.length > 3 && nt.indexOf(st) !== -1) { match = s; break }
370
+ // fall back to cwd basename match
371
+ var cwdBase = s.cwd ? s.cwd.replace(/\\/g, '/').split('/').pop() : ''
372
+ if (cwdBase && (cwdBase === nt || nt.indexOf(cwdBase) !== -1 || cwdBase.indexOf(nt) !== -1)) { match = s; break }
373
+ }
374
+ if (match === null) { showToast('error', '❌ 未找到匹配的会话'); return }
375
+ cb(match)
376
+ })
377
+ }
378
+
379
+ function resolveSessionExact(cb) {
380
+ fetchSessions(function (sessions) {
381
+ var nt = norm(title)
382
+ var exact = []
383
+ for (var i = 0; i < sessions.length; i++) {
384
+ if (norm(sessions[i].title) === nt) exact.push(sessions[i])
385
+ }
386
+ if (exact.length === 0) { showToast('error', '❌ 未找到匹配的会话'); return }
387
+ if (exact.length > 1) {
388
+ showToast('error', '❌ 存在 ' + String(exact.length) + ' 个同名会话,无法确定要删除的目标;请在 设置 → 管理中心 → 会话管理 中按会话 ID 删除')
389
+ return
390
+ }
391
+ cb(exact[0])
392
+ })
393
+ }
394
+
395
+ // Copy the session id to the clipboard.
396
+ appendMenuItem(viewport, '复制会话 ID', 'normal', function () {
397
+ resolveSessionFuzzy(function (session) {
398
+ copyTextToClipboard(session.id)
399
+ })
400
+ })
401
+
402
+ // Delete the session. Online sessions are now closable through the
403
+ // captured AgentHandle (closeSession disposes the live agent/session
404
+ // first, so removing the log cannot resurrect it) this stops a
405
+ // running conversation in that session. The item arms on the first
406
+ // click and only fires on a second click within 4s (confirmText below)
407
+ // deletion is physical, so one misclick must never be enough.
408
+ appendMenuItem(viewport, '删除会话', 'danger', function () {
409
+ resolveSessionExact(function (match) {
410
+ var method = match.live ? 'sessionAdmin/closeSession' : 'sessionAdmin/deleteSession'
411
+ call(method, { sessionId: match.id }).then(function (result) {
412
+ if (result && result.ok) {
413
+ showToast('success', '🗑️ 会话已删除')
414
+ // Nudge the sidebar so the removed session vanishes now
415
+ // instead of lingering in 未分组 until reload.
416
+ if (refreshSessions) refreshSessions()
417
+ } else {
418
+ showToast('error', '❌ 删除会话失败:' + messageOf(result && result.error))
419
+ }
420
+ }, function (err) { showToast('error', '❌ 删除会话失败:' + messageOf(err)) })
421
+ })
422
+ }, '⚠️ 再点一次确认删除(在线会话将先关停)')
423
+ } else {
424
+ // Workspace: reveal in file manager. Path is resolved lazily at click.
425
+ var wsTitle = (row.querySelector('[class*="_title"]') || {}).textContent || ''
426
+ wsTitle = wsTitle.trim()
427
+ if (wsTitle === '') return
428
+ appendMenuItem(viewport, '在资源管理器打开', 'normal', function () {
429
+ call('sessionAdmin/list', {}).then(function (listResult) {
430
+ var workspaces = (listResult && listResult.ok && listResult.value && listResult.value.workspaces) || []
431
+ var wsPath = null
432
+ for (var i = 0; i < workspaces.length; i++) {
433
+ var w = workspaces[i]
434
+ var wTitle = (w && w.title) || ''
435
+ if (wTitle === wsTitle || (wsTitle !== '' && wTitle !== '' && (wsTitle.indexOf(wTitle) !== -1 || wTitle.indexOf(wsTitle) !== -1))) { wsPath = w && w.path; break }
436
+ }
437
+ if (wsPath === null) { showToast('error', '❌ 打开失败:未找到工作区路径'); return }
438
+ call('fsAdmin/reveal', { path: wsPath }).then(function (result) {
439
+ // Success needs no toast — the Explorer window opening is the
440
+ // feedback itself. Only failures get a hint.
441
+ if (!(result && result.ok)) showToast('error', '❌ 打开失败:' + messageOf(result && result.error))
442
+ }, function (err) { showToast('error', '❌ 打开失败:' + messageOf(err)) })
443
+ }, function () { showToast('error', ' 打开失败:无法加载工作区列表') })
444
+ })
445
+ }
446
+ }
447
+
448
+ function appendMenuItem(viewport, label, kind, onClick, confirmText) {
449
+ // Separator (only if there are already items — always, to visually group)
450
+ var sep = document.createElement('div')
451
+ sep.setAttribute('role', 'separator')
452
+ sep.setAttribute('data-dsh-admin-injected', '')
453
+ sep.style.cssText = 'margin:3px 0;border-top:1px solid var(--dsw-alias-border-subtle,rgba(200,200,210,0.3))'
454
+ viewport.appendChild(sep)
455
+
456
+ var btn = document.createElement('button')
457
+ btn.type = 'button'
458
+ btn.setAttribute('role', 'menuitem')
459
+ btn.setAttribute('data-dsh-admin-injected', '')
460
+ btn.textContent = label
461
+ var color = kind === 'danger' ? '#ef4444' : 'inherit'
462
+ var hoverBg = kind === 'danger' ? 'rgba(239,68,68,0.12)' : 'rgba(200,200,210,0.3)'
463
+ 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'
464
+ btn.addEventListener('mouseenter', function () { btn.style.background = hoverBg })
465
+ btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent' })
466
+ // Optional two-step confirm (confirmText): the first click arms the
467
+ // item and relabels it; only a second click within 4s runs onClick —
468
+ // destructive items must never fire on a single misclick.
469
+ var armed = false
470
+ var disarmTimer = null
471
+ btn.addEventListener('click', function (e) {
472
+ e.stopPropagation()
473
+ if (confirmText === undefined) { onClick(); return }
474
+ if (!armed) {
475
+ armed = true
476
+ btn.textContent = confirmText
477
+ disarmTimer = setTimeout(function () {
478
+ armed = false
479
+ btn.textContent = label
480
+ }, 4000)
481
+ return
482
+ }
483
+ clearTimeout(disarmTimer)
484
+ armed = false
485
+ btn.textContent = label
486
+ onClick()
487
+ })
488
+ viewport.appendChild(btn)
489
+ }
490
+
491
+ var observer = new MutationObserver(function (mutations) {
492
+ for (var i = 0; i < mutations.length; i++) {
493
+ var added = mutations[i].addedNodes
494
+ if (!added || added.length === 0) continue
495
+ for (var j = 0; j < added.length; j++) {
496
+ var node = added[j]
497
+ if (node.nodeType !== 1) continue
498
+ var el = node
499
+ if (el.getAttribute && el.getAttribute('role') === 'menu') {
500
+ injectItems(el)
501
+ } else {
502
+ var menu = el.querySelector && el.querySelector('[role="menu"]')
503
+ if (menu) injectItems(menu)
504
+ }
505
+ }
506
+ }
507
+ })
508
+
509
+ observer.observe(document.body, { childList: true, subtree: true })
510
+
511
+ return function () {
512
+ observer.disconnect()
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Plugins management settings section (standalone, no internal tabs).
518
+ */
519
+ function PluginsSection(props) {
520
+ // Lazy initializer: the seed object (and its localStorage read) must be
521
+ // built once at mount, not re-parsed on every render.
522
+ var pairPlugins = useState(function () {
523
+ return {
524
+ profileDir: '',
525
+ plugins: [],
526
+ busy: false,
527
+ error: '',
528
+ spec: '',
529
+ confirming: null,
530
+ note: '',
531
+ output: '',
532
+ filter: 'all',
533
+ needle: '',
534
+ checkingUpdates: false,
535
+ updateChecked: false, // true once a check finished (even if no updates)
536
+ // Seed from persisted reminders so the ⬆ 有新版本 badge is visible the
537
+ // moment the panel mounts (before the fresh host check resolves) the
538
+ // reminder is only dropped once a check confirms the upgrade completed.
539
+ updates: loadUpdateReminders(), // name -> { latest, updateAvailable, error?, at? }
540
+ }
541
+ })
542
+ var pView = pairPlugins[0]
543
+ var setPView = pairPlugins[1]
544
+
545
+ var alive = useRef(false)
546
+ // In-flight check guard as a ref, not state: the state field drives the
547
+ // disabled button visuals, but gating ASYNC handlers on it races — a stale
548
+ // closure reads TRUE after an earlier check already settled and silently
549
+ // skips a required follow-up (the post-upgrade refresh), or FALSE mid-flight
550
+ // and lets a second one through. Refs are immune to closure staleness.
551
+ var checkingRef = useRef(false)
552
+
553
+ function patchPlugin(partial) {
554
+ setPView(function (cur) {
555
+ var next = {}
556
+ for (var k in cur) next[k] = cur[k]
557
+ for (var pk in partial) next[pk] = partial[pk]
558
+ return next
559
+ })
560
+ }
561
+
562
+ function callRemote(method, args) {
563
+ return props.call(method, args)
564
+ }
565
+
566
+ /**
567
+ * Reload the plugin layer list from the host.
568
+ * @param keepFeedback - when true, refresh ONLY the rows: busy/error/note
569
+ * stay untouched. Failure branches use this after patching the error —
570
+ * patch + reload in the same microtask otherwise batch into one render
571
+ * (React 18) and the just-set error text never paints, so a failed
572
+ * 安装/更新/卸载 would look like "nothing happened".
573
+ */
574
+ function reloadPlugins(keepFeedback) {
575
+ if (!keepFeedback) patchPlugin({ busy: true, error: '', note: '' })
576
+ callRemote('pluginAdmin/list', {}).then(function (result) {
577
+ if (!alive.current) return
578
+ if (result.ok) {
579
+ var next = {
580
+ profileDir: (result.value && result.value.profileDir) || '',
581
+ plugins: (result.value && result.value.plugins) || [],
582
+ }
583
+ if (!keepFeedback) next.busy = false
584
+ patchPlugin(next)
585
+ } else if (!keepFeedback) {
586
+ patchPlugin({ busy: false, error: '加载插件失败:' + messageOf(result.error) })
587
+ }
588
+ // keepFeedback: the visible error stays; a failed follow-up list just
589
+ // leaves the rows stale.
590
+ }, function (failure) {
591
+ if (!alive.current) return
592
+ if (!keepFeedback) patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
593
+ })
594
+ }
595
+
596
+ /**
597
+ * Query the npm registry for newer versions of registry-installed bundles
598
+ * and stash per-plugin results. The resolution MERGES the fresh authoritative
599
+ * results onto the previously persisted reminders:
600
+ * - newer version confirmed -> (re)arm & persist the reminder
601
+ * - confirmed up to date -> drop the reminder (更新完删除提醒)
602
+ * - per-plugin check error -> keep the known reminder (a transient
603
+ * registry failure must not erase it);
604
+ * with no known reminder the failure
605
+ * itself is recorded so cards/strip/
606
+ * note can surface it
607
+ * - no longer a registry install -> drop stale reminders
608
+ * The AUTO check on panel open is cache-friendly; a manual check passes
609
+ * force=true so the host bypasses its 5-minute TTL, re-queries the registry
610
+ * and refreshes the cached content (检查更新 = 强制重新查,而不是回放缓存).
611
+ * Runs concurrently with the list so the panel stays responsive; failures
612
+ * are per-plugin, never fatal.
613
+ * @param force - true to force the host to bypass its update cache.
614
+ */
615
+ function checkUpdates(force) {
616
+ if (checkingRef.current) return
617
+ checkingRef.current = true
618
+ patchPlugin({ checkingUpdates: true, note: '' })
619
+ callRemote('pluginAdmin/checkUpdates', { force: force === true }).then(function (result) {
620
+ checkingRef.current = false
621
+ if (!alive.current) return
622
+ if (!result.ok) {
623
+ patchPlugin({ checkingUpdates: false, error: '检查更新失败:' + messageOf(result.error) })
624
+ // A failed call must not wipe previously known reminders — they stay
625
+ // seeded from localStorage and keep rendering on the cards.
626
+ return
627
+ }
628
+ var list = (result.value && result.value.updates) || []
629
+ // Merge INSIDE the functional updater against the LATEST committed
630
+ // state, never the render closure's snapshot: a reminder armed by an
631
+ // in-flight check resolving alongside an upgrade commit must survive
632
+ // (a stale-snapshot full replace erased it). The updater stays pure —
633
+ // localStorage persistence is owned by the mirror effect below.
634
+ setPView(function (cur) {
635
+ var next = {}
636
+ for (var k in cur) next[k] = cur[k]
637
+ next.checkingUpdates = false
638
+ next.updates = mergeUpdateReminders(cur.updates || {}, list)
639
+ next.updateChecked = true
640
+ next.note = updateCheckNote(next.updates, list.length)
641
+ return next
642
+ })
643
+ }, function (failure) {
644
+ checkingRef.current = false
645
+ if (!alive.current) return
646
+ patchPlugin({ checkingUpdates: false, error: '检查更新调用失败:' + messageOf(failure) })
647
+ })
648
+ }
649
+
650
+ /** Upgrade one plugin to its latest version (registry install by name). */
651
+ function upgradePlugin(name) {
652
+ if (pView.busy) return
653
+ var spec = name + '@latest'
654
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
655
+ callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
656
+ if (!alive.current) return
657
+ if (result.ok) {
658
+ // The upgrade consumed this reminder: strip it from the CURRENT state
659
+ // inside the updater (更新完删除提醒) — never from the click-time
660
+ // closure snapshot, which may miss reminders an in-flight check armed
661
+ // meanwhile (erasing those was exactly scenario 7's stale-snapshot
662
+ // bug). localStorage follows via the shared mirror effect; the forced
663
+ // refresh below confirms the new version is current.
664
+ setPView(function (cur) {
665
+ var next = {}
666
+ for (var k in cur) next[k] = cur[k]
667
+ next.busy = false
668
+ next.note = '已更新 ' + name + '。更改在重启 dsh 后生效'
669
+ next.output = (result.value && result.value.output) || ''
670
+ next.profileDir = (result.value && result.value.profileDir) || ''
671
+ next.plugins = (result.value && result.value.plugins) || []
672
+ var remaining = {}
673
+ for (var rk in cur.updates) {
674
+ if (rk !== name) remaining[rk] = cur.updates[rk]
675
+ }
676
+ next.updates = remaining
677
+ return next
678
+ })
679
+ // Refresh the update map so the upgraded entry stops flagging.
680
+ // Force: the @latest spec installed the registry's CURRENT latest,
681
+ // but the host TTL cache may still hold an older latest published
682
+ // minutes ago — comparing against that would re-flag the freshly
683
+ // upgraded plugin. Bypass the cache and resync its content instead.
684
+ checkUpdates(true)
685
+ return
686
+ }
687
+ var failMessage = '更新失败:' + messageOf(result.error)
688
+ patchPlugin({ busy: false, error: failMessage })
689
+ // Floating toast for the failure in addition to the persistent panel
690
+ // error bar (the toast demands attention, the bar keeps the detail).
691
+ showToast('error', failMessage)
692
+ reloadPlugins(true)
693
+ }, function (failure) {
694
+ if (!alive.current) return
695
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
696
+ })
697
+ }
698
+
699
+ function installPlugin() {
700
+ if (pView.busy || pView.spec.trim() === '') return
701
+ var spec = pView.spec.trim()
702
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
703
+ callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
704
+ if (!alive.current) return
705
+ if (result.ok) {
706
+ patchPlugin({
707
+ busy: false,
708
+ note: '安装完成。更改在重启 dsh 后生效',
709
+ output: (result.value && result.value.output) || '',
710
+ profileDir: (result.value && result.value.profileDir) || '',
711
+ plugins: (result.value && result.value.plugins) || [],
712
+ spec: '',
713
+ })
714
+ return
715
+ }
716
+ var failMessage = '安装失败:' + messageOf(result.error)
717
+ patchPlugin({ busy: false, error: failMessage })
718
+ showToast('error', failMessage)
719
+ reloadPlugins(true)
720
+ }, function (failure) {
721
+ if (!alive.current) return
722
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
723
+ })
724
+ }
725
+
726
+ function removePlugin(name) {
727
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
728
+ callRemote('pluginAdmin/remove', { name: name }).then(function (result) {
729
+ if (!alive.current) return
730
+ if (result.ok) {
731
+ patchPlugin({
732
+ busy: false,
733
+ note: '卸载完成。更改在重启 dsh 后生效',
734
+ output: (result.value && result.value.output) || '',
735
+ profileDir: (result.value && result.value.profileDir) || '',
736
+ plugins: (result.value && result.value.plugins) || [],
737
+ })
738
+ return
739
+ }
740
+ var failMessage = '卸载失败:' + messageOf(result.error)
741
+ patchPlugin({ busy: false, error: failMessage })
742
+ showToast('error', failMessage)
743
+ reloadPlugins(true)
744
+ }, function (failure) {
745
+ if (!alive.current) return
746
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
747
+ })
748
+ }
749
+
750
+ useEffect(function () {
751
+ alive.current = true
752
+ reloadPlugins()
753
+ // Auto-check for remote updates on mount (the panel loads lazily when
754
+ // the 插件管理 tab is opened, so this runs once per open). The auto path
755
+ // is cache-friendly: the host serves its 5-minute cache, so re-opening
756
+ // the tab shortly after does not re-hit the registry. The manual
757
+ // 检查更新 button is the force path — it bypasses the cache and
758
+ // refreshes its content.
759
+ checkUpdates()
760
+ return function () { alive.current = false }
761
+ }, [])
762
+
763
+ // Single persistence point: localStorage always mirrors the LATEST
764
+ // committed reminders. Moving saves out of the async handlers (and out of
765
+ // the functional updaters, which must stay pure) removes drift by
766
+ // construction — whatever merge won the last commit is what survives
767
+ // dialog reopen and dsh restarts (best-effort; unavailable storage just
768
+ // degrades to session-only badges).
769
+ useEffect(function () {
770
+ saveUpdateReminders(pView.updates || {})
771
+ }, [pView.updates])
772
+
773
+ return createElement('div', { 'data-dsh-admin-section': '' },
774
+ renderPluginsView(pView, patchPlugin, installPlugin, removePlugin, checkUpdates, upgradePlugin))
775
+ }
776
+
777
+ /**
778
+ * Sessions management settings section (standalone, no internal tabs).
779
+ */
780
+ function SessionsSection(props) {
781
+ var pairSessions = useState({
782
+ sessions: [],
783
+ workspaces: [],
784
+ busy: false,
785
+ error: '',
786
+ needle: '',
787
+ confirming: null,
788
+ filter: 'all',
789
+ })
790
+ var sView = pairSessions[0]
791
+ var setSView = pairSessions[1]
792
+
793
+ var alive = useRef(false)
794
+
795
+ function patchSession(partial) {
796
+ setSView(function (cur) {
797
+ var next = {}
798
+ for (var k in cur) next[k] = cur[k]
799
+ for (var pk in partial) next[pk] = partial[pk]
800
+ return next
801
+ })
802
+ }
803
+
804
+ function callRemote(method, args) {
805
+ return props.call(method, args)
806
+ }
807
+
808
+ function reloadSessions() {
809
+ patchSession({ busy: true, error: '' })
810
+ callRemote('sessionAdmin/list', {}).then(function (result) {
811
+ if (!alive.current) return
812
+ if (result.ok) {
813
+ patchSession({
814
+ busy: false,
815
+ sessions: (result.value && result.value.sessions) || [],
816
+ workspaces: (result.value && result.value.workspaces) || [],
817
+ })
818
+ } else {
819
+ patchSession({ busy: false, error: '加载会话失败:' + messageOf(result.error) })
820
+ }
821
+ }, function (failure) {
822
+ if (!alive.current) return
823
+ patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
824
+ })
825
+ }
826
+
827
+ function actSession(method, sessionId) {
828
+ patchSession({ busy: true, error: '', confirming: null })
829
+ callRemote('sessionAdmin/' + method, { sessionId: sessionId }).then(function (result) {
830
+ if (!alive.current) return
831
+ if (result.ok) {
832
+ // Refresh this panel's own list, and nudge the sidebar (via the
833
+ // sessions service) so a deleted session disappears immediately
834
+ // instead of lingering until reload.
835
+ if ((method === 'deleteSession' || method === 'closeSession') && props.refreshSessions) props.refreshSessions()
836
+ return reloadSessions()
837
+ }
838
+ patchSession({ busy: false, error: '操作失败:' + messageOf(result.error) })
839
+ }, function (failure) {
840
+ if (!alive.current) return
841
+ patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
842
+ })
843
+ }
844
+
845
+ useEffect(function () {
846
+ alive.current = true
847
+ reloadSessions()
848
+ return function () { alive.current = false }
849
+ }, [])
850
+
851
+ return createElement('div', { 'data-dsh-admin-section': '' },
852
+ renderSessionsView(sView, patchSession, reloadSessions, actSession))
853
+ }
854
+
855
+ /**
856
+ * MCP configuration settings section (standalone, no internal tabs).
857
+ */
858
+
859
+ /* ------------------------------------------------------------------------ */
860
+ /* MCP test-result cache */
861
+ /* */
862
+ /* Successful connectivity probes are persisted to localStorage so the */
863
+ /* panel shows the last known status after tab switches or reopening the */
864
+ /* dialog instead of an empty state. Only OK results are cached (failures */
865
+ /* are usually transient); a cached row is labelled with its probe time, */
866
+ /* and the cache entry is invalidated when the config is saved or removed, */
867
+ /* or pruned when the entry disappears from the host list. */
868
+ /* ------------------------------------------------------------------------ */
869
+
870
+ var MCP_TEST_CACHE_KEY = 'dsh-plugin-admin/mcp-test-results'
871
+
872
+ /**
873
+ * Touch localStorage safely: on some environments (jsdom with an opaque
874
+ * origin, hardened browsers, privacy modes) merely READING window.localStorage
875
+ * throws a SecurityError, so the access itself has to sit inside try/catch.
876
+ * Returns null when storage is unavailable.
877
+ */
878
+ function safeLocalStorage() {
879
+ try {
880
+ if (typeof window === 'undefined' || !window.localStorage) return null
881
+ return window.localStorage
882
+ } catch (e) {
883
+ return null
884
+ }
885
+ }
886
+
887
+ function mcpTestStorage() {
888
+ return safeLocalStorage()
889
+ }
890
+
891
+ function loadMcpTestCache() {
892
+ var store = mcpTestStorage()
893
+ if (store === null) return {}
894
+ try {
895
+ var parsed = JSON.parse(store.getItem(MCP_TEST_CACHE_KEY))
896
+ if (parsed === null || typeof parsed !== 'object') return {}
897
+ // Purge failure records persisted by older builds: only a durable OK is
898
+ // worth restoring on mount; a stale ❌ from a past outage must not
899
+ // outlive the session it happened in.
900
+ var out = {}
901
+ for (var id in parsed) {
902
+ var entry = parsed[id]
903
+ if (entry && entry.result && entry.result.ok === true) out[id] = entry
904
+ }
905
+ return out
906
+ } catch (e) {
907
+ return {}
908
+ }
909
+ }
910
+
911
+ /** Mirror settled, successful probes into localStorage (best-effort). */
912
+ function saveMcpTestCache(state) {
913
+ var store = mcpTestStorage()
914
+ if (store === null) return
915
+ try {
916
+ var out = {}
917
+ for (var id in state) {
918
+ var s = state[id]
919
+ // Busy rows keep the previous cached result; failures stay ephemeral
920
+ // INCLUDING structured {ok:false} probes (timeout, connection refused),
921
+ // which arrive as a truthy result object and need their own ok check.
922
+ if (!s || s.busy || s.result === null || s.result === undefined
923
+ || s.result.ok !== true) continue
924
+ out[id] = { result: s.result, at: typeof s.at === 'number' ? s.at : Date.now() }
925
+ }
926
+ store.setItem(MCP_TEST_CACHE_KEY, JSON.stringify(out))
927
+ } catch (e) {
928
+ // Quota exceeded / storage blocked — the cache is optional, never fatal.
929
+ }
930
+ }
931
+
932
+ /** Drop one entry's cached probe (config saved or entry removed). */
933
+ function clearMcpTestCacheEntry(id) {
934
+ var store = mcpTestStorage()
935
+ if (store === null) return
936
+ try {
937
+ var state = loadMcpTestCache()
938
+ if (state[id] === undefined) return
939
+ delete state[id]
940
+ store.setItem(MCP_TEST_CACHE_KEY, JSON.stringify(state))
941
+ } catch (e) { /* ignore */ }
942
+ }
943
+
944
+ /** Drop test states whose entry no longer exists in the host list. */
945
+ function pruneMcpTestState(map, entries) {
946
+ var valid = {}
947
+ for (var i = 0; i < entries.length; i++) valid[entries[i].id] = true
948
+ var next = {}
949
+ var changed = false
950
+ for (var id in map) {
951
+ if (valid[id]) next[id] = map[id]
952
+ else changed = true
953
+ }
954
+ if (changed) saveMcpTestCache(next)
955
+ return next
956
+ }
957
+
958
+ /** Compact timestamp for a cached probe ("14:32" today, "6-1 14:32" older). */
959
+ function formatTestTime(ms) {
960
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
961
+ var d = new Date(ms)
962
+ var pad = function (n) { return (n < 10 ? '0' : '') + String(n) }
963
+ var hm = pad(d.getHours()) + ':' + pad(d.getMinutes())
964
+ var now = new Date()
965
+ if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) return hm
966
+ return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm
967
+ }
968
+
969
+ /* Update-reminder persistence */
970
+ /* */
971
+ /* A "⬆ 有新版本" reminder is a durable fact, not a transient fetch result: */
972
+ /* once a newer version is detected it is kept in localStorage until a */
973
+ /* check confirms the installed version is current again (i.e. the update */
974
+ /* actually completed). This is what keeps the badge visible after closing */
975
+ /* and reopening the 管理中心, across dsh restarts, and even when a later */
976
+ /* re-check hits a transient network failure — and what clears it only once */
977
+ /* the plugin is upgraded (更新完删除提醒). */
978
+ /* ------------------------------------------------------------------------ */
979
+
980
+ var UPDATE_REMINDER_KEY = 'dsh-plugin-admin/update-reminders'
981
+
982
+ /** Shape-merge a reminder entry (keeps the original truthy fields). */
983
+ function mergeReminder(entry, partial) {
984
+ var next = {}
985
+ for (var k in entry) next[k] = entry[k]
986
+ for (var pk in partial) next[pk] = partial[pk]
987
+ return next
988
+ }
989
+
990
+ /**
991
+ * Load persisted update reminders as state entries:
992
+ * { name: { latest, at, updateAvailable, error } }. Storage-unavailable
993
+ * environments (opaque-origin jsdom, privacy modes) just get an empty map.
994
+ */
995
+ function loadUpdateReminders() {
996
+ var store = safeLocalStorage()
997
+ if (store === null) return {}
998
+ try {
999
+ var parsed = JSON.parse(store.getItem(UPDATE_REMINDER_KEY))
1000
+ var out = {}
1001
+ if (parsed !== null && typeof parsed === 'object') {
1002
+ for (var name in parsed) {
1003
+ var r = parsed[name]
1004
+ if (r && typeof r.latest === 'string' && r.latest.length > 0) {
1005
+ out[name] = {
1006
+ latest: r.latest,
1007
+ at: typeof r.at === 'number' ? r.at : 0,
1008
+ updateAvailable: true,
1009
+ error: '',
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ return out
1015
+ } catch (e) {
1016
+ return {}
1017
+ }
1018
+ }
1019
+
1020
+ /** Mirror the current reminder set into localStorage (best-effort). */
1021
+ function saveUpdateReminders(map) {
1022
+ var store = safeLocalStorage()
1023
+ if (store === null) return
1024
+ try {
1025
+ var out = {}
1026
+ for (var name in map) {
1027
+ var r = map[name]
1028
+ if (r && r.updateAvailable === true && typeof r.latest === 'string' && r.latest.length > 0) {
1029
+ out[name] = { latest: r.latest, at: typeof r.at === 'number' ? r.at : Date.now() }
1030
+ }
1031
+ }
1032
+ store.setItem(UPDATE_REMINDER_KEY, JSON.stringify(out))
1033
+ } catch (e) {
1034
+ // Quota exceeded / storage blocked the reminder is optional, never fatal.
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Merge one check response into the CURRENT reminder map. Pure on purpose:
1040
+ * it runs inside the setPView updater, so it reads only its arguments and
1041
+ * must never touch localStorage or other component state. Per-entry semantics
1042
+ * (list = fresh host results, one row per live registry install):
1043
+ * - updateAvailable -> (re)arm the reminder
1044
+ * - confirmed current -> drop any entry (更新完删除提醒)
1045
+ * - query error -> keep a prior reminder untouched (error attached);
1046
+ * without one, record an ephemeral failure
1047
+ * ({updateAvailable:false}) so cards/strip/note can
1048
+ * surface it — saveUpdateReminders skips those
1049
+ * - entries absent from `list` are no longer live registry installs ->
1050
+ * stale reminders dropped
1051
+ * @param curMap - the CURRENT updates state ({ name -> entry }).
1052
+ * @param list - per-plugin check results.
1053
+ * @returns the complete next map.
1054
+ */
1055
+ function mergeUpdateReminders(curMap, list) {
1056
+ var liveNames = {}
1057
+ for (var a = 0; a < list.length; a++) liveNames[list[a].name] = true
1058
+ var map = {}
1059
+ for (var k in curMap) {
1060
+ if (curMap[k] && liveNames[k]) map[k] = curMap[k]
1061
+ }
1062
+ for (var i = 0; i < list.length; i++) {
1063
+ var u = list[i]
1064
+ if (u.updateAvailable === true) {
1065
+ map[u.name] = { latest: u.latest, updateAvailable: true, error: '', at: Date.now() }
1066
+ } else if (u.error) {
1067
+ // A prior reminder survives untouched with the transient error
1068
+ // alongside; WITHOUT one the failure itself lands in the map so an
1069
+ // all-failed first check cannot claim 全部为最新版本.
1070
+ map[u.name] = map[u.name]
1071
+ ? mergeReminder(map[u.name], { error: u.error || '' })
1072
+ : { latest: '', updateAvailable: false, error: u.error || '', at: Date.now() }
1073
+ } else if (u.latest !== null && u.latest !== undefined) {
1074
+ delete map[u.name]
1075
+ }
1076
+ }
1077
+ return map
1078
+ }
1079
+
1080
+ /** Aggregate summary line for a finished check (pure). */
1081
+ function updateCheckNote(map, checkedCount) {
1082
+ var updateCount = 0
1083
+ var checkErrorCount = 0
1084
+ for (var n in map) {
1085
+ if (map[n].updateAvailable === true) updateCount++
1086
+ if (map[n].error) checkErrorCount++
1087
+ }
1088
+ if (updateCount > 0) return '发现 ' + updateCount + ' 个插件有新版本'
1089
+ if (checkErrorCount > 0) {
1090
+ return '已检查 ' + checkedCount + ' 个插件(' + checkErrorCount + ' 个查询失败)'
1091
+ + (checkedCount > checkErrorCount ? ',其余均为最新版本' : '')
1092
+ }
1093
+ return checkedCount > 0 ? '已检查 ' + checkedCount + ' 个插件,均为最新版本' : ''
1094
+ }
1095
+
1096
+ function McpSection(props) {
1097
+ var pairMcp = useState({
1098
+ mcpEntries: [],
1099
+ mcpBusy: false,
1100
+ mcpError: '',
1101
+ mcpEditorOpen: false,
1102
+ mcpDraft: null,
1103
+ // entryId -> { busy?, result?, error?, at? }; previous successful probes
1104
+ // are restored from localStorage so statuses survive panel remounts.
1105
+ mcpTestState: loadMcpTestCache(),
1106
+ })
1107
+ var mView = pairMcp[0]
1108
+ var setMView = pairMcp[1]
1109
+
1110
+ var alive = useRef(false)
1111
+
1112
+ function patchMcp(partial) {
1113
+ setMView(function (cur) {
1114
+ var next = {}
1115
+ for (var k in cur) next[k] = cur[k]
1116
+ for (var pk in partial) next[pk] = partial[pk]
1117
+ return next
1118
+ })
1119
+ }
1120
+
1121
+ // Merge a partial into the CURRENT mcpDraft via a functional update, so
1122
+ // rapid successive field edits (React batching) never lose earlier input.
1123
+ function patchDraft(partial) {
1124
+ setMView(function (cur) {
1125
+ var next = {}
1126
+ for (var k in cur) next[k] = cur[k]
1127
+ next.mcpDraft = mergeDraft(cur.mcpDraft, partial)
1128
+ return next
1129
+ })
1130
+ }
1131
+
1132
+ function callRemote(method, args) {
1133
+ return props.call(method, args)
1134
+ }
1135
+
1136
+ function reloadMcp() {
1137
+ patchMcp({ mcpBusy: true, mcpError: '' })
1138
+ callRemote('mcpAdmin/list', {}).then(function (result) {
1139
+ if (!alive.current) return
1140
+ if (result.ok) {
1141
+ var entries = (result.value && result.value.entries) || []
1142
+ setMView(function (cur) {
1143
+ var next = {}
1144
+ for (var k in cur) next[k] = cur[k]
1145
+ next.mcpBusy = false
1146
+ next.mcpEntries = entries
1147
+ // Drop cached probes whose entry no longer exists on the host.
1148
+ next.mcpTestState = pruneMcpTestState(cur.mcpTestState, entries)
1149
+ return next
1150
+ })
1151
+ } else {
1152
+ patchMcp({ mcpBusy: false, mcpError: '加载 MCP 配置失败:' + messageOf(result.error) })
1153
+ }
1154
+ }, function (failure) {
1155
+ if (!alive.current) return
1156
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1157
+ })
1158
+ }
1159
+
1160
+ function openMcpEditor(entry) {
1161
+ if (entry !== null && entry !== undefined && (entry.config === null || entry.config === undefined)) {
1162
+ patchMcp({ mcpError: '该 MCP 配置无法安全解析,已禁止在此覆盖;请在 cordis.patch.yml 中手动编辑。' })
1163
+ return
1164
+ }
1165
+ var source = entry !== null && entry !== undefined ? entry.config : null
1166
+ var draft = source ? {
1167
+ id: entry.id,
1168
+ isNew: false,
1169
+ serverName: source.serverName || entry.id,
1170
+ transport: source.transport,
1171
+ command: source.command || '',
1172
+ url: source.url || '',
1173
+ args: (source.args || []).join(' '),
1174
+ argsOriginal: source.args || [],
1175
+ argsChanged: false,
1176
+ env: Object.keys(source.env || {}).map(function (key) { return key + '=' + source.env[key] }).join('\n'),
1177
+ envOriginal: source.env || {},
1178
+ envChanged: false,
1179
+ cwd: source.cwd || '',
1180
+ headers: Object.keys(source.headers || {}).map(function (k) { return k + '=' + source.headers[k] }).join('\n'),
1181
+ headersOriginal: source.headers || {},
1182
+ headersChanged: false,
1183
+ toolCallTimeoutMs: source.toolCallTimeoutMs,
1184
+ failOnStartupError: source.failOnStartupError === true,
1185
+ reconnectEnabled: source.reconnect ? source.reconnect.enabled === true : false,
1186
+ reconnectInitialDelayMs: source.reconnect ? source.reconnect.initialDelayMs : 1000,
1187
+ reconnectMaxDelayMs: source.reconnect ? source.reconnect.maxDelayMs : 30000,
1188
+ reconnectMaxAttempts: source.reconnect ? source.reconnect.maxAttempts : 10,
1189
+ } : {
1190
+ // New server: pre-fill a collision-free id so the user does not have to
1191
+ // invent one; they can still edit it or regenerate it.
1192
+ id: generateMcpId(mView.mcpEntries),
1193
+ isNew: true,
1194
+ serverName: '',
1195
+ transport: 'stdio',
1196
+ command: '',
1197
+ url: '',
1198
+ args: '',
1199
+ argsOriginal: [],
1200
+ argsChanged: false,
1201
+ env: '',
1202
+ envOriginal: {},
1203
+ envChanged: false,
1204
+ cwd: '',
1205
+ headers: '',
1206
+ headersOriginal: {},
1207
+ headersChanged: false,
1208
+ toolCallTimeoutMs: undefined,
1209
+ failOnStartupError: false,
1210
+ reconnectEnabled: false,
1211
+ reconnectInitialDelayMs: 1000,
1212
+ reconnectMaxDelayMs: 30000,
1213
+ reconnectMaxAttempts: 10,
1214
+ }
1215
+ patchMcp({ mcpEditorOpen: true, mcpDraft: draft, mcpError: '' })
1216
+ }
1217
+
1218
+ function closeMcpEditor() {
1219
+ patchMcp({ mcpEditorOpen: false, mcpDraft: null, mcpError: '' })
1220
+ }
1221
+
1222
+ function saveMcpDraft() {
1223
+ var draft = mView.mcpDraft
1224
+ if (draft === null) return
1225
+ // A NEW entry must not collide with an existing id: the host's upsert
1226
+ // locates its target block by id and would replace that entry in place,
1227
+ // silently destroying its config. The edit form keeps its own id (the
1228
+ // field is disabled), so this guard only ever fires on the add form.
1229
+ if (draft.isNew) {
1230
+ for (var ci = 0; ci < mView.mcpEntries.length; ci++) {
1231
+ if (mView.mcpEntries[ci].id === draft.id) {
1232
+ patchMcp({ mcpError: 'ID \'' + draft.id + '\' 已被其他 MCP 条目占用,请换一个再保存。' })
1233
+ return
1234
+ }
1235
+ }
1236
+ }
1237
+ var config
1238
+ if (draft.transport === 'streamable-http') {
1239
+ config = { transport: 'streamable-http', serverName: draft.serverName, url: draft.url }
1240
+ var headersStr = Object.keys(draft.headersOriginal || {}).map(function (k) { return k + '=' + draft.headersOriginal[k] }).join('\n')
1241
+ var headers = (draft.headersChanged && draft.headers !== headersStr) ? {} : draft.headersOriginal
1242
+ if (draft.headersChanged && draft.headers !== headersStr && draft.headers !== '') {
1243
+ // Newline-only split: header values legitimately contain ';'/','
1244
+ // (Cookie, Accept), which must never become pair separators.
1245
+ var pairs = draft.headers.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
1246
+ for (var i = 0; i < pairs.length; i++) {
1247
+ var eq = pairs[i].indexOf('=')
1248
+ if (eq > 0) headers[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
1249
+ }
1250
+ }
1251
+ if (Object.keys(headers).length > 0) config.headers = headers
1252
+ } else {
1253
+ config = { transport: 'stdio', serverName: draft.serverName, command: draft.command }
1254
+ var argsStr = (draft.argsOriginal || []).join(' ')
1255
+ var args = (draft.argsChanged && draft.args !== argsStr) ? draft.args.split(/\s+/).filter(Boolean) : draft.argsOriginal
1256
+ if (args.length > 0) config.args = args
1257
+ var envStr = Object.keys(draft.envOriginal || {}).map(function (k) { return k + '=' + draft.envOriginal[k] }).join('\n')
1258
+ var env = (draft.envChanged && draft.env !== envStr) ? {} : draft.envOriginal
1259
+ if (draft.envChanged && draft.env !== envStr && draft.env !== '') {
1260
+ // Newline-only split: values like PATH=C:\a;C:\b would lose their
1261
+ // tail (and Windows paths carry ';' everywhere).
1262
+ var pairs = draft.env.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
1263
+ for (var i = 0; i < pairs.length; i++) {
1264
+ var eq = pairs[i].indexOf('=')
1265
+ if (eq > 0) env[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
1266
+ }
1267
+ }
1268
+ if (Object.keys(env).length > 0) config.env = env
1269
+ if (draft.cwd !== '') config.cwd = draft.cwd
1270
+ }
1271
+ if (draft.toolCallTimeoutMs !== undefined) config.toolCallTimeoutMs = draft.toolCallTimeoutMs
1272
+ if (draft.failOnStartupError) config.failOnStartupError = true
1273
+ if (draft.reconnectEnabled) {
1274
+ config.reconnect = {
1275
+ enabled: true,
1276
+ initialDelayMs: Number(draft.reconnectInitialDelayMs),
1277
+ maxDelayMs: Number(draft.reconnectMaxDelayMs),
1278
+ maxAttempts: Number(draft.reconnectMaxAttempts),
1279
+ }
1280
+ }
1281
+ patchMcp({ mcpBusy: true, mcpError: '' })
1282
+ callRemote('mcpAdmin/upsert', { entry: { id: draft.id, config: config } }).then(function (result) {
1283
+ if (!alive.current) return
1284
+ if (result.ok) {
1285
+ // The config changed, so any cached probe no longer describes this
1286
+ // server drop it from storage and from the panel.
1287
+ clearMcpTestCacheEntry(draft.id)
1288
+ patchMcpTest(draft.id, { busy: false, result: null, error: null })
1289
+ patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [], mcpEditorOpen: false, mcpDraft: null })
1290
+ } else {
1291
+ patchMcp({ mcpBusy: false, mcpError: '保存 MCP 配置失败:' + messageOf(result.error) })
1292
+ }
1293
+ }, function (failure) {
1294
+ if (!alive.current) return
1295
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1296
+ })
1297
+ }
1298
+
1299
+ function removeMcpEntry(id) {
1300
+ patchMcp({ mcpBusy: true, mcpError: '' })
1301
+ callRemote('mcpAdmin/remove', { id: id }).then(function (result) {
1302
+ if (!alive.current) return
1303
+ if (result.ok) {
1304
+ // The entry is gone — its cached probe must not linger in storage.
1305
+ clearMcpTestCacheEntry(id)
1306
+ patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [] })
1307
+ } else {
1308
+ patchMcp({ mcpBusy: false, mcpError: '移除 MCP 配置失败:' + messageOf(result.error) })
1309
+ }
1310
+ }, function (failure) {
1311
+ if (!alive.current) return
1312
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
1313
+ })
1314
+ }
1315
+
1316
+ /**
1317
+ * Set one entry's connectivity-test status against the CURRENT state.
1318
+ * Settled successful probes are mirrored into localStorage so the last
1319
+ * known status survives tab switches and reopening the settings dialog.
1320
+ */
1321
+ function patchMcpTest(id, partial) {
1322
+ setMView(function (cur) {
1323
+ var nextTestState = mergeTestState(cur.mcpTestState, id, partial)
1324
+ saveMcpTestCache(nextTestState)
1325
+ var next = {}
1326
+ for (var k in cur) next[k] = cur[k]
1327
+ next.mcpTestState = nextTestState
1328
+ return next
1329
+ })
1330
+ }
1331
+
1332
+ /** Run a host-side connectivity probe for one entry and stash the result. */
1333
+ function testMcpEntry(id) {
1334
+ patchMcpTest(id, { busy: true, result: null, error: null })
1335
+ callRemote('mcpAdmin/test', { id: id }).then(function (result) {
1336
+ if (!alive.current) return
1337
+ if (result.ok && result.value !== null && typeof result.value === 'object') {
1338
+ // `at` timestamps the probe; it is what the cached label renders.
1339
+ patchMcpTest(id, { busy: false, result: result.value, at: Date.now() })
1340
+ } else {
1341
+ patchMcpTest(id, { busy: false, result: null, error: messageOf(result.error) })
1342
+ }
1343
+ }, function (failure) {
1344
+ if (!alive.current) return
1345
+ patchMcpTest(id, { busy: false, result: null, error: messageOf(failure) })
1346
+ })
1347
+ }
1348
+
1349
+ useEffect(function () {
1350
+ alive.current = true
1351
+ reloadMcp()
1352
+ return function () { alive.current = false }
1353
+ }, [])
1354
+
1355
+ return createElement('div', { 'data-dsh-admin-section': '' },
1356
+ renderMcpSection(mView, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry))
1357
+ }
1358
+
1359
+ /** Unified settings page; only the selected panel mounts and loads. */
1360
+ function AdminCenterSection(props) {
1361
+ var pairTab = useState('plugins')
1362
+ var activeTab = pairTab[0]
1363
+ var setActiveTab = pairTab[1]
1364
+ var tabs = [
1365
+ { id: 'plugins', label: '插件管理', component: PluginsSection },
1366
+ { id: 'sessions', label: '会话管理', component: SessionsSection },
1367
+ { id: 'mcp', label: 'MCP 配置', component: McpSection },
1368
+ ]
1369
+ var selected = tabs.find(function (tab) { return tab.id === activeTab }) || tabs[0]
1370
+
1371
+ return createElement('div', { 'data-dsh-admin-section': '' },
1372
+ createElement('div', { className: 'tabs', role: 'tablist', 'aria-label': '管理中心功能' },
1373
+ tabs.map(function (tab) {
1374
+ return createElement('button', {
1375
+ type: 'button',
1376
+ key: tab.id,
1377
+ className: 'tab' + (tab.id === selected.id ? ' active' : ''),
1378
+ role: 'tab',
1379
+ 'aria-selected': tab.id === selected.id,
1380
+ onClick: function () { setActiveTab(tab.id) },
1381
+ }, tab.label)
1382
+ })
1383
+ ),
1384
+ createElement(selected.component, { key: selected.id, call: props.call, refreshSessions: props.refreshSessions })
1385
+ )
1386
+ }
1387
+
1388
+ /* ========================================================================== */
1389
+ /* Render Plugins View */
1390
+ /* ========================================================================== */
1391
+
1392
+ function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade) {
1393
+ var elements = []
1394
+
1395
+ // Toolbar: search (primary, wide) + install input + actions in one row.
1396
+ elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
1397
+ createElement('div', { className: 'search-wrap', key: 'search-wrap' },
1398
+ createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
1399
+ createElement('input', {
1400
+ className: 'input',
1401
+ key: 'search-input',
1402
+ placeholder: '搜索插件(名称/版本/路径)...',
1403
+ value: view.needle,
1404
+ onChange: function (e) { patch({ needle: e.target.value }) },
1405
+ }),
1406
+ view.needle !== '' ? createElement('button', {
1407
+ type: 'button',
1408
+ key: 'btn-clear-search',
1409
+ className: 'btn sm',
1410
+ title: '清空搜索',
1411
+ onClick: function () { patch({ needle: '' }) },
1412
+ }, '✕') : null
1413
+ ),
1414
+ createElement('div', { className: 'input-wrap install-wrap', key: 'install-wrap' },
1415
+ createElement('input', {
1416
+ className: 'input',
1417
+ placeholder: '安装包名/路径...',
1418
+ value: view.spec,
1419
+ disabled: view.busy,
1420
+ onChange: function (e) { patch({ spec: e.target.value }) },
1421
+ onKeyDown: function (e) { if (e.key === 'Enter') install() },
1422
+ })
1423
+ ),
1424
+ createElement('button', {
1425
+ type: 'button',
1426
+ key: 'btn-install',
1427
+ className: 'btn primary',
1428
+ disabled: view.busy || view.spec.trim() === '',
1429
+ onClick: install,
1430
+ },
1431
+ view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1432
+ '安装'
1433
+ ),
1434
+ createElement('button', {
1435
+ type: 'button',
1436
+ key: 'btn-check-updates',
1437
+ className: 'btn',
1438
+ disabled: view.busy || view.checkingUpdates,
1439
+ title: '强制绕过 5 分钟缓存,重新查询 registry 并刷新缓存',
1440
+ onClick: function () { checkUpdates(true) },
1441
+ },
1442
+ view.checkingUpdates ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1443
+ view.checkingUpdates ? '检查中...' : '⬆️ 检查更新'
1444
+ ),
1445
+ ))
1446
+
1447
+ // Filter Pills
1448
+ var totalPlugins = view.plugins.length
1449
+ var thirdPartyCount = 0
1450
+ var builtinCount = 0
1451
+ for (var i = 0; i < view.plugins.length; i++) {
1452
+ if (view.plugins[i].removable) thirdPartyCount++
1453
+ else builtinCount++
1454
+ }
1455
+
1456
+ elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
1457
+ createElement('button', {
1458
+ type: 'button',
1459
+ key: 'filter-all',
1460
+ className: 'pill' + (view.filter === 'all' ? ' active' : ''),
1461
+ onClick: function () { patch({ filter: 'all' }) },
1462
+ }, '全部 (' + String(totalPlugins) + ')'),
1463
+ createElement('button', {
1464
+ type: 'button',
1465
+ key: 'filter-plugin',
1466
+ className: 'pill' + (view.filter === 'plugin' ? ' active' : ''),
1467
+ onClick: function () { patch({ filter: 'plugin' }) },
1468
+ }, '扩展插件 (' + String(thirdPartyCount) + ')'),
1469
+ createElement('button', {
1470
+ type: 'button',
1471
+ key: 'filter-builtin',
1472
+ className: 'pill' + (view.filter === 'builtin' ? ' active' : ''),
1473
+ onClick: function () { patch({ filter: 'builtin' }) },
1474
+ }, '系统内置 (' + String(builtinCount) + ')')
1475
+ ))
1476
+
1477
+ // Busy banner / Error
1478
+ if (view.busy) {
1479
+ elements.push(createElement('div', { className: 'busy-banner', key: 'busy' },
1480
+ createElement('span', { className: 'spinner', key: 'spin' }),
1481
+ '正在执行 pnpm 操作(可能需要数秒至数分钟,请勿关闭窗口)...'
1482
+ ))
1483
+ }
1484
+ if (view.error !== '') {
1485
+ elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
1486
+ }
1487
+
1488
+ // Update-check status strip: gives the auto-check a visible outcome instead
1489
+ // of hiding it in the footer. Checking spinner; done with updates →
1490
+ // orange; done without updates green; per-plugin query failures surface
1491
+ // on the strip rather than vanishing. Purely informational — the single
1492
+ // re-check entry point is the toolbar「⬆️ 检查更新」button (which forces a
1493
+ // fresh registry query), so no redundant button lives in the strip.
1494
+ if (view.checkingUpdates) {
1495
+ elements.push(createElement('div', { className: 'update-strip checking', key: 'update-checking' },
1496
+ createElement('span', { className: 'spinner', key: 'spin' }),
1497
+ '正在检查插件版本更新…'
1498
+ ))
1499
+ } else if (view.updateChecked) {
1500
+ var upCount = 0
1501
+ var errCount = 0
1502
+ for (var un in view.updates) {
1503
+ if (view.updates[un].updateAvailable === true) upCount++
1504
+ if (view.updates[un].error) errCount++
1505
+ }
1506
+ var stripClass = upCount > 0 ? 'update-strip has-updates'
1507
+ : (errCount > 0 ? 'update-strip has-errors' : 'update-strip ok')
1508
+ var stripText = upCount > 0
1509
+ ? '⬆️ 发现 ' + upCount + ' 个插件有新版本,可点击卡片上的「更新」升级'
1510
+ : (errCount > 0
1511
+ ? '⚠️ ' + errCount + ' 个插件查询版本失败(网络或 registry 不可达)'
1512
+ : ' 已自动检查版本更新,全部为最新版本')
1513
+ elements.push(createElement('div', { className: stripClass, key: 'update-result' },
1514
+ createElement('span', { key: 't' }, stripText)
1515
+ ))
1516
+ }
1517
+
1518
+ // Filter and render rows
1519
+ var needle = view.needle.trim().toLowerCase()
1520
+ var filtered = filterPlugins(view.plugins, view.filter, needle)
1521
+ var rows = []
1522
+ for (var j = 0; j < filtered.length; j++) {
1523
+ var p = filtered[j]
1524
+ rows.push(renderPluginCard(p, view, remove, patch, upgrade))
1525
+ }
1526
+
1527
+ if (rows.length === 0) {
1528
+ rows.push(createElement('div', { className: 'empty', key: 'empty' },
1529
+ createElement('div', null, needle !== '' ? '🔍 无匹配的插件(试试其他关键词)' : '📦 暂无匹配的插件层')
1530
+ ))
1531
+ }
1532
+
1533
+ elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
1534
+
1535
+ // Footer
1536
+ var hintText = view.note !== '' ? view.note : '更改在重启 dsh 后生效(关闭 dsh 进程后重新运行即可)'
1537
+ var hintTitle = view.output !== '' ? 'pnpm 输出:\n' + view.output : undefined
1538
+ elements.push(createElement('div', { className: 'footer', key: 'footer' },
1539
+ createElement('span', { className: 'path', key: 'profile-path', title: view.profileDir }, view.profileDir || 'Profile: 默认'),
1540
+ createElement('span', { className: 'hint', key: 'hint', title: hintTitle }, hintText)
1541
+ ))
1542
+
1543
+ return createElement('div', { key: 'plugins-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
1544
+ }
1545
+
1546
+
1547
+ function renderMcpSection(view, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry) {
1548
+ var children = []
1549
+ var header = createElement('div', { className: 'group-header', key: 'mcp-header', style: { marginTop: 0 } },
1550
+ createElement('span', { className: 'group-title', key: 't' }, '🔌 MCP 配置'),
1551
+ createElement('span', { className: 'group-count', key: 'c' }, String(view.mcpEntries.length) + ' '),
1552
+ createElement('button', {
1553
+ type: 'button',
1554
+ key: 'btn-refresh-mcp',
1555
+ className: 'btn',
1556
+ disabled: view.mcpBusy,
1557
+ onClick: reloadMcp,
1558
+ }, '🔄 刷新'),
1559
+ createElement('button', {
1560
+ type: 'button',
1561
+ key: 'btn-add-mcp',
1562
+ className: 'btn primary',
1563
+ disabled: view.mcpBusy,
1564
+ onClick: function () { openMcpEditor(null) },
1565
+ }, '➕ 添加服务器'),
1566
+ )
1567
+ children.push(header)
1568
+
1569
+ if (view.mcpError !== '') {
1570
+ children.push(createElement('div', { className: 'error', key: 'mcp-error' }, view.mcpError))
1571
+ }
1572
+
1573
+ var rows = []
1574
+ for (var i = 0; i < view.mcpEntries.length; i++) {
1575
+ var entry = view.mcpEntries[i]
1576
+ var tag = createElement('span', { className: 'tag plugin', key: 'mcp-tag-' + entry.id },
1577
+ 'MCP ' + String(entry.serverName || entry.id))
1578
+ var testState = (view.mcpTestState && view.mcpTestState[entry.id]) || null
1579
+ var testIndicator = null
1580
+ if (testState !== null && testState.busy) {
1581
+ testIndicator = createElement('span', { className: 'mcp-test mcp-test-busy', key: 'test-busy' },
1582
+ createElement('span', { className: 'spinner', key: 'sp' }),
1583
+ ' 检测中...')
1584
+ } else if (testState !== null && testState.result !== null && testState.result !== undefined) {
1585
+ var r = testState.result
1586
+ var ok = r.ok === true
1587
+ var label = ok ? '✅ 连通'
1588
+ : ' 不通'
1589
+ var detail = ok
1590
+ ? (r.serverInfo ? (r.serverInfo.name || '') + (r.serverInfo.version ? ' v' + r.serverInfo.version : '') : '') +
1591
+ (typeof r.toolCount === 'number' ? ' · ' + r.toolCount + ' 个工具' : '') +
1592
+ (r.pingOk === false ? ' · 初始化成功但 ping 失败' : '')
1593
+ : (r.error || '连接失败') + (r.ms !== undefined ? ' (' + r.ms + 'ms)' : '')
1594
+ var detailChildren = [
1595
+ createElement('span', { key: 'lbl' }, label + (detail !== '' ? ' ' + detail : '')),
1596
+ ]
1597
+ if (ok && Array.isArray(r.tools) && r.tools.length > 0) {
1598
+ detailChildren.push(createElement('span', { key: 'tools-hint', style: { opacity: 0.75 } },
1599
+ '工具:' + r.tools.join('')))
1600
+ }
1601
+ if (r.warning) {
1602
+ detailChildren.push(createElement('span', { key: 'warn', className: 'mcp-test-warn', title: r.warning },
1603
+ '⚠️ ' + r.warning))
1604
+ }
1605
+ // Cached probe: label when it was taken so a restored status is never
1606
+ // mistaken for a just-run check. Clicking 🔌 测试 refreshes it.
1607
+ if (typeof testState.at === 'number') {
1608
+ detailChildren.push(createElement('span', {
1609
+ key: 'cached-at',
1610
+ className: 'mcp-test-cached',
1611
+ title: '上次检测结果(本地缓存)。点击「🔌 测试」可重新检测。',
1612
+ }, '· 缓存于 ' + formatTestTime(testState.at)))
1613
+ }
1614
+ testIndicator = createElement('div', {
1615
+ className: 'mcp-test mcp-test-list ' + (ok ? 'mcp-test-ok' : 'mcp-test-fail'),
1616
+ key: 'test-result',
1617
+ title: detail,
1618
+ }, detailChildren)
1619
+ } else if (testState !== null && testState.error !== null && testState.error !== undefined) {
1620
+ testIndicator = createElement('span', {
1621
+ className: 'mcp-test mcp-test-fail',
1622
+ key: 'test-error',
1623
+ title: testState.error,
1624
+ }, '❌ ' + testState.error)
1625
+ }
1626
+ var rowChildren = [
1627
+ createElement('div', { className: 'card-header', key: 'h' },
1628
+ createElement('span', { className: 'card-title-text', key: 'name' }, entry.id),
1629
+ tag,
1630
+ createElement('div', { className: 'card-actions', key: 'a' },
1631
+ createElement('button', {
1632
+ type: 'button',
1633
+ className: 'btn sm',
1634
+ disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1635
+ title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : (testState !== null && testState.busy ? '检测中...' : (testState !== null && typeof testState.at === 'number' ? '重新检测该服务器的连通性(当前显示的是缓存结果)' : '测试该服务器的连通性')),
1636
+ onClick: function (id) { return function () { testMcpEntry(id) } }(entry.id),
1637
+ }, testState !== null && testState.busy ? '检测中' : '🔌 测试'),
1638
+ createElement('button', {
1639
+ type: 'button',
1640
+ className: 'btn sm',
1641
+ disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1642
+ title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : undefined,
1643
+ onClick: function (value) { return function () { openMcpEditor(value) } }(entry),
1644
+ }, '编辑'),
1645
+ createElement('button', {
1646
+ type: 'button',
1647
+ className: 'btn danger sm',
1648
+ disabled: view.mcpBusy,
1649
+ onClick: function (id) { return function () { removeMcpEntry(id) } }(entry.id),
1650
+ }, '移除'),
1651
+ ),
1652
+ ),
1653
+ ]
1654
+ if (testIndicator !== null) {
1655
+ rowChildren.push(createElement('div', { className: 'mcp-test-row', key: 'test-row' }, testIndicator))
1656
+ }
1657
+ rows.push(createElement('div', { className: 'card', key: 'mcp-' + entry.id }, rowChildren))
1658
+ }
1659
+ if (rows.length === 0) {
1660
+ rows.push(createElement('div', { className: 'empty', key: 'mcp-empty' },
1661
+ createElement('div', null, '🔌 暂无 MCP 服务器配置')
1662
+ ))
1663
+ }
1664
+ children.push(createElement('div', { className: 'list', key: 'mcp-list' }, rows))
1665
+
1666
+ // Editor form. patchDraft merges against the CURRENT draft in state so
1667
+ // typing across fields never clobbers earlier edits (stale-closure guard).
1668
+ if (view.mcpEditorOpen && view.mcpDraft !== null) {
1669
+ var d = view.mcpDraft
1670
+ var fieldStyle = { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px' }
1671
+ var labelStyle = { fontSize: '11px', color: 'var(--dsw-alias-label-secondary, #666)' }
1672
+ var editor = createElement('div', { className: 'card', key: 'mcp-editor' },
1673
+ createElement('div', { className: 'card-header', key: 'h' },
1674
+ createElement('span', { className: 'card-title-text', key: 't' }, d.isNew ? '添加 MCP 服务器' : '编辑 ' + d.id),
1675
+ ),
1676
+ createElement('div', { style: fieldStyle, key: 'f-id' },
1677
+ createElement('label', { style: labelStyle }, 'ID(唯一标识,[A-Za-z0-9_-])'),
1678
+ createElement('div', { style: { display: 'flex', gap: '6px' } },
1679
+ createElement('input', {
1680
+ className: 'input',
1681
+ value: d.id,
1682
+ disabled: !d.isNew,
1683
+ onChange: function (e) { patchDraft({ id: e.target.value }) },
1684
+ }),
1685
+ d.isNew ? createElement('button', {
1686
+ type: 'button',
1687
+ className: 'btn sm',
1688
+ title: '重新生成一个随机 ID',
1689
+ onClick: function () { patchDraft({ id: generateMcpId(view.mcpEntries) }) },
1690
+ }, '🎲') : null,
1691
+ ),
1692
+ ),
1693
+ createElement('div', { style: fieldStyle, key: 'f-server' },
1694
+ createElement('label', { style: labelStyle }, 'serverName(模型命名空间)'),
1695
+ createElement('input', {
1696
+ className: 'input',
1697
+ value: d.serverName,
1698
+ onChange: function (e) { patchDraft({ serverName: e.target.value }) },
1699
+ }),
1700
+ ),
1701
+ createElement('div', { style: fieldStyle, key: 'f-transport' },
1702
+ createElement('label', { style: labelStyle }, '传输方式'),
1703
+ createElement('select', {
1704
+ className: 'input',
1705
+ value: d.transport,
1706
+ onChange: function (e) { patchDraft({ transport: e.target.value }) },
1707
+ },
1708
+ createElement('option', { value: 'stdio' }, 'stdio(子进程)'),
1709
+ createElement('option', { value: 'streamable-http' }, 'streamable-http(HTTP)'),
1710
+ ),
1711
+ ),
1712
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-cmd' },
1713
+ createElement('label', { style: labelStyle }, 'command(启动命令)'),
1714
+ createElement('input', {
1715
+ className: 'input',
1716
+ value: d.command,
1717
+ placeholder: 'npx -y @modelcontextprotocol/server-github',
1718
+ onChange: function (e) { patchDraft({ command: e.target.value }) },
1719
+ }),
1720
+ ) : createElement('div', { style: fieldStyle, key: 'f-url' },
1721
+ createElement('label', { style: labelStyle }, 'url(MCP 端点)'),
1722
+ createElement('input', {
1723
+ className: 'input',
1724
+ value: d.url,
1725
+ placeholder: 'http://localhost:3000/mcp',
1726
+ onChange: function (e) { patchDraft({ url: e.target.value }) },
1727
+ }),
1728
+ ),
1729
+ d.transport === 'streamable-http' ? createElement('div', { style: fieldStyle, key: 'f-headers' },
1730
+ createElement('label', { style: labelStyle }, 'headers(每行 KEY=VALUE,可选)'),
1731
+ createElement('textarea', {
1732
+ className: 'input',
1733
+ style: { minHeight: '48px' },
1734
+ value: d.headers,
1735
+ onChange: function (e) { patchDraft({ headers: e.target.value, headersChanged: true }) },
1736
+ }),
1737
+ ) : null,
1738
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-args' },
1739
+ createElement('label', { style: labelStyle }, 'args(空格分隔,可选)'),
1740
+ createElement('input', {
1741
+ className: 'input',
1742
+ value: d.args,
1743
+ onChange: function (e) { patchDraft({ args: e.target.value, argsChanged: true }) },
1744
+ }),
1745
+ ) : null,
1746
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-env' },
1747
+ createElement('label', { style: labelStyle }, 'env(每行 KEY=VALUE,可选)'),
1748
+ createElement('textarea', {
1749
+ className: 'input',
1750
+ style: { minHeight: '48px' },
1751
+ value: d.env,
1752
+ onChange: function (e) { patchDraft({ env: e.target.value, envChanged: true }) },
1753
+ }),
1754
+ ) : null,
1755
+ createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 0', marginBottom: '4px', borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(200,200,210,0.3))', paddingTop: '8px' }, key: 'f-reconnect-header' },
1756
+ createElement('input', {
1757
+ type: 'checkbox',
1758
+ id: 'reconnect-toggle',
1759
+ checked: d.reconnectEnabled,
1760
+ onChange: function (e) { patchDraft({ reconnectEnabled: e.target.checked }) },
1761
+ style: { margin: '0' },
1762
+ }),
1763
+ createElement('label', { htmlFor: 'reconnect-toggle', style: { fontSize: '12px', fontWeight: 500, cursor: 'pointer', color: 'var(--dsw-alias-label-primary, #333)' } }, '启用自动重连'),
1764
+ ),
1765
+ d.reconnectEnabled ? createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '8px', paddingLeft: '4px' }, key: 'f-reconnect-fields' },
1766
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-id' },
1767
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'initialDelayMs'),
1768
+ createElement('input', {
1769
+ className: 'input',
1770
+ type: 'number',
1771
+ min: 0,
1772
+ value: d.reconnectInitialDelayMs,
1773
+ onChange: function (e) { patchDraft({ reconnectInitialDelayMs: Number(e.target.value) }) },
1774
+ }),
1775
+ ),
1776
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-md' },
1777
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxDelayMs'),
1778
+ createElement('input', {
1779
+ className: 'input',
1780
+ type: 'number',
1781
+ min: 0,
1782
+ value: d.reconnectMaxDelayMs,
1783
+ onChange: function (e) { patchDraft({ reconnectMaxDelayMs: Number(e.target.value) }) },
1784
+ }),
1785
+ ),
1786
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-ma' },
1787
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxAttempts'),
1788
+ createElement('input', {
1789
+ className: 'input',
1790
+ type: 'number',
1791
+ min: 0,
1792
+ value: d.reconnectMaxAttempts,
1793
+ onChange: function (e) { patchDraft({ reconnectMaxAttempts: Number(e.target.value) }) },
1794
+ }),
1795
+ ),
1796
+ ) : null,
1797
+ createElement('div', { className: 'card-actions', key: 'f-actions', style: { justifyContent: 'flex-end', gap: '6px' } },
1798
+ createElement('button', {
1799
+ type: 'button',
1800
+ className: 'btn',
1801
+ disabled: view.mcpBusy,
1802
+ onClick: closeMcpEditor,
1803
+ }, '取消'),
1804
+ createElement('button', {
1805
+ type: 'button',
1806
+ className: 'btn primary',
1807
+ disabled: view.mcpBusy || d.id.trim() === '' || d.serverName.trim() === '' ||
1808
+ (d.transport === 'stdio' ? d.command.trim() === '' : d.url.trim() === ''),
1809
+ onClick: saveMcpDraft,
1810
+ }, view.mcpBusy ? '保存中...' : '保存'),
1811
+ ),
1812
+ )
1813
+ children.push(editor)
1814
+ }
1815
+
1816
+ return createElement('div', { key: 'mcp-section', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, children)
1817
+ }
1818
+
1819
+ // Characters allowed in an MCP entry id: [A-Za-z0-9_-]. The random part uses
1820
+ // alphanumerics only so the generated id reads cleanly and always matches the
1821
+ // host-side validation regex.
1822
+ var MCP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
1823
+
1824
+ /**
1825
+ * Generate a fresh MCP entry id that is not already used by any listed entry
1826
+ * (an id collision would silently overwrite the existing entry on upsert).
1827
+ */
1828
+ function generateMcpId(entries) {
1829
+ var taken = {}
1830
+ for (var i = 0; i < entries.length; i++) taken[entries[i].id] = true
1831
+ var candidate
1832
+ do {
1833
+ candidate = 'mcp-'
1834
+ for (var j = 0; j < 8; j++) {
1835
+ candidate += MCP_ID_ALPHABET.charAt(Math.floor(Math.random() * MCP_ID_ALPHABET.length))
1836
+ }
1837
+ } while (taken[candidate])
1838
+ return candidate
1839
+ }
1840
+
1841
+ function mergeDraft(draft, partial) {
1842
+ var next = {}
1843
+ for (var k in draft) next[k] = draft[k]
1844
+ for (var pk in partial) next[pk] = partial[pk]
1845
+ return next
1846
+ }
1847
+
1848
+ /** Set one entry's test status inside the shared mcpTestState map. */
1849
+ function mergeTestState(map, id, partial) {
1850
+ var next = {}
1851
+ for (var k in map) next[k] = map[k]
1852
+ next[id] = {}
1853
+ var cur = map[id] || {}
1854
+ for (var ck in cur) next[id][ck] = cur[ck]
1855
+ for (var pk in partial) next[id][pk] = partial[pk]
1856
+ return next
1857
+ }
1858
+ function renderPluginCard(plugin, view, remove, patch, upgrade) {
1859
+ var isConfirming = view.confirming === plugin.name
1860
+
1861
+ var titleChildren = [
1862
+ createElement('span', { className: 'card-title-text', key: 'name' }, plugin.name),
1863
+ ]
1864
+ if (plugin.version) {
1865
+ titleChildren.push(createElement('span', { className: 'tag version', key: 'version' }, 'v' + plugin.version))
1866
+ }
1867
+ var isLocal = Boolean(plugin.localPath)
1868
+ titleChildren.push(createElement('span', {
1869
+ className: plugin.removable ? (isLocal ? 'tag local' : 'tag plugin') : 'tag',
1870
+ key: 'type-tag',
1871
+ },
1872
+ plugin.removable ? (isLocal ? '本地安装' : '包安装') : '内置'
1873
+ ))
1874
+
1875
+ // Remote update badge: only for registry-installed bundles (removable and
1876
+ // not a local path) after a check ran. `updates` is keyed by plugin name.
1877
+ var updateInfo = view.updates && view.updates[plugin.name]
1878
+ if (updateInfo && updateInfo.updateAvailable && updateInfo.latest) {
1879
+ titleChildren.push(createElement('span', {
1880
+ className: 'tag update',
1881
+ key: 'update-badge',
1882
+ title: '远程 registry 有新版本:v' + updateInfo.latest + '(当前 v' + plugin.version + ''
1883
+ + (typeof updateInfo.at === 'number' && updateInfo.at > 0 ? ',检测于 ' + formatTestTime(updateInfo.at) + ',更新后提醒自动消除' : ''),
1884
+ }, '⬆ 有新版本 v' + updateInfo.latest))
1885
+ } else if (updateInfo && updateInfo.error) {
1886
+ titleChildren.push(createElement('span', {
1887
+ className: 'tag update-error',
1888
+ key: 'update-error',
1889
+ title: updateInfo.error,
1890
+ }, '⚠ 更新检查失败'))
1891
+ }
1892
+
1893
+ var headerChildren = [
1894
+ createElement('div', { className: 'card-title', key: 'title' }, titleChildren),
1895
+ ]
1896
+
1897
+ if (plugin.removable && !isConfirming) {
1898
+ var actionChildren = []
1899
+ // 更新 button — only when an update is available and the plugin is a
1900
+ // registry install (not local path).
1901
+ if (updateInfo && updateInfo.updateAvailable && updateInfo.latest && !isLocal) {
1902
+ actionChildren.push(createElement('button', {
1903
+ type: 'button',
1904
+ key: 'btn-upgrade',
1905
+ className: 'btn sm',
1906
+ disabled: view.busy,
1907
+ title: '升级到 v' + updateInfo.latest + '(npm install ' + plugin.name + '@latest)',
1908
+ onClick: function () { upgrade(plugin.name) },
1909
+ }, '⬆ 更新'))
1910
+ }
1911
+ actionChildren.push(createElement('button', {
1912
+ type: 'button',
1913
+ key: 'btn-remove',
1914
+ className: 'btn danger sm',
1915
+ disabled: view.busy,
1916
+ onClick: function () { patch({ confirming: plugin.name }) },
1917
+ }, '卸载'))
1918
+ headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' }, actionChildren))
1919
+ }
1920
+
1921
+ var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
1922
+ var cardChildren = [header]
1923
+
1924
+ if (plugin.localPath) {
1925
+ cardChildren.push(createElement('div', { className: 'plugin-path', key: 'path', title: plugin.localPath },
1926
+ '📂 ' + plugin.localPath
1927
+ ))
1928
+ }
1929
+
1930
+ if (isConfirming) {
1931
+ cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
1932
+ createElement('span', { className: 'confirm-text', key: 'text' }, '⚠️ 确定要卸载该插件吗?'),
1933
+ createElement('div', { className: 'confirm-actions', key: 'actions' },
1934
+ createElement('button', {
1935
+ type: 'button',
1936
+ key: 'btn-confirm',
1937
+ className: 'btn danger-solid sm',
1938
+ disabled: view.busy,
1939
+ onClick: function () { remove(plugin.name) },
1940
+ }, '确认卸载'),
1941
+ createElement('button', {
1942
+ type: 'button',
1943
+ key: 'btn-cancel',
1944
+ className: 'btn sm',
1945
+ disabled: view.busy,
1946
+ onClick: function () { patch({ confirming: null }) },
1947
+ }, '取消')
1948
+ )
1949
+ ))
1950
+ }
1951
+
1952
+ return createElement('div', { key: plugin.name, className: 'card' }, cardChildren)
1953
+ }
1954
+
1955
+ /* ========================================================================== */
1956
+ /* Render Sessions View */
1957
+ /* ========================================================================== */
1958
+
1959
+ /**
1960
+ * Live-session display hint. "Online" means the session is still mounted in
1961
+ * the dsh host's in-memory SessionStore (created or opened at least once in
1962
+ * this process) — NOT that a turn is running. Online sessions can now be
1963
+ * closed directly: the admin panel's "关停并删除" disposes the captured agent
1964
+ * handle (stopping any running turn), removes the session from the store,
1965
+ * and then deletes the log no restart needed.
1966
+ */
1967
+ var LIVE_HINT = '会话在线:仍挂载于 dsh host 内存(本进程内创建或打开过的会话保持在线,不代表正在运行);可直接"关停并删除"(会中断该会话正在进行的对话)'
1968
+
1969
+ /**
1970
+ * Canonical session status. Live takes precedence over archived (a session
1971
+ * that is both live and in the archived set is shown as 会话在线); otherwise
1972
+ * archived wins over ended. Used by both the filter pills and the pill
1973
+ * counts so they can never disagree about membership.
1974
+ */
1975
+ function sessionStatus(s) {
1976
+ if (s.live) return 'live'
1977
+ if (s.archived) return 'archived'
1978
+ return 'ended'
1979
+ }
1980
+
1981
+ function filterSessions(sessions, filter, needle) {
1982
+ var result = []
1983
+ for (var i = 0; i < sessions.length; i++) {
1984
+ var s = sessions[i]
1985
+ if (filter !== 'all' && sessionStatus(s) !== filter) continue
1986
+ if (needle !== '') {
1987
+ var hay = ((s.title || '') + ' ' + (s.summary || '') + ' ' + (s.cwd || '') + ' '
1988
+ + (s.workspaceTitle || '') + ' ' + s.id).toLowerCase()
1989
+ if (hay.indexOf(needle) === -1) continue
1990
+ }
1991
+ result.push(s)
1992
+ }
1993
+ return result
1994
+ }
1995
+
1996
+ /**
1997
+ * Filter plugin layers by the type pill and a fuzzy name/version/path query.
1998
+ * The needle matches case-insensitively against the package name, the
1999
+ * installed version, and the local source path, so "tool" finds
2000
+ * dsh-custom-tool and "0.2" finds version rows.
2001
+ */
2002
+ function filterPlugins(plugins, filter, needle) {
2003
+ var result = []
2004
+ for (var i = 0; i < plugins.length; i++) {
2005
+ var p = plugins[i]
2006
+ if (filter === 'plugin' && !p.removable) continue
2007
+ if (filter === 'builtin' && p.removable) continue
2008
+ if (needle !== '') {
2009
+ var hay = ((p.name || '') + ' ' + (p.version || '') + ' ' + (p.localPath || '')).toLowerCase()
2010
+ if (hay.indexOf(needle) === -1) continue
2011
+ }
2012
+ result.push(p)
2013
+ }
2014
+ return result
2015
+ }/**
2016
+ * Group sessions by their accounting workspace in registry order; sessions
2017
+ * without a workspace trail under an "未分组" bucket. Empty workspaces are
2018
+ * omitted so the user only sees groups that currently hold a session.
2019
+ */
2020
+ function buildSessionGroups(filtered, workspaceOrder) {
2021
+ var byWs = new Map()
2022
+ for (var i = 0; i < workspaceOrder.length; i++) {
2023
+ var ws = workspaceOrder[i]
2024
+ byWs.set(ws.workspaceId, {
2025
+ workspaceId: ws.workspaceId, title: ws.title, path: ws.path, sessions: [],
2026
+ })
2027
+ }
2028
+ var ungrouped = []
2029
+ for (var j = 0; j < filtered.length; j++) {
2030
+ var s = filtered[j]
2031
+ var g = s.workspaceId ? byWs.get(s.workspaceId) : undefined
2032
+ if (g !== undefined) g.sessions.push(s)
2033
+ else ungrouped.push(s)
2034
+ }
2035
+ var groups = []
2036
+ for (var k = 0; k < workspaceOrder.length; k++) {
2037
+ var bucket = byWs.get(workspaceOrder[k].workspaceId)
2038
+ if (bucket !== undefined && bucket.sessions.length > 0) groups.push(bucket)
2039
+ }
2040
+ if (ungrouped.length > 0) {
2041
+ groups.push({ workspaceId: null, title: '未分组', path: null, sessions: ungrouped })
2042
+ }
2043
+ return groups
2044
+ }
2045
+
2046
+ function renderSessionsView(view, patch, reload, act) {
2047
+ var elements = []
2048
+
2049
+ // Toolbar: full-width search with inline icon; the refresh action sits at
2050
+ // the row's end so the search field gets the whole width.
2051
+ elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
2052
+ createElement('div', { className: 'search-wrap session-search', key: 'wrap' },
2053
+ createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
2054
+ createElement('input', {
2055
+ className: 'input',
2056
+ placeholder: '搜索标题、内容摘要、目录或 Session ID...',
2057
+ value: view.needle,
2058
+ onChange: function (e) { patch({ needle: e.target.value }) },
2059
+ }),
2060
+ view.needle !== '' ? createElement('button', {
2061
+ type: 'button',
2062
+ key: 'btn-clear-search',
2063
+ className: 'btn sm',
2064
+ title: '清空搜索',
2065
+ onClick: function () { patch({ needle: '' }) },
2066
+ }, '✕') : null
2067
+ ),
2068
+ createElement('button', {
2069
+ type: 'button',
2070
+ key: 'btn-refresh',
2071
+ className: 'btn',
2072
+ disabled: view.busy,
2073
+ onClick: reload,
2074
+ },
2075
+ view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
2076
+ '🔄 刷新'
2077
+ )
2078
+ ))
2079
+
2080
+ // Filter Pills
2081
+ var totalSessions = view.sessions.length
2082
+ var liveCount = 0
2083
+ var archivedCount = 0
2084
+ var endedCount = 0
2085
+ for (var i = 0; i < view.sessions.length; i++) {
2086
+ var s = view.sessions[i]
2087
+ var st = sessionStatus(s)
2088
+ if (st === 'live') liveCount++
2089
+ else if (st === 'archived') archivedCount++
2090
+ else endedCount++
2091
+ }
2092
+
2093
+ elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
2094
+ createElement('button', {
2095
+ type: 'button',
2096
+ key: 'filter-all',
2097
+ className: 'pill' + (view.filter === 'all' ? ' active' : ''),
2098
+ onClick: function () { patch({ filter: 'all' }) },
2099
+ }, '全部 (' + String(totalSessions) + ')'),
2100
+ createElement('button', {
2101
+ type: 'button',
2102
+ key: 'filter-live',
2103
+ className: 'pill' + (view.filter === 'live' ? ' active' : ''),
2104
+ title: LIVE_HINT,
2105
+ onClick: function () { patch({ filter: 'live' }) },
2106
+ }, '在线 (' + String(liveCount) + ')'),
2107
+ createElement('button', {
2108
+ type: 'button',
2109
+ key: 'filter-archived',
2110
+ className: 'pill' + (view.filter === 'archived' ? ' active' : ''),
2111
+ onClick: function () { patch({ filter: 'archived' }) },
2112
+ }, '已归档 (' + String(archivedCount) + ')'),
2113
+ createElement('button', {
2114
+ type: 'button',
2115
+ key: 'filter-ended',
2116
+ className: 'pill' + (view.filter === 'ended' ? ' active' : ''),
2117
+ onClick: function () { patch({ filter: 'ended' }) },
2118
+ }, '已结束 (' + String(endedCount) + ')')
2119
+ ))
2120
+
2121
+ if (view.error !== '') {
2122
+ elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
2123
+ }
2124
+
2125
+ // Filter then group by workspace (registry order); sessions without an
2126
+ // accounting workspace trail under "未分组", matching the sidebar model.
2127
+ var needle = view.needle.trim().toLowerCase()
2128
+ var filtered = filterSessions(view.sessions, view.filter, needle)
2129
+ var groups = buildSessionGroups(filtered, view.workspaces || [])
2130
+ var rows = []
2131
+ for (var gi = 0; gi < groups.length; gi++) {
2132
+ var g = groups[gi]
2133
+ var groupKey = g.workspaceId === null ? 'ungrouped' : 'ws-' + g.workspaceId
2134
+ rows.push(createElement('div', { className: 'group-header', key: 'header-' + groupKey },
2135
+ createElement('span', { className: 'group-title', key: 'title' },
2136
+ (g.workspaceId === null ? '📂 ' : '📁 ') + (g.title || '未命名')
2137
+ ),
2138
+ createElement('span', { className: 'group-count', key: 'count' }, String(g.sessions.length) + ' 个'),
2139
+ g.path !== null
2140
+ ? createElement('span', { className: 'group-path', key: 'path', title: g.path }, g.path)
2141
+ : null
2142
+ ))
2143
+ for (var si = 0; si < g.sessions.length; si++) {
2144
+ rows.push(renderSessionCard(g.sessions[si], view, act, patch))
2145
+ }
2146
+ }
2147
+
2148
+ if (rows.length === 0) {
2149
+ rows.push(createElement('div', { className: 'empty', key: 'empty' },
2150
+ createElement('div', null, needle !== '' ? '🔍 无匹配的会话内容' : '💬 没有已持久化的会话')
2151
+ ))
2152
+ }
2153
+
2154
+ elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
2155
+
2156
+ // Footer
2157
+ var summaryText = view.busy ? '加载中...'
2158
+ : '' + String(totalSessions) + ' 个会话,当前展示 ' + String(filtered.length) + ' 个'
2159
+
2160
+ elements.push(createElement('div', { className: 'footer', key: 'footer' },
2161
+ createElement('span', { key: 'summary' }, summaryText),
2162
+ createElement('span', { className: 'hint', key: 'hint' }, '会话修改即时同步到侧边栏')
2163
+ ))
2164
+
2165
+ return createElement('div', { key: 'sessions-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
2166
+ }
2167
+
2168
+ function renderSessionCard(session, view, act, patch) {
2169
+ var isConfirming = view.confirming === session.id
2170
+ var dotClass = 'dot' + (session.live ? ' live' : session.archived ? ' archived' : '')
2171
+ var dotTitle = session.live ? LIVE_HINT : session.archived ? '已归档' : '已结束'
2172
+
2173
+ var actions = []
2174
+ if (!isConfirming) {
2175
+ if (!session.archived && !session.live) {
2176
+ actions.push(createElement('button', {
2177
+ type: 'button',
2178
+ className: 'btn sm',
2179
+ key: 'btn-archive',
2180
+ disabled: view.busy,
2181
+ onClick: function () { act('archive', session.id) },
2182
+ }, '归档'))
2183
+ }
2184
+ if (session.archived) {
2185
+ actions.push(createElement('button', {
2186
+ type: 'button',
2187
+ className: 'btn sm',
2188
+ key: 'btn-unarchive',
2189
+ disabled: view.busy,
2190
+ onClick: function () { act('unarchive', session.id) },
2191
+ }, '取消归档'))
2192
+ }
2193
+ if (!session.live) {
2194
+ actions.push(createElement('button', {
2195
+ type: 'button',
2196
+ className: 'btn danger sm',
2197
+ key: 'btn-delete',
2198
+ disabled: view.busy,
2199
+ onClick: function () { patch({ confirming: session.id }) },
2200
+ }, '删除'))
2201
+ } else {
2202
+ // Online sessions can now be closed through the captured AgentHandle
2203
+ // (closeSession disposes the live agent/session first, so the log can
2204
+ // be removed without resurrection). This stops a running conversation,
2205
+ // so it is a destructive action with its own confirm flow.
2206
+ actions.push(createElement('button', {
2207
+ type: 'button',
2208
+ className: 'btn danger sm',
2209
+ key: 'btn-close',
2210
+ disabled: view.busy,
2211
+ title: '关停该在线会话(停止其 agent 运行)并永久删除日志记录',
2212
+ onClick: function () { patch({ confirming: session.id }) },
2213
+ }, '关停并删除'))
2214
+ }
2215
+ }
2216
+
2217
+ var titleChildren = [
2218
+ createElement('span', { className: dotClass, title: dotTitle, key: 'dot' }),
2219
+ createElement('div', { className: 'card-title', key: 'title' },
2220
+ createElement('span', { className: 'card-title-text', key: 'name', title: session.title || session.cwd },
2221
+ session.title || (session.cwd ? baseName(session.cwd) : '未命名会话')
2222
+ ),
2223
+ session.live ? createElement('span', { className: 'tag live', key: 'tag-live', title: LIVE_HINT }, '会话在线')
2224
+ : session.archived ? createElement('span', { className: 'tag archived', key: 'tag-archived' }, '已归档')
2225
+ : null,
2226
+ session.messageCount > 0 ? createElement('span', { className: 'tag turns', key: 'tag-turns' }, String(session.messageCount) + ' 条消息') : null
2227
+ ),
2228
+ ]
2229
+ var headerChildren
2230
+ if (actions.length > 0) {
2231
+ headerChildren = titleChildren.concat([createElement('div', { className: 'card-actions', key: 'actions' }, actions)])
2232
+ } else {
2233
+ headerChildren = titleChildren
2234
+ }
2235
+
2236
+ var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
2237
+ var cardChildren = [header]
2238
+
2239
+ // Content summary block
2240
+ if (session.summary) {
2241
+ cardChildren.push(createElement('div', { className: 'card-summary', key: 'summary', title: session.summary },
2242
+ createElement('span', { className: 'summary-icon', key: 'icon' }, '💬'),
2243
+ createElement('span', { className: 'summary-text', key: 'text' }, session.summary)
2244
+ ))
2245
+ } else if (session.summaryError) {
2246
+ cardChildren.push(createElement('div', { className: 'error', key: 'summary-error' },
2247
+ '摘要读取失败:' + session.summaryError
2248
+ ))
2249
+ }
2250
+
2251
+ // Sub metadata line
2252
+ var subChildren = [
2253
+ createElement('span', { className: 'card-sub-item', title: session.cwd || '无工作目录', key: 'path' },
2254
+ '📁 ' + (session.cwd ? baseName(session.cwd) + ' (' + session.cwd + ')' : '(无工作目录)')
2255
+ ),
2256
+ createElement('span', { key: 'sep1' }, '·'),
2257
+ createElement('span', { className: 'card-sub-item', key: 'time' }, '🕒 ' + formatDate(session.createdAt)),
2258
+ ]
2259
+ if (session.id) {
2260
+ subChildren.push(createElement('span', { key: 'sep2' }, '·'))
2261
+ subChildren.push(createElement('span', { className: 'card-sub-item session-id-badge', key: 'id', title: '会话 ID: ' + session.id },
2262
+ '🆔 ' + session.id.slice(0, 8)
2263
+ ))
2264
+ }
2265
+
2266
+ var sub = createElement('div', { className: 'card-sub', key: 'sub' }, subChildren)
2267
+ cardChildren.push(sub)
2268
+
2269
+ if (isConfirming) {
2270
+ cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
2271
+ createElement('span', { className: 'confirm-text', key: 'text' },
2272
+ session.live
2273
+ ? '⚠️ 将关停该在线会话(正在运行则会中断)并永久删除记录与日志,确定?'
2274
+ : '⚠️ 确定永久删除该会话记录及日志文件?'),
2275
+ createElement('div', { className: 'confirm-actions', key: 'actions' },
2276
+ createElement('button', {
2277
+ type: 'button',
2278
+ key: 'btn-confirm',
2279
+ className: 'btn danger-solid sm',
2280
+ disabled: view.busy,
2281
+ onClick: function () { act(session.live ? 'closeSession' : 'deleteSession', session.id) },
2282
+ }, '确认删除'),
2283
+ createElement('button', {
2284
+ type: 'button',
2285
+ key: 'btn-cancel',
2286
+ className: 'btn sm',
2287
+ disabled: view.busy,
2288
+ onClick: function () { patch({ confirming: null }) },
2289
+ }, '取消')
2290
+ )
2291
+ ))
2292
+ }
2293
+
2294
+ return createElement('div', { key: session.id, className: 'card' }, cardChildren)
2295
+ }
2296
+
2297
+ /* ========================================================================== */
2298
+ /* Plugin Entrypoint */
2299
+ /* ========================================================================== */
2300
+
2301
+ function apply(ctx) {
2302
+ injectStyles()
2303
+
2304
+ var call = function (method, args) {
2305
+ return ctx.connection.rpc.call('/api', method, { args: args })
2306
+ }
2307
+
2308
+ // Sidebar right-click menus: delete session (after archive) on session
2309
+ // rows, reveal in explorer on workspace rows. Mounted once for the page
2310
+ // lifetime; dispose when the plugin unmounts.
2311
+ // The sessions service lets us nudge the sidebar list after a delete so
2312
+ // the removed session disappears immediately instead of lingering in
2313
+ // "未分组" until the next reload.
2314
+ var refreshSessions = null
2315
+ try {
2316
+ var sessionsSvc = ctx.get && ctx.get('sessions')
2317
+ if (sessionsSvc && typeof sessionsSvc.refresh === 'function') {
2318
+ refreshSessions = function () { sessionsSvc.refresh().catch(function () {}) }
2319
+ }
2320
+ } catch (e) { refreshSessions = null }
2321
+ var disposeSidebar = ctx.effect(function () { return setupMenuInjection(call, refreshSessions) })
2322
+
2323
+ // One management-center entry keeps related local administration together;
2324
+ // its tab container mounts only the selected panel.
2325
+ ctx.slots.inject('settings.section', function () {
2326
+ return ctx.slots.register({
2327
+ name: 'settings.section',
2328
+ id: 'plugin-admin',
2329
+ order: 25,
2330
+ label: '管理中心',
2331
+ inject: function () { return { call: call, refreshSessions: refreshSessions } },
2332
+ }, AdminCenterSection)
2333
+ })
2334
+ }
2335
+
2336
+ module.exports = { apply: apply, inject: ['slots', 'connection'] }
2337
+ return module.exports
2338
+ } });
2339
+