diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 6f21ed2..31003cb 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -2,118 +2,15 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useChatStore } from "@/stores/chat"; -import { useSettingsStore } from "@/stores/settings"; -import { apiGet, transcribeAudio } from "@/api/client"; -import { useStreamingTts } from "@/composables/useStreamingTts"; -import { renderMarkdown } from "@/utils/markdown"; -import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; -import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio"; -import { useListenMode } from "@/composables/useListenMode"; -import ChatMessage from "@/components/ChatMessage.vue"; -import ToolCallCard from "@/components/ToolCallCard.vue"; -import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; -import type { Note } from "@/types/note"; +import ChatPanel from "@/components/ChatPanel.vue"; const route = useRoute(); const router = useRouter(); const store = useChatStore(); -const settingsStore = useSettingsStore(); -const messageInput = ref(""); -const messagesEl = ref(null); -const inputEl = ref(null); -const sending = ref(false); const summarizing = ref(false); const sidebarOpen = ref(false); -// Voice PTT — use store so status is globally reactive -const transcribingVoice = ref(false); -const recorder = useVoiceRecorder(); -const audio = useVoiceAudio(); -const voiceEnabled = computed(() => settingsStore.voiceSttReady); -const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady); -const listenMode = useListenMode(); -const showVolumeSlider = ref(false); -const tts = useStreamingTts({ - streamingContent: computed(() => store.streamingContent), - streaming: computed(() => store.streaming), - enabled: computed(() => listenMode.value && voiceTtsEnabled.value), -}); - -async function startPtt() { - if (!voiceEnabled.value || recorder.recording.value) return; - audio.stop(); - await recorder.startRecording(); -} - -async function stopPtt() { - if (!recorder.recording.value) return; - transcribingVoice.value = true; - try { - const blob = await recorder.stopRecording(); - const { transcript } = await transcribeAudio(blob); - if (transcript.trim()) { - messageInput.value = transcript.trim(); - autoResize(); - await sendMessage(); - } - } catch { /* silent */ } - finally { transcribingVoice.value = false; } -} - -// Research modal state -const showResearchModal = ref(false); -const researchTopic = ref(""); - -// Note picker state -const showNotePicker = ref(false); -const noteSearchQuery = ref(""); -const noteSearchResults = ref<{ id: number; title: string }[]>([]); -const noteSearchLoading = ref(false); -let noteSearchTimer: ReturnType | null = null; - -// Explicitly included notes (user clicked "+ include" in sidebar) -const includedNoteIds = ref>(new Set()); -const includedNotes = ref<{ id: number; title: string }[]>([]); - -// Suggested notes — auto-found by search, not yet included (score 0.45–0.59) -const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]); - -// Auto-injected notes — injected automatically because score >= 0.60 -const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]); - -// Note IDs excluded from auto-injection on next message -const excludedNoteIds = ref([]); - -// Scope chip state -const scopeDropdownOpen = ref(false); -const projects = ref<{ id: number; title: string }[]>([]); -const scopePulse = ref(false); - -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; - if (!convId.value) return; - await store.updateRagScope(convId.value, value); - scopePulse.value = true; - setTimeout(() => { scopePulse.value = false; }, 600); -} - let prevConvId: number | null = null; const convId = computed(() => { @@ -161,7 +58,6 @@ const groupedConversations = computed((): ConvGroup[] => { } else if (d >= startOfMonth) { key = "This month"; } else { - // Sub-group older by "Month Year" key = d.toLocaleDateString(undefined, { month: "long", year: "numeric" }); } if (!buckets[key]) { buckets[key] = []; order.push(key); } @@ -171,34 +67,21 @@ const groupedConversations = computed((): ConvGroup[] => { return order.map((label) => ({ label, convs: buckets[label] })); }); -const streamingRendered = computed(() => { - if (!store.streamingContent) return ""; - return renderMarkdown(store.streamingContent); -}); - -const inputPlaceholder = computed(() => { - if (!store.chatReady) return "Chat unavailable"; - if (store.streaming) return "Type to queue next message… (Enter to queue)"; - return "Type a message… (Enter to send, Shift+Enter for new line)"; -}); - onMounted(async () => { document.addEventListener("keydown", onGlobalKeydown); - await Promise.all([store.fetchConversations(), loadProjects()]); + await store.fetchConversations(); if (convId.value) { if (store.currentConversation?.id !== convId.value) { await store.fetchConversation(convId.value); } - // Reconnect if the last assistant message is still generating (e.g. after page refresh) store.reconnectIfGenerating(convId.value); } else { await startNewConversation(); } - nextTick(() => inputEl.value?.focus()); + nextTick(() => chatPanelRef.value?.focus()); }); watch(convId, async (newId) => { - // Clean up empty previous conversation if (prevConvId && prevConvId !== newId) { const prev = store.conversations.find((c) => c.id === prevConvId); if (prev && prev.message_count === 0) { @@ -207,74 +90,17 @@ watch(convId, async (newId) => { } prevConvId = newId ?? null; - includedNoteIds.value = new Set(); - includedNotes.value = []; - suggestedNotes.value = []; - autoInjectedNotes.value = []; - excludedNoteIds.value = []; if (newId) { - // Skip re-fetch if this conversation is already loaded or still streaming - // (avoids a stale GET overwriting in-progress messages) if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) { await store.fetchConversation(newId); } - // Reconnect if a prior stream died while the backend was still generating store.reconnectIfGenerating(newId); - scrollToBottom(); } else { await startNewConversation(); } - nextTick(() => inputEl.value?.focus()); + nextTick(() => chatPanelRef.value?.focus()); }); -watch( - () => store.streamingContent, - () => scrollToBottom() -); - -watch( - () => store.lastContextMeta, - (meta) => { - if (!meta) 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)); - - // Process auto_injected_notes (explicitly marked as injected) - const injectedFromMeta = meta.auto_injected_notes ?? []; - for (const note of injectedFromMeta) { - if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) { - autoInjectedNotes.value.push(note); - alreadyAutoInjected.add(note.id); - } - } - - // Process auto_notes — auto_injected flag separates injected from suggested - const autoNotes = meta.auto_notes ?? []; - for (const note of autoNotes) { - 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 scrollToBottom() { - nextTick(() => { - if (messagesEl.value) { - messagesEl.value.scrollTop = messagesEl.value.scrollHeight; - } - }); -} - function toggleSidebar() { sidebarOpen.value = !sidebarOpen.value; } @@ -287,14 +113,13 @@ async function selectConversation(id: number) { async function startNewConversation() { const conv = await store.createConversation(); router.push(`/chat/${conv.id}`); - // watch(convId) will fire and fetch the conversation } async function newConversation() { await startNewConversation(); } -// Bulk selection state +// ── Bulk selection ──────────────────────────────────────────────────────────── const selectMode = ref(false); const selectedIds = ref>(new Set()); const bulkConfirm = ref(false); @@ -312,7 +137,7 @@ function toggleSelectConv(id: number) { } else { selectedIds.value.add(id); } - selectedIds.value = new Set(selectedIds.value); // trigger reactivity + selectedIds.value = new Set(selectedIds.value); } function selectAll() { @@ -346,50 +171,7 @@ async function removeConversation(id: number) { } } -async function sendMessage() { - const content = messageInput.value.trim(); - if (!content) return; - - // Auto-create conversation if none selected - if (!store.currentConversation) { - const conv = await store.createConversation(); - await store.fetchConversation(conv.id); - router.push(`/chat/${conv.id}`); - } - - sending.value = true; - messageInput.value = ""; - resetTextareaHeight(); - scrollToBottom(); - - await store.sendMessage( - content, - undefined, - includedNoteIds.value.size ? [...includedNoteIds.value] : undefined, - true, // enable thinking in the full chat view - undefined, - excludedNoteIds.value.length ? excludedNoteIds.value : undefined, - store.ragProjectId, - ); - sending.value = false; - - // Refresh conversation list to show updated title/timestamps - store.fetchConversations(); - scrollToBottom(); - nextTick(() => inputEl.value?.focus()); -} - -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"); - } -} - +// ── Summarize ───────────────────────────────────────────────────────────────── async function handleSummarize() { if (!store.currentConversation || summarizing.value) return; summarizing.value = true; @@ -405,27 +187,11 @@ async function handleSummarize() { } } -function autoResize() { - const el = inputEl.value; - if (!el) return; - el.style.height = "auto"; - el.style.height = Math.min(el.scrollHeight, 150) + "px"; -} +// ── Research modal ──────────────────────────────────────────────────────────── +const showResearchModal = ref(false); +const researchTopic = ref(""); +const chatPanelRef = ref | null>(null); -function resetTextareaHeight() { - const el = inputEl.value; - if (!el) return; - el.style.height = "auto"; -} - -function onInputKeydown(e: KeyboardEvent) { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } -} - -// Research modal function toggleResearchModal() { showResearchModal.value = !showResearchModal.value; if (showResearchModal.value) { @@ -440,107 +206,28 @@ function toggleResearchModal() { function submitResearch() { const topic = researchTopic.value.trim(); if (!topic) return; - messageInput.value = `Research: ${topic}`; showResearchModal.value = false; researchTopic.value = ""; - sendMessage(); + // Prefill sends via ChatPanel — user sees it in the input, then it auto-submits + chatPanelRef.value?.send(`Research: ${topic}`); } -// Note picker -function toggleNotePicker() { - showNotePicker.value = !showNotePicker.value; - if (showNotePicker.value) { - noteSearchQuery.value = ""; - noteSearchResults.value = []; - nextTick(() => { - const el = document.querySelector(".note-picker-search") as HTMLInputElement; - el?.focus(); - }); - } -} - -function onNoteSearchInput() { - if (noteSearchTimer) clearTimeout(noteSearchTimer); - noteSearchTimer = setTimeout(async () => { - const q = noteSearchQuery.value.trim(); - if (!q) { - noteSearchResults.value = []; - return; - } - noteSearchLoading.value = true; - try { - const data = await apiGet<{ notes: Note[] }>( - `/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5` - ); - noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title })); - } catch { - noteSearchResults.value = []; - } finally { - noteSearchLoading.value = false; - } - }, 250); -} - -function selectNote(note: { id: number; title: string }) { - includeNote(note); - showNotePicker.value = false; -} - -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); - } -} - -// Keyboard shortcuts +// ── Keyboard ────────────────────────────────────────────────────────────────── function onGlobalKeydown(e: KeyboardEvent) { if (e.key !== "Escape") return; - // Close research modal first if (showResearchModal.value) { showResearchModal.value = false; return; } - // Close note picker - if (showNotePicker.value) { - showNotePicker.value = false; - return; - } - // Close mobile sidebar overlay if (sidebarOpen.value) { sidebarOpen.value = false; return; } - // If textarea is focused and has content, clear it (don't navigate away) - if (document.activeElement === inputEl.value && messageInput.value.trim()) { - messageInput.value = ""; - resetTextareaHeight(); - return; - } - // Navigate home router.push("/"); } onUnmounted(() => { document.removeEventListener("keydown", onGlobalKeydown); - // Clean up empty conversation when navigating away from the chat view entirely if (prevConvId) { const conv = store.conversations.find((c) => c.id === prevConvId); if (conv && conv.message_count === 0) { @@ -568,7 +255,6 @@ onUnmounted(() => { >Select -
{{ selectedIds.size }} selected @@ -632,363 +318,53 @@ onUnmounted(() => {

{{ store.currentConversation.title || "New Chat" }}

- + + +
+ +
+
Research topic
+ +
+ + +
+
+
+ + >{{ summarizing ? "Summarizing..." : "Summarize as Note" }}
-
-
-
- - - -
-
-
- {{ settingsStore.assistantName }} -
-
- -
- -
- - {{ store.streamingStatus }} -
-
- Reasoning -
{{ store.streamingThinking }}
-
-
- -
-
- - - - -

- Send a message to start the conversation. -

-
-
- - - -
- -
- -
-
- -
- - - -
-
-
- -
- -
- - - -
-
Research topic
- -
- - -
-
-
- - -
- - - -
- -
-
- {{ note.title || "Untitled" }} -
-
Searching...
-
- No notes found -
-
-
-
- - - - - - - - - - -
-
+
-