stable POC version 1 - chat and topics

This commit is contained in:
Adrien
2026-04-02 21:04:33 +02:00
parent 618e28b354
commit bcc80d250b
74 changed files with 11692 additions and 278 deletions
+224 -99
View File
@@ -3,26 +3,51 @@
<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>
<!-- Step 1: Topic Selection -->
<div v-if="!chatStore.session && !selectedTopic" class="topic-selection">
<h2 class="section-title">Select a Topic</h2>
<div class="topic-grid">
<button
v-for="topic in topicStore.topics"
:key="topic.id"
:class="['topic-tile', { 'topic-tile-freeform': topic.id === 'free-form' }]"
@click="handleTopicSelect(topic)"
>
<span class="topic-tile-name">{{ topic.name }}</span>
<span v-if="topic.id === 'free-form'" class="topic-tile-hint">Any neurosurgery question</span>
</button>
</div>
</div>
<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>
<!-- Step 2: Topic selected previous sessions + new chat -->
<div v-else-if="!chatStore.session && selectedTopic" class="session-setup card">
<div class="setup-header">
<button class="btn-back" @click="handleBack"> Topics</button>
<h2 class="section-title">{{ selectedTopic.name }}</h2>
</div>
<div v-if="chatStore.error" class="error-banner">
{{ chatStore.error }}
<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
@@ -31,29 +56,19 @@
@click="handleNewChat"
>
<span v-if="chatStore.loading" class="spinner"></span>
{{ chatStore.loading ? 'Starting...' : 'New Chat' }}
{{ chatStore.loading ? 'Starting...' : '+ New Chat' }}
</button>
</div>
<!-- Active Session -->
<div v-else class="chat-layout">
<!-- 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">
<span class="session-label">Session</span>
<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>
<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 -->
@@ -85,9 +100,7 @@
<!-- Input Area -->
<div class="input-area card">
<div v-if="chatStore.error" class="error-banner">
{{ chatStore.error }}
</div>
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
<div class="input-row">
<textarea
v-model="inputText"
@@ -117,13 +130,23 @@
import { ref, nextTick, onMounted, watch, inject } from 'vue'
import { useChatStore } from '@/stores/chatStore'
import { useTopicStore } from '@/stores/topicStore'
import type { ChatSession } from '@/stores/chatStore'
import ChatMessage from '@/components/ChatMessage.vue'
interface Topic {
id: string
name: string
category: string
description: string
}
const chatStore = useChatStore()
const topicStore = useTopicStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const selectedTopicId = ref('')
const selectedTopic = ref<Topic | null>(null)
const topicSessions = ref<ChatSession[]>([])
const loadingTopicSessions = ref(false)
const inputText = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
@@ -135,18 +158,12 @@ onMounted(async () => {
watch(
() => chatStore.messages.length,
async () => {
await nextTick()
scrollToBottom()
}
async () => { await nextTick(); scrollToBottom() }
)
watch(
() => chatStore.sending,
async () => {
await nextTick()
scrollToBottom()
}
async () => { await nextTick(); scrollToBottom() }
)
function scrollToBottom() {
@@ -155,25 +172,50 @@ function scrollToBottom() {
}
}
function getTopicName(topicId: string): string {
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
}
function handleBack() {
selectedTopic.value = null
topicSessions.value = []
}
async function handleNewChat() {
const topicId = selectedTopicId.value || undefined
const ok = await chatStore.createSession(topicId)
const ok = await chatStore.createSession(selectedTopic.value!.id)
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 handleResumeSession(session: ChatSession) {
chatStore.session = session
await chatStore.loadMessages()
}
function handleLeaveSession() {
// Leave without deleting — session stays in DB and will appear in "Previous Chats"
chatStore.leaveSession()
// Refresh the sessions list for the current topic
if (selectedTopic.value) {
loadingTopicSessions.value = true
chatStore.fetchSessionsByTopic(selectedTopic.value.id).then((sessions) => {
topicSessions.value = sessions
loadingTopicSessions.value = false
})
}
}
@@ -181,7 +223,6 @@ 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')
@@ -190,44 +231,146 @@ async function handleSend() {
</script>
<style scoped>
.session-setup {
max-width: 540px;
.topic-selection {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1.25rem;
margin: 0;
}
.form-group {
margin-bottom: 1.25rem;
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.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;
.topic-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 1rem 1.1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-select:focus {
outline: none;
.topic-tile:hover {
border-color: #3182ce;
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.2);
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.15);
}
.topic-tile-freeform {
border-style: dashed;
border-color: #a0aec0;
}
.topic-tile-freeform:hover {
border-color: #718096;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.topic-tile-name {
font-size: 0.9rem;
font-weight: 600;
color: #2d3748;
}
.topic-tile-hint {
font-size: 0.78rem;
color: #a0aec0;
}
.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 {
@@ -241,7 +384,6 @@ async function handleSend() {
.session-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
flex-shrink: 0;
}
@@ -253,14 +395,6 @@ async function handleSend() {
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;
@@ -314,21 +448,12 @@ async function handleSend() {
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;
}
.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);
}
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.input-area {