dsh-custom-mode 1.2.0 → 1.4.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/assistants.mjs +5 -4
- package/client.js +108 -10
- package/index.mjs +107 -25
- package/journal.mjs +40 -6
- package/locales.mjs +58 -0
- package/package.json +3 -3
- package/preset/prompt-tool.mjs +56 -0
package/assistants.mjs
CHANGED
|
@@ -334,14 +334,14 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
|
|
|
334
334
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
335
335
|
const direction = input !== null && typeof input === 'object' ? input.direction : undefined
|
|
336
336
|
if (direction !== 'up' && direction !== 'down') {
|
|
337
|
-
return { ok: false, error: '未知的排序方向:' + String(direction) }
|
|
337
|
+
return { ok: false, code: 'badDirection', params: { direction: String(direction) }, error: '未知的排序方向:' + String(direction) }
|
|
338
338
|
}
|
|
339
339
|
const list = assistantsFromRoster(rows)
|
|
340
340
|
const index = list.findIndex((item) => item.id === id)
|
|
341
|
-
if (index === -1) return { ok: false, error: '找不到助手「' + id + '」。' }
|
|
341
|
+
if (index === -1) return { ok: false, code: 'unknownAssistant', params: { id }, error: '找不到助手「' + id + '」。' }
|
|
342
342
|
const target = direction === 'up' ? index - 1 : index + 1
|
|
343
|
-
if (target < 0) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最前面。' }
|
|
344
|
-
if (target >= list.length) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最后面。' }
|
|
343
|
+
if (target < 0) return { ok: false, code: 'alreadyFirst', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最前面。' }
|
|
344
|
+
if (target >= list.length) return { ok: false, code: 'alreadyLast', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最后面。' }
|
|
345
345
|
|
|
346
346
|
const next = [...list]
|
|
347
347
|
const [moved] = next.splice(index, 1)
|
|
@@ -357,6 +357,7 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
|
|
|
357
357
|
ok: true,
|
|
358
358
|
id,
|
|
359
359
|
order: next.map((item) => item.id),
|
|
360
|
+
code: 'reordered',
|
|
360
361
|
note: '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
|
|
361
362
|
}
|
|
362
363
|
}
|
package/client.js
CHANGED
|
@@ -92,6 +92,35 @@ try {
|
|
|
92
92
|
"msg.reorderFailed": "调整顺序失败",
|
|
93
93
|
"msg.imported": "已导入到编辑器(还没有保存):检查后点「保存」。",
|
|
94
94
|
"msg.importFailed": "导入失败",
|
|
95
|
+
"api.badDirection": "未知的排序方向:{direction}",
|
|
96
|
+
"api.unknownAssistant": "找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。",
|
|
97
|
+
"api.alreadyFirst": "「{name}」已经在最前面。",
|
|
98
|
+
"api.alreadyLast": "「{name}」已经在最后面。",
|
|
99
|
+
"api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
|
|
100
|
+
"api.created": "已创建「{name}」。现在可以为它写系统提示词。",
|
|
101
|
+
"api.duplicated": "已复制自「{from}」。两份从此各改各的。",
|
|
102
|
+
"api.deleted": "已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。",
|
|
103
|
+
"api.reordered": "顺序已保存:新建会话时的模式选择器按这个顺序排列。",
|
|
104
|
+
"api.nameRequired": "请先给新助手起个名字。",
|
|
105
|
+
"api.nameTooLong": "名字太长了(上限 {max} 个字符)。",
|
|
106
|
+
"api.descriptionTooLong": "描述太长了(上限 {max} 个字符)。",
|
|
107
|
+
"api.promptEmpty": "保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。",
|
|
108
|
+
"api.badMode": "未知的基础模式:{mode}",
|
|
109
|
+
"api.dirExists": "目录已存在,请换一个名字:{path}",
|
|
110
|
+
"api.compositionMissing": "找不到组成文件:{path}",
|
|
111
|
+
"api.writeFailed": "写入失败:{detail}",
|
|
112
|
+
"api.promptWriteFailed": "写入提示词失败:{detail}",
|
|
113
|
+
"api.renderFailed": "生成组成文件失败:{detail}",
|
|
114
|
+
"api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
|
|
115
|
+
"api.promptReadFailed": "读取提示词失败:{detail}",
|
|
116
|
+
"api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
|
|
117
|
+
"api.deleteFailed": "删除失败:{detail}",
|
|
118
|
+
"api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
|
|
119
|
+
"api.badJson": "请求体不是合法 JSON",
|
|
120
|
+
"warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
|
|
121
|
+
"warn.toolOff": "「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。",
|
|
122
|
+
"warn.noDescription": "没有描述:新建会话的模式选择器里会显示成「暂无描述」。",
|
|
123
|
+
"warn.noName": "没有名字:模式选择器里会显示成目录 id(例如 custom)。",
|
|
95
124
|
"history.label": "改动历史",
|
|
96
125
|
"history.pick": "选择要载入的版本…",
|
|
97
126
|
"history.load": "载入这一版",
|
|
@@ -231,6 +260,35 @@ try {
|
|
|
231
260
|
"msg.reorderFailed": "Could not reorder",
|
|
232
261
|
"msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
|
|
233
262
|
"msg.importFailed": "Import failed",
|
|
263
|
+
"api.badDirection": "Unknown reorder direction: {direction}",
|
|
264
|
+
"api.unknownAssistant": "No assistant 「{id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
|
|
265
|
+
"api.alreadyFirst": "「{name}」 is already first.",
|
|
266
|
+
"api.alreadyLast": "「{name}」 is already last.",
|
|
267
|
+
"api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
|
|
268
|
+
"api.created": "Created 「{name}」. You can write its system prompt now.",
|
|
269
|
+
"api.duplicated": "Copied from 「{from}」. The two are independent from now on.",
|
|
270
|
+
"api.deleted": "Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.",
|
|
271
|
+
"api.reordered": "Order saved: the new-session mode picker follows it.",
|
|
272
|
+
"api.nameRequired": "Give the new assistant a name first.",
|
|
273
|
+
"api.nameTooLong": "That name is too long (limit {max} characters).",
|
|
274
|
+
"api.descriptionTooLong": "That description is too long (limit {max} characters).",
|
|
275
|
+
"api.promptEmpty": "Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.",
|
|
276
|
+
"api.badMode": "Unknown base mode: {mode}",
|
|
277
|
+
"api.dirExists": "That directory already exists — pick another name: {path}",
|
|
278
|
+
"api.compositionMissing": "Composition file not found: {path}",
|
|
279
|
+
"api.writeFailed": "Write failed: {detail}",
|
|
280
|
+
"api.promptWriteFailed": "Writing the prompt failed: {detail}",
|
|
281
|
+
"api.renderFailed": "Rendering the composition failed: {detail}",
|
|
282
|
+
"api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
|
|
283
|
+
"api.promptReadFailed": "Reading the prompt failed: {detail}",
|
|
284
|
+
"api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
|
|
285
|
+
"api.deleteFailed": "Delete failed: {detail}",
|
|
286
|
+
"api.versionMissing": "That version is gone (the history is capped).",
|
|
287
|
+
"api.badJson": "The request body is not valid JSON",
|
|
288
|
+
"warn.personaOffWithPrompt": "The \"Identity (system prompt)\" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.",
|
|
289
|
+
"warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
|
|
290
|
+
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
291
|
+
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
234
292
|
"history.label": "Change history",
|
|
235
293
|
"history.pick": "Pick a version to load…",
|
|
236
294
|
"history.load": "Load this version",
|
|
@@ -612,6 +670,8 @@ try {
|
|
|
612
670
|
// 改动历史:列表来自 state(只有元数据),正文点「载入这一版」时按需取。
|
|
613
671
|
history: Array.isArray(state.history) ? state.history : [],
|
|
614
672
|
historyPick: "",
|
|
673
|
+
// 「配置了却不生效」的告警码;文案按当前语言渲染。
|
|
674
|
+
warnings: Array.isArray(state.warnings) ? state.warnings : [],
|
|
615
675
|
}
|
|
616
676
|
}
|
|
617
677
|
|
|
@@ -710,6 +770,32 @@ try {
|
|
|
710
770
|
*/
|
|
711
771
|
const t = (key, fallback) => translate(key, fallback, shellT)
|
|
712
772
|
|
|
773
|
+
/** 把 `{name}` 这类占位符换成 params 里的值(缺失就原样留着,便于发现漏参)。 */
|
|
774
|
+
const fillPlaceholders = (template, params) =>
|
|
775
|
+
template.replace(/\{(\w+)\}/g, (match, key) =>
|
|
776
|
+
params !== null && typeof params === "object" && params[key] !== undefined ? String(params[key]) : match,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* 服务端结果的**本地化渲染**。
|
|
781
|
+
*
|
|
782
|
+
* 宿主仍然回中文的 `note`/`error`(那是 HTTP API 的兼容面),但页面优先用自己的词典按 `code`
|
|
783
|
+
* 渲染 —— 否则英文界面会在出错那一刻掉回中文(外部审阅点名的"硬伤")。服务端若带来了页面无从
|
|
784
|
+
* 知道的细节(路径、底层错误),放在 `params` 里填进模板。没有 `code` 时退回宿主文案,兼容旧宿主。
|
|
785
|
+
*/
|
|
786
|
+
const apiText = (result, fallbackKey) => {
|
|
787
|
+
const code = result !== null && typeof result === "object" && typeof result.code === "string" ? result.code : ""
|
|
788
|
+
if (code !== "") {
|
|
789
|
+
const template = translate("api." + code, "", shellT)
|
|
790
|
+
if (template !== "") return fillPlaceholders(template, result.params)
|
|
791
|
+
}
|
|
792
|
+
if (result !== null && typeof result === "object") {
|
|
793
|
+
if (typeof result.error === "string" && result.error !== "") return result.error
|
|
794
|
+
if (typeof result.note === "string" && result.note !== "") return result.note
|
|
795
|
+
}
|
|
796
|
+
return t(fallbackKey)
|
|
797
|
+
}
|
|
798
|
+
|
|
713
799
|
const [list, setList] = react.useState(null)
|
|
714
800
|
const [selected, setSelected] = react.useState("")
|
|
715
801
|
/** id → { payload, saved, value }: loaded state, baseline and live draft. */
|
|
@@ -754,7 +840,7 @@ try {
|
|
|
754
840
|
const result = await fetchState(id)
|
|
755
841
|
if (result === null || result === undefined || result.ok !== true) {
|
|
756
842
|
setFailed(true)
|
|
757
|
-
setStatus((result
|
|
843
|
+
setStatus(apiText(result, "msg.readFailed"))
|
|
758
844
|
return
|
|
759
845
|
}
|
|
760
846
|
const fresh = draftOf(result)
|
|
@@ -771,7 +857,7 @@ try {
|
|
|
771
857
|
const result = await fetchList()
|
|
772
858
|
if (result === null || result === undefined || result.ok !== true) {
|
|
773
859
|
setFailed(true)
|
|
774
|
-
setStatus((result
|
|
860
|
+
setStatus(apiText(result, "msg.readFailed"))
|
|
775
861
|
return
|
|
776
862
|
}
|
|
777
863
|
const assistants = Array.isArray(result.assistants) ? result.assistants : []
|
|
@@ -885,11 +971,11 @@ try {
|
|
|
885
971
|
if (result !== null && result !== undefined && result.ok === true) {
|
|
886
972
|
setNewName("")
|
|
887
973
|
setFailed(false)
|
|
888
|
-
setStatus(result
|
|
974
|
+
setStatus(apiText(result, from === undefined ? "msg.created" : "msg.duplicated"))
|
|
889
975
|
await reload(result.id)
|
|
890
976
|
} else {
|
|
891
977
|
setFailed(true)
|
|
892
|
-
setStatus((result
|
|
978
|
+
setStatus(apiText(result, "msg.createFailed"))
|
|
893
979
|
}
|
|
894
980
|
} catch (error) {
|
|
895
981
|
setFailed(true)
|
|
@@ -913,11 +999,11 @@ try {
|
|
|
913
999
|
const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
|
|
914
1000
|
if (result !== null && result !== undefined && result.ok === true) {
|
|
915
1001
|
setFailed(false)
|
|
916
|
-
setStatus(result
|
|
1002
|
+
setStatus(apiText(result, "msg.reordered"))
|
|
917
1003
|
await reload(draft.id)
|
|
918
1004
|
} else {
|
|
919
1005
|
setFailed(true)
|
|
920
|
-
setStatus((result
|
|
1006
|
+
setStatus(apiText(result, "msg.reorderFailed"))
|
|
921
1007
|
}
|
|
922
1008
|
} catch (error) {
|
|
923
1009
|
setFailed(true)
|
|
@@ -1022,7 +1108,7 @@ try {
|
|
|
1022
1108
|
setDeleteOpen(false)
|
|
1023
1109
|
setAcknowledged(false)
|
|
1024
1110
|
setFailed(false)
|
|
1025
|
-
setStatus(result
|
|
1111
|
+
setStatus(apiText(result, "msg.deleted"))
|
|
1026
1112
|
setEntries((previous) => {
|
|
1027
1113
|
const next = { ...previous }
|
|
1028
1114
|
delete next[id]
|
|
@@ -1031,7 +1117,7 @@ try {
|
|
|
1031
1117
|
await reload("")
|
|
1032
1118
|
} else {
|
|
1033
1119
|
setFailed(true)
|
|
1034
|
-
setStatus((result
|
|
1120
|
+
setStatus(apiText(result, "msg.deleteFailed"))
|
|
1035
1121
|
}
|
|
1036
1122
|
} catch (error) {
|
|
1037
1123
|
setFailed(true)
|
|
@@ -1077,7 +1163,7 @@ try {
|
|
|
1077
1163
|
})
|
|
1078
1164
|
if (result !== null && result !== undefined && result.ok === true) {
|
|
1079
1165
|
setFailed(false)
|
|
1080
|
-
setStatus(result
|
|
1166
|
+
setStatus(apiText(result, "msg.saved"))
|
|
1081
1167
|
// The server normalises what it stores (trimmed name, recomputed overrides),
|
|
1082
1168
|
// so the just-saved draft is replaced by what it actually wrote. Without this
|
|
1083
1169
|
// the page would show 「已保存」 and 「未保存」 at the same time.
|
|
@@ -1094,7 +1180,7 @@ try {
|
|
|
1094
1180
|
}
|
|
1095
1181
|
} else {
|
|
1096
1182
|
setFailed(true)
|
|
1097
|
-
setStatus((result
|
|
1183
|
+
setStatus(apiText(result, "msg.saveFailed"))
|
|
1098
1184
|
}
|
|
1099
1185
|
} catch (error) {
|
|
1100
1186
|
setFailed(true)
|
|
@@ -1402,6 +1488,18 @@ try {
|
|
|
1402
1488
|
{ className: "cpfe" },
|
|
1403
1489
|
assistantList,
|
|
1404
1490
|
react.createElement("p", { className: "cpfe-note" }, t("assistant.switchHint")),
|
|
1491
|
+
// 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
|
|
1492
|
+
// `draft` 在没选中任何助手时是 null(列表还没加载完 / 一个都没有)—— 这里必须先守卫,
|
|
1493
|
+
// 否则整块设置页崩掉(实测:浏览器验收当场报 Cannot read properties of null)。
|
|
1494
|
+
draft === null || draft.warnings === undefined || draft.warnings.length === 0
|
|
1495
|
+
? null
|
|
1496
|
+
: react.createElement(
|
|
1497
|
+
"div",
|
|
1498
|
+
{ className: "cpfe-warns" },
|
|
1499
|
+
...draft.warnings.map((code) =>
|
|
1500
|
+
react.createElement("p", { key: code, className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
|
|
1501
|
+
),
|
|
1502
|
+
),
|
|
1405
1503
|
...editorSections,
|
|
1406
1504
|
react.createElement(
|
|
1407
1505
|
"div",
|
package/index.mjs
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { randomBytes } from 'node:crypto'
|
|
41
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
41
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
42
42
|
import { dirname, join } from 'node:path'
|
|
43
43
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
44
44
|
import {
|
|
@@ -166,7 +166,7 @@ export function readPrompt(directory) {
|
|
|
166
166
|
try {
|
|
167
167
|
return { ok: true, path, text: readFileSync(path, 'utf8') }
|
|
168
168
|
} catch (error) {
|
|
169
|
-
return { ok: false, error: '读取提示词失败:' + describe(error) }
|
|
169
|
+
return { ok: false, code: 'promptReadFailed', params: { detail: describe(error) }, error: '读取提示词失败:' + describe(error) }
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
@@ -211,11 +211,42 @@ function packagedPrompt() {
|
|
|
211
211
|
return packagedPromptCache
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 「配置了,但不生效」的告警码。
|
|
216
|
+
*
|
|
217
|
+
* 这一条借自同生态的 whale-persona:它会主动点名"配了却没起作用"的项。这里对应四种**静默**的
|
|
218
|
+
* 自相矛盾 —— 每一种此前都只会让人以为"我明明写了提示词/描述,怎么没效果":
|
|
219
|
+
*
|
|
220
|
+
* - `personaOffWithPrompt`:身份行被关掉,`prompt.md` 根本不会被注入(最坑的一种);
|
|
221
|
+
* - `toolOff`:`custom_prompt` 工具行关掉 → 会话内改提示词不可用(设置页照旧);
|
|
222
|
+
* - `noDescription`:描述为空 → 新建会话的模式选择器里显示「暂无描述」;
|
|
223
|
+
* - `noName`:名字为空 → 选择器里显示成裸目录 id。
|
|
224
|
+
*
|
|
225
|
+
* 只返回**码**,文案由页面按语言渲染(双语文案的真值源在 locales.mjs 一处)。
|
|
226
|
+
*
|
|
227
|
+
* @param {string} text - the composition text.
|
|
228
|
+
* @param {string} prompt - the prompt file's content.
|
|
229
|
+
* @param {{name?: string, description?: string}} meta - `preset.yml`.
|
|
230
|
+
* @returns {string[]} warning codes, stable order.
|
|
231
|
+
*/
|
|
232
|
+
export function configWarnings(text, prompt, meta) {
|
|
233
|
+
const warnings = []
|
|
234
|
+
const rows = collectRows(text)
|
|
235
|
+
const find = (id) => rows.find((row) => row.id === id)
|
|
236
|
+
if (find('persona')?.disabled === true && typeof prompt === 'string' && prompt.trim() !== '') {
|
|
237
|
+
warnings.push('personaOffWithPrompt')
|
|
238
|
+
}
|
|
239
|
+
if (find('custom-prompt-tool')?.disabled === true) warnings.push('toolOff')
|
|
240
|
+
if (typeof meta?.description !== 'string' || meta.description.trim() === '') warnings.push('noDescription')
|
|
241
|
+
if (typeof meta?.name !== 'string' || meta.name.trim() === '') warnings.push('noName')
|
|
242
|
+
return warnings
|
|
243
|
+
}
|
|
244
|
+
|
|
214
245
|
export function readState(rows, id, options = {}) {
|
|
215
246
|
const directory = assistantDir(rows, id)
|
|
216
247
|
if (directory === undefined) return unknownAssistant(id)
|
|
217
248
|
const composition = compositionFile(directory)
|
|
218
|
-
if (!existsSync(composition)) return { ok: false, error: '找不到组成文件:' + composition }
|
|
249
|
+
if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
|
|
219
250
|
const text = readFileSync(composition, 'utf8')
|
|
220
251
|
const mode = modeOf(text)
|
|
221
252
|
const prompt = readPrompt(directory)
|
|
@@ -242,6 +273,8 @@ export function readState(rows, id, options = {}) {
|
|
|
242
273
|
factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
|
|
243
274
|
// 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
|
|
244
275
|
history: listHistory(directory),
|
|
276
|
+
// 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
|
|
277
|
+
warnings: configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
|
|
245
278
|
}
|
|
246
279
|
}
|
|
247
280
|
|
|
@@ -271,11 +304,11 @@ export function saveState(rows, input) {
|
|
|
271
304
|
|
|
272
305
|
const mode = input !== null && typeof input === 'object' && typeof input.mode === 'string' ? input.mode : ''
|
|
273
306
|
if (!BASE_MODES.some((entry) => entry.id === mode)) {
|
|
274
|
-
return { ok: false, error: '未知的基础模式:' + mode }
|
|
307
|
+
return { ok: false, code: 'badMode', params: { mode }, error: '未知的基础模式:' + mode }
|
|
275
308
|
}
|
|
276
309
|
const prompt = input !== null && typeof input === 'object' && typeof input.prompt === 'string' ? input.prompt : ''
|
|
277
310
|
if (prompt.trim() === '') {
|
|
278
|
-
return { ok: false, error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
|
|
311
|
+
return { ok: false, code: 'promptEmpty', error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
|
|
279
312
|
}
|
|
280
313
|
const verdict = checkPromptText(prompt)
|
|
281
314
|
if (verdict.ok !== true) return { ok: false, error: verdict.error }
|
|
@@ -283,13 +316,13 @@ export function saveState(rows, input) {
|
|
|
283
316
|
const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
|
|
284
317
|
const name = rawName.replace(/\r?\n/g, ' ').trim()
|
|
285
318
|
if (name.length > MAX_NAME) {
|
|
286
|
-
return { ok: false, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
|
|
319
|
+
return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
|
|
287
320
|
}
|
|
288
321
|
const rawDescription =
|
|
289
322
|
input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
|
|
290
323
|
const description = rawDescription.replace(/\r?\n/g, ' ').trim()
|
|
291
324
|
if (description.length > MAX_DESCRIPTION) {
|
|
292
|
-
return { ok: false, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
|
|
325
|
+
return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
|
|
293
326
|
}
|
|
294
327
|
|
|
295
328
|
const overrides = new Map()
|
|
@@ -304,7 +337,7 @@ export function saveState(rows, input) {
|
|
|
304
337
|
try {
|
|
305
338
|
composition = renderComposition(mode, overrides, { modeName: name, assistantId: id })
|
|
306
339
|
} catch (error) {
|
|
307
|
-
return { ok: false, error: '生成组成文件失败:' + describe(error) }
|
|
340
|
+
return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
|
|
308
341
|
}
|
|
309
342
|
|
|
310
343
|
// Self-check our own output before publishing it: a composition that lost its
|
|
@@ -313,7 +346,7 @@ export function saveState(rows, input) {
|
|
|
313
346
|
if (collectRows(composition).length === 0) throw new Error('生成的组成文件没有任何行')
|
|
314
347
|
readBaseComposition(mode)
|
|
315
348
|
} catch (error) {
|
|
316
|
-
return { ok: false, error: '生成结果自检失败,已放弃写入:' + describe(error) }
|
|
349
|
+
return { ok: false, code: 'selfCheckFailed', params: { detail: describe(error) }, error: '生成结果自检失败,已放弃写入:' + describe(error) }
|
|
317
350
|
}
|
|
318
351
|
|
|
319
352
|
try {
|
|
@@ -323,7 +356,7 @@ export function saveState(rows, input) {
|
|
|
323
356
|
// 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
|
|
324
357
|
recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
|
|
325
358
|
} catch (error) {
|
|
326
|
-
return { ok: false, error: '写入失败:' + describe(error) }
|
|
359
|
+
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
327
360
|
}
|
|
328
361
|
|
|
329
362
|
// A name the user cleared is left alone rather than written as an empty scalar:
|
|
@@ -337,6 +370,8 @@ export function saveState(rows, input) {
|
|
|
337
370
|
ok: true,
|
|
338
371
|
id,
|
|
339
372
|
mode,
|
|
373
|
+
code: 'saved',
|
|
374
|
+
params: { name: name === '' ? id : name, mode },
|
|
340
375
|
note: '已保存(' + (name === '' ? id : name) + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
|
|
341
376
|
}
|
|
342
377
|
}
|
|
@@ -357,15 +392,15 @@ export function saveState(rows, input) {
|
|
|
357
392
|
export function createAssistant(rows, input, templateDir = packagedPresetDir()) {
|
|
358
393
|
const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
|
|
359
394
|
const name = rawName.replace(/\r?\n/g, ' ').trim()
|
|
360
|
-
if (name === '') return { ok: false, error: '请先给新助手起个名字。' }
|
|
395
|
+
if (name === '') return { ok: false, code: 'nameRequired', error: '请先给新助手起个名字。' }
|
|
361
396
|
if (name.length > MAX_NAME) {
|
|
362
|
-
return { ok: false, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
|
|
397
|
+
return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
|
|
363
398
|
}
|
|
364
399
|
const rawDescription =
|
|
365
400
|
input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
|
|
366
401
|
const description = rawDescription.replace(/\r?\n/g, ' ').trim()
|
|
367
402
|
if (description.length > MAX_DESCRIPTION) {
|
|
368
|
-
return { ok: false, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
|
|
403
|
+
return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
|
|
369
404
|
}
|
|
370
405
|
|
|
371
406
|
const root = userPresetRoot(rows)
|
|
@@ -377,7 +412,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
377
412
|
// The roster can lag a directory it skipped (a hand-made one with no
|
|
378
413
|
// composition). Refuse the name rather than half-own that directory.
|
|
379
414
|
if (existsSync(join(root, id))) {
|
|
380
|
-
return { ok: false, error: '目录已存在,请换一个名字:' + join(root, id) }
|
|
415
|
+
return { ok: false, code: 'dirExists', params: { path: join(root, id) }, error: '目录已存在,请换一个名字:' + join(root, id) }
|
|
381
416
|
}
|
|
382
417
|
|
|
383
418
|
// A "duplicate" carries the source's prompt, base mode and row switches into a
|
|
@@ -391,7 +426,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
391
426
|
const fromDir = assistantDir(rows, from)
|
|
392
427
|
if (fromDir === undefined) return unknownAssistant(from)
|
|
393
428
|
const fromComposition = compositionFile(fromDir)
|
|
394
|
-
if (!existsSync(fromComposition)) return { ok: false, error: '找不到组成文件:' + fromComposition }
|
|
429
|
+
if (!existsSync(fromComposition)) return { ok: false, code: 'compositionMissing', params: { path: fromComposition }, error: '找不到组成文件:' + fromComposition }
|
|
395
430
|
const text = readFileSync(fromComposition, 'utf8')
|
|
396
431
|
const sourceMode = modeOf(text)
|
|
397
432
|
const sourcePrompt = readPrompt(fromDir)
|
|
@@ -420,7 +455,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
420
455
|
try {
|
|
421
456
|
writeAtomic(promptFile(created.dir), source.prompt)
|
|
422
457
|
} catch (error) {
|
|
423
|
-
return { ok: false, error: '写入提示词失败:' + describe(error) }
|
|
458
|
+
return { ok: false, code: 'promptWriteFailed', params: { detail: describe(error) }, error: '写入提示词失败:' + describe(error) }
|
|
424
459
|
}
|
|
425
460
|
}
|
|
426
461
|
const metaResult = writePresetMeta(name, description === '' && source !== null ? source.description : description, created.dir)
|
|
@@ -430,6 +465,9 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
430
465
|
ok: true,
|
|
431
466
|
id,
|
|
432
467
|
name,
|
|
468
|
+
code: source === null ? 'created' : 'duplicated',
|
|
469
|
+
// D5:文案里用**显示名**而不是内部目录 id。
|
|
470
|
+
params: source === null ? { name } : { name, from: source === null ? '' : from },
|
|
433
471
|
note: source === null
|
|
434
472
|
? '已创建「' + name + '」。它的系统提示词现在是模板默认文本;写好后新建会话即可选择它。'
|
|
435
473
|
: '已复制出「' + name + '」:提示词、基础模式与插件开关都来自「' + from + '」,之后各改各的,互不影响。',
|
|
@@ -452,14 +490,14 @@ export async function deleteAssistant(rows, input, agentPresets) {
|
|
|
452
490
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
453
491
|
if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
|
|
454
492
|
if (typeof agentPresets?.remove !== 'function') {
|
|
455
|
-
return { ok: false, error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
|
|
493
|
+
return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
|
|
456
494
|
}
|
|
457
495
|
try {
|
|
458
496
|
await agentPresets.remove(id)
|
|
459
497
|
} catch (error) {
|
|
460
|
-
return { ok: false, error: '删除失败:' + describe(error) }
|
|
498
|
+
return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
|
|
461
499
|
}
|
|
462
|
-
return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
|
|
500
|
+
return { ok: true, id, code: 'deleted', params: { name: id }, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
|
|
463
501
|
}
|
|
464
502
|
|
|
465
503
|
/**
|
|
@@ -618,7 +656,7 @@ export function apply(ctx) {
|
|
|
618
656
|
const directory = assistantDir(await roster(), id)
|
|
619
657
|
if (directory === undefined) return json(unknownAssistant(id), 404)
|
|
620
658
|
const text = readVersion(directory, url.searchParams.get('n') ?? '')
|
|
621
|
-
if (text === null) return json({ ok: false, error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
|
|
659
|
+
if (text === null) return json({ ok: false, code: 'versionMissing', error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
|
|
622
660
|
return json({ ok: true, id, n: url.searchParams.get('n'), text })
|
|
623
661
|
}
|
|
624
662
|
|
|
@@ -636,7 +674,7 @@ export function apply(ctx) {
|
|
|
636
674
|
try {
|
|
637
675
|
parsed = await request.json()
|
|
638
676
|
} catch {
|
|
639
|
-
return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
|
|
677
|
+
return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
|
|
640
678
|
}
|
|
641
679
|
await ensureShipped()
|
|
642
680
|
if (pathname === STATE_PATH) {
|
|
@@ -683,12 +721,56 @@ export function apply(ctx) {
|
|
|
683
721
|
* prompt,而这个文件正是"用户的提示词"。
|
|
684
722
|
*/
|
|
685
723
|
function writeAtomic(file, text) {
|
|
686
|
-
// 临时名必须**每个请求唯一**:只带 pid
|
|
687
|
-
//
|
|
688
|
-
//
|
|
724
|
+
// 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名。
|
|
725
|
+
// 但随机后缀只消除 tmp-vs-tmp 竞争 —— Windows 上**两个 rename 指向同一目标**仍会以
|
|
726
|
+
// EPERM/EBUSY 失败(实测:10 并发保存 2 例 400),所以还要短退避重试。
|
|
689
727
|
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
690
728
|
writeFileSync(temporary, text, 'utf8')
|
|
691
|
-
|
|
729
|
+
try {
|
|
730
|
+
renameWithRetry(temporary, file)
|
|
731
|
+
} catch (error) {
|
|
732
|
+
// 失败必须清掉临时文件:助手目录会被 agent-presets 扫描,孤儿 tmp 是脏残留(实测留下过
|
|
733
|
+
// `agent.cordis.yml.tmp-135052-36a5bf5c`)。
|
|
734
|
+
try {
|
|
735
|
+
rmSync(temporary, { force: true })
|
|
736
|
+
} catch {
|
|
737
|
+
/* 清理失败不再掩盖原始错误 */
|
|
738
|
+
}
|
|
739
|
+
throw error
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** 同步退避:这条写路径本身是同步的,等一小会儿比把整个调用链改成异步更合适。 */
|
|
744
|
+
function sleepSync(ms) {
|
|
745
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* `rename` 带重试。
|
|
750
|
+
*
|
|
751
|
+
* Windows 上并发 rename 到同一目标会短暂报 `EPERM`/`EBUSY`/`EACCES`(目标被另一个 rename 持有),
|
|
752
|
+
* 退避几毫秒就能跨过这个窗口;其它错误立刻抛出,不做无谓等待。`rename`/`sleep` 可注入,
|
|
753
|
+
* 于是这条重试逻辑在 Linux 上也能被测试钉住(见 test/journal.test.mjs 的对应条目)。
|
|
754
|
+
*
|
|
755
|
+
* @param {string} from - 临时文件名(已写好内容)。
|
|
756
|
+
* @param {string} to - 目标文件名。
|
|
757
|
+
* @returns {void}
|
|
758
|
+
*/
|
|
759
|
+
export function renameWithRetry(from, to, options = {}) {
|
|
760
|
+
const rename = typeof options.rename === 'function' ? options.rename : renameSync
|
|
761
|
+
const sleep = typeof options.sleep === 'function' ? options.sleep : sleepSync
|
|
762
|
+
const attempts = Number.isInteger(options.attempts) ? options.attempts : 5
|
|
763
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
764
|
+
try {
|
|
765
|
+
rename(from, to)
|
|
766
|
+
return
|
|
767
|
+
} catch (error) {
|
|
768
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined
|
|
769
|
+
const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
|
|
770
|
+
if (retryable !== true || attempt >= attempts) throw error
|
|
771
|
+
sleep(20 * attempt)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
692
774
|
}
|
|
693
775
|
|
|
694
776
|
/** `error` as a readable string, without assuming it is an Error. */
|
package/journal.mjs
CHANGED
|
@@ -23,9 +23,30 @@
|
|
|
23
23
|
* 但这里记录的是**已发生的事实**而不是待确认的提议 —— 我们的场景是"改了什么要看得见、回得去",
|
|
24
24
|
* 不需要它的确认闸门。
|
|
25
25
|
*/
|
|
26
|
-
import {
|
|
26
|
+
import { randomBytes } from 'node:crypto'
|
|
27
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
28
|
import { join } from 'node:path'
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* `rename` 带重试(与宿主半同一实现,见 editor/index.mjs 的说明)。
|
|
32
|
+
*
|
|
33
|
+
* 这两个文件刻意各留一份:preset 侧的模块是独立分发的,不能 import 宿主半 —— 与 `checkPromptText`
|
|
34
|
+
* 同样的取舍。journal 只在宿主半写,所以这里只需与宿主半保持一致的重试策略。
|
|
35
|
+
*/
|
|
36
|
+
function renameWithRetry(from, to, attempts = 5) {
|
|
37
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
38
|
+
try {
|
|
39
|
+
renameSync(from, to)
|
|
40
|
+
return
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined
|
|
43
|
+
const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
|
|
44
|
+
if (retryable !== true || attempt >= attempts) throw error
|
|
45
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * attempt)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
/** 每个助手目录下的日志文件名。 */
|
|
30
51
|
export const HISTORY_NAME = 'prompt-history.jsonl'
|
|
31
52
|
|
|
@@ -89,16 +110,29 @@ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
|
|
|
89
110
|
|
|
90
111
|
const at = new Date().toISOString()
|
|
91
112
|
const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
|
|
113
|
+
// `bytes` 是给人看的体积,所以按 **UTF-8 字节**计(原先用 text.length = UTF-16 码元,
|
|
114
|
+
// CJK 内容下显示值只有真实字节的约 1/3,而界面标注是 "B")。
|
|
92
115
|
const lines = entries.slice(-(HISTORY_MAX - 1)).map((entry) =>
|
|
93
|
-
JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: entry.text
|
|
116
|
+
JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: Buffer.byteLength(entry.text, 'utf8'), text: entry.text }),
|
|
94
117
|
)
|
|
95
|
-
lines.push(JSON.stringify({ n, at, by, bytes: text
|
|
118
|
+
lines.push(JSON.stringify({ n, at, by, bytes: Buffer.byteLength(text, 'utf8'), text }))
|
|
96
119
|
|
|
97
120
|
// 唯一的整文件重写:顺手把上限外的旧条目丢掉,用原子写落盘(读取器不会看到写了一半的文件)。
|
|
98
121
|
const file = historyFile(directory)
|
|
99
|
-
|
|
122
|
+
// 与宿主半同一条纪律:随机临时名(消除 tmp-vs-tmp 碰撞)+ 重试(Windows 上目标被并发 rename
|
|
123
|
+
// 持有时会短暂 EPERM)+ 失败清理。
|
|
124
|
+
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
100
125
|
writeFileSync(temporary, lines.join('\n') + '\n', 'utf8')
|
|
101
|
-
|
|
126
|
+
try {
|
|
127
|
+
renameWithRetry(temporary, file)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(temporary, { force: true })
|
|
131
|
+
} catch {
|
|
132
|
+
/* 不掩盖原始错误 */
|
|
133
|
+
}
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
102
136
|
return { recorded: true, at, n }
|
|
103
137
|
}
|
|
104
138
|
|
|
@@ -119,7 +153,7 @@ export function listHistory(directory, limit = HISTORY_MAX) {
|
|
|
119
153
|
n: entry.n,
|
|
120
154
|
at: entry.at,
|
|
121
155
|
by: entry.by,
|
|
122
|
-
bytes: entry.text
|
|
156
|
+
bytes: Buffer.byteLength(entry.text, 'utf8'),
|
|
123
157
|
preview: firstLine.trim().slice(0, 80),
|
|
124
158
|
})
|
|
125
159
|
}
|
package/locales.mjs
CHANGED
|
@@ -52,6 +52,35 @@ export const zh = {
|
|
|
52
52
|
'msg.reorderFailed': '调整顺序失败',
|
|
53
53
|
'msg.imported': '已导入到编辑器(还没有保存):检查后点「保存」。',
|
|
54
54
|
'msg.importFailed': '导入失败',
|
|
55
|
+
'api.badDirection': '未知的排序方向:{direction}',
|
|
56
|
+
'api.unknownAssistant': '找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。',
|
|
57
|
+
'api.alreadyFirst': '「{name}」已经在最前面。',
|
|
58
|
+
'api.alreadyLast': '「{name}」已经在最后面。',
|
|
59
|
+
'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
|
|
60
|
+
'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
|
|
61
|
+
'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
|
|
62
|
+
'api.deleted': '已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。',
|
|
63
|
+
'api.reordered': '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
|
|
64
|
+
'api.nameRequired': '请先给新助手起个名字。',
|
|
65
|
+
'api.nameTooLong': '名字太长了(上限 {max} 个字符)。',
|
|
66
|
+
'api.descriptionTooLong': '描述太长了(上限 {max} 个字符)。',
|
|
67
|
+
'api.promptEmpty': '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。',
|
|
68
|
+
'api.badMode': '未知的基础模式:{mode}',
|
|
69
|
+
'api.dirExists': '目录已存在,请换一个名字:{path}',
|
|
70
|
+
'api.compositionMissing': '找不到组成文件:{path}',
|
|
71
|
+
'api.writeFailed': '写入失败:{detail}',
|
|
72
|
+
'api.promptWriteFailed': '写入提示词失败:{detail}',
|
|
73
|
+
'api.renderFailed': '生成组成文件失败:{detail}',
|
|
74
|
+
'api.selfCheckFailed': '生成结果自检失败,已放弃写入:{detail}',
|
|
75
|
+
'api.promptReadFailed': '读取提示词失败:{detail}',
|
|
76
|
+
'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
|
|
77
|
+
'api.deleteFailed': '删除失败:{detail}',
|
|
78
|
+
'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
|
|
79
|
+
'api.badJson': '请求体不是合法 JSON',
|
|
80
|
+
'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
|
|
81
|
+
'warn.toolOff': '「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。',
|
|
82
|
+
'warn.noDescription': '没有描述:新建会话的模式选择器里会显示成「暂无描述」。',
|
|
83
|
+
'warn.noName': '没有名字:模式选择器里会显示成目录 id(例如 custom)。',
|
|
55
84
|
'history.label': '改动历史',
|
|
56
85
|
'history.pick': '选择要载入的版本…',
|
|
57
86
|
'history.load': '载入这一版',
|
|
@@ -208,6 +237,35 @@ export const en = {
|
|
|
208
237
|
'msg.reorderFailed': 'Could not reorder',
|
|
209
238
|
'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
|
|
210
239
|
'msg.importFailed': 'Import failed',
|
|
240
|
+
'api.badDirection': 'Unknown reorder direction: {direction}',
|
|
241
|
+
'api.unknownAssistant': 'No assistant 「{id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
|
|
242
|
+
'api.alreadyFirst': '「{name}」 is already first.',
|
|
243
|
+
'api.alreadyLast': '「{name}」 is already last.',
|
|
244
|
+
'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
|
|
245
|
+
'api.created': 'Created 「{name}」. You can write its system prompt now.',
|
|
246
|
+
'api.duplicated': 'Copied from 「{from}」. The two are independent from now on.',
|
|
247
|
+
'api.deleted': 'Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.',
|
|
248
|
+
'api.reordered': 'Order saved: the new-session mode picker follows it.',
|
|
249
|
+
'api.nameRequired': 'Give the new assistant a name first.',
|
|
250
|
+
'api.nameTooLong': 'That name is too long (limit {max} characters).',
|
|
251
|
+
'api.descriptionTooLong': 'That description is too long (limit {max} characters).',
|
|
252
|
+
'api.promptEmpty': 'Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.',
|
|
253
|
+
'api.badMode': 'Unknown base mode: {mode}',
|
|
254
|
+
'api.dirExists': 'That directory already exists — pick another name: {path}',
|
|
255
|
+
'api.compositionMissing': 'Composition file not found: {path}',
|
|
256
|
+
'api.writeFailed': 'Write failed: {detail}',
|
|
257
|
+
'api.promptWriteFailed': 'Writing the prompt failed: {detail}',
|
|
258
|
+
'api.renderFailed': 'Rendering the composition failed: {detail}',
|
|
259
|
+
'api.selfCheckFailed': 'The generated composition failed its self-check, so nothing was written: {detail}',
|
|
260
|
+
'api.promptReadFailed': 'Reading the prompt failed: {detail}',
|
|
261
|
+
'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
|
|
262
|
+
'api.deleteFailed': 'Delete failed: {detail}',
|
|
263
|
+
'api.versionMissing': 'That version is gone (the history is capped).',
|
|
264
|
+
'api.badJson': 'The request body is not valid JSON',
|
|
265
|
+
'warn.personaOffWithPrompt': 'The "Identity (system prompt)" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.',
|
|
266
|
+
'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
|
|
267
|
+
'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
|
|
268
|
+
'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
|
|
211
269
|
'history.label': 'Change history',
|
|
212
270
|
'history.pick': 'Pick a version to load…',
|
|
213
271
|
'history.load': 'Load this version',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"test": "node ../test/run.mjs"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@deepseek-ai/dsh": ">=0.1.
|
|
63
|
+
"@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0"
|
|
64
64
|
},
|
|
65
65
|
"publishConfig": {
|
|
66
66
|
"access": "public",
|
|
@@ -72,6 +72,6 @@
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
|
-
"dsh": ">=0.1.
|
|
75
|
+
"dsh": ">=0.1.5-rc.2 <0.2.0-0"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/preset/prompt-tool.mjs
CHANGED
|
@@ -40,6 +40,7 @@ const META_PATH = fileURLToPath(new URL('./preset.yml', import.meta.url))
|
|
|
40
40
|
const MISSING = '(prompt.md 不存在,当前模式会退回上一次成功的提示词文本)'
|
|
41
41
|
|
|
42
42
|
/** Name used when neither the composition nor `preset.yml` supplies one. */
|
|
43
|
+
const TOOL_NAME = 'custom_prompt'
|
|
43
44
|
const FALLBACK_MODE_NAME = '自定义模式'
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -201,12 +202,67 @@ function makeDefinition(modeName) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 自我改提示词的审批闸门。
|
|
207
|
+
*
|
|
208
|
+
* `action: "write"` 会**覆盖整个系统提示词**,而调用它的可能是模型自己 —— 一次提示词注入就足以
|
|
209
|
+
* 让它重写自己的身份。此前这条路径没有任何门:工具直接落盘。
|
|
210
|
+
*
|
|
211
|
+
* 现在它走平台的审批缝:`tools/pre-execute` 是一个 waterfall,返回 `{kind:'ask'}` 会由审批策略决定
|
|
212
|
+
* 怎么处理。**实测(0.1.6-alpha.2,一次性实例)**:
|
|
213
|
+
*
|
|
214
|
+
* - 审批策略为 `ask`(权限预设 `workspace-write`):页面出现「等待审批」行,带这里的 reason,
|
|
215
|
+
* 按钮为「拒绝 / 允许一次」;点「允许一次」后工具真的执行,prompt.md 变成写入的内容;
|
|
216
|
+
* - 审批策略为 `never`(权限预设 `danger-full-access`):**不弹窗,直接判为拒绝**
|
|
217
|
+
* (轨迹里是 `Error: the user rejected tool "custom_prompt"`,文件未被修改)。
|
|
218
|
+
*
|
|
219
|
+
* 也就是说这条闸门**最坏情况是"改不成"而不是"悄悄改成了"** —— 与平台的默认语义一致
|
|
220
|
+
* (类型注释原话:missing approval support turns `ask` into denial)。
|
|
221
|
+
*
|
|
222
|
+
* 只拦 `write`:`read` 不改变任何东西,弹窗只会让人麻木。
|
|
223
|
+
*
|
|
224
|
+
* @param {object} ctx - the preset row's scope.
|
|
225
|
+
* @returns {boolean} whether a gate was registered.
|
|
226
|
+
*/
|
|
227
|
+
function registerApprovalGate(ctx) {
|
|
228
|
+
if (typeof ctx.on !== 'function') return false
|
|
229
|
+
ctx.effect(
|
|
230
|
+
() => ctx.on('tools/pre-execute', (exec, next) => {
|
|
231
|
+
if (exec === null || typeof exec !== 'object' || exec.name !== TOOL_NAME) return next()
|
|
232
|
+
const args = exec.arguments
|
|
233
|
+
const action = args !== null && typeof args === 'object' ? args.action : undefined
|
|
234
|
+
// 只拦写入;未指定 action 时工具按 read 处理,同样不拦。
|
|
235
|
+
if (action !== 'write') return next()
|
|
236
|
+
const text = typeof args.text === 'string' ? args.text : ''
|
|
237
|
+
const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''
|
|
238
|
+
return {
|
|
239
|
+
kind: 'ask',
|
|
240
|
+
reason:
|
|
241
|
+
'把「' + resolveModeName(undefined) + '」的系统提示词整体替换为 ' + String(text.length) + ' 字符' +
|
|
242
|
+
(firstLine === '' ? '' : ':' + firstLine.trim().slice(0, 60)) +
|
|
243
|
+
'(写入 ' + PROMPT_PATH + ')',
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
'custom-prompt.approval-gate',
|
|
247
|
+
)
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
204
251
|
/** The tool registry is a hard dependency; without it there is no tool. */
|
|
205
252
|
export const inject = ['tools']
|
|
206
253
|
|
|
207
254
|
export function apply(ctx, config = {}) {
|
|
208
255
|
const definition = makeDefinition(resolveModeName(config))
|
|
209
256
|
ctx.effect(() => ctx.tools.register(definition), 'custom-prompt.tool')
|
|
257
|
+
|
|
258
|
+
// 审批闸门。宿主若不支持 `tools/pre-execute`(比本插件声明的下限还老的构建),这里会**明确**
|
|
259
|
+
// 说一声再继续 —— 降级是有的,但不许静默。
|
|
260
|
+
if (registerApprovalGate(ctx) !== true) {
|
|
261
|
+
console.error(
|
|
262
|
+
'custom-mode: 这个宿主没有 tools/pre-execute 事件,会话内改写系统提示词的审批闸门**未启用**' +
|
|
263
|
+
'(设置页不受影响)。请升级 DSH,或把「custom_prompt 工具」这一行关掉。',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
/**
|