Skip to content

Commit 6b011eb

Browse files
committed
feat(prompts): add system prompt editor with scope/mode, optimistic lock, and diff preview (#50)
- Add cli/system-prompt-files.js controller: scope (global/project) × mode (system/append) dual-axis file resolution, SHA-256 optimistic lock, line diff - Add 3 RPC actions: get/apply/preview-diff for system prompt - Add web-ui/modules/app.methods.system-prompt.mjs: load/diff/save/export/copy/paste - Add system sub-tab in Prompts panel with independent scope/mode controls - Extract shared request-token utility to eliminate bundle duplicate declarations - Add 5-language i18n keys (zh/en/ja/vi/zh-tw) for system prompt UI - Add tests/unit/system-prompt-files.test.mjs: 24 tests covering mode/scope normalization, path resolution, read/write, optimistic lock, empty/oversize rejection, diff generation, and truncation handling - Fix saveSystemPromptFile passing params instead of resolved target to readSystemPromptFilePath (optimistic lock was always failing) - Clean up obsolete CSS classes from collapsed-section design - Bump version to 0.1.8
1 parent f721ecf commit 6b011eb

21 files changed

Lines changed: 1622 additions & 303 deletions

cli.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ const {
126126
const {
127127
createAgentsFileController
128128
} = require('./cli/agents-files');
129+
const {
130+
createSystemPromptFileController
131+
} = require('./cli/system-prompt-files');
129132
const {
130133
createArchiveHelperController
131134
} = require('./cli/archive-helpers');
@@ -308,6 +311,7 @@ const FAST_SESSION_DETAIL_PREVIEW_FILE_BYTES = 256 * 1024;
308311
const FAST_SESSION_DETAIL_PREVIEW_CHUNK_BYTES = 64 * 1024;
309312
const FAST_SESSION_DETAIL_PREVIEW_MAX_BYTES = 1024 * 1024;
310313
const AGENTS_FILE_NAME = 'AGENTS.md';
314+
const PI_AGENT_DIR = path.join(os.homedir(), '.pi', 'agent');
311315
const MODELS_CACHE_TTL_MS = 60 * 1000;
312316
const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
313317
const MODELS_CACHE_MAX_ENTRIES = 50;
@@ -2102,6 +2106,20 @@ const {
21022106
}
21032107
});
21042108

