@lolkda/dsh-prompt-manager 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +606 -0
  3. package/client/client.js +2320 -0
  4. package/cordis.patch.yml +20 -0
  5. package/environment.md +24 -0
  6. package/lib/entries.js +303 -0
  7. package/lib/entries.js.map +1 -0
  8. package/lib/guard.js +134 -0
  9. package/lib/guard.js.map +1 -0
  10. package/lib/index.js +959 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/net.js +179 -0
  13. package/lib/net.js.map +1 -0
  14. package/lib/pack.js +327 -0
  15. package/lib/pack.js.map +1 -0
  16. package/lib/probe.js +251 -0
  17. package/lib/probe.js.map +1 -0
  18. package/lib/routes.js +718 -0
  19. package/lib/routes.js.map +1 -0
  20. package/lib/scripts.js +803 -0
  21. package/lib/scripts.js.map +1 -0
  22. package/lib/source.js +308 -0
  23. package/lib/source.js.map +1 -0
  24. package/lib/store.js +223 -0
  25. package/lib/store.js.map +1 -0
  26. package/lib/subscriptions.js +269 -0
  27. package/lib/subscriptions.js.map +1 -0
  28. package/lib/sync.js +646 -0
  29. package/lib/sync.js.map +1 -0
  30. package/lib/types/entries.d.ts +194 -0
  31. package/lib/types/entries.d.ts.map +1 -0
  32. package/lib/types/guard.d.ts +63 -0
  33. package/lib/types/guard.d.ts.map +1 -0
  34. package/lib/types/index.d.ts +176 -0
  35. package/lib/types/index.d.ts.map +1 -0
  36. package/lib/types/net.d.ts +81 -0
  37. package/lib/types/net.d.ts.map +1 -0
  38. package/lib/types/pack.d.ts +298 -0
  39. package/lib/types/pack.d.ts.map +1 -0
  40. package/lib/types/probe.d.ts +150 -0
  41. package/lib/types/probe.d.ts.map +1 -0
  42. package/lib/types/routes.d.ts +85 -0
  43. package/lib/types/routes.d.ts.map +1 -0
  44. package/lib/types/scripts.d.ts +455 -0
  45. package/lib/types/scripts.d.ts.map +1 -0
  46. package/lib/types/source.d.ts +194 -0
  47. package/lib/types/source.d.ts.map +1 -0
  48. package/lib/types/store.d.ts +140 -0
  49. package/lib/types/store.d.ts.map +1 -0
  50. package/lib/types/subscriptions.d.ts +204 -0
  51. package/lib/types/subscriptions.d.ts.map +1 -0
  52. package/lib/types/sync.d.ts +248 -0
  53. package/lib/types/sync.d.ts.map +1 -0
  54. package/package.json +100 -0
