Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SAFE_URL_SCHEME = /^https?:\/\//i
|
||||
|
||||
export const useArticleOverlayStore = defineStore('articleOverlay', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref<string | null>(null)
|
||||
const title = ref('')
|
||||
const content = ref<string | null>(null)
|
||||
const imgSrc = ref<string | null>(null)
|
||||
|
||||
function open(articleUrl: string, articleTitle = '', articleContent?: string, articleImgSrc?: string) {
|
||||
const trimmed = String(articleUrl ?? '').trim()
|
||||
if (!SAFE_URL_SCHEME.test(trimmed)) return
|
||||
try {
|
||||
new URL(trimmed)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
url.value = trimmed
|
||||
title.value = String(articleTitle ?? 'Article').slice(0, 200)
|
||||
content.value = typeof articleContent === 'string' && articleContent.trim().length > 0 ? articleContent.trim() : null
|
||||
imgSrc.value = typeof articleImgSrc === 'string' && articleImgSrc.trim().length > 0 ? articleImgSrc.trim() : null
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
url.value = null
|
||||
title.value = ''
|
||||
content.value = null
|
||||
imgSrc.value = null
|
||||
}
|
||||
|
||||
return { isOpen, url, title, content, imgSrc, open, close }
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Message, Conversation, WebSearchResult } from '@aiui/core/types/message'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback for non-secure contexts (HTTP over network IP)
|
||||
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)
|
||||
})
|
||||
}
|
||||
import {
|
||||
saveConversation as idbSave,
|
||||
loadAllConversations as idbLoadAll,
|
||||
deleteConversation as idbDelete,
|
||||
isIDBAvailable,
|
||||
} from '@/utils/idb-storage'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const useIDB = isIDBAvailable()
|
||||
|
||||
// ─── Dev-chats server middleware (file-based fallback) ────────
|
||||
|
||||
let _loaded = false
|
||||
let devSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const DEV_SAVE_DEBOUNCE = 800
|
||||
|
||||
async function loadServerChats(): Promise<{ conversations: Map<string, Conversation>; activeId: string | null }> {
|
||||
const empty = { conversations: new Map<string, Conversation>(), activeId: null }
|
||||
if (!isDev) return empty
|
||||
try {
|
||||
const res = await apiFetch('/api/dev-chats')
|
||||
if (!res.ok) return empty
|
||||
const data = await res.json() as {
|
||||
conversations?: Record<string, Conversation>
|
||||
activeConversationId?: string | null
|
||||
}
|
||||
if (!data.conversations) return empty
|
||||
const conversations = new Map(Object.entries(data.conversations))
|
||||
return {
|
||||
conversations,
|
||||
activeId: data.activeConversationId ?? null,
|
||||
}
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
function saveServerChats(conversations: Map<string, Conversation>, activeId: string | null) {
|
||||
if (!isDev || !_loaded) return
|
||||
if (devSaveTimer) clearTimeout(devSaveTimer)
|
||||
devSaveTimer = setTimeout(() => {
|
||||
const obj = Object.fromEntries(conversations)
|
||||
apiFetch('/api/dev-chats', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ conversations: obj, activeConversationId: activeId }),
|
||||
}).catch(() => {})
|
||||
}, DEV_SAVE_DEBOUNCE)
|
||||
}
|
||||
|
||||
// ─── IDB save with debounce + flush-on-unload ────────────────
|
||||
|
||||
const idbSaveTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const pendingSaves = new Map<string, Conversation>()
|
||||
|
||||
function debouncedIDBSave(conv: Conversation) {
|
||||
if (!useIDB) return
|
||||
pendingSaves.set(conv.id, conv)
|
||||
const existing = idbSaveTimers.get(conv.id)
|
||||
if (existing) clearTimeout(existing)
|
||||
idbSaveTimers.set(conv.id, setTimeout(() => {
|
||||
idbSave(conv).then(() => {
|
||||
pendingSaves.delete(conv.id)
|
||||
}).catch((err) => {
|
||||
console.warn('[chat] IDB save failed:', err)
|
||||
})
|
||||
idbSaveTimers.delete(conv.id)
|
||||
}, 800))
|
||||
}
|
||||
|
||||
/** Immediately save a conversation to IDB (no debounce). */
|
||||
function immediateIDBSave(conv: Conversation) {
|
||||
if (!useIDB) return
|
||||
pendingSaves.delete(conv.id)
|
||||
const existing = idbSaveTimers.get(conv.id)
|
||||
if (existing) clearTimeout(existing)
|
||||
idbSaveTimers.delete(conv.id)
|
||||
idbSave(conv).catch((err) => {
|
||||
console.warn('[chat] IDB save failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
/** Flush all pending debounced saves — called on page unload. */
|
||||
function flushPendingSaves() {
|
||||
for (const [id, timer] of idbSaveTimers) {
|
||||
clearTimeout(timer)
|
||||
idbSaveTimers.delete(id)
|
||||
}
|
||||
for (const [id, conv] of pendingSaves) {
|
||||
idbSave(conv).catch(() => {})
|
||||
pendingSaves.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending saves before the page unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', flushPendingSaves)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushPendingSaves()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Store definition ─────────────────────────────────────────
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref<Map<string, Conversation>>(new Map())
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const savedSide = localStorage.getItem('aiui-panel-side') as 'left' | 'right' | null
|
||||
const panelSide = ref<'left' | 'right'>(savedSide ?? 'left')
|
||||
|
||||
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
|
||||
|
||||
// D-14a (Archipelago phase 02-ui-performance): when the host explicitly asks
|
||||
// for an expanded start via ?chatExpanded, open in the full message list
|
||||
// rather than the collapsed prompt-index — this is a presentation-only,
|
||||
// one-time initial-state override and is never written back to
|
||||
// localStorage, so it can never change the standalone (non-embedded) app's
|
||||
// own persisted default. A user who manually collapses while embedded still
|
||||
// gets that choice persisted for next time, same as before this change.
|
||||
const _startExpanded = new URLSearchParams(window.location.search).has('chatExpanded')
|
||||
const chatCollapsed = ref(_startExpanded ? false : localStorage.getItem('aiui-chat-collapsed') !== 'false')
|
||||
const showHistory = ref(false)
|
||||
|
||||
// Load chats: try IndexedDB first, fall back to dev-chats middleware
|
||||
async function loadChats() {
|
||||
if (useIDB) {
|
||||
try {
|
||||
const idbConversations = await idbLoadAll()
|
||||
if (idbConversations.size > 0) {
|
||||
conversations.value = idbConversations
|
||||
const savedActiveId = localStorage.getItem('aiui-active-conversation')
|
||||
activeConversationId.value =
|
||||
savedActiveId && idbConversations.has(savedActiveId)
|
||||
? savedActiveId
|
||||
: [...idbConversations.keys()].pop() ?? null
|
||||
loaded.value = true
|
||||
_loaded = true
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chat] IDB load failed, falling back:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through: IDB empty or unavailable — try dev-chats seed
|
||||
if (isDev) {
|
||||
const data = await loadServerChats()
|
||||
if (data.conversations.size > 0) {
|
||||
conversations.value = data.conversations
|
||||
activeConversationId.value =
|
||||
data.activeId && data.conversations.has(data.activeId)
|
||||
? data.activeId
|
||||
: [...data.conversations.keys()][0] ?? null
|
||||
// Migrate seed data to IDB immediately (no debounce)
|
||||
if (useIDB) {
|
||||
for (const conv of data.conversations.values()) {
|
||||
immediateIDBSave(conv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded.value = true
|
||||
_loaded = true
|
||||
}
|
||||
|
||||
loadChats().then(() => {
|
||||
// Seed guide + node demo conversations on first use
|
||||
seedDemoConversations()
|
||||
})
|
||||
|
||||
// Persist active conversation ID to localStorage
|
||||
watch(activeConversationId, (id) => {
|
||||
if (id) localStorage.setItem('aiui-active-conversation', id)
|
||||
else localStorage.removeItem('aiui-active-conversation')
|
||||
})
|
||||
|
||||
watch(panelSide, (val) => {
|
||||
localStorage.setItem('aiui-panel-side', val)
|
||||
})
|
||||
|
||||
watch(webSearchEnabled, (val) => {
|
||||
localStorage.setItem('aiui-web-search', String(val))
|
||||
})
|
||||
|
||||
watch(chatCollapsed, (val) => {
|
||||
localStorage.setItem('aiui-chat-collapsed', String(val))
|
||||
})
|
||||
|
||||
watch(
|
||||
[conversations, activeConversationId],
|
||||
([conv, active]) => {
|
||||
// Dev-chats middleware fallback
|
||||
saveServerChats(conv as Map<string, Conversation>, active as string | null)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
if (!activeConversationId.value) return null
|
||||
return conversations.value.get(activeConversationId.value) ?? null
|
||||
})
|
||||
|
||||
const messages = computed(() => activeConversation.value?.messages ?? [])
|
||||
|
||||
const conversationList = computed(() =>
|
||||
Array.from(conversations.value.values()).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
)
|
||||
|
||||
function createConversation(title = 'New Chat', personaId?: string): string {
|
||||
const id = generateId()
|
||||
const conversation: Conversation = {
|
||||
id,
|
||||
title,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
personaId,
|
||||
}
|
||||
conversations.value.set(id, conversation)
|
||||
activeConversationId.value = id
|
||||
immediateIDBSave(conversation)
|
||||
return id
|
||||
}
|
||||
|
||||
function addMessage(conversationId: string, message: Omit<Message, 'id' | 'timestamp'>) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
|
||||
const msg: Message = {
|
||||
...message,
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
conv.messages.push(msg)
|
||||
conv.updatedAt = Date.now()
|
||||
|
||||
if (conv.messages.length === 1 && message.role === 'user') {
|
||||
conv.title = message.content.slice(0, 60) + (message.content.length > 60 ? '...' : '')
|
||||
}
|
||||
|
||||
debouncedIDBSave(conv)
|
||||
return msg
|
||||
}
|
||||
|
||||
function appendToLastMessage(conversationId: string, text: string) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv || conv.messages.length === 0) return
|
||||
const last = conv.messages[conv.messages.length - 1]
|
||||
last.content += text
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
|
||||
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||
if (msg) {
|
||||
msg.webResults = results
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageFeedback(conversationId: string, messageId: string, feedback: 'up' | 'down' | undefined) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||
if (msg) {
|
||||
msg.feedback = feedback
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
}
|
||||
|
||||
function switchSide() {
|
||||
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
|
||||
}
|
||||
|
||||
function toggleChatCollapse() {
|
||||
chatCollapsed.value = !chatCollapsed.value
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
showHistory.value = !showHistory.value
|
||||
}
|
||||
|
||||
function setActiveConversation(id: string) {
|
||||
if (conversations.value.has(id)) {
|
||||
activeConversationId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
conversations.value.delete(id)
|
||||
if (useIDB) idbDelete(id).catch(() => {})
|
||||
pendingSaves.delete(id)
|
||||
if (activeConversationId.value === id) {
|
||||
const remaining = conversationList.value
|
||||
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a message's content (for editing) */
|
||||
function updateMessageContent(conversationId: string, messageId: string, newContent: string) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||
if (msg) {
|
||||
msg.content = newContent
|
||||
msg.editedAt = Date.now()
|
||||
conv.updatedAt = Date.now()
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all messages after the given index (inclusive of afterIndex) */
|
||||
function deleteMessagesAfter(conversationId: string, afterIndex: number) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
conv.messages.splice(afterIndex)
|
||||
conv.updatedAt = Date.now()
|
||||
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)
|
||||
immediateIDBSave(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
|
||||
}
|
||||
|
||||
/** Load all seed prompts as a single conversation */
|
||||
async function loadSeedChats(): Promise<number> {
|
||||
try {
|
||||
const { seedPromptsToConversation } = await import('@/__tests__/fixtures/seedPrompts')
|
||||
const conv = seedPromptsToConversation() as Conversation
|
||||
|
||||
if (conversations.value.has(conv.id)) {
|
||||
// Already loaded — just switch to it
|
||||
activeConversationId.value = conv.id
|
||||
return 0
|
||||
}
|
||||
|
||||
const merged = new Map(conversations.value)
|
||||
merged.set(conv.id, conv)
|
||||
conversations.value = merged
|
||||
activeConversationId.value = conv.id
|
||||
immediateIDBSave(conv)
|
||||
return conv.messages.length / 2 // number of prompt/response pairs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Load node demo conversation showing local node capabilities */
|
||||
async function loadNodeDemoChat(): Promise<number> {
|
||||
try {
|
||||
const { nodeDemoToConversation } = await import('@/__tests__/fixtures/nodeDemoPrompts')
|
||||
const conv = nodeDemoToConversation() as Conversation
|
||||
|
||||
if (conversations.value.has(conv.id)) {
|
||||
activeConversationId.value = conv.id
|
||||
return 0
|
||||
}
|
||||
|
||||
const merged = new Map(conversations.value)
|
||||
merged.set(conv.id, conv)
|
||||
conversations.value = merged
|
||||
activeConversationId.value = conv.id
|
||||
immediateIDBSave(conv)
|
||||
return conv.messages.length / 2
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Load guide conversation (auto-loaded on first visit) */
|
||||
async function loadGuide(): Promise<void> {
|
||||
try {
|
||||
const { guideToConversation } = await import('@/__tests__/fixtures/guideConversation')
|
||||
const conv = guideToConversation() as Conversation
|
||||
|
||||
if (conversations.value.has(conv.id)) {
|
||||
activeConversationId.value = conv.id
|
||||
return
|
||||
}
|
||||
|
||||
const merged = new Map(conversations.value)
|
||||
merged.set(conv.id, conv)
|
||||
conversations.value = merged
|
||||
activeConversationId.value = conv.id
|
||||
immediateIDBSave(conv)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed demo conversations (guide + node demo) on first use */
|
||||
async function seedDemoConversations(): Promise<void> {
|
||||
const hasGuide = conversations.value.has('aiui-guide')
|
||||
const hasDemo = conversations.value.has('node-demo')
|
||||
if (hasGuide && hasDemo) {
|
||||
// Already seeded — still select guide if nothing active
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = 'aiui-guide'
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { guideToConversation } = await import('@/__tests__/fixtures/guideConversation')
|
||||
const { nodeDemoToConversation } = await import('@/__tests__/fixtures/nodeDemoPrompts')
|
||||
const guide = guideToConversation() as Conversation
|
||||
const demo = nodeDemoToConversation() as Conversation
|
||||
const merged = new Map(conversations.value)
|
||||
if (!merged.has(guide.id)) {
|
||||
merged.set(guide.id, guide)
|
||||
immediateIDBSave(guide)
|
||||
}
|
||||
if (!merged.has(demo.id)) {
|
||||
merged.set(demo.id, demo)
|
||||
immediateIDBSave(demo)
|
||||
}
|
||||
conversations.value = merged
|
||||
// Show guide conversation on first load
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = guide.id
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
messages,
|
||||
conversationList,
|
||||
isStreaming,
|
||||
loaded,
|
||||
panelSide,
|
||||
webSearchEnabled,
|
||||
chatCollapsed,
|
||||
showHistory,
|
||||
createConversation,
|
||||
addMessage,
|
||||
appendToLastMessage,
|
||||
setMessageWebResults,
|
||||
setMessageFeedback,
|
||||
switchSide,
|
||||
toggleChatCollapse,
|
||||
toggleHistory,
|
||||
setActiveConversation,
|
||||
deleteConversation,
|
||||
updateMessageContent,
|
||||
deleteMessagesAfter,
|
||||
branchFromMessage,
|
||||
getSiblingBranches,
|
||||
loadSeedChats,
|
||||
loadNodeDemoChat,
|
||||
loadGuide,
|
||||
seedDemoConversations,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type FavoriteType = 'film' | 'song' | 'podcast' | 'book' | 'tv' | 'place' | 'article'
|
||||
|
||||
export interface FavoriteItem {
|
||||
id: string
|
||||
type: FavoriteType
|
||||
title: string
|
||||
subtitle?: string
|
||||
data: unknown
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
const IDB_STORE = 'favorites'
|
||||
const DB_NAME = 'aiui-favorites'
|
||||
const DB_VERSION = 1
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
||||
const store = db.createObjectStore(IDB_STORE, { keyPath: 'id' })
|
||||
store.createIndex('type', 'type', { unique: false })
|
||||
store.createIndex('savedAt', 'savedAt', { unique: false })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbGetAll(): Promise<FavoriteItem[]> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readonly')
|
||||
const store = tx.objectStore(IDB_STORE)
|
||||
const req = store.getAll()
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbPut(item: FavoriteItem): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite')
|
||||
const store = tx.objectStore(IDB_STORE)
|
||||
const req = store.put(item)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbDelete(id: string): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite')
|
||||
const store = tx.objectStore(IDB_STORE)
|
||||
const req = store.delete(id)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
export const useFavoritesStore = defineStore('favorites', () => {
|
||||
const items = ref<FavoriteItem[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
items.value = await idbGetAll()
|
||||
} catch {
|
||||
// IDB unavailable — in-memory only
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
loadFavorites()
|
||||
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => b.savedAt - a.savedAt)
|
||||
)
|
||||
|
||||
function isFavorited(id: string): boolean {
|
||||
return items.value.some(i => i.id === id)
|
||||
}
|
||||
|
||||
async function addFavorite(item: Omit<FavoriteItem, 'savedAt'>) {
|
||||
if (isFavorited(item.id)) return
|
||||
const fav: FavoriteItem = { ...item, savedAt: Date.now() }
|
||||
items.value = [...items.value, fav]
|
||||
idbPut(fav).catch(() => {})
|
||||
}
|
||||
|
||||
async function removeFavorite(id: string) {
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
idbDelete(id).catch(() => {})
|
||||
}
|
||||
|
||||
async function toggleFavorite(item: Omit<FavoriteItem, 'savedAt'>) {
|
||||
if (isFavorited(item.id)) {
|
||||
await removeFavorite(item.id)
|
||||
} else {
|
||||
await addFavorite(item)
|
||||
}
|
||||
}
|
||||
|
||||
function getFavoritesByType(type: FavoriteType): FavoriteItem[] {
|
||||
return sortedItems.value.filter(i => i.type === type)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
sortedItems,
|
||||
loaded,
|
||||
isFavorited,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
toggleFavorite,
|
||||
getFavoritesByType,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aiui-memory'
|
||||
const MAX_ITEMS = 20
|
||||
|
||||
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(): MemoryItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(items: MemoryItem[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
}
|
||||
|
||||
export const useMemoryStore = defineStore('memory', () => {
|
||||
const items = ref<MemoryItem[]>(loadFromStorage())
|
||||
|
||||
const isFull = computed(() => items.value.length >= MAX_ITEMS)
|
||||
|
||||
function persist() {
|
||||
saveToStorage(items.value)
|
||||
}
|
||||
|
||||
function addItem(text: string): MemoryItem | null {
|
||||
if (items.value.length >= MAX_ITEMS) return null
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
const item: MemoryItem = { id: generateId(), text: trimmed }
|
||||
items.value.push(item)
|
||||
persist()
|
||||
return item
|
||||
}
|
||||
|
||||
function updateItem(id: string, text: string) {
|
||||
const item = items.value.find(x => x.id === id)
|
||||
if (!item) return
|
||||
item.text = text.trim()
|
||||
persist()
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
const idx = items.value.findIndex(x => x.id === id)
|
||||
if (idx !== -1) {
|
||||
items.value.splice(idx, 1)
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the memory section for the system prompt */
|
||||
function buildMemoryContext(): string {
|
||||
if (items.value.length === 0) return ''
|
||||
const facts = items.value.map(i => `- ${i.text}`).join('\n')
|
||||
return `\n\n**User memory (always remember these facts):**\n${facts}`
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
isFull,
|
||||
addItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
buildMemoryContext,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type PluginPermission = 'chat-messages' | 'network' | 'favorites' | 'storage' | 'nostr' | 'wallet'
|
||||
|
||||
export interface RegistryPlugin {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
author: string
|
||||
version: string
|
||||
rating: number
|
||||
url: string
|
||||
permissions: PluginPermission[]
|
||||
changelog?: string
|
||||
settingsSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
type: string
|
||||
author: string
|
||||
url: string
|
||||
permissions: PluginPermission[]
|
||||
grantedPermissions: PluginPermission[]
|
||||
settings: Record<string, unknown>
|
||||
installedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aiui-installed-plugins'
|
||||
const REGISTRY_URL = 'https://raw.githubusercontent.com/aiui-app/plugin-registry/main/registry.json'
|
||||
|
||||
export const usePluginMarketplaceStore = defineStore('pluginMarketplace', () => {
|
||||
const registryPlugins = ref<RegistryPlugin[]>([])
|
||||
const installedPlugins = ref<InstalledPlugin[]>([])
|
||||
const isLoadingRegistry = ref(false)
|
||||
const registryError = ref('')
|
||||
const updatesAvailable = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Load installed plugins from localStorage
|
||||
function loadInstalled() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) installedPlugins.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveInstalled() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(installedPlugins.value))
|
||||
}
|
||||
|
||||
loadInstalled()
|
||||
|
||||
const hasUpdates = computed(() => updatesAvailable.value.size > 0)
|
||||
|
||||
async function fetchRegistry() {
|
||||
isLoadingRegistry.value = true
|
||||
registryError.value = ''
|
||||
try {
|
||||
const res = await fetch(REGISTRY_URL)
|
||||
if (!res.ok) throw new Error('Failed to fetch registry')
|
||||
const data = await res.json()
|
||||
registryPlugins.value = data.plugins ?? data ?? []
|
||||
} catch (e) {
|
||||
registryError.value = e instanceof Error ? e.message : 'Failed to load registry'
|
||||
// Provide built-in fallback entries
|
||||
registryPlugins.value = getBuiltinRegistry()
|
||||
} finally {
|
||||
isLoadingRegistry.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isInstalled(pluginId: string): boolean {
|
||||
return installedPlugins.value.some(p => p.id === pluginId)
|
||||
}
|
||||
|
||||
function installPlugin(plugin: RegistryPlugin, grantedPermissions: PluginPermission[]) {
|
||||
if (isInstalled(plugin.id)) return
|
||||
|
||||
installedPlugins.value.push({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
type: plugin.type,
|
||||
author: plugin.author,
|
||||
url: plugin.url,
|
||||
permissions: plugin.permissions,
|
||||
grantedPermissions,
|
||||
settings: {},
|
||||
installedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
saveInstalled()
|
||||
}
|
||||
|
||||
function uninstallPlugin(pluginId: string) {
|
||||
installedPlugins.value = installedPlugins.value.filter(p => p.id !== pluginId)
|
||||
updatesAvailable.value.delete(pluginId)
|
||||
saveInstalled()
|
||||
}
|
||||
|
||||
function updatePluginSettings(pluginId: string, settings: Record<string, unknown>) {
|
||||
const plugin = installedPlugins.value.find(p => p.id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.settings = settings
|
||||
saveInstalled()
|
||||
}
|
||||
}
|
||||
|
||||
function updatePluginPermissions(pluginId: string, permissions: PluginPermission[]) {
|
||||
const plugin = installedPlugins.value.find(p => p.id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.grantedPermissions = permissions
|
||||
saveInstalled()
|
||||
}
|
||||
}
|
||||
|
||||
function checkForUpdates() {
|
||||
updatesAvailable.value.clear()
|
||||
for (const installed of installedPlugins.value) {
|
||||
const registry = registryPlugins.value.find(r => r.id === installed.id)
|
||||
if (registry && registry.version !== installed.version) {
|
||||
updatesAvailable.value.set(installed.id, registry.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlugin(pluginId: string) {
|
||||
const registry = registryPlugins.value.find(r => r.id === pluginId)
|
||||
const installed = installedPlugins.value.find(p => p.id === pluginId)
|
||||
if (!registry || !installed) return
|
||||
|
||||
installed.version = registry.version
|
||||
installed.updatedAt = Date.now()
|
||||
updatesAvailable.value.delete(pluginId)
|
||||
saveInstalled()
|
||||
}
|
||||
|
||||
function updateAllPlugins() {
|
||||
for (const [id] of updatesAvailable.value) {
|
||||
updatePlugin(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromUrl(url: string): Promise<RegistryPlugin | null> {
|
||||
try {
|
||||
const manifestUrl = url.endsWith('/') ? `${url}aiui-plugin.json` : url
|
||||
const res = await fetch(manifestUrl)
|
||||
if (!res.ok) throw new Error('Failed to fetch manifest')
|
||||
const manifest = await res.json()
|
||||
|
||||
if (!manifest.id || !manifest.name || !manifest.version) {
|
||||
throw new Error('Invalid plugin manifest')
|
||||
}
|
||||
|
||||
return {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
description: manifest.description ?? '',
|
||||
type: manifest.type ?? 'search',
|
||||
author: manifest.author ?? 'Unknown',
|
||||
version: manifest.version,
|
||||
rating: 0,
|
||||
url,
|
||||
permissions: manifest.permissions ?? [],
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasPermission(pluginId: string, permission: PluginPermission): boolean {
|
||||
const plugin = installedPlugins.value.find(p => p.id === pluginId)
|
||||
return plugin?.grantedPermissions.includes(permission) ?? false
|
||||
}
|
||||
|
||||
return {
|
||||
registryPlugins,
|
||||
installedPlugins,
|
||||
isLoadingRegistry,
|
||||
registryError,
|
||||
updatesAvailable,
|
||||
hasUpdates,
|
||||
fetchRegistry,
|
||||
isInstalled,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
updatePluginSettings,
|
||||
updatePluginPermissions,
|
||||
checkForUpdates,
|
||||
updatePlugin,
|
||||
updateAllPlugins,
|
||||
importFromUrl,
|
||||
hasPermission,
|
||||
}
|
||||
})
|
||||
|
||||
function getBuiltinRegistry(): RegistryPlugin[] {
|
||||
return [
|
||||
{
|
||||
id: 'wikipedia',
|
||||
name: 'Wikipedia',
|
||||
description: 'Search Wikipedia articles with /wiki command',
|
||||
type: 'search',
|
||||
author: 'AIUI',
|
||||
version: '1.0.0',
|
||||
rating: 5,
|
||||
url: 'builtin:wikipedia',
|
||||
permissions: ['network'],
|
||||
},
|
||||
{
|
||||
id: 'openlibrary',
|
||||
name: 'Open Library',
|
||||
description: 'Search books from Open Library with /book command',
|
||||
type: 'search',
|
||||
author: 'AIUI',
|
||||
version: '1.0.0',
|
||||
rating: 5,
|
||||
url: 'builtin:openlibrary',
|
||||
permissions: ['network'],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
|
||||
const STORAGE_KEY = 'aiui-settings'
|
||||
|
||||
export interface AppSettings {
|
||||
// M15.1 — Accent colour
|
||||
accentColor: string
|
||||
// M15.2 — Glass intensity
|
||||
glassIntensity: 'subtle' | 'default' | 'strong'
|
||||
// M15.3 — Font size
|
||||
fontSize: 'compact' | 'default' | 'large'
|
||||
// M15.4 — Content type visibility
|
||||
hiddenContentTabs: ContentTab[]
|
||||
// M15.5 — Keyboard shortcuts
|
||||
shortcuts: Record<string, string>
|
||||
// M15.6 — Push notifications
|
||||
notificationsEnabled: boolean
|
||||
// M15.7 — Auto-archive
|
||||
autoArchiveDays: number // 0 = never, 7/30/90
|
||||
// M15.10 — Default conversation settings
|
||||
defaultModel: string
|
||||
defaultPersonaId: string
|
||||
defaultWebSearch: boolean
|
||||
defaultShowTokens: boolean
|
||||
// API key management
|
||||
claudeApiKey: string
|
||||
useOwnApiKey: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
accentColor: '#F7931A',
|
||||
glassIntensity: 'default',
|
||||
fontSize: 'default',
|
||||
hiddenContentTabs: [],
|
||||
shortcuts: {
|
||||
'send-message': 'Enter',
|
||||
'new-line': 'Shift+Enter',
|
||||
'search': 'Cmd+F',
|
||||
'new-chat': 'Cmd+N',
|
||||
'settings': 'Cmd+,',
|
||||
'close-panel': 'Escape',
|
||||
},
|
||||
notificationsEnabled: false,
|
||||
autoArchiveDays: 0,
|
||||
defaultModel: '',
|
||||
defaultPersonaId: '',
|
||||
defaultWebSearch: false,
|
||||
defaultShowTokens: false,
|
||||
claudeApiKey: '',
|
||||
useOwnApiKey: false,
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<AppSettings>(loadSettings())
|
||||
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings.value))
|
||||
}
|
||||
|
||||
// Apply CSS variables when settings change
|
||||
function applyCssVars() {
|
||||
const root = document.documentElement
|
||||
|
||||
// Accent colour
|
||||
root.style.setProperty('--color-accent', settings.value.accentColor)
|
||||
|
||||
// Glass intensity
|
||||
const glass = {
|
||||
subtle: { blur: '12px', opacity: '0.25' },
|
||||
default: { blur: '18px', opacity: '0.35' },
|
||||
strong: { blur: '28px', opacity: '0.50' },
|
||||
}[settings.value.glassIntensity]
|
||||
root.style.setProperty('--glass-blur', glass.blur)
|
||||
root.style.setProperty('--glass-opacity', glass.opacity)
|
||||
|
||||
// Font size
|
||||
const sizes = { compact: '13px', default: '15px', large: '17px' }
|
||||
root.style.setProperty('--font-size-base', sizes[settings.value.fontSize])
|
||||
}
|
||||
|
||||
// Watch for changes and persist + apply
|
||||
watch(settings, () => {
|
||||
save()
|
||||
applyCssVars()
|
||||
}, { deep: true })
|
||||
|
||||
// Apply on init
|
||||
applyCssVars()
|
||||
|
||||
const accentColor = computed({
|
||||
get: () => settings.value.accentColor,
|
||||
set: (v) => { settings.value.accentColor = v },
|
||||
})
|
||||
|
||||
const glassIntensity = computed({
|
||||
get: () => settings.value.glassIntensity,
|
||||
set: (v) => { settings.value.glassIntensity = v },
|
||||
})
|
||||
|
||||
const fontSize = computed({
|
||||
get: () => settings.value.fontSize,
|
||||
set: (v) => { settings.value.fontSize = v },
|
||||
})
|
||||
|
||||
const hiddenContentTabs = computed({
|
||||
get: () => settings.value.hiddenContentTabs,
|
||||
set: (v) => { settings.value.hiddenContentTabs = v },
|
||||
})
|
||||
|
||||
const notificationsEnabled = computed({
|
||||
get: () => settings.value.notificationsEnabled,
|
||||
set: (v) => { settings.value.notificationsEnabled = v },
|
||||
})
|
||||
|
||||
const autoArchiveDays = computed({
|
||||
get: () => settings.value.autoArchiveDays,
|
||||
set: (v) => { settings.value.autoArchiveDays = v },
|
||||
})
|
||||
|
||||
function isTabVisible(tab: ContentTab): boolean {
|
||||
return !settings.value.hiddenContentTabs.includes(tab)
|
||||
}
|
||||
|
||||
function toggleTabVisibility(tab: ContentTab) {
|
||||
const idx = settings.value.hiddenContentTabs.indexOf(tab)
|
||||
if (idx >= 0) {
|
||||
settings.value.hiddenContentTabs.splice(idx, 1)
|
||||
} else {
|
||||
settings.value.hiddenContentTabs.push(tab)
|
||||
}
|
||||
}
|
||||
|
||||
function setShortcut(action: string, binding: string) {
|
||||
settings.value.shortcuts[action] = binding
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
settings.value = { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
accentColor,
|
||||
glassIntensity,
|
||||
fontSize,
|
||||
hiddenContentTabs,
|
||||
notificationsEnabled,
|
||||
autoArchiveDays,
|
||||
isTabVisible,
|
||||
toggleTabVisibility,
|
||||
setShortcut,
|
||||
resetSettings,
|
||||
applyCssVars,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useVideoPlayerStore = defineStore('videoPlayer', () => {
|
||||
const isOpen = ref(false)
|
||||
const videoUrl = ref('')
|
||||
const title = ref('')
|
||||
const posterUrl = ref<string | null>(null)
|
||||
|
||||
function open(url: string, filmTitle: string, filmPoster?: string | null) {
|
||||
videoUrl.value = url
|
||||
title.value = filmTitle
|
||||
posterUrl.value = filmPoster ?? null
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
videoUrl.value = ''
|
||||
title.value = ''
|
||||
posterUrl.value = null
|
||||
}
|
||||
|
||||
return { isOpen, videoUrl, title, posterUrl, open, close }
|
||||
})
|
||||
Reference in New Issue
Block a user