dsh-prompt-customizer 0.2.7 → 0.3.1

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.
@@ -1,78 +1,167 @@
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'
1
+ /** 配置 Tab:两种「预设」分开 ——
2
+ * - 保存当前配置:本插件内的配置快照库(config.presets),可应用 / 导出 / 导入,
3
+ * 不会变成可选的 agent 预设;
4
+ * - 存为 agent 预设:整体 fork 当前编辑目标的预设目录,并把当前定制写进它的
5
+ * 覆盖项,新预设随即出现在顶部的编辑目标选择器里。
6
+ * 导入导出经 preset-io 双端适配:Tauri 2 桌面走原生对话框,Web 走
7
+ * 下载 / 文件选择;成功 / 失败 / 取消都有面板内消息条提示。 */
8
+ import { createElement as h, useEffect, useRef, useState, type ReactElement, type ChangeEvent } from 'react'
9
+ import type { Config, Inventory, PhaseViewKey, Preview, Preset } from './types.ts'
4
10
  import type { Translate } from './locales.ts'
5
- import { addImportedPresets, applyPresetData, buildPresetData, mergeSections, removePreset, serializePreset } from './presets.ts'
11
+ import { addImportedPresets, applyPresetData, buildPresetData, genId, mergeSections, PART_ORDER, removePreset, type ConfigPatch } from './presets.ts'
12
+ import { decodePresetExport, exportPresetFile, importPresetFile } from './preset-io.ts'
6
13
  import { s } from './styles.ts'
7
14
 
