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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
77b985db9c
commit
751edd2790
@@ -59,6 +59,15 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Prompt palette -->
|
||||
<PromptPalette
|
||||
ref="paletteRef"
|
||||
:query="paletteQuery"
|
||||
:is-open="isPaletteMode"
|
||||
@select="handlePaletteSelect"
|
||||
@close="closePalette"
|
||||
/>
|
||||
|
||||
<!-- Drag overlay -->
|
||||
<div
|
||||
v-if="isDragging"
|
||||
@@ -89,7 +98,7 @@
|
||||
rows="1"
|
||||
:placeholder="placeholder"
|
||||
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
@keydown="handleKeydown"
|
||||
@input="autoResize"
|
||||
@paste="onPaste"
|
||||
@focus="focused = true"
|
||||
@@ -152,6 +161,7 @@
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
|
||||
import SearchResults from '@/components/ui/SearchResults.vue'
|
||||
import PromptPalette from './PromptPalette.vue'
|
||||
import type { ImageAttachment } from '@aiui/core/types/message'
|
||||
|
||||
const MAX_IMAGES = 4
|
||||
@@ -187,6 +197,63 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const images = ref<ImageAttachment[]>([])
|
||||
|
||||
const paletteRef = ref<InstanceType<typeof PromptPalette> | 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() {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="absolute bottom-full left-0 right-0 mb-1 z-20">
|
||||
<!-- Variable fill form -->
|
||||
<div
|
||||
v-if="selectedTemplate && variables.length > 0"
|
||||
class="glass-card p-4 space-y-3 animate-scale-in"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
|
||||
<button
|
||||
class="text-[10px] text-white/40 hover:text-white/60 transition-colors"
|
||||
@click="cancelTemplate"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="v in variables" :key="v" class="space-y-1">
|
||||
<label class="text-[11px] text-white/40">{{ v }}</label>
|
||||
<input
|
||||
v-model="variableValues[v]"
|
||||
type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white/90 focus:outline-none focus:border-accent/50"
|
||||
:placeholder="v"
|
||||
@keydown.enter="applyTemplate"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
|
||||
@click="applyTemplate"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Template list -->
|
||||
<div
|
||||
v-else
|
||||
class="glass-card max-h-[240px] overflow-y-auto scrollbar-hide animate-scale-in"
|
||||
>
|
||||
<div class="p-2 border-b border-white/5">
|
||||
<p class="text-[10px] text-white/30 px-2">Templates</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(t, i) in filteredTemplates"
|
||||
:key="t.id"
|
||||
class="px-3 py-2 cursor-pointer transition-colors"
|
||||
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
|
||||
@click="selectTemplate(t)"
|
||||
@mouseenter="highlightIndex = i"
|
||||
>
|
||||
<p class="text-sm text-white/80">{{ t.title }}</p>
|
||||
<p v-if="t.preview" class="text-[11px] text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
|
||||
</div>
|
||||
<div v-if="filteredTemplates.length === 0" class="px-3 py-4 text-center">
|
||||
<p class="text-xs text-white/30">No matching templates</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
|
||||
|
||||
const props = defineProps<{
|
||||
query: string
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [text: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const templateStore = usePromptTemplateStore()
|
||||
const highlightIndex = ref(0)
|
||||
const selectedTemplate = ref<PromptTemplate | null>(null)
|
||||
const variableValues = ref<Record<string, string>>({})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const q = props.query.toLowerCase()
|
||||
if (!q) return templateStore.sortedTemplates
|
||||
return templateStore.sortedTemplates.filter(t =>
|
||||
t.title.toLowerCase().includes(q) || (t.preview ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const variables = computed(() => {
|
||||
if (!selectedTemplate.value) return []
|
||||
return extractVariables(selectedTemplate.value.content)
|
||||
})
|
||||
|
||||
watch(() => props.query, () => {
|
||||
highlightIndex.value = 0
|
||||
selectedTemplate.value = null
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (!open) {
|
||||
selectedTemplate.value = null
|
||||
variableValues.value = {}
|
||||
highlightIndex.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
function selectTemplate(t: PromptTemplate) {
|
||||
const vars = extractVariables(t.content)
|
||||
if (vars.length === 0) {
|
||||
// No variables — insert directly
|
||||
emit('select', t.content)
|
||||
return
|
||||
}
|
||||
selectedTemplate.value = t
|
||||
variableValues.value = Object.fromEntries(vars.map(v => [v, '']))
|
||||
}
|
||||
|
||||
function applyTemplate() {
|
||||
if (!selectedTemplate.value) return
|
||||
let result = selectedTemplate.value.content
|
||||
for (const [key, val] of Object.entries(variableValues.value)) {
|
||||
result = result.replaceAll(`{{${key}}}`, val || key)
|
||||
}
|
||||
emit('select', result)
|
||||
selectedTemplate.value = null
|
||||
variableValues.value = {}
|
||||
}
|
||||
|
||||
function cancelTemplate() {
|
||||
selectedTemplate.value = null
|
||||
variableValues.value = {}
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
if (highlightIndex.value > 0) highlightIndex.value--
|
||||
}
|
||||
|
||||
function navigateDown() {
|
||||
if (highlightIndex.value < filteredTemplates.value.length - 1) highlightIndex.value++
|
||||
}
|
||||
|
||||
function selectHighlighted() {
|
||||
const t = filteredTemplates.value[highlightIndex.value]
|
||||
if (t) selectTemplate(t)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
selectHighlighted,
|
||||
hasSelectedTemplate: computed(() => !!selectedTemplate.value),
|
||||
})
|
||||
</script>
|
||||
@@ -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<PromptTemplate[]>(loadFromStorage())
|
||||
|
||||
const sortedTemplates = computed(() =>
|
||||
[...templates.value].sort((a, b) => a.title.localeCompare(b.title))
|
||||
)
|
||||
|
||||
function persist() {
|
||||
saveToStorage(templates.value)
|
||||
}
|
||||
|
||||
function addTemplate(data: Omit<PromptTemplate, 'id'>): PromptTemplate {
|
||||
const template: PromptTemplate = { ...data, id: generateId() }
|
||||
templates.value.push(template)
|
||||
persist()
|
||||
return template
|
||||
}
|
||||
|
||||
function updateTemplate(id: string, data: Partial<Omit<PromptTemplate, 'id'>>) {
|
||||
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user