feat: migrate KnowledgeView mini-chat to ChatPanel + enable note picker everywhere
- KnowledgeView: replace custom mini-chat (voice, PTT, manual scroll, message rendering) with ChatPanel variant="full"; gains RAG scope chip, TTS listen mode, volume control, and note picker automatically (-218 lines) - ChatInputBar: remove briefingMode guard on note picker so attach/search works in briefing, workspace, widget, and knowledge chat surfaces Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,7 @@ defineExpose({ focus, prefill })
|
||||
|
||||
<div class="input-row">
|
||||
<!-- Note picker -->
|
||||
<div v-if="!briefingMode" class="note-picker-wrapper">
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-icon"
|
||||
@click="toggleNotePicker"
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, transcribeAudio } from "@/api/client";
|
||||
import { apiGet, apiPatch } from "@/api/client";
|
||||
import { fmtCompact } from "@/utils/dateFormat";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import ChatInputBar from "@/components/ChatInputBar.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -154,58 +152,20 @@ function toggleGraphExpand() {
|
||||
|
||||
// ─── Mini-chat widget ─────────────────────────────────────────────────────────
|
||||
|
||||
const chatInput = ref("");
|
||||
const chatOpen = ref(false);
|
||||
const chatCollapsed = ref(false);
|
||||
const chatConvId = ref<number | null>(null);
|
||||
const chatSending = ref(false);
|
||||
const chatMessages = computed(() => {
|
||||
if (!chatConvId.value) return [];
|
||||
const conv = chatStore.currentConversation;
|
||||
if (!conv || conv.id !== chatConvId.value) return [];
|
||||
return conv.messages ?? [];
|
||||
});
|
||||
const chatScrollEl = ref<HTMLElement | null>(null);
|
||||
const chatInputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
|
||||
const transcribingVoice = ref(false);
|
||||
const recorder = useVoiceRecorder();
|
||||
|
||||
async function sendChatMessage() {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text || chatSending.value || chatStore.streaming) return;
|
||||
|
||||
chatOpen.value = true;
|
||||
|
||||
// Create a conversation on first message
|
||||
async function onMinichatSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
if (!chatConvId.value) {
|
||||
try {
|
||||
const conv = await chatStore.createConversation("Quick chat");
|
||||
chatConvId.value = conv.id;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
} catch { return; }
|
||||
const conv = await chatStore.createConversation("Knowledge chat");
|
||||
chatConvId.value = conv.id;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
} else if (chatStore.currentConversation?.id !== chatConvId.value) {
|
||||
await chatStore.fetchConversation(chatConvId.value);
|
||||
}
|
||||
|
||||
chatInput.value = "";
|
||||
chatSending.value = true;
|
||||
try {
|
||||
await chatStore.sendMessage(text);
|
||||
await nextTick();
|
||||
scrollChatToBottom();
|
||||
} catch { /* silent */ } finally {
|
||||
chatSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollChatToBottom() {
|
||||
nextTick(() => {
|
||||
if (chatScrollEl.value) {
|
||||
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
chatOpen.value = true;
|
||||
chatCollapsed.value = false;
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, true);
|
||||
}
|
||||
|
||||
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
|
||||
@@ -232,50 +192,12 @@ watch(
|
||||
);
|
||||
|
||||
watch(() => chatStore.streaming, (streaming) => {
|
||||
if (!streaming) {
|
||||
scrollChatToBottom();
|
||||
processedToolCalls.value = 0;
|
||||
}
|
||||
if (!streaming) processedToolCalls.value = 0;
|
||||
});
|
||||
|
||||
async function closeChat() {
|
||||
function closeChat() {
|
||||
chatOpen.value = false;
|
||||
chatConvId.value = null;
|
||||
chatInput.value = "";
|
||||
}
|
||||
|
||||
function onChatKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
}
|
||||
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || recorder.recording.value) return;
|
||||
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()) {
|
||||
chatInput.value = transcript.trim();
|
||||
await sendChatMessage();
|
||||
}
|
||||
} catch { /* silent */ } finally {
|
||||
transcribingVoice.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function chatAutoResize() {
|
||||
const el = chatInputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}
|
||||
|
||||
// ─── List item toggle ─────────────────────────────────────────────────────────
|
||||
@@ -357,10 +279,7 @@ onMounted(() => {
|
||||
fetchItems(true);
|
||||
fetchTags();
|
||||
fetchTodayBar();
|
||||
nextTick(() => {
|
||||
chatInputEl.value?.focus();
|
||||
setupScrollObserver();
|
||||
});
|
||||
nextTick(() => setupScrollObserver());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -571,81 +490,36 @@ watchEffect(() => {
|
||||
|
||||
<!-- Floating mini-chat -->
|
||||
<div class="minichat" :class="{ 'minichat--open': chatOpen }">
|
||||
<!-- Message history (shown when chatOpen and not collapsed) -->
|
||||
<div v-if="chatOpen && !chatCollapsed" class="minichat-messages" ref="chatScrollEl">
|
||||
<template v-if="chatMessages.length === 0 && !chatStore.streaming">
|
||||
<div class="minichat-welcome">Ask me anything about your notes, tasks, or ideas.</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="msg in chatMessages"
|
||||
:key="msg.id"
|
||||
class="minichat-msg"
|
||||
:class="msg.role === 'user' ? 'msg--user' : 'msg--assistant'"
|
||||
>
|
||||
<div class="msg-content" v-html="msg.role === 'assistant' ? renderMarkdown(msg.content) : msg.content"></div>
|
||||
</div>
|
||||
<div v-if="chatStore.streaming" class="minichat-msg msg--assistant">
|
||||
<div class="msg-content streaming-indicator">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
</div>
|
||||
<!-- Header with controls — only when chat is open -->
|
||||
<div v-if="chatOpen" class="minichat-header">
|
||||
<span class="minichat-title">Chat</span>
|
||||
<div class="minichat-header-actions">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
@click="chatCollapsed = !chatCollapsed"
|
||||
:title="chatCollapsed ? 'Expand' : 'Collapse'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
|
||||
<polyline v-else points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="closeChat" title="Close chat">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input bar — matches shared chat-input-bar pattern -->
|
||||
<div class="minichat-input-row">
|
||||
<div class="chat-input-bar">
|
||||
<textarea
|
||||
ref="chatInputEl"
|
||||
v-model="chatInput"
|
||||
@keydown="onChatKeydown"
|
||||
@input="chatAutoResize"
|
||||
placeholder="Ask anything…"
|
||||
:disabled="chatStore.streaming"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
v-if="voiceEnabled && recorder.isSupported"
|
||||
class="btn-attach btn-mic-ptt"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
:disabled="transcribingVoice"
|
||||
title="Hold to speak"
|
||||
aria-label="Push to talk"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-send"
|
||||
:disabled="!chatInput.trim() || chatSending || chatStore.streaming"
|
||||
@click="sendChatMessage"
|
||||
title="Send"
|
||||
>↑</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="chatOpen"
|
||||
class="btn-close-chat"
|
||||
@click="chatCollapsed = !chatCollapsed"
|
||||
:title="chatCollapsed ? 'Expand chat' : 'Collapse chat'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
|
||||
<polyline v-else points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="chatOpen"
|
||||
class="btn-close-chat"
|
||||
@click="closeChat"
|
||||
title="Close chat"
|
||||
>✕</button>
|
||||
<!-- Full ChatPanel — when open, not collapsed, conversation exists -->
|
||||
<div v-if="chatOpen && !chatCollapsed && chatConvId" class="minichat-chat-panel">
|
||||
<ChatPanel variant="full" placeholder="Ask anything…" />
|
||||
</div>
|
||||
|
||||
<!-- Standalone input bar — when closed or collapsed -->
|
||||
<div v-if="!chatOpen || chatCollapsed" class="minichat-input-row">
|
||||
<ChatInputBar
|
||||
placeholder="Ask anything…"
|
||||
@submit="onMinichatSubmit"
|
||||
@abort="chatStore.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1083,7 +957,10 @@ watchEffect(() => {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 32px rgba(0,0,0,0.35), 0 -1px 0 rgba(255,255,255,0.05);
|
||||
transition: box-shadow 0.2s;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.minichat--open {
|
||||
height: 500px;
|
||||
}
|
||||
.knowledge-root.graph-open .minichat {
|
||||
right: 500px;
|
||||
@@ -1092,140 +969,45 @@ watchEffect(() => {
|
||||
.knowledge-root.graph-open.graph-expanded .minichat {
|
||||
right: min(960px, 60vw);
|
||||
}
|
||||
.minichat-messages {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
|
||||
/* Header row (collapse / close buttons) */
|
||||
.minichat-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 16px 16px 0 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.minichat-welcome {
|
||||
.minichat-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.minichat-msg { max-width: 85%; }
|
||||
.msg--user {
|
||||
align-self: flex-end;
|
||||
background: rgba(99,102,241,0.12);
|
||||
border: 1px solid rgba(99,102,241,0.2);
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.msg--assistant {
|
||||
align-self: flex-start;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.88rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: 0 12px 12px 2px;
|
||||
}
|
||||
.msg-content { line-height: 1.5; white-space: pre-wrap; }
|
||||
.streaming-indicator {
|
||||
.minichat-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
animation: dot-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes dot-pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ChatPanel body — fills remaining space */
|
||||
.minichat-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.minichat-chat-panel :deep(.chat-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Standalone input row (closed / collapsed state) */
|
||||
.minichat-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
/* Reuse the shared chat-input-bar pattern */
|
||||
.chat-input-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px var(--color-shadow);
|
||||
}
|
||||
.chat-input-bar textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-input-bar textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
||||
.chat-input-bar textarea:disabled { opacity: 0.5; }
|
||||
.btn-attach {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-attach:hover { opacity: 1; }
|
||||
.btn-attach:disabled { opacity: 0.3; cursor: default; }
|
||||
.btn-mic-ptt { transition: color 0.15s, opacity 0.15s; }
|
||||
.btn-mic-ptt.mic-recording { color: #ef4444 !important; opacity: 1 !important; }
|
||||
.btn-mic-ptt.mic-transcribing { color: var(--color-primary); opacity: 1 !important; }
|
||||
.btn-send {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; }
|
||||
.btn-close-chat {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
border-radius: 8px;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-close-chat:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user