2109+
const {
2110+
readSystemPromptFile,
2111+
saveSystemPromptFile,
2112+
buildSystemPromptDiff
2113+
} = createSystemPromptFileController({
2114+
fs,
2115+
path,
2116+
os,
2117+
crypto,
2118+
buildLineDiff,
2119+
CONFIG_DIR,
2120+
PI_AGENT_DIR
2121+
});
2122+
21052123
const {
21062124
readOpenclawConfigFile,
21072125
applyOpenclawConfig,
@@ -13479,6 +13497,15 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1347913497
case 'apply-openclaw-workspace-file':
1348013498
result = applyOpenclawWorkspaceFile(params || {});
1348113499
break;
13500+
case 'get-system-prompt':
13501+
result = readSystemPromptFile(params || {});
13502+
break;
13503+
case 'apply-system-prompt':
13504+
result = saveSystemPromptFile(params || {});
13505+
break;
13506+
case 'preview-system-prompt-diff':
13507+
result = buildSystemPromptDiff(params || {});
13508+
break;
1348213509
case 'switch':
1348313510
case 'use':
1348413511
case 'add':

cli/system-prompt-files.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use strict';
2+
3+
function createSystemPromptFileController(deps = {}) {
4+
const {
5+
fs,
6+
path,
7+
os,
8+
crypto,
9+
buildLineDiff,
10+
CONFIG_DIR,
11+
PI_AGENT_DIR
12+
} = deps;
13+
14+
if (!fs) throw new Error('createSystemPromptFileController 缺少 fs');
15+
if (!path) throw new Error('createSystemPromptFileController 缺少 path');
16+
if (!os) throw new Error('createSystemPromptFileController 缺少 os');
17+
if (!crypto) throw new Error('createSystemPromptFileController 缺少 crypto');
18+
if (typeof buildLineDiff !== 'function') throw new Error('createSystemPromptFileController 缺少 buildLineDiff');
19+
if (typeof CONFIG_DIR !== 'string' || !CONFIG_DIR) throw new Error('createSystemPromptFileController 缺少 CONFIG_DIR');
20+
if (typeof PI_AGENT_DIR !== 'string' || !PI_AGENT_DIR) throw new Error('createSystemPromptFileController 缺少 PI_AGENT_DIR');
21+
22+
const MODES = {
23+
system: 'SYSTEM.md',
24+
append: 'APPEND_SYSTEM.md'
25+
};
26+
27+
const SCOPES = ['global', 'project'];
28+
29+
function normalizeMode(mode) {
30+
const key = typeof mode === 'string' ? mode.trim() : '';
31+
if (!Object.prototype.hasOwnProperty.call(MODES, key)) {
32+
throw new Error("Invalid system prompt mode: expected 'system' or 'append'");
33+
}
34+
return key;
35+
}
36+
37+
function normalizeScope(scope) {
38+
const key = typeof scope === 'string' ? scope.trim() : '';
39+
if (!SCOPES.includes(key)) {
40+
throw new Error("Invalid system prompt scope: expected 'global' or 'project'");
41+
}
42+
return key;
43+
}
44+
45+
function hashContent(content) {
46+
return crypto.createHash('sha256').update(String(content || '')).digest('hex');
47+
}
48+
49+
function resolveSystemPromptFilePath(params = {}) {
50+
const scope = normalizeScope(params.scope);
51+
const mode = normalizeMode(params.mode);
52+
const filename = MODES[mode];
53+
let base;
54+
if (scope === 'global') {
55+
base = PI_AGENT_DIR;
56+
} else {
57+
const cwd = typeof params.cwd === 'string' && params.cwd.trim()
58+
? params.cwd.trim()
59+
: process.cwd();
60+
base = path.join(path.resolve(cwd), '.pi');
61+
}
62+
return {
63+
scope,
64+
mode,
65+
filename,
66+
path: path.join(base, filename),
67+
replaceDefault: mode === 'system'
68+
};
69+
}
70+
71+
function readSystemPromptFile(params = {}) {
72+
const target = resolveSystemPromptFilePath(params);
73+
const exists = fs.existsSync(target.path);
74+
let content = '';
75+
if (exists) {
76+
try {
77+
content = fs.readFileSync(target.path, 'utf8');
78+
} catch (e) {
79+
return { ...target, exists: false, content: '', hash: hashContent(''), error: '读取 system prompt 失败: ' + e.message };
80+
}
81+
}
82+
return {
83+
...target,
84+
exists,
85+
content,
86+
hash: hashContent(content)
87+
};
88+
}
89+
90+
function saveSystemPromptFile(params = {}) {
91+
const content = typeof params.content === 'string' ? params.content : '';
92+
if (!content.trim()) {
93+
return { error: 'System prompt 不能为空' };
94+
}
95+
if (content.length > 2 * 1024 * 1024) {
96+
return { error: '内容过大(最大 2MB)' };
97+
}
98+
const target = resolveSystemPromptFilePath(params);
99+
const current = readSystemPromptFilePath(target);
100+
if (current.error) {
101+
return { error: current.error };
102+
}
103+
const baseHash = typeof params.baseHash === 'string' ? params.baseHash.trim() : '';
104+
if (baseHash && baseHash !== current.hash) {
105+
return { error: '文件已被外部修改,请重新加载后再保存' };
106+
}
107+
try {
108+
const dir = path.dirname(target.path);
109+
fs.mkdirSync(dir, { recursive: true });
110+
const finalContent = content.endsWith('\n') ? content : content + '\n';
111+
fs.writeFileSync(target.path, finalContent, { encoding: 'utf8', mode: 0o600 });
112+
try { fs.chmodSync(target.path, 0o600); } catch (_) {}
113+
} catch (e) {
114+
return { error: '写入 system prompt 失败: ' + e.message };
115+
}
116+
return readSystemPromptFilePath(target);
117+
}
118+
119+
function readSystemPromptFilePath(resolved) {
120+
const exists = fs.existsSync(resolved.path);
121+
let content = '';
122+
if (exists) {
123+
try {
124+
content = fs.readFileSync(resolved.path, 'utf8');
125+
} catch (e) {
126+
return { ...resolved, exists: false, content: '', hash: hashContent(''), error: '读取 system prompt 失败: ' + e.message };
127+
}
128+
}
129+
return {
130+
...resolved,
131+
exists,
132+
content,
133+
hash: hashContent(content)
134+
};
135+
}
136+
137+
function buildSystemPromptDiff(params = {}) {
138+
const hasBaseContent = typeof params.baseContent === 'string';
139+
const target = resolveSystemPromptFilePath(params);
140+
const current = readSystemPromptFilePath(target);
141+
if (current.error) {
142+
return { error: current.error };
143+
}
144+
const beforeText = hasBaseContent
145+
? (typeof params.baseContent === 'string' ? params.baseContent : '')
146+
: (current.content || '');
147+
const afterText = typeof params.content === 'string' ? params.content : '';
148+
const normalizedBefore = beforeText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
149+
const normalizedAfter = afterText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
150+
const diff = buildLineDiff(normalizedBefore, normalizedAfter);
151+
const hasChanges = diff.truncated
152+
? normalizedBefore !== normalizedAfter
153+
: (diff.stats.added > 0 || diff.stats.removed > 0);
154+
return {
155+
diff: {
156+
...diff,
157+
hasChanges
158+
},
159+
path: target.path,
160+
exists: current.exists,
161+
scope: target.scope,
162+
mode: target.mode
163+
};
164+
}
165+
166+
return {
167+
readSystemPromptFile,
168+
saveSystemPromptFile,
169+
buildSystemPromptDiff,
170+
normalizeMode,
171+
normalizeScope
172+
};
173+
}
174+
175+
module.exports = {
176+
createSystemPromptFileController
177+
};

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {

tests/unit/run.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ await import(pathToFileURL(path.join(__dirname, 'openclaw-editing.test.mjs')));
4141
await import(pathToFileURL(path.join(__dirname, 'openclaw-persist-regression.test.mjs')));
4242
await import(pathToFileURL(path.join(__dirname, 'agents-modal-guards.test.mjs')));
4343
await import(pathToFileURL(path.join(__dirname, 'agents-files-project.test.mjs')));
44+
await import(pathToFileURL(path.join(__dirname, 'system-prompt-files.test.mjs')));
4445
await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test.mjs')));
4546
await import(pathToFileURL(path.join(__dirname, 'session-resume-command.test.mjs')));
4647
await import(pathToFileURL(path.join(__dirname, 'session-header-actions-layout.test.mjs')));

0 commit comments

Comments
 (0)