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/README.md +107 -0
- package/README.zh-CN.md +107 -0
- package/client/client.js +896 -0
- package/client/client.js.map +1 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +344 -0
- package/lib/index.js +140 -0
- package/lib/schema.js +27 -0
- package/package.json +61 -0
- package/src/client/PresetsTab.tsx +184 -0
- package/src/client/SectionsTab.tsx +207 -0
- package/src/client/ToolsTab.tsx +62 -0
- package/src/client/index.ts +117 -0
- package/src/client/locales.ts +81 -0
- package/src/client/styles.ts +36 -0
- package/src/client/types.ts +41 -0
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
|
+
}
|