Archipelago — open-source initial import
This commit is contained in:
@@ -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">·</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)">×</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>
|
||||
Reference in New Issue
Block a user