dsh-prompt-customizer 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +278 -227
- package/README.i18n.yaml +2 -2
- package/README.md +278 -227
- package/client/client.js +82 -80
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +8 -8
- package/lib/catalog.js +131 -131
- package/lib/effective.js +161 -158
- package/lib/index.js +943 -943
- package/lib/promotion.js +80 -80
- package/lib/schema.js +124 -124
- package/lib/sectionOps.d.mts +32 -32
- package/lib/sectionOps.mjs +128 -133
- package/lib/store.js +124 -124
- package/lib/vars.js +147 -147
- package/package.json +84 -64
- package/src/client/PresetsTab.tsx +281 -281
- package/src/client/PreviewTab.tsx +91 -91
- package/src/client/PreviewTools.tsx +33 -33
- package/src/client/SectionsTab.tsx +539 -556
- package/src/client/ToolsTab.tsx +293 -266
- package/src/client/index.ts +433 -422
- package/src/client/locales.ts +263 -259
- package/src/client/preset-io.ts +226 -226
- package/src/client/presets.ts +413 -408
- package/src/client/styles.ts +49 -49
- package/src/client/types.ts +128 -128
|
@@ -1,281 +1,281 @@
|
|
|
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'
|
|
10
|
-
import type { Translate } from './locales.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'
|
|
13
|
-
import { s } from './styles.ts'
|
|
14
|
-
|
|
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, onReset, envBlocklist }: {
|
|
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
|
-
/** 恢复初始状态:清空全部定制并关闭 forceSections(与不装插件等效)。 */
|
|
36
|
-
onReset: () => void
|
|
37
|
-
/** 全局环境变量黑名单(永远顶层字段,不分作用域);导出随行、导入并集。 */
|
|
38
|
-
envBlocklist: string[]
|
|
39
|
-
}): ReactElement {
|
|
40
|
-
const presets = cfg.presets ?? []
|
|
41
|
-
const [name, setName] = useState('')
|
|
42
|
-
// 黑名单「添加」输入框的暂存值。
|
|
43
|
-
const [blockInput, setBlockInput] = useState('')
|
|
44
|
-
// 「存为 agent 预设」的名字与在途标记(与服务端往返期间禁用按钮)。
|
|
45
|
-
const [agentName, setAgentName] = useState('')
|
|
46
|
-
const [creating, setCreating] = useState(false)
|
|
47
|
-
const [notice, setNotice] = useState<Notice | null>(null)
|
|
48
|
-
// Web 回退用的隐藏文件输入(tauri 不可用时才点它)。
|
|
49
|
-
const fileRef = useRef<HTMLInputElement | null>(null)
|
|
50
|
-
// 消息条自动消失的定时器;新消息会重置旧定时器。
|
|
51
|
-
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
52
|
-
|
|
53
|
-
useEffect(() => () => {
|
|
54
|
-
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
|
|
55
|
-
}, [])
|
|
56
|
-
|
|
57
|
-
// 显示提示;ok 与 error 分别配色,4 秒后自动消失。
|
|
58
|
-
const show = (kind: Notice['kind'], text: string): void => {
|
|
59
|
-
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
|
|
60
|
-
setNotice({ kind, text })
|
|
61
|
-
noticeTimer.current = setTimeout(() => setNotice(null), 4000)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const blockedNames = new Set(cfg.sections ?? [])
|
|
65
|
-
// 快照的捕获宇宙 = 当前预设三个阶段装配里真实存在的段名。「本系统全部提示词」
|
|
66
|
-
// 是跨预设只增不减的并集池,直接拿它当捕获集合会把别的预设独有段名灌进快照,
|
|
67
|
-
// 应用回来就是一条条空白幽灵段。装配数据未就绪(宇宙为空)时不过滤,避免捕获成空。
|
|
68
|
-
const assemblyNames = new Set<string>()
|
|
69
|
-
for (const key of PART_ORDER) {
|
|
70
|
-
for (const sec of phases?.[key]?.baseSections ?? []) assemblyNames.add(sec.name)
|
|
71
|
-
}
|
|
72
|
-
const mergedAll = mergeSections(inv, cfg, blockedNames)
|
|
73
|
-
const merged = assemblyNames.size === 0
|
|
74
|
-
? mergedAll
|
|
75
|
-
: mergedAll.filter((sec) => sec.source === 'custom' || assemblyNames.has(sec.name))
|
|
76
|
-
const currentNames = new Set(merged.map((sec) => sec.name))
|
|
77
|
-
|
|
78
|
-
// 把当前定制捕获为新预设(完整快照)。绝对顺序会被转成相对形式
|
|
79
|
-
// (每段记录它应跟随的前一段),因此预设可以跨不同提示词移植。
|
|
80
|
-
const saveCurrent = (): void => {
|
|
81
|
-
const presetName = name.trim() || `${t('preset')} ${presets.length + 1}`
|
|
82
|
-
const data = buildPresetData(cfg, merged)
|
|
83
|
-
writeGlobal('presets', [...presets, { id: genId(), name: presetName, data }])
|
|
84
|
-
setName('')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 应用预设。补丁由纯函数助手计算:
|
|
88
|
-
// - 同名段被覆盖(内容 + 顺序)
|
|
89
|
-
// -
|
|
90
|
-
// - 当前有、但不在预设列表中的段被屏蔽
|
|
91
|
-
// - 只有预设的「激活段」被解除屏蔽(预设自己的屏蔽名单被保留)
|
|
92
|
-
// 四个定制字段以一个补丁写入当前编辑目标(全局顶层或 overrides[id]);
|
|
93
|
-
// 快照库(presets / activePreset)永远保持在全局。
|
|
94
|
-
const applyPreset = (preset: Preset): void => {
|
|
95
|
-
writePatch(applyPresetData(preset.data, cfg, currentNames))
|
|
96
|
-
writeGlobal('activePreset', preset.id)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const deletePreset = (id: string): void => {
|
|
100
|
-
const next = removePreset(presets, id, cfg.activePreset)
|
|
101
|
-
writeGlobal('presets', next.presets)
|
|
102
|
-
if (next.activeId === undefined && cfg.activePreset === id) writeGlobal('activePreset', undefined)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 导出一个预设:Tauri 桌面走原生保存对话框,Web(或 tauri 插件不可用
|
|
106
|
-
// 时)回退为下载。saved/downloaded = 成功;用户取消单独提示,不算失败。
|
|
107
|
-
const exportPreset = async (preset: Preset): Promise<void> => {
|
|
108
|
-
try {
|
|
109
|
-
const res = await exportPresetFile(preset, undefined, envBlocklist)
|
|
110
|
-
if (res.ok) show('ok', t('exportOk'))
|
|
111
|
-
else if (res.cancelled) show('ok', t('exportCancel'))
|
|
112
|
-
else show('error', `${t('exportFail')}${res.message ? ': ' + res.message : ''}`)
|
|
113
|
-
} catch (e) {
|
|
114
|
-
show('error', `${t('exportFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 解析导入文本 → 去重追加 → 提示新增数量。解析失败提示错误。
|
|
119
|
-
// 单对象格式的文件还可能随行携带全局环境变量黑名单:与当前名单并集合并
|
|
120
|
-
// (只增不减),新增条数一并报进提示。
|
|
121
|
-
const importParsed = (text: string): void => {
|
|
122
|
-
try {
|
|
123
|
-
const parsed = decodePresetExport(text)
|
|
124
|
-
const next = addImportedPresets(presets, parsed, genId)
|
|
125
|
-
const added = next.length - presets.length
|
|
126
|
-
const fileBlock = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
127
|
-
&& Array.isArray((parsed as { envBlocklist?: unknown }).envBlocklist)
|
|
128
|
-
? ((parsed as { envBlocklist?: unknown[] }).envBlocklist ?? []).filter((e): e is string => typeof e === 'string')
|
|
129
|
-
: []
|
|
130
|
-
const fresh = fileBlock.filter((e) => !envBlocklist.includes(e))
|
|
131
|
-
if (fresh.length > 0) writeGlobal('envBlocklist', [...envBlocklist, ...fresh])
|
|
132
|
-
const mergedNote = fresh.length > 0 ? t('importBlockMerge', { count: fresh.length }) : ''
|
|
133
|
-
if (added <= 0) show('ok', t('importNone') + mergedNote)
|
|
134
|
-
else {
|
|
135
|
-
writeGlobal('presets', next)
|
|
136
|
-
show('ok', `${t('importOk')} (+${added})${mergedNote}`)
|
|
137
|
-
}
|
|
138
|
-
} catch (e) {
|
|
139
|
-
show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// 导入预设:优先 Tauri 原生打开对话框;unavailable(Web 或桌面端缺
|
|
144
|
-
// dialog/fs 插件)时点隐藏 file input 回退到文件选择器。取消单独提示。
|
|
145
|
-
const importPreset = async (): Promise<void> => {
|
|
146
|
-
try {
|
|
147
|
-
const res = await importPresetFile()
|
|
148
|
-
if (res.kind === 'text') { importParsed(res.text); return }
|
|
149
|
-
if (res.kind === 'cancelled') { show('ok', t('importCancel')); return }
|
|
150
|
-
fileRef.current?.click()
|
|
151
|
-
} catch (e) {
|
|
152
|
-
show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Web 回退路径:文件选择器的 onChange。用户取消(无文件)静默返回。
|
|
157
|
-
const onImportFile = (e: ChangeEvent<HTMLInputElement>): void => {
|
|
158
|
-
const file = e.target.files?.[0]
|
|
159
|
-
e.target.value = ''
|
|
160
|
-
if (!file) return
|
|
161
|
-
const reader = new FileReader()
|
|
162
|
-
reader.onload = () => importParsed(String(reader.result))
|
|
163
|
-
reader.onerror = () => show('error', t('importFail'))
|
|
164
|
-
reader.readAsText(file)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// 存为 agent 预设:fork 当前编辑目标的预设目录 + 把当前定制写进它的覆盖项。
|
|
168
|
-
// 成败由服务端返回,提示统一走面板顶部的消息条(Panel 的 flash),这里只在
|
|
169
|
-
// 成功时清空输入框,避免同一条消息显示两遍。
|
|
170
|
-
const createAgentPreset = async (): Promise<void> => {
|
|
171
|
-
const trimmed = agentName.trim()
|
|
172
|
-
if (trimmed.length === 0 || creating) return
|
|
173
|
-
setCreating(true)
|
|
174
|
-
if (await saveAsPreset(trimmed)) setAgentName('')
|
|
175
|
-
setCreating(false)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 环境变量黑名单:全局字段,改动即写(与 forceSections 同一通道,不经草稿)。
|
|
179
|
-
const removeBlockEntry = (entry: string): void => {
|
|
180
|
-
writeGlobal('envBlocklist', envBlocklist.filter((e) => e !== entry))
|
|
181
|
-
}
|
|
182
|
-
const addBlockEntry = (): void => {
|
|
183
|
-
const entry = blockInput.trim()
|
|
184
|
-
setBlockInput('')
|
|
185
|
-
if (entry === '' || envBlocklist.includes(entry)) return
|
|
186
|
-
writeGlobal('envBlocklist', [...envBlocklist, entry])
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return h('div', { style: s.list }, [
|
|
190
|
-
notice ? h('div', { style: notice.kind === 'ok' ? s.noticeOk : s.error }, notice.text) : null,
|
|
191
|
-
h('div', { style: s.injectBox }, [
|
|
192
|
-
h('div', { style: s.rowTitle }, t('savePreset')),
|
|
193
|
-
h('div', { style: s.injectRow }, [
|
|
194
|
-
h('input', { style: { ...s.input, flex: 1 }, placeholder: t('presetName'), value: name, onChange: (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value) }),
|
|
195
|
-
h('button', { style: s.mini, onClick: saveCurrent }, t('save')),
|
|
196
|
-
]),
|
|
197
|
-
]),
|
|
198
|
-
h('div', { style: s.injectBox }, [
|
|
199
|
-
h('div', { style: s.rowTitle }, t('saveAsPresetCard')),
|
|
200
|
-
h('div', { style: s.muted }, t('saveAsPresetHint', { name: forkSource ?? t('forkSourceDefault') })),
|
|
201
|
-
h('div', { style: s.injectRow }, [
|
|
202
|
-
h('input', { style: { ...s.input, flex: 1 }, placeholder: t('agentPresetName'), value: agentName, onChange: (e: ChangeEvent<HTMLInputElement>) => setAgentName(e.target.value) }),
|
|
203
|
-
h('button', { style: s.mini, disabled: creating || agentName.trim().length === 0, onClick: () => { void createAgentPreset() } }, t('saveAsPreset')),
|
|
204
|
-
]),
|
|
205
|
-
]),
|
|
206
|
-
h('div', { style: s.injectBox }, [
|
|
207
|
-
h('div', { style: s.rowTitle }, t('importPreset')),
|
|
208
|
-
h('div', { style: s.injectRow }, [
|
|
209
|
-
h('button', { style: s.mini, onClick: () => { void importPreset() } }, t('import')),
|
|
210
|
-
h('input', { ref: fileRef, type: 'file', accept: '.json,application/json', style: { display: 'none' }, onChange: onImportFile }),
|
|
211
|
-
]),
|
|
212
|
-
]),
|
|
213
|
-
h('div', { style: s.injectBox }, [
|
|
214
|
-
h('div', { style: s.rowTitle }, t('forceTitle')),
|
|
215
|
-
h('div', { style: s.muted }, t('forceHint')),
|
|
216
|
-
h('div', { style: s.injectRow }, [
|
|
217
|
-
h('label', { style: s.switchWrap }, [
|
|
218
|
-
h('input', {
|
|
219
|
-
type: 'checkbox',
|
|
220
|
-
checked: cfg.forceSections !== false,
|
|
221
|
-
// forceSections 是全局字段,与 presets/activePreset 一样永远写顶层。
|
|
222
|
-
onChange: (e: ChangeEvent<HTMLInputElement>) => writeGlobal('forceSections', e.target.checked),
|
|
223
|
-
}),
|
|
224
|
-
h('span', { style: cfg.forceSections !== false ? s.badgeOk : s.badgeBlocked },
|
|
225
|
-
cfg.forceSections !== false ? t('forceOn') : t('forceOff')),
|
|
226
|
-
]),
|
|
227
|
-
]),
|
|
228
|
-
]),
|
|
229
|
-
h('div', { style: s.injectBox }, [
|
|
230
|
-
h('div', { style: s.rowTitle }, t('envBlockTitle')),
|
|
231
|
-
h('div', { style: s.muted }, t('envBlockHint')),
|
|
232
|
-
h('div', { style: { ...s.toolWrap, marginTop: 6 } }, envBlocklist.map((entry) => h('span', {
|
|
233
|
-
key: entry,
|
|
234
|
-
style: { ...s.toolChip, display: 'inline-flex', alignItems: 'center', gap: 4 },
|
|
235
|
-
}, [
|
|
236
|
-
entry,
|
|
237
|
-
h('span', { style: { cursor: 'pointer' }, title: t('delete'), onClick: () => removeBlockEntry(entry) }, '×'),
|
|
238
|
-
]))),
|
|
239
|
-
envBlocklist.length === 0 ? h('div', { style: s.muted }, t('envBlockEmpty')) : null,
|
|
240
|
-
h('div', { style: { ...s.injectRow, marginTop: 6 } }, [
|
|
241
|
-
h('input', {
|
|
242
|
-
style: { ...s.input, flex: 1 },
|
|
243
|
-
placeholder: t('envBlockAdd'),
|
|
244
|
-
value: blockInput,
|
|
245
|
-
onChange: (e: ChangeEvent<HTMLInputElement>) => setBlockInput(e.target.value),
|
|
246
|
-
}),
|
|
247
|
-
h('button', { style: s.mini, disabled: blockInput.trim() === '', onClick: addBlockEntry }, t('envBlockAddAction')),
|
|
248
|
-
]),
|
|
249
|
-
h('details', { style: { marginTop: 6 } }, [
|
|
250
|
-
h('summary', { style: { ...s.muted, cursor: 'pointer' } }, `${t('envVarsTitle')} (${inv?.variables?.length ?? 0})`),
|
|
251
|
-
h('div', { style: { ...s.muted, marginTop: 4 } }, t('envVarsHint')),
|
|
252
|
-
h('div', { style: { ...s.toolWrap, marginTop: 4 } }, (inv?.variables ?? []).map((name) => h('span', { key: name, style: s.toolChip }, `{{${name}}}`))),
|
|
253
|
-
]),
|
|
254
|
-
]),
|
|
255
|
-
h('div', { style: s.injectBox }, [
|
|
256
|
-
h('div', { style: s.rowTitle }, t('resetTitle')),
|
|
257
|
-
h('div', { style: s.muted }, t('resetHint')),
|
|
258
|
-
h('div', { style: s.injectRow }, [
|
|
259
|
-
h('button', { style: s.mini, onClick: () => {
|
|
260
|
-
// 二次确认:清空不可撤销,必须明确点头才动手。
|
|
261
|
-
if (window.confirm(t('resetConfirm'))) onReset()
|
|
262
|
-
} }, t('resetAction')),
|
|
263
|
-
]),
|
|
264
|
-
]),
|
|
265
|
-
presets.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
|
|
266
|
-
presets.map((preset) => {
|
|
267
|
-
const active = cfg.activePreset === preset.id
|
|
268
|
-
return h('div', { key: preset.id, style: s.row }, [
|
|
269
|
-
h('div', { style: s.rowBody }, [
|
|
270
|
-
h('div', { style: s.rowTitle }, [
|
|
271
|
-
h('span', { style: s.code }, preset.name),
|
|
272
|
-
active ? h('span', { style: s.badgeOk }, t('active')) : null,
|
|
273
|
-
]),
|
|
274
|
-
]),
|
|
275
|
-
h('button', { style: s.mini, onClick: () => applyPreset(preset) }, t('apply')),
|
|
276
|
-
h('button', { style: s.mini, onClick: () => { void exportPreset(preset) } }, t('export')),
|
|
277
|
-
h('button', { style: s.mini, onClick: () => deletePreset(preset.id) }, t('delete')),
|
|
278
|
-
])
|
|
279
|
-
}),
|
|
280
|
-
])
|
|
281
|
-
}
|
|
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'
|
|
10
|
+
import type { Translate } from './locales.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'
|
|
13
|
+
import { s } from './styles.ts'
|
|
14
|
+
|
|
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, onReset, envBlocklist }: {
|
|
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
|
+
/** 恢复初始状态:清空全部定制并关闭 forceSections(与不装插件等效)。 */
|
|
36
|
+
onReset: () => void
|
|
37
|
+
/** 全局环境变量黑名单(永远顶层字段,不分作用域);导出随行、导入并集。 */
|
|
38
|
+
envBlocklist: string[]
|
|
39
|
+
}): ReactElement {
|
|
40
|
+
const presets = cfg.presets ?? []
|
|
41
|
+
const [name, setName] = useState('')
|
|
42
|
+
// 黑名单「添加」输入框的暂存值。
|
|
43
|
+
const [blockInput, setBlockInput] = useState('')
|
|
44
|
+
// 「存为 agent 预设」的名字与在途标记(与服务端往返期间禁用按钮)。
|
|
45
|
+
const [agentName, setAgentName] = useState('')
|
|
46
|
+
const [creating, setCreating] = useState(false)
|
|
47
|
+
const [notice, setNotice] = useState<Notice | null>(null)
|
|
48
|
+
// Web 回退用的隐藏文件输入(tauri 不可用时才点它)。
|
|
49
|
+
const fileRef = useRef<HTMLInputElement | null>(null)
|
|
50
|
+
// 消息条自动消失的定时器;新消息会重置旧定时器。
|
|
51
|
+
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
52
|
+
|
|
53
|
+
useEffect(() => () => {
|
|
54
|
+
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
// 显示提示;ok 与 error 分别配色,4 秒后自动消失。
|
|
58
|
+
const show = (kind: Notice['kind'], text: string): void => {
|
|
59
|
+
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current)
|
|
60
|
+
setNotice({ kind, text })
|
|
61
|
+
noticeTimer.current = setTimeout(() => setNotice(null), 4000)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const blockedNames = new Set(cfg.sections ?? [])
|
|
65
|
+
// 快照的捕获宇宙 = 当前预设三个阶段装配里真实存在的段名。「本系统全部提示词」
|
|
66
|
+
// 是跨预设只增不减的并集池,直接拿它当捕获集合会把别的预设独有段名灌进快照,
|
|
67
|
+
// 应用回来就是一条条空白幽灵段。装配数据未就绪(宇宙为空)时不过滤,避免捕获成空。
|
|
68
|
+
const assemblyNames = new Set<string>()
|
|
69
|
+
for (const key of PART_ORDER) {
|
|
70
|
+
for (const sec of phases?.[key]?.baseSections ?? []) assemblyNames.add(sec.name)
|
|
71
|
+
}
|
|
72
|
+
const mergedAll = mergeSections(inv, cfg, blockedNames)
|
|
73
|
+
const merged = assemblyNames.size === 0
|
|
74
|
+
? mergedAll
|
|
75
|
+
: mergedAll.filter((sec) => sec.source === 'custom' || assemblyNames.has(sec.name))
|
|
76
|
+
const currentNames = new Set(merged.map((sec) => sec.name))
|
|
77
|
+
|
|
78
|
+
// 把当前定制捕获为新预设(完整快照)。绝对顺序会被转成相对形式
|
|
79
|
+
// (每段记录它应跟随的前一段),因此预设可以跨不同提示词移植。
|
|
80
|
+
const saveCurrent = (): void => {
|
|
81
|
+
const presetName = name.trim() || `${t('preset')} ${presets.length + 1}`
|
|
82
|
+
const data = buildPresetData(cfg, merged)
|
|
83
|
+
writeGlobal('presets', [...presets, { id: genId(), name: presetName, data }])
|
|
84
|
+
setName('')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 应用预设。补丁由纯函数助手计算:
|
|
88
|
+
// - 同名段被覆盖(内容 + 顺序)
|
|
89
|
+
// - 预设中有、当前系统里匹配不上的段默认跳过(跨系统导入不凭空建段)
|
|
90
|
+
// - 当前有、但不在预设列表中的段被屏蔽
|
|
91
|
+
// - 只有预设的「激活段」被解除屏蔽(预设自己的屏蔽名单被保留)
|
|
92
|
+
// 四个定制字段以一个补丁写入当前编辑目标(全局顶层或 overrides[id]);
|
|
93
|
+
// 快照库(presets / activePreset)永远保持在全局。
|
|
94
|
+
const applyPreset = (preset: Preset): void => {
|
|
95
|
+
writePatch(applyPresetData(preset.data, cfg, currentNames))
|
|
96
|
+
writeGlobal('activePreset', preset.id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const deletePreset = (id: string): void => {
|
|
100
|
+
const next = removePreset(presets, id, cfg.activePreset)
|
|
101
|
+
writeGlobal('presets', next.presets)
|
|
102
|
+
if (next.activeId === undefined && cfg.activePreset === id) writeGlobal('activePreset', undefined)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 导出一个预设:Tauri 桌面走原生保存对话框,Web(或 tauri 插件不可用
|
|
106
|
+
// 时)回退为下载。saved/downloaded = 成功;用户取消单独提示,不算失败。
|
|
107
|
+
const exportPreset = async (preset: Preset): Promise<void> => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await exportPresetFile(preset, undefined, envBlocklist)
|
|
110
|
+
if (res.ok) show('ok', t('exportOk'))
|
|
111
|
+
else if (res.cancelled) show('ok', t('exportCancel'))
|
|
112
|
+
else show('error', `${t('exportFail')}${res.message ? ': ' + res.message : ''}`)
|
|
113
|
+
} catch (e) {
|
|
114
|
+
show('error', `${t('exportFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 解析导入文本 → 去重追加 → 提示新增数量。解析失败提示错误。
|
|
119
|
+
// 单对象格式的文件还可能随行携带全局环境变量黑名单:与当前名单并集合并
|
|
120
|
+
// (只增不减),新增条数一并报进提示。
|
|
121
|
+
const importParsed = (text: string): void => {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = decodePresetExport(text)
|
|
124
|
+
const next = addImportedPresets(presets, parsed, genId)
|
|
125
|
+
const added = next.length - presets.length
|
|
126
|
+
const fileBlock = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
127
|
+
&& Array.isArray((parsed as { envBlocklist?: unknown }).envBlocklist)
|
|
128
|
+
? ((parsed as { envBlocklist?: unknown[] }).envBlocklist ?? []).filter((e): e is string => typeof e === 'string')
|
|
129
|
+
: []
|
|
130
|
+
const fresh = fileBlock.filter((e) => !envBlocklist.includes(e))
|
|
131
|
+
if (fresh.length > 0) writeGlobal('envBlocklist', [...envBlocklist, ...fresh])
|
|
132
|
+
const mergedNote = fresh.length > 0 ? t('importBlockMerge', { count: fresh.length }) : ''
|
|
133
|
+
if (added <= 0) show('ok', t('importNone') + mergedNote)
|
|
134
|
+
else {
|
|
135
|
+
writeGlobal('presets', next)
|
|
136
|
+
show('ok', `${t('importOk')} (+${added})${mergedNote}`)
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 导入预设:优先 Tauri 原生打开对话框;unavailable(Web 或桌面端缺
|
|
144
|
+
// dialog/fs 插件)时点隐藏 file input 回退到文件选择器。取消单独提示。
|
|
145
|
+
const importPreset = async (): Promise<void> => {
|
|
146
|
+
try {
|
|
147
|
+
const res = await importPresetFile()
|
|
148
|
+
if (res.kind === 'text') { importParsed(res.text); return }
|
|
149
|
+
if (res.kind === 'cancelled') { show('ok', t('importCancel')); return }
|
|
150
|
+
fileRef.current?.click()
|
|
151
|
+
} catch (e) {
|
|
152
|
+
show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Web 回退路径:文件选择器的 onChange。用户取消(无文件)静默返回。
|
|
157
|
+
const onImportFile = (e: ChangeEvent<HTMLInputElement>): void => {
|
|
158
|
+
const file = e.target.files?.[0]
|
|
159
|
+
e.target.value = ''
|
|
160
|
+
if (!file) return
|
|
161
|
+
const reader = new FileReader()
|
|
162
|
+
reader.onload = () => importParsed(String(reader.result))
|
|
163
|
+
reader.onerror = () => show('error', t('importFail'))
|
|
164
|
+
reader.readAsText(file)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 存为 agent 预设:fork 当前编辑目标的预设目录 + 把当前定制写进它的覆盖项。
|
|
168
|
+
// 成败由服务端返回,提示统一走面板顶部的消息条(Panel 的 flash),这里只在
|
|
169
|
+
// 成功时清空输入框,避免同一条消息显示两遍。
|
|
170
|
+
const createAgentPreset = async (): Promise<void> => {
|
|
171
|
+
const trimmed = agentName.trim()
|
|
172
|
+
if (trimmed.length === 0 || creating) return
|
|
173
|
+
setCreating(true)
|
|
174
|
+
if (await saveAsPreset(trimmed)) setAgentName('')
|
|
175
|
+
setCreating(false)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 环境变量黑名单:全局字段,改动即写(与 forceSections 同一通道,不经草稿)。
|
|
179
|
+
const removeBlockEntry = (entry: string): void => {
|
|
180
|
+
writeGlobal('envBlocklist', envBlocklist.filter((e) => e !== entry))
|
|
181
|
+
}
|
|
182
|
+
const addBlockEntry = (): void => {
|
|
183
|
+
const entry = blockInput.trim()
|
|
184
|
+
setBlockInput('')
|
|
185
|
+
if (entry === '' || envBlocklist.includes(entry)) return
|
|
186
|
+
writeGlobal('envBlocklist', [...envBlocklist, entry])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return h('div', { style: s.list }, [
|
|
190
|
+
notice ? h('div', { style: notice.kind === 'ok' ? s.noticeOk : s.error }, notice.text) : null,
|
|
191
|
+
h('div', { style: s.injectBox }, [
|
|
192
|
+
h('div', { style: s.rowTitle }, t('savePreset')),
|
|
193
|
+
h('div', { style: s.injectRow }, [
|
|
194
|
+
h('input', { style: { ...s.input, flex: 1 }, placeholder: t('presetName'), value: name, onChange: (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value) }),
|
|
195
|
+
h('button', { style: s.mini, onClick: saveCurrent }, t('save')),
|
|
196
|
+
]),
|
|
197
|
+
]),
|
|
198
|
+
h('div', { style: s.injectBox }, [
|
|
199
|
+
h('div', { style: s.rowTitle }, t('saveAsPresetCard')),
|
|
200
|
+
h('div', { style: s.muted }, t('saveAsPresetHint', { name: forkSource ?? t('forkSourceDefault') })),
|
|
201
|
+
h('div', { style: s.injectRow }, [
|
|
202
|
+
h('input', { style: { ...s.input, flex: 1 }, placeholder: t('agentPresetName'), value: agentName, onChange: (e: ChangeEvent<HTMLInputElement>) => setAgentName(e.target.value) }),
|
|
203
|
+
h('button', { style: s.mini, disabled: creating || agentName.trim().length === 0, onClick: () => { void createAgentPreset() } }, t('saveAsPreset')),
|
|
204
|
+
]),
|
|
205
|
+
]),
|
|
206
|
+
h('div', { style: s.injectBox }, [
|
|
207
|
+
h('div', { style: s.rowTitle }, t('importPreset')),
|
|
208
|
+
h('div', { style: s.injectRow }, [
|
|
209
|
+
h('button', { style: s.mini, onClick: () => { void importPreset() } }, t('import')),
|
|
210
|
+
h('input', { ref: fileRef, type: 'file', accept: '.json,application/json', style: { display: 'none' }, onChange: onImportFile }),
|
|
211
|
+
]),
|
|
212
|
+
]),
|
|
213
|
+
h('div', { style: s.injectBox }, [
|
|
214
|
+
h('div', { style: s.rowTitle }, t('forceTitle')),
|
|
215
|
+
h('div', { style: s.muted }, t('forceHint')),
|
|
216
|
+
h('div', { style: s.injectRow }, [
|
|
217
|
+
h('label', { style: s.switchWrap }, [
|
|
218
|
+
h('input', {
|
|
219
|
+
type: 'checkbox',
|
|
220
|
+
checked: cfg.forceSections !== false,
|
|
221
|
+
// forceSections 是全局字段,与 presets/activePreset 一样永远写顶层。
|
|
222
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => writeGlobal('forceSections', e.target.checked),
|
|
223
|
+
}),
|
|
224
|
+
h('span', { style: cfg.forceSections !== false ? s.badgeOk : s.badgeBlocked },
|
|
225
|
+
cfg.forceSections !== false ? t('forceOn') : t('forceOff')),
|
|
226
|
+
]),
|
|
227
|
+
]),
|
|
228
|
+
]),
|
|
229
|
+
h('div', { style: s.injectBox }, [
|
|
230
|
+
h('div', { style: s.rowTitle }, t('envBlockTitle')),
|
|
231
|
+
h('div', { style: s.muted }, t('envBlockHint')),
|
|
232
|
+
h('div', { style: { ...s.toolWrap, marginTop: 6 } }, envBlocklist.map((entry) => h('span', {
|
|
233
|
+
key: entry,
|
|
234
|
+
style: { ...s.toolChip, display: 'inline-flex', alignItems: 'center', gap: 4 },
|
|
235
|
+
}, [
|
|
236
|
+
entry,
|
|
237
|
+
h('span', { style: { cursor: 'pointer' }, title: t('delete'), onClick: () => removeBlockEntry(entry) }, '×'),
|
|
238
|
+
]))),
|
|
239
|
+
envBlocklist.length === 0 ? h('div', { style: s.muted }, t('envBlockEmpty')) : null,
|
|
240
|
+
h('div', { style: { ...s.injectRow, marginTop: 6 } }, [
|
|
241
|
+
h('input', {
|
|
242
|
+
style: { ...s.input, flex: 1 },
|
|
243
|
+
placeholder: t('envBlockAdd'),
|
|
244
|
+
value: blockInput,
|
|
245
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => setBlockInput(e.target.value),
|
|
246
|
+
}),
|
|
247
|
+
h('button', { style: s.mini, disabled: blockInput.trim() === '', onClick: addBlockEntry }, t('envBlockAddAction')),
|
|
248
|
+
]),
|
|
249
|
+
h('details', { style: { marginTop: 6 } }, [
|
|
250
|
+
h('summary', { style: { ...s.muted, cursor: 'pointer' } }, `${t('envVarsTitle')} (${inv?.variables?.length ?? 0})`),
|
|
251
|
+
h('div', { style: { ...s.muted, marginTop: 4 } }, t('envVarsHint')),
|
|
252
|
+
h('div', { style: { ...s.toolWrap, marginTop: 4 } }, (inv?.variables ?? []).map((name) => h('span', { key: name, style: s.toolChip }, `{{${name}}}`))),
|
|
253
|
+
]),
|
|
254
|
+
]),
|
|
255
|
+
h('div', { style: s.injectBox }, [
|
|
256
|
+
h('div', { style: s.rowTitle }, t('resetTitle')),
|
|
257
|
+
h('div', { style: s.muted }, t('resetHint')),
|
|
258
|
+
h('div', { style: s.injectRow }, [
|
|
259
|
+
h('button', { style: s.mini, onClick: () => {
|
|
260
|
+
// 二次确认:清空不可撤销,必须明确点头才动手。
|
|
261
|
+
if (window.confirm(t('resetConfirm'))) onReset()
|
|
262
|
+
} }, t('resetAction')),
|
|
263
|
+
]),
|
|
264
|
+
]),
|
|
265
|
+
presets.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
|
|
266
|
+
presets.map((preset) => {
|
|
267
|
+
const active = cfg.activePreset === preset.id
|
|
268
|
+
return h('div', { key: preset.id, style: s.row }, [
|
|
269
|
+
h('div', { style: s.rowBody }, [
|
|
270
|
+
h('div', { style: s.rowTitle }, [
|
|
271
|
+
h('span', { style: s.code }, preset.name),
|
|
272
|
+
active ? h('span', { style: s.badgeOk }, t('active')) : null,
|
|
273
|
+
]),
|
|
274
|
+
]),
|
|
275
|
+
h('button', { style: s.mini, onClick: () => applyPreset(preset) }, t('apply')),
|
|
276
|
+
h('button', { style: s.mini, onClick: () => { void exportPreset(preset) } }, t('export')),
|
|
277
|
+
h('button', { style: s.mini, onClick: () => deletePreset(preset.id) }, t('delete')),
|
|
278
|
+
])
|
|
279
|
+
}),
|
|
280
|
+
])
|
|
281
|
+
}
|