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:
co-authored by
Claude Opus 4.6
parent
27aeb2b188
commit
77b985db9c
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user