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
+287
View File
@@ -0,0 +1,287 @@
<template>
<div id="app">
<nav class="navbar">
<div class="navbar-brand">
<span class="brand-icon">🧠</span>
<span class="brand-name">AI Teacher</span>
<span class="brand-subtitle">Neurosurgeon Learning Platform</span>
</div>
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
<span class="nav-icon">📚</span> Library
</RouterLink>
</li>
<li>
<RouterLink to="/topics" :class="{ active: $route.path === '/topics' }">
<span class="nav-icon">🗂</span> Topics
</RouterLink>
</li>
<li>
<RouterLink to="/chat" :class="{ active: $route.path === '/chat' }">
<span class="nav-icon">💬</span> Chat
</RouterLink>
</li>
</ul>
</nav>
<main class="main-content">
<div v-if="toastMessage" class="toast" :class="toastType" @click="toastMessage = ''">
{{ toastMessage }}
</div>
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
const toastMessage = ref('')
const toastType = ref<'toast-error' | 'toast-success'>('toast-error')
function showToast(message: string, type: 'error' | 'success' = 'error') {
toastMessage.value = message
toastType.value = type === 'error' ? 'toast-error' : 'toast-success'
setTimeout(() => {
toastMessage.value = ''
}, 5000)
}
provide('showToast', showToast)
</script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #f0f4f8;
color: #2d3748;
min-height: 100vh;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background: #1a365d;
color: white;
padding: 0 2rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand-icon {
font-size: 1.5rem;
}
.brand-name {
font-size: 1.2rem;
font-weight: 700;
color: #bee3f8;
}
.brand-subtitle {
font-size: 0.8rem;
color: #90cdf4;
margin-left: 0.25rem;
}
.navbar-links {
list-style: none;
display: flex;
gap: 0.5rem;
}
.navbar-links a {
color: #bee3f8;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 0.4rem;
}
.navbar-links a:hover,
.navbar-links a.active {
background: #2b6cb0;
color: white;
}
.main-content {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
position: relative;
}
.toast {
position: fixed;
top: 80px;
right: 2rem;
padding: 1rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
z-index: 1000;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
animation: slideIn 0.2s ease;
}
.toast-error {
background: #fed7d7;
color: #c53030;
border: 1px solid #fc8181;
}
.toast-success {
background: #c6f6d5;
color: #276749;
border: 1px solid #68d391;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Shared utility classes */
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.2rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3182ce;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2b6cb0;
}
.btn-danger {
background: #e53e3e;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c53030;
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn-secondary:hover:not(:disabled) {
background: #cbd5e0;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.4);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner-dark {
border-color: rgba(49, 130, 206, 0.3);
border-top-color: #3182ce;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card {
background: white;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
}
.page-title {
font-size: 1.8rem;
font-weight: 700;
color: #1a365d;
margin-bottom: 0.5rem;
}
.page-subtitle {
color: #718096;
margin-bottom: 2rem;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
color: #a0aec0;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state-text {
font-size: 1.1rem;
font-weight: 500;
}
.empty-state-hint {
font-size: 0.9rem;
margin-top: 0.5rem;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="book-card card">
<div class="book-header">
<div class="book-info">
<h3 class="book-title">{{ book.title }}</h3>
<p class="book-filename">{{ book.fileName }}</p>
</div>
<span class="status-badge" :class="statusClass">{{ book.status }}</span>
</div>
<div class="book-meta">
<span v-if="book.fileSizeBytes" class="meta-item">
{{ formatFileSize(book.fileSizeBytes) }}
</span>
<span v-if="book.pageCount" class="meta-item">
{{ book.pageCount }} pages
</span>
<span class="meta-item">
Uploaded {{ formatDate(book.uploadedAt) }}
</span>
<span v-if="book.processedAt" class="meta-item">
Processed {{ formatDate(book.processedAt) }}
</span>
</div>
<div v-if="book.status === 'FAILED' && book.errorMessage" class="error-message">
<strong>Error:</strong> {{ book.errorMessage }}
</div>
<div v-if="book.status === 'PENDING' || book.status === 'PROCESSING'" class="processing-indicator">
<div class="spinner spinner-dark"></div>
<span>{{ book.status === 'PENDING' ? 'Queued for processing...' : 'Embedding in progress...' }}</span>
</div>
<div class="book-actions">
<button
class="btn btn-danger"
:disabled="book.status === 'PROCESSING' || deleting"
@click="$emit('delete', book.id)"
:title="book.status === 'PROCESSING' ? 'Cannot delete while processing' : 'Delete book'"
>
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Book } from '@/stores/bookStore'
const props = defineProps<{
book: Book
deleting?: boolean
}>()
defineEmits<{
(e: 'delete', id: string): void
}>()
const statusClass = computed(() => {
switch (props.book.status) {
case 'READY':
return 'status-ready'
case 'PROCESSING':
return 'status-processing'
case 'PENDING':
return 'status-pending'
case 'FAILED':
return 'status-failed'
default:
return ''
}
})
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.book-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.book-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.book-info {
flex: 1;
min-width: 0;
}
.book-title {
font-size: 1rem;
font-weight: 600;
color: #1a365d;
word-break: break-word;
margin-bottom: 0.2rem;
}
.book-filename {
font-size: 0.8rem;
color: #718096;
word-break: break-all;
}
.status-badge {
flex-shrink: 0;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.status-ready {
background: #c6f6d5;
color: #22543d;
}
.status-processing {
background: #bee3f8;
color: #1a365d;
}
.status-pending {
background: #fefcbf;
color: #744210;
}
.status-failed {
background: #fed7d7;
color: #742a2a;
}
.book-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.meta-item {
font-size: 0.8rem;
color: #718096;
background: #f7fafc;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.error-message {
font-size: 0.85rem;
color: #742a2a;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.processing-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #2b6cb0;
}
.book-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.25rem;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="message-wrapper" :class="isUser ? 'message-wrapper--user' : 'message-wrapper--assistant'">
<div class="message-bubble" :class="isUser ? 'bubble-user' : 'bubble-assistant'">
<div class="message-role">{{ isUser ? 'You' : 'AI Teacher' }}</div>
<div class="message-content">{{ message.content }}</div>
<!-- Source chips for assistant messages -->
<div v-if="!isUser && message.sources && message.sources.length > 0" class="message-sources">
<div class="sources-label">Sources:</div>
<div class="source-chips">
<span
v-for="(source, idx) in message.sources"
:key="idx"
class="source-chip"
>
<span class="source-book-icon">📖</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
</span>
</div>
</div>
<div class="message-timestamp">{{ formatTime(message.createdAt) }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/stores/chatStore'
const props = defineProps<{
message: ChatMessage
}>()
const isUser = computed(() => props.message.role === 'USER')
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.message-wrapper {
display: flex;
margin-bottom: 1rem;
}
.message-wrapper--user {
justify-content: flex-end;
}
.message-wrapper--assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 75%;
border-radius: 12px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bubble-user {
background: #3182ce;
color: white;
border-bottom-right-radius: 4px;
}
.bubble-assistant {
background: white;
color: #2d3748;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
}
.message-role {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.75;
}
.message-content {
font-size: 0.925rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-sources {
margin-top: 0.5rem;
border-top: 1px solid #e2e8f0;
padding-top: 0.5rem;
}
.sources-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #a0aec0;
margin-bottom: 0.35rem;
}
.source-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-book-icon {
font-size: 0.8rem;
}
.source-book-title {
color: #2b6cb0;
font-weight: 500;
}
.source-page {
color: #718096;
}
.message-timestamp {
font-size: 0.7rem;
opacity: 0.6;
text-align: right;
margin-top: 0.1rem;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="topic-card card" :class="{ 'topic-card--active': isGenerating }">
<div class="topic-header">
<span class="category-badge" :class="categoryClass">{{ topic.category }}</span>
</div>
<h3 class="topic-name">{{ topic.name }}</h3>
<p class="topic-description">{{ topic.description }}</p>
<div class="topic-actions">
<button
class="btn btn-primary"
:disabled="isGenerating"
@click="$emit('generate', topic.id)"
>
<span v-if="isGenerating" class="spinner"></span>
{{ isGenerating ? 'Generating...' : 'Generate Summary' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Topic } from '@/stores/topicStore'
const props = defineProps<{
topic: Topic
isGenerating?: boolean
}>()
defineEmits<{
(e: 'generate', id: string): void
}>()
const categoryClass = computed(() => {
switch (props.topic.category) {
case 'Vascular':
return 'category-vascular'
case 'Oncology':
return 'category-oncology'
case 'Spine':
return 'category-spine'
case 'Trauma':
return 'category-trauma'
default:
return 'category-default'
}
})
</script>
<style scoped>
.topic-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
transition: box-shadow 0.15s;
}
.topic-card--active {
box-shadow: 0 0 0 2px #3182ce;
}
.topic-header {
display: flex;
align-items: center;
}
.category-badge {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.55rem;
border-radius: 999px;
}
.category-vascular {
background: #fed7e2;
color: #702459;
}
.category-oncology {
background: #feebc8;
color: #7b341e;
}
.category-spine {
background: #bee3f8;
color: #1a365d;
}
.category-trauma {
background: #fefcbf;
color: #744210;
}
.category-default {
background: #e2e8f0;
color: #4a5568;
}
.topic-name {
font-size: 1rem;
font-weight: 600;
color: #1a365d;
line-height: 1.3;
}
.topic-description {
font-size: 0.875rem;
color: #718096;
line-height: 1.5;
flex: 1;
}
.topic-actions {
margin-top: 0.25rem;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_PASSWORD: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+27
View File
@@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
import UploadView from '@/views/UploadView.vue'
import TopicsView from '@/views/TopicsView.vue'
import ChatView from '@/views/ChatView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'upload',
component: UploadView
},
{
path: '/topics',
name: 'topics',
component: TopicsView
},
{
path: '/chat',
name: 'chat',
component: ChatView
}
]
})
export default router
+24
View File
@@ -0,0 +1,24 @@
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
auth: {
username: 'neurosurgeon',
password: import.meta.env.VITE_APP_PASSWORD ?? 'changeme'
},
headers: {
'Content-Type': 'application/json'
}
})
// Response interceptor for error normalisation
api.interceptors.response.use(
(response) => response,
(error) => {
const message =
error.response?.data?.error ??
error.message ??
'An unexpected error occurred.'
return Promise.reject(new Error(message))
}
)
+81
View File
@@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
export interface Book {
id: string
title: string
fileName: string
fileSizeBytes: number
pageCount: number | null
status: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED'
uploadedAt: string
processedAt: string | null
errorMessage?: string
}
export const useBookStore = defineStore('books', () => {
const books = ref<Book[]>([])
const loading = ref(false)
const uploading = ref(false)
const error = ref<string | null>(null)
async function fetchBooks() {
loading.value = true
error.value = null
try {
const response = await api.get<Book[]>('/books')
books.value = response.data
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
async function uploadBook(file: File): Promise<Book | null> {
uploading.value = true
error.value = null
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post<Book>('/books', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const newBook = response.data
books.value.unshift(newBook)
return newBook
} catch (err: any) {
error.value = err.message
return null
} finally {
uploading.value = false
}
}
async function refreshBook(id: string): Promise<void> {
try {
const response = await api.get<Book>(`/books/${id}`)
const idx = books.value.findIndex((b) => b.id === id)
if (idx >= 0) {
books.value[idx] = response.data
}
} catch {
// ignore — book may have been deleted
}
}
async function deleteBook(id: string): Promise<boolean> {
error.value = null
try {
await api.delete(`/books/${id}`)
books.value = books.value.filter((b) => b.id !== id)
return true
} catch (err: any) {
error.value = err.message
return false
}
}
return { books, loading, uploading, error, fetchBooks, uploadBook, refreshBook, deleteBook }
})
+122
View File
@@ -0,0 +1,122 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
export interface ChatMessage {
id: string
role: 'USER' | 'ASSISTANT'
content: string
sources: Array<{ bookTitle: string; page: number | null }>
createdAt: string
}
export interface ChatSession {
sessionId: string
topicId: string | null
createdAt: string
}
export const useChatStore = defineStore('chat', () => {
const session = ref<ChatSession | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const error = ref<string | null>(null)
async function createSession(topicId?: string): Promise<boolean> {
loading.value = true
error.value = null
try {
const body = topicId ? { topicId } : {}
const response = await api.post<ChatSession>('/chat/sessions', body)
session.value = response.data
messages.value = []
return true
} catch (err: any) {
error.value = err.message
return false
} finally {
loading.value = false
}
}
async function loadMessages(): Promise<void> {
if (!session.value) return
loading.value = true
error.value = null
try {
const response = await api.get<ChatMessage[]>(
`/chat/sessions/${session.value.sessionId}/messages`
)
messages.value = response.data
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
async function sendMessage(content: string): Promise<boolean> {
if (!session.value) return false
sending.value = true
error.value = null
// Optimistically add user message
const tempUserMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'USER',
content,
sources: [],
createdAt: new Date().toISOString()
}
messages.value.push(tempUserMsg)
try {
const response = await api.post<ChatMessage>(
`/chat/sessions/${session.value.sessionId}/messages`,
{ content }
)
// Replace temp message & add assistant response
messages.value = messages.value.filter((m) => m.id !== tempUserMsg.id)
// Reload full conversation to stay in sync
await loadMessages()
return true
} catch (err: any) {
// Remove optimistic message on failure
messages.value = messages.value.filter((m) => m.id !== tempUserMsg.id)
error.value = err.message
return false
} finally {
sending.value = false
}
}
async function deleteSession(): Promise<boolean> {
if (!session.value) return false
loading.value = true
error.value = null
try {
await api.delete(`/chat/sessions/${session.value.sessionId}`)
session.value = null
messages.value = []
return true
} catch (err: any) {
error.value = err.message
return false
} finally {
loading.value = false
}
}
return {
session,
messages,
loading,
sending,
error,
createSession,
loadMessages,
sendMessage,
deleteSession
}
})
+74
View File
@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
export interface Topic {
id: string
name: string
description: string
category: string
}
export interface SourceReference {
bookTitle: string
page: number | null
}
export interface TopicSummary {
topicId: string
topicName: string
summary: string
sources: SourceReference[]
generatedAt: string
}
export const useTopicStore = defineStore('topics', () => {
const topics = ref<Topic[]>([])
const activeSummary = ref<TopicSummary | null>(null)
const activeSummaryTopicId = ref<string | null>(null)
const loading = ref(false)
const summaryLoading = ref(false)
const error = ref<string | null>(null)
async function fetchTopics() {
loading.value = true
error.value = null
try {
const response = await api.get<Topic[]>('/topics')
topics.value = response.data
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
async function generateSummary(topicId: string): Promise<TopicSummary | null> {
summaryLoading.value = true
activeSummaryTopicId.value = topicId
activeSummary.value = null
error.value = null
try {
const response = await api.post<TopicSummary>(`/topics/${topicId}/summary`)
activeSummary.value = response.data
return response.data
} catch (err: any) {
error.value = err.message
return null
} finally {
summaryLoading.value = false
activeSummaryTopicId.value = null
}
}
return {
topics,
activeSummary,
activeSummaryTopicId,
loading,
summaryLoading,
error,
fetchTopics,
generateSummary
}
})
+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>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="topics-view">
<h1 class="page-title">Topics</h1>
<p class="page-subtitle">Select a topic to generate an AI-powered summary from uploaded books.</p>
<!-- Loading state -->
<div v-if="topicStore.loading" class="empty-state">
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
<p class="empty-state-text">Loading topics...</p>
</div>
<!-- Error state -->
<div v-else-if="topicStore.error && topicStore.topics.length === 0" class="empty-state">
<div class="empty-state-icon"></div>
<p class="empty-state-text">Failed to load topics</p>
<p class="empty-state-hint">{{ topicStore.error }}</p>
<button class="btn btn-primary" style="margin-top:1rem;" @click="topicStore.fetchTopics()">Retry</button>
</div>
<div v-else class="topics-layout">
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
v-for="topic in topicStore.topics"
:key="topic.id"
:topic="topic"
:is-generating="topicStore.activeSummaryTopicId === topic.id"
@generate="handleGenerate"
/>
</div>
<!-- Summary Panel -->
<div v-if="topicStore.summaryLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating summary from uploaded books...</p>
<p class="summary-loading-hint">This may take up to 30 seconds.</p>
</div>
</div>
<div v-else-if="summaryError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Summary Error</h2>
<p class="error-text">{{ summaryError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
Please
<RouterLink to="/">upload and process at least one book</RouterLink>
first.
</p>
</div>
<div v-else-if="topicStore.activeSummary" class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<span class="summary-timestamp">{{ formatDate(topicStore.activeSummary.generatedAt) }}</span>
</div>
<div class="summary-text">{{ topicStore.activeSummary.summary }}</div>
<div v-if="topicStore.activeSummary.sources.length > 0" class="sources-section">
<button class="sources-toggle" @click="showSources = !showSources">
Sources ({{ topicStore.activeSummary.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<div v-if="showSources" class="sources-list">
<div
v-for="(source, idx) in topicStore.activeSummary.sources"
:key="idx"
class="source-chip"
>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p. {{ source.page }}</span>
</div>
</div>
</div>
<div v-else class="no-sources">
No source citations available for this summary.
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import { RouterLink } from 'vue-router'
import { useTopicStore } from '@/stores/topicStore'
import TopicCard from '@/components/TopicCard.vue'
const topicStore = useTopicStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const showSources = ref(true)
const summaryError = ref<string | null>(null)
const isNoBooks = ref(false)
onMounted(async () => {
await topicStore.fetchTopics()
})
async function handleGenerate(topicId: string) {
summaryError.value = null
isNoBooks.value = false
showSources.value = true
const result = await topicStore.generateSummary(topicId)
if (!result) {
summaryError.value = topicStore.error ?? 'Failed to generate summary.'
isNoBooks.value =
summaryError.value.toLowerCase().includes('no books') ||
summaryError.value.toLowerCase().includes('knowledge source')
showToast?.(summaryError.value, 'error')
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.topics-layout {
display: flex;
flex-direction: column;
gap: 2rem;
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.summary-panel {
border-top: 3px solid #3182ce;
}
.summary-loading {
text-align: center;
padding: 2rem;
}
.summary-loading-text {
font-size: 1rem;
font-weight: 500;
color: #2d3748;
margin-bottom: 0.25rem;
}
.summary-loading-hint {
font-size: 0.85rem;
color: #718096;
}
.summary-error {
border-top-color: #e53e3e;
}
.summary-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.summary-topic-name {
font-size: 1.2rem;
font-weight: 700;
color: #1a365d;
}
.summary-timestamp {
font-size: 0.8rem;
color: #a0aec0;
}
.summary-text {
font-size: 0.95rem;
line-height: 1.7;
color: #2d3748;
white-space: pre-wrap;
margin-bottom: 1rem;
}
.sources-section {
border-top: 1px solid #e2e8f0;
padding-top: 0.75rem;
}
.sources-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: #3182ce;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0;
margin-bottom: 0.75rem;
}
.sources-toggle:hover {
color: #2b6cb0;
}
.sources-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.source-chip {
display: flex;
align-items: center;
gap: 0.4rem;
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 6px;
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
}
.source-book {
color: #2b6cb0;
font-weight: 500;
}
.source-page {
color: #718096;
}
.no-sources {
font-size: 0.85rem;
color: #a0aec0;
font-style: italic;
}
.error-text {
color: #742a2a;
margin-bottom: 0.5rem;
}
.no-books-hint {
font-size: 0.875rem;
color: #718096;
}
.no-books-hint a {
color: #3182ce;
text-decoration: underline;
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<template>
<div class="upload-view">
<h1 class="page-title">Book Library</h1>
<p class="page-subtitle">Upload medical textbooks (PDF) to build the knowledge base.</p>
<!-- Upload Section -->
<div class="upload-section card">
<h2 class="section-title">Upload a Book</h2>
<div
class="drop-zone"
:class="{ 'drop-zone--active': isDragging, 'drop-zone--has-file': selectedFile }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
accept=".pdf,application/pdf"
class="file-input-hidden"
@change="onFileChange"
/>
<div v-if="!selectedFile" class="drop-zone-content">
<span class="drop-zone-icon">📄</span>
<p class="drop-zone-text">Drop a PDF here or click to browse</p>
<p class="drop-zone-hint">Maximum size: 100 MB</p>
</div>
<div v-else class="drop-zone-selected">
<span class="drop-zone-icon"></span>
<p class="drop-zone-text">{{ selectedFile.name }}</p>
<p class="drop-zone-hint">{{ formatFileSize(selectedFile.size) }}</p>
</div>
</div>
<div class="upload-controls">
<button
class="btn btn-secondary"
:disabled="!selectedFile || bookStore.uploading"
@click="clearFile"
>
Clear
</button>
<button
class="btn btn-primary"
:disabled="!selectedFile || bookStore.uploading"
@click="handleUpload"
>
<span v-if="bookStore.uploading" class="spinner"></span>
{{ bookStore.uploading ? 'Uploading...' : 'Upload' }}
</button>
</div>
<div v-if="uploadSuccess" class="upload-feedback upload-feedback--success">
Book uploaded successfully embedding will start shortly.
</div>
<div v-if="uploadError" class="upload-feedback upload-feedback--error">
{{ uploadError }}
</div>
</div>
<!-- Book List -->
<div class="book-list-section">
<div class="book-list-header">
<h2 class="section-title">Uploaded Books</h2>
<button class="btn btn-secondary" :disabled="bookStore.loading" @click="bookStore.fetchBooks()">
<span v-if="bookStore.loading" class="spinner spinner-dark"></span>
{{ bookStore.loading ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
<div v-if="bookStore.loading && bookStore.books.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 library...</p>
</div>
<div v-else-if="bookStore.books.length === 0" class="empty-state">
<div class="empty-state-icon">📚</div>
<p class="empty-state-text">No books uploaded yet</p>
<p class="empty-state-hint">Upload a medical textbook PDF to get started.</p>
</div>
<div v-else class="book-grid">
<BookCard
v-for="book in bookStore.books"
:key="book.id"
:book="book"
:deleting="deletingId === book.id"
@delete="handleDelete"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from 'vue'
import { useBookStore } from '@/stores/bookStore'
import BookCard from '@/components/BookCard.vue'
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const isDragging = ref(false)
const uploadSuccess = ref(false)
const uploadError = ref<string | null>(null)
const deletingId = ref<string | null>(null)
let pollInterval: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await bookStore.fetchBooks()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
function startPolling() {
pollInterval = setInterval(async () => {
const hasActive = bookStore.books.some(
(b) => b.status === 'PENDING' || b.status === 'PROCESSING'
)
if (hasActive) {
await bookStore.fetchBooks()
}
}, 5000)
}
function stopPolling() {
if (pollInterval != null) {
clearInterval(pollInterval)
pollInterval = null
}
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
setFile(input.files[0])
}
}
function onDrop(event: DragEvent) {
isDragging.value = false
const file = event.dataTransfer?.files[0]
if (file) setFile(file)
}
function setFile(file: File) {
if (!file.name.toLowerCase().endsWith('.pdf')) {
uploadError.value = 'Only PDF files are accepted.'
return
}
selectedFile.value = file
uploadError.value = null
uploadSuccess.value = false
}
function clearFile() {
selectedFile.value = null
uploadError.value = null
uploadSuccess.value = false
if (fileInput.value) fileInput.value.value = ''
}
async function handleUpload() {
if (!selectedFile.value) return
uploadError.value = null
uploadSuccess.value = false
const result = await bookStore.uploadBook(selectedFile.value)
if (result) {
uploadSuccess.value = true
clearFile()
showToast?.('Book uploaded — embedding started.', 'success')
} else {
uploadError.value = bookStore.error ?? 'Upload failed.'
showToast?.(uploadError.value, 'error')
}
}
async function handleDelete(id: string) {
deletingId.value = id
const ok = await bookStore.deleteBook(id)
deletingId.value = null
if (!ok) {
showToast?.(bookStore.error ?? 'Delete failed.', 'error')
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<style scoped>
.upload-section {
margin-bottom: 2rem;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1rem;
}
.drop-zone {
border: 2px dashed #cbd5e0;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 1rem;
}
.drop-zone:hover,
.drop-zone--active {
border-color: #3182ce;
background: #ebf8ff;
}
.drop-zone--has-file {
border-color: #48bb78;
background: #f0fff4;
}
.file-input-hidden {
display: none;
}
.drop-zone-icon {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
.drop-zone-content,
.drop-zone-selected {
pointer-events: none;
}
.drop-zone-text {
font-size: 1rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.25rem;
}
.drop-zone-hint {
font-size: 0.85rem;
color: #a0aec0;
}
.upload-controls {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.upload-feedback {
margin-top: 0.75rem;
padding: 0.6rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
}
.upload-feedback--success {
background: #c6f6d5;
color: #22543d;
border: 1px solid #68d391;
}
.upload-feedback--error {
background: #fed7d7;
color: #742a2a;
border: 1px solid #fc8181;
}
.book-list-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.book-list-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
}
</style>