@zihanw/pi-forge 0.2.0 → 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.
Files changed (161) hide show
  1. package/README.md +233 -105
  2. package/README.zh-CN.md +215 -104
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +364 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lifecycle.d.ts +14 -0
  16. package/dist/lifecycle.d.ts.map +1 -0
  17. package/dist/lifecycle.js +139 -0
  18. package/dist/lifecycle.js.map +1 -0
  19. package/dist/loader.d.ts +8 -0
  20. package/dist/loader.d.ts.map +1 -0
  21. package/dist/loader.js +345 -0
  22. package/dist/loader.js.map +1 -0
  23. package/dist/macro-engine.d.ts +23 -0
  24. package/dist/macro-engine.d.ts.map +1 -0
  25. package/dist/macro-engine.js +262 -0
  26. package/dist/macro-engine.js.map +1 -0
  27. package/dist/payload-capture.d.ts +15 -0
  28. package/dist/payload-capture.d.ts.map +1 -0
  29. package/dist/payload-capture.js +86 -0
  30. package/dist/payload-capture.js.map +1 -0
  31. package/dist/payload-command.d.ts +10 -0
  32. package/dist/payload-command.d.ts.map +1 -0
  33. package/dist/payload-command.js +112 -0
  34. package/dist/payload-command.js.map +1 -0
  35. package/dist/policy.d.ts +6 -0
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +38 -0
  38. package/dist/policy.js.map +1 -0
  39. package/dist/preset-command.d.ts +14 -0
  40. package/dist/preset-command.d.ts.map +1 -0
  41. package/dist/preset-command.js +223 -0
  42. package/dist/preset-command.js.map +1 -0
  43. package/dist/preview.d.ts +12 -0
  44. package/dist/preview.d.ts.map +1 -0
  45. package/dist/preview.js +179 -0
  46. package/dist/preview.js.map +1 -0
  47. package/dist/regex.d.ts +7 -0
  48. package/dist/regex.d.ts.map +1 -0
  49. package/dist/regex.js +432 -0
  50. package/dist/regex.js.map +1 -0
  51. package/dist/render-helpers.d.ts +44 -0
  52. package/dist/render-helpers.d.ts.map +1 -0
  53. package/dist/render-helpers.js +128 -0
  54. package/dist/render-helpers.js.map +1 -0
  55. package/dist/runtime-state.d.ts +24 -0
  56. package/dist/runtime-state.d.ts.map +1 -0
  57. package/dist/runtime-state.js +13 -0
  58. package/dist/runtime-state.js.map +1 -0
  59. package/dist/sillytavern-importer/items.d.ts +3 -0
  60. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  61. package/dist/sillytavern-importer/items.js +88 -0
  62. package/dist/sillytavern-importer/items.js.map +1 -0
  63. package/dist/sillytavern-importer/macros.d.ts +15 -0
  64. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/macros.js +141 -0
  66. package/dist/sillytavern-importer/macros.js.map +1 -0
  67. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  68. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/prompt-order.js +38 -0
  70. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  71. package/dist/sillytavern-importer/regex.d.ts +3 -0
  72. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/regex.js +275 -0
  74. package/dist/sillytavern-importer/regex.js.map +1 -0
  75. package/dist/sillytavern-importer/report.d.ts +21 -0
  76. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/report.js +166 -0
  78. package/dist/sillytavern-importer/report.js.map +1 -0
  79. package/dist/sillytavern-importer/types.d.ts +106 -0
  80. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/types.js +2 -0
  82. package/dist/sillytavern-importer/types.js.map +1 -0
  83. package/dist/sillytavern-importer.d.ts +5 -0
  84. package/dist/sillytavern-importer.d.ts.map +1 -0
  85. package/dist/sillytavern-importer.js +117 -0
  86. package/dist/sillytavern-importer.js.map +1 -0
  87. package/dist/slot-renderers.d.ts +25 -0
  88. package/dist/slot-renderers.d.ts.map +1 -0
  89. package/dist/slot-renderers.js +334 -0
  90. package/dist/slot-renderers.js.map +1 -0
  91. package/dist/stack-migration.d.ts +29 -0
  92. package/dist/stack-migration.d.ts.map +1 -0
  93. package/dist/stack-migration.js +126 -0
  94. package/dist/stack-migration.js.map +1 -0
  95. package/dist/storage.d.ts +6 -0
  96. package/dist/storage.d.ts.map +1 -0
  97. package/dist/storage.js +23 -0
  98. package/dist/storage.js.map +1 -0
  99. package/dist/types.d.ts +173 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/web-editor/index.d.ts +3 -0
  104. package/dist/web-editor/index.d.ts.map +1 -0
  105. package/dist/web-editor/index.js +2 -0
  106. package/dist/web-editor/index.js.map +1 -0
  107. package/dist/web-editor/page.d.ts +2 -0
  108. package/dist/web-editor/page.d.ts.map +1 -0
  109. package/dist/web-editor/page.js +3331 -0
  110. package/dist/web-editor/page.js.map +1 -0
  111. package/dist/web-editor/server.d.ts +4 -0
  112. package/dist/web-editor/server.d.ts.map +1 -0
  113. package/dist/web-editor/server.js +263 -0
  114. package/dist/web-editor/server.js.map +1 -0
  115. package/dist/web-editor/types.d.ts +125 -0
  116. package/dist/web-editor/types.d.ts.map +1 -0
  117. package/dist/web-editor/types.js +2 -0
  118. package/dist/web-editor/types.js.map +1 -0
  119. package/dist/web-host.d.ts +29 -0
  120. package/dist/web-host.d.ts.map +1 -0
  121. package/dist/web-host.js +174 -0
  122. package/dist/web-host.js.map +1 -0
  123. package/examples/custom-system-status-extension/README.md +78 -0
  124. package/examples/custom-system-status-extension/index.ts +176 -0
  125. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  126. package/examples/default-prompt-stack.json +52 -93
  127. package/examples/image-reader-prompt-stack.json +124 -0
  128. package/examples/reviewer-prompt-stack.json +115 -0
  129. package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
  130. package/package.json +30 -9
  131. package/src/compiler.ts +321 -420
  132. package/src/extension-registry.ts +33 -0
  133. package/src/index.ts +310 -1110
  134. package/src/lifecycle.ts +171 -0
  135. package/src/loader.ts +141 -56
  136. package/src/macro-engine.ts +358 -0
  137. package/src/payload-capture.ts +85 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/policy.ts +42 -0
  140. package/src/preset-command.ts +271 -0
  141. package/src/preview.ts +226 -0
  142. package/src/regex.ts +500 -0
  143. package/src/render-helpers.ts +169 -0
  144. package/src/runtime-state.ts +36 -0
  145. package/src/sillytavern-importer/items.ts +98 -0
  146. package/src/sillytavern-importer/macros.ts +159 -0
  147. package/src/sillytavern-importer/prompt-order.ts +54 -0
  148. package/src/sillytavern-importer/regex.ts +270 -0
  149. package/src/sillytavern-importer/report.ts +202 -0
  150. package/src/sillytavern-importer/types.ts +120 -0
  151. package/src/sillytavern-importer.ts +57 -482
  152. package/src/slot-renderers.ts +414 -0
  153. package/src/stack-migration.ts +159 -0
  154. package/src/storage.ts +28 -0
  155. package/src/types.ts +83 -52
  156. package/src/web-editor/index.ts +2 -0
  157. package/src/web-editor/page.ts +3330 -0
  158. package/src/web-editor/server.ts +294 -0
  159. package/src/web-editor/types.ts +98 -0
  160. package/src/web-host.ts +232 -0
  161. package/src/web-editor.ts +0 -1491
