Skip to content

Commit 4ee8ae6

Browse files
committed
Prompt Library upgrade: template variables and edit version history
1 parent ed96014 commit 4ee8ae6

10 files changed

Lines changed: 391 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
3434
**Organization**
3535
- **Projects** — group related chats under shared instructions and default model parameters.
3636
- **Per-session and per-project overrides** — pin a specific prompt, model, temperature, seed, top-K/top-P, repeat penalty, context length, GPU offload, or stop sequences to a single chat or an entire project, falling back to sane defaults. Provider-specific parameters (e.g. seed isn't supported by Claude, top-K isn't supported by ChatGPT) are automatically disabled when they don't apply to the selected model.
37-
- **Prompt library** — save and reuse system prompts across chats.
37+
- **Prompt library** — save and reuse system prompts across chats. Prompts can include `{{variables}}` (e.g. `{{topic}}`) that you fill in each time you apply one, and edits keep version history so a bad change can be restored.
3838
- **Command palette** (`Ctrl/Cmd+K`) — jump between chats, projects, and settings without touching the mouse.
3939

4040
**Files & retrieval**

app/src/settings-store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,21 @@ import * as path from "node:path";
22
import { app } from "electron";
33
import { readJson, writeJson } from "./json-store";
44

5+
export interface PromptVersion {
6+
prompt: string;
7+
savedAt: string;
8+
}
9+
510
export interface PromptPreset {
611
id: string;
712
name: string;
813
prompt: string;
14+
// Previous versions of `prompt`, newest first, capped at 10 — pushed here
15+
// whenever an edit overwrites the current content, so a bad edit can be
16+
// undone.
17+
versions?: PromptVersion[];
18+
createdAt?: string;
19+
updatedAt?: string;
920
}
1021