8
- export function PresetsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
15
+ /** 一次性操作提示:kind 决定配色,几秒后自动消失。 */
16
+ interface Notice {
17
+ kind: 'ok' | 'error'
18
+ text: string
19
+ }
20
+
21
+ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveAsPreset, forkSource }: {
22
+ cfg: Config
23
+ inv: Inventory | null
24
+ /** Panel 并行拉取的三阶段装配:快照只捕获当前装配里真实存在的段。 */
25
+ phases: Record<PhaseViewKey, Preview | null> | null
26
+ t: Translate
27
+ /** 应用预设:完整补丁一次落盘(显式意图,不经草稿)。 */
28
+ writePatch: (patch: ConfigPatch) => void
29
+ /** 预设库专用:永远写在全局字段(presets / activePreset 不分作用域)。 */
30
+ writeGlobal: (field: string, value: unknown) => void
31
+ /** 存为 agent 预设:fork 当前编辑目标的预设目录并写入 overrides[新名]。 */
32
+ saveAsPreset: (name: string) => Promise<boolean>
33
+ /** fork 来源的显示名(当前编辑目标预设);undefined = 全局目标,由服务端回落默认预设。 */
34
+ forkSource: string | undefined
35
+ }): ReactElement {
9
36
  const presets = cfg.presets ?? []
10
37
  const [name, setName] = useState('')
38
+ // 「存为 agent 预设」的名字与在途标记(与服务端往返期间禁用按钮)。
39
+ const [agentName, setAgentName] = useState('')
40
+ const [creating, setCreating] = useState(false)
41
+ const [notice, setNotice] = useState<Notice | null>(null)
42
+ // Web 回退用的隐藏文件输入(tauri 不可用时才点它)。
11
43
  const fileRef = useRef<HTMLInputElement | null>(null)
44
+ // 消息条自动消失的定时器;新消息会重置旧定时器。
45
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
46
+
47
+ useEffect(() => () => {
48
+ if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
49
+ }, [])
50
+
51
+ // 显示提示;ok 与 error 分别配色,4 秒后自动消失。
52
+ const show = (kind: Notice['kind'], text: string): void => {
53
+ if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
54
+ setNotice({ kind, text })
55
+ noticeTimer.current = setTimeout(() => setNotice(null), 4000)
56
+ }
12
57
 
13
58
  const blockedNames = new Set(cfg.sections ?? [])
14
- const merged = mergeSections(inv, cfg, blockedNames)
59
+ // 快照的捕获宇宙 = 当前预设三个阶段装配里真实存在的段名。「本系统全部提示词」
60
+ // 是跨预设只增不减的并集池,直接拿它当捕获集合会把别的预设独有段名灌进快照,
61
+ // 应用回来就是一条条空白幽灵段。装配数据未就绪(宇宙为空)时不过滤,避免捕获成空。
62
+ const assemblyNames = new Set<string>()
63
+ for (const key of PART_ORDER) {
64
+ for (const sec of phases?.[key]?.baseSections ?? []) assemblyNames.add(sec.name)
65
+ }
66
+ const mergedAll = mergeSections(inv, cfg, blockedNames)
67
+ const merged = assemblyNames.size === 0
68
+ ? mergedAll
69
+ : mergedAll.filter((sec) => sec.source === 'custom' || assemblyNames.has(sec.name))
15
70
  const currentNames = new Set(merged.map((sec) => sec.name))
16
71
 
17
- // Capture the current customization as a new preset (full snapshot). The
18
- // absolute order is converted to a relative form (each section records the
19
- // section it should follow) so the preset stays portable across prompts.
72
+ // 把当前定制捕获为新预设(完整快照)。绝对顺序会被转成相对形式
73
+ // (每段记录它应跟随的前一段),因此预设可以跨不同提示词移植。
20
74
  const saveCurrent = (): void => {
21
75
  const presetName = name.trim() || `${t('preset')} ${presets.length + 1}`
22
76
  const data = buildPresetData(cfg, merged)
23
- scope.set('presets', [...presets, { id: genId(), name: presetName, data }])
77
+ writeGlobal('presets', [...presets, { id: genId(), name: presetName, data }])
24
78
  setName('')
25
79
  }
26
80
 
27
- // Apply a preset. The patch is computed by the pure helper:
28
- // - same-name sections are overridden (content + order)
29
- // - preset sections missing from the current prompt are added
30
- // - current sections NOT in the preset list are disabled (blocked)
31
- // - only the preset's ACTIVE sections are unblocked (its own blocked list
32
- // is preserved)
81
+ // 应用预设。补丁由纯函数助手计算:
82
+ // - 同名段被覆盖(内容 + 顺序)
83
+ // - 预设中有、当前提示词中没有的段被添加
84
+ // - 当前有、但不在预设列表中的段被屏蔽
85
+ // - 只有预设的「激活段」被解除屏蔽(预设自己的屏蔽名单被保留)
86
+ // 四个定制字段以一个补丁写入当前编辑目标(全局顶层或 overrides[id]);
87
+ // 快照库(presets / activePreset)永远保持在全局。
33
88
  const applyPreset = (preset: Preset): void => {
34
- const patch = applyPresetData(preset.data, cfg, currentNames)
35
- scope.set('inject', patch.inject)
36
- scope.set('replace', patch.replace)
37
- scope.set('sections', patch.sections)
38
- scope.set('tools', patch.tools)
39
- scope.set('activePreset', preset.id)
89
+ writePatch(applyPresetData(preset.data, cfg, currentNames))
90
+ writeGlobal('activePreset', preset.id)
40
91
  }
41
92
 
42
93
  const deletePreset = (id: string): void => {
43
94
  const next = removePreset(presets, id, cfg.activePreset)
44
- scope.set('presets', next.presets)
45
- if (next.activeId === undefined && cfg.activePreset === id) scope.unset('activePreset')
95
+ writeGlobal('presets', next.presets)
96
+ if (next.activeId === undefined && cfg.activePreset === id) writeGlobal('activePreset', undefined)
97
+ }
98
+
99
+ // 导出一个预设:Tauri 桌面走原生保存对话框,Web(或 tauri 插件不可用
100
+ // 时)回退为下载。saved/downloaded = 成功;用户取消单独提示,不算失败。
101
+ const exportPreset = async (preset: Preset): Promise<void> => {
102
+ try {
103
+ const res = await exportPresetFile(preset)
104
+ if (res.ok) show('ok', t('exportOk'))
105
+ else if (res.cancelled) show('ok', t('exportCancel'))
106
+ else show('error', `${t('exportFail')}${res.message ? ': ' + res.message : ''}`)
107
+ } catch (e) {
108
+ show('error', `${t('exportFail')}: ${e instanceof Error ? e.message : String(e)}`)
109
+ }
110
+ }
111
+
112
+ // 解析导入文本 → 去重追加 → 提示新增数量。解析失败提示错误。
113
+ const importParsed = (text: string): void => {
114
+ try {
115
+ const parsed = decodePresetExport(text)
116
+ const next = addImportedPresets(presets, parsed, genId)
117
+ const added = next.length - presets.length
118
+ if (added <= 0) show('ok', t('importNone'))
119
+ else {
120
+ writeGlobal('presets', next)
121
+ show('ok', `${t('importOk')} (+${added})`)
122
+ }
123
+ } catch (e) {
124
+ show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
125
+ }
46
126
  }
47
127
 
48
- // Serialize a preset to a downloadable JSON file.
49
- const exportPreset = (preset: Preset): void => {
50
- const blob = new Blob([JSON.stringify(serializePreset(preset), null, 2)], { type: 'application/json' })
51
- const url = URL.createObjectURL(blob)
52
- const a = document.createElement('a')
53
- a.href = url
54
- a.download = `${preset.name}.json`
55
- a.click()
56
- URL.revokeObjectURL(url)
128
+ // 导入预设:优先 Tauri 原生打开对话框;unavailable(Web 或桌面端缺
129
+ // dialog/fs 插件)时点隐藏 file input 回退到文件选择器。取消单独提示。
130
+ const importPreset = async (): Promise<void> => {
131
+ try {
132
+ const res = await importPresetFile()
133
+ if (res.kind === 'text') { importParsed(res.text); return }
134
+ if (res.kind === 'cancelled') { show('ok', t('importCancel')); return }
135
+ fileRef.current?.click()
136
+ } catch (e) {
137
+ show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
138
+ }
57
139
  }
58
140
 
59
- // Import preset(s) from a JSON file. Same-name presets are skipped; only
60
- // presets with new names are added to the local list.
141
+ // Web 回退路径:文件选择器的 onChange。用户取消(无文件)静默返回。
61
142
  const onImportFile = (e: ChangeEvent<HTMLInputElement>): void => {
62
143
  const file = e.target.files?.[0]
144
+ e.target.value = ''
63
145
  if (!file) return
64
146
  const reader = new FileReader()
65
- reader.onload = () => {
66
- try {
67
- const parsed = JSON.parse(String(reader.result))
68
- scope.set('presets', addImportedPresets(presets, parsed, genId))
69
- } catch { /* ignore invalid json */ }
70
- }
147
+ reader.onload = () => importParsed(String(reader.result))
148
+ reader.onerror = () => show('error', t('importFail'))
71
149
  reader.readAsText(file)
72
- e.target.value = ''
150
+ }
151
+
152
+ // 存为 agent 预设:fork 当前编辑目标的预设目录 + 把当前定制写进它的覆盖项。
153
+ // 成败由服务端返回,提示统一走面板顶部的消息条(Panel 的 flash),这里只在
154
+ // 成功时清空输入框,避免同一条消息显示两遍。
155
+ const createAgentPreset = async (): Promise<void> => {
156
+ const trimmed = agentName.trim()
157
+ if (trimmed.length === 0 || creating) return
158
+ setCreating(true)
159
+ if (await saveAsPreset(trimmed)) setAgentName('')
160
+ setCreating(false)
73
161
  }
74
162
 
75
163
  return h('div', { style: s.list }, [
164
+ notice ? h('div', { style: notice.kind === 'ok' ? s.noticeOk : s.error }, notice.text) : null,
76
165
  h('div', { style: s.injectBox }, [
77
166
  h('div', { style: s.rowTitle }, t('savePreset')),
78
167
  h('div', { style: s.injectRow }, [
@@ -80,10 +169,18 @@ export function PresetsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory
80
169
  h('button', { style: s.mini, onClick: saveCurrent }, t('save')),
81
170
  ]),
82
171
  ]),
172
+ h('div', { style: s.injectBox }, [
173
+ h('div', { style: s.rowTitle }, t('saveAsPresetCard')),
174
+ h('div', { style: s.muted }, t('saveAsPresetHint', { name: forkSource ?? t('forkSourceDefault') })),
175
+ h('div', { style: s.injectRow }, [
176
+ h('input', { style: { ...s.input, flex: 1 }, placeholder: t('agentPresetName'), value: agentName, onChange: (e: ChangeEvent<HTMLInputElement>) => setAgentName(e.target.value) }),
177
+ h('button', { style: s.mini, disabled: creating || agentName.trim().length === 0, onClick: () => { void createAgentPreset() } }, t('saveAsPreset')),
178
+ ]),
179
+ ]),
83
180
  h('div', { style: s.injectBox }, [
84
181
  h('div', { style: s.rowTitle }, t('importPreset')),
85
182
  h('div', { style: s.injectRow }, [
86
- h('button', { style: s.mini, onClick: () => fileRef.current?.click() }, t('import')),
183
+ h('button', { style: s.mini, onClick: () => { void importPreset() } }, t('import')),
87
184
  h('input', { ref: fileRef, type: 'file', accept: '.json,application/json', style: { display: 'none' }, onChange: onImportFile }),
88
185
  ]),
89
186
  ]),
@@ -98,13 +195,9 @@ export function PresetsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory
98
195
  ]),
99
196
  ]),
