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
+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,
}
})