1122
export interface AppSettings {

frontend/src/components/layout.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import { CommandPalette } from "@/components/command-palette";
2424
import { useSessions } from "@/lib/sessions-context";
2525
import { useI18n } from "@/lib/i18n";
2626
import { cn } from "@/lib/utils";
27-
import type { ChatOptions, ChatSession, Project } from "@/types/electron";
27+
import type { ChatOptions, ChatSession, Project, PromptPreset } from "@/types/electron";
28+
import { extractVariables, fillTemplate } from "@/lib/prompt-templates";
29+
import { PromptVariableDialog } from "@/components/prompt-variable-dialog";
2830

2931
function SessionRow({
3032
session,
@@ -79,8 +81,9 @@ function ProjectGroup({
7981
const [name, setName] = useState(project.name);
8082
const [instructions, setInstructions] = useState(project.instructions);
8183
const [params, setParams] = useState<ChatOptions>(project.params ?? {});
82-
const [presets, setPresets] = useState<{ id: string; name: string; prompt: string }[]>([]);
84+
const [presets, setPresets] = useState<PromptPreset[]>([]);
8385
const [newPresetName, setNewPresetName] = useState("");
86+
const [pendingVariablePreset, setPendingVariablePreset] = useState<PromptPreset | null>(null);
8487

8588
async function handleSave() {
8689
await updateProject(project.id, { name, instructions });
@@ -102,11 +105,21 @@ function ProjectGroup({
102105
updateProject(project.id, { instructions: prompt });
103106
}
104107

108+
function selectPreset(preset: PromptPreset) {
109+
const variables = extractVariables(preset.prompt);
110+
if (variables.length === 0) {
111+
applyPreset(preset.prompt);
112+
} else {
113+
setPendingVariablePreset(preset);
114+
}
115+
}
116+
105117
async function saveCurrentAsPreset() {
106118
const name = newPresetName.trim();
107119
if (!name || !instructions.trim()) return;
108120
const settings = await window.api.settings.get();
109-
const preset = { id: crypto.randomUUID(), name, prompt: instructions };
121+
const now = new Date().toISOString();
122+
const preset: PromptPreset = { id: crypto.randomUUID(), name, prompt: instructions, versions: [], createdAt: now, updatedAt: now };
110123
await window.api.settings.save({ promptPresets: [...settings.promptPresets, preset] });
111124
setPresets([...settings.promptPresets, preset]);
112125
setNewPresetName("");
@@ -182,7 +195,7 @@ function ProjectGroup({
182195
{presets.map((preset) => (
183196
<button
184197
key={preset.id}
185-
onClick={() => applyPreset(preset.prompt)}
198+
onClick={() => selectPreset(preset)}
186199
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted"
187200
>
188201
<span className="truncate font-medium">{preset.name}</span>
@@ -399,6 +412,16 @@ function ProjectGroup({
399412
))}
400413
</div>
401414
)}
415+
<PromptVariableDialog
416+
open={pendingVariablePreset !== null}
417+
onOpenChange={(open) => {
418+
if (!open) setPendingVariablePreset(null);
419+
}}
420+
variables={pendingVariablePreset ? extractVariables(pendingVariablePreset.prompt) : []}
421+
onSubmit={(values) => {
422+
if (pendingVariablePreset) applyPreset(fillTemplate(pendingVariablePreset.prompt, values));
423+
}}
424+
/>
402425
</div>
403426
);
404427
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useEffect, useState } from "react";
2+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
3+
import { Button } from "@/components/ui/button";
4+
import { Input } from "@/components/ui/input";
5+
import { useI18n } from "@/lib/i18n";
6+
7+
export function PromptVariableDialog({
8+
open,
9+
onOpenChange,
10+
variables,
11+
onSubmit,
12+
}: {
13+
open: boolean;
14+
onOpenChange: (open: boolean) => void;
15+
variables: string[];
16+
onSubmit: (values: Record<string, string>) => void;
17+
}) {
18+
const { t } = useI18n();
19+
const [values, setValues] = useState<Record<string, string>>({});
20+
21+
// Reset the form fresh every time a new set of variables is opened,
22+
// rather than carrying over stale values from whatever preset was
23+
// filled in last.
24+
useEffect(() => {
25+
// Intentional: reset the form fresh each time the dialog opens for a
26+
// new preset, rather than carrying over stale values.
27+
// eslint-disable-next-line react-hooks/set-state-in-effect
28+
if (open) setValues({});
29+
}, [open, variables]);
30+
31+
function handleSubmit() {
32+
onSubmit(values);
33+
onOpenChange(false);
34+
}
35+
36+
return (
37+
<Dialog open={open} onOpenChange={onOpenChange}>
38+
<DialogContent>
39+
<DialogHeader>
40+
<DialogTitle>{t.fillPromptVariables}</DialogTitle>
41+
<DialogDescription>{t.fillPromptVariablesHelp}</DialogDescription>
42+
</DialogHeader>
43+
<div className="flex flex-col gap-3">
44+
{variables.map((v, i) => (
45+
<div key={v} className="flex flex-col gap-1">
46+
<label className="text-xs text-muted-foreground">{v}</label>
47+
<Input
48+
autoFocus={i === 0}
49+
value={values[v] ?? ""}
50+
onChange={(e) => setValues((prev) => ({ ...prev, [v]: e.target.value }))}
51+
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
52+
/>
53+
</div>
54+
))}
55+
</div>
56+
<DialogFooter>
57+
<Button variant="outline" onClick={() => onOpenChange(false)}>
58+
{t.cancel}
59+
</Button>
60+
<Button onClick={handleSubmit}>{t.apply}</Button>
61+
</DialogFooter>
62+
</DialogContent>
63+
</Dialog>
64+
);
65+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from "vitest";
2+
import { extractVariables, fillTemplate } from "./prompt-templates";
3+
4+
describe("extractVariables", () => {
5+
it("returns an empty array for a template with no variables", () => {
6+
expect(extractVariables("You are a helpful assistant.")).toEqual([]);
7+
});
8+
9+
it("finds a single variable", () => {
10+
expect(extractVariables("Write about {{topic}}.")).toEqual(["topic"]);
11+
});
12+
13+
it("finds multiple distinct variables in order of first appearance", () => {
14+
expect(extractVariables("{{tone}} summary of {{topic}} for {{audience}}.")).toEqual([
15+
"tone",
16+
"topic",
17+
"audience",
18+
]);
19+
});
20+
21+
it("deduplicates repeated variables, keeping first-seen order", () => {
22+
expect(extractVariables("{{topic}} ... more about {{topic}} and {{audience}}")).toEqual([
23+
"topic",
24+
"audience",
25+
]);
26+
});
27+
28+
it("tolerates extra whitespace inside the braces", () => {
29+
expect(extractVariables("{{ topic }}")).toEqual(["topic"]);
30+
});
31+
});
32+
33+
describe("fillTemplate", () => {
34+
it("substitutes a single variable", () => {
35+
expect(fillTemplate("Write about {{topic}}.", { topic: "cats" })).toBe("Write about cats.");
36+
});
37+
38+
it("substitutes multiple occurrences of the same variable", () => {
39+
expect(fillTemplate("{{name}} says hi, {{name}}!", { name: "Ada" })).toBe("Ada says hi, Ada!");
40+
});
41+
42+
it("replaces a missing value with an empty string rather than leaving the placeholder", () => {
43+
expect(fillTemplate("Hello {{name}}.", {})).toBe("Hello .");
44+
});
45+
46+
it("leaves plain text with no variables unchanged", () => {
47+
expect(fillTemplate("You are a helpful assistant.", { unused: "x" })).toBe("You are a helpful assistant.");
48+
});
49+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Prompt Library templates can embed variables as {{name}} — this module
2+
// finds them and fills them in. Kept intentionally simple (no conditionals,
3+
// no loops): the goal is reusable prompts with a few blanks to fill in, not
4+
// a templating language.
5+
const VARIABLE_PATTERN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
6+
7+
export function extractVariables(template: string): string[] {
8+
const seen = new Set<string>();
9+
const ordered: string[] = [];
10+
for (const match of template.matchAll(VARIABLE_PATTERN)) {
11+
const name = match[1];
12+
if (!seen.has(name)) {
13+
seen.add(name);
14+
ordered.push(name);
15+
}
16+
}
17+
return ordered;
18+
}
19+
20+
export function fillTemplate(template: string, values: Record<string, string>): string {
21+
return template.replace(VARIABLE_PATTERN, (_match, name: string) => values[name] ?? "");
22+
}

frontend/src/lib/translations.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ export interface Dictionary {
7171
savePromptAsPreset: string;
7272
presetName: string;
7373
apply: string;
74+
cancel: string;
75+
fillPromptVariables: string;
76+
fillPromptVariablesHelp: string;
77+
editPreset: string;
78+
presetHistory: string;
79+
restore: string;
80+
noPreviousVersions: string;
81+
savePreset: string;
82+
promptLibraryVariablesHint: string;
7483
resetToDefault: string;
7584
usingCustomPrompt: string;
7685
dataManagement: string;
@@ -180,6 +189,15 @@ export const en: Dictionary = {
180189
savePromptAsPreset: "Save current prompt as preset",
181190
presetName: "Preset name...",
182191
apply: "Apply",
192+
cancel: "Cancel",
193+
fillPromptVariables: "Fill in prompt variables",
194+
fillPromptVariablesHelp: "This prompt has blanks to fill in before it's applied.",
195+
editPreset: "Edit",
196+
presetHistory: "History",
197+
restore: "Restore",
198+
noPreviousVersions: "No previous versions yet.",
199+
savePreset: "Save",
200+
promptLibraryVariablesHint: "Add {{variables}} to a prompt (e.g. {{topic}}) and you'll be asked to fill them in each time you apply it. Edits keep version history so you can undo a change.",
183201
resetToDefault: "Reset to default",
184202
usingCustomPrompt: "Custom prompt for this chat",
185203
dataManagement: "Data management",
@@ -297,6 +315,15 @@ export const tr: Dictionary = {
297315
savePromptAsPreset: "Mevcut istemi ön ayar olarak kaydet",
298316
presetName: "Ön ayar adı...",
299317
apply: "Uygula",
318+
cancel: "İptal",
319+
fillPromptVariables: "İstem değişkenlerini doldurun",
320+
fillPromptVariablesHelp: "Bu istemde uygulanmadan önce doldurulması gereken boşluklar var.",
321+
editPreset: "Düzenle",
322+
presetHistory: "Geçmiş",
323+
restore: "Geri yükle",
324+
noPreviousVersions: "Henüz önceki bir sürüm yok.",
325+
savePreset: "Kaydet",
326+
promptLibraryVariablesHint: "Bir isteme {{değişkenler}} ekleyin (ör. {{konu}}) — her uyguladığınızda bunları doldurmanız istenir. Düzenlemeler sürüm geçmişini korur, böylece bir değişikliği geri alabilirsiniz.",
300327
resetToDefault: "Varsayılana dön",
301328
usingCustomPrompt: "Bu sohbet için özel istem",
302329
dataManagement: "Veri yönetimi",

frontend/src/pages/Chat.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import { useSessions } from "@/lib/sessions-context";
4747
import { useI18n } from "@/lib/i18n";
4848
import { OPENAI_MODELS, ANTHROPIC_MODELS, formatModelRef, parseModelRef } from "@/lib/providers";
4949
import { estimateCost, formatCost } from "@/lib/pricing";
50+
import { extractVariables, fillTemplate } from "@/lib/prompt-templates";
51+
import { PromptVariableDialog } from "@/components/prompt-variable-dialog";
5052
import type {
5153
ChatMessage,
5254
OllamaModel,
@@ -58,6 +60,7 @@ import type {
5860
ChatOptions,
5961
UsageInfo,
6062
ToolCall,
63+
PromptPreset,
6164
} from "@/types/electron";
6265

6366
type Attachment = AttachedFile & { folder?: string };
@@ -260,6 +263,7 @@ export default function Chat() {
260263
const [agentMode, setAgentMode] = useState(false);
261264
const [agentWorkspace, setAgentWorkspace] = useState<string | null>(null);
262265
const [pendingToolCalls, setPendingToolCalls] = useState<ToolCall[]>([]);
266+
const [pendingVariablePreset, setPendingVariablePreset] = useState<PromptPreset | null>(null);
263267
const [agentStepCount, setAgentStepCount] = useState(0);
264268
const [autoApprovedTools, setAutoApprovedTools] = useState<Set<string>>(new Set());
265269
const [newPresetName, setNewPresetName] = useState("");
@@ -664,6 +668,17 @@ export default function Chat() {
664668
if (sessionId) window.api.sessions.update(sessionId, { systemPrompt: prompt });
665669
}
666670

671+
// Presets with {{variables}} need values filled in before they're usable
672+
// as a system prompt — presets without any apply immediately as before.
673+
function selectPromptPreset(preset: PromptPreset) {
674+
const variables = extractVariables(preset.prompt);
675+
if (variables.length === 0) {
676+
applyPromptPreset(preset.prompt);
677+
} else {
678+
setPendingVariablePreset(preset);
679+
}
680+
}
681+
667682
function resetPromptToDefault() {
668683
setSessionSystemPrompt(null);
669684
if (sessionId) window.api.sessions.update(sessionId, { systemPrompt: null });
@@ -679,7 +694,8 @@ export default function Chat() {
679694
const name = newPresetName.trim();
680695
const prompt = sessionSystemPrompt ?? settings.systemPrompt;
681696
if (!name || !prompt.trim()) return;
682-
const preset = { id: crypto.randomUUID(), name, prompt };
697+
const now = new Date().toISOString();
698+
const preset: PromptPreset = { id: crypto.randomUUID(), name, prompt, versions: [], createdAt: now, updatedAt: now };
683699
const updated = await window.api.settings.save({ promptPresets: [...settings.promptPresets, preset] });
684700
setSettings(updated);
685701
setNewPresetName("");
@@ -931,7 +947,7 @@ export default function Chat() {
931947
{settings.promptPresets.map((preset) => (
932948
<button
933949
key={preset.id}
934-
onClick={() => applyPromptPreset(preset.prompt)}
950+
onClick={() => selectPromptPreset(preset)}
935951
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted"
936952
>
937953
<span className="truncate font-medium">{preset.name}</span>
@@ -1363,6 +1379,16 @@ export default function Chat() {
13631379
</div>
13641380
</div>
13651381
</div>
1382+
<PromptVariableDialog
1383+
open={pendingVariablePreset !== null}
1384+
onOpenChange={(open) => {
1385+
if (!open) setPendingVariablePreset(null);
1386+
}}
1387+
variables={pendingVariablePreset ? extractVariables(pendingVariablePreset.prompt) : []}
1388+
onSubmit={(values) => {
1389+
if (pendingVariablePreset) applyPromptPreset(fillTemplate(pendingVariablePreset.prompt, values));
1390+
}}
1391+
/>
13661392
</div>
13671393
);
13681394
}

0 commit comments

Comments
 (0)