first implementation
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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. {{ 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>
|
||||
@@ -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>
|
||||
Vendored
+10
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user