dsh-prompt-customizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "dsh-prompt-customizer",
3
+ "description": "System-prompt & tool customizer for DeepSeek Harness with a settings-panel UI: block / replace / inject prompt sections by name, and hide tools from the model catalog by exclude/include. Config lives in the dsh settings document (namespace prompt-customizer).",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "private": false,
7
+ "main": "lib/index.js",
8
+ "scripts": {
9
+ "typecheck": "tsc -p tsconfig.client.json --noEmit",
10
+ "build": "tsdown",
11
+ "check": "npm run typecheck && npm run build",
12
+ "prepack": "npm run build"
13
+ },
14
+ "exports": {
15
+ ".": "./lib/index.js",
16
+ "./client": "./client/client.js",
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ },
24
+ "client": {
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-connection",
27
+ "@deepseek-ai/dsh-client-runtime",
28
+ "@deepseek-ai/dsh-client-locale",
29
+ "@deepseek-ai/dsh-client-ui-settings"
30
+ ],
31
+ "platform": "web"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": "^4.0.1",
36
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
37
+ "react": "^18.2.0",
38
+ "react-dom": "^18.2.0"
39
+ },
40
+ "dependencies": {
41
+ "@deepseek-ai/schemastery": "^3.18.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "~18.3.1",
45
+ "@types/react-dom": "~18.3.1",
46
+ "react": "^18.3.1",
47
+ "react-dom": "~18.3.1",
48
+ "tsdown": "^0.22.14",
49
+ "typescript": "^5.6.0"
50
+ },
51
+ "files": [
52
+ "lib",
53
+ "client",
54
+ "src",
55
+ "cordis.patch.yml"
56
+ ],
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "license": "MIT"
61
+ }
@@ -0,0 +1,184 @@
1
+ /** Presets tab: save / apply / export / import complete customization snapshots. */
2
+ import { createElement as h, useRef, useState, type ReactElement, type ChangeEvent } from 'react'
3
+ import type { Config, Inventory, Preset, PresetData, SettingsScope } from './types.ts'
4
+ import type { Translate } from './locales.ts'
5
+ import { s } from './styles.ts'
6
+
7
+ export function PresetsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
8
+ const presets = cfg.presets ?? []
9
+ const [name, setName] = useState('')
10
+ const fileRef = useRef<HTMLInputElement | null>(null)
11
+
12
+ // Current section names (inventory + injected), used to decide what to block.
13
+ const currentNames = (() => {
14
+ const set = new Set<string>()
15
+ for (const sec of inv?.sections ?? []) set.add(sec.name)
16
+ for (const item of cfg.inject ?? []) set.add(item.name)
17
+ return set
18
+ })()
19
+
20
+ // Capture the current customization as a new preset (full snapshot). The
21
+ // absolute order is converted to a relative form (each section records the
22
+ // section it should follow) so the preset stays portable across prompts.
23
+ const saveCurrent = (): void => {
24
+ const presetName = name.trim() || `${t('preset')} ${presets.length + 1}`
25
+ const inject = cfg.inject ?? []
26
+ const order = inject.map((sec, i) => ({
27
+ name: sec.name,
28
+ after: i > 0 ? inject[i - 1].name : undefined,
29
+ text: sec.text,
30
+ }))
31
+ const data: PresetData = {
32
+ sections: cfg.sections,
33
+ replace: cfg.replace,
34
+ order,
35
+ tools: cfg.tools,
36
+ }
37
+ scope.set('presets', [...presets, { id: genId(), name: presetName, data }])
38
+ setName('')
39
+ }
40
+
41
+ // Apply a preset. The preset's section list defines the active set:
42
+ // - same-name sections are overridden (content + order)
43
+ // - preset sections missing from the current prompt are added
44
+ // - current sections NOT in the preset list are disabled (blocked)
45
+ // - the active sections are arranged by the preset's relative order
46
+ const applyPreset = (preset: Preset): void => {
47
+ const presetOrder = preset.data.order ?? []
48
+ const presetNames = new Set(presetOrder.map((x) => x.name))
49
+
50
+ // Blocked list: preset's own blocked names, plus any current section that
51
+ // is not part of the preset's active set; preset's active names unblocked.
52
+ const blocked = new Set(preset.data.sections ?? [])
53
+ for (const name of currentNames) if (!presetNames.has(name)) blocked.add(name)
54
+ for (const name of presetNames) blocked.delete(name)
55
+
56
+ scope.set('inject', resolveOrder(presetOrder))
57
+ scope.set('replace', { ...(cfg.replace ?? {}), ...(preset.data.replace ?? {}) })
58
+ scope.set('sections', [...blocked])
59
+ scope.set('tools', preset.data.tools ?? { exclude: [], include: [] })
60
+ scope.set('activePreset', preset.id)
61
+ }
62
+
63
+ const deletePreset = (id: string): void => {
64
+ scope.set('presets', presets.filter((p) => p.id !== id))
65
+ if (cfg.activePreset === id) scope.unset('activePreset')
66
+ }
67
+
68
+ // Serialize a preset to a downloadable JSON file.
69
+ const exportPreset = (preset: Preset): void => {
70
+ const blob = new Blob([JSON.stringify({ name: preset.name, data: preset.data }, null, 2)], { type: 'application/json' })
71
+ const url = URL.createObjectURL(blob)
72
+ const a = document.createElement('a')
73
+ a.href = url
74
+ a.download = `${preset.name}.json`
75
+ a.click()
76
+ URL.revokeObjectURL(url)
77
+ }
78
+
79
+ // Import preset(s) from a JSON file. Same-name presets are skipped; only
80
+ // presets with new names are added to the local list.
81
+ const onImportFile = (e: ChangeEvent<HTMLInputElement>): void => {
82
+ const file = e.target.files?.[0]
83
+ if (!file) return
84
+ const reader = new FileReader()
85
+ reader.onload = () => {
86
+ try {
87
+ const parsed = JSON.parse(String(reader.result))
88
+ const incoming = Array.isArray(parsed) ? parsed : [parsed]
89
+ const existing = new Set(presets.map((p) => p.name))
90
+ const added = incoming
91
+ .filter((p) => p && typeof p.name === 'string' && !existing.has(p.name))
92
+ .map((p) => ({ id: genId(), name: p.name, data: (p.data ?? {}) as PresetData }))
93
+ if (added.length > 0) scope.set('presets', [...presets, ...added])
94
+ } catch { /* ignore invalid json */ }
95
+ }
96
+ reader.readAsText(file)
97
+ e.target.value = ''
98
+ }
99
+
100
+ return h('div', { style: s.list }, [
101
+ h('div', { style: s.injectBox }, [
102
+ h('div', { style: s.rowTitle }, t('savePreset')),
103
+ h('div', { style: s.injectRow }, [
104
+ h('input', { style: { ...s.input, flex: 1 }, placeholder: t('presetName'), value: name, onChange: (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value) }),
105
+ h('button', { style: s.mini, onClick: saveCurrent }, t('save')),
106
+ ]),
107
+ ]),
108
+ h('div', { style: s.injectBox }, [
109
+ h('div', { style: s.rowTitle }, t('importPreset')),
110
+ h('div', { style: s.injectRow }, [
111
+ h('button', { style: s.mini, onClick: () => fileRef.current?.click() }, t('import')),
112
+ h('input', { ref: fileRef, type: 'file', accept: '.json,application/json', style: { display: 'none' }, onChange: onImportFile }),
113
+ ]),
114
+ ]),
115
+ presets.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
116
+ presets.map((preset) => {
117
+ const active = cfg.activePreset === preset.id
118
+ return h('div', { key: preset.id, style: s.row }, [
119
+ h('div', { style: s.rowBody }, [
120
+ h('div', { style: s.rowTitle }, [
121
+ h('span', { style: s.code }, preset.name),
122
+ active ? h('span', { style: s.badgeOk }, t('active')) : null,
123
+ ]),
124
+ ]),
125
+ h('button', { style: s.mini, onClick: () => applyPreset(preset) }, t('apply')),
126
+ h('button', { style: s.mini, onClick: () => exportPreset(preset) }, t('export')),
127
+ h('button', { style: s.mini, onClick: () => deletePreset(preset.id) }, t('delete')),
128
+ ])
129
+ }),
130
+ ])
131
+ }
132
+
133
+ function genId(): string {
134
+ return 'p_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
135
+ }
136
+
137
+ /**
138
+ * Resolve a preset's relative order into an absolute ordered list (0..n-1).
139
+ * Sections with no anchor (or whose anchor is absent) come first in preset
140
+ * order; each anchored section is inserted right after its anchor; any
141
+ * remaining sections (cycles) are appended.
142
+ */
143
+ function resolveOrder(presetOrder: Array<{ name: string; after?: string; text: string }>): Array<{ name: string; order: number; text: string }> {
144
+ const afterMap = new Map<string, string | undefined>()
145
+ const textMap = new Map<string, string>()
146
+ for (const sec of presetOrder) {
147
+ afterMap.set(sec.name, sec.after)
148
+ textMap.set(sec.name, sec.text)
149
+ }
150
+
151
+ const result: string[] = []
152
+ const placed = new Set<string>()
153
+
154
+ // Roots: no anchor, or anchor not part of the preset's own set.
155
+ for (const sec of presetOrder) {
156
+ const anchor = afterMap.get(sec.name)
157
+ if (!anchor || !afterMap.has(anchor)) {
158
+ result.push(sec.name)
159
+ placed.add(sec.name)
160
+ }
161
+ }
162
+
163
+ // Chain resolution: place each section right after its (already placed) anchor.
164
+ let changed = true
165
+ while (changed) {
166
+ changed = false
167
+ for (const sec of presetOrder) {
168
+ if (placed.has(sec.name)) continue
169
+ const anchor = afterMap.get(sec.name)
170
+ if (anchor && placed.has(anchor)) {
171
+ result.splice(result.indexOf(anchor) + 1, 0, sec.name)
172
+ placed.add(sec.name)
173
+ changed = true
174
+ }
175
+ }
176
+ }
177
+
178
+ // Any remaining (cycle / unresolved) → append in preset order.
179
+ for (const sec of presetOrder) {
180
+ if (!placed.has(sec.name)) { result.push(sec.name); placed.add(sec.name) }
181
+ }
182
+
183
+ return result.map((name, i) => ({ name, order: i, text: textMap.get(name) ?? '' }))
184
+ }
@@ -0,0 +1,207 @@
1
+ /** Prompt-sections tab: block / replace / inject / reorder prompt sections. */
2
+ import { createElement as h, useState, type ReactElement, type DragEvent, type ChangeEvent } from 'react'
3
+ import type { Config, Inventory, SettingsScope } from './types.ts'
4
+ import type { Translate } from './locales.ts'
5
+ import { s } from './styles.ts'
6
+
7
+ interface Section {
8
+ name: string
9
+ order: number
10
+ text?: string
11
+ active: boolean
12
+ replaced: boolean
13
+ }
14
+
15
+ export function SectionsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
16
+ const replace = cfg.replace ?? {}
17
+ const blockedNames = new Set(cfg.sections ?? [])
18
+ const [editing, setEditing] = useState<string | null>(null)
19
+ const [draft, setDraft] = useState('')
20
+ const [dragName, setDragName] = useState<string | null>(null)
21
+ const [dropTarget, setDropTarget] = useState<{ name: string; pos: 'above' | 'below' } | null>(null)
22
+
23
+ // Merge inventory sections with user-injected sections, sorted by order.
24
+ // Injected sections that don't exist in the original prompt appear here too,
25
+ // positioned by their order value.
26
+ const merged: Section[] = (() => {
27
+ const map = new Map<string, Section>()
28
+ for (const sec of inv?.sections ?? []) map.set(sec.name, sec)
29
+ for (const item of cfg.inject ?? []) {
30
+ const existing = map.get(item.name)
31
+ if (existing) map.set(item.name, { ...existing, order: item.order })
32
+ else map.set(item.name, {
33
+ name: item.name,
34
+ order: item.order,
35
+ text: item.text || '<动态生成>',
36
+ active: !blockedNames.has(item.name),
37
+ replaced: false,
38
+ })
39
+ }
40
+ return [...map.values()].sort((a, b) => a.order - b.order)
41
+ })()
42
+
43
+ const toggleBlock = (name: string, currentlyBlocked: boolean): void => {
44
+ const list = (cfg.sections ?? []).slice()
45
+ if (currentlyBlocked) { const i = list.indexOf(name); if (i >= 0) list.splice(i, 1) }
46
+ else list.push(name)
47
+ scope.set('sections', list)
48
+ }
49
+ const startReplace = (name: string, original: string): void => {
50
+ // Echo the ORIGINAL prompt text so the user edits from the current value.
51
+ setEditing(name)
52
+ setDraft(replace[name] ?? original)
53
+ }
54
+ const commitReplace = (name: string): void => {
55
+ const next = { ...replace }
56
+ if (draft.trim() === '') delete next[name]
57
+ else next[name] = draft
58
+ scope.set('replace', next)
59
+ setEditing(null)
60
+ }
61
+ const clearReplace = (name: string): void => {
62
+ const next = { ...replace }
63
+ delete next[name]
64
+ scope.set('replace', next)
65
+ }
66
+
67
+ // Persist the full ordered list. We treat the sorted list as an array and
68
+ // re-index every section with a sequential integer order (0, 1, 2, …), so
69
+ // there are never duplicate or fractional orders. text is left empty for
70
+ // original sections (keeps their text); injected sections keep their own.
71
+ const persistOrder = (ordered: Section[]): void => {
72
+ const list = ordered.map((sec, i) => {
73
+ const existing = (cfg.inject ?? []).find((x) => x.name === sec.name)
74
+ return { name: sec.name, order: i, text: existing ? existing.text : '' }
75
+ })
76
+ scope.set('inject', list)
77
+ }
78
+
79
+ // Move a section up/down one slot (array-index swap), then re-index.
80
+ const moveOrder = (index: number, dir: -1 | 1): void => {
81
+ const target = index + dir
82
+ if (target < 0 || target >= merged.length) return
83
+ const next = merged.slice()
84
+ const [item] = next.splice(index, 1)
85
+ next.splice(target, 0, item)
86
+ persistOrder(next)
87
+ }
88
+
89
+ const onDragStart = (e: DragEvent, name: string): void => {
90
+ setDragName(name)
91
+ e.dataTransfer.effectAllowed = 'move'
92
+ try { e.dataTransfer.setData('text/plain', name) } catch { /* ignore */ }
93
+ }
94
+ const onDragOver = (e: DragEvent, name: string): void => {
95
+ if (!dragName || dragName === name) return
96
+ e.preventDefault()
97
+ e.dataTransfer.dropEffect = 'move'
98
+ const rect = e.currentTarget.getBoundingClientRect()
99
+ const pos = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
100
+ setDropTarget({ name, pos })
101
+ }
102
+ const onDrop = (e: DragEvent, target: Section): void => {
103
+ e.preventDefault()
104
+ if (!dragName || dragName === target.name) { setDragName(null); setDropTarget(null); return }
105
+ const dragged = merged.find((x) => x.name === dragName)
106
+ if (!dragged) { setDragName(null); setDropTarget(null); return }
107
+ const next = merged.filter((x) => x.name !== dragName)
108
+ const idx = next.findIndex((x) => x.name === target.name)
109
+ const pos = dropTarget?.name === target.name ? dropTarget.pos : 'below'
110
+ next.splice(pos === 'above' ? idx : idx + 1, 0, dragged)
111
+ persistOrder(next)
112
+ setDragName(null)
113
+ setDropTarget(null)
114
+ }
115
+ const onDragEnd = (): void => { setDragName(null); setDropTarget(null) }
116
+
117
+ // Add a new injected section, placed by its order hint, then re-index.
118
+ const addSection = (name: string, order: number, text: string): void => {
119
+ const next = merged.slice()
120
+ const entry: Section = { name, order, text: text || '<动态生成>', active: true, replaced: false }
121
+ const existing = next.findIndex((x) => x.name === name)
122
+ if (existing >= 0) next[existing] = entry
123
+ else {
124
+ const idx = next.findIndex((x) => x.order > order)
125
+ if (idx < 0) next.push(entry)
126
+ else next.splice(idx, 0, entry)
127
+ }
128
+ persistOrder(next)
129
+ }
130
+
131
+ return h('div', { style: s.list }, [
132
+ merged.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
133
+ merged.map((sec, index) => {
134
+ const blocked = blockedNames.has(sec.name)
135
+ const isEditing = editing === sec.name
136
+ const isDragging = dragName === sec.name
137
+ const isDropTarget = dropTarget?.name === sec.name
138
+ const rowStyle = {
139
+ ...s.row,
140
+ ...(isDragging ? s.rowDragging : {}),
141
+ ...(isDropTarget && dropTarget.pos === 'above' ? s.rowDropAbove : {}),
142
+ ...(isDropTarget && dropTarget.pos === 'below' ? s.rowDropBelow : {}),
143
+ }
144
+ return h('div', {
145
+ key: sec.name,
146
+ style: rowStyle,
147
+ draggable: true,
148
+ onDragStart: (e: DragEvent) => onDragStart(e, sec.name),
149
+ onDragOver: (e: DragEvent) => onDragOver(e, sec.name),
150
+ onDrop: (e: DragEvent) => onDrop(e, sec),
151
+ onDragEnd,
152
+ }, [
153
+ h('span', { style: s.dragHandle, title: t('drag') }, '⠿'),
154
+ h('label', { style: s.switchWrap }, [
155
+ h('input', { type: 'checkbox', checked: blocked, onChange: () => toggleBlock(sec.name, blocked) }),
156
+ h('span', { style: blocked ? s.badgeBlocked : s.badgeOk }, blocked ? t('blockedOn') : t('blockedOff')),
157
+ ]),
158
+ h('div', { style: s.rowBody }, [
159
+ h('div', { style: s.rowTitle }, [
160
+ h('span', { style: s.code }, sec.name),
161
+ h('span', { style: s.orderTag }, '#' + index),
162
+ sec.replaced ? h('span', { style: s.badgeReplaced }, t('replaced')) : null,
163
+ ]),
164
+ isEditing
165
+ ? h('div', { style: s.editBox }, [
166
+ h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
167
+ h('div', { style: s.injectRow }, [
168
+ h('button', { style: s.mini, onClick: () => commitReplace(sec.name) }, t('add')),
169
+ h('button', { style: s.mini, onClick: () => setEditing(null) }, t('clearReplace')),
170
+ ]),
171
+ ])
172
+ : h('div', { style: s.preview }, String(sec.text ?? '').slice(0, 140) || t('dynamic')),
173
+ ]),
174
+ h('div', { style: s.arrowCol }, [
175
+ h('button', { style: s.arrow, disabled: index === 0, onClick: () => moveOrder(index, -1), title: t('moveUp') }, '↑'),
176
+ h('button', { style: s.arrow, disabled: index === merged.length - 1, onClick: () => moveOrder(index, 1), title: t('moveDown') }, '↓'),
177
+ ]),
178
+ h('button', { style: s.mini, onClick: () => replace[sec.name] ? clearReplace(sec.name) : startReplace(sec.name, String(sec.text ?? '')) }, replace[sec.name] ? t('clearReplace') : t('replace')),
179
+ ])
180
+ }),
181
+ h('div', { style: s.injectBox }, [
182
+ h('div', { style: s.rowTitle }, t('injectNew')),
183
+ h(InjectForm, { onAdd: addSection, t }),
184
+ ]),
185
+ ])
186
+ }
187
+
188
+ function InjectForm({ onAdd, t }: { onAdd: (name: string, order: number, text: string) => void; t: Translate }): ReactElement {
189
+ const [name, setName] = useState('')
190
+ const [order, setOrder] = useState('120')
191
+ const [text, setText] = useState('')
192
+
193
+ const submit = (): void => {
194
+ if (!name.trim()) return
195
+ onAdd(name.trim(), Number(order) || 120, text)
196
+ setName(''); setText(''); setOrder('120')
197
+ }
198
+
199
+ return h('div', {}, [
200
+ h('div', { style: s.injectRow }, [
201
+ h('input', { style: { ...s.input, width: '22%' }, placeholder: t('name'), value: name, onChange: (e) => setName(e.target.value) }),
202
+ h('input', { style: { ...s.input, width: '12%' }, type: 'number', placeholder: t('order'), value: order, onChange: (e) => setOrder(e.target.value) }),
203
+ h('input', { style: { ...s.input, flex: 1 }, placeholder: t('text'), value: text, onChange: (e) => setText(e.target.value) }),
204
+ h('button', { style: s.mini, onClick: submit }, t('add')),
205
+ ]),
206
+ ])
207
+ }
@@ -0,0 +1,62 @@
1
+ /** Tools tab: hide tools from the model catalog by exclude/include. */
2
+ import { createElement as h, type ReactElement } from 'react'
3
+ import type { Config, Inventory, SettingsScope } from './types.ts'
4
+ import type { Translate } from './locales.ts'
5
+ import { s } from './styles.ts'
6
+
7
+ export function ToolsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
8
+ const tools = inv?.tools ?? []
9
+ const toolsCfg = cfg.tools ?? { exclude: [], include: [] }
10
+ const exclude = toolsCfg.exclude ?? []
11
+ const include = toolsCfg.include ?? []
12
+ const includeMode = include.length > 0
13
+
14
+ const toggle = (name: string, currentlyHidden: boolean): void => {
15
+ if (includeMode) {
16
+ const next = include.slice()
17
+ if (currentlyHidden) next.push(name)
18
+ else { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
19
+ scope.set('tools', { ...toolsCfg, include: next })
20
+ } else {
21
+ const next = exclude.slice()
22
+ if (currentlyHidden) { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
23
+ else next.push(name)
24
+ scope.set('tools', { ...toolsCfg, exclude: next })
25
+ }
26
+ }
27
+ const setIncludeMode = (on: boolean): void => {
28
+ scope.set('tools', on
29
+ ? { exclude: exclude.filter((n) => tools.some((tool) => tool.name === n)), include: tools.filter((tool) => !tool.hidden).map((tool) => tool.name) }
30
+ : { exclude: ensureOnlyKnown(exclude, tools), include: [] })
31
+ }
32
+
33
+ return h('div', {}, [
34
+ h('div', { style: s.bar }, [
35
+ h('button', { style: includeMode ? s.tabActive : s.tab, onClick: () => setIncludeMode(true) }, t('includeMode')),
36
+ h('button', { style: includeMode ? s.tab : s.tabActive, onClick: () => setIncludeMode(false) }, t('excludeMode')),
37
+ ]),
38
+ h('div', { style: s.list }, [
39
+ tools.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
40
+ tools.map((tool) => {
41
+ // Hidden state comes from the CONFIG (exclude/include lists), not the
42
+ // inventory snapshot, so toggling updates the checkbox immediately.
43
+ const hidden = includeMode ? !include.includes(tool.name) : exclude.includes(tool.name)
44
+ return h('div', { key: tool.name, style: s.row }, [
45
+ h('label', { style: s.switchWrap }, [
46
+ h('input', { type: 'checkbox', checked: hidden, onChange: () => toggle(tool.name, hidden) }),
47
+ h('span', { style: hidden ? s.badgeBlocked : s.badgeOk }, hidden ? t('hiddenOn') : t('hiddenOff')),
48
+ ]),
49
+ h('div', { style: s.rowBody }, [
50
+ h('div', { style: s.rowTitle }, h('span', { style: s.code }, tool.name)),
51
+ h('div', { style: s.preview }, String(tool.description ?? '').slice(0, 140)),
52
+ ]),
53
+ ])
54
+ }),
55
+ ]),
56
+ ])
57
+ }
58
+
59
+ function ensureOnlyKnown(list: string[], known: Array<{ name: string }>): string[] {
60
+ const names = known.map((tool) => tool.name)
61
+ return list.filter((n) => names.includes(n))
62
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * dsh-prompt-customizer — browser half (TypeScript source).
3
+ *
4
+ * Registers a "提示词定制" settings section (sidebar entry) and a plugin
5
+ * settings card. The panel fetches the effective section / tool inventory
6
+ * from the host `/api/prompt-customizer/inventory` and lets the user block,
7
+ * replace, inject prompt sections and hide tools. All writes go through the
8
+ * bound settings scope (`prompt-customizer` namespace) and apply live.
9
+ *
10
+ * Built by tsdown into the __ModuleLoader__ factory bundle at client/client.js;
11
+ * the only external is the loader module table's react entry.
12
+ */
13
+ import { createElement as h, useEffect, useState, type ReactElement } from 'react'
14
+ import { DICT, type Translate } from './locales.ts'
15
+ import { s } from './styles.ts'
16
+ import type { Config, Inventory, SettingsScope } from './types.ts'
17
+ import { SectionsTab } from './SectionsTab.tsx'
18
+ import { ToolsTab } from './ToolsTab.tsx'
19
+ import { PresetsTab } from './PresetsTab.tsx'
20
+
21
+ const NS = 'prompt-customizer'
22
+ const INVENTORY_URL = '/api/prompt-customizer/inventory'
23
+
24
+ export const name = 'prompt-customizer'
25
+ export const inject = ['slots', 'locale']
26
+
27
+ /** The subset of the client cordis context this plugin touches. */
28
+ interface ClientContext {
29
+ effect(callback: () => unknown, label?: string): void
30
+ locale: {
31
+ register(namespace: string, dicts: { zh: Record<string, string>; en: Record<string, string> }): unknown
32
+ bind(namespace: string): Translate
33
+ }
34
+ inject(services: string[], callback: (scoped: SettingsScopeHost) => void): void
35
+ }
36
+
37
+ /** The host surface the settings card needs (settingsScope transport). */
38
+ interface SettingsScopeHost {
39
+ settingsScope: {
40
+ bind(options: { namespace: string }): SettingsScope
41
+ }
42
+ slots: {
43
+ inject(name: string, register: () => unknown): void
44
+ register(meta: Record<string, unknown>, render: () => unknown): unknown
45
+ }
46
+ }
47
+
48
+ export function apply(ctx: ClientContext): void {
49
+ ctx.effect(() => ctx.locale.register(NS, DICT), 'prompt-customizer: locale')
50
+ const t = ctx.locale.bind(NS)
51
+
52
+ // Register a standalone settings section (sidebar menu entry) AND a plugin
53
+ // settings card. Both go through a NESTED inject of settingsScope so the
54
+ // panel can read/write the namespace config; on hosts without the settings
55
+ // transport neither appears instead of unmounting the whole plugin.
56
+ ctx.inject(['settingsScope'], (scoped) => {
57
+ const scope = scoped.settingsScope.bind({ namespace: NS })
58
+
59
+ scoped.slots.inject('settings.section', () => scoped.slots.register({
60
+ name: 'settings.section',
61
+ id: 'prompt-customizer',
62
+ order: 50,
63
+ label: () => t('nav'),
64
+ locale: NS,
65
+ inject: () => ({ t }),
66
+ }, () => h(Panel, { scope, t })))
67
+
68
+ scoped.slots.inject('settings.plugin.item', () => scoped.slots.register({
69
+ name: 'settings.plugin.item',
70
+ key: NS,
71
+ locale: NS,
72
+ inject: () => ({ t }),
73
+ }, () => h(Panel, { scope, t })))
74
+ })
75
+ }
76
+
77
+ // ── Root panel ──────────────────────────────────────────────────────────────
78
+
79
+ function Panel({ scope, t }: { scope: SettingsScope; t: Translate }): ReactElement {
80
+ const [snap, setSnap] = useState(() => scope.getSnapshot())
81
+ const [inv, setInv] = useState<Inventory | null>(null)
82
+ const [tab, setTab] = useState<'sections' | 'tools' | 'presets'>('sections')
83
+ const [error, setError] = useState<string | null>(null)
84
+
85
+ useEffect(() => scope.subscribe(() => setSnap(scope.getSnapshot())), [scope])
86
+
87
+ const refresh = (): void => {
88
+ fetch(INVENTORY_URL)
89
+ .then((r) => r.json())
90
+ .then((data: Inventory) => { setInv(data); setError(null) })
91
+ .catch((e: unknown) => setError(String(e instanceof Error ? e.message : e)))
92
+ }
93
+ useEffect(refresh, [])
94
+
95
+ if (snap.status !== 'ready' && snap.value === undefined) {
96
+ return h('div', { style: s.muted }, t('loading'))
97
+ }
98
+
99
+ const cfg: Config = snap.value && typeof snap.value === 'object'
100
+ ? snap.value as Config
101
+ : { sections: [], replace: {}, inject: [], tools: { exclude: [], include: [] } }
102
+
103
+ return h('div', { style: s.root }, [
104
+ h('div', { style: s.bar }, [
105
+ h('button', { style: tab === 'sections' ? s.tabActive : s.tab, onClick: () => setTab('sections') }, t('tabsSections')),
106
+ h('button', { style: tab === 'tools' ? s.tabActive : s.tab, onClick: () => setTab('tools') }, t('tabsTools')),
107
+ h('button', { style: tab === 'presets' ? s.tabActive : s.tab, onClick: () => setTab('presets') }, t('tabsPresets')),
108
+ h('button', { style: s.refresh, onClick: refresh }, t('refresh')),
109
+ ]),
110
+ error ? h('div', { style: s.error }, String(error)) : null,
111
+ tab === 'sections'
112
+ ? h(SectionsTab, { cfg, inv, scope, t })
113
+ : tab === 'tools'
114
+ ? h(ToolsTab, { cfg, inv, scope, t })
115
+ : h(PresetsTab, { cfg, inv, scope, t }),
116
+ ])
117
+ }