511 lines
12 KiB
Vue
511 lines
12 KiB
Vue
<template>
|
|
<div class="chat-view">
|
|
<h1 class="page-title">Knowledge Chat</h1>
|
|
<p class="page-subtitle">Ask questions grounded in your uploaded medical textbooks.</p>
|
|
|
|
<!-- Session selection -->
|
|
<div v-if="!chatStore.session" class="session-setup card">
|
|
<div class="setup-header">
|
|
<h2 class="section-title">Free-form Chat</h2>
|
|
</div>
|
|
|
|
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
|
|
|
|
<div v-if="loadingTopicSessions" class="sessions-loading">
|
|
<div class="spinner spinner-dark" style="width:20px;height:20px;display:inline-block;"></div>
|
|
Loading previous chats...
|
|
</div>
|
|
|
|
<div v-else-if="topicSessions.length > 0" class="previous-sessions">
|
|
<h3 class="sessions-label">Previous Chats</h3>
|
|
<div
|
|
v-for="s in topicSessions"
|
|
:key="s.sessionId"
|
|
class="session-row"
|
|
@click="handleResumeSession(s)"
|
|
>
|
|
<span class="session-row-date">{{ formatDate(s.createdAt) }}</span>
|
|
<span class="session-row-action">Resume →</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="!loadingTopicSessions" class="no-sessions-hint">
|
|
No previous chats for this topic.
|
|
</div>
|
|
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="chatStore.loading"
|
|
@click="handleNewChat"
|
|
>
|
|
<span v-if="chatStore.loading" class="spinner"></span>
|
|
{{ chatStore.loading ? 'Starting...' : '+ New Chat' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Step 3: Active Session -->
|
|
<div v-else-if="chatStore.session" class="chat-layout">
|
|
<!-- Session Info Bar -->
|
|
<div class="session-bar card">
|
|
<div class="session-info">
|
|
<button class="btn-back" @click="handleLeaveSession">← Back</button>
|
|
<span class="session-topic">{{ getTopicName(chatStore.session.topicId) }}</span>
|
|
<span class="session-id">{{ chatStore.session.sessionId.slice(0, 8) }}...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat + Reader split -->
|
|
<div class="chat-reader-split">
|
|
<!-- Messages + Input -->
|
|
<div class="chat-column">
|
|
<!-- Messages Area -->
|
|
<div class="messages-container" ref="messagesContainer">
|
|
<div v-if="chatStore.loading && chatStore.messages.length === 0" class="empty-state">
|
|
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
|
|
<p class="empty-state-text">Loading messages...</p>
|
|
</div>
|
|
|
|
<div v-else-if="chatStore.messages.length === 0" class="empty-state">
|
|
<div class="empty-state-icon">💬</div>
|
|
<p class="empty-state-text">No messages yet</p>
|
|
<p class="empty-state-hint">Ask a question about the uploaded books below.</p>
|
|
</div>
|
|
|
|
<div v-else class="messages-list">
|
|
<ChatMessage
|
|
v-for="message in chatStore.messages"
|
|
:key="message.id"
|
|
:message="message"
|
|
@open-source="handleOpenSource"
|
|
/>
|
|
<div v-if="chatStore.sending" class="typing-indicator">
|
|
<div class="typing-bubble">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input Area -->
|
|
<div class="input-area card">
|
|
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
|
|
<div class="input-row">
|
|
<textarea
|
|
v-model="inputText"
|
|
class="message-input"
|
|
placeholder="Ask a question about your uploaded books..."
|
|
rows="2"
|
|
:disabled="chatStore.sending"
|
|
@keydown.enter.exact.prevent="handleSend"
|
|
@keydown.enter.shift.exact="inputText += '\n'"
|
|
></textarea>
|
|
<button
|
|
class="btn btn-primary send-btn"
|
|
:disabled="!inputText.trim() || chatStore.sending"
|
|
@click="handleSend"
|
|
>
|
|
<span v-if="chatStore.sending" class="spinner"></span>
|
|
<span v-else>Send</span>
|
|
</button>
|
|
</div>
|
|
<p class="input-hint">Press Enter to send, Shift+Enter for new line.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Inline book reader panel -->
|
|
<BookPagePanel
|
|
v-if="readerPanel"
|
|
:book-id="readerPanel.bookId"
|
|
:page="readerPanel.page"
|
|
:book-title="readerPanel.bookTitle"
|
|
class="reader-panel"
|
|
@close="readerPanel = null"
|
|
@navigate="(p) => readerPanel && (readerPanel.page = p)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, nextTick, onMounted, watch, inject } from 'vue'
|
|
import { useChatStore } from '@/stores/chatStore'
|
|
import { useTopicStore } from '@/stores/topicStore'
|
|
import { useBookStore } from '@/stores/bookStore'
|
|
import type { ChatSession } from '@/stores/chatStore'
|
|
import ChatMessage from '@/components/ChatMessage.vue'
|
|
import BookPagePanel from '@/components/BookPagePanel.vue'
|
|
|
|
interface Topic {
|
|
id: string
|
|
name: string
|
|
category: string
|
|
description: string
|
|
}
|
|
|
|
const chatStore = useChatStore()
|
|
const topicStore = useTopicStore()
|
|
const bookStore = useBookStore()
|
|
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
|
|
|
|
const selectedTopic = ref<Topic | null>(null)
|
|
const topicSessions = ref<ChatSession[]>([])
|
|
const loadingTopicSessions = ref(false)
|
|
const inputText = ref('')
|
|
const messagesContainer = ref<HTMLElement | null>(null)
|
|
|
|
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
|
|
const readerPanel = ref<ReaderPanel | null>(null)
|
|
|
|
function handleOpenSource(bookId: string, page: number) {
|
|
const book = bookStore.books.find(b => b.id === bookId)
|
|
readerPanel.value = { bookId, page, bookTitle: book?.title }
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (topicStore.topics.length === 0) {
|
|
await topicStore.fetchTopics()
|
|
}
|
|
const freeForm = topicStore.topics.find((t) => t.id === 'free-form')
|
|
if (freeForm) {
|
|
await handleTopicSelect(freeForm)
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => chatStore.messages.length,
|
|
async () => { await nextTick(); scrollToBottom() }
|
|
)
|
|
|
|
watch(
|
|
() => chatStore.sending,
|
|
async () => { await nextTick(); scrollToBottom() }
|
|
)
|
|
|
|
function scrollToBottom() {
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
function getTopicName(topicId: string | null): string {
|
|
if (!topicId) return 'Free-form Chat'
|
|
const topic = topicStore.topics.find((t) => t.id === topicId)
|
|
return topic ? topic.name : topicId
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleString()
|
|
}
|
|
|
|
async function handleTopicSelect(topic: Topic) {
|
|
selectedTopic.value = topic
|
|
loadingTopicSessions.value = true
|
|
topicSessions.value = await chatStore.fetchSessionsByTopic(topic.id)
|
|
loadingTopicSessions.value = false
|
|
}
|
|
|
|
async function handleNewChat() {
|
|
const ok = await chatStore.createSession(selectedTopic.value!.id)
|
|
if (!ok) {
|
|
showToast?.(chatStore.error ?? 'Could not start session.', 'error')
|
|
}
|
|
}
|
|
|
|
async function handleResumeSession(session: ChatSession) {
|
|
chatStore.session = session
|
|
await chatStore.loadMessages()
|
|
}
|
|
|
|
function handleLeaveSession() {
|
|
chatStore.leaveSession()
|
|
if (selectedTopic.value) {
|
|
loadingTopicSessions.value = true
|
|
chatStore.fetchSessionsByTopic(selectedTopic.value.id).then((sessions) => {
|
|
topicSessions.value = sessions
|
|
loadingTopicSessions.value = false
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = inputText.value.trim()
|
|
if (!content || chatStore.sending) return
|
|
inputText.value = ''
|
|
const ok = await chatStore.sendMessage(content)
|
|
if (!ok) {
|
|
showToast?.(chatStore.error ?? 'Failed to send message.', 'error')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.section-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
margin: 0;
|
|
}
|
|
|
|
.session-setup {
|
|
max-width: 540px;
|
|
}
|
|
|
|
.setup-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.btn-back {
|
|
background: none;
|
|
border: none;
|
|
color: #3182ce;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-back:hover {
|
|
color: #2b6cb0;
|
|
}
|
|
|
|
.sessions-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
color: #718096;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.previous-sessions {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.sessions-label {
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #a0aec0;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.session-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
margin-bottom: 0.4rem;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
}
|
|
|
|
.session-row:hover {
|
|
background: #ebf8ff;
|
|
border-color: #bee3f8;
|
|
}
|
|
|
|
.session-row-date {
|
|
font-size: 0.875rem;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.session-row-action {
|
|
font-size: 0.8rem;
|
|
color: #3182ce;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.no-sessions-hint {
|
|
font-size: 0.875rem;
|
|
color: #a0aec0;
|
|
font-style: italic;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.chat-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
height: calc(100vh - 180px);
|
|
min-height: 500px;
|
|
}
|
|
|
|
.chat-reader-split {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
gap: 0;
|
|
}
|
|
|
|
.chat-column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-width: 0;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.reader-panel {
|
|
width: 420px;
|
|
flex-shrink: 0;
|
|
border-radius: 10px;
|
|
margin-left: 1rem;
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.07);
|
|
}
|
|
|
|
.session-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.session-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.session-id {
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
color: #4a5568;
|
|
background: #f7fafc;
|
|
padding: 0.15rem 0.4rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.session-topic {
|
|
font-size: 0.875rem;
|
|
color: #2b6cb0;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.messages-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
background: #f7fafc;
|
|
border-radius: 10px;
|
|
padding: 1rem;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.messages-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.typing-indicator {
|
|
display: flex;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.typing-bubble {
|
|
background: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 12px;
|
|
border-bottom-left-radius: 4px;
|
|
padding: 0.75rem 1rem;
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.typing-bubble span {
|
|
width: 8px;
|
|
height: 8px;
|
|
background: #a0aec0;
|
|
border-radius: 50%;
|
|
animation: bounce 1.2s infinite ease-in-out;
|
|
}
|
|
|
|
.typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
|
|
.typing-bubble span:nth-child(3) { animation-delay: 0.3s; }
|
|
|
|
@keyframes bounce {
|
|
0%, 60%, 100% { transform: translateY(0); }
|
|
30% { transform: translateY(-6px); }
|
|
}
|
|
|
|
.input-area {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.message-input {
|
|
flex: 1;
|
|
padding: 0.65rem 0.75rem;
|
|
border: 1px solid #cbd5e0;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
font-family: inherit;
|
|
color: #2d3748;
|
|
resize: none;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.message-input:focus {
|
|
outline: none;
|
|
border-color: #3182ce;
|
|
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.2);
|
|
}
|
|
|
|
.send-btn {
|
|
flex-shrink: 0;
|
|
min-width: 72px;
|
|
height: 42px;
|
|
}
|
|
|
|
.input-hint {
|
|
font-size: 0.75rem;
|
|
color: #a0aec0;
|
|
margin-top: 0.4rem;
|
|
}
|
|
|
|
.error-banner {
|
|
background: #fff5f5;
|
|
border: 1px solid #fed7d7;
|
|
color: #742a2a;
|
|
border-radius: 6px;
|
|
padding: 0.6rem 0.85rem;
|
|
font-size: 0.875rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.chat-layout {
|
|
height: auto;
|
|
min-height: unset;
|
|
}
|
|
|
|
.chat-reader-split {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.chat-column {
|
|
min-height: 60vh;
|
|
}
|
|
|
|
.reader-panel {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
margin-top: 1rem;
|
|
box-shadow: none;
|
|
}
|
|
}
|
|
</style>
|