Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 25d938cd1a
2315 changed files with 510085 additions and 0 deletions
@@ -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 }
})
+541
View File
@@ -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,
}
})
+125
View File
@@ -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,
}
})
+85
View File
@@ -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,
}
})
+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,
}
})
@@ -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,
}
})
+165
View File
@@ -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 }
})