@@ -0,0 +1,2320 @@
1
+ /**
2
+ * Browser half of @lolkda/dsh-prompt-manager: the「提示词」settings section.
3
+ *
4
+ * The section has two views. The landing view lists the prompt entries: each row
5
+ * is a title, an injection switch, and a kebab menu carrying that row's actions
6
+ * (edit, delete). Choosing 编辑 opens the editor view — the whole section area
7
+ * becomes the markdown editor and its preview, with a return control — so the
8
+ * panel's narrow column is spent on one thing at a time.
9
+ *
10
+ * The index (title, order, enabled) rides the shared settings transport through
11
+ * `ctx.settingsScope`; the bodies ride the plugin's own `/dsh-prompt-manager`
12
+ * route, because they are markdown files on disk.
13
+ *
14
+ * Built in the client module system's lazy-CJS factory format by hand, so the
15
+ * package needs no bundler: the factory only requests modules the shell's
16
+ * platform table already carries (`react` and the UI primitives).
17
+ *
18
+ * Registered as `exports.name` / `exports.inject` / `exports.apply`, the same
19
+ * shape every other client bundle exports. The factory id is the package name —
20
+ * that is how the client module system ties this bundle to its Loader row.
21
+ */
22
+ window.__ModuleLoader__.load({
23
+ id: '@lolkda/dsh-prompt-manager',
24
+ factory: (require) => {
25
+ const React = require('react')
26
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
27
+
28
+ const h = React.createElement
29
+
30
+ /** Settings namespace carrying the entry index. Mirrors the Host constant. */
31
+ const NAMESPACE = 'prompt-manager'
32
+
33
+ /** Prefix of the Host route serving the body files. */
34
+ const ROUTE = '/dsh-prompt-manager'
35
+
36
+ /** Placement the section claims in the settings navigation. */
37
+ const SECTION_ORDER = 60
38
+
39
+ /**
40
+ * The composer slot the preset chip claims: the tool row's trailing group,
41
+ * in front of the model selector, which is where a person looks when they
42
+ * want to change what this conversation is working with.
43
+ */
44
+ const PRESET_SLOT = 'conversation.input.right'
45
+
46
+ /**
47
+ * `activePreset` value meaning "no preset": each entry's own switch decides.
48
+ * Mirrors the Host, which reads an empty or unknown id the same way.
49
+ */
50
+ const NO_PRESET = ''
51
+
52
+ /** Style tag identity, so unload removes exactly this bundle's styles. */
53
+ const STYLE_ID = 'dsh-prompt-manager/Section.css'
54
+
55
+ /**
56
+ * This plugin's own repository, shown on the section's heading line.
57
+ *
58
+ * The bundle is hand-written and has no build step that could import
59
+ * `package.json`, so the two are kept in step by hand — this mirrors the
60
+ * `repository` field there.
61
+ */
62
+ const REPO_SLUG = 'lolkda/dsh-prompt-manager'
63
+ const REPO_URL = `https://github.com/${REPO_SLUG}`
64
+
65
+ // Every value below is copied from the shell's own settings pages — the
66
+ // plugin page (heading/intro/tab row/card list) and the model page (row
67
+ // card, chip, row buttons, dashed add buttons, status dot) — so this section
68
+ // sits in the same visual language, and in the same theme, as the pages it
69
+ // appears beside. Only aliases that exist in the shell's theme are used: an
70
+ // unknown custom property would silently fall back and leave the element
71
+ // unstyled.
72
+ const CSS = `
73
+ .dsh-prompt-manager{max-width:760px;min-width:0;display:flex;flex-direction:column;gap:12px;padding-bottom:12px;color:var(--dsw-alias-label-primary)}
74
+ .dsh-prompt-manager__heading{margin:0;font-size:18px;font-weight:600}
75
+ .dsh-prompt-manager__intro{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
76
+ .dsh-prompt-manager__note{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
77
+ .dsh-prompt-manager__status{margin:0;font-size:12px;line-height:1.5}
78
+ .dsh-prompt-manager__status--error{color:var(--dsw-alias-label-error)}
79
+ .dsh-prompt-manager__status--ok{color:var(--dsw-alias-state-success-primary)}
80
+ .dsh-prompt-manager__block{display:flex;flex-direction:column;gap:12px;min-width:0}
81
+ .dsh-prompt-manager__head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
82
+ .dsh-prompt-manager__headTitle{margin:0;font-size:18px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
83
+ .dsh-prompt-manager__headSpacer{flex:1 1 auto}
84
+
85
+ /* the tab row: plain labels, the active one underlined, hairline underneath */
86
+ .dsh-prompt-manager__tabs{display:flex;align-items:flex-end;gap:22px;margin-top:2px;border-bottom:.5px solid var(--dsw-alias-border-l2)}
87
+ .dsh-prompt-manager__tab{position:relative;background:0 0;border:0;padding:7px 1px 9px;font:inherit;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);cursor:pointer}
88
+ .dsh-prompt-manager__tab:hover{color:var(--dsw-alias-label-primary)}
89
+ .dsh-prompt-manager__tab--active{color:var(--dsw-alias-label-primary)}
90
+ .dsh-prompt-manager__tab--active:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;border-radius:2px 2px 0 0;background:var(--dsw-alias-label-primary)}
91
+
92
+ /* one card per entry, matching the model page's row card */
93
+ .dsh-prompt-manager__list{display:flex;flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;min-width:0}
94
+ .dsh-prompt-manager__card{display:flex;align-items:center;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;min-width:0}
95
+ .dsh-prompt-manager__cardBody{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}
96
+ .dsh-prompt-manager__cardMain{flex:1 1 auto;display:flex;flex-direction:column;gap:2px;min-width:0;margin:0;padding:0;background:0 0;border:0;text-align:left;color:inherit;font:inherit;cursor:pointer}
97
+ .dsh-prompt-manager__cardMain:disabled{cursor:default}
98
+ .dsh-prompt-manager__cardSide{display:inline-flex;align-items:center;gap:8px;margin-left:auto;flex:0 0 auto}
99
+ .dsh-prompt-manager__title{font-size:14px;font-weight:500;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
100
+ .dsh-prompt-manager__meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
101
+ .dsh-prompt-manager__sourceLink{color:var(--dsw-alias-link);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
102
+ .dsh-prompt-manager__sourceLink:hover{color:var(--dsw-alias-label-primary)}
103
+ .dsh-prompt-manager__iconButton{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:14px;background:0 0;color:var(--dsw-alias-label-secondary);cursor:pointer}
104
+ .dsh-prompt-manager__iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
105
+ .dsh-prompt-manager__inlineMenu{display:flex;flex-direction:column;gap:2px;padding:6px;margin:0 0 6px 28px;border:.5px solid var(--dsw-alias-border-l3);border-radius:12px}
106
+ .dsh-prompt-manager__inlineMenu button{background:0 0;border:0;padding:6px 8px;border-radius:8px;text-align:left;color:inherit;font:inherit;font-size:13px;cursor:pointer}
107
+ .dsh-prompt-manager__inlineMenu button:hover{background:var(--dsw-alias-interactive-bg-hover)}
108
+ .dsh-prompt-manager__inlineMenu button:disabled{color:var(--dsw-alias-label-quaternary);cursor:default}
109
+
110
+ /* chip and status dot, as on the model page */
111
+ .dsh-prompt-manager__badge{flex:0 0 auto;padding:1px 6px;border:.5px solid var(--dsw-alias-border-l3);border-radius:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
112
+ .dsh-prompt-manager__dot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:var(--dsw-alias-state-success-primary)}
113
+ .dsh-prompt-manager__dot--idle{background:var(--dsw-alias-label-quaternary)}
114
+ .dsh-prompt-manager__dot--pending{background:var(--dsw-alias-state-warn-primary)}
115
+ .dsh-prompt-manager__dot--error{background:var(--dsw-alias-state-error-primary)}
116
+
117
+ /* buttons: the shell's own settings pages hand-roll these three shapes */
118
+ .dsh-prompt-manager__button{box-sizing:border-box;height:28px;padding:0 10px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:.5px solid var(--dsw-alias-border-l3);border-radius:14px;background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1;white-space:nowrap;cursor:pointer}
119
+ .dsh-prompt-manager__button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
120
+ .dsh-prompt-manager__button:disabled{color:var(--dsw-alias-label-quaternary);border-color:var(--dsw-alias-border-l4);cursor:default}
121
+ .dsh-prompt-manager__button--primary{height:32px;padding:0 14px;border-radius:16px;border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-size:13px}
122
+ .dsh-prompt-manager__button--primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
123
+ .dsh-prompt-manager__button--primary:disabled{background:var(--dsw-alias-button-primary-dimmed);color:var(--dsw-alias-label-quaternary)}
124
+ .dsh-prompt-manager__button--danger{color:var(--dsw-alias-state-error-primary)}
125
+ .dsh-prompt-manager__button--danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
126
+ .dsh-prompt-manager__button--ghost{border-color:transparent;color:var(--dsw-alias-label-secondary)}
127
+ .dsh-prompt-manager__addRow{display:flex;flex-wrap:wrap;gap:10px}
128
+ .dsh-prompt-manager__addButton{flex:1 1 0;min-width:180px;height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px dashed var(--dsw-alias-border-l3);border-radius:16px;background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer}
129
+ .dsh-prompt-manager__addButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
130
+ .dsh-prompt-manager__addButton:disabled{color:var(--dsw-alias-label-quaternary);cursor:default}
131
+ /* The import control is a real file input behind the same dashed pill, so the
132
+ picker opens natively and the browser keeps its own file-selection UI. */
133
+ .dsh-prompt-manager__fileLabel{cursor:pointer}
134
+ .dsh-prompt-manager__fileLabel[aria-disabled='true']{color:var(--dsw-alias-label-quaternary);cursor:default}
135
+ .dsh-prompt-manager__fileInput{display:none}
136
+
137
+ /* surfaces and fields */
138
+ .dsh-prompt-manager__surface{display:flex;flex-direction:column;gap:14px;padding:14px 16px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);min-width:0}
139
+ .dsh-prompt-manager__fields{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;min-width:0}
140
+ .dsh-prompt-manager__field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
141
+ .dsh-prompt-manager__field--grow{flex:1 1 200px}
142
+ .dsh-prompt-manager__field--order{flex:0 0 96px}
143
+ .dsh-prompt-manager__field input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
144
+ .dsh-prompt-manager__field input:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
145
+ .dsh-prompt-manager__field input:disabled{color:var(--dsw-alias-label-quaternary)}
146
+ .dsh-prompt-manager__pane{display:flex;flex-direction:column;gap:6px;min-width:0}
147
+ .dsh-prompt-manager__paneLabel{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
148
+ .dsh-prompt-manager__pane textarea{box-sizing:border-box;width:100%;min-height:300px;resize:vertical;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:18px}
149
+ .dsh-prompt-manager__pane textarea:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
150
+ .dsh-prompt-manager__preview{box-sizing:border-box;width:100%;max-height:320px;overflow:auto;padding:12px;border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;overflow-wrap:anywhere}
151
+ .dsh-prompt-manager__preview pre{white-space:pre-wrap;overflow-wrap:anywhere}
152
+ .dsh-prompt-manager__preview code{overflow-wrap:anywhere}
153
+ .dsh-prompt-manager__preview>*{max-width:100%}
154
+ .dsh-prompt-manager__previewRaw{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:18px}
155
+ .dsh-prompt-manager__actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
156
+ .dsh-prompt-manager__empty{margin:0;padding:12px;border:1px dashed var(--dsw-alias-border-l3);border-radius:8px;text-align:center;font-size:13px;color:var(--dsw-alias-label-tertiary)}
157
+
158
+ /* one card per subscription source */
159
+ .dsh-prompt-manager__source{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;min-width:0}
160
+ .dsh-prompt-manager__sourceHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
161
+ .dsh-prompt-manager__sourceRepo{font-size:14px;font-weight:500;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
162
+ .dsh-prompt-manager__sourceActions{display:inline-flex;align-items:center;gap:4px;margin-left:auto}
163
+ .dsh-prompt-manager__changes{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);min-width:0}
164
+ .dsh-prompt-manager__change{display:flex;align-items:center;gap:8px;font-size:12px;line-height:18px;min-width:0}
165
+ .dsh-prompt-manager__changePath{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px}
166
+ .dsh-prompt-manager__delta{flex:0 0 auto;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
167
+
168
+ /* one combo's member checklist */
169
+ .dsh-prompt-manager__members{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);min-width:0;max-height:320px;overflow:auto}
170
+ .dsh-prompt-manager__member{display:flex;align-items:center;gap:8px;font-size:12px;line-height:18px;min-width:0}
171
+ .dsh-prompt-manager__memberLabel{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
172
+ .dsh-prompt-manager__memberId{flex:0 0 auto;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px;color:var(--dsw-alias-label-tertiary)}
173
+
174
+ /* the composer chip: one compact control in the tool row below the input box */
175
+ .dsh-prompt-manager__presetChip{box-sizing:border-box;max-width:240px;height:28px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:14px;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1;white-space:nowrap;overflow:hidden;cursor:pointer}
176
+ .dsh-prompt-manager__presetChip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
177
+ .dsh-prompt-manager__presetChip--on{color:var(--dsw-alias-label-primary)}
178
+ .dsh-prompt-manager__presetChipLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
179
+
180
+ /* the shell marks keyboard focus with a 2px business-colour ring; keep that */
181
+ .dsh-prompt-manager__tab:focus-visible,.dsh-prompt-manager__button:focus-visible,.dsh-prompt-manager__addButton:focus-visible,.dsh-prompt-manager__cardMain:focus-visible,.dsh-prompt-manager__iconButton:focus-visible,.dsh-prompt-manager__presetChip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
182
+ `.trim()
183
+
184
+ /**
185
+ * Request one Host route.
186
+ * @param method - HTTP method.
187
+ * @param path - path below {@link ROUTE}, leading slash included.
188
+ * @param payload - JSON body to send, when the method takes one.
189
+ * @returns the parsed JSON response.
190
+ */
191
+ async function request(method, path, payload) {
192
+ const init = { method }
193
+ if (payload !== undefined) {
194
+ init.headers = { 'content-type': 'application/json' }
195
+ init.body = JSON.stringify(payload)
196
+ }
197
+ const response = await fetch(ROUTE + path, init)
198
+ const data = await response.json().catch(() => ({}))
199
+ if (!response.ok) {
200
+ const detail = data && typeof data.error === 'string' ? data.error : `${method} ${path} failed (${response.status})`
201
+ const error = new Error(detail)
202
+ error.status = response.status
203
+ // A refused save carries the run that refused it, so the editor can show
204
+ // what the script actually printed rather than only that it was refused.
205
+ error.report = data && typeof data === 'object' ? data.report : undefined
206
+ throw error
207
+ }
208
+ return data
209
+ }
210
+
211
+ /** Where a variable's value came from, in the page's own words. */
212
+ const SOURCE_LABELS = { environment: '系统', config: '配置', probe: '探测', script: '脚本' }
213
+
214
+ /** Valid variable names, mirroring the registry's own rule. */
215
+ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
216
+
217
+ /**
218
+ * The source a brand-new script starts from.
219
+ *
220
+ * It runs as it stands — printing a JSON object whose keys become variable
221
+ * names — so the first thing a person sees is a working example rather than
222
+ * an empty box and a paragraph explaining what to type.
223
+ */
224
+ /**
225
+ * The source a new script starts from.
226
+ *
227
+ * It runs as it stands, and its one key is deliberately not a name the
228
+ * plugin already provides (`pwsh` / `bash` / `git` / `node` / `python` and
229
+ * the four environment facts): a template that claims one of those would be
230
+ * refused on save, and one that claims an arbitrary tool would leave a
231
+ * `(not installed)` variable behind on every machine that lacks it.
232
+ */
233
+ const SCRIPT_TEMPLATE = [
234
+ '// 打印一个 JSON 对象:键就是提示词里能用的变量名 {{名字}}。',
235
+ '// 这个脚本跑在一个独立子进程里,超时、报错都不会影响 DSH 本身。',
236
+ 'const { execSync } = require("node:child_process")',
237
+ '',
238
+ 'const firstLine = (command) => {',
239
+ ' try {',
240
+ ' return execSync(command, { encoding: "utf8" }).trim().split("\\n")[0]',
241
+ ' } catch {',
242
+ ' return "(not installed)"',
243
+ ' }',
244
+ '}',
245
+ '',
246
+ 'console.log(JSON.stringify({',
247
+ ' // 换成你要探测的东西,一行一个变量,例如:',
248
+ ' // toolchain_java: firstLine("java -version"),',
249
+ ' node_version: process.version.replace(/^v/, ""),',
250
+ '}))',
251
+ '',
252
+ ].join('\n')
253
+
254
+ /**
255
+ * Every `{{...}}` a body carries, as written.
256
+ *
257
+ * The inner text is returned whatever it holds, because the point of this
258
+ * scan is to catch a reference that would fail assembly — and a name that is
259
+ * not a valid variable name fails it just as hard as one that is not
260
+ * registered.
261
+ * @param text - markdown body.
262
+ * @returns the reference texts, in order, without repeats.
263
+ */
264
+ function referencesIn(text) {
265
+ const found = []
266
+ const pattern = /\{\{([^{}]*)\}\}/g
267
+ let match = pattern.exec(text)
268
+ while (match !== null) {
269
+ if (!found.includes(match[1])) found.push(match[1])
270
+ match = pattern.exec(text)
271
+ }
272
+ return found
273
+ }
274
+
275
+ /**
276
+ * A stored timestamp as the page shows it: local time, falling back to the
277
+ * raw value when it cannot be parsed (an empty string means "never").
278
+ * @param value - ISO-8601 text from the Host.
279
+ * @returns the text to render.
280
+ */
281
+ function stamp(value) {
282
+ const parsed = new Date(value)
283
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
284
+ }
285
+
286
+ /**
287
+ * Whether a script source parses, checked here so the editor can say so
288
+ * while somebody types. The Host re-checks before writing, and this is only
289
+ * ever a hint: a browser without `new Function` simply reports nothing.
290
+ * @param source - script text.
291
+ * @returns the parser's complaint, or `null` when it parses.
292
+ */
293
+ function syntaxProblem(source) {
294
+ try {
295
+ // The compilation is what is wanted here, never the call.
296
+ Function(source)
297
+ return null
298
+ } catch (error) {
299
+ const message = error && error.message ? error.message : String(error)
300
+ // A host that forbids eval refuses the compilation itself. That is not a
301
+ // syntax error, so the editor says nothing rather than something wrong.
302
+ if (error && (error.name === 'EvalError' || /Content Security Policy|unsafe-eval/i.test(message))) return null
303
+ return message
304
+ }
305
+ }
306
+
307
+ /** Read the entry index out of a settings snapshot. */
308
+ function entriesOf(snapshot) {
309
+ const value = snapshot && snapshot.value
310
+ const list = value && Array.isArray(value.entries) ? value.entries : []
311
+ return list.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string')
312
+ }
313
+
314
+ /** Read the configured subscriptions out of a settings snapshot. */
315
+ function sourcesOf(snapshot) {
316
+ const value = snapshot && snapshot.value
317
+ const list = value && Array.isArray(value.sources) ? value.sources : []
318
+ return list.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string')
319
+ }
320
+
321
+ /**
322
+ * Read the configured presets out of a settings snapshot.
323
+ *
324
+ * Normalized the same way {@link sourcesOf} is, because this feeds a control
325
+ * that renders on the composer: a hand-edited document must not be able to
326
+ * make the chip throw where the input box lives.
327
+ * @param snapshot - the settings snapshot.
328
+ * @returns one record per preset, with a display name and a member list.
329
+ */
330
+ function presetsOf(snapshot) {
331
+ const value = snapshot && snapshot.value
332
+ const list = value && Array.isArray(value.presets) ? value.presets : []
333
+ return list
334
+ .filter((preset) => preset !== null && typeof preset === 'object' && typeof preset.id === 'string')
335
+ .map((preset) => ({
336
+ id: preset.id,
337
+ name: typeof preset.name === 'string' && preset.name.length > 0 ? preset.name : preset.id,
338
+ entries: Array.isArray(preset.entries)
339
+ ? preset.entries.filter((id) => typeof id === 'string')
340
+ : [],
341
+ }))
342
+ }
343
+
344
+ /**
345
+ * The preset in force, or `null` when each entry's own switch decides.
346
+ *
347
+ * An id that names nothing reads as no preset at all — exactly what the Host
348
+ * does with it, so the chip can never claim a preset the prompt is not using.
349
+ * @param snapshot - the settings snapshot.
350
+ * @param presets - the presets already read out of it.
351
+ * @returns the active preset, or `null`.
352
+ */
353
+ function activePresetOf(snapshot, presets) {
354
+ const value = snapshot && snapshot.value
355
+ const wanted = value && typeof value.activePreset === 'string' ? value.activePreset : NO_PRESET
356
+ if (wanted.length === 0) return null
357
+ return presets.find((preset) => preset.id === wanted) ?? null
358
+ }
359
+
360
+ /** Whether two preset lists differ in anything the Host stores. */
361
+ function presetsDiffer(before, after) {
362
+ if (before.length !== after.length) return true
363
+ for (let index = 0; index < before.length; index += 1) {
364
+ const left = before[index]
365
+ const right = after[index]
366
+ if (left.id !== right.id || left.name !== right.name) return true
367
+ if (left.entries.length !== right.entries.length) return true
368
+ for (let member = 0; member < left.entries.length; member += 1) {
369
+ if (left.entries[member] !== right.entries[member]) return true
370
+ }
371
+ }
372
+ return false
373
+ }
374
+
375
+ /**
376
+ * The paths a check staged, whatever shape the answer arrived in.
377
+ *
378
+ * A proxy that answers with something that is not the Host's JSON would
379
+ * otherwise turn every consumer of the outcome into a `TypeError`, which the
380
+ * page can only report as gibberish.
381
+ * @param outcome - the check answer.
382
+ * @returns the staged paths, or an empty list.
383
+ */
384
+ function changedPaths(outcome) {
385
+ const changes = outcome && Array.isArray(outcome.changes) ? outcome.changes : []
386
+ return changes
387
+ .map((change) => (change !== null && typeof change === 'object' ? change.path : undefined))
388
+ .filter((path) => typeof path === 'string')
389
+ }
390
+
391
+ /**
392
+ * Whether an entry came from a subscription.
393
+ *
394
+ * Tested by value, not by presence: the settings schema resolves a missing
395
+ * source to an empty string, so `'source' in entry` is true for every local
396
+ * entry as well — which used to badge them all as subscribed.
397
+ */
398
+ function isSubscribed(entry) {
399
+ return typeof entry.source === 'string' && entry.source.length > 0
400
+ }
401
+
402
+ /** The next free placement for an added entry. */
403
+ function nextOrder(entries) {
404
+ let highest = 0
405
+ for (const entry of entries) if (typeof entry.order === 'number' && entry.order > highest) highest = entry.order
406
+ return highest + 10
407
+ }
408
+
409
+ /** Whether two indexes differ in anything the Host stores. */
410
+ function indexChanged(before, after) {
411
+ if (before.length !== after.length) return true
412
+ for (let index = 0; index < before.length; index += 1) {
413
+ const left = before[index]
414
+ const right = after[index]
415
+ if (left.id !== right.id || left.title !== right.title || left.order !== right.order || left.enabled !== right.enabled) return true
416
+ }
417
+ return false
418
+ }
419
+
420
+ /** One shell icon, or nothing when that primitive is missing. */
421
+ /**
422
+ * Whether a primitives export can be rendered.
423
+ *
424
+ * React's `memo` and `forwardRef` return objects rather than functions — the
425
+ * shell's `MarkdownText` is a `memo` — so a `typeof === 'function'` check
426
+ * silently drops exactly the primitive a bundle most wants and degrades to
427
+ * plain text. Anything React can mount is accepted here.
428
+ */
429
+ function isComponent(value) {
430
+ if (typeof value === 'function') return true
431
+ return typeof value === 'object' && value !== null && typeof value.$$typeof === 'symbol'
432
+ }
433
+
434
+ /** One shell icon, or nothing when that primitive is missing. */
435
+ function icon(name) {
436
+ const component = primitives[name]
437
+ return isComponent(component) ? h(component, {}) : undefined
438
+ }
439
+
440
+ /** A switch control, falling back to a checkbox on a host without the primitive. */
441
+ function Switch(props) {
442
+ if (isComponent(primitives.Switch)) {
443
+ return h(primitives.Switch, {
444
+ checked: props.checked,
445
+ label: props.label,
446
+ disabled: props.disabled,
447
+ onChange: props.onChange,
448
+ })
449
+ }
450
+ return h('input', {
451
+ type: 'checkbox',
452
+ checked: props.checked,
453
+ disabled: props.disabled,
454
+ 'aria-label': props.label,
455
+ onChange: props.onChange,
456
+ })
457
+ }
458
+
459
+ /**
460
+ * One button, drawn in the shape the shell's own settings pages use: a
461
+ * hairline outline by default, a filled primary, a borderless ghost, and a
462
+ * danger label that turns red on hover. The shell's `Button` primitive is the
463
+ * larger, dialog-sized control, which is why the settings pages hand-roll
464
+ * these instead — this section follows them so it looks like a sibling page.
465
+ * @param props - label, variant, disabled flag, and click handler.
466
+ */
467
+ function Button(props) {
468
+ const variant = props.variant ?? 'secondary'
469
+ return h('button', {
470
+ type: 'button',
471
+ className: `dsh-prompt-manager__button dsh-prompt-manager__button--${variant}`,
472
+ disabled: props.disabled,
473
+ onClick: props.onClick,
474
+ }, props.children)
475
+ }
476
+
477
+ /**
478
+ * One dashed "add something" control, the shape the shell puts under a list.
479
+ * @param props - label, disabled flag, click handler, and an optional glyph.
480
+ */
481
+ function AddButton(props) {
482
+ return h('button', {
483
+ type: 'button',
484
+ className: 'dsh-prompt-manager__addButton',
485
+ disabled: props.disabled,
486
+ onClick: props.onClick,
487
+ }, [
488
+ icon(props.icon ?? 'IconPlusOutline16') ?? h('span', { key: 'glyph' }, '+'),
489
+ h('span', { key: 'label' }, props.children),
490
+ ])
491
+ }
492
+
493
+ /**
494
+ * A menu over the shell's `Menu`, anchored to a control of the caller's
495
+ * choosing, with an inline list as the fallback for a host that predates that
496
+ * primitive.
497
+ * @param props - open state, items, selection handler, label, and anchor node.
498
+ */
499
+ function SlotMenu(props) {
500
+ if (isComponent(primitives.Menu)) {
501
+ return h(primitives.Menu, {
502
+ open: props.open,
503
+ onClose: props.onClose,
504
+ items: props.items,
505
+ onSelect: props.onSelect,
506
+ // Portal: the settings panel scrolls, and a menu rendered inside it
507
+ // would be clipped by the panel's own overflow — and the composer's own
508
+ // stacking is no more forgiving.
509
+ portal: true,
510
+ closeOnPointerLeave: true,
511
+ anchor: props.anchor,
512
+ })
513
+ }
514
+ if (!props.open) return props.anchor
515
+ return h(React.Fragment, null, props.anchor, h('div', { className: 'dsh-prompt-manager__inlineMenu' },
516
+ props.items.map((item) => h('button', {
517
+ key: item.id,
518
+ type: 'button',
519
+ disabled: item.disabled === true,
520
+ onClick: () => props.onSelect(item.id),
521
+ }, item.label))))
522
+ }
523
+
524
+ /**
525
+ * A kebab menu over {@link SlotMenu}: the row action control the list pages
526
+ * use.
527
+ * @param props - open state, items, selection handler, and accessibility label.
528
+ */
529
+ function RowMenu(props) {
530
+ const anchor = h('button', {
531
+ type: 'button',
532
+ className: 'dsh-prompt-manager__iconButton',
533
+ 'aria-label': props.label,
534
+ 'aria-haspopup': 'menu',
535
+ 'aria-expanded': props.open,
536
+ onClick: (event) => {
537
+ event.stopPropagation()
538
+ props.onToggle()
539
+ },
540
+ }, icon('IconEllipsisOutline16') ?? '⋯')
541
+
542
+ return h(SlotMenu, { ...props, anchor })
543
+ }
544
+
545
+ /** Markdown preview over the shell's own renderer. */
546
+ function Preview(props) {
547
+ if (props.text.length === 0) return h('div', { className: 'dsh-prompt-manager__empty' }, '(空正文:该条目不会注入任何内容)')
548
+ if (isComponent(primitives.MarkdownText)) {
549
+ return h(primitives.MarkdownText, {
550
+ text: props.text,
551
+ streaming: false,
552
+ labels: { code: { copyLabel: '复制', copiedLabel: '已复制' }, footnotes: '脚注' },
553
+ })
554
+ }
555
+ // Last resort on a host without the primitive: the raw source, clearly
556
+ // marked as unrendered so it cannot be mistaken for a real preview.
557
+ return h('pre', { className: 'dsh-prompt-manager__previewRaw' }, props.text)
558
+ }
559
+
560
+ /**
561
+ * What one script run produced, as the editor shows it.
562
+ *
563
+ * A test run is the same execution a save performs, so this is not a
564
+ * simulation: it is the variables the script would supply, the exit code it
565
+ * exited with, and everything that went wrong — which is exactly what a
566
+ * person needs before deciding to save.
567
+ * @param props - the run report, or null when nothing has been run.
568
+ */
569
+ function RunReport(props) {
570
+ const report = props.report
571
+ if (report === null || report === undefined) return null
572
+ const names = report.variables === undefined || report.variables === null ? [] : Object.keys(report.variables)
573
+ return h('div', { className: 'dsh-prompt-manager__changes' }, [
574
+ h('span', {
575
+ key: 'head',
576
+ className: report.ok === true
577
+ ? 'dsh-prompt-manager__status dsh-prompt-manager__status--ok'
578
+ : 'dsh-prompt-manager__status dsh-prompt-manager__status--error',
579
+ }, [
580
+ report.ok === true ? `这次会提供 ${String(names.length)} 个变量` : '这次运行没有产出可用的变量',
581
+ `退出码 ${report.exitCode === undefined ? '无' : String(report.exitCode)}`,
582
+ `${String(report.ms)}ms`,
583
+ ].join(' · ')),
584
+ ...names.map((name) => h('div', { key: name, className: 'dsh-prompt-manager__change' }, [
585
+ h('span', { key: 'token', className: 'dsh-prompt-manager__changePath' }, `{{${name}}}`),
586
+ h('span', { key: 'value', className: 'dsh-prompt-manager__delta' }, report.variables[name]),
587
+ ])),
588
+ ...(report.problems ?? []).map((problem, index) => h('span', {
589
+ key: `problem-${String(index)}`,
590
+ className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error',
591
+ }, problem)),
592
+ ...(report.warnings ?? []).map((warning, index) => h('span', {
593
+ key: `warning-${String(index)}`,
594
+ className: 'dsh-prompt-manager__note',
595
+ }, warning)),
596
+ typeof report.stderr === 'string' && report.stderr.length > 0
597
+ ? h('pre', { key: 'stderr', className: 'dsh-prompt-manager__previewRaw' }, report.stderr)
598
+ : null,
599
+ ])
600
+ }
601
+
602
+ /**
603
+ * The composer chip: which prompt preset is in force, and the control that
604
+ * switches it.
605
+ *
606
+ * It rides the composer tool row so the set can be swapped from beside the
607
+ * input box instead of through Settings. Everything it shows comes from the
608
+ * settings namespace itself — the presets, and which one is active — so a
609
+ * change made on the settings page shows up here with no wiring of its own,
610
+ * and a switch here is one settings write that the next model step already
611
+ * honours.
612
+ * @param props - composed slot props carrying the bound settings scope.
613
+ * @returns the chip element tree.
614
+ */
615
+ function PresetChip(props) {
616
+ const scope = props.scope
617
+ const subscribe = React.useCallback((listener) => scope.subscribe(listener), [scope])
618
+ const getSnapshot = React.useCallback(() => scope.getSnapshot(), [scope])
619
+ const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
620
+ const presets = React.useMemo(() => presetsOf(snapshot), [snapshot])
621
+ const active = React.useMemo(() => activePresetOf(snapshot, presets), [snapshot, presets])
622
+ const [open, setOpen] = React.useState(false)
623
+ const [failed, setFailed] = React.useState(null)
624
+
625
+ // Nothing to say until the namespace has answered, and nothing to offer on
626
+ // a host that does not serve it at all.
627
+ if (snapshot.status !== 'ready') return null
628
+
629
+ const writable = snapshot.writable !== false && snapshot.mode !== 'memory'
630
+ const current = active === null ? NO_PRESET : active.id
631
+ const items = presets.length === 0
632
+ ? [{ id: 'no-presets', label: '还没有组合:设置 → 提示词 → 组合 里新建', disabled: true }]
633
+ : [
634
+ { id: NO_PRESET, label: `不用组合(按每条开关)${current === NO_PRESET ? '(当前)' : ''}` },
635
+ ...presets.map((preset) => ({
636
+ id: preset.id,
637
+ label: `${preset.name}(${String(preset.entries.length)} 条)${current === preset.id ? '(当前)' : ''}`,
638
+ disabled: !writable,
639
+ })),
640
+ ]
641
+
642
+ const choose = (id) => {
643
+ setOpen(false)
644
+ if (id === current || id === 'no-presets') return
645
+ // The chip keeps showing the namespace's own answer, so a refused write
646
+ // leaves the old preset in place rather than a selection that never
647
+ // reached the Host.
648
+ Promise.resolve(scope.set('activePreset', id))
649
+ .then(() => { setFailed(null) })
650
+ .catch((error) => {
651
+ setFailed(error && error.message ? error.message : String(error))
652
+ })
653
+ }
654
+
655
+ return h(SlotMenu, {
656
+ open,
657
+ items,
658
+ onClose: () => setOpen(false),
659
+ onSelect: choose,
660
+ anchor: h('button', {
661
+ type: 'button',
662
+ className: active === null
663
+ ? 'dsh-prompt-manager__presetChip'
664
+ : 'dsh-prompt-manager__presetChip dsh-prompt-manager__presetChip--on',
665
+ 'aria-label': '切换提示词组合',
666
+ 'aria-haspopup': 'menu',
667
+ 'aria-expanded': open,
668
+ title: failed !== null
669
+ ? `切换失败:${failed}`
670
+ : writable
671
+ ? '切换提示词组合(下一个模型步骤生效)'
672
+ : '这个页面是只读的:局域网地址打开时设置通道退化为内存模式',
673
+ onClick: () => setOpen((wasOpen) => !wasOpen),
674
+ }, h('span', { className: 'dsh-prompt-manager__presetChipLabel' },
675
+ failed !== null ? '提示词 · 切换失败' : `提示词 · ${active === null ? '按开关' : active.name}`)),
676
+ })
677
+ }
678
+
679
+ /**
680
+ * Render the settings section: the entry list, the editor page, or the
681
+ * subscription sources.
682
+ * @param props - composed slot props carrying the bound settings scope.
683
+ * @returns the section element tree.
684
+ */
685
+ function PromptSection(props) {
686
+ const scope = props.scope
687
+ // The scope's methods read their own state off `this`, so they are bound
688
+ // here instead of being handed to React as bare references.
689
+ const subscribe = React.useCallback((listener) => scope.subscribe(listener), [scope])
690
+ const getSnapshot = React.useCallback(() => scope.getSnapshot(), [scope])
691
+ const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
692
+ const entries = React.useMemo(() => entriesOf(snapshot), [snapshot])
693
+ const configured = React.useMemo(() => sourcesOf(snapshot), [snapshot])
694
+ const presets = React.useMemo(() => presetsOf(snapshot), [snapshot])
695
+ const activePreset = React.useMemo(() => activePresetOf(snapshot, presets), [snapshot, presets])
696
+ const [view, setView] = React.useState('list')
697
+ const [filter, setFilter] = React.useState('all')
698
+ const [selectedId, setSelectedId] = React.useState(null)
699
+ const [menuFor, setMenuFor] = React.useState(null)
700
+ const [draft, setDraft] = React.useState(null)
701
+ const [saved, setSaved] = React.useState(null)
702
+ const [busy, setBusy] = React.useState(false)
703
+ const [status, setStatus] = React.useState(null)
704
+ const [store, setStore] = React.useState(null)
705
+ const [sources, setSources] = React.useState([])
706
+ const [report, setReport] = React.useState(null)
707
+ const [picked, setPicked] = React.useState([])
708
+ const [newRepo, setNewRepo] = React.useState('')
709
+ const [newRef, setNewRef] = React.useState('main')
710
+ const [newMirror, setNewMirror] = React.useState('')
711
+ const [variableReport, setVariableReport] = React.useState(null)
712
+ const [scriptDraft, setScriptDraft] = React.useState(null)
713
+ const [scriptSaved, setScriptSaved] = React.useState(null)
714
+ const [runReport, setRunReport] = React.useState(null)
715
+ const [caret, setCaret] = React.useState(null)
716
+ const [presetDraft, setPresetDraft] = React.useState(null)
717
+
718
+ /**
719
+ * The configured sources by slug: a subscribed entry carries its source's
720
+ * slug, not its repository, so the row needs this to link where the body
721
+ * actually comes from.
722
+ */
723
+ const sourceById = React.useMemo(
724
+ () => new Map(configured.map((source) => [source.id, source])),
725
+ [configured],
726
+ )
727
+
728
+ const refreshStore = React.useCallback(() => {
729
+ return request('GET', '/status')
730
+ .then((next) => { setStore(next) })
731
+ .catch((error) => { setStore({ writable: false, dir: '', ids: [], error: error.message }) })
732
+ }, [])
733
+
734
+ const refreshSources = React.useCallback(() => {
735
+ return request('GET', '/sources')
736
+ .then((next) => { setSources(Array.isArray(next.sources) ? next.sources : []) })
737
+ .catch((error) => { setStatus({ kind: 'error', text: error.message }) })
738
+ }, [])
739
+
740
+ /** Read the variables in force and the scripts that supply them. */
741
+ const refreshVariables = React.useCallback(() => {
742
+ return request('GET', '/variables')
743
+ .then((next) => { setVariableReport(next) })
744
+ .catch((error) => {
745
+ setVariableReport({ variables: [], scripts: [], error: error.message })
746
+ })
747
+ }, [])
748
+
749
+ React.useEffect(() => {
750
+ void refreshStore()
751
+ void refreshSources()
752
+ void refreshVariables()
753
+ }, [refreshSources, refreshStore, refreshVariables])
754
+
755
+ const writable = snapshot.writable !== false && (store === null || store.writable !== false)
756
+ // A brand-new entry has no saved side yet and is therefore always dirty.
757
+ const dirty = draft !== null && (saved === null
758
+ || draft.title !== saved.title || draft.order !== saved.order || draft.body !== saved.body)
759
+ // A subscribed body belongs to upstream: it is shown, never edited here.
760
+ const subscribedDraft = draft !== null && draft.source === 'subscribed'
761
+ // A built-in body ships with the plugin; editing and saving overrides it.
762
+ const builtinDraft = draft !== null && draft.source === 'builtin'
763
+ // The variables in force, and the scripts that supply them, as the Host
764
+ // last reported them. The list is what a body may reference, so it is also
765
+ // what the editor uses to catch a reference that would fail assembly.
766
+ const variables = variableReport !== null && Array.isArray(variableReport.variables) ? variableReport.variables : []
767
+ const scripts = variableReport !== null && Array.isArray(variableReport.scripts) ? variableReport.scripts : []
768
+ const knownVariables = variables.map((variable) => variable.name)
769
+ // A reference the prompt cannot resolve makes assembly throw, so the body
770
+ // editor says so first: a name that is not registered, and a reference
771
+ // whose shape is not a variable name at all.
772
+ const referencedInDraft = draft === null ? [] : referencesIn(draft.body)
773
+ const unknownInDraft = referencedInDraft.filter((name) => !knownVariables.includes(name) && VARIABLE_NAME.test(name))
774
+ const malformedInDraft = referencedInDraft.filter((name) => !VARIABLE_NAME.test(name))
775
+
776
+ /** Load one entry's body and enter the editor page. */
777
+ const open = React.useCallback(async (id) => {
778
+ setBusy(true)
779
+ setSelectedId(id)
780
+ try {
781
+ const body = await request('GET', `/body/${encodeURIComponent(id)}`)
782
+ const known = entries.find((entry) => entry.id === id)
783
+ const next = {
784
+ id,
785
+ title: known !== undefined ? known.title : id,
786
+ order: known !== undefined && typeof known.order === 'number' ? known.order : nextOrder(entries),
787
+ body: typeof body.body === 'string' ? body.body : '',
788
+ source: body.source,
789
+ fileSha1: typeof body.fileSha1 === 'string' ? body.fileSha1 : null,
790
+ isNew: false,
791
+ }
792
+ setDraft(next)
793
+ setSaved(next)
794
+ setStatus(null)
795
+ setView('editor')
796
+ } catch (error) {
797
+ setStatus({ kind: 'error', text: error.message })
798
+ setDraft(null)
799
+ setSaved(null)
800
+ } finally {
801
+ setBusy(false)
802
+ }
803
+ }, [entries])
804
+
805
+ const select = React.useCallback((entry) => {
806
+ if (entry.id === selectedId && view === 'editor') return
807
+ void open(entry.id)
808
+ }, [open, selectedId, view])
809
+
810
+ /** Leave the editor page, keeping nothing unsaved. */
811
+ const back = React.useCallback(() => {
812
+ if (draft !== null && dirty && !window.confirm(draft.isNew ? '放弃这条新提示词?' : '放弃未保存的修改?')) return
813
+ if (draft !== null && draft.isNew) {
814
+ setDraft(null)
815
+ setSaved(null)
816
+ setSelectedId(null)
817
+ } else {
818
+ setDraft(saved)
819
+ }
820
+ setStatus(null)
821
+ setView('list')
822
+ }, [draft, dirty, saved])
823
+
824
+ const add = React.useCallback(async () => {
825
+ // The registry keeps a bounded number of sections, so an entry past the
826
+ // cap would save a body and then never reach the prompt. Refusing here is
827
+ // the only place a person can be told why.
828
+ const cap = store !== null && typeof store.maxEntries === 'number' ? store.maxEntries : null
829
+ if (cap !== null && entries.length >= cap) {
830
+ setStatus({ kind: 'error', text: `最多 ${String(cap)} 条提示词,先删掉一条,或关掉不用的条目(关掉的条目也占位置)。` })
831
+ return
832
+ }
833
+ setBusy(true)
834
+ try {
835
+ const allocated = await request('POST', '/id', { title: '新提示词' })
836
+ const next = {
837
+ id: allocated.id,
838
+ title: '新提示词',
839
+ order: nextOrder(entries),
840
+ body: '# 新提示词\n\n在这里写要注入的提示词正文。\n',
841
+ source: 'empty',
842
+ fileSha1: null,
843
+ isNew: true,
844
+ }
845
+ setSelectedId(allocated.id)
846
+ setDraft(next)
847
+ setSaved(null)
848
+ setStatus({ kind: 'info', text: `已分配 id ${allocated.id},保存后才会出现在提示词里。` })
849
+ setView('editor')
850
+ } catch (error) {
851
+ setStatus({ kind: 'error', text: error.message })
852
+ } finally {
853
+ setBusy(false)
854
+ }
855
+ }, [entries, store])
856
+
857
+ const save = React.useCallback(async () => {
858
+ if (draft === null) return
859
+ setBusy(true)
860
+ try {
861
+ const written = await request('PUT', `/body/${encodeURIComponent(draft.id)}`, {
862
+ body: draft.body,
863
+ fileSha1: draft.fileSha1,
864
+ })
865
+ // The body is on disk, so the fence the editor holds has to be the one
866
+ // that write produced — a later save that reused the old hash would be
867
+ // refused as stale, and the editor would be stuck on 409.
868
+ const next = {
869
+ ...draft,
870
+ source: typeof written.source === 'string' ? written.source : draft.source,
871
+ fileSha1: typeof written.fileSha1 === 'string' ? written.fileSha1 : null,
872
+ isNew: false,
873
+ }
874
+ setDraft(next)
875
+ setSaved(next)
876
+ const nextEntries = draft.isNew
877
+ ? [...entries, { id: draft.id, title: draft.title, order: draft.order, enabled: true }]
878
+ : entries.map((entry) => entry.id === draft.id
879
+ ? { ...entry, title: draft.title, order: draft.order }
880
+ : entry)
881
+ if (indexChanged(entries, nextEntries)) {
882
+ try {
883
+ await scope.set('entries', nextEntries)
884
+ } catch (error) {
885
+ // Half a save is worth saying precisely: the prose is stored, only
886
+ // the index write failed, and pressing save again finishes it.
887
+ setStatus({ kind: 'error', text: `正文已保存,但索引没写进去:${error.message}(再点一次「保存修改」即可)` })
888
+ await refreshStore()
889
+ return
890
+ }
891
+ }
892
+ setStatus({ kind: 'info', text: '已保存,下一个模型步骤生效。' })
893
+ await refreshStore()
894
+ } catch (error) {
895
+ setStatus({ kind: 'error', text: error.message })
896
+ } finally {
897
+ setBusy(false)
898
+ }
899
+ }, [draft, entries, refreshStore, scope])
900
+
901
+ // ── presets ──────────────────────────────────────────────────────────────
902
+
903
+ /**
904
+ * Enter the preset editor: an existing one, or an empty draft.
905
+ *
906
+ * A new preset has no id yet, because the id has to be one no other preset
907
+ * holds and only the Host knows that — so it is allocated on save, by the
908
+ * same route the entry editor uses.
909
+ * @param preset - the preset to edit, or `null` for a new one.
910
+ */
911
+ const openPreset = React.useCallback((preset) => {
912
+ setPresetDraft(preset === null
913
+ ? { id: null, name: '', members: [] }
914
+ : { id: preset.id, name: preset.name, members: [...preset.entries] })
915
+ setStatus(null)
916
+ setView('preset')
917
+ }, [])
918
+
919
+ /** Leave the preset editor, keeping nothing unsaved. */
920
+ const closePreset = React.useCallback(() => {
921
+ setPresetDraft(null)
922
+ setStatus(null)
923
+ setView('presets')
924
+ }, [])
925
+
926
+ /**
927
+ * Write the draft over the preset list.
928
+ *
929
+ * The list is one settings field and the Host replaces it whole, so this
930
+ * is the complete next list rather than a patch — the same shape the entry
931
+ * index is written in.
932
+ */
933
+ const savePreset = React.useCallback(async () => {
934
+ if (presetDraft === null) return
935
+ const label = presetDraft.name.trim()
936
+ if (label.length === 0) {
937
+ setStatus({ kind: 'error', text: '先给组合起个名字。' })
938
+ return
939
+ }
940
+ if (presetDraft.members.length === 0
941
+ && !window.confirm('这个组合一条提示词都没选:启用它就是一条都不注入。仍然保存?')) return
942
+ setBusy(true)
943
+ try {
944
+ const held = presetDraft.id
945
+ const id = held !== null ? held : (await request('POST', '/preset/id', { title: label })).id
946
+ const next = held !== null
947
+ ? presets.map((preset) => (preset.id === held ? { id, name: label, entries: presetDraft.members } : preset))
948
+ : [...presets, { id, name: label, entries: presetDraft.members }]
949
+ if (presetsDiffer(presets, next)) await scope.set('presets', next)
950
+ setPresetDraft({ id, name: label, members: presetDraft.members })
951
+ setStatus({ kind: 'info', text: `组合「${label}」已保存。` })
952
+ setView('presets')
953
+ } catch (error) {
954
+ setStatus({ kind: 'error', text: error.message })
955
+ } finally {
956
+ setBusy(false)
957
+ }
958
+ }, [presetDraft, presets, scope])
959
+
960
+ const removePreset = React.useCallback(async (preset) => {
961
+ if (!window.confirm(`删除组合「${preset.name}」?`)) return
962
+ setBusy(true)
963
+ try {
964
+ await scope.set('presets', presets.filter((candidate) => candidate.id !== preset.id))
965
+ // Deleting the preset in force has to clear the selection too: the Host
966
+ // falls back to the entry switches either way, but leaving the id behind
967
+ // would make every page report a preset that is not there.
968
+ if (activePreset !== null && activePreset.id === preset.id) await scope.set('activePreset', NO_PRESET)
969
+ setStatus({ kind: 'info', text: `已删除组合「${preset.name}」。` })
970
+ } catch (error) {
971
+ setStatus({ kind: 'error', text: error.message })
972
+ } finally {
973
+ setBusy(false)
974
+ }
975
+ }, [activePreset, presets, scope])
976
+
977
+ const activatePreset = React.useCallback(async (id) => {
978
+ setBusy(true)
979
+ try {
980
+ await scope.set('activePreset', id)
981
+ setStatus({
982
+ kind: 'info',
983
+ text: id === NO_PRESET
984
+ ? '已取消组合:回到每条提示词自己的开关。'
985
+ : '已切换组合,下一个模型步骤生效。',
986
+ })
987
+ } catch (error) {
988
+ setStatus({ kind: 'error', text: error.message })
989
+ } finally {
990
+ setBusy(false)
991
+ }
992
+ }, [scope])
993
+
994
+ // ── preset packs ─────────────────────────────────────────────────────────
995
+
996
+ /**
997
+ * Hand one preset to the browser as a file.
998
+ *
999
+ * The Host builds the pack, so the body it carries is the one this machine
1000
+ * would actually inject — not whatever the page last fetched. A failure is
1001
+ * shown rather than swallowed: a download that silently does nothing is
1002
+ * indistinguishable from a browser that ignored it.
1003
+ */
1004
+ const exportPreset = React.useCallback(async (preset) => {
1005
+ setBusy(true)
1006
+ setStatus(null)
1007
+ try {
1008
+ const response = await fetch(`${ROUTE}/pack/export?preset=${encodeURIComponent(preset.id)}`)
1009
+ const text = await response.text()
1010
+ if (!response.ok) {
1011
+ let detail = `导出失败(HTTP ${String(response.status)})`
1012
+ try {
1013
+ const payload = JSON.parse(text)
1014
+ if (typeof payload.error === 'string') detail = payload.error
1015
+ } catch { /* not JSON: the status line stands */ }
1016
+ throw new Error(detail)
1017
+ }
1018
+ const name = `prompt-manager-pack-${preset.id}.json`
1019
+ saveFile(name, text)
1020
+ setStatus({ kind: 'info', text: `已导出组合「${preset.name}」:${name}` })
1021
+ } catch (error) {
1022
+ setStatus({ kind: 'error', text: error.message })
1023
+ } finally {
1024
+ setBusy(false)
1025
+ }
1026
+ }, [])
1027
+
1028
+ /**
1029
+ * Read one file back into this deployment.
1030
+ *
1031
+ * The page sends the text as it found it: validating a pack belongs where
1032
+ * the bodies are written, so a refusal names the entry that is wrong
1033
+ * instead of the first thing a browser-side check happened to notice.
1034
+ */
1035
+ const importPackFile = React.useCallback(async (file) => {
1036
+ if (file === undefined || file === null) return
1037
+ setBusy(true)
1038
+ setStatus(null)
1039
+ try {
1040
+ const text = await file.text()
1041
+ let payload
1042
+ try {
1043
+ payload = JSON.parse(text)
1044
+ } catch {
1045
+ throw new Error(`${file.name} 不是 JSON 文件`)
1046
+ }
1047
+ const report = await request('POST', '/pack/import', payload)
1048
+ setStatus({ kind: 'info', text: describeImport(report) })
1049
+ } catch (error) {
1050
+ setStatus({ kind: 'error', text: error.message })
1051
+ } finally {
1052
+ setBusy(false)
1053
+ }
1054
+ }, [])
1055
+
1056
+ /** What an import did, in one line, including everything it could not do. */
1057
+ function describeImport(report) {
1058
+ const entries = Array.isArray(report && report.entries) ? report.entries : []
1059
+ const preset = report && typeof report.preset === 'object' && report.preset !== null ? report.preset : { name: '?' }
1060
+ const parts = [`已导入 ${String(entries.length)} 条,组合「${String(preset.name)}」`]
1061
+ const renames = Array.isArray(report.renamed) ? report.renamed : []
1062
+ if (renames.length > 0) {
1063
+ const shown = renames.slice(0, 3).map((rename) => `${rename.from}→${rename.to}`).join('、')
1064
+ parts.push(`${String(renames.length)} 条换了 id(${shown}${renames.length > 3 ? '…' : ''})`)
1065
+ }
1066
+ const noBody = Array.isArray(report.noBody) ? report.noBody : []
1067
+ if (noBody.length > 0) parts.push(`${noBody.join('、')} 的正文来自订阅源,去「来源」页配上那个仓库就会出现`)
1068
+ const dropped = Array.isArray(report.sourceDropped) ? report.sourceDropped : []
1069
+ if (dropped.length > 0) parts.push(`${dropped.join('、')} 的 id 在本机被占用,已作为普通条目导入(不跟随上游)`)
1070
+ const missing = Array.isArray(report.missingMembers) ? report.missingMembers : []
1071
+ if (missing.length > 0) parts.push(`组合里还记着 ${missing.join('、')},本机没有这些条目`)
1072
+ const unregistered = Array.isArray(report.unregistered) ? report.unregistered : []
1073
+ if (unregistered.length > 0) {
1074
+ parts.push(`${unregistered.map((name) => `{{${name}}}`).join(' ')} 还没注册,会按字面量渲染`)
1075
+ }
1076
+ return `${parts.join(';')}。导入不会自动启用这个组合。`
1077
+ }
1078
+
1079
+ /** Give a text file to the browser's downloader. */
1080
+ function saveFile(name, text) {
1081
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
1082
+ const anchor = document.createElement('a')
1083
+ anchor.href = url
1084
+ anchor.download = name
1085
+ // Firefox only follows a click on an anchor that is in the document.
1086
+ document.body.appendChild(anchor)
1087
+ anchor.click()
1088
+ anchor.remove()
1089
+ URL.revokeObjectURL(url)
1090
+ }
1091
+
1092
+ // ── subscriptions ────────────────────────────────────────────────────────
1093
+
1094
+ /** Add a source: the Host validates the three fields and allocates the slug. */
1095
+ const addSource = React.useCallback(async () => {
1096
+ const repo = newRepo.trim()
1097
+ if (repo.length === 0) {
1098
+ setStatus({ kind: 'error', text: '先填一个 owner/name。' })
1099
+ return
1100
+ }
1101
+ const ref = newRef.trim().length === 0 ? 'main' : newRef.trim()
1102
+ const mirror = newMirror.trim()
1103
+ setBusy(true)
1104
+ try {
1105
+ const allocated = await request('POST', '/sources', { repo, ref, mirror })
1106
+ // The Host echoes what it validated; prefer it, so the stored record is
1107
+ // exactly the one it accepted rather than this form's raw text.
1108
+ const next = [...configured, {
1109
+ id: allocated.id,
1110
+ repo: typeof allocated.repo === 'string' ? allocated.repo : repo,
1111
+ ref: typeof allocated.ref === 'string' ? allocated.ref : ref,
1112
+ mirror: typeof allocated.mirror === 'string' ? allocated.mirror : mirror,
1113
+ enabled: true,
1114
+ }]
1115
+ await scope.set('sources', next)
1116
+ setNewRepo('')
1117
+ setNewMirror('')
1118
+ setStatus({ kind: 'info', text: `已添加来源 ${allocated.id},正在检查…` })
1119
+ const outcome = await request('POST', `/sources/${encodeURIComponent(allocated.id)}/check`)
1120
+ setReport(outcome)
1121
+ setPicked(changedPaths(outcome))
1122
+ await refreshSources()
1123
+ } catch (error) {
1124
+ setStatus({ kind: 'error', text: error.message })
1125
+ } finally {
1126
+ setBusy(false)
1127
+ }
1128
+ }, [configured, newMirror, newRef, newRepo, refreshSources, scope])
1129
+
1130
+ const checkSource = React.useCallback(async (slug) => {
1131
+ setBusy(true)
1132
+ try {
1133
+ const outcome = await request('POST', `/sources/${encodeURIComponent(slug)}/check`)
1134
+ const changes = Array.isArray(outcome.changes) ? outcome.changes : []
1135
+ setReport({ ...outcome, changes })
1136
+ setPicked(changedPaths(outcome))
1137
+ setStatus({
1138
+ kind: 'info',
1139
+ text: outcome.upToDate === true ? `「${slug}」已是最新。` : `「${slug}」有 ${String(changes.length)} 个文件可以更新。`,
1140
+ })
1141
+ await refreshSources()
1142
+ } catch (error) {
1143
+ setStatus({ kind: 'error', text: error.message })
1144
+ } finally {
1145
+ setBusy(false)
1146
+ }
1147
+ }, [refreshSources])
1148
+
1149
+ const applySource = React.useCallback(async (slug) => {
1150
+ setBusy(true)
1151
+ try {
1152
+ const outcome = await request('POST', `/sources/${encodeURIComponent(slug)}/apply`, { files: picked })
1153
+ setReport(null)
1154
+ setPicked([])
1155
+ const applied = Array.isArray(outcome.applied) ? outcome.applied : []
1156
+ setStatus({ kind: 'info', text: `「${slug}」应用了 ${String(applied.length)} 个文件;新条目默认关闭,打开后才会注入。` })
1157
+ await refreshSources()
1158
+ } catch (error) {
1159
+ setStatus({ kind: 'error', text: error.message })
1160
+ } finally {
1161
+ setBusy(false)
1162
+ }
1163
+ }, [picked, refreshSources])
1164
+
1165
+ const revertSource = React.useCallback(async (slug) => {
1166
+ if (!window.confirm(`把「${slug}」还原到上次应用之前的正文?`)) return
1167
+ setBusy(true)
1168
+ try {
1169
+ const outcome = await request('POST', `/sources/${encodeURIComponent(slug)}/revert`)
1170
+ const reverted = Array.isArray(outcome.reverted) ? outcome.reverted : []
1171
+ setStatus({ kind: 'info', text: `「${slug}」还原了 ${String(reverted.length)} 个文件。` })
1172
+ await refreshSources()
1173
+ } catch (error) {
1174
+ setStatus({ kind: 'error', text: error.message })
1175
+ } finally {
1176
+ setBusy(false)
1177
+ }
1178
+ }, [refreshSources])
1179
+
1180
+ const removeSource = React.useCallback(async (slug) => {
1181
+ if (!window.confirm(`删除来源「${slug}」?它导入的条目会一起移除,本地条目不受影响。`)) return
1182
+ setBusy(true)
1183
+ try {
1184
+ // The Host drops the files and rebuilds the index; the settings writes
1185
+ // only record what it already did. Deleting first keeps a failure from
1186
+ // leaving the page showing a source the engine still has.
1187
+ const outcome = await request('DELETE', `/sources/${encodeURIComponent(slug)}`)
1188
+ await scope.set('entries', Array.isArray(outcome.entries)
1189
+ ? outcome.entries
1190
+ : entries.filter((entry) => entry.source !== slug))
1191
+ await scope.set('sources', configured.filter((source) => source.id !== slug))
1192
+ if (report !== null && report.slug === slug) setReport(null)
1193
+ await refreshSources()
1194
+ setStatus({ kind: 'info', text: `已删除来源「${slug}」。` })
1195
+ } catch (error) {
1196
+ setStatus({ kind: 'error', text: error.message })
1197
+ } finally {
1198
+ setBusy(false)
1199
+ }
1200
+ }, [configured, entries, refreshSources, report, scope])
1201
+
1202
+ /** Turn a subscribed entry into an editable local one, body and all. */
1203
+ const forkEntry = React.useCallback(async () => {
1204
+ if (draft === null) return
1205
+ setBusy(true)
1206
+ try {
1207
+ const allocated = await request('POST', '/id', { title: draft.title })
1208
+ const title = `${draft.title}(本地)`
1209
+ // The index goes first. If the body write then fails, what is left is a
1210
+ // visible entry with an empty body — which this editor can fix — where
1211
+ // the other order would leave a body file no page can reach.
1212
+ await scope.set('entries', [...entries, { id: allocated.id, title, order: draft.order, enabled: false }])
1213
+ const forked = {
1214
+ id: allocated.id,
1215
+ title,
1216
+ order: draft.order,
1217
+ body: draft.body,
1218
+ source: 'empty',
1219
+ fileSha1: null,
1220
+ isNew: false,
1221
+ }
1222
+ setSelectedId(allocated.id)
1223
+ setDraft(forked)
1224
+ setSaved(null)
1225
+ const written = await request('PUT', `/body/${encodeURIComponent(allocated.id)}`, { body: draft.body, fileSha1: null })
1226
+ // The body file exists now, so the editor must fence against exactly
1227
+ // what the write produced: keeping `null` would make the next save look
1228
+ // like a create, and the store refuses to create over an existing file.
1229
+ const placed = {
1230
+ ...forked,
1231
+ source: typeof written.source === 'string' ? written.source : 'user',
1232
+ fileSha1: typeof written.fileSha1 === 'string' ? written.fileSha1 : null,
1233
+ }
1234
+ setDraft(placed)
1235
+ setSaved(placed)
1236
+ setStatus({ kind: 'info', text: `已 fork 成 ${allocated.id},改完点保存;原订阅条目继续跟随更新。` })
1237
+ await refreshStore()
1238
+ } catch (error) {
1239
+ setStatus({ kind: 'error', text: error.message })
1240
+ } finally {
1241
+ setBusy(false)
1242
+ }
1243
+ }, [draft, entries, refreshStore, scope])
1244
+
1245
+ const toggle = React.useCallback((entry, enabled) => {
1246
+ const nextEntries = entries.map((candidate) => candidate.id === entry.id ? { ...candidate, enabled } : candidate)
1247
+ setBusy(true)
1248
+ Promise.resolve(scope.set('entries', nextEntries))
1249
+ .then(() => setStatus({ kind: 'info', text: enabled ? `已启用「${entry.title}」。` : `已关闭「${entry.title}」。` }))
1250
+ .catch((error) => setStatus({ kind: 'error', text: error.message }))
1251
+ .finally(() => setBusy(false))
1252
+ }, [entries, scope])
1253
+
1254
+ const remove = React.useCallback((entry) => {
1255
+ if (!window.confirm(`删除「${entry.title}」?它的正文文件也会一起删除。`)) return
1256
+ setBusy(true)
1257
+ // The body file goes first: if that fails, nothing has changed and the
1258
+ // entry is still there to retry, rather than an index record pointing at
1259
+ // a file the page no longer shows.
1260
+ request('DELETE', `/body/${encodeURIComponent(entry.id)}`)
1261
+ .then(() => scope.set('entries', entries.filter((candidate) => candidate.id !== entry.id)))
1262
+ .then(async () => {
1263
+ if (selectedId === entry.id) {
1264
+ setDraft(null)
1265
+ setSaved(null)
1266
+ setSelectedId(null)
1267
+ }
1268
+ await refreshStore()
1269
+ setStatus({ kind: 'info', text: `已删除「${entry.title}」。` })
1270
+ })
1271
+ .catch((error) => setStatus({ kind: 'error', text: error.message }))
1272
+ .finally(() => setBusy(false))
1273
+ }, [entries, refreshStore, scope, selectedId])
1274
+
1275
+ // ── variables and scripts ────────────────────────────────────────────────
1276
+
1277
+ /** Copy one `{{name}}` so it can be pasted into a body. */
1278
+ const copyVariable = React.useCallback((name) => {
1279
+ const token = `{{${name}}}`
1280
+ const clipboard = typeof navigator === 'object' && navigator !== null ? navigator.clipboard : undefined
1281
+ if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === 'function') {
1282
+ clipboard.writeText(token)
1283
+ .then(() => { setStatus({ kind: 'info', text: `已复制 ${token}` }) })
1284
+ .catch((error) => { setStatus({ kind: 'info', text: token }) })
1285
+ return
1286
+ }
1287
+ setStatus({ kind: 'info', text: token })
1288
+ }, [])
1289
+
1290
+ /**
1291
+ * Put one `{{name}}` into the body being edited, at the caret.
1292
+ *
1293
+ * Only the editor calls this: it is the one place where the body it edits
1294
+ * is on screen, and where a reference that lands somewhere unexpected is
1295
+ * something you can see and undo.
1296
+ * @param name - the variable to reference.
1297
+ */
1298
+ const insertVariable = React.useCallback((name) => {
1299
+ if (draft === null) return
1300
+ const token = `{{${name}}}`
1301
+ const at = typeof caret === 'number' && caret >= 0 && caret <= draft.body.length ? caret : draft.body.length
1302
+ setDraft({ ...draft, body: `${draft.body.slice(0, at)}${token}${draft.body.slice(at)}` })
1303
+ setCaret(at + token.length)
1304
+ }, [caret, draft])
1305
+
1306
+ /** Open a script in the editor: an existing one, or a fresh template. */
1307
+ const openScript = React.useCallback(async (name) => {
1308
+ setBusy(true)
1309
+ setRunReport(null)
1310
+ try {
1311
+ if (name === null) {
1312
+ const fresh = { name: '', source: SCRIPT_TEMPLATE, fileSha1: null, isNew: true }
1313
+ setScriptDraft(fresh)
1314
+ setScriptSaved(fresh)
1315
+ } else {
1316
+ const stored = await request('GET', `/script/${encodeURIComponent(name)}`)
1317
+ const opened = {
1318
+ name,
1319
+ source: typeof stored.source === 'string' ? stored.source : '',
1320
+ fileSha1: typeof stored.sha1 === 'string' ? stored.sha1 : null,
1321
+ isNew: false,
1322
+ }
1323
+ setScriptDraft(opened)
1324
+ setScriptSaved(opened)
1325
+ }
1326
+ setStatus(null)
1327
+ setView('script')
1328
+ } catch (error) {
1329
+ setStatus({ kind: 'error', text: error.message })
1330
+ } finally {
1331
+ setBusy(false)
1332
+ }
1333
+ }, [])
1334
+
1335
+ /**
1336
+ * Run what the editor holds, without saving it.
1337
+ *
1338
+ * Always sent as a source, so the Host writes a throwaway copy, runs that,
1339
+ * and registers nothing — the same code, the same command and the same
1340
+ * directory a save would use, minus the side effects. Running the saved
1341
+ * file itself is the variables page's own 「运行一次」, which is also what
1342
+ * refreshes the values in force.
1343
+ */
1344
+ const testScript = React.useCallback(async () => {
1345
+ if (scriptDraft === null) return
1346
+ setBusy(true)
1347
+ try {
1348
+ const name = scriptDraft.name.trim()
1349
+ const report = await request('POST', '/variables/run', {
1350
+ name: name.length > 0 ? name : 'draft',
1351
+ source: scriptDraft.source,
1352
+ })
1353
+ setRunReport(report)
1354
+ setStatus(report.ok === true
1355
+ ? { kind: 'info', text: `这次会提供 ${String(Object.keys(report.variables ?? {}).length)} 个变量;保存后才会注册。` }
1356
+ : { kind: 'error', text: report.problems.join(';') })
1357
+ } catch (error) {
1358
+ setRunReport(error.report === undefined ? null : error.report)
1359
+ setStatus({ kind: 'error', text: error.message })
1360
+ } finally {
1361
+ setBusy(false)
1362
+ }
1363
+ }, [scriptDraft])
1364
+
1365
+ /** Save a script: the Host validates, runs, and only then writes it. */
1366
+ const saveScript = React.useCallback(async () => {
1367
+ if (scriptDraft === null) return
1368
+ const name = scriptDraft.name.trim()
1369
+ if (name.length === 0) {
1370
+ setStatus({ kind: 'error', text: '先给脚本起个名字(字母数字和连字符,就是文件名)。' })
1371
+ return
1372
+ }
1373
+ setBusy(true)
1374
+ try {
1375
+ const saved = await request('PUT', `/script/${encodeURIComponent(name)}`, {
1376
+ source: scriptDraft.source,
1377
+ fileSha1: scriptDraft.fileSha1,
1378
+ })
1379
+ const written = { ...scriptDraft, name, fileSha1: typeof saved.sha1 === 'string' ? saved.sha1 : null, isNew: false }
1380
+ setScriptDraft(written)
1381
+ setScriptSaved(written)
1382
+ setRunReport(saved.report === undefined ? null : saved.report)
1383
+ await refreshVariables()
1384
+ const supplied = Object.keys(saved.variables ?? {})
1385
+ setStatus({
1386
+ kind: 'info',
1387
+ text: supplied.length === 0
1388
+ ? '已保存;这条脚本没有提供任何变量。'
1389
+ : `已保存并启用:${supplied.map((variable) => `{{${variable}}}`).join(' ')},下一个模型步骤生效。`,
1390
+ })
1391
+ } catch (error) {
1392
+ setRunReport(error.report === undefined ? null : error.report)
1393
+ setStatus({ kind: 'error', text: error.message })
1394
+ } finally {
1395
+ setBusy(false)
1396
+ }
1397
+ }, [refreshVariables, scriptDraft])
1398
+
1399
+ /** Measure every script again, and republish what they supply. */
1400
+ const refreshScripts = React.useCallback(async () => {
1401
+ setBusy(true)
1402
+ try {
1403
+ const outcome = await request('POST', '/variables/refresh')
1404
+ await refreshVariables()
1405
+ const reports = Array.isArray(outcome.reports) ? outcome.reports : []
1406
+ const failed = reports.filter((report) => report.ok !== true)
1407
+ setStatus(failed.length === 0
1408
+ ? { kind: 'info', text: `已重新测量 ${String(reports.length)} 条脚本。` }
1409
+ : {
1410
+ kind: 'error',
1411
+ text: `${String(failed.length)} 条脚本失败了:${failed.map((report) => `${report.name}(${report.problems.join(',')})`).join(';')}`,
1412
+ })
1413
+ } catch (error) {
1414
+ setStatus({ kind: 'error', text: error.message })
1415
+ } finally {
1416
+ setBusy(false)
1417
+ }
1418
+ }, [refreshVariables])
1419
+
1420
+ /**
1421
+ * Forget a script.
1422
+ *
1423
+ * The values it declared stay in force — a reference with no value fails
1424
+ * assembly — so the confirmation names the entries that would be left
1425
+ * holding the last measured value.
1426
+ */
1427
+ const removeScript = React.useCallback(async (name) => {
1428
+ const affected = variables.filter((variable) => variable.detail === name && variable.referencedBy.length > 0)
1429
+ const question = affected.length === 0
1430
+ ? `删除脚本「${name}」?它的文件会被移除。`
1431
+ : `删除脚本「${name}」?这些变量还在被提示词引用:${affected.map((variable) => `${variable.name}(${variable.referencedBy.join('、')})`).join(';')}。删除后它们会继续用最后一次测到的值,重启 profile 后才真正消失。`
1432
+ if (!window.confirm(question)) return
1433
+ setBusy(true)
1434
+ try {
1435
+ await request('DELETE', `/script/${encodeURIComponent(name)}`)
1436
+ await refreshVariables()
1437
+ if (scriptDraft !== null && scriptDraft.name === name) {
1438
+ setScriptDraft(null)
1439
+ setScriptSaved(null)
1440
+ setView('variables')
1441
+ }
1442
+ setStatus({ kind: 'info', text: `已删除脚本「${name}」。它提供的变量留在最后一次的值上。` })
1443
+ } catch (error) {
1444
+ setStatus({ kind: 'error', text: error.message })
1445
+ } finally {
1446
+ setBusy(false)
1447
+ }
1448
+ }, [refreshVariables, scriptDraft, variables])
1449
+
1450
+ /**
1451
+ * Run a saved script from the list, without opening the editor.
1452
+ *
1453
+ * The list has nowhere to show a full run report, so the outcome is
1454
+ * summarised here and the editor stays the place that shows the variables,
1455
+ * the exit code, and stderr in full.
1456
+ */
1457
+ const runScript = React.useCallback(async (name) => {
1458
+ setBusy(true)
1459
+ try {
1460
+ const report = await request('POST', '/variables/run', { name })
1461
+ await refreshVariables()
1462
+ const provided = Object.keys(report.variables ?? {}).length
1463
+ setStatus(report.ok === true
1464
+ ? {
1465
+ kind: 'info',
1466
+ text: `「${name}」提供了 ${String(provided)} 个变量${report.exitCode === undefined ? '' : `(退出码 ${String(report.exitCode)})`},用时 ${String(report.ms)}ms。`,
1467
+ }
1468
+ : { kind: 'error', text: report.problems.join(';') })
1469
+ } catch (error) {
1470
+ setStatus({ kind: 'error', text: error.message })
1471
+ } finally {
1472
+ setBusy(false)
1473
+ }
1474
+ }, [refreshVariables])
1475
+
1476
+ const statusLine = status === null ? null : h('p', {
1477
+ key: 'status',
1478
+ className: status.kind === 'error' ? 'dsh-prompt-manager__status dsh-prompt-manager__status--error' : 'dsh-prompt-manager__status',
1479
+ role: status.kind === 'error' ? 'alert' : 'status',
1480
+ }, status.text)
1481
+
1482
+ // ── the editor page ─────────────────────────────────────────────────────
1483
+
1484
+ if (view === 'editor' && draft !== null) {
1485
+ return h('div', { className: 'dsh-prompt-manager' }, [
1486
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
1487
+ h(Button, { key: 'back', variant: 'ghost', disabled: busy, onClick: back }, '← 返回'),
1488
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' }, `编辑「${draft.title}」`),
1489
+ h('span', { key: 'spacer', className: 'dsh-prompt-manager__headSpacer' }),
1490
+ h('span', { key: 'id', className: 'dsh-prompt-manager__note' }, [
1491
+ `id ${draft.id}`,
1492
+ draft.isNew ? '(尚未保存)' : '',
1493
+ subscribedDraft
1494
+ ? ' · 订阅正文,只读'
1495
+ : builtinDraft
1496
+ ? ' · 插件内置正文,保存即覆盖'
1497
+ : (draft.fileSha1 === null ? ' · 还没有正文文件' : ' · 已保存'),
1498
+ ].join('')),
1499
+ ]),
1500
+ h('div', { key: 'form', className: 'dsh-prompt-manager__surface' }, [
1501
+ h('div', { key: 'fields', className: 'dsh-prompt-manager__fields' }, [
1502
+ h('label', { key: 'title', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--grow' }, [
1503
+ '标题',
1504
+ h('input', {
1505
+ key: 'input',
1506
+ value: draft.title,
1507
+ disabled: !writable || busy,
1508
+ onChange: (event) => setDraft({ ...draft, title: event.target.value }),
1509
+ }),
1510
+ ]),
1511
+ h('label', { key: 'order', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--order' }, [
1512
+ '顺序',
1513
+ h('input', {
1514
+ key: 'input',
1515
+ type: 'number',
1516
+ value: String(draft.order),
1517
+ disabled: !writable || busy,
1518
+ onChange: (event) => {
1519
+ // An empty box or a lone `-` parses to something unusable;
1520
+ // keeping the previous number is kinder than writing a value
1521
+ // the index schema then refuses.
1522
+ const parsed = Number(event.target.value)
1523
+ if (!Number.isFinite(parsed)) return
1524
+ setDraft({ ...draft, order: parsed })
1525
+ },
1526
+ }),
1527
+ ]),
1528
+ ]),
1529
+ h('div', { key: 'body', className: 'dsh-prompt-manager__pane' }, [
1530
+ h('span', { key: 'label', className: 'dsh-prompt-manager__paneLabel' }, subscribedDraft
1531
+ ? 'Markdown 正文(来自订阅,只读)'
1532
+ : builtinDraft ? 'Markdown 正文(插件内置,保存即覆盖)' : 'Markdown 正文'),
1533
+ h('textarea', {
1534
+ key: 'textarea',
1535
+ value: draft.body,
1536
+ disabled: !writable || busy,
1537
+ readOnly: subscribedDraft,
1538
+ spellCheck: false,
1539
+ onChange: (event) => setDraft({ ...draft, body: event.target.value }),
1540
+ // The caret is where an inserted reference lands; a host that does
1541
+ // not report one simply gets the reference appended.
1542
+ onSelect: (event) => setCaret(event.target.selectionStart),
1543
+ onKeyUp: (event) => setCaret(event.target.selectionStart),
1544
+ onClick: (event) => setCaret(event.target.selectionStart),
1545
+ }),
1546
+ ]),
1547
+ h('div', { key: 'vars', className: 'dsh-prompt-manager__actions' }, [
1548
+ h('span', { key: 'label', className: 'dsh-prompt-manager__note' }, '可用变量(点一下插到光标处):'),
1549
+ ...knownVariables.map((name) => h(Button, {
1550
+ key: name,
1551
+ disabled: busy || !writable,
1552
+ onClick: () => insertVariable(name),
1553
+ }, `{{${name}}}`)),
1554
+ ]),
1555
+ unknownInDraft.length === 0 ? null : h('span', {
1556
+ key: 'unknown',
1557
+ className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error',
1558
+ }, `这些变量还没有注册:${unknownInDraft.map((name) => `{{${name}}}`).join(' ')}(宿主会把它按字面量渲染并记一条警告;去「变量」页建一条脚本提供它,或把引用删掉)`),
1559
+ malformedInDraft.length === 0 ? null : h('span', {
1560
+ key: 'malformed',
1561
+ className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error',
1562
+ }, `这些引用的写法不对:${malformedInDraft.map((name) => `{{${name}}}`).join(' ')}(注册表解析不了这种写法,宿主会按字面量渲染,且保存会被拒绝;变量名只能是数字、下划线和小写字母,且以字母开头)`),
1563
+ h('div', { key: 'preview', className: 'dsh-prompt-manager__pane' }, [
1564
+ h('span', { key: 'label', className: 'dsh-prompt-manager__paneLabel' }, '预览'),
1565
+ h('div', { key: 'body', className: 'dsh-prompt-manager__preview' }, h(Preview, { text: draft.body })),
1566
+ ]),
1567
+ ]),
1568
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__actions' }, [ h(Button, { key: 'save', variant: 'primary', disabled: !writable || busy || !dirty, onClick: save }, dirty ? '保存修改' : '已保存'),
1569
+ subscribedDraft
1570
+ ? h(Button, { key: 'fork', disabled: !writable || busy, onClick: () => { void forkEntry() } }, 'fork 成本地条目')
1571
+ : null,
1572
+ subscribedDraft
1573
+ ? h('span', { key: 'note', className: 'dsh-prompt-manager__note' }, '订阅条目的正文来自上游,只能通过「检查更新」改;想自己改就先 fork。')
1574
+ : builtinDraft
1575
+ ? h('span', { key: 'note', className: 'dsh-prompt-manager__note' }, '这条正文是插件内置的默认内容;保存后会写成本机的覆盖版本,插件升级也不会覆盖它。')
1576
+ : null,
1577
+ ]),
1578
+ statusLine,
1579
+ ])
1580
+ }
1581
+
1582
+ // ── the script editor ───────────────────────────────────────────────────
1583
+
1584
+ if (view === 'script' && scriptDraft !== null) {
1585
+ const syntax = syntaxProblem(scriptDraft.source)
1586
+ return h('div', { className: 'dsh-prompt-manager' }, [
1587
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
1588
+ h(Button, {
1589
+ key: 'back',
1590
+ variant: 'ghost',
1591
+ disabled: busy,
1592
+ onClick: () => {
1593
+ // Leaving drops the draft, so an edited one is confirmed first —
1594
+ // a half-written script is not recoverable from anywhere else.
1595
+ const dirtyScript = scriptDraft !== null
1596
+ && (scriptSaved === null || scriptDraft.source !== scriptSaved.source || scriptDraft.name !== scriptSaved.name)
1597
+ if (dirtyScript && !window.confirm(scriptDraft.isNew ? '放弃这条新脚本?' : '放弃未保存的修改?')) return
1598
+ setScriptDraft(null)
1599
+ setScriptSaved(null)
1600
+ setRunReport(null)
1601
+ setView('variables')
1602
+ },
1603
+ }, '← 返回'),
1604
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' },
1605
+ scriptDraft.isNew ? '新建脚本' : `脚本「${scriptDraft.name}」`),
1606
+ h('span', { key: 'spacer', className: 'dsh-prompt-manager__headSpacer' }),
1607
+ h('span', { key: 'state', className: 'dsh-prompt-manager__note' },
1608
+ scriptDraft.fileSha1 === null ? '尚未保存' : '已保存'),
1609
+ ]),
1610
+ h('div', { key: 'form', className: 'dsh-prompt-manager__surface' }, [
1611
+ // `--grow` fills the width of a `__fields` row. Put straight into the
1612
+ // column `__surface` it fills the *height* instead, which is the blank
1613
+ // gap that used to sit under this input.
1614
+ h('div', { key: 'fields', className: 'dsh-prompt-manager__fields' }, [
1615
+ h('label', { key: 'name', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--grow' }, [
1616
+ '脚本名(小写字母、数字、连字符;它就是文件名)',
1617
+ h('input', {
1618
+ key: 'input',
1619
+ value: scriptDraft.name,
1620
+ placeholder: 'toolchain',
1621
+ disabled: !writable || busy || !scriptDraft.isNew,
1622
+ onChange: (event) => setScriptDraft({ ...scriptDraft, name: event.target.value }),
1623
+ }),
1624
+ ]),
1625
+ ]),
1626
+ h('div', { key: 'body', className: 'dsh-prompt-manager__pane' }, [
1627
+ h('span', { key: 'label', className: 'dsh-prompt-manager__paneLabel' },
1628
+ '脚本正文:打印一个 JSON 对象,键就是提示词里能用的变量名'),
1629
+ h('textarea', {
1630
+ key: 'textarea',
1631
+ value: scriptDraft.source,
1632
+ disabled: !writable || busy,
1633
+ spellCheck: false,
1634
+ onChange: (event) => setScriptDraft({ ...scriptDraft, source: event.target.value }),
1635
+ }),
1636
+ ]),
1637
+ syntax === null ? null : h('span', {
1638
+ key: 'syntax',
1639
+ className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error',
1640
+ }, `语法错误:${syntax}`),
1641
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__actions' }, [
1642
+ h(Button, { key: 'run', disabled: !writable || busy || syntax !== null, onClick: () => { void testScript() } }, '运行一次(测试)'),
1643
+ h(Button, {
1644
+ key: 'save',
1645
+ variant: 'primary',
1646
+ disabled: !writable || busy || syntax !== null,
1647
+ onClick: () => { void saveScript() },
1648
+ }, '保存并启用'),
1649
+ scriptDraft.isNew
1650
+ ? null
1651
+ : h(Button, { key: 'remove', variant: 'danger', disabled: busy, onClick: () => { void removeScript(scriptDraft.name) } }, '删除脚本'),
1652
+ ]),
1653
+ h('span', { key: 'hint', className: 'dsh-prompt-manager__note' },
1654
+ '「运行一次」跑的是上面这个框里的内容:同一条命令、同一个目录,但写成临时文件跑完就删,不写正式文件、不注册变量,所以随便试都不会动到正在生效的值。保存会先跑一遍,跑不出可用输出就不会落盘。想验已经保存的文件本身,用变量页那一行的「运行一次」。'),
1655
+ ]),
1656
+ h(RunReport, { key: 'report', report: runReport }),
1657
+ statusLine,
1658
+ ])
1659
+ }
1660
+
1661
+ // ── the sources page ────────────────────────────────────────────────────
1662
+
1663
+ if (view === 'sources') {
1664
+ const cards = sources.map((source) => {
1665
+ const active = report !== null && report.slug === source.id
1666
+ const state = active && report.changes.length > 0
1667
+ ? 'pending'
1668
+ : active && report.warnings.length > 0 ? 'error' : 'ready'
1669
+ return h('div', { key: source.id, className: 'dsh-prompt-manager__source' }, [
1670
+ h('div', { key: 'head', className: 'dsh-prompt-manager__sourceHead' }, [
1671
+ h('span', {
1672
+ key: 'dot',
1673
+ className: state === 'ready'
1674
+ ? 'dsh-prompt-manager__dot'
1675
+ : `dsh-prompt-manager__dot dsh-prompt-manager__dot--${state}`,
1676
+ 'aria-hidden': 'true',
1677
+ }),
1678
+ h('span', { key: 'repo', className: 'dsh-prompt-manager__sourceRepo' }, `${source.repo}@${source.ref}`),
1679
+ source.enabled === false
1680
+ ? h('span', { key: 'off', className: 'dsh-prompt-manager__badge' }, '已关闭')
1681
+ : null,
1682
+ h('span', { key: 'meta', className: 'dsh-prompt-manager__meta' }, [
1683
+ `${String(source.files)} 个文件`,
1684
+ source.appliedAt !== undefined ? `上次应用 ${source.appliedAt}` : '还没应用过',
1685
+ ].join(' · ')),
1686
+ h('span', { key: 'spacer', className: 'dsh-prompt-manager__headSpacer' }),
1687
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__sourceActions' }, [
1688
+ h(Button, { key: 'check', disabled: busy, onClick: () => { void checkSource(source.id) } }, '检查更新'),
1689
+ h(Button, { key: 'revert', disabled: busy, onClick: () => { void revertSource(source.id) } }, '还原'),
1690
+ h(Button, { key: 'remove', variant: 'danger', disabled: busy, onClick: () => { void removeSource(source.id) } }, '删除来源'),
1691
+ ]),
1692
+ ]),
1693
+ h('span', { key: 'note', className: 'dsh-prompt-manager__note' }, [
1694
+ source.headSha !== undefined ? `远端 ${source.headSha.slice(0, 7)}` : '还没检查过远端',
1695
+ source.mirror.length > 0 ? `经 ${source.mirror}` : '直连',
1696
+ ].join(' · ')),
1697
+ active && report.changes.length > 0
1698
+ ? h('div', { key: 'changes', className: 'dsh-prompt-manager__changes' }, [
1699
+ ...report.changes.map((change) => h('label', { key: change.path, className: 'dsh-prompt-manager__change' }, [
1700
+ h('input', {
1701
+ key: 'pick',
1702
+ type: 'checkbox',
1703
+ checked: picked.includes(change.path),
1704
+ onChange: () => setPicked(picked.includes(change.path)
1705
+ ? picked.filter((candidate) => candidate !== change.path)
1706
+ : [...picked, change.path]),
1707
+ }),
1708
+ h('span', { key: 'path', className: 'dsh-prompt-manager__changePath' },
1709
+ `${change.kind === 'removed' ? '(删除)' : ''}${change.path}`),
1710
+ // A rename is one move across two rows. Saying so is what keeps
1711
+ // it from looking like a deletion plus an unrelated new prompt —
1712
+ // and it is why picking either row applies both.
1713
+ change.renamedFrom === undefined && change.renamedTo === undefined
1714
+ ? null
1715
+ : h('span', { key: 'rename', className: 'dsh-prompt-manager__note' },
1716
+ change.renamedFrom !== undefined ? `由 ${change.renamedFrom} 改名` : `改名为 ${change.renamedTo}`),
1717
+ h('span', { key: 'delta', className: 'dsh-prompt-manager__delta' }, `+${String(change.added)} / −${String(change.removed)}`),
1718
+ ])),
1719
+ h('div', { key: 'apply', className: 'dsh-prompt-manager__actions' }, [
1720
+ h(Button, {
1721
+ key: 'go',
1722
+ variant: 'primary',
1723
+ disabled: busy || picked.length === 0,
1724
+ onClick: () => { void applySource(source.id) },
1725
+ }, `应用(${String(picked.length)})`),
1726
+ ]),
1727
+ ])
1728
+ : active
1729
+ ? h('span', { key: 'clean', className: 'dsh-prompt-manager__status dsh-prompt-manager__status--ok' }, '没有需要更新的内容。')
1730
+ : null,
1731
+ active && report.warnings.length > 0
1732
+ ? h('span', { key: 'warn', className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error' }, report.warnings.join(';'))
1733
+ : null,
1734
+ ])
1735
+ })
1736
+
1737
+ return h('div', { className: 'dsh-prompt-manager' }, [
1738
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
1739
+ h(Button, { key: 'back', variant: 'ghost', disabled: busy, onClick: () => { setView('list'); setReport(null) } }, '← 返回'),
1740
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' }, '订阅来源'),
1741
+ ]),
1742
+ h('p', { key: 'lede', className: 'dsh-prompt-manager__intro' },
1743
+ '一个来源 = 一个 GitHub 仓库 + 一个 ref;仓库根要有 prompt-manager.json 清单。检查更新只把远端内容取到暂存区,点「应用」才覆盖本地,改动在下一个模型步骤生效。'),
1744
+ h('div', { key: 'add', className: 'dsh-prompt-manager__surface' }, [
1745
+ h('div', { key: 'fields', className: 'dsh-prompt-manager__fields' }, [
1746
+ h('label', { key: 'repo', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--grow' }, [
1747
+ '仓库(owner/name)',
1748
+ h('input', {
1749
+ key: 'input',
1750
+ value: newRepo,
1751
+ placeholder: 'owner/repo',
1752
+ disabled: busy,
1753
+ onChange: (event) => setNewRepo(event.target.value),
1754
+ }),
1755
+ ]),
1756
+ h('label', { key: 'ref', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--order' }, [
1757
+ 'ref',
1758
+ h('input', {
1759
+ key: 'input',
1760
+ value: newRef,
1761
+ disabled: busy,
1762
+ onChange: (event) => setNewRef(event.target.value),
1763
+ }),
1764
+ ]),
1765
+ h('label', { key: 'mirror', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--grow' }, [
1766
+ '镜像(可留空)',
1767
+ h('input', {
1768
+ key: 'input',
1769
+ value: newMirror,
1770
+ placeholder: 'https://gh-proxy.example',
1771
+ disabled: busy,
1772
+ onChange: (event) => setNewMirror(event.target.value),
1773
+ }),
1774
+ ]),
1775
+ ]),
1776
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__actions' }, [
1777
+ h(Button, { key: 'go', variant: 'primary', disabled: !writable || busy, onClick: () => { void addSource() } }, '添加来源'),
1778
+ ]),
1779
+ ]),
1780
+ sources.length === 0
1781
+ ? h('div', { key: 'empty', className: 'dsh-prompt-manager__empty' }, '还没有订阅来源。')
1782
+ : h('div', { key: 'cards', className: 'dsh-prompt-manager__list' }, cards),
1783
+ statusLine,
1784
+ ])
1785
+ }
1786
+
1787
+ // ── the variables page ──────────────────────────────────────────────────
1788
+
1789
+ if (view === 'variables') {
1790
+ const variableRows = variables.map((variable) => h('div', {
1791
+ key: variable.name,
1792
+ className: 'dsh-prompt-manager__card',
1793
+ }, [
1794
+ h('div', { key: 'main', className: 'dsh-prompt-manager__cardMain', style: { cursor: 'default' } }, [
1795
+ h('span', { key: 'name', className: 'dsh-prompt-manager__title' }, `{{${variable.name}}}`),
1796
+ h('span', { key: 'value', className: 'dsh-prompt-manager__meta' }, variable.value),
1797
+ h('span', { key: 'refs', className: 'dsh-prompt-manager__meta' },
1798
+ variable.referencedBy.length === 0
1799
+ ? '还没有提示词引用它'
1800
+ : `被引用:${variable.referencedBy.join('、')}`),
1801
+ ]),
1802
+ h('div', { key: 'side', className: 'dsh-prompt-manager__cardSide' }, [
1803
+ h('span', { key: 'source', className: 'dsh-prompt-manager__badge' },
1804
+ SOURCE_LABELS[variable.source] === undefined ? variable.source : SOURCE_LABELS[variable.source]),
1805
+ variable.detail === undefined
1806
+ ? null
1807
+ : h('span', { key: 'detail', className: 'dsh-prompt-manager__meta' }, variable.detail),
1808
+ h(Button, { key: 'copy', disabled: busy, onClick: () => copyVariable(variable.name) }, '复制引用'),
1809
+ ]),
1810
+ ]))
1811
+
1812
+ const scriptCards = scripts.map((script) => h('div', {
1813
+ key: script.name,
1814
+ className: 'dsh-prompt-manager__source',
1815
+ }, [
1816
+ h('div', { key: 'head', className: 'dsh-prompt-manager__sourceHead' }, [
1817
+ h('span', {
1818
+ key: 'dot',
1819
+ className: script.error === undefined
1820
+ ? 'dsh-prompt-manager__dot'
1821
+ : 'dsh-prompt-manager__dot dsh-prompt-manager__dot--error',
1822
+ 'aria-hidden': 'true',
1823
+ }),
1824
+ h('span', { key: 'name', className: 'dsh-prompt-manager__sourceRepo' }, script.name),
1825
+ h('span', { key: 'meta', className: 'dsh-prompt-manager__meta' }, [
1826
+ script.variables.length === 0 ? '还没有变量' : script.variables.map((name) => `{{${name}}}`).join(' '),
1827
+ script.ranAt === undefined ? '还没成功运行过' : `上次运行 ${stamp(script.ranAt)}`,
1828
+ script.pending === true ? '文件已改动,待重测' : '',
1829
+ ].filter((part) => part.length > 0).join(' · ')),
1830
+ h('span', { key: 'spacer', className: 'dsh-prompt-manager__headSpacer' }),
1831
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__sourceActions' }, [
1832
+ h(Button, { key: 'run', disabled: busy, onClick: () => { void runScript(script.name) } }, '运行一次'),
1833
+ h(Button, { key: 'edit', disabled: busy, onClick: () => { void openScript(script.name) } }, '编辑'),
1834
+ h(Button, { key: 'remove', variant: 'danger', disabled: busy, onClick: () => { void removeScript(script.name) } }, '删除'),
1835
+ ]),
1836
+ ]),
1837
+ script.error === undefined
1838
+ ? null
1839
+ : h('span', { key: 'error', className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error' }, script.error),
1840
+ ]))
1841
+
1842
+ return h('div', { className: 'dsh-prompt-manager' }, [
1843
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
1844
+ h(Button, {
1845
+ key: 'back',
1846
+ variant: 'ghost',
1847
+ disabled: busy,
1848
+ // Through the same guard the editor uses: a body edited from this
1849
+ // page (an inserted reference) must not vanish without a word.
1850
+ onClick: () => { back(); setRunReport(null) },
1851
+ }, '← 返回'),
1852
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' }, '提示词变量'),
1853
+ h('span', { key: 'spacer', className: 'dsh-prompt-manager__headSpacer' }),
1854
+ h(Button, { key: 'refresh', disabled: busy, onClick: () => { void refreshScripts() } }, '重新测量'),
1855
+ ]),
1856
+ h('p', { key: 'lede', className: 'dsh-prompt-manager__intro' },
1857
+ '变量是提示词里那段大括号引用的来源:系统事实由插件注册,脚本由你写。一条脚本打印一个 JSON,键就是变量名,所以一条脚本能提供多个变量;它跑在独立子进程里,超时或报错都伤不到 DSH 本身。'),
1858
+ variableReport !== null && typeof variableReport.error === 'string'
1859
+ ? h('p', { key: 'error', className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error' }, variableReport.error)
1860
+ : null,
1861
+ variableRows.length === 0
1862
+ ? h('div', { key: 'empty', className: 'dsh-prompt-manager__empty' }, '还没有注册任何变量。')
1863
+ : h('div', { key: 'vars', className: 'dsh-prompt-manager__list' }, variableRows),
1864
+ h('div', { key: 'scripts', className: 'dsh-prompt-manager__block' }, [
1865
+ h('span', { key: 'label', className: 'dsh-prompt-manager__paneLabel' }, `脚本(${String(scripts.length)})`),
1866
+ scripts.length === 0
1867
+ ? h('div', { key: 'none', className: 'dsh-prompt-manager__empty' }, '还没有脚本。点「新建脚本」会给你一个能直接跑的模板。')
1868
+ : h('div', { key: 'cards', className: 'dsh-prompt-manager__list' }, scriptCards),
1869
+ h('div', { key: 'addRow', className: 'dsh-prompt-manager__addRow' }, [
1870
+ h(AddButton, { key: 'add', disabled: !writable || busy, onClick: () => { void openScript(null) } }, '新建脚本'),
1871
+ ]),
1872
+ ]),
1873
+ statusLine,
1874
+ ])
1875
+ }
1876
+
1877
+ // ── the presets page ────────────────────────────────────────────────────
1878
+
1879
+ /**
1880
+ * The title one entry id stands for.
1881
+ *
1882
+ * A preset may name an entry the index does not carry — a subscription that
1883
+ * has not come back, or a body somebody removed by hand — and saying so is
1884
+ * better than showing a bare id nobody can act on.
1885
+ * @param id - the entry id a preset selected.
1886
+ * @returns the entry's title, or a marker naming the missing id.
1887
+ */
1888
+ const titleOfEntry = (id) => {
1889
+ const known = entries.find((entry) => entry.id === id)
1890
+ return known !== undefined ? known.title : `(条目不存在:${id})`
1891
+ }
1892
+
1893
+ /**
1894
+ * The preset editor: a name, and the entries this preset selects.
1895
+ *
1896
+ * The checklist is the whole entry index rather than only the ones switched
1897
+ * on, because a preset is what decides injection — an entry a person left
1898
+ * off is exactly the kind of thing a preset exists to turn on.
1899
+ */
1900
+ if (view === 'preset' && presetDraft !== null) {
1901
+ const members = presetDraft.members
1902
+ const missing = members.filter((id) => !entries.some((entry) => entry.id === id))
1903
+ const rows = entries.map((entry) => {
1904
+ const checked = members.includes(entry.id)
1905
+ return h('label', { key: entry.id, className: 'dsh-prompt-manager__member' }, [
1906
+ h('input', {
1907
+ key: 'pick',
1908
+ type: 'checkbox',
1909
+ checked,
1910
+ disabled: busy,
1911
+ onChange: () => setPresetDraft({
1912
+ ...presetDraft,
1913
+ members: checked
1914
+ ? members.filter((id) => id !== entry.id)
1915
+ : [...members, entry.id],
1916
+ }),
1917
+ }),
1918
+ h('span', { key: 'title', className: 'dsh-prompt-manager__memberLabel' }, entry.title),
1919
+ h('span', { key: 'id', className: 'dsh-prompt-manager__memberId' }, entry.id),
1920
+ h('span', { key: 'state', className: 'dsh-prompt-manager__note' },
1921
+ entry.enabled === true ? '开关:开' : '开关:关'),
1922
+ ])
1923
+ })
1924
+
1925
+ return h('div', { className: 'dsh-prompt-manager' }, [
1926
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
1927
+ h(Button, { key: 'back', variant: 'ghost', disabled: busy, onClick: closePreset }, '← 返回'),
1928
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' },
1929
+ presetDraft.id === null ? '新建组合' : `组合:${presetDraft.name}`),
1930
+ presetDraft.id === null
1931
+ ? null
1932
+ : h('span', { key: 'id', className: 'dsh-prompt-manager__badge' }, presetDraft.id),
1933
+ ]),
1934
+ h('p', { key: 'lede', className: 'dsh-prompt-manager__intro' },
1935
+ '勾选这个组合要注入的提示词。启用组合后由它决定注入哪些条目,每条自己的开关会暂时不生效;取消组合就回到那些开关。'),
1936
+ h('div', { key: 'name', className: 'dsh-prompt-manager__surface' }, [
1937
+ h('div', { key: 'fields', className: 'dsh-prompt-manager__fields' }, [
1938
+ h('label', { key: 'name', className: 'dsh-prompt-manager__field dsh-prompt-manager__field--grow' }, [
1939
+ '名字(显示在输入栏的切换器上)',
1940
+ h('input', {
1941
+ key: 'input',
1942
+ value: presetDraft.name,
1943
+ placeholder: '例如:CTF 作业',
1944
+ disabled: busy,
1945
+ onChange: (event) => setPresetDraft({ ...presetDraft, name: event.target.value }),
1946
+ }),
1947
+ ]),
1948
+ ]),
1949
+ h('div', { key: 'actions', className: 'dsh-prompt-manager__actions' }, [
1950
+ h(Button, {
1951
+ key: 'all',
1952
+ disabled: busy,
1953
+ onClick: () => setPresetDraft({ ...presetDraft, members: entries.map((entry) => entry.id) }),
1954
+ }, `全选(${String(entries.length)})`),
1955
+ h(Button, {
1956
+ key: 'none',
1957
+ disabled: busy || members.length === 0,
1958
+ onClick: () => setPresetDraft({ ...presetDraft, members: [] }),
1959
+ }, '清空'),
1960
+ h(Button, {
1961
+ key: 'save',
1962
+ variant: 'primary',
1963
+ disabled: !writable || busy,
1964
+ onClick: () => { void savePreset() },
1965
+ }, '保存组合'),
1966
+ ]),
1967
+ ]),
1968
+ entries.length === 0
1969
+ ? h('div', { key: 'empty', className: 'dsh-prompt-manager__empty' }, '还没有提示词可以选:先在列表页新增一条。')
1970
+ : h('div', { key: 'members', className: 'dsh-prompt-manager__members' }, rows),
1971
+ missing.length === 0
1972
+ ? null
1973
+ : h('p', { key: 'missing', className: 'dsh-prompt-manager__note' },
1974
+ `这个组合还记着索引里没有的条目:${missing.join('、')}。它们不会注入;订阅回来的话会自动生效。`),
1975
+ statusLine,
1976
+ ])
1977
+ }
1978
+
1979
+ // ── the preset list ─────────────────────────────────────────────────────
1980
+
1981
+ if (view === 'presets') {
1982
+ const cards = presets.map((preset) => {
1983
+ const current = activePreset !== null && activePreset.id === preset.id
1984
+ return h('div', { key: preset.id, className: 'dsh-prompt-manager__card' }, [
1985
+ h('button', {
1986
+ key: 'open',
1987
+ type: 'button',
1988
+ className: 'dsh-prompt-manager__cardMain',
1989
+ onClick: () => openPreset(preset),
1990
+ }, [
1991
+ h('span', { key: 'title', className: 'dsh-prompt-manager__title' }, preset.name),
1992
+ h('span', { key: 'meta', className: 'dsh-prompt-manager__meta' },
1993
+ preset.entries.length === 0
1994
+ ? '没有选中任何条目(启用它等于一条都不注入)'
1995
+ : `${String(preset.entries.length)} 条 · ${preset.entries.slice(0, 4).map(titleOfEntry).join('、')}${preset.entries.length > 4 ? '…' : ''}`),
1996
+ ]),
1997
+ h('div', { key: 'side', className: 'dsh-prompt-manager__cardSide' }, [
1998
+ current ? h('span', { key: 'badge', className: 'dsh-prompt-manager__badge' }, '当前') : null,
1999
+ h(RowMenu, {
2000
+ key: 'menu',
2001
+ open: menuFor === preset.id,
2002
+ label: `更多操作:${preset.name}`,
2003
+ items: [
2004
+ { id: 'edit', label: '编辑', icon: icon('IconEditOutline16') },
2005
+ { id: 'export', label: '导出组合包' },
2006
+ { id: 'activate', label: '设为当前', disabled: !writable || current },
2007
+ { id: 'clear', label: '取消当前', disabled: !writable || !current },
2008
+ { id: 'delete', label: '删除', icon: icon('IconTrashOutline16'), disabled: !writable },
2009
+ ],
2010
+ onToggle: () => setMenuFor(menuFor === preset.id ? null : preset.id),
2011
+ onClose: () => setMenuFor(null),
2012
+ onSelect: (id) => {
2013
+ setMenuFor(null)
2014
+ if (id === 'edit') openPreset(preset)
2015
+ else if (id === 'export') { void exportPreset(preset) }
2016
+ else if (id === 'activate') { void activatePreset(preset.id) }
2017
+ else if (id === 'clear') { void activatePreset(NO_PRESET) }
2018
+ else if (id === 'delete') { void removePreset(preset) }
2019
+ },
2020
+ }),
2021
+ ]),
2022
+ ])
2023
+ })
2024
+
2025
+ return h('div', { className: 'dsh-prompt-manager' }, [
2026
+ h('div', { key: 'head', className: 'dsh-prompt-manager__head' }, [
2027
+ h(Button, { key: 'back', variant: 'ghost', disabled: busy, onClick: () => { setView('list'); setStatus(null) } }, '← 返回'),
2028
+ h('h2', { key: 'title', className: 'dsh-prompt-manager__headTitle' }, '组合'),
2029
+ ]),
2030
+ h('p', { key: 'lede', className: 'dsh-prompt-manager__intro' },
2031
+ '一个组合 = 挑一组提示词。当前组合在下一个模型步骤生效,决定注入哪些条目;它管选哪些,不管顺序(顺序仍是每条自己的)。切换在聊天页输入栏右侧的「提示词」上。'),
2032
+ presets.length === 0
2033
+ ? h('div', { key: 'empty', className: 'dsh-prompt-manager__empty' }, '还没有组合。点「新建组合」挑几条提示词试试。')
2034
+ : h('div', { key: 'cards', className: 'dsh-prompt-manager__list' }, cards),
2035
+ h('div', { key: 'addRow', className: 'dsh-prompt-manager__addRow' }, [
2036
+ h(AddButton, {
2037
+ key: 'add',
2038
+ disabled: !writable || busy,
2039
+ onClick: () => openPreset(null),
2040
+ }, '新建组合'),
2041
+ // A label wrapping the input, so the picker opens without a ref and
2042
+ // the control is a real file input rather than a lookalike button.
2043
+ h('label', {
2044
+ key: 'import',
2045
+ className: 'dsh-prompt-manager__addButton dsh-prompt-manager__fileLabel',
2046
+ 'aria-disabled': !writable || busy,
2047
+ }, [
2048
+ '导入组合包…',
2049
+ h('input', {
2050
+ key: 'file',
2051
+ type: 'file',
2052
+ accept: '.json,application/json',
2053
+ className: 'dsh-prompt-manager__fileInput',
2054
+ disabled: !writable || busy,
2055
+ onChange: (event) => {
2056
+ const file = event.target.files === undefined || event.target.files === null
2057
+ ? undefined
2058
+ : event.target.files[0]
2059
+ event.target.value = ''
2060
+ void importPackFile(file)
2061
+ },
2062
+ }),
2063
+ ]),
2064
+ ]),
2065
+ statusLine,
2066
+ ])
2067
+ }
2068
+
2069
+ // ── the list page ───────────────────────────────────────────────────────
2070
+
2071
+ const visible = entries.filter((entry) => {
2072
+ if (filter === 'local') return !isSubscribed(entry)
2073
+ if (filter === 'subscribed') return isSubscribed(entry)
2074
+ return true
2075
+ })
2076
+
2077
+ const rows = visible.map((entry) => {
2078
+ // What this entry contributes right now. An active preset answers it by
2079
+ // itself, so the row's state dot has to follow the preset rather than the
2080
+ // switch — otherwise the page would show a prompt as on while the prompt
2081
+ // it describes is not being injected.
2082
+ const injected = activePreset === null
2083
+ ? entry.enabled === true
2084
+ : activePreset.entries.includes(entry.id)
2085
+ const subscribed = isSubscribed(entry)
2086
+ const source = subscribed ? sourceById.get(entry.source) : undefined
2087
+ // The metadata line sits outside the row's button so the repository can be
2088
+ // a real link: an anchor nested in a button is neither valid markup nor
2089
+ // something a click can be trusted to split correctly. Parts are grouped so
2090
+ // the label and its link stay adjacent and only the links are underlined.
2091
+ const meta = [
2092
+ subscribed
2093
+ ? source === undefined
2094
+ // The source is gone from the settings document, so the slug is all
2095
+ // that is left of where this body came from — a link would be dead.
2096
+ ? [`订阅 ${entry.source}`]
2097
+ : [
2098
+ '订阅 ',
2099
+ h('a', {
2100
+ key: 'source',
2101
+ className: 'dsh-prompt-manager__sourceLink',
2102
+ href: `https://github.com/${source.repo}`,
2103
+ target: '_blank',
2104
+ rel: 'noreferrer',
2105
+ title: `${source.repo}@${source.ref}(来源 ${source.id})`,
2106
+ }, source.repo),
2107
+ ]
2108
+ : ['本地'],
2109
+ entry.enabled === true ? [] : ['已关闭'],
2110
+ activePreset === null ? [] : [`组合:${injected ? '注入' : '不注入'}`],
2111
+ ].filter((group) => group.length > 0)
2112
+ return h('div', {
2113
+ key: entry.id,
2114
+ className: 'dsh-prompt-manager__card',
2115
+ }, [
2116
+ h('div', { key: 'body', className: 'dsh-prompt-manager__cardBody' }, [
2117
+ h('button', {
2118
+ key: 'open',
2119
+ type: 'button',
2120
+ className: 'dsh-prompt-manager__cardMain',
2121
+ onClick: () => select(entry),
2122
+ }, h('span', { key: 'title', className: 'dsh-prompt-manager__title' }, entry.title)),
2123
+ h('span', { key: 'meta', className: 'dsh-prompt-manager__meta' },
2124
+ meta.flatMap((group, index) => (index === 0 ? group : [' · ', ...group]))),
2125
+ ]),
2126
+ h('div', { key: 'side', className: 'dsh-prompt-manager__cardSide' }, [
2127
+ h('span', {
2128
+ key: 'dot',
2129
+ className: `dsh-prompt-manager__dot${injected ? '' : ' dsh-prompt-manager__dot--idle'}`,
2130
+ 'aria-hidden': 'true',
2131
+ }),
2132
+ isSubscribed(entry) ? h('span', { key: 'badge', className: 'dsh-prompt-manager__badge' }, '订阅') : null,
2133
+ h(Switch, {
2134
+ key: 'switch',
2135
+ checked: entry.enabled === true,
2136
+ label: entry.title,
2137
+ disabled: !writable || busy,
2138
+ onChange: () => toggle(entry, entry.enabled !== true),
2139
+ }),
2140
+ h(RowMenu, {
2141
+ key: 'menu',
2142
+ open: menuFor === entry.id,
2143
+ label: `更多操作:${entry.title}`,
2144
+ items: [
2145
+ { id: 'edit', label: '编辑', icon: icon('IconEditOutline16') },
2146
+ { id: 'delete', label: '删除', icon: icon('IconTrashOutline16'), disabled: !writable },
2147
+ ],
2148
+ onToggle: () => setMenuFor(menuFor === entry.id ? null : entry.id),
2149
+ onClose: () => setMenuFor(null),
2150
+ onSelect: (id) => {
2151
+ setMenuFor(null)
2152
+ if (id === 'edit') select(entry)
2153
+ else if (id === 'delete') remove(entry)
2154
+ },
2155
+ }),
2156
+ ]),
2157
+ ])
2158
+ })
2159
+
2160
+ const enabledCount = entries.filter((entry) => entry.enabled === true).length
2161
+ const ready = snapshot.status === 'ready'
2162
+ const note = snapshot.status === 'loading' || snapshot.status === null
2163
+ ? '正在读取设置…'
2164
+ : snapshot.status === 'unavailable'
2165
+ ? '设置命名空间不可用(非 loopback 页面或 Host 未挂载 settings):本页只读。'
2166
+ : null
2167
+
2168
+ return h('div', { className: 'dsh-prompt-manager' }, [
2169
+ h('h1', { key: 'heading', className: 'dsh-prompt-manager__heading' }, '提示词'),
2170
+ h('p', { key: 'lede', className: 'dsh-prompt-manager__intro' }, [
2171
+ '每条提示词都是一个独立的 system prompt section;开关、排序、正文改动在下一个模型步骤生效,不需要重启。',
2172
+ '插件仓库地址:',
2173
+ h('a', {
2174
+ key: 'repo',
2175
+ className: 'dsh-prompt-manager__sourceLink',
2176
+ href: REPO_URL,
2177
+ target: '_blank',
2178
+ rel: 'noreferrer',
2179
+ title: `${REPO_SLUG}(这个插件的仓库)`,
2180
+ }, REPO_SLUG),
2181
+ // The ask sits outside the link on purpose: it is advice, not part of
2182
+ // the address, and a click meant for the repository should not land on
2183
+ // a sentence.
2184
+ ' 拜托动个小手点颗星星吧。',
2185
+ ]),
2186
+ h('p', { key: 'dir', className: 'dsh-prompt-manager__note' }, [
2187
+ `已启用 ${String(enabledCount)}/${String(entries.length)}`,
2188
+ store !== null && typeof store.dir === 'string' ? `正文目录:${store.dir}` : '',
2189
+ ].filter((part) => part.length > 0).join(' · ')),
2190
+ note === null ? null : h('p', { key: 'note', className: 'dsh-prompt-manager__note' }, note),
2191
+ activePreset === null
2192
+ ? null
2193
+ : h('p', { key: 'preset', className: 'dsh-prompt-manager__note' }, [
2194
+ `当前组合「${activePreset.name}」生效中(${String(activePreset.entries.length)} 条)—— 单条开关只在「不用组合」时生效。`,
2195
+ ]),
2196
+ store !== null && store.writable === false
2197
+ ? h('p', { key: 'unwritable', className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error' }, '正文目录不可写,编辑器已禁用;开关和排序仍然可用。')
2198
+ : null,
2199
+ h('div', { key: 'list', className: 'dsh-prompt-manager__block' }, [
2200
+ h('div', { key: 'tabs', className: 'dsh-prompt-manager__tabs' }, [
2201
+ ...[['all', '全部'], ['local', '本地'], ['subscribed', '订阅']].map(([id, label]) => h('button', {
2202
+ key: id,
2203
+ type: 'button',
2204
+ className: filter === id ? 'dsh-prompt-manager__tab dsh-prompt-manager__tab--active' : 'dsh-prompt-manager__tab',
2205
+ onClick: () => setFilter(id),
2206
+ }, label)),
2207
+ ]),
2208
+ visible.length === 0
2209
+ ? h('div', { key: 'empty', className: 'dsh-prompt-manager__empty' }, filter === 'subscribed' ? '还没有订阅来的提示词。' : '还没有提示词,点「新增提示词」加一条。')
2210
+ : h('div', { key: 'rows', className: 'dsh-prompt-manager__list' }, rows),
2211
+ h('div', { key: 'addRow', className: 'dsh-prompt-manager__addRow' }, [
2212
+ h(AddButton, { key: 'add', disabled: !writable || busy, onClick: add }, '新增提示词'),
2213
+ h(AddButton, {
2214
+ key: 'sources',
2215
+ icon: 'IconRefreshOutline16',
2216
+ disabled: busy,
2217
+ onClick: () => setView('sources'),
2218
+ }, `订阅来源(${String(sources.length)})`),
2219
+ h(AddButton, {
2220
+ key: 'variables',
2221
+ icon: 'IconEditOutline16',
2222
+ disabled: busy,
2223
+ onClick: () => { setView('variables'); setRunReport(null) },
2224
+ }, `变量(${String(knownVariables.length)})`),
2225
+ h(AddButton, {
2226
+ key: 'presets',
2227
+ icon: 'IconEditOutline16',
2228
+ disabled: busy,
2229
+ onClick: () => { setView('presets'); setStatus(null); setMenuFor(null) },
2230
+ }, `组合(${String(presets.length)})`),
2231
+ ]),
2232
+ ]),
2233
+ ready ? null : h('div', { key: 'waiting', className: 'dsh-prompt-manager__empty' }, '设置载入后这里会显示列表。'),
2234
+ statusLine,
2235
+ h('p', { key: 'foot', className: 'dsh-prompt-manager__note' }, [
2236
+ ready ? `settings revision ${String(snapshot.revision)}` : '',
2237
+ typeof snapshot.mode === 'string' ? ` · 持久化 ${snapshot.mode}` : '',
2238
+ ' · 正文写在磁盘文件中,索引写在 settings.yaml 里;订阅条目的正文只读,来源在「来源」页里手动检查更新。',
2239
+ ].join('')),
2240
+ ])
2241
+ }
2242
+
2243
+ /**
2244
+ * Keep one surface's failure from taking down the page it sits on.
2245
+ *
2246
+ * The settings section is one thing, but the preset chip lives in the
2247
+ * composer, where an exception would cost the input box itself — so the
2248
+ * label says which surface broke and the boundary stays as small as the
2249
+ * thing it wraps.
2250
+ */
2251
+ class Boundary extends React.Component {
2252
+ constructor(props) {
2253
+ super(props)
2254
+ this.state = { error: null }
2255
+ }
2256
+
2257
+ static getDerivedStateFromError(error) {
2258
+ return { error }
2259
+ }
2260
+
2261
+ render() {
2262
+ if (this.state.error !== null) {
2263
+ const label = typeof this.props.label === 'string' ? this.props.label : '提示词页面'
2264
+ return h('div', { className: 'dsh-prompt-manager__status dsh-prompt-manager__status--error', role: 'alert' },
2265
+ `${label}出错:${String(this.state.error && this.state.error.message ? this.state.error.message : this.state.error)}`)
2266
+ }
2267
+ return this.props.children
2268
+ }
2269
+ }
2270
+
2271
+ /** Attach this bundle's stylesheet to the document. */
2272
+ function injectStyle() {
2273
+ const existing = document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_ID)}]`)
2274
+ if (existing !== null) return null
2275
+ const tag = document.createElement('style')
2276
+ tag.dataset.plugin = 'dsh-prompt-manager'
2277
+ tag.dataset.pluginCss = STYLE_ID
2278
+ tag.textContent = CSS
2279
+ document.head.appendChild(tag)
2280
+ return tag
2281
+ }
2282
+
2283
+ const name = 'dsh-prompt-manager'
2284
+
2285
+ // `settingsScope` is a hard requirement: this section is nothing but the
2286
+ // index it serves, so a host without the settings domain mounts nothing
2287
+ // rather than rendering controls that cannot persist.
2288
+ const inject = ['slots', 'settingsScope']
2289
+
2290
+ /**
2291
+ * Register the settings section and the composer chip.
2292
+ *
2293
+ * One bundle, one settings scope, two surfaces: the page where presets are
2294
+ * authored, and the control beside the input box that switches between them.
2295
+ * Both read the same namespace, so neither has to tell the other anything.
2296
+ * @param ctx - the browser plugin context.
2297
+ */
2298
+ function apply(ctx) {
2299
+ const scope = ctx.settingsScope.bind({ namespace: NAMESPACE })
2300
+ ctx.effect(() => {
2301
+ const tag = injectStyle()
2302
+ return () => { if (tag !== null) tag.remove() }
2303
+ }, 'prompt-manager: section styles')
2304
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2305
+ name: 'settings.section',
2306
+ id: NAMESPACE,
2307
+ order: SECTION_ORDER,
2308
+ label: () => '提示词',
2309
+ inject: () => ({ scope }),
2310
+ }, (props) => h(Boundary, null, h(PromptSection, props))))
2311
+ ctx.slots.inject(PRESET_SLOT, () => ctx.slots.register({
2312
+ name: PRESET_SLOT,
2313
+ id: NAMESPACE,
2314
+ order: 10,
2315
+ }, (props) => h(Boundary, { label: '提示词组合' }, h(PresetChip, { ...props, scope }))))
2316
+ }
2317
+
2318
+ return { name, inject, apply }
2319
+ },
2320
+ })