100
197
  h('button', { style: s.mini, onClick: () => applyPreset(preset) }, t('apply')),
101
- h('button', { style: s.mini, onClick: () => exportPreset(preset) }, t('export')),
198
+ h('button', { style: s.mini, onClick: () => { void exportPreset(preset) } }, t('export')),
102
199
  h('button', { style: s.mini, onClick: () => deletePreset(preset.id) }, t('delete')),
103
200
  ])
104
201
  }),
105
202
  ])
106
203
  }
107
-
108
- function genId(): string {
109
- return 'p_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
110
- }
@@ -1,54 +1,70 @@
1
- /** Preview tab: two read-only sub-tabs — the FINAL assembled system prompt
2
- * after every plugin's filter, and the tools the current preset will inject
3
- * (whitelist-style, after include/exclude). Neither view edits config. */
4
- import { createElement as h, useEffect, useRef, useState, type ReactElement } from 'react'
5
- import type { Preview, SettingsScope } from './types.ts'
1
+ /** 预览 Tab:两个只读子视图 —— 所有插件过滤后的最终装配系统提示词,
2
+ * 以及当前定制(黑名单过滤之后)将注入的工具目录视图。
3
+ * 两个视图都不修改配置。预览目标跟随面板顶部的编辑目标(`target`);
4
+ * 数据直接来自 Panel 并行拉取的三阶段装配(`phases`)—— 与提示词/
5
+ * 工具 Tab 同源(同一份 base + post 视图),杜绝三个视图数据不一致。
6
+ * 阶段按钮恒为引导期 / 常驻期 / 压缩受控期三个(与另两个 Tab 同源同形)。 */
7
+ import { createElement as h, useState, type ReactElement } from 'react'
8
+ import type { PhaseViewKey, Preview } from './types.ts'
6
9
  import type { Translate } from './locales.ts'
