feat(chat): add system prompt editor & personas (M9.2)

Named personas with system prompt, model preference, and accent colour.
Persona pill selector above chat input, editor modal for create/edit/delete.
Default persona auto-applied to new conversations. Persona system prompt
prepended to AI context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:29:01 +00:00
co-authored by Claude Opus 4.6
parent 27aeb2b188
commit 77b985db9c
6 changed files with 450 additions and 14 deletions
@@ -20,6 +20,8 @@
<ContextBar :messages="messages" />
<PersonaSelector />
<!-- Collapsed: prompt index -->
<PromptIndex
v-if="chatCollapsed"
@@ -116,6 +118,7 @@ import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
@@ -142,6 +145,8 @@ const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse } = u
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
@@ -220,7 +225,7 @@ function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
}
function handleNewChat() {
chatStore.createConversation()
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
}
function handleStop() {
@@ -0,0 +1,248 @@
<template>
<div v-if="personaStore.personas.length > 0" class="px-3 md:px-4">
<div class="flex items-center gap-1.5 flex-wrap">
<button
v-for="p in personaStore.sortedPersonas"
:key="p.id"
class="px-2.5 py-1 rounded-full text-[11px] font-medium transition-all duration-200 border"
:class="isActive(p.id)
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-white/5 text-white/50 border-white/10 hover:text-white/70 hover:bg-white/10'"
@click="selectPersona(p.id)"
>
<span
v-if="p.accentColor"
class="inline-block w-2 h-2 rounded-full mr-1"
:style="{ backgroundColor: p.accentColor }"
/>
{{ p.name }}
</button>
<button
class="px-2 py-1 rounded-full text-[11px] text-white/30 hover:text-white/60 border border-transparent hover:border-white/10 transition-all duration-200"
@click="showEditor = true"
>
+ New
</button>
<button
v-if="activePersonaId"
class="px-2 py-1 rounded-full text-[11px] text-white/30 hover:text-white/60 transition-all"
title="Clear persona"
@click="clearPersona"
>
</button>
</div>
<!-- Persona editor modal -->
<Teleport to="body">
<div
v-if="showEditor"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeEditor"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div class="relative glass-card w-full max-w-md p-5 space-y-4 animate-scale-in">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/90">
{{ editingPersona ? 'Edit Persona' : 'New Persona' }}
</h3>
<button
class="w-7 h-7 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-[11px] text-white/40 mb-1">Name</label>
<input
v-model="formName"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 focus:outline-none focus:border-accent/50"
placeholder="e.g. Film Critic"
/>
</div>
<div>
<label class="block text-[11px] text-white/40 mb-1">System Prompt</label>
<textarea
v-model="formPrompt"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="5"
placeholder="You are a film critic who..."
/>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-[11px] text-white/40 mb-1">Model Preference</label>
<select
v-model="formModel"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 focus:outline-none focus:border-accent/50"
>
<option value="">Default</option>
<option value="claude-haiku-4.5">Claude 4.5 Haiku</option>
<option value="claude-sonnet-4">Claude Sonnet 4</option>
<option value="claude-opus-4">Claude Opus 4</option>
</select>
</div>
<div class="w-20">
<label class="block text-[11px] text-white/40 mb-1">Colour</label>
<input
v-model="formColor"
type="color"
class="w-full h-9 rounded-lg bg-white/5 border border-white/10 cursor-pointer"
/>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-white/60">
<input
v-model="formDefault"
type="checkbox"
class="accent-[#F7931A]"
/>
Set as default for new conversations
</label>
</div>
<div class="flex items-center gap-2 pt-1">
<button
v-if="editingPersona"
class="px-3 py-1.5 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-red-400/10 transition-all"
@click="handleDelete"
>
Delete
</button>
<div class="flex-1" />
<button
class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
Cancel
</button>
<button
class="px-4 py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
:disabled="!formName.trim()"
@click="handleSave"
>
{{ editingPersona ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePersonaStore, type Persona } from '@/stores/personas'
import { useChatStore } from '@/stores/chat'
const personaStore = usePersonaStore()
const chatStore = useChatStore()
const showEditor = ref(false)
const editingPersona = ref<Persona | null>(null)
// Form state
const formName = ref('')
const formPrompt = ref('')
const formModel = ref('')
const formColor = ref('#F7931A')
const formDefault = ref(false)
const activePersonaId = computed(() => chatStore.activeConversation?.personaId ?? null)
function isActive(id: string): boolean {
return activePersonaId.value === id
}
function selectPersona(id: string) {
const conv = chatStore.activeConversation
if (!conv) return
if (conv.personaId === id) {
// Double-click to edit
openEditorFor(personaStore.getPersona(id))
return
}
conv.personaId = id
conv.updatedAt = Date.now()
}
function clearPersona() {
const conv = chatStore.activeConversation
if (!conv) return
conv.personaId = undefined
conv.updatedAt = Date.now()
}
function openEditorFor(persona?: Persona) {
if (persona) {
editingPersona.value = persona
formName.value = persona.name
formPrompt.value = persona.systemPrompt
formModel.value = persona.modelPreference ?? ''
formColor.value = persona.accentColor ?? '#F7931A'
formDefault.value = persona.isDefault ?? false
} else {
editingPersona.value = null
formName.value = ''
formPrompt.value = ''
formModel.value = ''
formColor.value = '#F7931A'
formDefault.value = false
}
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
editingPersona.value = null
}
function handleSave() {
if (!formName.value.trim()) return
const data = {
name: formName.value.trim(),
systemPrompt: formPrompt.value.trim(),
modelPreference: formModel.value || undefined,
accentColor: formColor.value,
isDefault: formDefault.value,
}
if (editingPersona.value) {
personaStore.updatePersona(editingPersona.value.id, data)
} else {
const created = personaStore.addPersona(data)
// Auto-select the new persona
const conv = chatStore.activeConversation
if (conv) {
conv.personaId = created.id
conv.updatedAt = Date.now()
}
}
closeEditor()
}
function handleDelete() {
if (!editingPersona.value) return
const id = editingPersona.value.id
// Clear from any active conversation
const conv = chatStore.activeConversation
if (conv?.personaId === id) {
conv.personaId = undefined
}
personaStore.deletePersona(id)
closeEditor()
}
</script>
+26 -12
View File
@@ -3,6 +3,7 @@ import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
import { getApiKey } from '@/utils/key-vault'
import type { ImageAttachment } from '@aiui/core/types/message'
import { usePersonaStore } from '@/stores/personas'
type Provider = 'claude' | 'openrouter' | 'mock'
@@ -303,6 +304,29 @@ function formatWebSearchContext(results: { title: string; url: string; content?:
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
/** Build the system prompt, incorporating persona if active */
function buildSystemPrompt(chatStore: ReturnType<typeof useChatStore>): string {
let prompt = SYSTEM_PROMPT
// Prepend persona system prompt if active
const personaId = chatStore.activeConversation?.personaId
if (personaId) {
const personaStore = usePersonaStore()
const persona = personaStore.getPersona(personaId)
if (persona?.systemPrompt) {
prompt = persona.systemPrompt + '\n\n' + prompt
}
}
if (chatStore.webSearchEnabled) {
prompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
return prompt
}
let currentAbort: AbortController | null = null
/** Background title generation after first exchange */
@@ -408,12 +432,7 @@ export function useAI() {
chatStore.isStreaming = true
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
let systemPrompt = buildSystemPrompt(chatStore)
if (chatStore.webSearchEnabled && userText.trim()) {
const results = await searchWeb(userText)
if (results.length > 0) {
@@ -522,12 +541,7 @@ export function useAI() {
chatStore.isStreaming = true
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
let systemPrompt = buildSystemPrompt(chatStore)
if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) {
const results = await searchWeb(lastUserMsg.content)
if (results.length > 0) {
+70 -1
View File
@@ -168,7 +168,7 @@ export const useChatStore = defineStore('chat', () => {
Array.from(conversations.value.values()).sort((a, b) => b.updatedAt - a.updatedAt)
)
function createConversation(title = 'New Chat'): string {
function createConversation(title = 'New Chat', personaId?: string): string {
const id = generateId()
const conversation: Conversation = {
id,
@@ -176,6 +176,7 @@ export const useChatStore = defineStore('chat', () => {
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
personaId,
}
conversations.value.set(id, conversation)
activeConversationId.value = id
@@ -266,6 +267,72 @@ export const useChatStore = defineStore('chat', () => {
debouncedIDBSave(conv)
}
/** Branch from a message — creates a new conversation with messages up to (and including) the given message */
function branchFromMessage(conversationId: string, messageId: string): string | null {
const conv = conversations.value.get(conversationId)
if (!conv) return null
const msgIndex = conv.messages.findIndex((m: { id: string }) => m.id === messageId)
if (msgIndex === -1) return null
const branchId = generateId()
const branchMessages = conv.messages.slice(0, msgIndex + 1).map(m => ({
...m,
id: generateId(),
timestamp: m.timestamp,
}))
// Count existing branches to label them
const existingBranches = (conv.childBranchIds ?? []).length
const branchConv: Conversation = {
id: branchId,
title: `${conv.title} (Branch ${existingBranches + 2})`,
messages: branchMessages,
createdAt: Date.now(),
updatedAt: Date.now(),
model: conv.model,
systemPrompt: conv.systemPrompt,
parentConversationId: conversationId,
branchPoint: messageId,
}
conversations.value.set(branchId, branchConv)
debouncedIDBSave(branchConv)
// Track branch in parent
if (!conv.childBranchIds) conv.childBranchIds = []
conv.childBranchIds.push(branchId)
conv.updatedAt = Date.now()
debouncedIDBSave(conv)
activeConversationId.value = branchId
return branchId
}
/** Get sibling branches for a conversation (parent + all its children) */
function getSiblingBranches(conversationId: string): { id: string; title: string; isCurrent: boolean }[] {
const conv = conversations.value.get(conversationId)
if (!conv) return []
// Find the family (parent + children)
const parentId = conv.parentConversationId ?? conversationId
const parent = conversations.value.get(parentId)
if (!parent) return []
const siblings: { id: string; title: string; isCurrent: boolean }[] = [
{ id: parentId, title: parent.title, isCurrent: parentId === conversationId },
]
for (const childId of parent.childBranchIds ?? []) {
const child = conversations.value.get(childId)
if (child) {
siblings.push({ id: childId, title: child.title, isCurrent: childId === conversationId })
}
}
return siblings
}
return {
conversations,
activeConversationId,
@@ -287,5 +354,7 @@ export const useChatStore = defineStore('chat', () => {
deleteConversation,
updateMessageContent,
deleteMessagesAfter,
branchFromMessage,
getSiblingBranches,
}
})
+98
View File
@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface Persona {
id: string
name: string
systemPrompt: string
modelPreference?: string
accentColor?: string
isDefault?: boolean
}
const STORAGE_KEY = 'aiui-personas'
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)
})
}
function loadFromStorage(): Persona[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw)
} catch {
return []
}
}
function saveToStorage(personas: Persona[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(personas))
}
export const usePersonaStore = defineStore('personas', () => {
const personas = ref<Persona[]>(loadFromStorage())
const defaultPersona = computed(() => personas.value.find(p => p.isDefault) ?? null)
const sortedPersonas = computed(() =>
[...personas.value].sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1
if (!a.isDefault && b.isDefault) return 1
return a.name.localeCompare(b.name)
})
)
function persist() {
saveToStorage(personas.value)
}
function addPersona(data: Omit<Persona, 'id'>): Persona {
const persona: Persona = { ...data, id: generateId() }
// If this is the first or marked default, clear other defaults
if (persona.isDefault) {
personas.value.forEach(p => { p.isDefault = false })
}
personas.value.push(persona)
persist()
return persona
}
function updatePersona(id: string, data: Partial<Omit<Persona, 'id'>>) {
const p = personas.value.find(x => x.id === id)
if (!p) return
if (data.isDefault) {
personas.value.forEach(x => { x.isDefault = false })
}
Object.assign(p, data)
persist()
}
function deletePersona(id: string) {
const idx = personas.value.findIndex(x => x.id === id)
if (idx !== -1) {
personas.value.splice(idx, 1)
persist()
}
}
function getPersona(id: string): Persona | undefined {
return personas.value.find(x => x.id === id)
}
return {
personas,
sortedPersonas,
defaultPersona,
addPersona,
updatePersona,
deletePersona,
getPersona,
}
})
+2
View File
@@ -54,4 +54,6 @@ export interface Conversation {
branchPoint?: string
/** IDs of child branches forked from this conversation */
childBranchIds?: string[]
/** ID of the persona applied to this conversation */
personaId?: string
}