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>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<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