10
+ import { PART_ORDER } from './presets.ts'
7
11
  import { s } from './styles.ts'
8
12
  import { PreviewTools } from './PreviewTools.tsx'
9
13
 
10
- const PREVIEW_URL = '/api/prompt-customizer/preview'
11
-
12
- export function PreviewTab({ scope, t }: { scope: SettingsScope; t: Translate }): ReactElement {
14
+ export function PreviewTab({ t, active, refreshId, target, version, phases }: {
15
+ t: Translate
16
+ active: boolean
17
+ refreshId: number
18
+ /** 顶部编辑目标:undefined = 全局(standard scope);字符串 = agent 预设 id。 */
19
+ target: string | undefined
20
+ /** 配置写入计数:Panel 每次成功写配置后 +1(驱动统一重载)。
21
+ * ponytail: refreshId/version 由 Panel 的统一重载消费,本组件内未直接使用。 */
22
+ version: number
23
+ /** Panel 并行拉取的三阶段装配(模型视角统一数据源)。 */
24
+ phases: Record<PhaseViewKey, Preview | null> | null
25
+ }): ReactElement {
13
26
  const [sub, setSub] = useState<'prompt' | 'tools'>('prompt')
14
- const [data, setData] = useState<Preview | null>(null)
15
- const [error, setError] = useState<string | null>(null)
27
+ const [phase, setPhase] = useState<PhaseViewKey>('bootstrap')
28
+
29
+ const data = phases?.[phase] ?? null
30
+ // 按当前所选阶段判定「段级定制没进最终提示词」:有 complete 段整段接管就说
31
+ // 接管,否则探测本插件产出的段是否被下游丢弃(丢弃规则不止一种)。
32
+ const lossNote = data === null || data === undefined ? null
33
+ : data.takenOverBy !== undefined
34
+ ? t('sectionsTakenOver', { name: data.takenOverBy })
35
+ : data.lostSections !== undefined
36
+ ? t('sectionsLost', { emitted: data.lostSections.emitted, survived: data.lostSections.survived })
37
+ : null
16
38
 
17
- // Reload whenever the namespace config changes (block/replace/inject), so the
18
- // section count and final text always reflect the current customization —
19
- // not just on mount. A config change that lands while a fetch is in flight is
20
- // never dropped: it schedules one trailing reload, so the preview converges
21
- // to the latest config even when the in-flight response was served before the
22
- // write committed on the host.
23
- const inFlight = useRef(false)
24
- const pending = useRef(false)
25
- const load = (): void => {
26
- if (inFlight.current) { pending.current = true; return }
27
- inFlight.current = true
28
- setData(null)
29
- fetch(PREVIEW_URL, { cache: 'no-store' })
30
- .then((r) => r.json())
31
- .then((body: Preview) => {
32
- if (body && body.ok === false) throw new Error((body as { error?: string }).error ?? 'preview failed')
33
- setData(body)
34
- setError(null)
35
- })
36
- .catch((e: unknown) => setError(String(e instanceof Error ? e.message : e)))
37
- .finally(() => {
38
- inFlight.current = false
39
- if (pending.current) { pending.current = false; load() }
40
- })
41
- }
42
- useEffect(load, [])
43
- useEffect(() => scope.subscribe(load), [scope])
39
+ // 阶段按钮命名:引导期 / 常驻期 / 压缩受控期。
40
+ const stageLabel = (p: PhaseViewKey): string =>
41
+ p === 'bootstrap' ? t('phaseStageGuide') : p === 'compaction' ? t('phaseStageControlled') : t('phaseStageResident')
44
42
 
45
43
  return h('div', { style: s.list }, [
46
44
  h('div', { style: s.bar }, [
47
45
  h('button', { style: sub === 'prompt' ? s.tabActive : s.tab, onClick: () => setSub('prompt') }, t('previewPrompt')),
48
46
  h('button', { style: sub === 'tools' ? s.tabActive : s.tab, onClick: () => setSub('tools') }, t('previewTools')),
47
+ h('div', { style: { marginLeft: 'auto', display: 'flex', gap: 4 } },
48
+ PART_ORDER.map((key) => h('button', {
49
+ key,
50
+ style: phase === key ? s.tabActive : s.tab,
51
+ onClick: () => setPhase(key),
52
+ }, stageLabel(key)))),
49
53
  ]),
50
- error ? h('div', { style: s.error }, String(error)) : null,
51
- data === null && !error ? h('div', { style: s.muted }, t('loading')) : null,
54
+ // 该阶段的本插件段级产出没进最终提示词(整段接管或被下游丢弃)。
55
+ lossNote !== null ? h('div', { style: s.noticeWarn }, lossNote) : null,
56
+ // scope 挂载失败回退全局层时明确警示:这不是该预设的原生装配。
57
+ data !== null && data.scopeResolved === false
58
+ ? h('div', { style: s.noticeWarn }, t('scopeFallback'))
59
+ : null,
60
+ // 伪会话触发预设插件异常时本次预览降级为无会话装配(原生阶段规则未运行)。
61
+ data !== null && data.degraded === true
62
+ ? h('div', { style: s.noticeWarn }, t('previewDegraded'))
63
+ : null,
64
+ phases === null ? h('div', { style: s.muted }, t('loading')) : null,
65
+ phases !== null && data === null
66
+ ? h('div', { style: s.error }, t('previewFail'))
67
+ : null,
52
68
  sub === 'prompt'
53
69
  ? data
54
70
  ? [
@@ -60,7 +76,17 @@ export function PreviewTab({ scope, t }: { scope: SettingsScope; t: Translate })
60
76
  ]
61
77
  : null
62
78
  : data
63
- ? h(PreviewTools, { tools: data.tools, t })
79
+ ? [
80
+ // 模型视角 vs 注册表视角的对照:预览按所选阶段运行全部装配规则
81
+ // (含预设原生的阶段裁剪),某些预设(如 PTC / Code Mode)会把
82
+ // 完整目录包装成单一工具,注册表原始目录仍列在工具 Tab。
83
+ h('div', { style: s.rowTitle }, [
84
+ h('span', { style: s.muted }, t('previewToolsHint')),
85
+ h('span', { style: s.orderTag },
86
+ `${data.tools.length} / ${data.registryTotal ?? '?'} ${t('previewToolsCount')}`),
87
+ ]),
88
+ h(PreviewTools, { tools: data.tools, t }),
89
+ ]
64
90
  : null,
65
91
  ])
66
- }
92
+ }
@@ -1,13 +1,15 @@
1
- /** Tools preview: read-only whitelist-style list of the tools the current
2
- * preset will inject (the FINAL model-visible catalog after include/exclude).
3
- * A tool being listed means it is visible — there is no "blocked" state here.
4
- * This view never edits the blacklist/whitelist config. */
1
+ /** 工具预览:当前定制(黑名单过滤之后)最终对模型可见的工具目录,
2
+ * 只读、清单式呈现:列出即代表模型看得见 —— 这里没有「已屏蔽」状态。
3
+ * 本视图绝不修改黑名单配置。 */
5
4
  import { createElement as h, type ReactElement } from 'react'
6
5
  import type { Translate } from './locales.ts'
7
6
  import { s } from './styles.ts'
8
7
 
8
+ /** 预览工具条目:对象形态或纯字符串工具名(统一归一化为对象)。 */
9
9
  export type PreviewTool = { name: string; description: string } | string
10
10
 
11
+ /** 把字符串形态的条目补全为 { name, description: '' }。 */
12
+
11
13
  function norm(tool: PreviewTool): { name: string; description: string } {
12
14
  return typeof tool === 'string' ? { name: tool, description: '' } : tool
13
15
  }