- Add [[app_ext:...]] tag format and rewrite extractApps() for reliable app extraction - Wire AppsGrid and RecipeGrid into ContentGridView (was missing on wide desktop) - Add mock Archy node data for standalone dev testing (VITE_MOCK_ARCHY=true) - Fix PromptPalette: z-50 + opaque bg so slash menu renders above chat content - Fix detail banner not updating: add :key to all detail components in ContentPanel - Guide page moved to /guide, chat is now root route, guide auto-selected on first load - Code browser: click opens file in viewer, separate checkbox for chat context selection - Restore folder context selector (round checkbox on hover) in FileTreeNode - Demo projects for prod deployment instead of hardcoded personal paths - Improve Archy context injection with media breakdown and better error logging - Add 11 Claude Code skills for efficient development workflows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
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')
|
|
const chatCollapsed = ref(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,
|
|
}
|
|
})
|