Skip to content

Commit ac14bbb

Browse files
committed
feat(prompts): implement prompt and category management features
- Added API endpoints for creating, updating, deleting, and reordering prompts and categories. - Implemented prompt library UI component for managing prompts and categories. - Integrated prompt usage tracking and export/import functionality. - Enhanced chat interface with prompt selection and new session confirmation dialog.
1 parent a85f2b3 commit ac14bbb

9 files changed

Lines changed: 1531 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66

77
### Added
88

9+
#### Prompt Library
10+
- **Prompt Library dialog** — reusable prompt manager accessible from chat input toolbar (Zap icon); CRUD for prompts with title, content, and category assignment (`components/prompt-library.tsx`, `lib/prompts-api.ts`)
11+
- **Category management** — create, rename, and delete prompt categories; filter prompts by category with pill-style toggle buttons; default categories seeded on first launch (General, Development, Operations, Research, Writing)
12+
- **Prompt pinning** — pin/unpin prompts to prioritize them at the top of the list; pinned state persisted server-side
13+
- **Drag-and-drop reorder** — drag prompts to reorder within the library; sort order persisted via `POST /api/prompts/reorder`
14+
- **Usage tracking** — tracks per-prompt usage count and last-used timestamp; displayed on each prompt card
15+
- **Import / Export** — export all prompts and categories as versioned JSON; import with client-side format validation and error toast on malformed files
16+
- **Search** — real-time search across prompt titles and content
17+
- **New session confirmation** — "New Session" button now shows a confirm dialog before starting a new session (prevents accidental session loss)
18+
19+
#### Prompt Library — Backend
20+
- **SQLite tables**`prompt_categories` and `prompts` tables created with `IF NOT EXISTS`; Drizzle ORM schema with typed columns (`server/db.ts`)
21+
- **CRUD service**`server/lib/prompts.ts`: `listCategories`, `createCategory`, `updateCategory`, `deleteCategory` (cascade-deletes prompts), `reorderCategories`, `listPrompts`, `createPrompt`, `updatePrompt`, `deletePrompt`, `recordUsage`, `reorderPrompts`, `exportAll`, `importAll`
22+
- **API routes** — 13 endpoints under `/api/prompts` and `/api/prompt-categories`; all mutating routes require `Authorization: Bearer` when `CK_API_TOKEN` is set; 404 responses for non-existent prompt IDs on update/delete/use (`server/routes/prompts.ts`)
23+
- **Default category seeding** — 5 default categories inserted on first database initialization
24+
925
- **Slash Commands** — type `/` to open autocomplete menu with 18 commands; keyboard navigation (↑↓ Tab Enter Esc); arg picker for `/think`, `/fast`, `/verbose`; categorized by Session, Model, Tools, Agents (`lib/slash-commands.ts`, `components/slash-menu.tsx`, `hooks/use-slash-menu.ts`)
1026
- **Slash Command Executor** — 15 client-side commands executed via Gateway RPC without sending to agent: `/model`, `/think`, `/compact`, `/fast`, `/verbose`, `/usage`, `/agents`, `/kill`, `/help`, `/new`, `/reset`, `/stop`, `/clear`, `/export`, `/focus` (`lib/slash-executor.ts`)
1127
- **Input History** — Arrow Up/Down navigates last 50 sent messages, deduplicates consecutive identical entries (`lib/input-history.ts`)

bin/server.mjs

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { z } from "zod";
5656
import { eq } from "drizzle-orm";
5757

