feat(web): add ChatView, CitationPanel, and Natural 20 easter egg
This commit is contained in:
parent
e401cb5f48
commit
6bda1143cc
2 changed files with 308 additions and 2 deletions
69
web/src/components/CitationPanel.vue
Normal file
69
web/src/components/CitationPanel.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<div class="citation-panel">
|
||||||
|
<button
|
||||||
|
class="citation-toggle"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<span class="citation-badge" :class="{ 'nat20': showNat20 }" aria-hidden="true">
|
||||||
|
{{ showNat20 ? "⚀ Natural 20" : `p.${citation.page_number}` }}
|
||||||
|
</span>
|
||||||
|
<span class="citation-doc">{{ docTitle }}</span>
|
||||||
|
<span class="citation-chevron">{{ open ? "▲" : "▼" }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="citation-body" v-show="open" role="region" :aria-label="`Excerpt from page ${citation.page_number}`">
|
||||||
|
<p class="citation-source-label">Source text (not paraphrased):</p>
|
||||||
|
<blockquote class="citation-text">{{ citation.snippet }}</blockquote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import type { Citation } from "@/api"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
citation: Citation
|
||||||
|
docTitle?: string
|
||||||
|
bm25Score?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const showNat20 = ref(false)
|
||||||
|
|
||||||
|
const NAT20_THRESHOLD = 8.0
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
if (!prefersReduced && props.bm25Score && props.bm25Score >= NAT20_THRESHOLD) {
|
||||||
|
showNat20.value = true
|
||||||
|
setTimeout(() => { showNat20.value = false }, 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.citation-panel { border: 1px solid var(--color-border); border-radius: var(--radius-sm); margin-bottom: 0.5rem; overflow: hidden; }
|
||||||
|
.citation-toggle {
|
||||||
|
width: 100%; display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--color-surface-alt); border: none; cursor: pointer; color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.citation-toggle:hover { background: var(--color-border); }
|
||||||
|
.citation-badge {
|
||||||
|
font-size: 0.75rem; font-weight: 700; padding: 2px 8px;
|
||||||
|
background: var(--color-surface); border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border); font-family: var(--font-mono);
|
||||||
|
white-space: nowrap; transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.citation-badge.nat20 { background: var(--color-accent); color: #fff; border-color: var(--color-accent); }
|
||||||
|
.citation-doc { flex: 1; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||||
|
.citation-chevron { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||||
|
.citation-body { padding: 0.75rem; background: var(--color-surface); }
|
||||||
|
.citation-source-label { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.4rem; font-style: italic; }
|
||||||
|
.citation-text {
|
||||||
|
border-left: 3px solid var(--color-accent); padding-left: 0.75rem;
|
||||||
|
font-size: 0.9rem; color: var(--color-text); line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,2 +1,239 @@
|
||||||
<template><div>Chat coming soon.</div></template>
|
<template>
|
||||||
<script setup lang="ts"></script>
|
<div class="chat-layout">
|
||||||
|
<!-- Message pane -->
|
||||||
|
<div class="chat-pane">
|
||||||
|
<div class="chat-messages" ref="messagesEl">
|
||||||
|
<p class="empty-chat" v-if="history.length === 0">
|
||||||
|
Ask a question across your indexed rulebooks.
|
||||||
|
No rulebooks indexed? Go to <RouterLink to="/">Library</RouterLink> first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(msg, i) in history"
|
||||||
|
:key="i"
|
||||||
|
class="message"
|
||||||
|
:class="msg.role"
|
||||||
|
>
|
||||||
|
<div class="message-body">{{ msg.content }}</div>
|
||||||
|
<div class="message-citations" v-if="msg.citations?.length">
|
||||||
|
<p class="citations-label">Sources:</p>
|
||||||
|
<CitationPanel
|
||||||
|
v-for="(cite, j) in msg.citations"
|
||||||
|
:key="j"
|
||||||
|
:citation="cite"
|
||||||
|
:doc-title="docTitles[cite.doc_id] ?? cite.doc_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message assistant loading" v-if="thinking">
|
||||||
|
<div class="loading-dots"><span /><span /><span /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="error-banner" v-if="errorMsg" role="alert">
|
||||||
|
{{ errorMsg }}
|
||||||
|
<span v-if="error402"> — <RouterLink to="/">Library</RouterLink> or set PAGEPIPER_OLLAMA_URL.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="chat-input-row" @submit.prevent="send">
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="draft"
|
||||||
|
class="chat-input"
|
||||||
|
placeholder="Ask about your rulebooks…"
|
||||||
|
:disabled="thinking"
|
||||||
|
aria-label="Chat message"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button class="btn-send" type="submit" :disabled="thinking || !draft.trim()">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book filter sidebar -->
|
||||||
|
<aside class="sidebar" role="complementary" aria-label="Filter by book">
|
||||||
|
<h2 class="sidebar-title">Books</h2>
|
||||||
|
<p class="sidebar-hint">Select books to search (all = none selected)</p>
|
||||||
|
<label
|
||||||
|
v-for="doc in readyDocs"
|
||||||
|
:key="doc.id"
|
||||||
|
class="book-filter"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :value="doc.id" v-model="selectedDocs" />
|
||||||
|
{{ doc.title }}
|
||||||
|
</label>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref } from "vue"
|
||||||
|
import { RouterLink } from "vue-router"
|
||||||
|
import { api, type Citation, type Document } from "@/api"
|
||||||
|
import CitationPanel from "@/components/CitationPanel.vue"
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
citations?: Citation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = ref<ChatMessage[]>([])
|
||||||
|
const draft = ref("")
|
||||||
|
const thinking = ref(false)
|
||||||
|
const errorMsg = ref("")
|
||||||
|
const error402 = ref(false)
|
||||||
|
const messagesEl = ref<HTMLElement | null>(null)
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
const allDocs = ref<Document[]>([])
|
||||||
|
const selectedDocs = ref<string[]>([])
|
||||||
|
|
||||||
|
const readyDocs = computed(() => allDocs.value.filter(d => d.status === "ready"))
|
||||||
|
const docTitles = computed(() =>
|
||||||
|
Object.fromEntries(allDocs.value.map(d => [d.id, d.title]))
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
allDocs.value = await api.getLibrary().catch(() => [])
|
||||||
|
inputEl.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const msg = draft.value.trim()
|
||||||
|
if (!msg || thinking.value) return
|
||||||
|
|
||||||
|
draft.value = ""
|
||||||
|
errorMsg.value = ""
|
||||||
|
error402.value = false
|
||||||
|
history.value.push({ role: "user", content: msg })
|
||||||
|
thinking.value = true
|
||||||
|
await nextTick()
|
||||||
|
scrollBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docIds = selectedDocs.value.length ? selectedDocs.value : undefined
|
||||||
|
const apiHistory = history.value.slice(0, -1).map(m => ({ role: m.role, content: m.content }))
|
||||||
|
const result = await api.chat(msg, apiHistory, docIds)
|
||||||
|
history.value.push({ role: "assistant", content: result.answer, citations: result.citations })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as Error & { status?: number; detail?: { message?: string } }
|
||||||
|
if (e.status === 402) {
|
||||||
|
error402.value = true
|
||||||
|
errorMsg.value = e.detail?.message ?? "Ollama not configured. Set PAGEPIPER_OLLAMA_URL."
|
||||||
|
} else {
|
||||||
|
errorMsg.value = e.message ?? "Something went wrong."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
thinking.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollBottom()
|
||||||
|
inputEl.value?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBottom() {
|
||||||
|
if (messagesEl.value) {
|
||||||
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-chat { color: var(--color-text-muted); line-height: 1.8; }
|
||||||
|
|
||||||
|
.message { max-width: 80%; }
|
||||||
|
.message.user { align-self: flex-end; }
|
||||||
|
.message.assistant { align-self: flex-start; }
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.message.user .message-body {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-citations { margin-top: 0.75rem; }
|
||||||
|
.citations-label { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.4rem; font-style: italic; }
|
||||||
|
|
||||||
|
.loading-dots { display: flex; gap: 6px; padding: 0.75rem 1rem; }
|
||||||
|
.loading-dots span {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
animation: bounce 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes bounce { 0%, 80%, 100% { transform: scale(0.6); } 40% { transform: scale(1); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .loading-dots span { animation: none; opacity: 0.5; } }
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: color-mix(in srgb, var(--color-error) 15%, var(--color-surface));
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
flex: 1; padding: 0.6rem 1rem;
|
||||||
|
background: var(--color-bg); border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm); color: var(--color-text); font-size: 1rem;
|
||||||
|
}
|
||||||
|
.chat-input:focus { outline: 2px solid var(--color-accent); border-color: transparent; }
|
||||||
|
.btn-send {
|
||||||
|
padding: 0.6rem 1.25rem; background: var(--color-accent); color: #fff;
|
||||||
|
border: none; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.btn-send:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px; border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface); overflow-y: auto; padding: 1rem;
|
||||||
|
}
|
||||||
|
.sidebar-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.sidebar-hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.75rem; line-height: 1.4; }
|
||||||
|
.book-filter {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.5rem;
|
||||||
|
font-size: 0.85rem; margin-bottom: 0.5rem; cursor: pointer; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chat-layout { flex-direction: column-reverse; }
|
||||||
|
.sidebar { width: 100%; height: auto; max-height: 30vh; border-left: none; border-top: 1px solid var(--color-border); }
|
||||||
|
.message { max-width: 95%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue