first implementation

This commit is contained in:
Adrien
2026-03-31 20:58:47 +02:00
parent dc0bcab36e
commit 618e28b354
1878 changed files with 1381732 additions and 5 deletions
+383
View File
@@ -0,0 +1,383 @@
<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 Setup (no active session) -->
<div v-if="!chatStore.session" class="session-setup card">
<h2 class="section-title">Start a New Chat</h2>
<div class="form-group">
<label class="form-label">Topic (optional)</label>
<select v-model="selectedTopicId" class="form-select">
<option value="">Free-form chat (any neurosurgery question)</option>
<option
v-for="topic in topicStore.topics"
:key="topic.id"
:value="topic.id"
>
{{ topic.name }} ({{ topic.category }})
</option>
</select>
</div>
<div v-if="chatStore.error" class="error-banner">
{{ chatStore.error }}
</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>
<!-- Active Session -->
<div v-else class="chat-layout">
<!-- Session Info Bar -->
<div class="session-bar card">
<div class="session-info">
<span class="session-label">Session</span>
<span class="session-id">{{ chatStore.session.sessionId.slice(0, 8) }}...</span>
<span v-if="chatStore.session.topicId" class="session-topic">
Topic: {{ getTopicName(chatStore.session.topicId) }}
</span>
<span v-else class="session-topic">Free-form chat</span>
</div>
<button
class="btn btn-danger"
:disabled="chatStore.loading"
@click="handleClearConversation"
>
Clear Conversation
</button>
</div>
<!-- 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"
/>
<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>
</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 ChatMessage from '@/components/ChatMessage.vue'
const chatStore = useChatStore()
const topicStore = useTopicStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const selectedTopicId = ref('')
const inputText = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
onMounted(async () => {
if (topicStore.topics.length === 0) {
await topicStore.fetchTopics()
}
})
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): string {
const topic = topicStore.topics.find((t) => t.id === topicId)
return topic ? topic.name : topicId
}
async function handleNewChat() {
const topicId = selectedTopicId.value || undefined
const ok = await chatStore.createSession(topicId)
if (!ok) {
showToast?.(chatStore.error ?? 'Could not start session.', 'error')
}
}
async function handleClearConversation() {
const ok = await chatStore.deleteSession()
if (!ok) {
showToast?.(chatStore.error ?? 'Could not clear conversation.', 'error')
} else {
selectedTopicId.value = ''
}
}
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>
.session-setup {
max-width: 540px;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1.25rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #4a5568;
margin-bottom: 0.4rem;
}
.form-select {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.9rem;
color: #2d3748;
background: white;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: #3182ce;
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.2);
}
.chat-layout {
display: flex;
flex-direction: column;
gap: 1rem;
height: calc(100vh - 180px);
min-height: 500px;
}
.session-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.session-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.session-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #a0aec0;
}
.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;
}
</style>