diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue index 61f43929..cfb0a787 100644 --- a/packages/app/src/components/chat/ChatWindow.vue +++ b/packages/app/src/components/chat/ChatWindow.vue @@ -20,6 +20,8 @@ + + (null) const chatSearchRef = ref | 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() { diff --git a/packages/app/src/components/chat/PersonaSelector.vue b/packages/app/src/components/chat/PersonaSelector.vue new file mode 100644 index 00000000..caebfcc7 --- /dev/null +++ b/packages/app/src/components/chat/PersonaSelector.vue @@ -0,0 +1,248 @@ + + + + + + {{ p.name }} + + + + New + + + ✕ + + + + + + + + + + + {{ editingPersona ? 'Edit Persona' : 'New Persona' }} + + + + + + + + + + + Name + + + + + System Prompt + + + + + + Model Preference + + Default + Claude 4.5 Haiku + Claude Sonnet 4 + Claude Opus 4 + + + + Colour + + + + + + + Set as default for new conversations + + + + + + Delete + + + + Cancel + + + {{ editingPersona ? 'Save' : 'Create' }} + + + + + + + + + diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 1eba480d..5363c841 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -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): 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) { diff --git a/packages/app/src/stores/chat.ts b/packages/app/src/stores/chat.ts index 26e5c37a..bd652524 100644 --- a/packages/app/src/stores/chat.ts +++ b/packages/app/src/stores/chat.ts @@ -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, } }) diff --git a/packages/app/src/stores/personas.ts b/packages/app/src/stores/personas.ts new file mode 100644 index 00000000..16229d8a --- /dev/null +++ b/packages/app/src/stores/personas.ts @@ -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(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 { + 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>) { + 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, + } +}) diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index 63602e32..0fc7a8bb 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -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 }