9af8ab8f70
- build_context: when conversation_type is 'briefing', inject a system prompt instruction telling the model to answer from conversation history and article context instead of searching the web - Consolidate briefing conversation type detection to one DB query (was being checked twice — once for the system prompt addition, once for article context injection) - ChatPanel: render a visual 'New Briefing Update' separator line before 2nd+ briefing slot messages (identified by metadata.rss_item_ids) - types/chat.ts: add metadata field to Message interface Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
890 lines
30 KiB
Vue
890 lines
30 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import { useSettingsStore } from '@/stores/settings'
|
|
import { useStreamingTts } from '@/composables/useStreamingTts'
|
|
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
|
import { useListenMode } from '@/composables/useListenMode'
|
|
import { apiGet } from '@/api/client'
|
|
import ChatMessage from '@/components/ChatMessage.vue'
|
|
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
|
import ChatInputBar from '@/components/ChatInputBar.vue'
|
|
import ToolCallCard from '@/components/ToolCallCard.vue'
|
|
import type { Message, ToolCallRecord } from '@/types/chat'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
variant: 'full' | 'widget'
|
|
/** Workspace: pins RAG scope and workspace_project_id */
|
|
projectId?: number
|
|
/** Briefing: hides scope chip, hides note picker */
|
|
briefingMode?: boolean
|
|
/** Hides input bar — for read-only historical views */
|
|
readOnly?: boolean
|
|
placeholder?: string
|
|
autoFocus?: boolean
|
|
}>(), {
|
|
briefingMode: false,
|
|
readOnly: false,
|
|
autoFocus: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
/** Widget only: emitted when a new conversation is created */
|
|
'conversation-started': [convId: number]
|
|
}>()
|
|
|
|
const store = useChatStore()
|
|
const settingsStore = useSettingsStore()
|
|
|
|
// ── Voice / TTS ───────────────────────────────────────────────────────────────
|
|
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
|
const listenMode = useListenMode()
|
|
const audio = useVoiceAudio()
|
|
const showVolumeSlider = ref(false)
|
|
|
|
const tts = useStreamingTts({
|
|
streamingContent: computed(() => store.streamingContent),
|
|
streaming: computed(() => store.streaming),
|
|
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
})
|
|
|
|
function lastAssistantContent(): string {
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
|
}
|
|
|
|
function toggleListen() {
|
|
listenMode.value = !listenMode.value
|
|
if (listenMode.value) {
|
|
tts.speak(lastAssistantContent())
|
|
} else {
|
|
tts.stop()
|
|
}
|
|
}
|
|
|
|
// ── Scroll ────────────────────────────────────────────────────────────────────
|
|
const messagesEl = ref<HTMLElement | null>(null)
|
|
|
|
function scrollToBottom() {
|
|
nextTick(() => {
|
|
if (messagesEl.value) {
|
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
watch(() => store.streamingContent, () => scrollToBottom())
|
|
watch(() => store.currentConversation?.messages.length, () => scrollToBottom())
|
|
|
|
// Notify CalendarView when an event is created/updated/deleted via tool
|
|
const _calendarToolNames = new Set(['create_event', 'update_event', 'delete_event'])
|
|
const _seenCalendarToolIdx = new Set<number>()
|
|
watch(() => store.streamingToolCalls, (calls) => {
|
|
calls.forEach((tc, i) => {
|
|
if (!_seenCalendarToolIdx.has(i) && _calendarToolNames.has(tc.function) && tc.status === 'success') {
|
|
_seenCalendarToolIdx.add(i)
|
|
document.dispatchEvent(new CustomEvent('fable:calendar-changed'))
|
|
}
|
|
})
|
|
}, { deep: true })
|
|
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
|
|
|
|
// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
|
|
const projects = ref<{ id: number; title: string }[]>([])
|
|
const scopeDropdownOpen = ref(false)
|
|
const scopePulse = ref(false)
|
|
|
|
const showScopeChip = computed(
|
|
() => props.variant === 'full' && !props.briefingMode && !props.projectId
|
|
)
|
|
|
|
const scopeLabel = computed(() => {
|
|
const id = store.ragProjectId
|
|
if (id === -1) return 'All notes'
|
|
if (id === null) return 'Orphan notes'
|
|
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
|
|
})
|
|
|
|
async function loadProjects() {
|
|
try {
|
|
const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
|
|
projects.value = data.projects ?? []
|
|
} catch {
|
|
projects.value = []
|
|
}
|
|
}
|
|
|
|
async function onScopeSelect(value: number | null) {
|
|
scopeDropdownOpen.value = false
|
|
const convId = store.currentConversation?.id
|
|
if (!convId) return
|
|
await store.updateRagScope(convId, value)
|
|
scopePulse.value = true
|
|
setTimeout(() => { scopePulse.value = false }, 600)
|
|
}
|
|
|
|
// Pulse when model-driven scope change fires
|
|
watch(() => store.ragProjectId, () => {
|
|
if (!showScopeChip.value) return
|
|
scopePulse.value = true
|
|
setTimeout(() => { scopePulse.value = false }, 600)
|
|
})
|
|
|
|
// ── Note context (full variant — included / suggested / auto-injected notes) ──
|
|
const includedNoteIds = ref<Set<number>>(new Set())
|
|
const includedNotes = ref<{ id: number; title: string }[]>([])
|
|
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
|
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
|
const excludedNoteIds = ref<number[]>([])
|
|
|
|
watch(
|
|
() => store.lastContextMeta,
|
|
(meta) => {
|
|
if (!meta || props.variant !== 'full') return
|
|
const alreadyIncluded = includedNoteIds.value
|
|
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id))
|
|
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id))
|
|
|
|
for (const note of meta.auto_injected_notes ?? []) {
|
|
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
|
autoInjectedNotes.value.push(note)
|
|
alreadyAutoInjected.add(note.id)
|
|
}
|
|
}
|
|
for (const note of meta.auto_notes ?? []) {
|
|
if (note.auto_injected) {
|
|
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
|
autoInjectedNotes.value.push(note)
|
|
alreadyAutoInjected.add(note.id)
|
|
}
|
|
} else {
|
|
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
|
|
suggestedNotes.value.push(note)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
function includeNote(note: { id: number; title: string }) {
|
|
if (includedNoteIds.value.has(note.id)) return
|
|
includedNoteIds.value = new Set([...includedNoteIds.value, note.id])
|
|
includedNotes.value.push(note)
|
|
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id)
|
|
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id)
|
|
}
|
|
|
|
function excludeAutoNote(noteId: number) {
|
|
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId)
|
|
if (!excludedNoteIds.value.includes(noteId)) {
|
|
excludedNoteIds.value = [...excludedNoteIds.value, noteId]
|
|
}
|
|
}
|
|
|
|
function removeIncludedNote(noteId: number) {
|
|
includedNoteIds.value = new Set([...includedNoteIds.value].filter((id) => id !== noteId))
|
|
const removed = includedNotes.value.find((n) => n.id === noteId)
|
|
includedNotes.value = includedNotes.value.filter((n) => n.id !== noteId)
|
|
if (removed && !suggestedNotes.value.some((n) => n.id === noteId)) {
|
|
suggestedNotes.value.push(removed)
|
|
}
|
|
}
|
|
|
|
const hasContextSidebar = computed(
|
|
() => props.variant === 'full' && !props.briefingMode && !props.projectId &&
|
|
(autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
|
|
)
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────────────────
|
|
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
|
|
|
|
async function onSubmit(payload: { content: string; contextNoteId?: number }) {
|
|
if (props.variant === 'widget') {
|
|
await widgetSend(payload)
|
|
return
|
|
}
|
|
if (!store.currentConversation) return
|
|
await store.sendMessage(
|
|
payload.content,
|
|
payload.contextNoteId,
|
|
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
|
true,
|
|
undefined,
|
|
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
|
props.projectId ?? store.ragProjectId,
|
|
props.projectId ?? undefined,
|
|
)
|
|
scrollToBottom()
|
|
nextTick(() => inputBarRef.value?.focus())
|
|
}
|
|
|
|
// ── Widget state ──────────────────────────────────────────────────────────────
|
|
const widgetConvId = ref<number | null>(null)
|
|
const widgetQuery = ref('')
|
|
const widgetDone = ref(false)
|
|
const widgetFinalContent = ref('')
|
|
const widgetFinalToolCalls = ref<ToolCallRecord[]>([])
|
|
|
|
const isConversational = computed(
|
|
() => widgetDone.value && widgetFinalToolCalls.value.length === 0
|
|
)
|
|
|
|
function clearWidget() {
|
|
widgetConvId.value = null
|
|
widgetDone.value = false
|
|
widgetQuery.value = ''
|
|
widgetFinalContent.value = ''
|
|
widgetFinalToolCalls.value = []
|
|
}
|
|
|
|
async function widgetSend(payload: { content: string; contextNoteId?: number }) {
|
|
widgetConvId.value = null
|
|
widgetDone.value = false
|
|
widgetFinalContent.value = ''
|
|
widgetFinalToolCalls.value = []
|
|
widgetQuery.value = payload.content
|
|
|
|
const conv = await store.createConversation()
|
|
await store.fetchConversation(conv.id)
|
|
widgetConvId.value = conv.id
|
|
emit('conversation-started', conv.id)
|
|
|
|
await store.sendMessage(payload.content, payload.contextNoteId, undefined, true)
|
|
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
|
|
widgetFinalContent.value = lastAssistant?.content ?? ''
|
|
widgetFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[]
|
|
widgetDone.value = true
|
|
}
|
|
|
|
// ── Save as note ──────────────────────────────────────────────────────────────
|
|
async function handleSaveAsNote(messageId: number) {
|
|
try {
|
|
await store.saveMessageAsNote(messageId)
|
|
const { useToastStore } = await import('@/stores/toast')
|
|
useToastStore().show('Saved as note')
|
|
} catch {
|
|
const { useToastStore } = await import('@/stores/toast')
|
|
useToastStore().show('Failed to save as note', 'error')
|
|
}
|
|
}
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
onMounted(async () => {
|
|
if (showScopeChip.value) await loadProjects()
|
|
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
|
|
})
|
|
|
|
// ── Exposed ───────────────────────────────────────────────────────────────────
|
|
function focus() {
|
|
inputBarRef.value?.focus()
|
|
}
|
|
|
|
function prefill(text: string) {
|
|
inputBarRef.value?.prefill(text)
|
|
}
|
|
|
|
async function send(text: string) {
|
|
await onSubmit({ content: text })
|
|
}
|
|
|
|
defineExpose({ focus, prefill, send })
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
|
|
<template v-if="variant === 'full'">
|
|
<div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
|
|
<!-- Message list -->
|
|
<div ref="messagesEl" class="messages-container">
|
|
<div class="messages-inner">
|
|
<template
|
|
v-for="(msg, index) in store.currentConversation?.messages ?? []"
|
|
:key="msg.id"
|
|
>
|
|
<!-- Briefing slot separator: shown before a 2nd+ briefing slot message -->
|
|
<div
|
|
v-if="briefingMode && index > 0 && msg.role === 'assistant' && msg.metadata?.rss_item_ids"
|
|
class="briefing-slot-separator"
|
|
>
|
|
<span>New Briefing Update</span>
|
|
</div>
|
|
<ChatMessage
|
|
:message="msg"
|
|
@save-as-note="handleSaveAsNote"
|
|
/>
|
|
</template>
|
|
<!-- Streaming bubble -->
|
|
<ChatStreamingBubble v-if="store.streaming" />
|
|
<!-- Queued messages -->
|
|
<template v-if="store.queuedMessages.length">
|
|
<div
|
|
v-for="(q, i) in store.queuedMessages"
|
|
:key="`queued-${i}`"
|
|
class="chat-message role-user queued-message"
|
|
>
|
|
<div class="message-bubble queued-bubble">
|
|
<div class="queued-badge">Queued</div>
|
|
<div class="message-content">{{ q.content }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="queued-clear-row">
|
|
<button class="queued-clear-btn" @click="store.clearQueue()">
|
|
Cancel {{ store.queuedMessages.length }} queued
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<p
|
|
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
|
class="empty-msg"
|
|
>Send a message to start the conversation.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context sidebar (full, non-briefing, non-workspace) -->
|
|
<aside v-if="hasContextSidebar" class="context-sidebar">
|
|
<template v-if="autoInjectedNotes.length">
|
|
<div class="context-sidebar-header">Auto-included</div>
|
|
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<span
|
|
v-if="note.score != null"
|
|
class="context-note-score"
|
|
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
|
>{{ Math.round(note.score * 100) }}%</span>
|
|
<button class="context-note-remove" @click="excludeAutoNote(note.id)">×</button>
|
|
</div>
|
|
</template>
|
|
<template v-if="suggestedNotes.length">
|
|
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
|
|
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<span
|
|
v-if="note.score != null"
|
|
class="context-note-score"
|
|
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
|
>{{ Math.round(note.score * 100) }}%</span>
|
|
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
|
</div>
|
|
</template>
|
|
<template v-if="includedNotes.length">
|
|
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
|
|
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<button class="context-note-remove" @click="removeIncludedNote(note.id)">×</button>
|
|
</div>
|
|
</template>
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- Input area (hidden when readOnly) -->
|
|
<div v-if="!readOnly" class="input-wrapper">
|
|
<!-- Scope chip -->
|
|
<div v-if="showScopeChip" class="scope-chip-row">
|
|
<div class="scope-chip-wrapper">
|
|
<button
|
|
class="scope-chip"
|
|
:class="{ pulse: scopePulse }"
|
|
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
|
title="Change RAG scope"
|
|
>
|
|
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
|
</button>
|
|
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
|
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
|
|
<button
|
|
v-for="p in projects"
|
|
:key="p.id"
|
|
class="scope-option"
|
|
:class="{ active: store.ragProjectId === p.id }"
|
|
@click="onScopeSelect(p.id)"
|
|
>{{ p.title }}</button>
|
|
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Listen / volume controls -->
|
|
<div v-if="voiceTtsEnabled" class="voice-controls">
|
|
<div class="volume-wrapper">
|
|
<button
|
|
class="btn-voice"
|
|
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
|
|
@click="toggleListen"
|
|
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
|
aria-label="Toggle listen mode"
|
|
>
|
|
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
</svg>
|
|
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="btn-voice btn-volume-icon"
|
|
@click="showVolumeSlider = !showVolumeSlider"
|
|
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
|
|
aria-label="Volume"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
|
</svg>
|
|
</button>
|
|
<div v-if="showVolumeSlider" class="volume-popup">
|
|
<input
|
|
type="range" min="0" max="1" step="0.05"
|
|
:value="audio.volume.value"
|
|
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
|
class="volume-range"
|
|
aria-label="Volume"
|
|
/>
|
|
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="tts.speaking.value"
|
|
class="btn-voice"
|
|
@click="tts.stop()"
|
|
title="Stop playback"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Unified input bar -->
|
|
<ChatInputBar
|
|
ref="inputBarRef"
|
|
:placeholder="placeholder"
|
|
:briefing-mode="briefingMode"
|
|
@submit="onSubmit"
|
|
@abort="store.cancelGeneration()"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ══════════════════════════════ WIDGET VARIANT ══════════════════════════════ -->
|
|
<template v-else>
|
|
<ChatInputBar
|
|
ref="inputBarRef"
|
|
pill
|
|
:placeholder="placeholder ?? 'Start a new chat… (Enter to send)'"
|
|
:briefing-mode="false"
|
|
@submit="onSubmit"
|
|
@abort="store.cancelGeneration()"
|
|
/>
|
|
|
|
<!-- Inline response area -->
|
|
<div v-if="widgetConvId" class="widget-response">
|
|
<div class="widget-query">{{ widgetQuery }}</div>
|
|
|
|
<!-- Tool calls -->
|
|
<div v-if="store.streaming && store.streamingToolCalls.length" class="widget-tool-calls">
|
|
<ToolCallCard v-for="(tc, i) in store.streamingToolCalls" :key="i" :tool-call="tc" />
|
|
</div>
|
|
<div v-else-if="widgetDone && widgetFinalToolCalls.length" class="widget-tool-calls">
|
|
<ToolCallCard v-for="(tc, i) in widgetFinalToolCalls" :key="i" :tool-call="tc" />
|
|
</div>
|
|
|
|
<!-- Streaming / final text -->
|
|
<div v-if="store.streaming" class="widget-text streaming">
|
|
<div v-if="store.streamingStatus && !store.streamingContent" class="widget-status">
|
|
<span class="widget-status-dot"></span>{{ store.streamingStatus }}
|
|
</div>
|
|
<span v-else-if="store.streamingContent">{{ store.streamingContent }}</span>
|
|
<span v-else class="thinking-dots">...</span>
|
|
</div>
|
|
<div v-else-if="widgetDone && widgetFinalContent" class="widget-text">
|
|
{{ widgetFinalContent }}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="widget-actions" :class="{ conversational: isConversational }">
|
|
<router-link
|
|
:to="`/chat/${widgetConvId}`"
|
|
class="btn-open-chat"
|
|
:class="{ prominent: isConversational }"
|
|
>{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
|
|
<button class="btn-clear-response" @click="clearWidget">Clear</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Full variant layout ── */
|
|
.chat-body {
|
|
flex: 1;
|
|
display: flex;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.messages-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
.messages-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
/* Briefing slot separator */
|
|
.briefing-slot-separator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin: 1.25rem 0 0.5rem;
|
|
color: var(--color-text-muted, #888);
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
opacity: 0.7;
|
|
}
|
|
.briefing-slot-separator::before,
|
|
.briefing-slot-separator::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1px;
|
|
background: var(--color-border, #333);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Context sidebar */
|
|
.context-sidebar {
|
|
width: 200px;
|
|
min-width: 160px;
|
|
max-width: 220px;
|
|
border-left: 1px solid var(--color-border);
|
|
padding: 0.75rem 0.5rem;
|
|
overflow-y: auto;
|
|
background: var(--color-bg-secondary);
|
|
font-size: 0.78rem;
|
|
}
|
|
.context-sidebar-header {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
|
.context-note {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.context-note-name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
font-size: 0.78rem;
|
|
}
|
|
.context-note-name:hover { color: var(--color-primary); }
|
|
.context-note-score {
|
|
font-size: 0.65rem;
|
|
padding: 0.05rem 0.25rem;
|
|
border-radius: 4px;
|
|
background: var(--color-bg);
|
|
}
|
|
.score-high { color: #22c55e; }
|
|
.score-medium { color: #f59e0b; }
|
|
.score-low { color: var(--color-text-muted); }
|
|
.context-note-remove, .context-note-add {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
line-height: 1;
|
|
padding: 0 0.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.context-note-remove:hover { color: #ef4444; }
|
|
.context-note-add:hover { color: var(--color-primary); }
|
|
|
|
/* Input wrapper */
|
|
.input-wrapper {
|
|
border-top: 1px solid var(--color-border);
|
|
padding: 0.5rem 1rem 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* Scope chip */
|
|
.scope-chip-row { display: flex; }
|
|
.scope-chip-wrapper { position: relative; }
|
|
.scope-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 20px;
|
|
padding: 0.2rem 0.65rem;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
|
@keyframes pulse-chip {
|
|
0%, 100% { border-color: var(--color-border); }
|
|
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
|
|
}
|
|
.scope-dot { font-size: 0.6rem; }
|
|
.scope-dropdown {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
min-width: 180px;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
z-index: 20;
|
|
overflow: hidden;
|
|
}
|
|
.scope-option {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.45rem 0.75rem;
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
}
|
|
.scope-option:hover { background: var(--color-bg-secondary); }
|
|
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
|
|
|
/* Voice controls */
|
|
.voice-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
}
|
|
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
|
|
.btn-voice {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
opacity: 0.65;
|
|
padding: 0.2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
border-radius: var(--radius-sm);
|
|
transition: opacity 0.15s, color 0.15s;
|
|
}
|
|
.btn-voice:hover { opacity: 1; }
|
|
.btn-voice--active { opacity: 1; color: var(--color-primary); }
|
|
.btn-voice--busy { opacity: 1; color: #f59e0b; }
|
|
.volume-popup {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 0;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.5rem 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
z-index: 20;
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
}
|
|
.volume-range { width: 80px; }
|
|
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
|
|
|
/* Queued messages */
|
|
.queued-message { opacity: 0.45; }
|
|
.queued-bubble {
|
|
background: var(--color-bg-secondary);
|
|
border: 1px dashed var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.queued-badge {
|
|
font-size: 0.65rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
font-weight: 700;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.queued-clear-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 0.25rem 0;
|
|
}
|
|
.queued-clear-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.empty-msg {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
text-align: center;
|
|
padding: 2rem 1rem;
|
|
}
|
|
|
|
/* ── Widget variant ── */
|
|
.widget-response {
|
|
margin-top: 0.75rem;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 0.75rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.widget-query {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
font-style: italic;
|
|
}
|
|
.widget-text {
|
|
font-size: 0.9rem;
|
|
line-height: 1.55;
|
|
color: var(--color-text);
|
|
}
|
|
.widget-text.streaming { color: var(--color-text-muted); }
|
|
.thinking-dots { color: var(--color-text-muted); letter-spacing: 0.2em; }
|
|
.widget-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.widget-status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
@keyframes blink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
.widget-tool-calls { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.widget-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding-top: 0.25rem;
|
|
border-top: 1px solid var(--color-border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn-open-chat {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
}
|
|
.btn-open-chat:hover { color: var(--color-primary); }
|
|
.btn-open-chat.prominent {
|
|
font-size: 0.9rem;
|
|
color: var(--color-primary);
|
|
font-weight: 600;
|
|
}
|
|
.btn-clear-response {
|
|
background: none;
|
|
border: none;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
margin-left: auto;
|
|
}
|
|
.btn-clear-response:hover { color: var(--color-text); }
|
|
|
|
/* Streaming bubble styles — applied via :deep to ChatStreamingBubble children */
|
|
:deep(.streaming-bubble) {
|
|
max-width: 85%;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 16px 16px 16px 4px;
|
|
background: var(--color-bg-card);
|
|
border-left: 2px solid var(--color-primary);
|
|
box-shadow: 0 1px 4px var(--color-shadow);
|
|
}
|
|
:deep(.streaming-tool-calls) { margin-bottom: 0.5rem; }
|
|
:deep(.streaming-status-line) {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
:deep(.streaming-status-dot) {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
:deep(.thinking-block) {
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
}
|
|
:deep(.thinking-summary) {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
list-style: none;
|
|
background: var(--color-bg-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
:deep(.thinking-summary::-webkit-details-marker) { display: none; }
|
|
:deep(.thinking-text) {
|
|
margin: 0;
|
|
padding: 0.5rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
background: var(--color-bg-secondary);
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
:deep(.typing-indicator) {
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
margin-left: 4px;
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|