5858
// server/db.ts
59+
import crypto from "node:crypto";
5960
import os from "node:os";
6061
import path from "node:path";
6162
import Database from "better-sqlite3";
@@ -86,6 +87,24 @@ var usageHistory = sqliteTable("usage_history", {
8687
outputTokens: integer("output_tokens").notNull(),
8788
costUsd: real("cost_usd").notNull()
8889
});
90+
var promptCategories = sqliteTable("prompt_categories", {
91+
id: text("id").primaryKey(),
92+
name: text("name").notNull(),
93+
sortOrder: integer("sort_order").notNull().default(0),
94+
createdAt: integer("created_at").notNull()
95+
});
96+
var prompts = sqliteTable("prompts", {
97+
id: text("id").primaryKey(),
98+
categoryId: text("category_id").notNull(),
99+
title: text("title").notNull(),
100+
content: text("content").notNull(),
101+
pinned: integer("pinned", { mode: "boolean" }).notNull().default(false),
102+
sortOrder: integer("sort_order").notNull().default(0),
103+
usageCount: integer("usage_count").notNull().default(0),
104+
lastUsedAt: integer("last_used_at"),
105+
createdAt: integer("created_at").notNull(),
106+
updatedAt: integer("updated_at").notNull()
107+
});
89108
function initDb() {
90109
const sqlite = new Database(DB_PATH);
91110
sqlite.pragma("journal_mode = WAL");
@@ -118,9 +137,42 @@ function initDb() {
118137
cost_usd REAL NOT NULL
119138
)
120139
`);
140+
sqlite.exec(`
141+
CREATE TABLE IF NOT EXISTS prompt_categories (
142+
id TEXT PRIMARY KEY,
143+
name TEXT NOT NULL,
144+
sort_order INTEGER NOT NULL DEFAULT 0,
145+
created_at INTEGER NOT NULL
146+
)
147+
`);
148+
sqlite.exec(`
149+
CREATE TABLE IF NOT EXISTS prompts (
150+
id TEXT PRIMARY KEY,
151+
category_id TEXT NOT NULL,
152+
title TEXT NOT NULL,
153+
content TEXT NOT NULL,
154+
pinned INTEGER NOT NULL DEFAULT 0,
155+
sort_order INTEGER NOT NULL DEFAULT 0,
156+
usage_count INTEGER NOT NULL DEFAULT 0,
157+
last_used_at INTEGER,
158+
created_at INTEGER NOT NULL,
159+
updated_at INTEGER NOT NULL
160+
)
161+
`);
162+
seedDefaultCategories(sqlite);
121163
return drizzle(sqlite, { schema });
122164
}
123-
var schema = { preferences, tokenAlarms, usageHistory };
165+
function seedDefaultCategories(sqlite) {
166+
const count = sqlite.prepare("SELECT COUNT(*) as n FROM prompt_categories").get();
167+
if (count.n > 0) return;
168+
const now3 = Math.floor(Date.now() / 1e3);
169+
const defaults = ["General", "Development", "Operations", "Research", "Writing"];
170+
const stmt = sqlite.prepare("INSERT INTO prompt_categories (id, name, sort_order, created_at) VALUES (?, ?, ?, ?)");
171+
for (let i = 0; i < defaults.length; i++) {
172+
stmt.run(crypto.randomUUID(), defaults[i], i, now3);
173+
}
174+
}
175+
var schema = { preferences, tokenAlarms, usageHistory, promptCategories, prompts };
124176
var db = initDb();
125177

126178
// server/lib/prefs.ts
@@ -158,6 +210,187 @@ async function handlePrefsPatch(c) {
158210
return c.json({ ok: true });
159211
}
160212

213+
// server/lib/prompts.ts
214+
import crypto2 from "node:crypto";
215+
import { eq as eq2, sql } from "drizzle-orm";
216+
var now2 = () => Math.floor(Date.now() / 1e3);
217+
function listCategories() {
218+
return db.select().from(promptCategories).orderBy(promptCategories.sortOrder).all();
219+
}
220+
function createCategory(name) {
221+
const maxOrder = db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }).from(promptCategories).get();
222+
const entry = {
223+
id: crypto2.randomUUID(),
224+
name: name.trim(),
225+
sortOrder: (maxOrder?.max ?? -1) + 1,
226+
createdAt: now2()
227+
};
228+
db.insert(promptCategories).values(entry).run();
229+
return entry;
230+
}
231+
function updateCategory(id, name) {
232+
db.update(promptCategories).set({ name: name.trim() }).where(eq2(promptCategories.id, id)).run();
233+
}
234+
function deleteCategory(id) {
235+
db.delete(prompts).where(eq2(prompts.categoryId, id)).run();
236+
const result = db.delete(promptCategories).where(eq2(promptCategories.id, id)).run();
237+
return { deleted: result.changes };
238+
}
239+
function reorderCategories(ids) {
240+
for (let i = 0; i < ids.length; i++) {
241+
db.update(promptCategories).set({ sortOrder: i }).where(eq2(promptCategories.id, ids[i])).run();
242+
}
243+
}
244+
function listPrompts() {
245+
return db.select().from(prompts).orderBy(prompts.sortOrder).all();
246+
}
247+
function createPrompt(data) {
248+
const maxOrder = db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }).from(prompts).where(eq2(prompts.categoryId, data.categoryId)).get();
249+
const entry = {
250+
id: crypto2.randomUUID(),
251+
categoryId: data.categoryId,
252+
title: data.title.trim(),
253+
content: data.content.trim(),
254+
pinned: false,
255+
sortOrder: (maxOrder?.max ?? -1) + 1,
256+
usageCount: 0,
257+
lastUsedAt: null,
258+
createdAt: now2(),
259+
updatedAt: now2()
260+
};
261+
db.insert(prompts).values(entry).run();
262+
return entry;
263+
}
264+
function updatePrompt(id, data) {
265+
const set = { updatedAt: now2() };
266+
if (data.title !== void 0) set.title = data.title.trim();
267+
if (data.content !== void 0) set.content = data.content.trim();
268+
if (data.categoryId !== void 0) set.categoryId = data.categoryId;
269+
if (data.pinned !== void 0) set.pinned = data.pinned;
270+
return db.update(prompts).set(set).where(eq2(prompts.id, id)).run();
271+
}
272+
function deletePrompt(id) {
273+
return db.delete(prompts).where(eq2(prompts.id, id)).run();
274+
}
275+
function recordUsage(id) {
276+
return db.update(prompts).set({ usageCount: sql`usage_count + 1`, lastUsedAt: now2(), updatedAt: now2() }).where(eq2(prompts.id, id)).run();
277+
}
278+
function reorderPrompts(ids) {
279+
for (let i = 0; i < ids.length; i++) {
280+
db.update(prompts).set({ sortOrder: i }).where(eq2(prompts.id, ids[i])).run();
281+
}
282+
}
283+
function exportAll() {
284+
return {
285+
version: 1,
286+
categories: listCategories(),
287+
prompts: listPrompts()
288+
};
289+
}
290+
function importAll(data) {
291+
let catCount = 0;
292+
let promptCount = 0;
293+
const ts = now2();
294+
const categoryIdMap = /* @__PURE__ */ new Map();
295+
for (const cat of data.categories) {
296+
const newId = crypto2.randomUUID();
297+
categoryIdMap.set(cat.id, newId);
298+
db.insert(promptCategories).values({ id: newId, name: cat.name, sortOrder: cat.sortOrder, createdAt: ts }).run();
299+
catCount++;
300+
}
301+
for (const p of data.prompts) {
302+
const mappedCategoryId = categoryIdMap.get(p.categoryId);
303+
if (!mappedCategoryId) continue;
304+
db.insert(prompts).values({
305+
id: crypto2.randomUUID(),
306+
categoryId: mappedCategoryId,
307+
title: p.title,
308+
content: p.content,
309+
pinned: p.pinned,
310+
sortOrder: p.sortOrder,
311+
usageCount: 0,
312+
lastUsedAt: null,
313+
createdAt: ts,
314+
updatedAt: ts
315+
}).run();
316+
promptCount++;
317+
}
318+
return { categories: catCount, prompts: promptCount };
319+
}
320+
321+
// server/routes/prompts.ts
322+
function handlePromptsGet(c) {
323+
return c.json({ categories: listCategories(), prompts: listPrompts() });
324+
}
325+
async function handlePromptsCreate(c) {
326+
const body = await c.req.json();
327+
if (!body.categoryId || !body.title?.trim() || !body.content?.trim()) {
328+
return c.json({ ok: false, error: "categoryId, title, and content required" }, 400);
329+
}
330+
const prompt = createPrompt({ categoryId: body.categoryId, title: body.title, content: body.content });
331+
return c.json({ ok: true, prompt });
332+
}
333+
async function handlePromptsUpdate(c) {
334+
const id = c.req.param("id");
335+
const body = await c.req.json();
336+
const result = updatePrompt(id, body);
337+
if (result.changes === 0) return c.json({ ok: false, error: "Prompt not found" }, 404);
338+
return c.json({ ok: true });
339+
}
340+
async function handlePromptsDelete(c) {
341+
const result = deletePrompt(c.req.param("id"));
342+
if (result.changes === 0) return c.json({ ok: false, error: "Prompt not found" }, 404);
343+
return c.json({ ok: true });
344+
}
345+
async function handlePromptsUse(c) {
346+
const result = recordUsage(c.req.param("id"));
347+
if (result.changes === 0) return c.json({ ok: false, error: "Prompt not found" }, 404);
348+
return c.json({ ok: true });
349+
}
350+
async function handlePromptsReorder(c) {
351+
const body = await c.req.json();
352+
if (!Array.isArray(body.ids)) return c.json({ ok: false, error: "ids array required" }, 400);
353+
reorderPrompts(body.ids);
354+
return c.json({ ok: true });
355+
}
356+
function handleCategoriesGet(c) {
357+
return c.json({ categories: listCategories() });
358+
}
359+
async function handleCategoriesCreate(c) {
360+
const body = await c.req.json();
361+
if (!body.name?.trim()) return c.json({ ok: false, error: "name required" }, 400);
362+
const category = createCategory(body.name);
363+
return c.json({ ok: true, category });
364+
}
365+
async function handleCategoriesUpdate(c) {
366+
const id = c.req.param("id");
367+
const body = await c.req.json();
368+
if (!body.name?.trim()) return c.json({ ok: false, error: "name required" }, 400);
369+
updateCategory(id, body.name);
370+
return c.json({ ok: true });
371+
}
372+
async function handleCategoriesDelete(c) {
373+
const result = deleteCategory(c.req.param("id"));
374+
return c.json({ ok: true, ...result });
375+
}
376+
async function handleCategoriesReorder(c) {
377+
const body = await c.req.json();
378+
if (!Array.isArray(body.ids)) return c.json({ ok: false, error: "ids array required" }, 400);
379+
reorderCategories(body.ids);
380+
return c.json({ ok: true });
381+
}
382+
function handlePromptsExport(c) {
383+
return c.json(exportAll());
384+
}
385+
async function handlePromptsImport(c) {
386+
const body = await c.req.json();
387+
if (body.version !== 1 || !Array.isArray(body.categories) || !Array.isArray(body.prompts)) {
388+
return c.json({ ok: false, error: "Invalid import format" }, 400);
389+
}
390+
const result = importAll(body);
391+
return c.json({ ok: true, ...result });
392+
}
393+
161394
// server/routes/version.ts
162395
import { z as z2 } from "zod";
163396

@@ -299,6 +532,9 @@ var api = new Hono().basePath("/api");
299532
api.get("/health", handleHealth);
300533
api.get("/version", handleVersionGet);
301534
api.get("/prefs", handlePrefsGet);
535+
api.get("/prompts", handlePromptsGet);
536+
api.get("/prompts/export", handlePromptsExport);
537+
api.get("/prompt-categories", handleCategoriesGet);
302538
api.post("/version/dismiss", (c) => {
303539
const denied = requireAuth(c);
304540
if (denied) return denied;
@@ -319,6 +555,56 @@ api.patch("/prefs", (c) => {
319555
if (denied) return denied;
320556
return handlePrefsPatch(c);
321557
});
558+
api.post("/prompts", (c) => {
559+
const denied = requireAuth(c);
560+
if (denied) return denied;
561+
return handlePromptsCreate(c);
562+
});
563+
api.patch("/prompts/:id", (c) => {
564+
const denied = requireAuth(c);
565+
if (denied) return denied;
566+
return handlePromptsUpdate(c);
567+
});
568+
api.delete("/prompts/:id", (c) => {
569+
const denied = requireAuth(c);
570+
if (denied) return denied;
571+
return handlePromptsDelete(c);
572+
});
573+
api.post("/prompts/:id/use", (c) => {
574+
const denied = requireAuth(c);
575+
if (denied) return denied;
576+
return handlePromptsUse(c);
577+
});
578+
api.post("/prompts/reorder", (c) => {
579+
const denied = requireAuth(c);
580+
if (denied) return denied;
581+
return handlePromptsReorder(c);
582+
});
583+
api.post("/prompts/import", (c) => {
584+
const denied = requireAuth(c);
585+
if (denied) return denied;
586+
return handlePromptsImport(c);
587+
});
588+
api.post("/prompt-categories", (c) => {
589+
const denied = requireAuth(c);
590+
if (denied) return denied;
591+
return handleCategoriesCreate(c);
592+
});
593+
api.patch("/prompt-categories/:id", (c) => {
594+
const denied = requireAuth(c);
595+
if (denied) return denied;
596+
return handleCategoriesUpdate(c);
597+
});
598+
api.delete("/prompt-categories/:id", (c) => {
599+
const denied = requireAuth(c);
600+
if (denied) return denied;
601+
return handleCategoriesDelete(c);
602+
});
603+
api.post("/prompt-categories/reorder", (c) => {
604+
const denied = requireAuth(c);
605+
if (denied) return denied;
606+
return handleCategoriesReorder(c);
607+
});
322608
app.route("/", api);
323609
var VITE_DEV_PORT = 5173;
324610
var spaResponse = () => new Response(injectedHtmlBuffer, {

0 commit comments

Comments
 (0)