feat(web): add ChatView, CitationPanel, and Natural 20 easter egg

This commit is contained in:
pyr0ball 2026-05-04 18:32:20 -07:00
parent e401cb5f48
commit 6bda1143cc
2 changed files with 308 additions and 2 deletions

View 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>

View file

@ -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>