Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,179 @@
<template>
<div class="px-3 md:px-4 pb-1">
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Advanced
</button>
<div v-if="isExpanded && conv" class="mt-2 space-y-3 animate-fade-up-fast">
<!-- Temperature -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Max Tokens -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Top P -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Stop Sequences (M9.10) -->
<div class="space-y-1">
<label class="text-xs text-white/40">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">×</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<!-- Reset -->
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const isExpanded = ref(false)
const newStopSeq = ref('')
const conv = computed(() => chatStore.activeConversation)
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
// Sync from conversation on switch
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
@@ -0,0 +1,55 @@
<template>
<div
v-if="branches.length > 1"
class="flex items-center justify-center px-4 py-1.5 animate-fade-up-fast"
>
<div class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 flex items-center gap-1.5 px-2 text-xs">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex <= 0"
aria-label="Previous branch"
@click="switchToBranch(currentIndex - 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-white/70 select-none whitespace-nowrap">
Branch {{ currentIndex + 1 }} of {{ branches.length }}
</span>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex >= branches.length - 1"
aria-label="Next branch"
@click="switchToBranch(currentIndex + 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const branches = computed(() => {
if (!chatStore.activeConversationId) return []
return chatStore.getSiblingBranches(chatStore.activeConversationId)
})
const currentIndex = computed(() => {
return branches.value.findIndex(b => b.isCurrent)
})
function switchToBranch(index: number) {
const branch = branches.value[index]
if (branch) {
chatStore.setActiveConversation(branch.id)
}
}
</script>
@@ -0,0 +1,116 @@
<template>
<div
v-if="parsed"
class="rounded-xl p-3 space-y-2 my-2"
:class="isDark
? 'bg-white/[0.03] border border-[#F7931A]/20'
: 'bg-black/[0.02] border border-[#F7931A]/20'"
>
<!-- Header -->
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-[#F7931A]/10 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<text x="12" y="16" text-anchor="middle" fill="white" font-size="12" font-weight="bold">C</text>
</svg>
</div>
<span
class="text-xs font-semibold"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
Cashu Token
</span>
<span class="ml-auto text-sm font-bold text-[#F7931A]">
{{ formattedAmount }}
</span>
</div>
<!-- Mint info -->
<div
class="text-xs font-mono"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
Mint: {{ displayMint }}
</div>
<!-- Memo -->
<div
v-if="parsed.memo"
class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'"
>
{{ parsed.memo }}
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 text-white/60 hover:bg-white/10'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="copyToken"
>
{{ copied ? 'Copied!' : 'Copy Token' }}
</button>
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-[#F7931A]/10 text-[#F7931A] hover:bg-[#F7931A]/20"
@click="openInWallet"
>
Open in Wallet
</button>
</div>
</div>
<!-- Fallback for unparseable tokens -->
<div
v-else
class="rounded-lg p-2 my-1 text-xs font-mono break-all"
:class="isDark ? 'bg-white/5 text-white/40' : 'bg-gray-50 text-gray-500'"
>
{{ truncatedRaw }}
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { parseCashuToken, formatMintUrl, formatCashuAmount } from '@/utils/cashu'
const { isDark } = useTheme()
const props = defineProps<{
token: string
}>()
const copied = ref(false)
const parsed = computed(() => parseCashuToken(props.token))
const formattedAmount = computed(() => {
if (!parsed.value) return ''
return formatCashuAmount(parsed.value.amount, parsed.value.unit)
})
const displayMint = computed(() => {
if (!parsed.value) return ''
return formatMintUrl(parsed.value.mint)
})
const truncatedRaw = computed(() => {
const t = props.token
if (t.length <= 40) return t
return t.slice(0, 20) + '...' + t.slice(-16)
})
async function copyToken() {
await navigator.clipboard.writeText(props.token)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function openInWallet() {
// Use web+cashu: URI scheme for wallet deep-linking
window.open(`web+cashu:${props.token}`, '_blank')
}
</script>
@@ -0,0 +1,431 @@
<template>
<div
ref="headerRef"
class="flex flex-col gap-0 p-3 relative z-[60] shrink-0"
>
<div class="flex items-center justify-between gap-2">
<button
ref="modelPickerTriggerRef"
class="touch-target rounded-xl path-glass-icon shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker"
>
<span class="text-base">✦</span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.showHistory
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.showHistory ? 'Back to chat' : 'Chat history'"
aria-label="Toggle chat history"
@click="chatStore.toggleHistory()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Prompt index'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
<svg v-if="!chatStore.chatCollapsed" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h16" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="comparison.isComparing.value
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="comparison.isComparing.value ? 'Comparison mode on' : 'Compare models'"
aria-label="Toggle model comparison"
@click="comparison.toggleComparison()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Settings"
title="Settings"
@click="$emit('openSettings')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
ref="menuTriggerRef"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Conversation menu"
@click="showMenu = !showMenu"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="New conversation"
@click="$emit('newChat')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
<button
v-if="showClose"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Close"
@click="$emit('close')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="w-full text-left pt-3 pb-1 min-w-0">
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-xs truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-xs text-white/20">·</span>
<span class="text-xs truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</div>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle"
@click.stop
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }}
</p>
<div class="space-y-0.5">
<button
v-for="model in provider.models"
:key="model.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
:class="model.id === activeModel && provider.id === activeProvider
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectModel(provider.id, model.id)"
>
<span class="flex-1">{{ model.name }}</span>
<span class="flex gap-0.5 shrink-0">
<span
v-if="getModelCaps(model.id).vision"
class="text-xs opacity-60"
title="Supports vision input"
>👁</span>
<span
v-if="getModelCaps(model.id).tools"
class="text-xs opacity-60"
title="Supports tool use"
>🔧</span>
<span
v-if="getModelCaps(model.id).longContext"
class="text-xs opacity-60"
title="Long context window"
>📄</span>
</span>
</button>
</div>
</div>
<!-- Design System -->
<div class="border-t mt-2 pt-2 border-white/10">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2 text-white/60 hover:text-white hover:bg-white/10"
@click="openDesignSystem"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Design System
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Conversation menu (export, delete) -->
<Teleport to="body">
<div v-if="showMenu" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showMenu = false" />
<Transition name="picker">
<div
v-if="showMenu"
class="fixed z-[9999] path-glass-card header-overlay-panel p-2 animate-fade-up-fast shadow-2xl min-w-[160px]"
:style="menuDropdownStyle"
@click.stop
>
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-2 text-white/40">Export</p>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('markdown')"
>
Markdown (.md)
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('json')"
>
JSON
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('text')"
>
Plain text (.txt)
</button>
<div class="border-t border-white/10 mt-1 pt-1">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="triggerImport"
>
Import conversations
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-white/10 transition-all"
@click="handleDelete"
>
Delete conversation
</button>
</div>
</div>
</Transition>
</Teleport>
<input
ref="importInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
<!-- Import status -->
<div
v-if="importStatus"
class="absolute top-full left-3 right-3 mt-1 z-[100] glass px-3 py-2 rounded-lg text-xs animate-fade-up-fast"
:class="importStatus.startsWith('Error') ? 'text-red-400' : 'text-accent'"
>
{{ importStatus }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{
title: string
conversationId: string
side: 'left' | 'right'
showClose?: boolean
}>()
defineEmits<{
switchSide: []
newChat: []
close: []
openSettings: []
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const comparison = useComparisonMode()
// Model capabilities map
const MODEL_CAPS: Record<string, { vision: boolean; tools: boolean; longContext: boolean }> = {
'claude-haiku-4.5': { vision: true, tools: true, longContext: true },
'claude-sonnet-4': { vision: true, tools: true, longContext: true },
'claude-opus-4': { vision: true, tools: true, longContext: true },
'meta-llama/llama-4-maverick': { vision: true, tools: true, longContext: true },
'qwen/qwen3-235b-a22b-thinking-2507': { vision: false, tools: false, longContext: true },
'mistralai/mistral-small-3.1-24b-instruct:free': { vision: true, tools: true, longContext: false },
'google/gemma-3-27b-it:free': { vision: true, tools: false, longContext: false },
'echo': { vision: false, tools: false, longContext: false },
}
function getModelCaps(modelId: string) {
return MODEL_CAPS[modelId] ?? { vision: false, tools: false, longContext: false }
}
const chatStore = useChatStore()
const showModelPicker = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
const modelPickerDropdownStyle = ref<Record<string, string>>({})
function updateModelPickerPosition() {
nextTick(() => {
const el = modelPickerTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
modelPickerDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: 'auto',
left: `${r.left}px`,
width: `${Math.max(r.width + 24, 220)}px`,
}
}
})
}
function updateMenuPosition() {
nextTick(() => {
const el = menuTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
menuDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: `${window.innerWidth - r.right}px`,
}
}
})
}
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
const m = p.models.find((mm) => mm.id === activeModel.value)
if (m) return m.name
}
return activeModel.value
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
const { enterDesignSystemMode } = useContentPanel()
function openDesignSystem() {
enterDesignSystemMode()
showModelPicker.value = false
}
async function handleExport(format: ExportFormat) {
const conv = chatStore.activeConversation
if (!conv) return
await downloadConversation(conv, format)
showMenu.value = false
}
function handleDelete() {
const id = chatStore.activeConversationId
if (!id) return
chatStore.deleteConversation(id)
showMenu.value = false
}
const importInputRef = ref<HTMLInputElement | null>(null)
const importStatus = ref<string | null>(null)
function triggerImport() {
showMenu.value = false
importInputRef.value?.click()
}
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const result = parseImportFile(text)
if (result.error || result.conversations.length === 0) {
importStatus.value = `Error: ${result.error ?? 'No conversations found'}`
} else {
for (const conv of result.conversations) {
chatStore.conversations.set(conv.id, conv)
}
const count = result.conversations.length
importStatus.value = `Imported ${count} conversation${count > 1 ? 's' : ''} (${result.format})`
// Switch to first imported conversation
chatStore.setActiveConversation(result.conversations[0].id)
}
} catch {
importStatus.value = 'Error: Failed to read file'
}
// Clear file input for re-use
input.value = ''
// Auto-hide status after 3 seconds
setTimeout(() => { importStatus.value = null }, 3000)
}
</script>
<style scoped>
/* The two overlays this header opens — the model picker and the conversation
* menu — float over live chat content, so the shared `path-glass-card`
* translucency (rgba(0,0,0,0.65)) leaves their text hard to read against a
* busy backdrop. Operator-reported 2026-08-04.
*
* Deliberately scoped to this component rather than changing `.path-glass-card`
* itself: that class is also used by BookDetail, ArticleDetail, TVSeriesDetail,
* WebsiteDetail, ContentPanel, ChatWindow and every ChatPage panel, none of
* which have this problem. Only these two get the extra opacity.
*
* Scoped styles reach these nodes even though they are inside <Teleport to="body">
* (Vue stamps the scope attribute on the element itself), and this rule is
* unlayered while `.path-glass-card` lives in `@layer components` — unlayered
* wins over layered regardless of specificity, so no !important is needed.
*/
.header-overlay-panel {
background: rgba(0, 0, 0, 0.88);
}
.picker-enter-active {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.picker-leave-active {
transition: all 0.15s ease-in;
}
.picker-enter-from,
.picker-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<button
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150 hover:bg-white/5 flex items-center gap-2 text-white/70 mb-2"
@click="$emit('newChat')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="text-sm font-medium">New Chat</span>
</button>
<div v-if="conversations.length === 0" class="flex items-center justify-center h-32">
<p class="text-xs text-white/30">No conversations yet</p>
</div>
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150"
:class="conv.id === activeId ? 'nav-tab-active' : 'hover:bg-white/5'"
@click="selectConversation(conv.id)"
>
<p class="text-sm leading-snug truncate" :class="conv.id === activeId ? 'text-white' : 'text-white/90'">
{{ conv.title || 'Untitled' }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-xs text-white/30">
{{ formatTime(conv.updatedAt) }}
</span>
<span class="text-xs text-white/20">&middot;</span>
<span class="text-xs text-white/30">
{{ conv.messages.length }} msg{{ conv.messages.length !== 1 ? 's' : '' }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
select: [id: string]
newChat: []
}>()
const chatStore = useChatStore()
const conversations = computed(() => chatStore.conversationList)
const activeId = computed(() => chatStore.activeConversationId)
function selectConversation(id: string) {
emit('select', id)
}
function formatTime(ts: number): string {
const now = Date.now()
const diff = now - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
</script>
@@ -0,0 +1,447 @@
<template>
<div
class="p-3 md:p-4 relative"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<SearchResults
v-if="isSearchMode"
:results="searchResults"
:is-searching="isSearching"
@select="handleSearchSelect"
/>
<!-- Reply-to quote -->
<div
v-if="replyTo"
class="mb-2 flex items-start gap-2 rounded-xl bg-white/5 border border-white/10 px-3 py-2 animate-fade-up-fast"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-accent/70 font-medium mb-0.5">Replying to</p>
<p class="text-xs text-white/50 truncate">{{ replyTo.excerpt }}</p>
</div>
<button
class="shrink-0 touch-target rounded-md hover:bg-white/10 transition-colors text-white/40 hover:text-white/70"
aria-label="Cancel reply"
@click="$emit('clearReply')"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Image thumbnails -->
<div v-if="images.length > 0" class="mb-2 flex gap-2 flex-wrap animate-fade-up-fast">
<div
v-for="(img, i) in images"
:key="i"
class="relative group w-16 h-16 rounded-lg overflow-hidden border border-white/10 bg-white/5"
>
<img
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="w-full h-full object-cover"
/>
<button
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove image"
@click="removeImage(i)"
>
<svg class="w-4 h-4 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<span v-if="images.length >= MAX_IMAGES" class="self-center text-xs text-white/30">
Max {{ MAX_IMAGES }} images
</span>
</div>
<!-- Prompt palette -->
<PromptPalette
ref="paletteRef"
:query="paletteQuery"
:is-open="isPaletteMode"
@select="handlePaletteSelect"
@close="closePalette"
/>
<!-- Drag overlay -->
<div
v-if="isDragging"
class="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-accent/50 bg-accent/5 backdrop-blur-sm pointer-events-none"
>
<p class="text-sm text-accent/80 font-medium">Drop image here</p>
</div>
<div
class="rounded-2xl px-4 py-3 flex items-center gap-2 transition-all duration-300"
:class="[
isCodeMode
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
: 'path-glass-bubble',
focused ? (isCodeMode ? 'border-accent/40' : 'border-white/30') : '',
]"
>
<!-- Image attach button -->
<button
v-if="!streaming && images.length < MAX_IMAGES"
class="shrink-0 min-w-[44px] min-h-[44px] -my-2 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Attach image"
@click="openFilePicker"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-base outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@focus="onInputFocus"
@blur="focused = false"
/>
<button
v-if="streaming"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
aria-label="Stop generation"
@click="$emit('stop')"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<template v-else>
<!-- Extract/contextualize button — shown after paste -->
<button
v-if="hasPasted && canSend"
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95 text-accent/80"
aria-label="Extract content"
title="Contextualize — extract media without sending to AI"
@click="extract"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Send button -->
<button
:disabled="!canSend"
class="shrink-0 rounded-xl px-3 transition-all duration-200"
:class="[
isCodeMode ? 'bg-accent/80 text-white' : 'path-glass-button path-glass-button-sm',
canSend
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed',
]"
:style="isCodeMode ? 'height: 32px' : ''"
aria-label="Send message"
@click="send"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</template>
</div>
<!-- Hidden file input -->
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import { archyBridge } from '../../services/archyBridge'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
import SearchResults from '@/components/ui/SearchResults.vue'
import PromptPalette from './PromptPalette.vue'
import type { ImageAttachment } from '@aiui/core/types/message'
const MAX_IMAGES = 4
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const props = withDefaults(
defineProps<{
disabled?: boolean
streaming?: boolean
placeholder?: string
activeTab?: string
replyTo?: { messageId: string; excerpt: string } | null
}>(),
{
disabled: false,
streaming: false,
placeholder: 'Message AIUI...',
activeTab: '',
replyTo: null,
}
)
const isCodeMode = computed(() => props.activeTab === 'code')
const emit = defineEmits<{
send: [text: string, images: ImageAttachment[]]
extract: [text: string]
stop: []
clearReply: []
}>()
const text = ref('')
const focused = ref(false)
const hasPasted = ref(false)
const isDragging = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const images = ref<ImageAttachment[]>([])
const paletteRef = ref<InstanceType<typeof PromptPalette> | null>(null)
// Archy's ⌘K search can hand us the text the operator typed there
// ("Talk to AIUI about it"). We prefill and focus rather than auto-sending:
// the operator gets to see and amend the question before it costs a model
// call. Anything already half-typed here wins — their own draft is not
// clobbered by a background handoff.
let releasePrefill: (() => void) | null = null
onMounted(() => {
releasePrefill = archyBridge.onPrefill((incoming: string) => {
if (text.value.trim()) return
text.value = incoming
nextTick(() => {
autoResize()
textareaRef.value?.focus()
// Caret to the end so they can keep typing straight away.
const el = textareaRef.value
if (el) el.setSelectionRange(el.value.length, el.value.length)
})
})
})
onBeforeUnmount(() => {
releasePrefill?.()
releasePrefill = null
})
// Prompt palette: opens when text starts with "/" and has no space yet
const isPaletteMode = computed(() => {
const t = text.value.trimStart()
return t === '/' || (t.startsWith('/') && !t.includes(' '))
})
const paletteQuery = computed(() => {
if (!isPaletteMode.value) return ''
return text.value.trimStart().slice(1) // strip leading /
})
function handlePaletteSelect(templateText: string) {
// Slash commands — send immediately (except /search which needs query input)
if (templateText.startsWith('/') && !templateText.includes('{{')) {
text.value = templateText
if (templateText.trimEnd() === '/search') {
// /search needs a query — set text and let user type
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
return
}
nextTick(() => send())
return
}
text.value = templateText
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
}
function closePalette() {
// Add a space to exit palette mode
if (text.value.trimStart() === '/') {
text.value = ''
}
}
function onInputFocus() {
focused.value = true
nextTick(() => {
textareaRef.value?.scrollIntoView({ block: 'end', behavior: 'smooth' })
})
}
function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') {
e.preventDefault()
paletteRef.value?.navigateUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
paletteRef.value?.navigateDown()
return
}
if (e.key === 'Enter') {
e.preventDefault()
paletteRef.value?.selectHighlighted()
return
}
if (e.key === 'Escape') {
e.preventDefault()
closePalette()
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled)
function send() {
if (!canSend.value) return
emit('send', text.value.trim(), [...images.value])
text.value = ''
images.value = []
hasPasted.value = false
nextTick(autoResize)
}
function extract() {
if (!canSend.value) return
emit('extract', text.value.trim())
text.value = ''
hasPasted.value = false
nextTick(autoResize)
}
function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) {
hasPasted.value = true
return
}
let hasImage = false
for (const item of items) {
if (ACCEPTED_TYPES.includes(item.type)) {
hasImage = true
const file = item.getAsFile()
if (file) addImageFile(file)
}
}
if (!hasImage) {
hasPasted.value = true
}
}
function onDragOver(e: DragEvent) {
if (e.dataTransfer?.types.includes('Files')) {
isDragging.value = true
}
}
function onDragLeave() {
isDragging.value = false
}
function onDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
}
function openFilePicker() {
fileInputRef.value?.click()
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
// Reset input so same file can be re-selected
input.value = ''
}
function addImageFile(file: File) {
if (images.value.length >= MAX_IMAGES) return
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Strip the data:...;base64, prefix
const base64 = result.split(',')[1]
if (base64) {
images.value.push({ data: base64, mediaType: file.type })
}
}
reader.readAsDataURL(file)
}
function removeImage(index: number) {
images.value.splice(index, 1)
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
// Federated search via /search command
const { results: searchResults, isSearching, search: doSearch, clear: clearSearch } = useFederatedSearch()
const isSearchMode = computed(() => text.value.trimStart().startsWith('/search '))
watch(text, (val) => {
if (isSearchMode.value) {
const searchQuery = val.trimStart().replace(/^\/search\s+/, '')
doSearch(searchQuery)
} else {
clearSearch()
}
})
function handleSearchSelect(result: SearchResult) {
// Insert content reference tag based on type
const tags: Record<string, string> = {
film: `[[film:${result.id}]]`,
song: `[[song:${result.id}]]`,
podcast: `[[podcast:${result.id}]]`,
}
text.value = tags[result.type] ?? result.title
clearSearch()
nextTick(() => textareaRef.value?.focus())
}
</script>
@@ -0,0 +1,759 @@
<template>
<div
class="group/msg flex animate-fade-up-fast"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
@contextmenu.prevent="openContextMenu"
>
<div class="relative max-w-[85%] md:max-w-[75%]">
<!-- Action buttons (hover on desktop, always visible on touch) -->
<div
v-if="!isEditing"
class="absolute -top-4 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10"
:class="isUser ? 'right-1' : 'left-1'"
>
<div class="flex items-center gap-2 px-1 py-0.5 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 shadow-lg">
<button
v-if="isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Edit message"
aria-label="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Regenerate response"
aria-label="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Reply"
aria-label="Reply"
@click.stop="$emit('reply', message.id, message.content)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Branch from here"
aria-label="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
<div v-if="!isUser" class="w-px h-4 bg-white/10 mx-0.5" />
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'up' ? 'text-green-400' : 'text-white/50 hover:text-green-400/80 hover:bg-white/10'"
title="Good response"
aria-label="Good response"
@click.stop="toggleFeedback('up')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'down' ? 'text-red-400' : 'text-white/50 hover:text-red-400/80 hover:bg-white/10'"
title="Poor response"
aria-label="Poor response"
@click.stop="toggleFeedback('down')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.106-1.79l-.05-.025A4 4 0 0011.057 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" /></svg>
</button>
</div>
</div>
<div
class="rounded-2xl px-4 py-3 transition-all duration-300"
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<!-- Edit mode -->
<div v-if="isEditing" class="space-y-2" @click.stop>
<textarea
ref="editTextareaRef"
v-model="editContent"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="3"
@keydown.enter.exact="submitEdit"
@keydown.escape="cancelEdit"
/>
<div class="flex gap-2 justify-end">
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs text-white/60 hover:text-white/90 px-3"
@click="cancelEdit"
>Cancel</button>
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs bg-accent/20 text-accent hover:bg-accent/30 px-3"
@click="submitEdit"
>Save & Resend</button>
</div>
</div>
<!-- Normal display -->
<template v-else>
<!-- Attached images -->
<div v-if="message.images && message.images.length > 0" class="flex gap-2 flex-wrap mb-2">
<img
v-for="(img, i) in message.images"
:key="i"
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="rounded-lg max-w-[200px] max-h-[200px] object-cover border border-white/10"
/>
</div>
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="renderedMarkdown"
/>
<p
v-else-if="message.content"
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
>{{ displayText }}</p>
</template>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
v-for="film in inlineFilms"
:key="film.id"
:film="film"
@select="handleFilmSelect"
/>
</div>
<div v-if="inlineBooks.length > 0" class="mt-3 space-y-1" @click.stop>
<BookCard
v-for="book in inlineBooks"
:key="book.id"
:book="book"
@select="handleBookSelect"
/>
</div>
<div v-if="inlineTVSeries.length > 0" class="mt-3 space-y-1" @click.stop>
<TVSeriesCard
v-for="series in inlineTVSeries"
:key="series.id"
:series="series"
@select="handleTVSeriesSelect"
/>
</div>
<div v-if="inlineSongs.length > 0" class="mt-3 space-y-1" @click.stop>
<SongCard
v-for="song in inlineSongs"
:key="song.id"
:song="song"
@select="handleSongSelect"
/>
</div>
<div v-if="inlinePodcasts.length > 0" class="mt-3 space-y-1" @click.stop>
<PodcastCard
v-for="podcast in inlinePodcasts"
:key="podcast.id"
:podcast="podcast"
@select="handlePodcastSelect"
/>
</div>
<div v-if="inlinePlaces.length > 0" class="mt-3 space-y-1" @click.stop>
<PlaceCard
v-for="place in inlinePlaces"
:key="place.id"
:place="place"
@select="handlePlaceSelect"
/>
</div>
<div v-if="nostrUris.length > 0" class="mt-3 space-y-1.5" @click.stop>
<NostrEmbed
v-for="uri in nostrUris"
:key="uri"
:uri="uri"
/>
</div>
<div v-if="cashuTokens.length > 0" class="mt-3 space-y-1.5" @click.stop>
<CashuToken
v-for="token in cashuTokens"
:key="token"
:token="token"
/>
</div>
<div v-if="inlineRecipes.length > 0" class="mt-3 space-y-2" @click.stop>
<RecipeCard
v-for="(recipe, i) in inlineRecipes"
:key="`recipe-${i}`"
:recipe="recipe"
/>
</div>
<!-- Timeline view for 3+ events -->
<div v-if="inlineEvents.length >= 3" class="mt-3" @click.stop>
<TimelineRenderer :events="inlineEvents" />
</div>
<!-- Individual event cards for 1-2 events -->
<div v-else-if="inlineEvents.length > 0" class="mt-3 space-y-2" @click.stop>
<EventCard
v-for="(event, i) in inlineEvents"
:key="`event-${i}`"
:event="event"
/>
</div>
<div v-if="inlineTables.length > 0" class="mt-3 space-y-2" @click.stop>
<InteractiveTable
v-for="(table, i) in inlineTables"
:key="`table-${i}`"
:headers="table.headers"
:rows="table.rows"
/>
</div>
<div v-if="runnableCodeBlocks.length > 0" class="mt-3 space-y-2" @click.stop>
<CodeRunner
v-for="(block, i) in runnableCodeBlocks"
:key="`code-${i}`"
:code="block.code"
:language="block.language"
/>
</div>
<div v-if="bitcoinAddresses.length > 0" class="mt-3 space-y-2" @click.stop>
<BitcoinAddressCard
v-for="(addr, i) in bitcoinAddresses"
:key="`btc-${i}`"
:address="addr.address"
/>
</div>
<div v-if="bolt11Invoices.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt11InvoiceCard
v-for="(inv, i) in bolt11Invoices"
:key="`bolt11-${i}`"
:invoice="inv.invoice"
/>
</div>
<div v-if="bolt12Offers.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt12OfferCard
v-for="(offer, i) in bolt12Offers"
:key="`bolt12-${i}`"
:offer="offer.offer"
/>
</div>
<div v-if="detectedTxIds.length > 0" class="mt-3 space-y-2" @click.stop>
<MempoolTxCard
v-for="(tx, i) in detectedTxIds"
:key="`tx-${i}`"
:txid="tx.txid"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
:key="i"
:article="link"
@select-article="handleArticleSelect"
/>
</div>
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineWebsitesLinks"
:key="`web-${i}`"
:article="link"
@select-article="handleWebsiteSelect"
/>
</div>
<div v-if="!isEditing" class="flex items-center gap-2 mt-1.5">
<span class="text-xs select-none text-white/30">{{ formattedTime }}</span>
<span v-if="message.editedAt" class="text-xs text-white/25 select-none">(edited)</span>
<span v-if="showTokenCount" class="text-xs text-white/20 select-none" :title="`~${estimatedTokens} tokens`">{{ tokenLabel }}</span>
<span v-if="message.feedback" class="text-xs select-none">{{ message.feedback === 'up' ? '👍' : '👎' }}</span>
<button
v-if="inlineFilms.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineFilms.length }} films →
</button>
<button
v-else-if="inlineBooks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineBooks.length }} books →
</button>
<button
v-else-if="inlineTVSeries.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineTVSeries.length }} series →
</button>
<button
v-else-if="inlineImages.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineImages.length }} images →
</button>
<button
v-else-if="inlinePlaces.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePlaces.length }} places →
</button>
<button
v-if="inlinePlaces.length > 0 && inlinePlaces.some(p => p.lat != null)"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openMapView(inlinePlaces)"
>
View on map →
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineSongs.length }} songs →
</button>
<button
v-else-if="inlinePodcasts.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePodcasts.length }} podcasts →
</button>
<button
v-else-if="inlineNewsLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineNewsLinks.length }} articles →
</button>
<button
v-else-if="inlineWebsitesLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineWebsitesLinks.length }} websites →
</button>
<button
v-else-if="inlineMagazineSections.length > 0"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View brief →
</button>
<button
v-if="isLongForm"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openLongFormArticle(message.content)"
>
Read as article →
</button>
</div>
</div>
</div>
<!-- Context menu -->
<ContextMenu ref="contextMenuRef">
<ContextMenuItem v-if="isUser" @click="startEditingFromMenu">Edit</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitRegenerate">Regenerate</ContextMenuItem>
<ContextMenuItem @click="emitReply">Reply</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitBranch">Branch from here</ContextMenuItem>
<ContextMenuItem @click="copyContent">Copy</ContextMenuItem>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { hasMath, renderMathInHtml } from '@/composables/useMathRenderer'
import { hasMermaid, renderMermaidBlocks } from '@/composables/useMermaidRenderer'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useCodeContext } from '@/composables/useCodeContext'
import FilmCard from '@/components/content/FilmCard.vue'
import BookCard from '@/components/content/BookCard.vue'
import TVSeriesCard from '@/components/content/TVSeriesCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import PlaceCard from '@/components/content/PlaceCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
import NostrEmbed from '@/components/chat/NostrEmbed.vue'
import CashuToken from '@/components/chat/CashuToken.vue'
import { extractCashuTokens } from '@/utils/cashu'
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
import CodeRunner from '@/components/renderers/CodeRunner.vue'
import BitcoinAddressCard from '@/components/renderers/BitcoinAddressCard.vue'
import Bolt11InvoiceCard from '@/components/renderers/Bolt11InvoiceCard.vue'
import Bolt12OfferCard from '@/components/renderers/Bolt12OfferCard.vue'
import MempoolTxCard from '@/components/renderers/MempoolTxCard.vue'
import { detectBitcoinAddresses, detectBolt11Invoices, detectBolt12Offers, detectTxIds } from '@/composables/useBitcoinDetector'
import { extractTables } from '@/composables/useTableExtractor'
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
const props = withDefaults(
defineProps<{
message: Message
index: number
triggeringQuery?: string
}>(),
{ triggeringQuery: '' }
)
const emit = defineEmits<{
edit: [messageId: string, newContent: string]
regenerate: []
branch: [messageId: string]
reply: [messageId: string, content: string]
feedback: [messageId: string, value: 'up' | 'down' | undefined]
}>()
function toggleFeedback(value: 'up' | 'down') {
const newValue = props.message.feedback === value ? undefined : value
emit('feedback', props.message.id, newValue)
}
// Editing state
const isEditing = ref(false)
const editContent = ref('')
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
function startEditing() {
editContent.value = props.message.content
isEditing.value = true
nextTick(() => {
editTextareaRef.value?.focus()
// Auto-size textarea
if (editTextareaRef.value) {
editTextareaRef.value.style.height = 'auto'
editTextareaRef.value.style.height = editTextareaRef.value.scrollHeight + 'px'
}
})
}
function cancelEdit() {
isEditing.value = false
editContent.value = ''
}
function submitEdit() {
const trimmed = editContent.value.trim()
if (!trimmed) return
emit('edit', props.message.id, trimmed)
isEditing.value = false
editContent.value = ''
}
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, openMapView, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], places: [] as Place[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
})
const bubbleClasses = computed(() =>
isUser.value
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
: 'path-glass-bubble rounded-bl-md rounded-2xl'
)
const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlinePlaces = computed(() => inlineContent.value.places ?? [])
const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
const NOSTR_URI_RE = /nostr:(note1[a-z0-9]{58}|npub1[a-z0-9]{58}|nevent1[a-z0-9]+|nprofile1[a-z0-9]+)/g
const nostrUris = computed(() => {
if (isUser.value) return []
const matches = props.message.content.match(NOSTR_URI_RE)
return matches ? [...new Set(matches)] : []
})
const cashuTokens = computed(() => {
if (isUser.value) return []
return extractCashuTokens(props.message.content)
})
const inlineRecipes = computed(() => {
if (isUser.value) return []
return extractRecipes(props.message.content)
})
const inlineTables = computed(() => {
if (isUser.value) return []
return extractTables(props.message.content)
})
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
})
const runnableCodeBlocks = computed(() => {
if (isUser.value) return []
return extractRunnableCodeBlocks(props.message.content)
})
const bitcoinAddresses = computed(() => {
if (isUser.value) return []
return detectBitcoinAddresses(props.message.content)
})
const bolt11Invoices = computed(() => {
if (isUser.value) return []
return detectBolt11Invoices(props.message.content)
})
const bolt12Offers = computed(() => {
if (isUser.value) return []
return detectBolt12Offers(props.message.content)
})
const detectedTxIds = computed(() => {
if (isUser.value) return []
return detectTxIds(props.message.content)
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
const hasContext = computed(() => !isUser.value && (
isCodeResponse.value ||
inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 ||
inlineImages.value.length > 0 ||
inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 ||
inlineWebsitesLinks.value.length > 0 ||
inlineMagazineSections.value.length > 0
))
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// Open links in new tab
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
return defaultRender(tokens, idx, options, env, self)
}
const displayText = computed(() => {
if (isUser.value) return props.message.content
let text = stripContentTags(props.message.content)
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
if (nostrUris.value.length > 0) text = text.replace(NOSTR_URI_RE, '').replace(/\n{3,}/g, '\n\n').trim()
if (cashuTokens.value.length > 0) {
for (const token of cashuTokens.value) {
text = text.replace(token, '')
}
text = text.replace(/\n{3,}/g, '\n\n').trim()
}
return text
})
const baseMarkdown = computed(() => md.render(displayText.value))
const renderedMarkdown = ref('')
// Render math/mermaid after markdown, batch on content change
watch(baseMarkdown, async (html) => {
let result = html
renderedMarkdown.value = html // Show immediately
if (hasMath(displayText.value)) {
result = await renderMathInHtml(result)
}
if (hasMermaid(displayText.value)) {
result = await renderMermaidBlocks(result)
}
renderedMarkdown.value = result
}, { immediate: true })
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
// Long-form article detection (>800 words + has headings)
const isLongForm = computed(() => {
if (isUser.value) return false
const text = props.message.content
const words = text.split(/\s+/).length
const hasHeadings = /^#{2,3}\s+.+$/m.test(text)
return words > 800 && hasHeadings
})
// Token estimate (~4 chars per token)
const estimatedTokens = computed(() => Math.ceil(props.message.content.length / 4))
const showTokenCount = computed(() => props.message.content.length > 20)
const tokenLabel = computed(() => {
const t = estimatedTokens.value
if (t >= 1000) return `${(t / 1000).toFixed(1)}k tok`
return `${t} tok`
})
function openPanelWithContext() {
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
}
function handleFilmSelect(film: Film) {
openPanelWithContext()
closeSongDetail()
closePodcastDetail()
openFilmDetail(film)
}
function handleBookSelect(book: Book) {
openPanelWithContext()
closeFilmDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openBookDetail(book)
}
function handleTVSeriesSelect(series: TVSeries) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closeSongDetail()
closePodcastDetail()
openTVSeriesDetail(series)
}
function handleSongSelect(song: Song) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePodcastDetail()
openSongDetail(song)
}
function handlePlaceSelect(place: Place) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePlaceDetail()
openPlaceDetail(place)
}
function handlePodcastSelect(podcast: Podcast) {
openPanelWithContext()
closeFilmDetail()
closeSongDetail()
openPodcastDetail(podcast)
}
function openPanel() {
closeFilmDetail()
closeBookDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openPanelWithContext()
}
function handleArticleSelect(article: WebSearchResult) {
openPanelWithContext()
openArticleDetail(article)
}
function handleWebsiteSelect(article: WebSearchResult) {
openPanelWithContext()
openWebsiteDetail(article)
}
function activateCodeMode() {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
}
function handleBubbleClick() {
if (isCodeResponse.value) {
activateCodeMode()
return
}
if (hasContext.value) openPanel()
}
// Context menu
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
function openContextMenu(e: MouseEvent) {
contextMenuRef.value?.open(e.clientX, e.clientY)
}
function startEditingFromMenu() {
contextMenuRef.value?.close()
startEditing()
}
function emitRegenerate() {
contextMenuRef.value?.close()
emit('regenerate')
}
function emitReply() {
contextMenuRef.value?.close()
emit('reply', props.message.id, props.message.content)
}
function emitBranch() {
contextMenuRef.value?.close()
emit('branch', props.message.id)
}
function copyContent() {
contextMenuRef.value?.close()
navigator.clipboard.writeText(props.message.content).catch(() => {})
}
</script>
@@ -0,0 +1,137 @@
<template>
<div
v-if="isOpen"
class="glass px-3 py-2 mx-3 mb-1 rounded-xl flex items-center gap-2 animate-fade-up-fast"
>
<svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search messages..."
class="flex-1 bg-transparent text-base text-white/90 placeholder:text-white/25 outline-none min-w-0"
@keydown.enter.exact="nextMatch"
@keydown.shift.enter="prevMatch"
@keydown.escape="close"
@keydown.up.prevent="prevMatch"
@keydown.down.prevent="nextMatch"
/>
<span v-if="query" class="text-xs text-white/40 whitespace-nowrap select-none">
{{ matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results' }}
</span>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Previous match"
@click="prevMatch"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Next match"
@click="nextMatch"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80"
aria-label="Close search"
@click="close"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
scrollToMessage: [index: number]
}>()
const isOpen = ref(false)
const query = ref('')
const currentMatchIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const matchingIndices = computed(() => {
if (!query.value.trim()) return []
const q = query.value.toLowerCase()
const indices: number[] = []
for (let i = 0; i < props.messages.length; i++) {
if (props.messages[i].content.toLowerCase().includes(q)) {
indices.push(i)
}
}
return indices
})
const matchCount = computed(() => matchingIndices.value.length)
watch(query, () => {
currentMatchIndex.value = 0
if (matchingIndices.value.length > 0) {
emit('scrollToMessage', matchingIndices.value[0])
}
})
function nextMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value + 1) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function prevMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value - 1 + matchCount.value) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function open() {
isOpen.value = true
nextTick(() => inputRef.value?.focus())
}
function close() {
isOpen.value = false
query.value = ''
currentMatchIndex.value = 0
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
if (isOpen.value) {
inputRef.value?.focus()
} else {
open()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
defineExpose({ open, close, isOpen, matchingIndices, currentMatchIndex })
</script>
@@ -0,0 +1,540 @@
<template>
<div class="flex flex-col h-full rounded-2xl overflow-visible transition-all duration-300">
<ChatHeader
:title="title"
:conversation-id="displayId"
:side="side"
:show-close="showClose"
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
@open-settings="showSettings = true"
/>
<BranchSwitcher />
<ChatSearch
ref="chatSearchRef"
:messages="messages"
@scroll-to-message="scrollToMessageIndex"
/>
<ContextBar :messages="messages" :active-model="activeModel" />
<PersonaSelector />
<SettingsModal v-model:open="showSettings" />
<!-- History: full conversation list -->
<ChatHistory
v-if="showHistory"
@select="handleHistorySelect"
@new-chat="handleNewChat"
/>
<!-- Collapsed: prompt index -->
<template v-else-if="chatCollapsed">
<PromptIndex
:messages="messages"
@select="handlePromptSelect"
/>
<div v-if="isStreaming" class="px-4 pb-3">
<StreamingDots />
</div>
</template>
<!-- Expanded: full message list (virtualized) -->
<div
v-else
ref="messageListRef"
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
<div class="text-center space-y-4 animate-fade-up">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl text-[#fafafa]">✦</span>
</div>
<p class="text-sm text-white/30">
Start a conversation
</p>
<router-link
to="/guide"
class="inline-flex items-center gap-1.5 text-xs text-white/25 hover:text-white/50 transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
AIUI Guide
</router-link>
</div>
</div>
<div
v-else
:style="{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="messages[virtualRow.index].id"
:ref="(el) => el && virtualizer.measureElement(el as Element)"
:data-index="virtualRow.index"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
:class="['px-4 py-2.5', virtualRow.index === 0 ? 'pt-6' : '']"
>
<ErrorBoundary title="Message failed to render">
<ChatMessage
:message="messages[virtualRow.index]"
:index="virtualRow.index"
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
@edit="handleEdit"
@regenerate="handleRegenerate"
@branch="handleBranch"
@reply="handleReply"
@feedback="handleFeedback"
/>
</ErrorBoundary>
</div>
</div>
<div v-if="isStreaming && lastMessageEmpty" class="px-4 pb-4">
<StreamingDots />
</div>
</div>
<!-- Comparison mode split view -->
<ComparisonView
v-if="comparison.isComparing.value && (comparison.response1.value || comparison.response2.value)"
class="flex-1 min-h-0"
/>
<!-- Code mode project indicator -->
<div
v-if="codeContext.isCodeMode.value && codeContext.activeProject.value"
class="px-3 pb-1 flex items-center gap-1.5"
>
<span class="text-xs px-2 py-0.5 rounded-md bg-accent/15 text-accent font-medium truncate max-w-[200px]">
{{ codeContext.activeProject.value.name }}
</span>
<button
class="text-white/30 hover:text-white/60 transition-colors p-0.5"
@click="codeContext.exitCodeMode()"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ChatInput
:disabled="isStreaming || comparison.isAnyStreaming.value"
:streaming="isStreaming || comparison.isAnyStreaming.value"
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
:active-tab="activeTab"
:reply-to="replyTo"
@send="handleSend"
@extract="handleExtract"
@stop="handleStop"
@clear-reply="clearReply"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useChatStore } from '@/stores/chat'
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
import { useAI, streamWithModel } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { useComparisonMode } from '@/composables/useComparisonMode'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import ChatHistory from './ChatHistory.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import SettingsModal from './SettingsModal.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
withDefaults(
defineProps<{
variant?: 'standalone' | 'modal'
side?: 'left' | 'right'
showClose?: boolean
}>(),
{
variant: 'standalone',
side: 'right',
showClose: false,
}
)
defineEmits<{
switchSide: []
close: []
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
const showSettings = ref(false)
// A send/regenerate/edit failure that looks like a missing or invalid API
// key (see useAI.ts's looksLikeMissingApiKey) opens Settings automatically
// instead of leaving the user stuck on a silent/dead error with no obvious
// next step. One-shot: reset immediately after acting so a later retry that
// fails the same way can re-trigger it (the user may have closed Settings
// without fixing anything).
watch(needsApiKey, (needs) => {
if (needs) {
showSettings.value = true
needsApiKey.value = false
}
})
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
function saveScrollPosition() {
const el = messageListRef.value
const id = chatStore.activeConversationId
if (el && id) {
scrollPositions.set(id, el.scrollTop)
}
}
function restoreScrollPosition(convId: string) {
const saved = scrollPositions.get(convId)
if (saved !== undefined) {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = saved
})
}
}
function scrollToMessageIndex(index: number) {
virtualizer.value.scrollToIndex(index, { align: 'center' })
}
// Reply-to threading state
const replyTo = ref<{ messageId: string; excerpt: string } | null>(null)
function handleReply(messageId: string, content: string) {
const excerpt = content.slice(0, 100) + (content.length > 100 ? '...' : '')
replyTo.value = { messageId, excerpt }
}
function clearReply() {
replyTo.value = null
}
const messages = computed(() => chatStore.messages)
const virtualizer = useVirtualizer(computed(() => ({
count: messages.value.length,
getScrollElement: () => messageListRef.value,
estimateSize: (index: number) => {
// User messages are typically shorter
return messages.value[index]?.role === 'user' ? 60 : 200
},
overscan: 5,
})))
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const showHistory = computed(() => chatStore.showHistory)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
)
const displayId = computed(
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
)
const lastMessageEmpty = computed(() => {
const msgs = messages.value
if (msgs.length === 0) return true
return msgs[msgs.length - 1].content === ''
})
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
if (msgs[idx]?.role !== 'assistant') return ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
}
return ''
}
function handleNewChat() {
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
chatStore.showHistory = false
}
function handleHistorySelect(id: string) {
chatStore.setActiveConversation(id)
chatStore.showHistory = false
}
function handleStop() {
stopGeneration()
comparison.stopComparison()
}
async function handleEdit(messageId: string, newContent: string) {
await editAndResend(messageId, newContent)
}
async function handleRegenerate() {
await regenerateLastResponse()
}
function handleBranch(messageId: string) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.branchFromMessage(convId, messageId)
}
function handleFeedback(messageId: string, value: 'up' | 'down' | undefined) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.setMessageFeedback(convId, messageId, value)
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
chatStore.addMessage(convId, { role: 'assistant', content: text })
updatePanelFromText(text, '', [])
}
async function handleSend(text: string, images: ImageAttachment[] = []) {
// Command handling
const trimmed = text.trim().toLowerCase()
if (trimmed === '/code') {
codeContext.enterCodeMode()
// Open the content panel with code tab
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
// Add system message to chat
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'user',
content: '/code',
})
chatStore.addMessage(convId, {
role: 'assistant',
content: 'Code mode activated. Select a project from the content panel to start coding.',
})
}
return
}
if (trimmed === '/seed') {
// Demo-site/dev only (S7): the seed showcase is fabricated example
// content and must not ship in the node build.
if (!DEMO_CONTENT_ENABLED) {
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'assistant',
content: 'The `/seed` showcase is only available in demo builds.',
})
}
return
}
await chatStore.loadSeedChats()
// loadSeedChats already switches to the seed conversation
// Collapse chat so user sees PromptIndex for quick picking
chatStore.chatCollapsed = true
chatStore.showHistory = false
return
}
if (trimmed === '/nostr') {
panelOpen.value = true
if (!availableTabs.value.includes('nostr')) {
availableTabs.value = [...availableTabs.value, 'nostr']
}
setActiveTab('nostr')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
}
return
}
if (trimmed === '/design') {
enterDesignSystemMode()
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/design' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Design system viewer opened. Browse colors, typography, spacing, and components.' })
}
return
}
if (trimmed === '/freefilms') {
const { freeFilms } = await import('@/data/freeFilms')
panelFilms.value = freeFilms
panelTitle.value = 'Free Documentary Films'
panelOpen.value = true
availableTabs.value = ['film', 'prompt']
setActiveTab('film')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
availableTabs.value = availableTabs.value.filter(t => t !== 'code')
const convId2 = chatStore.activeConversationId
if (convId2) {
chatStore.addMessage(convId2, {
role: 'assistant',
content: 'Code mode deactivated.',
})
}
return
}
}
// Prepend quote if replying to a message
let finalText = text
if (replyTo.value) {
const quoteLine = replyTo.value.excerpt.split('\n').map(l => `> ${l}`).join('\n')
finalText = `${quoteLine}\n\n${text}`
clearReply()
}
// Comparison mode: stream to both models simultaneously
if (comparison.isComparing.value) {
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: finalText, images: images.length > 0 ? images : undefined })
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
await comparison.streamBothModels(streamWithModel, history)
return
}
await sendMessage(finalText, images.length > 0 ? images : undefined)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
const userText = _userMsg.content?.trim().toLowerCase() ?? ''
if (userText === '/code') {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
return
}
if (assistantMsg?.content) {
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
}
}
function scrollToBottom() {
// Container scroll ONLY — deliberately not virtualizer.scrollToIndex.
//
// Doing both is what produced "Failed to scroll to index N after 10
// attempts" in the console on every send. scrollToIndex runs a retry loop
// that nudges scrollTop toward the measured offset of the target row and
// re-checks, up to 10 times, because dynamically-measured rows move the
// target as they settle. Assigning scrollTop ourselves on the next tick
// overwrote each of those nudges, so the loop never saw itself converge and
// always exhausted its attempts — the warning was the two of us fighting
// over the same scrollTop, not a real failure.
//
// For "go to the end" the virtualizer's index-settling machinery buys
// nothing: scrollHeight is already the bottom, and the virtualizer renders
// whatever window that offset implies. It also keeps working while a
// response streams and the last row keeps growing, which is exactly the
// case the old fallback was added for. scrollToMessageIndex still uses
// scrollToIndex, which is the right tool for jumping to an arbitrary row.
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
// Scroll to bottom when expanding from collapsed
watch(chatCollapsed, (collapsed, wasCollapsed) => {
if (!collapsed && wasCollapsed) {
scrollToBottom()
}
})
// Scroll to bottom when mobile keyboard opens so latest messages + input stay visible
const { isKeyboardOpen } = useVisualViewport()
watch(isKeyboardOpen, (open) => {
if (open) scrollToBottom()
})
// Save/restore scroll position on conversation switch
watch(
() => chatStore.activeConversationId,
(newId, oldId) => {
if (oldId) saveScrollPosition()
if (newId) restoreScrollPosition(newId)
}
)
watch(
() => messages.value.length,
() => scrollToBottom()
)
watch(
() => {
const msgs = messages.value
const last = msgs[msgs.length - 1]
return last ? { content: last.content, webResults: last.webResults } : null
},
(val) => {
scrollToBottom()
if (val?.content) {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
// Skip panel updates for command messages (e.g. /code, /exit)
const userText = lastUser?.content?.trim() ?? ''
if (!userText.startsWith('/')) {
updatePanelFromText(val.content, userText, lastMsg?.webResults ?? [])
}
}
},
{ deep: true, immediate: true }
)
</script>
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-full">
<!-- Mobile tabs -->
<div class="flex md:hidden gap-2 px-3 pt-2">
<button
v-for="(tab, i) in tabs"
:key="i"
class="flex-1 text-sm min-h-[44px] rounded-lg transition-all"
:class="activeTab === i
? 'bg-accent/20 text-accent'
: 'text-white/50 hover:text-white/70'"
@click="activeTab = i"
>
{{ tab }}
</button>
</div>
<!-- Split panes -->
<div class="flex-1 min-h-0 flex gap-0.5 p-2">
<!-- Model 1 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 0 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model1Label }}</span>
<span v-if="isStreaming1" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response1"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered1"
/>
<div v-else-if="error1" class="text-sm text-red-400/80">{{ error1 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
<!-- Model 2 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 1 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model2Label }}</span>
<span v-if="isStreaming2" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response2"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered2"
/>
<div v-else-if="error2" class="text-sm text-red-400/80">{{ error2 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { useComparisonMode } from '@/composables/useComparisonMode'
const { model1, model2, response1, response2, isStreaming1, isStreaming2, error1, error2 } = useComparisonMode()
const activeTab = ref(0)
const tabs = computed(() => [model1Label.value, model2Label.value])
const model1Label = computed(() => `${model1.value.provider}/${model1.value.model}`.split('/').pop() ?? 'Model 1')
const model2Label = computed(() => `${model2.value.provider}/${model2.value.model}`.split('/').pop() ?? 'Model 2')
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const rendered1 = computed(() => md.render(response1.value))
const rendered2 = computed(() => md.render(response2.value))
</script>
@@ -0,0 +1,74 @@
<template>
<div
v-if="messages.length > 0"
class="h-1 mx-3 rounded-full bg-white/5 overflow-hidden shrink-0 group cursor-help relative"
:title="tooltipText"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="percentage > 80 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
<!-- Tooltip on hover -->
<div
class="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:flex items-center px-2 py-1 rounded-md bg-black/80 text-xs text-white/80 whitespace-nowrap pointer-events-none z-10"
>
{{ tooltipText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
contextWindow?: number
activeModel?: string
}>()
// Default context window sizes per model (tokens)
const maxTokens = computed(() => props.contextWindow ?? 200000)
// Estimate: ~4 chars per token
const estimatedTokens = computed(() => {
let chars = 0
for (const msg of props.messages) {
chars += msg.content.length
}
return Math.ceil(chars / 4)
})
const percentage = computed(() => {
if (maxTokens.value === 0) return 0
return (estimatedTokens.value / maxTokens.value) * 100
})
// Model pricing per million tokens (input/output)
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'claude-haiku-4.5': { input: 0.80, output: 4.00 },
'claude-sonnet-4': { input: 3.00, output: 15.00 },
'claude-opus-4': { input: 15.00, output: 75.00 },
}
const estimatedCost = computed(() => {
const pricing = MODEL_PRICING[props.activeModel ?? '']
if (!pricing) return null
// Rough split: 70% input, 30% output
const inputTokens = estimatedTokens.value * 0.7
const outputTokens = estimatedTokens.value * 0.3
const cost = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
return cost
})
const tooltipText = computed(() => {
const est = estimatedTokens.value.toLocaleString()
const max = maxTokens.value.toLocaleString()
let text = `~${est} / ${max} tokens used`
if (estimatedCost.value !== null) {
text += ` · ~$${estimatedCost.value.toFixed(4)}`
}
return text
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="px-3 md:px-4 pb-1">
<!-- Collapsed toggle -->
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Memory ({{ memoryStore.items.length }}/20)
</button>
<!-- Expanded panel -->
<div v-if="isExpanded" class="mt-2 space-y-1.5 animate-fade-up-fast">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<!-- Add new -->
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
const memoryStore = useMemoryStore()
const isExpanded = ref(false)
const newText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newText.value.trim()) return
memoryStore.addItem(newText.value)
newText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
</script>
@@ -0,0 +1,173 @@
<template>
<div
class="rounded-xl p-3 transition-all duration-150"
:class="isDark
? 'bg-purple-500/10 border border-purple-500/20'
: 'bg-purple-50 border border-purple-200'"
>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2">
<div
class="w-6 h-6 rounded-full animate-pulse"
:class="isDark ? 'bg-purple-500/20' : 'bg-purple-200'"
/>
<div class="flex-1 space-y-1">
<div
class="h-3 w-24 rounded animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-gray-200'"
/>
<div
class="h-2 w-40 rounded animate-pulse"
:class="isDark ? 'bg-white/5' : 'bg-gray-100'"
/>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex items-center gap-2">
<span class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ isProfile ? 'Profile' : 'Note' }} not found
</span>
<span
class="text-xs font-mono truncate"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400'"
>
{{ truncatedId }}
</span>
</div>
<!-- Note content -->
<div v-else-if="note">
<div class="flex items-center gap-2 mb-1.5">
<div
class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
{{ note.authorName ?? 'anon' }}
</span>
<span
class="text-xs ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'"
>
{{ formatTime(note.created_at) }}
</span>
</div>
<p
class="text-xs leading-relaxed line-clamp-4"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ note.content }}
</p>
<div class="flex items-center gap-2 mt-1.5">
<span
class="text-xs font-mono"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr
</span>
</div>
</div>
<!-- Profile card (npub) -->
<div v-else-if="isProfile">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ truncatedId.charAt(0).toUpperCase() }}
</div>
<div>
<span
class="text-xs font-mono block"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ truncatedId }}
</span>
<span
class="text-xs"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr profile
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostr, type NostrNote } from '@/composables/useNostr'
import { decodeNIP19 } from '@/utils/bech32'
const props = defineProps<{
uri: string
}>()
const { isDark } = useTheme()
const { connect, fetchNote: fetchFromRelay } = useNostr()
const note = ref<NostrNote | null>(null)
const loading = ref(false)
const error = ref(false)
const decoded = computed(() => {
const raw = props.uri.replace(/^nostr:/, '')
return decodeNIP19(raw)
})
const isProfile = computed(() => decoded.value?.type === 'npub' || decoded.value?.type === 'nprofile')
const truncatedId = computed(() => {
const hex = decoded.value?.hex ?? ''
if (hex.length <= 12) return hex
return hex.slice(0, 8) + '...' + hex.slice(-4)
})
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000 - ts)
if (diff < 60) return 'now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
onMounted(async () => {
if (!decoded.value) {
error.value = true
return
}
// For profiles, just show the card with no fetch needed
if (isProfile.value) return
// For notes/events, fetch from relay
const hexId = decoded.value.hex
if (!hexId) {
error.value = true
return
}
loading.value = true
connect()
// Small delay to allow relay connections to establish
await new Promise(r => setTimeout(r, 500))
const result = await fetchFromRelay(hexId)
loading.value = false
if (result) {
note.value = result
} else {
error.value = true
}
})
</script>
@@ -0,0 +1,248 @@
<template>
<div v-if="personaStore.personas.length > 0" class="px-3 md:px-4">
<div class="flex items-center gap-1.5 flex-wrap">
<button
v-for="p in personaStore.sortedPersonas"
:key="p.id"
class="px-2.5 py-1 rounded-full text-xs font-medium transition-all duration-200 border"
:class="isActive(p.id)
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-white/5 text-white/50 border-white/10 hover:text-white/70 hover:bg-white/10'"
@click="selectPersona(p.id)"
>
<span
v-if="p.accentColor"
class="inline-block w-2 h-2 rounded-full mr-1"
:style="{ backgroundColor: p.accentColor }"
/>
{{ p.name }}
</button>
<button
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 border border-transparent hover:border-white/10 transition-all duration-200"
@click="showEditor = true"
>
+ New
</button>
<button
v-if="activePersonaId"
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 transition-all"
title="Clear persona"
@click="clearPersona"
>
✕
</button>
</div>
<!-- Persona editor modal -->
<Teleport to="body">
<div
v-if="showEditor"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeEditor"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div class="relative glass-card w-full max-w-md p-5 space-y-4 animate-scale-in">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/90">
{{ editingPersona ? 'Edit Persona' : 'New Persona' }}
</h3>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs text-white/40 mb-1">Name</label>
<input
v-model="formName"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
placeholder="e.g. Film Critic"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">System Prompt</label>
<textarea
v-model="formPrompt"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="5"
placeholder="You are a film critic who..."
/>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Model Preference</label>
<select
v-model="formModel"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
>
<option value="">Default</option>
<option value="claude-haiku-4.5">Claude 4.5 Haiku</option>
<option value="claude-sonnet-4">Claude Sonnet 4</option>
<option value="claude-opus-4">Claude Opus 4</option>
</select>
</div>
<div class="w-20">
<label class="block text-xs text-white/40 mb-1">Colour</label>
<input
v-model="formColor"
type="color"
class="w-full h-9 rounded-lg bg-white/5 border border-white/10 cursor-pointer"
/>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-white/60">
<input
v-model="formDefault"
type="checkbox"
class="accent-[#F7931A]"
/>
Set as default for new conversations
</label>
</div>
<div class="flex items-center gap-2 pt-1">
<button
v-if="editingPersona"
class="px-3 py-1.5 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-red-400/10 transition-all"
@click="handleDelete"
>
Delete
</button>
<div class="flex-1" />
<button
class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
Cancel
</button>
<button
class="px-4 py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
:disabled="!formName.trim()"
@click="handleSave"
>
{{ editingPersona ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePersonaStore, type Persona } from '@/stores/personas'
import { useChatStore } from '@/stores/chat'
const personaStore = usePersonaStore()
const chatStore = useChatStore()
const showEditor = ref(false)
const editingPersona = ref<Persona | null>(null)
// Form state
const formName = ref('')
const formPrompt = ref('')
const formModel = ref('')
const formColor = ref('#F7931A')
const formDefault = ref(false)
const activePersonaId = computed(() => chatStore.activeConversation?.personaId ?? null)
function isActive(id: string): boolean {
return activePersonaId.value === id
}
function selectPersona(id: string) {
const conv = chatStore.activeConversation
if (!conv) return
if (conv.personaId === id) {
// Double-click to edit
openEditorFor(personaStore.getPersona(id))
return
}
conv.personaId = id
conv.updatedAt = Date.now()
}
function clearPersona() {
const conv = chatStore.activeConversation
if (!conv) return
conv.personaId = undefined
conv.updatedAt = Date.now()
}
function openEditorFor(persona?: Persona) {
if (persona) {
editingPersona.value = persona
formName.value = persona.name
formPrompt.value = persona.systemPrompt
formModel.value = persona.modelPreference ?? ''
formColor.value = persona.accentColor ?? '#F7931A'
formDefault.value = persona.isDefault ?? false
} else {
editingPersona.value = null
formName.value = ''
formPrompt.value = ''
formModel.value = ''
formColor.value = '#F7931A'
formDefault.value = false
}
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
editingPersona.value = null
}
function handleSave() {
if (!formName.value.trim()) return
const data = {
name: formName.value.trim(),
systemPrompt: formPrompt.value.trim(),
modelPreference: formModel.value || undefined,
accentColor: formColor.value,
isDefault: formDefault.value,
}
if (editingPersona.value) {
personaStore.updatePersona(editingPersona.value.id, data)
} else {
const created = personaStore.addPersona(data)
// Auto-select the new persona
const conv = chatStore.activeConversation
if (conv) {
conv.personaId = created.id
conv.updatedAt = Date.now()
}
}
closeEditor()
}
function handleDelete() {
if (!editingPersona.value) return
const id = editingPersona.value.id
// Clear from any active conversation
const conv = chatStore.activeConversation
if (conv?.personaId === id) {
conv.personaId = undefined
}
personaStore.deletePersona(id)
closeEditor()
}
</script>
@@ -0,0 +1,109 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs text-white/30">
No prompts yet
</p>
</div>
<button
v-for="(pair, i) in promptPairs"
:key="pair.userMsg.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 group"
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: 'hover:bg-white/5'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate text-white/90">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-xs text-white/30">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-xs px-1.5 py-0.5 rounded-md bg-white/8 text-white/40"
>
{{ badge }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
userMsg: Message
assistantMsg: Message | null
badges: string[]
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
const promptPairs = computed<PromptPair[]>(() => {
const pairs: PromptPair[] = []
const msgs = props.messages
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].role !== 'user') continue
const userMsg = msgs[i]
const assistantMsg = (i + 1 < msgs.length && msgs[i + 1].role === 'assistant')
? msgs[i + 1]
: null
const badges: string[] = []
if (assistantMsg && assistantMsg.content) {
const content = getContextualInlineContent(
assistantMsg.content,
userMsg.content,
assistantMsg.webResults ?? [],
)
if (content.films.length > 0) badges.push('Films')
if ((content.books?.length ?? 0) > 0) badges.push('Books')
if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV')
if ((content.images?.length ?? 0) > 0) badges.push('Images')
if ((content.places?.length ?? 0) > 0) badges.push('Places')
if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
if ((content.codeBlocks?.length ?? 0) > 0) badges.push('Code')
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
if (content.hasNostr) badges.push('Nostr')
}
pairs.push({ userMsg, assistantMsg, badges })
}
return pairs
})
function selectPrompt(pair: PromptPair, index: number) {
activeIndex.value = index
emit('select', pair.userMsg, pair.assistantMsg)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
@@ -0,0 +1,218 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-50">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl p-4 space-y-3 animate-scale-in"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
<button
class="text-xs text-white/40 hover:text-white/60 transition-colors"
@click="cancelTemplate"
>
Cancel
</button>
</div>
<div v-for="v in variables" :key="v" class="space-y-1">
<label class="text-xs text-white/40">{{ v }}</label>
<input
v-model="variableValues[v]"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-base text-white/90 focus:outline-none focus:border-accent/50"
:placeholder="v"
@keydown.enter="applyTemplate"
/>
</div>
<button
class="w-full py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
@click="applyTemplate"
>
Insert
</button>
</div>
<!-- Command + template list -->
<div
v-else
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl max-h-72 overflow-y-auto animate-scale-in"
>
<!-- Commands section -->
<div v-if="filteredCommands.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Commands</p>
</div>
<div
v-for="(cmd, i) in filteredCommands"
:key="cmd.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectCommand(cmd)"
@mouseenter="highlightIndex = i"
>
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-accent/70">{{ cmd.slash }}</span>
<p class="text-sm text-white/80">{{ cmd.title }}</p>
</div>
<p v-if="cmd.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ cmd.preview }}</p>
</div>
</div>
<!-- Templates section -->
<div v-if="filteredUserTemplates.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Templates</p>
</div>
<div
v-for="(t, i) in filteredUserTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="(filteredCommands.length + i) === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = filteredCommands.length + i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
</div>
</div>
<div v-if="filteredCommands.length === 0 && filteredUserTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching commands</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
interface PaletteCommand {
id: string
slash: string
title: string
preview: string
}
const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-code', slash: '/code', title: 'Code', preview: 'Open project browser and code editor' },
{ id: 'cmd-nostr', slash: '/nostr', title: 'Nostr', preview: 'Browse the Nostr network feed' },
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
// /seed loads fabricated showcase content — demo-site/dev builds only (S7)
...(DEMO_CONTENT_ENABLED
? [{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' }]
: []),
{ id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' },
]
const props = defineProps<{
query: string
isOpen: boolean
}>()
const emit = defineEmits<{
select: [text: string]
close: []
}>()
const templateStore = usePromptTemplateStore()
const highlightIndex = ref(0)
const selectedTemplate = ref<PromptTemplate | null>(null)
const variableValues = ref<Record<string, string>>({})
const filteredCommands = computed(() => {
const q = props.query.toLowerCase()
if (!q) return BUILT_IN_COMMANDS
return BUILT_IN_COMMANDS.filter(c =>
c.slash.toLowerCase().includes('/' + q) ||
c.title.toLowerCase().includes(q) ||
c.preview.toLowerCase().includes(q)
)
})
const filteredUserTemplates = computed(() => {
const q = props.query.toLowerCase()
if (!q) return templateStore.sortedTemplates
return templateStore.sortedTemplates.filter(t =>
t.title.toLowerCase().includes(q) || (t.preview ?? '').toLowerCase().includes(q)
)
})
const allItemsCount = computed(() => filteredCommands.value.length + filteredUserTemplates.value.length)
const variables = computed(() => {
if (!selectedTemplate.value) return []
return extractVariables(selectedTemplate.value.content)
})
watch(() => props.query, () => {
highlightIndex.value = 0
selectedTemplate.value = null
})
watch(() => props.isOpen, (open) => {
if (!open) {
selectedTemplate.value = null
variableValues.value = {}
highlightIndex.value = 0
}
})
function selectCommand(cmd: PaletteCommand) {
// Commands like /code, /nostr send the slash text directly
emit('select', cmd.slash)
}
function selectTemplate(t: PromptTemplate) {
const vars = extractVariables(t.content)
if (vars.length === 0) {
emit('select', t.content)
return
}
selectedTemplate.value = t
variableValues.value = Object.fromEntries(vars.map(v => [v, '']))
}
function applyTemplate() {
if (!selectedTemplate.value) return
let result = selectedTemplate.value.content
for (const [key, val] of Object.entries(variableValues.value)) {
result = result.replaceAll(`{{${key}}}`, val || key)
}
emit('select', result)
selectedTemplate.value = null
variableValues.value = {}
}
function cancelTemplate() {
selectedTemplate.value = null
variableValues.value = {}
}
function navigateUp() {
if (highlightIndex.value > 0) highlightIndex.value--
}
function navigateDown() {
if (highlightIndex.value < allItemsCount.value - 1) highlightIndex.value++
}
function selectHighlighted() {
const idx = highlightIndex.value
if (idx < filteredCommands.value.length) {
selectCommand(filteredCommands.value[idx])
} else {
const t = filteredUserTemplates.value[idx - filteredCommands.value.length]
if (t) selectTemplate(t)
}
}
defineExpose({
navigateUp,
navigateDown,
selectHighlighted,
hasSelectedTemplate: computed(() => !!selectedTemplate.value),
})
</script>
@@ -0,0 +1,357 @@
<template>
<Teleport to="body">
<Transition name="settings-modal">
<div
v-if="open"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Settings"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape="$emit('update:open', false)"
@keydown.tab="trapFocus"
>
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click.self="$emit('update:open', false)"
/>
<div class="glass-card relative w-full max-w-md p-5 space-y-5 animate-scale-in max-h-[85vh] overflow-y-auto scrollbar-hide">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
<button
ref="closeButtonRef"
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Close settings"
@click="$emit('update:open', false)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Memory Section -->
<div class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Memory ({{ memoryStore.items.length }}/20)
</h3>
<div class="space-y-1.5">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newMemoryText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newMemoryText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-white/5" />
<!-- Advanced Section -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Advanced
</h3>
<template v-if="conv">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-white/50">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">&times;</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</template>
<p v-else class="text-xs text-white/30">Start a conversation to configure parameters</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
import { useChatStore } from '@/stores/chat'
const props = defineProps<{
open: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const dialogRef = ref<HTMLElement | null>(null)
const closeButtonRef = ref<HTMLElement | null>(null)
function trapFocus(e: KeyboardEvent) {
const dialog = dialogRef.value
if (!dialog) return
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.open, async (isOpen) => {
if (isOpen) {
await nextTick()
closeButtonRef.value?.focus()
}
})
// --- Memory ---
const memoryStore = useMemoryStore()
const newMemoryText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newMemoryText.value.trim()) return
memoryStore.addItem(newMemoryText.value)
newMemoryText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
// --- Advanced ---
const chatStore = useChatStore()
const conv = computed(() => chatStore.activeConversation)
const newStopSeq = ref('')
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
<style scoped>
.settings-modal-enter-active {
transition: opacity 0.2s ease-out;
}
.settings-modal-enter-active .glass-card {
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-modal-leave-active {
transition: opacity 0.15s ease-in;
}
.settings-modal-enter-from,
.settings-modal-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,14 @@
<template>
<div class="flex justify-start animate-fade-up-fast">
<div class="path-glass-card rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>