package/src/web-editor.ts DELETED
@@ -1,1491 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
- import type { Socket } from "node:net";
4
-
5
- import { convertSillyTavernPreset } from "./sillytavern-importer.ts";
6
- import type { PromptStack, PromptStackDiagnostic } from "./types.ts";
7
-
8
- export interface WebEditorStackSummary {
9
- id: string;
10
- name?: string;
11
- filePath: string;
12
- active: boolean;
13
- autoActivate?: boolean;
14
- mode?: string;
15
- itemCount: number;
16
- errors: number;
17
- warnings: number;
18
- diagnostics: PromptStackDiagnostic[];
19
- }
20
-
21
- export interface WebEditorHost {
22
- cwd: string;
23
- listStacks(): WebEditorStackSummary[];
24
- getStack(id: string): { stack: PromptStack; filePath: string; diagnostics: PromptStackDiagnostic[] } | undefined;
25
- createStack(stack: PromptStack, options: WebEditorCreateStackOptions): WebEditorOperationResult<{ stack: WebEditorStackSummary; stacks: WebEditorStackSummary[] }>;
26
- saveStack(id: string, stack: PromptStack): WebEditorOperationResult<{ stack: WebEditorStackSummary; stacks: WebEditorStackSummary[] }>;
27
- deleteStack(id: string): WebEditorOperationResult<{ activeId?: string; stacks: WebEditorStackSummary[] }>;
28
- validateStack(stack: PromptStack): PromptStackDiagnostic[];
29
- previewStack(id: string, stack: PromptStack): WebEditorOperationResult<{ text: string; diagnostics: PromptStackDiagnostic[] }>;
30
- activateStack(id: string): WebEditorOperationResult<{ activeId?: string; stacks: WebEditorStackSummary[] }>;
31
- disableStacks(): WebEditorOperationResult<{ activeId?: string; stacks: WebEditorStackSummary[] }>;
32
- reloadStacks(): WebEditorOperationResult<{ activeId?: string; stacks: WebEditorStackSummary[] }>;
33
- }
34
-
35
- export interface WebEditorCreateStackOptions {
36
- activate?: boolean;
37
- overwrite?: boolean;
38
- }
39
-
40
- export type WebEditorOperationResult<T> = ({ ok: true } & T) | { ok: false; status?: number; error: string };
41
-
42
- export interface WebEditorServer {
43
- url: string;
44
- port: number;
45
- close(): Promise<void>;
46
- }
47
-
48
- export const DEFAULT_WEB_EDITOR_PORT = 41738;
49
-
50
- export interface WebEditorServerOptions {
51
- port?: number;
52
- }
53
-
54
- export async function startWebEditorServer(host: WebEditorHost, options: WebEditorServerOptions = {}): Promise<WebEditorServer> {
55
- const token = randomBytes(24).toString("base64url");
56
- const sockets = new Set<Socket>();
57
- const server = createServer((req, res) => {
58
- void handleRequest(host, token, req, res).catch((error) => {
59
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
60
- });
61
- });
62
- server.on("connection", (socket) => {
63
- sockets.add(socket);
64
- socket.on("close", () => sockets.delete(socket));
65
- });
66
-
67
- await new Promise<void>((resolve, reject) => {
68
- server.once("error", reject);
69
- server.listen(options.port ?? DEFAULT_WEB_EDITOR_PORT, "127.0.0.1", () => {
70
- server.off("error", reject);
71
- resolve();
72
- });
73
- });
74
-
75
- const address = server.address();
76
- if (!address || typeof address === "string") {
77
- await closeServer(server, sockets);
78
- throw new Error("Failed to start pi-forge editor server.");
79
- }
80
-
81
- const url = `http://127.0.0.1:${address.port}/?token=${encodeURIComponent(token)}`;
82
- return {
83
- url,
84
- port: address.port,
85
- close: () => closeServer(server, sockets),
86
- };
87
- }
88
-
89
- async function handleRequest(host: WebEditorHost, token: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
90
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
91
-
92
- if (url.pathname === "/" && req.method === "GET") {
93
- if (url.searchParams.get("token") !== token) {
94
- sendText(res, 403, "Invalid pi-forge editor token.");
95
- return;
96
- }
97
- sendHtml(res, renderEditorHtml());
98
- return;
99
- }
100
-
101
- if (!url.pathname.startsWith("/api/")) {
102
- sendText(res, 404, "Not found.");
103
- return;
104
- }
105
-
106
- if (!hasValidToken(req, url, token)) {
107
- sendJson(res, 403, { error: "Invalid pi-forge editor token." });
108
- return;
109
- }
110
-
111
- const parts = url.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
112
-
113
- if (req.method === "GET" && parts[1] === "stacks" && parts.length === 2) {
114
- sendJson(res, 200, { stacks: host.listStacks(), cwd: host.cwd });
115
- return;
116
- }
117
-
118
- if (req.method === "POST" && parts[1] === "stacks" && parts.length === 2) {
119
- const body = await readJsonBody(req);
120
- const parsed = readStackPayload(body);
121
- if (!parsed.ok) {
122
- sendJson(res, 400, { error: parsed.error });
123
- return;
124
- }
125
- const options = isPlainObject(body)
126
- ? { activate: body.activate === true, overwrite: body.overwrite === true }
127
- : {};
128
- const result = host.createStack(parsed.stack, options);
129
- if (result.ok && parsed.importFormat) {
130
- sendJson(res, 200, { ...result, importFormat: parsed.importFormat, importReport: parsed.importReport });
131
- return;
132
- }
133
- sendOperation(res, result);
134
- return;
135
- }
136
-
137
- if (req.method === "GET" && parts[1] === "stacks" && parts.length === 3) {
138
- const loaded = host.getStack(parts[2]!);
139
- if (!loaded) {
140
- sendJson(res, 404, { error: `Unknown prompt stack: ${parts[2]}` });
141
- return;
142
- }
143
- sendJson(res, 200, loaded);
144
- return;
145
- }
146
-
147
- if (req.method === "PUT" && parts[1] === "stacks" && parts.length === 3) {
148
- const body = await readJsonBody(req);
149
- const parsed = readStackPayload(body);
150
- if (!parsed.ok) {
151
- sendJson(res, 400, { error: parsed.error });
152
- return;
153
- }
154
- sendOperation(res, host.saveStack(parts[2]!, parsed.stack));
155
- return;
156
- }
157
-
158
- if (req.method === "DELETE" && parts[1] === "stacks" && parts.length === 3) {
159
- sendOperation(res, host.deleteStack(parts[2]!));
160
- return;
161
- }
162
-
163
- if (req.method === "POST" && parts[1] === "stacks" && parts.length === 4 && parts[3] === "validate") {
164
- const body = await readJsonBody(req);
165
- const parsed = readStackPayload(body);
166
- if (!parsed.ok) {
167
- sendJson(res, 400, { error: parsed.error });
168
- return;
169
- }
170
- sendJson(res, 200, { diagnostics: host.validateStack(parsed.stack) });
171
- return;
172
- }
173
-
174
- if (req.method === "POST" && parts[1] === "stacks" && parts.length === 4 && parts[3] === "preview") {
175
- const body = await readJsonBody(req);
176
- const parsed = readStackPayload(body);
177
- if (!parsed.ok) {
178
- sendJson(res, 400, { error: parsed.error });
179
- return;
180
- }
181
- sendOperation(res, host.previewStack(parts[2]!, parsed.stack));
182
- return;
183
- }
184
-
185
- if (req.method === "POST" && parts[1] === "stacks" && parts.length === 4 && parts[3] === "activate") {
186
- sendOperation(res, host.activateStack(parts[2]!));
187
- return;
188
- }
189
-
190
- if (req.method === "POST" && parts[1] === "disable" && parts.length === 2) {
191
- sendOperation(res, host.disableStacks());
192
- return;
193
- }
194
-
195
- if (req.method === "POST" && parts[1] === "reload" && parts.length === 2) {
196
- sendOperation(res, host.reloadStacks());
197
- return;
198
- }
199
-
200
- sendJson(res, 404, { error: "Unknown pi-forge editor API route." });
201
- }
202
-
203
- function hasValidToken(req: IncomingMessage, url: URL, token: string): boolean {
204
- const header = req.headers["x-pi-forge-token"];
205
- return header === token || url.searchParams.get("token") === token;
206
- }
207
-
208
- async function readJsonBody(req: IncomingMessage): Promise<unknown> {
209
- const chunks: Buffer[] = [];
210
- let size = 0;
211
- const maxBytes = 2_000_000;
212
-
213
- for await (const chunk of req) {
214
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
215
- size += buffer.length;
216
- if (size > maxBytes) throw new Error("Request body is too large.");
217
- chunks.push(buffer);
218
- }
219
-
220
- const text = Buffer.concat(chunks).toString("utf8");
221
- return text.trim() ? JSON.parse(text) : {};
222
- }
223
-
224
- function readStackPayload(body: unknown): { ok: true; stack: PromptStack; importFormat?: "sillytavern"; importReport?: string } | { ok: false; error: string } {
225
- const rawStack = isPlainObject(body) && "stack" in body ? body.stack : body;
226
- if (!isPlainObject(rawStack)) return { ok: false, error: "Stack payload must be a JSON object." };
227
-
228
- if (isSillyTavernPresetPayload(rawStack)) {
229
- const sourceName = isPlainObject(body) && typeof body.sourceName === "string" ? body.sourceName : undefined;
230
- const characterId = readCharacterId(body);
231
- const result = convertSillyTavernPreset(rawStack, { sourceName, characterId });
232
- if ("error" in result) return { ok: false, error: `SillyTavern import error: ${result.error}` };
233
- return { ok: true, stack: result.stack, importFormat: "sillytavern", importReport: result.report };
234
- }
235
-
236
- if (typeof rawStack.id !== "string" || !rawStack.id.trim()) return { ok: false, error: "Stack id must be a non-empty string." };
237
- if (!Array.isArray(rawStack.items)) return { ok: false, error: "Stack items must be an array." };
238
-
239
- for (const [index, item] of rawStack.items.entries()) {
240
- if (!isPlainObject(item)) return { ok: false, error: `Item ${index + 1} must be an object.` };
241
- if (item.kind !== "block" && item.kind !== "slot") return { ok: false, error: `Item ${index + 1} kind must be block or slot.` };
242
- if (typeof item.id !== "string" || !item.id.trim()) return { ok: false, error: `Item ${index + 1} id must be a non-empty string.` };
243
- if (item.kind === "block" && typeof item.content !== "string") return { ok: false, error: `Block item ${item.id} content must be a string.` };
244
- if (item.kind === "slot" && typeof item.slot !== "string") return { ok: false, error: `Slot item ${item.id} slot must be a string.` };
245
- }
246
-
247
- return { ok: true, stack: rawStack as unknown as PromptStack };
248
- }
249
-
250
- function isSillyTavernPresetPayload(value: Record<string, unknown>): boolean {
251
- return Array.isArray(value.prompts) && !Array.isArray(value.items);
252
- }
253
-
254
- function readCharacterId(body: unknown): number | undefined {
255
- if (!isPlainObject(body)) return undefined;
256
- const raw = body.characterId;
257
- if (typeof raw === "number" && Number.isInteger(raw)) return raw;
258
- if (typeof raw === "string" && raw.trim()) {
259
- const parsed = Number(raw.trim());
260
- if (Number.isInteger(parsed)) return parsed;
261
- }
262
- return undefined;
263
- }
264
-
265
- function sendOperation<T>(res: ServerResponse, result: WebEditorOperationResult<T>): void {
266
- if (!result.ok) {
267
- sendJson(res, result.status ?? 400, { error: result.error });
268
- return;
269
- }
270
- sendJson(res, 200, result);
271
- }
272
-
273
- function sendHtml(res: ServerResponse, html: string): void {
274
- res.writeHead(200, {
275
- "content-type": "text/html; charset=utf-8",
276
- "cache-control": "no-store",
277
- "connection": "close",
278
- });
279
- res.end(html);
280
- }
281
-
282
- function sendText(res: ServerResponse, status: number, text: string): void {
283
- res.writeHead(status, {
284
- "content-type": "text/plain; charset=utf-8",
285
- "cache-control": "no-store",
286
- "connection": "close",
287
- });
288
- res.end(text);
289
- }
290
-
291
- function sendJson(res: ServerResponse, status: number, value: unknown): void {
292
- res.writeHead(status, {
293
- "content-type": "application/json; charset=utf-8",
294
- "cache-control": "no-store",
295
- "connection": "close",
296
- });
297
- res.end(JSON.stringify(value));
298
- }
299
-
300
- function closeServer(server: Server, sockets: Set<Socket>): Promise<void> {
301
- return new Promise((resolve, reject) => {
302
- server.close((error) => error ? reject(error) : resolve());
303
- for (const socket of sockets) socket.destroy();
304
- });
305
- }
306
-
307
- function isPlainObject(value: unknown): value is Record<string, unknown> {
308
- return !!value && typeof value === "object" && !Array.isArray(value);
309
- }
310
-
311
- function renderEditorHtml(): string {
312
- return String.raw`<!doctype html>
313
- <html lang="en">
314
- <head>
315
- <meta charset="utf-8">
316
- <meta name="viewport" content="width=device-width, initial-scale=1">
317
- <title>pi-forge stack editor</title>
318
- <style>
319
- :root {
320
- color-scheme: light;
321
- --bg: #f7f8fb;
322
- --pane: #ffffff;
323
- --line: #d8dee8;
324
- --line-strong: #aeb8c7;
325
- --text: #18202c;
326
- --muted: #647083;
327
- --accent: #146b5f;
328
- --accent-bg: #e5f3ef;
329
- --warning: #9b6200;
330
- --warning-bg: #fff4d8;
331
- --error: #b42318;
332
- --error-bg: #fde8e7;
333
- --success: #1f7a3a;
334
- --control: #ffffff;
335
- }
336
- * { box-sizing: border-box; }
337
- body {
338
- margin: 0;
339
- background: var(--bg);
340
- color: var(--text);
341
- font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
342
- letter-spacing: 0;
343
- }
344
- button, input, select, textarea {
345
- font: inherit;
346
- letter-spacing: 0;
347
- }
348
- button {
349
- border: 1px solid var(--line-strong);
350
- background: var(--control);
351
- color: var(--text);
352
- min-height: 32px;
353
- padding: 5px 10px;
354
- border-radius: 6px;
355
- cursor: pointer;
356
- }
357
- button.primary {
358
- border-color: var(--accent);
359
- background: var(--accent);
360
- color: white;
361
- }
362
- button.danger {
363
- border-color: var(--error);
364
- color: var(--error);
365
- }
366
- button.icon {
367
- width: 34px;
368
- padding: 5px 0;
369
- }
370
- button:disabled {
371
- cursor: default;
372
- opacity: .55;
373
- }
374
- input, select, textarea {
375
- border: 1px solid var(--line-strong);
376
- border-radius: 6px;
377
- background: white;
378
- color: var(--text);
379
- padding: 6px 8px;
380
- width: 100%;
381
- }
382
- textarea {
383
- min-height: 140px;
384
- resize: vertical;
385
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
386
- font-size: 13px;
387
- }
388
- html, body {
389
- height: 100%;
390
- overflow: hidden;
391
- }
392
- .topbar {
393
- display: flex;
394
- align-items: center;
395
- gap: 10px;
396
- min-height: 48px;
397
- padding: 8px 12px;
398
- border-bottom: 1px solid var(--line);
399
- background: var(--pane);
400
- }
401
- .brand {
402
- font-weight: 700;
403
- margin-right: 8px;
404
- white-space: nowrap;
405
- }
406
- .status {
407
- color: var(--muted);
408
- flex: 1;
409
- min-width: 0;
410
- overflow: hidden;
411
- text-overflow: ellipsis;
412
- white-space: nowrap;
413
- }
414
- .shell {
415
- display: grid;
416
- grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
417
- height: calc(100vh - 48px);
418
- min-height: 0;
419
- transition: grid-template-columns .16s ease;
420
- }
421
- .shell.sidebar-collapsed {
422
- grid-template-columns: 0 minmax(0, 1fr);
423
- }
424
- .sidebar {
425
- border-right: 1px solid var(--line);
426
- background: var(--pane);
427
- min-width: 0;
428
- overflow: hidden;
429
- }
430
- .shell.sidebar-collapsed .sidebar {
431
- border-right: 0;
432
- }
433
- .side-head {
434
- padding: 12px;
435
- border-bottom: 1px solid var(--line);
436
- }
437
- .side-title {
438
- font-weight: 650;
439
- }
440
- .cwd {
441
- margin-top: 4px;
442
- color: var(--muted);
443
- font-size: 12px;
444
- overflow-wrap: anywhere;
445
- }
446
- .stack-list {
447
- padding: 8px;
448
- overflow: auto;
449
- height: calc(100% - 74px);
450
- }
451
- .stack-row {
452
- display: block;
453
- width: 100%;
454
- text-align: left;
455
- border: 1px solid transparent;
456
- background: transparent;
457
- border-radius: 6px;
458
- padding: 8px;
459
- margin-bottom: 4px;
460
- }
461
- .stack-row.active {
462
- background: var(--accent-bg);
463
- border-color: #9dccbf;
464
- }
465
- .stack-row.selected {
466
- border-color: var(--accent);
467
- }
468
- .stack-name {
469
- font-weight: 650;
470
- overflow-wrap: anywhere;
471
- }
472
- .stack-meta {
473
- color: var(--muted);
474
- font-size: 12px;
475
- margin-top: 2px;
476
- }
477
- .badge {
478
- display: inline-block;
479
- border-radius: 999px;
480
- padding: 1px 7px;
481
- margin-left: 5px;
482
- font-size: 12px;
483
- border: 1px solid var(--line);
484
- color: var(--muted);
485
- }
486
- .badge.error {
487
- color: var(--error);
488
- background: var(--error-bg);
489
- border-color: #f2b8b5;
490
- }
491
- .badge.warning {
492
- color: var(--warning);
493
- background: var(--warning-bg);
494
- border-color: #efd28b;
495
- }
496
- .main {
497
- min-width: 0;
498
- min-height: 0;
499
- display: flex;
500
- flex-direction: column;
501
- }
502
- .main-actions {
503
- display: flex;
504
- gap: 8px;
505
- flex-wrap: wrap;
506
- padding: 10px 12px;
507
- border-bottom: 1px solid var(--line);
508
- background: var(--pane);
509
- }
510
- .action-spacer {
511
- flex: 1 1 auto;
512
- min-width: 12px;
513
- }
514
- .settings {
515
- display: grid;
516
- grid-template-columns: repeat(4, minmax(130px, 1fr));
517
- gap: 10px;
518
- padding: 12px;
519
- border-bottom: 1px solid var(--line);
520
- background: #fbfcfe;
521
- flex: 0 0 auto;
522
- }
523
- .settings textarea {
524
- min-height: 48px;
525
- max-height: 72px;
526
- resize: vertical;
527
- }
528
- .field label {
529
- display: block;
530
- color: var(--muted);
531
- font-size: 12px;
532
- margin-bottom: 4px;
533
- }
534
- .checkline {
535
- display: flex;
536
- align-items: center;
537
- gap: 8px;
538
- min-height: 32px;
539
- }
540
- .checkline input {
541
- width: auto;
542
- }
543
- .workspace {
544
- display: grid;
545
- grid-template-columns: minmax(230px, 340px) minmax(0, 1fr);
546
- flex: 1;
547
- min-height: 0;
548
- }
549
- .items-pane {
550
- border-right: 1px solid var(--line);
551
- background: var(--pane);
552
- min-width: 0;
553
- min-height: 0;
554
- display: flex;
555
- flex-direction: column;
556
- }
557
- .pane-head {
558
- display: flex;
559
- align-items: center;
560
- justify-content: space-between;
561
- gap: 8px;
562
- padding: 10px 12px;
563
- border-bottom: 1px solid var(--line);
564
- font-weight: 650;
565
- }
566
- .item-tools {
567
- display: flex;
568
- gap: 6px;
569
- padding: 8px 12px;
570
- border-bottom: 1px solid var(--line);
571
- flex: 0 0 auto;
572
- }
573
- .item-list {
574
- padding: 8px;
575
- overflow: auto;
576
- flex: 1;
577
- min-height: 0;
578
- }
579
- .item-row {
580
- width: 100%;
581
- text-align: left;
582
- border: 1px solid var(--line);
583
- background: white;
584
- border-radius: 6px;
585
- padding: 8px;
586
- margin-bottom: 6px;
587
- display: grid;
588
- grid-template-columns: 18px minmax(0, 1fr) 48px;
589
- gap: 8px;
590
- align-items: center;
591
- cursor: grab;
592
- }
593
- .item-row:active {
594
- cursor: grabbing;
595
- }
596
- .item-row.selected {
597
- border-color: var(--accent);
598
- background: var(--accent-bg);
599
- }
600
- .item-row.disabled {
601
- opacity: .62;
602
- }
603
- .item-row.dragging {
604
- border-style: dashed;
605
- }
606
- .drag-handle {
607
- color: var(--muted);
608
- font-size: 20px;
609
- line-height: 1;
610
- text-align: center;
611
- user-select: none;
612
- }
613
- .item-toggle {
614
- width: 44px;
615
- min-height: 26px;
616
- padding: 2px 0;
617
- border-radius: 999px;
618
- font-size: 12px;
619
- cursor: pointer;
620
- }
621
- .item-toggle.enabled {
622
- border-color: var(--accent);
623
- background: var(--accent);
624
- color: white;
625
- }
626
- .item-toggle.disabled {
627
- border-color: var(--line-strong);
628
- background: #f3f5f8;
629
- color: var(--muted);
630
- }
631
- .item-title {
632
- font-weight: 650;
633
- overflow-wrap: anywhere;
634
- }
635
- .item-meta {
636
- color: var(--muted);
637
- font-size: 12px;
638
- margin-top: 2px;
639
- }
640
- .editor-pane {
641
- min-width: 0;
642
- min-height: 0;
643
- display: flex;
644
- flex-direction: column;
645
- background: #fbfcfe;
646
- }
647
- .item-editor {
648
- flex: 1;
649
- min-height: 0;
650
- overflow: hidden;
651
- }
652
- .item-form {
653
- display: flex;
654
- flex-direction: column;
655
- gap: 10px;
656
- padding: 12px;
657
- border-bottom: 1px solid var(--line);
658
- height: 100%;
659
- min-height: 0;
660
- overflow: hidden;
661
- }
662
- .item-fields {
663
- display: grid;
664
- grid-template-columns: repeat(4, minmax(120px, 1fr));
665
- gap: 10px;
666
- flex: 0 0 auto;
667
- }
668
- .item-body {
669
- flex: 1;
670
- min-height: 0;
671
- display: flex;
672
- }
673
- .item-body > .field {
674
- width: 100%;
675
- }
676
- .wide {
677
- grid-column: 1 / -1;
678
- }
679
- .content-field {
680
- display: flex;
681
- flex-direction: column;
682
- min-height: 0;
683
- }
684
- .content-field textarea {
685
- flex: 1;
686
- min-height: 0;
687
- height: 100%;
688
- resize: none;
689
- }
690
- .slot-options {
691
- display: flex;
692
- flex-direction: column;
693
- min-height: 0;
694
- }
695
- .segmented {
696
- display: inline-flex;
697
- gap: 4px;
698
- margin-bottom: 8px;
699
- }
700
- .segmented button {
701
- min-height: 28px;
702
- padding: 3px 9px;
703
- }
704
- .segmented button.active {
705
- border-color: var(--accent);
706
- background: var(--accent-bg);
707
- color: var(--accent);
708
- }
709
- .options-grid {
710
- display: grid;
711
- grid-template-columns: repeat(3, minmax(150px, 1fr));
712
- gap: 10px;
713
- overflow: auto;
714
- padding-right: 4px;
715
- flex: 1;
716
- min-height: 0;
717
- }
718
- .option-note {
719
- color: var(--muted);
720
- font-size: 12px;
721
- }
722
- .json-options {
723
- flex: 1;
724
- min-height: 0;
725
- height: 100%;
726
- resize: none;
727
- }
728
- .empty {
729
- color: var(--muted);
730
- padding: 24px;
731
- }
732
- .diagnostics {
733
- border-top: 1px solid var(--line);
734
- background: var(--pane);
735
- flex: 0 0 128px;
736
- min-height: 0;
737
- overflow: auto;
738
- }
739
- .diagnostic {
740
- padding: 6px 12px;
741
- border-bottom: 1px solid var(--line);
742
- }
743
- .diagnostic.error {
744
- color: var(--error);
745
- background: var(--error-bg);
746
- }
747
- .diagnostic.warning {
748
- color: var(--warning);
749
- background: var(--warning-bg);
750
- }
751
- .diagnostic.info {
752
- color: var(--muted);
753
- }
754
- .preview {
755
- display: none;
756
- border-top: 1px solid var(--line);
757
- background: #111827;
758
- color: #e5e7eb;
759
- margin: 0;
760
- padding: 12px;
761
- flex: 0 0 260px;
762
- max-height: 260px;
763
- overflow: auto;
764
- white-space: pre-wrap;
765
- font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
766
- }
767
- @media (max-width: 900px) {
768
- .shell, .workspace, .settings, .item-fields {
769
- grid-template-columns: 1fr;
770
- }
771
- html, body {
772
- overflow: auto;
773
- }
774
- .shell {
775
- height: auto;
776
- min-height: calc(100vh - 48px);
777
- }
778
- .sidebar, .items-pane {
779
- border-right: 0;
780
- border-bottom: 1px solid var(--line);
781
- }
782
- .item-list {
783
- max-height: 260px;
784
- }
785
- }
786
- </style>
787
- </head>
788
- <body>
789
- <header class="topbar">
790
- <button id="sidebarToggleBtn" class="icon" title="Toggle prompt stacks sidebar">☰</button>
791
- <div class="brand">pi-forge stack editor</div>
792
- <div id="status" class="status">Loading</div>
793
- <button id="reloadBtn">Reload</button>
794
- <button id="disableBtn">Disable stack</button>
795
- </header>
796
- <div id="shell" class="shell">
797
- <aside class="sidebar">
798
- <div class="side-head">
799
- <div class="side-title">Prompt stacks</div>
800
- <div id="cwd" class="cwd"></div>
801
- </div>
802
- <div id="stackList" class="stack-list"></div>
803
- </aside>
804
- <main class="main">
805
- <div class="main-actions">
806
- <button id="activateBtn" class="primary">Activate</button>
807
- <button id="saveBtn" class="primary">Save</button>
808
- <button id="validateBtn">Validate</button>
809
- <button id="previewBtn">Preview</button>
810
- <button id="forkBtn">Fork</button>
811
- <button id="importBtn" title="Import pi-forge stack JSON or SillyTavern preset JSON">Import JSON</button>
812
- <button id="exportBtn">Export JSON</button>
813
- <span class="action-spacer"></span>
814
- <button id="deleteStackBtn" class="danger">Delete stack</button>
815
- <button id="deleteItemBtn" class="danger">Delete item</button>
816
- <input id="importFileInput" type="file" accept="application/json,.json" hidden>
817
- </div>
818
- <section id="settings" class="settings"></section>
819
- <section class="workspace">
820
- <div class="items-pane">
821
- <div class="pane-head">
822
- <span>Items</span>
823
- <span id="itemCount" class="stack-meta"></span>
824
- </div>
825
- <div class="item-tools">
826
- <button id="addBlockBtn">Add block</button>
827
- <button id="addSlotBtn">Add slot</button>
828
- </div>
829
- <div id="itemList" class="item-list"></div>
830
- </div>
831
- <div class="editor-pane">
832
- <div id="itemEditor" class="item-editor"></div>
833
- <div id="diagnostics" class="diagnostics"></div>
834
- <pre id="preview" class="preview"></pre>
835
- </div>
836
- </section>
837
- </main>
838
- </div>
839
- <script>
840
- const token = new URLSearchParams(location.search).get("token") || "";
841
- let stacks = [];
842
- let cwd = "";
843
- let selectedId = "";
844
- let currentStack = null;
845
- let currentFilePath = "";
846
- let selectedItemIndex = -1;
847
- let dirty = false;
848
- let dragIndex = -1;
849
- let optionsText = "";
850
- let optionsError = "";
851
- let sidebarCollapsed = false;
852
- let slotOptionsMode = "form";
853
-
854
- const slotNames = [
855
- "chat-history", "tools", "tool-guidelines", "skills", "project-context",
856
- "append-system-prompt", "variables", "date", "cwd", "date-cwd",
857
- "active-model", "pi-docs"
858
- ];
859
- const roles = ["", "system", "user", "assistant", "custom"];
860
-
861
- const el = (id) => document.getElementById(id);
862
-
863
- async function api(path, options = {}) {
864
- const headers = { "x-pi-forge-token": token, ...(options.headers || {}) };
865
- let body = options.body;
866
- if (body && typeof body !== "string") {
867
- headers["content-type"] = "application/json";
868
- body = JSON.stringify(body);
869
- }
870
- const res = await fetch(path, { ...options, headers, body });
871
- const text = await res.text();
872
- const data = text ? JSON.parse(text) : {};
873
- if (!res.ok) {
874
- const error = new Error(data.error || res.statusText);
875
- error.status = res.status;
876
- throw error;
877
- }
878
- return data;
879
- }
880
-
881
- function setStatus(text, tone = "") {
882
- el("status").textContent = text;
883
- el("status").style.color = tone === "error" ? "var(--error)" : tone === "success" ? "var(--success)" : "var(--muted)";
884
- }
885
-
886
- function markDirty() {
887
- dirty = true;
888
- setStatus("Unsaved changes");
889
- }
890
-
891
- async function loadStacks(preferId = selectedId) {
892
- const data = await api("/api/stacks");
893
- stacks = data.stacks || [];
894
- cwd = data.cwd || "";
895
- el("cwd").textContent = cwd;
896
- renderStackList();
897
- const next = stacks.find((stack) => stack.id === preferId) || stacks.find((stack) => stack.active) || stacks[0];
898
- if (next) await selectStack(next.id, { keepDirty: false });
899
- else renderEmpty();
900
- }
901
-
902
- async function selectStack(id, options = {}) {
903
- if (dirty && !options.keepDirty && !confirm("Discard unsaved changes?")) return;
904
- const data = await api("/api/stacks/" + encodeURIComponent(id));
905
- selectedId = id;
906
- currentStack = structuredClone(data.stack);
907
- currentFilePath = data.filePath || "";
908
- selectedItemIndex = currentStack.items.length ? 0 : -1;
909
- dirty = false;
910
- optionsError = "";
911
- renderAll(data.diagnostics || []);
912
- setStatus("Loaded " + currentStack.id);
913
- }
914
-
915
- function renderAll(diagnostics = []) {
916
- renderStackList();
917
- renderSettings();
918
- renderItemList();
919
- renderItemEditor();
920
- renderDiagnostics(diagnostics);
921
- el("preview").style.display = "none";
922
- }
923
-
924
- function renderStackList() {
925
- const list = el("stackList");
926
- list.innerHTML = "";
927
- for (const stack of stacks) {
928
- const row = document.createElement("button");
929
- row.className = "stack-row" + (stack.active ? " active" : "") + (stack.id === selectedId ? " selected" : "");
930
- const diag = stack.errors ? '<span class="badge error">' + stack.errors + ' error</span>' : stack.warnings ? '<span class="badge warning">' + stack.warnings + ' warning</span>' : "";
931
- row.innerHTML = '<div class="stack-name">' + escapeHtml(stack.id) + (stack.active ? '<span class="badge">active</span>' : '') + diag + '</div>' +
932
- '<div class="stack-meta">' + escapeHtml(stack.name || "(unnamed)") + '</div>' +
933
- '<div class="stack-meta">' + stack.itemCount + ' items | ' + escapeHtml(stack.mode || "replace") + '</div>';
934
- row.onclick = () => selectStack(stack.id);
935
- list.appendChild(row);
936
- }
937
- }
938
-
939
- function renderSettings() {
940
- const settings = el("settings");
941
- if (!currentStack) {
942
- settings.innerHTML = "";
943
- return;
944
- }
945
- settings.innerHTML = [
946
- field("Stack ID", '<input id="stackId" value="' + attr(currentStack.id) + '">'),
947
- field("Name", '<input id="stackName" value="' + attr(currentStack.name || "") + '">'),
948
- field("Mode", '<select id="stackMode"><option value="replace">replace</option><option value="append">append</option><option value="prepend">prepend</option></select>'),
949
- field("Auto activate", '<label class="checkline"><input id="stackAuto" type="checkbox"> enabled</label>'),
950
- field("Description", '<textarea id="stackDescription" class="wide">' + escapeHtml(currentStack.description || "") + '</textarea>', "wide"),
951
- field("File", '<input value="' + attr(currentFilePath) + '" disabled>', "wide"),
952
- ].join("");
953
- el("stackMode").value = currentStack.mode || "replace";
954
- el("stackAuto").checked = currentStack.autoActivate === true;
955
- el("stackId").oninput = (event) => { currentStack.id = event.target.value; markDirty(); };
956
- el("stackName").oninput = (event) => { setOptionalString(currentStack, "name", event.target.value); markDirty(); };
957
- el("stackMode").onchange = (event) => { currentStack.mode = event.target.value; markDirty(); };
958
- el("stackAuto").onchange = (event) => { currentStack.autoActivate = event.target.checked; markDirty(); };
959
- el("stackDescription").oninput = (event) => { setOptionalString(currentStack, "description", event.target.value); markDirty(); };
960
- }
961
-
962
- function renderItemList() {
963
- const list = el("itemList");
964
- list.innerHTML = "";
965
- if (!currentStack) return;
966
- el("itemCount").textContent = currentStack.items.length + " total";
967
- currentStack.items.forEach((item, index) => {
968
- const row = document.createElement("div");
969
- row.className = "item-row" + (index === selectedItemIndex ? " selected" : "") + (item.enabled === false ? " disabled" : "");
970
- row.draggable = true;
971
- const enabled = item.enabled !== false;
972
- row.innerHTML = '<div class="drag-handle" title="Drag to reorder">≡</div>' +
973
- '<div><div class="item-title">' + escapeHtml(displayItemName(item)) + '</div>' +
974
- '<div class="item-meta">' + escapeHtml(item.kind) + ' | id: ' + escapeHtml(item.id) + (item.role ? " | " + escapeHtml(item.role) : "") + (item.kind === "slot" ? " | " + escapeHtml(item.slot || "") : "") + '</div></div>' +
975
- '<button type="button" class="item-toggle ' + (enabled ? "enabled" : "disabled") + '" title="Toggle item">' + (enabled ? "On" : "Off") + '</button>';
976
- row.onclick = (event) => {
977
- if (event.target?.classList?.contains("item-toggle")) return;
978
- selectedItemIndex = index;
979
- renderItemList();
980
- renderItemEditor();
981
- };
982
- row.querySelector(".item-toggle").onclick = (event) => {
983
- event.stopPropagation();
984
- item.enabled = item.enabled === false;
985
- selectedItemIndex = index;
986
- markDirty();
987
- renderItemList();
988
- renderItemEditor();
989
- };
990
- row.ondragstart = () => { dragIndex = index; row.classList.add("dragging"); };
991
- row.ondragend = () => { dragIndex = -1; row.classList.remove("dragging"); };
992
- row.ondragover = (event) => event.preventDefault();
993
- row.ondrop = (event) => {
994
- event.preventDefault();
995
- if (dragIndex === -1 || dragIndex === index) return;
996
- const moved = currentStack.items.splice(dragIndex, 1)[0];
997
- currentStack.items.splice(index, 0, moved);
998
- selectedItemIndex = index;
999
- markDirty();
1000
- renderItemList();
1001
- renderItemEditor();
1002
- };
1003
- list.appendChild(row);
1004
- });
1005
- }
1006
-
1007
- function renderItemEditor() {
1008
- const editor = el("itemEditor");
1009
- if (!currentStack || selectedItemIndex < 0 || !currentStack.items[selectedItemIndex]) {
1010
- editor.innerHTML = '<div class="empty">No item selected.</div>';
1011
- el("deleteItemBtn").disabled = true;
1012
- return;
1013
- }
1014
- el("deleteItemBtn").disabled = false;
1015
- const item = currentStack.items[selectedItemIndex];
1016
- optionsText = JSON.stringify(item.options || {}, null, 2);
1017
- optionsError = "";
1018
- const slotSelect = '<select id="itemSlot">' + slotNames.map((slot) => '<option value="' + attr(slot) + '">' + escapeHtml(slot) + '</option>').join("") + '</select>';
1019
- const roleSelect = '<select id="itemRole">' + roles.map((role) => '<option value="' + attr(role) + '">' + escapeHtml(role || "(none)") + '</option>').join("") + '</select>';
1020
- const kindSelect = '<select id="itemKind"><option value="block">block</option><option value="slot">slot</option></select>';
1021
- const topFields = '<div class="item-fields">' +
1022
- field("Kind", kindSelect) +
1023
- field("ID", '<input id="itemId" value="' + attr(item.id) + '">') +
1024
- field("Name", '<input id="itemName" value="' + attr(item.name || "") + '">') +
1025
- field("Role", roleSelect) +
1026
- (item.kind === "slot" ? field("Slot", slotSelect) : "") +
1027
- '</div>';
1028
- const body = item.kind === "block"
1029
- ? field("Content", '<textarea id="itemContent">' + escapeHtml(item.content || "") + '</textarea>', "content-field")
1030
- : renderSlotOptionsEditor(item);
1031
- editor.innerHTML = '<div class="item-form">' + topFields + '<div class="item-body">' + body + '</div></div>';
1032
-
1033
- el("itemKind").value = item.kind;
1034
- el("itemRole").value = item.role || "";
1035
- if (item.kind === "slot") el("itemSlot").value = item.slot || "chat-history";
1036
-
1037
- el("itemKind").onchange = (event) => {
1038
- if (event.target.value === item.kind) return;
1039
- const base = { id: item.id, name: item.name, enabled: item.enabled, role: item.role, tags: item.tags, source: item.source };
1040
- currentStack.items[selectedItemIndex] = event.target.value === "slot"
1041
- ? { ...base, kind: "slot", slot: "chat-history" }
1042
- : { ...base, kind: "block", content: "" };
1043
- markDirty();
1044
- renderItemList();
1045
- renderItemEditor();
1046
- };
1047
- el("itemId").oninput = (event) => { item.id = event.target.value; markDirty(); renderItemList(); };
1048
- el("itemName").oninput = (event) => { setOptionalString(item, "name", event.target.value); markDirty(); };
1049
- el("itemRole").onchange = (event) => { setOptionalString(item, "role", event.target.value); markDirty(); renderItemList(); };
1050
- if (item.kind === "block") {
1051
- el("itemContent").oninput = (event) => { item.content = event.target.value; markDirty(); };
1052
- } else {
1053
- el("itemSlot").onchange = (event) => { item.slot = event.target.value; markDirty(); renderItemList(); };
1054
- bindSlotOptionsEditor(item);
1055
- }
1056
- }
1057
-
1058
- function renderSlotOptionsEditor(item) {
1059
- const options = item.options || {};
1060
- const jsonActive = slotOptionsMode === "json";
1061
- const formButton = '<button id="slotOptionsFormBtn" type="button" class="' + (!jsonActive ? "active" : "") + '">Form</button>';
1062
- const jsonButton = '<button id="slotOptionsJsonBtn" type="button" class="' + (jsonActive ? "active" : "") + '">JSON</button>';
1063
- const body = jsonActive
1064
- ? '<textarea id="itemOptions" class="json-options">' + escapeHtml(optionsText) + '</textarea>'
1065
- : renderSlotOptionsForm(item, options);
1066
- return '<div class="field wide slot-options"><label>Slot options</label><div class="segmented">' + formButton + jsonButton + '</div>' + body + '</div>';
1067
- }
1068
-
1069
- function renderSlotOptionsForm(item, options) {
1070
- const fields = [];
1071
- if (item.slot === "chat-history") {
1072
- fields.push(optionCheckbox("includeLastUserMessage", "Include last user message", options.includeLastUserMessage !== false));
1073
- }
1074
- if (item.slot === "variables") {
1075
- fields.push(
1076
- optionCheckbox("includeStatic", "Include static state", options.includeStatic !== false),
1077
- optionCheckbox("includeSession", "Include session state", options.includeSession !== false),
1078
- optionCheckbox("includeTurn", "Include turn state", options.includeTurn !== false),
1079
- optionCheckbox("includeMetadata", "Include metadata", options.includeMetadata === true),
1080
- optionSelect("format", "Format", options.format || "xml", ["xml", "json"]),
1081
- optionText("includeScopes", "Include scopes", arrayToCsv(options.includeScopes)),
1082
- optionText("includeNamespaces", "Include namespaces", arrayToCsv(options.includeNamespaces)),
1083
- optionText("excludeNamespaces", "Exclude namespaces", arrayToCsv(options.excludeNamespaces)),
1084
- optionNumber("maxValueChars", "Max value chars", options.maxValueChars ?? ""),
1085
- );
1086
- }
1087
- if (fields.length === 0) {
1088
- fields.push('<div class="wide option-note">This slot has no structured options yet. Use JSON mode for advanced settings.</div>');
1089
- }
1090
- fields.push('<div class="wide option-note">Unknown option keys are preserved. Use JSON mode for advanced settings.</div>');
1091
- return '<div class="options-grid">' + fields.join("") + '</div>';
1092
- }
1093
-
1094
- function bindSlotOptionsEditor(item) {
1095
- el("slotOptionsFormBtn").onclick = () => {
1096
- slotOptionsMode = "form";
1097
- renderItemEditor();
1098
- };
1099
- el("slotOptionsJsonBtn").onclick = () => {
1100
- slotOptionsMode = "json";
1101
- renderItemEditor();
1102
- };
1103
-
1104
- if (slotOptionsMode === "json") {
1105
- el("itemOptions").oninput = (event) => {
1106
- optionsText = event.target.value;
1107
- try {
1108
- const parsed = optionsText.trim() ? JSON.parse(optionsText) : {};
1109
- item.options = Object.keys(parsed).length ? parsed : undefined;
1110
- optionsError = "";
1111
- markDirty();
1112
- } catch (error) {
1113
- optionsError = error.message;
1114
- setStatus("Invalid item options JSON", "error");
1115
- }
1116
- };
1117
- return;
1118
- }
1119
-
1120
- document.querySelectorAll("[data-option]").forEach((control) => {
1121
- control.onchange = (event) => {
1122
- const target = event.target;
1123
- const key = target.dataset.option;
1124
- if (!key) return;
1125
- if (target.type === "checkbox") {
1126
- setSlotOption(item, key, target.checked, defaultSlotOptionValue(key));
1127
- } else if (target.type === "number") {
1128
- const value = target.value.trim();
1129
- setSlotOption(item, key, value ? Number(value) : undefined);
1130
- } else if (target.dataset.array === "true") {
1131
- const values = target.value.split(",").map((part) => part.trim()).filter(Boolean);
1132
- setSlotOption(item, key, values.length ? values : undefined);
1133
- } else {
1134
- setSlotOption(item, key, target.value || undefined);
1135
- }
1136
- markDirty();
1137
- };
1138
- });
1139
- }
1140
-
1141
- function setSlotOption(item, key, value, defaultValue) {
1142
- const options = { ...(item.options || {}) };
1143
- if (value === undefined || value === defaultValue) delete options[key];
1144
- else options[key] = value;
1145
- item.options = Object.keys(options).length ? options : undefined;
1146
- }
1147
-
1148
- function defaultSlotOptionValue(key) {
1149
- if (["includeLastUserMessage", "includeStatic", "includeSession", "includeTurn"].includes(key)) return true;
1150
- return undefined;
1151
- }
1152
-
1153
- function optionCheckbox(key, label, checked) {
1154
- return '<label class="checkline"><input type="checkbox" data-option="' + attr(key) + '" ' + (checked ? "checked" : "") + '> ' + escapeHtml(label) + '</label>';
1155
- }
1156
-
1157
- function optionSelect(key, label, value, choices) {
1158
- return '<div class="field"><label>' + escapeHtml(label) + '</label><select data-option="' + attr(key) + '">' +
1159
- choices.map((choice) => '<option value="' + attr(choice) + '"' + (choice === value ? " selected" : "") + '>' + escapeHtml(choice) + '</option>').join("") +
1160
- '</select></div>';
1161
- }
1162
-
1163
- function optionText(key, label, value) {
1164
- return '<div class="field"><label>' + escapeHtml(label) + '</label><input data-option="' + attr(key) + '" data-array="true" value="' + attr(value) + '" placeholder="comma,separated"></div>';
1165
- }
1166
-
1167
- function optionNumber(key, label, value) {
1168
- return '<div class="field"><label>' + escapeHtml(label) + '</label><input type="number" min="1" data-option="' + attr(key) + '" value="' + attr(value) + '"></div>';
1169
- }
1170
-
1171
- function addItem(kind) {
1172
- if (!currentStack) return;
1173
- const idBase = kind === "slot" ? "slot" : "block";
1174
- let index = currentStack.items.length + 1;
1175
- let id = idBase + "-" + index;
1176
- const existing = new Set(currentStack.items.map((item) => item.id));
1177
- while (existing.has(id)) id = idBase + "-" + (++index);
1178
- currentStack.items.push(kind === "slot"
1179
- ? { kind: "slot", id, enabled: true, slot: "chat-history" }
1180
- : { kind: "block", id, enabled: true, role: "user", content: "" });
1181
- selectedItemIndex = currentStack.items.length - 1;
1182
- markDirty();
1183
- renderItemList();
1184
- renderItemEditor();
1185
- }
1186
-
1187
- function deleteSelectedItem() {
1188
- if (!currentStack || selectedItemIndex < 0) return;
1189
- const item = currentStack.items[selectedItemIndex];
1190
- if (!confirm("Delete item " + item.id + "?")) return;
1191
- currentStack.items.splice(selectedItemIndex, 1);
1192
- selectedItemIndex = Math.min(selectedItemIndex, currentStack.items.length - 1);
1193
- markDirty();
1194
- renderItemList();
1195
- renderItemEditor();
1196
- }
1197
-
1198
- async function saveStack() {
1199
- const stack = stackForSubmit();
1200
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId), { method: "PUT", body: { stack } });
1201
- stacks = data.stacks || stacks;
1202
- selectedId = data.stack?.id || stack.id;
1203
- currentStack = structuredClone(stack);
1204
- dirty = false;
1205
- renderAll(data.stack?.diagnostics || []);
1206
- setStatus("Saved " + selectedId, "success");
1207
- await selectStack(selectedId, { keepDirty: true });
1208
- }
1209
-
1210
- async function createStackRemote(stack, options = {}) {
1211
- try {
1212
- return await api("/api/stacks", { method: "POST", body: { stack, ...options } });
1213
- } catch (error) {
1214
- if (error.status === 409 && !options.overwrite && confirm((error.message || "Stack already exists.") + "\n\nOverwrite it?")) {
1215
- return await api("/api/stacks", { method: "POST", body: { stack, ...options, overwrite: true } });
1216
- }
1217
- throw error;
1218
- }
1219
- }
1220
-
1221
- async function openImportedStack(stack, activate, actionLabel, extraOptions = {}) {
1222
- const data = await createStackRemote(stack, { ...extraOptions, activate });
1223
- stacks = data.stacks || stacks;
1224
- selectedId = data.stack?.id || stack.id;
1225
- dirty = false;
1226
- await selectStack(selectedId, { keepDirty: true });
1227
- const converted = data.importFormat === "sillytavern" ? " from SillyTavern" : "";
1228
- setStatus(actionLabel + converted + " " + selectedId, "success");
1229
- }
1230
-
1231
- async function importStackJson() {
1232
- el("importFileInput").value = "";
1233
- el("importFileInput").click();
1234
- }
1235
-
1236
- async function handleImportFile(event) {
1237
- const file = event.target.files?.[0];
1238
- if (!file) return;
1239
- const text = await file.text();
1240
- const imported = JSON.parse(text);
1241
- if (!imported || typeof imported !== "object" || Array.isArray(imported)) throw new Error("Imported JSON must be an object.");
1242
- if (isSillyTavernImport(imported)) {
1243
- const characterId = promptSillyTavernCharacterId(imported);
1244
- if (characterId === null) return;
1245
- const activate = confirm("Convert and activate imported SillyTavern stack now?");
1246
- await openImportedStack(imported, activate, "Imported", { sourceName: file.name, characterId });
1247
- return;
1248
- }
1249
-
1250
- const stack = imported;
1251
- if (!stack.id || typeof stack.id !== "string") {
1252
- const promptedId = prompt("Stack id", sanitizeStackId(file.name.replace(/\.json$/i, "")));
1253
- if (!promptedId) return;
1254
- stack.id = promptedId.trim();
1255
- }
1256
- if (!Array.isArray(stack.items)) throw new Error("Imported stack must contain an items array.");
1257
- if (!stack.schemaVersion) stack.schemaVersion = 1;
1258
- if (!stack.type) stack.type = "pi-forge.prompt-stack";
1259
- const activate = confirm("Activate imported stack now?");
1260
- await openImportedStack(stack, activate, "Imported");
1261
- }
1262
-
1263
- function isSillyTavernImport(value) {
1264
- return value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.prompts) && !Array.isArray(value.items);
1265
- }
1266
-
1267
- function promptSillyTavernCharacterId(value) {
1268
- const ids = Array.isArray(value.prompt_order)
1269
- ? value.prompt_order
1270
- .map((entry) => entry && entry.character_id)
1271
- .filter((id) => Number.isInteger(id))
1272
- : [];
1273
- const uniqueIds = [...new Set(ids)];
1274
- if (uniqueIds.length <= 1) return undefined;
1275
- const answer = prompt("SillyTavern character_id (" + uniqueIds.join(", ") + ")", String(uniqueIds[0]));
1276
- if (answer === null) return null;
1277
- const parsed = Number(answer.trim());
1278
- if (!Number.isInteger(parsed) || !uniqueIds.includes(parsed)) {
1279
- throw new Error("Choose one of these character_id values: " + uniqueIds.join(", "));
1280
- }
1281
- return parsed;
1282
- }
1283
-
1284
- async function forkStack() {
1285
- const source = stackForSubmit();
1286
- const forkId = prompt("New fork stack id", uniqueForkId(source.id || "stack"));
1287
- if (!forkId) return;
1288
- const forkName = prompt("Fork display name", ((source.name || source.id || "Prompt stack") + " fork"));
1289
- const fork = structuredClone(source);
1290
- fork.id = forkId.trim();
1291
- if (forkName && forkName.trim()) fork.name = forkName;
1292
- fork.autoActivate = false;
1293
- const activate = confirm("Activate fork now?");
1294
- await openImportedStack(fork, activate, "Forked");
1295
- }
1296
-
1297
- function exportStackJson() {
1298
- const stack = stackForSubmit();
1299
- const json = JSON.stringify(stack, null, 2) + "\n";
1300
- const blob = new Blob([json], { type: "application/json" });
1301
- const url = URL.createObjectURL(blob);
1302
- const link = document.createElement("a");
1303
- link.href = url;
1304
- link.download = sanitizeStackId(stack.id || "prompt-stack") + ".json";
1305
- document.body.appendChild(link);
1306
- link.click();
1307
- link.remove();
1308
- URL.revokeObjectURL(url);
1309
- setStatus("Exported " + (stack.id || "prompt stack"), "success");
1310
- }
1311
-
1312
- function uniqueForkId(baseId) {
1313
- const base = sanitizeStackId(baseId || "stack") || "stack";
1314
- const existing = new Set(stacks.map((stack) => stack.id));
1315
- let candidate = base + "-fork";
1316
- let index = 2;
1317
- while (existing.has(candidate)) candidate = base + "-fork-" + index++;
1318
- return candidate;
1319
- }
1320
-
1321
- function sanitizeStackId(value) {
1322
- return String(value || "")
1323
- .trim()
1324
- .replace(/[^A-Za-z0-9_-]+/g, "-")
1325
- .replace(/-+/g, "-")
1326
- .replace(/^-|-$/g, "")
1327
- .toLowerCase();
1328
- }
1329
-
1330
- async function validateStack() {
1331
- const stack = stackForSubmit();
1332
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/validate", { method: "POST", body: { stack } });
1333
- renderDiagnostics(data.diagnostics || []);
1334
- el("preview").style.display = "none";
1335
- setStatus("Validation complete", "success");
1336
- }
1337
-
1338
- async function previewStack() {
1339
- const stack = stackForSubmit();
1340
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/preview", { method: "POST", body: { stack } });
1341
- renderDiagnostics(data.diagnostics || []);
1342
- el("preview").textContent = data.text || "";
1343
- el("preview").style.display = "block";
1344
- setStatus("Preview rendered", "success");
1345
- }
1346
-
1347
- async function activateStack() {
1348
- if (!currentStack) return;
1349
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/activate", { method: "POST" });
1350
- stacks = data.stacks || stacks;
1351
- renderStackList();
1352
- setStatus("Activated " + selectedId, "success");
1353
- }
1354
-
1355
- async function disableStacks() {
1356
- const data = await api("/api/disable", { method: "POST" });
1357
- stacks = data.stacks || stacks;
1358
- renderStackList();
1359
- setStatus("Prompt stack disabled", "success");
1360
- }
1361
-
1362
- async function deleteCurrentStack() {
1363
- if (!currentStack) return;
1364
- const id = selectedId;
1365
- const message = "Delete prompt stack '" + id + "'?\n\nThis removes its JSON file from .pi/prompt-stacks.";
1366
- if (!confirm(message)) return;
1367
- const data = await api("/api/stacks/" + encodeURIComponent(id), { method: "DELETE" });
1368
- stacks = data.stacks || [];
1369
- dirty = false;
1370
- const next = stacks.find((stack) => stack.active) || stacks[0];
1371
- if (next) {
1372
- await selectStack(next.id, { keepDirty: true });
1373
- setStatus("Deleted " + id, "success");
1374
- } else {
1375
- renderStackList();
1376
- renderEmpty();
1377
- setStatus("Deleted " + id + "; no stacks remain", "success");
1378
- }
1379
- }
1380
-
1381
- async function reloadFromDisk() {
1382
- if (dirty && !confirm("Discard unsaved changes?")) return;
1383
- const data = await api("/api/reload", { method: "POST" });
1384
- stacks = data.stacks || [];
1385
- renderStackList();
1386
- await loadStacks(selectedId);
1387
- setStatus("Reloaded from disk", "success");
1388
- }
1389
-
1390
- function stackForSubmit() {
1391
- if (!currentStack) throw new Error("No stack selected.");
1392
- if (optionsError) throw new Error("Invalid item options JSON: " + optionsError);
1393
- const clone = structuredClone(currentStack);
1394
- if (!clone.type) clone.type = "pi-forge.prompt-stack";
1395
- if (!clone.schemaVersion) clone.schemaVersion = 1;
1396
- return clone;
1397
- }
1398
-
1399
- function renderDiagnostics(diagnostics) {
1400
- const pane = el("diagnostics");
1401
- if (!diagnostics.length) {
1402
- pane.innerHTML = '<div class="diagnostic info">No diagnostics.</div>';
1403
- return;
1404
- }
1405
- pane.innerHTML = diagnostics.map((diag) => {
1406
- const level = diag.level || "info";
1407
- const item = diag.itemId ? " [" + escapeHtml(diag.itemId) + "]" : "";
1408
- return '<div class="diagnostic ' + attr(level) + '"><strong>' + escapeHtml(level.toUpperCase()) + item + '</strong>: ' + escapeHtml(diag.message || "") + '</div>';
1409
- }).join("");
1410
- }
1411
-
1412
- function renderEmpty() {
1413
- currentStack = null;
1414
- selectedId = "";
1415
- el("settings").innerHTML = "";
1416
- el("itemList").innerHTML = "";
1417
- el("itemEditor").innerHTML = '<div class="empty">No prompt stacks found.</div>';
1418
- renderDiagnostics([]);
1419
- setStatus("No prompt stacks found");
1420
- }
1421
-
1422
- function field(label, control, className = "") {
1423
- return '<div class="field ' + className + '"><label>' + escapeHtml(label) + '</label>' + control + '</div>';
1424
- }
1425
-
1426
- function displayItemName(item) {
1427
- if (item.name) return item.name;
1428
- if (item.source && typeof item.source.previousName === "string" && item.source.previousName.trim()) return item.source.previousName;
1429
- if (item.kind === "slot" && item.slot) return item.slot;
1430
- if (item.kind === "block" && item.content) {
1431
- const firstLine = item.content.trim().split(/\n/)[0]?.trim();
1432
- if (firstLine) return firstLine.length > 46 ? firstLine.slice(0, 43) + "..." : firstLine;
1433
- }
1434
- return item.id || "(unnamed)";
1435
- }
1436
-
1437
- function arrayToCsv(value) {
1438
- return Array.isArray(value) ? value.join(", ") : "";
1439
- }
1440
-
1441
- function setOptionalString(target, key, value) {
1442
- const trimmed = value.trim();
1443
- if (trimmed) target[key] = value;
1444
- else delete target[key];
1445
- }
1446
-
1447
- function escapeHtml(value) {
1448
- return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1449
- }
1450
-
1451
- function attr(value) {
1452
- return escapeHtml(value);
1453
- }
1454
-
1455
- async function run(action) {
1456
- try {
1457
- await action();
1458
- } catch (error) {
1459
- setStatus(error.message || String(error), "error");
1460
- }
1461
- }
1462
-
1463
- function toggleSidebar() {
1464
- sidebarCollapsed = !sidebarCollapsed;
1465
- el("shell").classList.toggle("sidebar-collapsed", sidebarCollapsed);
1466
- el("sidebarToggleBtn").title = sidebarCollapsed ? "Show prompt stacks sidebar" : "Hide prompt stacks sidebar";
1467
- setStatus(sidebarCollapsed ? "Prompt stacks sidebar hidden" : "Prompt stacks sidebar shown");
1468
- }
1469
-
1470
- el("sidebarToggleBtn").onclick = toggleSidebar;
1471
- el("reloadBtn").onclick = () => run(reloadFromDisk);
1472
- el("disableBtn").onclick = () => run(disableStacks);
1473
- el("activateBtn").onclick = () => run(activateStack);
1474
- el("saveBtn").onclick = () => run(saveStack);
1475
- el("validateBtn").onclick = () => run(validateStack);
1476
- el("previewBtn").onclick = () => run(previewStack);
1477
- el("forkBtn").onclick = () => run(forkStack);
1478
- el("importBtn").onclick = () => run(importStackJson);
1479
- el("exportBtn").onclick = () => run(exportStackJson);
1480
- el("importFileInput").onchange = (event) => run(() => handleImportFile(event));
1481
- el("deleteStackBtn").onclick = () => run(deleteCurrentStack);
1482
- el("addBlockBtn").onclick = () => addItem("block");
1483
- el("addSlotBtn").onclick = () => addItem("slot");
1484
- el("deleteItemBtn").onclick = deleteSelectedItem;
1485
- window.onbeforeunload = () => dirty ? "Unsaved changes" : undefined;
1486
-
1487
- run(() => loadStacks());
1488
- </script>
1489
- </body>
1490
- </html>`;
1491
- }