stable POC version 1 - chat and topics
This commit is contained in:
@@ -2,21 +2,25 @@
|
||||
<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>
|
||||
<div v-if="isUser" class="message-content">{{ message.content }}</div>
|
||||
<div v-else class="message-content message-content--markdown" v-html="renderedContent"></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
|
||||
<div class="source-list">
|
||||
<div
|
||||
v-for="(source, idx) in message.sources"
|
||||
:key="idx"
|
||||
class="source-chip"
|
||||
class="source-item"
|
||||
>
|
||||
<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 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>
|
||||
</div>
|
||||
<div v-if="source.chunkText" class="source-chunk">{{ source.chunkText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +31,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import type { ChatMessage } from '@/stores/chatStore'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -34,6 +39,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const isUser = computed(() => props.message.role === 'USER')
|
||||
const renderedContent = computed(() => marked.parse(props.message.content) as string)
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -92,6 +98,63 @@ function formatTime(iso: string): string {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-content--markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(h1),
|
||||
.message-content--markdown :deep(h2),
|
||||
.message-content--markdown :deep(h3),
|
||||
.message-content--markdown :deep(h4) {
|
||||
font-weight: 700;
|
||||
margin: 0.75rem 0 0.35rem;
|
||||
line-height: 1.3;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(h1) { font-size: 1.15rem; }
|
||||
.message-content--markdown :deep(h2) { font-size: 1.05rem; }
|
||||
.message-content--markdown :deep(h3) { font-size: 0.975rem; }
|
||||
.message-content--markdown :deep(h4) { font-size: 0.925rem; }
|
||||
|
||||
.message-content--markdown :deep(p) {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(ul),
|
||||
.message-content--markdown :deep(ol) {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(li) {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(strong) {
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(code) {
|
||||
background: #edf2f7;
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.87em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.message-content--markdown :deep(blockquote) {
|
||||
border-left: 3px solid #bee3f8;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25rem 0.75rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.message-sources {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
@@ -107,10 +170,28 @@ function formatTime(iso: string): string {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.source-chips {
|
||||
.source-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.source-chunk {
|
||||
font-size: 0.78rem;
|
||||
color: #4a5568;
|
||||
background: #f7fafc;
|
||||
border-left: 3px solid #bee3f8;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0 4px 4px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ChatMessage {
|
||||
id: string
|
||||
role: 'USER' | 'ASSISTANT'
|
||||
content: string
|
||||
sources: Array<{ bookTitle: string; page: number | null }>
|
||||
sources: Array<{ bookTitle: string; page: number | null; chunkText?: string }>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -91,6 +91,21 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function leaveSession(): void {
|
||||
session.value = null
|
||||
messages.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function fetchSessionsByTopic(topicId: string): Promise<ChatSession[]> {
|
||||
try {
|
||||
const response = await api.get<ChatSession[]>('/chat/sessions', { params: { topicId } })
|
||||
return response.data
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(): Promise<boolean> {
|
||||
if (!session.value) return false
|
||||
loading.value = true
|
||||
@@ -117,6 +132,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
createSession,
|
||||
loadMessages,
|
||||
sendMessage,
|
||||
leaveSession,
|
||||
fetchSessionsByTopic,
|
||||
deleteSession
|
||||
}
|
||||
})
|
||||
|
||||
+224
-99
@@ -3,26 +3,51 @@
|
||||
<h1 class="page-title">Knowledge Chat</h1>
|
||||
<p class="page-subtitle">Ask questions grounded in your uploaded medical textbooks.</p>
|
||||
|
||||
<!-- Session Setup (no active session) -->
|
||||
<div v-if="!chatStore.session" class="session-setup card">
|
||||
<h2 class="section-title">Start a New Chat</h2>
|
||||
<!-- Step 1: Topic Selection -->
|
||||
<div v-if="!chatStore.session && !selectedTopic" class="topic-selection">
|
||||
<h2 class="section-title">Select a Topic</h2>
|
||||
<div class="topic-grid">
|
||||
<button
|
||||
v-for="topic in topicStore.topics"
|
||||
:key="topic.id"
|
||||
:class="['topic-tile', { 'topic-tile-freeform': topic.id === 'free-form' }]"
|
||||
@click="handleTopicSelect(topic)"
|
||||
>
|
||||
<span class="topic-tile-name">{{ topic.name }}</span>
|
||||
<span v-if="topic.id === 'free-form'" class="topic-tile-hint">Any neurosurgery question</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Topic (optional)</label>
|
||||
<select v-model="selectedTopicId" class="form-select">
|
||||
<option value="">Free-form chat (any neurosurgery question)</option>
|
||||
<option
|
||||
v-for="topic in topicStore.topics"
|
||||
:key="topic.id"
|
||||
:value="topic.id"
|
||||
>
|
||||
{{ topic.name }} ({{ topic.category }})
|
||||
</option>
|
||||
</select>
|
||||
<!-- Step 2: Topic selected — previous sessions + new chat -->
|
||||
<div v-else-if="!chatStore.session && selectedTopic" class="session-setup card">
|
||||
<div class="setup-header">
|
||||
<button class="btn-back" @click="handleBack">← Topics</button>
|
||||
<h2 class="section-title">{{ selectedTopic.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="chatStore.error" class="error-banner">
|
||||
{{ chatStore.error }}
|
||||
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
|
||||
|
||||
<div v-if="loadingTopicSessions" class="sessions-loading">
|
||||
<div class="spinner spinner-dark" style="width:20px;height:20px;display:inline-block;"></div>
|
||||
Loading previous chats...
|
||||
</div>
|
||||
|
||||
<div v-else-if="topicSessions.length > 0" class="previous-sessions">
|
||||
<h3 class="sessions-label">Previous Chats</h3>
|
||||
<div
|
||||
v-for="s in topicSessions"
|
||||
:key="s.sessionId"
|
||||
class="session-row"
|
||||
@click="handleResumeSession(s)"
|
||||
>
|
||||
<span class="session-row-date">{{ formatDate(s.createdAt) }}</span>
|
||||
<span class="session-row-action">Resume →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loadingTopicSessions" class="no-sessions-hint">
|
||||
No previous chats for this topic.
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -31,29 +56,19 @@
|
||||
@click="handleNewChat"
|
||||
>
|
||||
<span v-if="chatStore.loading" class="spinner"></span>
|
||||
{{ chatStore.loading ? 'Starting...' : 'New Chat' }}
|
||||
{{ chatStore.loading ? 'Starting...' : '+ New Chat' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Session -->
|
||||
<div v-else class="chat-layout">
|
||||
<!-- Step 3: Active Session -->
|
||||
<div v-else-if="chatStore.session" class="chat-layout">
|
||||
<!-- Session Info Bar -->
|
||||
<div class="session-bar card">
|
||||
<div class="session-info">
|
||||
<span class="session-label">Session</span>
|
||||
<button class="btn-back" @click="handleLeaveSession">← Back</button>
|
||||
<span class="session-topic">{{ getTopicName(chatStore.session.topicId) }}</span>
|
||||
<span class="session-id">{{ chatStore.session.sessionId.slice(0, 8) }}...</span>
|
||||
<span v-if="chatStore.session.topicId" class="session-topic">
|
||||
Topic: {{ getTopicName(chatStore.session.topicId) }}
|
||||
</span>
|
||||
<span v-else class="session-topic">Free-form chat</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="chatStore.loading"
|
||||
@click="handleClearConversation"
|
||||
>
|
||||
Clear Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
@@ -85,9 +100,7 @@
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area card">
|
||||
<div v-if="chatStore.error" class="error-banner">
|
||||
{{ chatStore.error }}
|
||||
</div>
|
||||
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
@@ -117,13 +130,23 @@
|
||||
import { ref, nextTick, onMounted, watch, inject } from 'vue'
|
||||
import { useChatStore } from '@/stores/chatStore'
|
||||
import { useTopicStore } from '@/stores/topicStore'
|
||||
import type { ChatSession } from '@/stores/chatStore'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
|
||||
interface Topic {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const topicStore = useTopicStore()
|
||||
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
|
||||
|
||||
const selectedTopicId = ref('')
|
||||
const selectedTopic = ref<Topic | null>(null)
|
||||
const topicSessions = ref<ChatSession[]>([])
|
||||
const loadingTopicSessions = ref(false)
|
||||
const inputText = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -135,18 +158,12 @@ onMounted(async () => {
|
||||
|
||||
watch(
|
||||
() => chatStore.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
async () => { await nextTick(); scrollToBottom() }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => chatStore.sending,
|
||||
async () => {
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
async () => { await nextTick(); scrollToBottom() }
|
||||
)
|
||||
|
||||
function scrollToBottom() {
|
||||
@@ -155,25 +172,50 @@ function scrollToBottom() {
|
||||
}
|
||||
}
|
||||
|
||||
function getTopicName(topicId: string): string {
|
||||
function getTopicName(topicId: string | null): string {
|
||||
if (!topicId) return 'Free-form Chat'
|
||||
const topic = topicStore.topics.find((t) => t.id === topicId)
|
||||
return topic ? topic.name : topicId
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleTopicSelect(topic: Topic) {
|
||||
selectedTopic.value = topic
|
||||
loadingTopicSessions.value = true
|
||||
topicSessions.value = await chatStore.fetchSessionsByTopic(topic.id)
|
||||
loadingTopicSessions.value = false
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
selectedTopic.value = null
|
||||
topicSessions.value = []
|
||||
}
|
||||
|
||||
async function handleNewChat() {
|
||||
const topicId = selectedTopicId.value || undefined
|
||||
const ok = await chatStore.createSession(topicId)
|
||||
const ok = await chatStore.createSession(selectedTopic.value!.id)
|
||||
if (!ok) {
|
||||
showToast?.(chatStore.error ?? 'Could not start session.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearConversation() {
|
||||
const ok = await chatStore.deleteSession()
|
||||
if (!ok) {
|
||||
showToast?.(chatStore.error ?? 'Could not clear conversation.', 'error')
|
||||
} else {
|
||||
selectedTopicId.value = ''
|
||||
async function handleResumeSession(session: ChatSession) {
|
||||
chatStore.session = session
|
||||
await chatStore.loadMessages()
|
||||
}
|
||||
|
||||
function handleLeaveSession() {
|
||||
// Leave without deleting — session stays in DB and will appear in "Previous Chats"
|
||||
chatStore.leaveSession()
|
||||
// Refresh the sessions list for the current topic
|
||||
if (selectedTopic.value) {
|
||||
loadingTopicSessions.value = true
|
||||
chatStore.fetchSessionsByTopic(selectedTopic.value.id).then((sessions) => {
|
||||
topicSessions.value = sessions
|
||||
loadingTopicSessions.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +223,6 @@ async function handleSend() {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || chatStore.sending) return
|
||||
inputText.value = ''
|
||||
|
||||
const ok = await chatStore.sendMessage(content)
|
||||
if (!ok) {
|
||||
showToast?.(chatStore.error ?? 'Failed to send message.', 'error')
|
||||
@@ -190,44 +231,146 @@ async function handleSend() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-setup {
|
||||
max-width: 540px;
|
||||
.topic-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
.topic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #2d3748;
|
||||
.topic-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
.topic-tile:hover {
|
||||
border-color: #3182ce;
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.15);
|
||||
}
|
||||
|
||||
.topic-tile-freeform {
|
||||
border-style: dashed;
|
||||
border-color: #a0aec0;
|
||||
}
|
||||
|
||||
.topic-tile-freeform:hover {
|
||||
border-color: #718096;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.topic-tile-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.topic-tile-hint {
|
||||
font-size: 0.78rem;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.session-setup {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3182ce;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.sessions-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #718096;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.previous-sessions {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.sessions-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #a0aec0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.session-row:hover {
|
||||
background: #ebf8ff;
|
||||
border-color: #bee3f8;
|
||||
}
|
||||
|
||||
.session-row-date {
|
||||
font-size: 0.875rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.session-row-action {
|
||||
font-size: 0.8rem;
|
||||
color: #3182ce;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-sessions-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #a0aec0;
|
||||
font-style: italic;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
@@ -241,7 +384,6 @@ async function handleSend() {
|
||||
.session-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -253,14 +395,6 @@ async function handleSend() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.session-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
@@ -314,21 +448,12 @@ async function handleSend() {
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-bubble span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.typing-bubble span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing-bubble span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.input-area {
|
||||
|
||||
Reference in New Issue
Block a user