From 751edd279042bcff00208052c5c5023de4750689 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 23:30:55 +0000 Subject: [PATCH] feat(chat): add prompt template library with / command palette (M9.3) Type / in chat input to open glass command palette with prompt templates. Templates support {{variable}} substitution with a mini form. Built-in templates for Explain, Compare, Summarise, Translate. Templates stored in localStorage, importable/exportable as JSON. Arrow key navigation and Enter to select. Co-Authored-By: Claude Opus 4.6 --- .../app/src/components/chat/ChatInput.vue | 69 +++++++- .../app/src/components/chat/PromptPalette.vue | 152 ++++++++++++++++++ packages/app/src/stores/promptTemplates.ts | 136 ++++++++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/chat/PromptPalette.vue create mode 100644 packages/app/src/stores/promptTemplates.ts diff --git a/packages/app/src/components/chat/ChatInput.vue b/packages/app/src/components/chat/ChatInput.vue index ff84cf9c..ee6db914 100644 --- a/packages/app/src/components/chat/ChatInput.vue +++ b/packages/app/src/components/chat/ChatInput.vue @@ -59,6 +59,15 @@ + + +
(null) const fileInputRef = ref(null) const images = ref([]) +const paletteRef = ref | null>(null) + +// Prompt palette: opens when text starts with "/" (but not "/search " or "/code" etc.) +const isPaletteMode = computed(() => { + const t = text.value.trimStart() + return t === '/' || (t.startsWith('/') && !t.includes(' ') && !t.startsWith('/search') && !t.startsWith('/code') && !t.startsWith('/nostr') && !t.startsWith('/exit')) +}) + +const paletteQuery = computed(() => { + if (!isPaletteMode.value) return '' + return text.value.trimStart().slice(1) // strip leading / +}) + +function handlePaletteSelect(templateText: string) { + text.value = templateText + nextTick(() => { + autoResize() + textareaRef.value?.focus() + }) +} + +function closePalette() { + // Add a space to exit palette mode + if (text.value.trimStart() === '/') { + text.value = '' + } +} + +function handleKeydown(e: KeyboardEvent) { + if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) { + if (e.key === 'ArrowUp') { + e.preventDefault() + paletteRef.value?.navigateUp() + return + } + if (e.key === 'ArrowDown') { + e.preventDefault() + paletteRef.value?.navigateDown() + return + } + if (e.key === 'Enter') { + e.preventDefault() + paletteRef.value?.selectHighlighted() + return + } + if (e.key === 'Escape') { + e.preventDefault() + closePalette() + return + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } +} + const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled) function send() { diff --git a/packages/app/src/components/chat/PromptPalette.vue b/packages/app/src/components/chat/PromptPalette.vue new file mode 100644 index 00000000..d1999568 --- /dev/null +++ b/packages/app/src/components/chat/PromptPalette.vue @@ -0,0 +1,152 @@ + + + diff --git a/packages/app/src/stores/promptTemplates.ts b/packages/app/src/stores/promptTemplates.ts new file mode 100644 index 00000000..3f7ba43b --- /dev/null +++ b/packages/app/src/stores/promptTemplates.ts @@ -0,0 +1,136 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface PromptTemplate { + id: string + title: string + content: string + /** Preview text shown in palette */ + preview?: string +} + +const STORAGE_KEY = 'aiui-prompt-templates' + +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) +} + +const DEFAULT_TEMPLATES: PromptTemplate[] = [ + { + id: 'builtin-explain', + title: 'Explain like I\'m 5', + content: 'Explain {{topic}} in simple terms that a 5-year-old would understand.', + preview: 'Simplify complex topics', + }, + { + id: 'builtin-compare', + title: 'Compare & Contrast', + content: 'Compare and contrast {{option A}} and {{option B}}. List pros, cons, and a recommendation.', + preview: 'Side-by-side analysis', + }, + { + id: 'builtin-summarise', + title: 'Summarise', + content: 'Summarise the following in {{length}} bullet points:\n\n{{text}}', + preview: 'Condense text to key points', + }, + { + id: 'builtin-translate', + title: 'Translate', + content: 'Translate the following to {{language}}:\n\n{{text}}', + preview: 'Translate text to another language', + }, +] + +function loadFromStorage(): PromptTemplate[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [...DEFAULT_TEMPLATES] + return JSON.parse(raw) + } catch { + return [...DEFAULT_TEMPLATES] + } +} + +function saveToStorage(templates: PromptTemplate[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(templates)) +} + +/** Extract variable names from template content */ +export function extractVariables(content: string): string[] { + const matches = content.match(/\{\{([^}]+)\}\}/g) + if (!matches) return [] + return [...new Set(matches.map(m => m.slice(2, -2).trim()))] +} + +export const usePromptTemplateStore = defineStore('promptTemplates', () => { + const templates = ref(loadFromStorage()) + + const sortedTemplates = computed(() => + [...templates.value].sort((a, b) => a.title.localeCompare(b.title)) + ) + + function persist() { + saveToStorage(templates.value) + } + + function addTemplate(data: Omit): PromptTemplate { + const template: PromptTemplate = { ...data, id: generateId() } + templates.value.push(template) + persist() + return template + } + + function updateTemplate(id: string, data: Partial>) { + const t = templates.value.find(x => x.id === id) + if (!t) return + Object.assign(t, data) + persist() + } + + function deleteTemplate(id: string) { + const idx = templates.value.findIndex(x => x.id === id) + if (idx !== -1) { + templates.value.splice(idx, 1) + persist() + } + } + + function exportTemplates(): string { + return JSON.stringify(templates.value, null, 2) + } + + function importTemplates(json: string): number { + try { + const parsed = JSON.parse(json) as PromptTemplate[] + if (!Array.isArray(parsed)) return 0 + let count = 0 + for (const t of parsed) { + if (t.title && t.content && !templates.value.find(x => x.id === t.id)) { + templates.value.push({ id: t.id || generateId(), title: t.title, content: t.content, preview: t.preview }) + count++ + } + } + persist() + return count + } catch { + return 0 + } + } + + return { + templates, + sortedTemplates, + addTemplate, + updateTemplate, + deleteTemplate, + exportTemplates, + importTemplates, + } +})