File size: 7,118 Bytes
d9f07b6 44215ae d9f07b6 44215ae 913d26e 44215ae d9f07b6 44215ae d9f07b6 913d26e d9f07b6 44215ae d9f07b6 44215ae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | import { useEffect, useRef, useState } from "react";
import { askQuestion, sendFeedback } from "./api";
// A single turn. Assistant turns carry the source passage so it can be shown
// under the answer. `pending` marks the in-flight assistant bubble.
function makeMessage(role, text, extra = {}) {
return { id: crypto.randomUUID(), role, text, ...extra };
}
export default function Chat({ doc, onReset }) {
const [messages, setMessages] = useState([]);
const [question, setQuestion] = useState("");
const [busy, setBusy] = useState(false);
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function send(e) {
e.preventDefault();
const q = question.trim();
if (!q || busy) return;
setMessages((m) => [...m, makeMessage("user", q)]);
setQuestion("");
setBusy(true);
try {
const res = await askQuestion(doc.document_id, q);
setMessages((m) => [
...m,
makeMessage("assistant", res.answer, {
source: res.source_passage,
interactionId: res.interaction_id,
feedback: null,
generated: res.generated,
}),
]);
} catch (err) {
setMessages((m) => [...m, makeMessage("error", err.message)]);
} finally {
setBusy(false);
}
}
async function rate(messageId, interactionId, value) {
// Optimistically reflect the choice; revert if the request fails.
setMessages((m) =>
m.map((msg) => (msg.id === messageId ? { ...msg, feedback: value } : msg)),
);
try {
await sendFeedback(interactionId, value);
} catch {
setMessages((m) =>
m.map((msg) => (msg.id === messageId ? { ...msg, feedback: null } : msg)),
);
}
}
return (
<div className="flex h-[70vh] flex-col rounded-2xl bg-white shadow-lg ring-1 ring-slate-200">
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-800">
{doc.filename || "Pasted text"}
</p>
<p className="text-xs text-slate-400">{doc.num_chunks} chunks indexed</p>
</div>
<button
onClick={onReset}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-slate-500 ring-1 ring-slate-200 transition hover:bg-slate-50"
>
New document
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
{messages.length === 0 && (
<p className="mt-10 text-center text-sm text-slate-400">
Ask a question about this document to get started.
</p>
)}
{messages.map((m) => {
if (m.role === "user") {
return (
<div key={m.id} className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-sm bg-slate-800 px-4 py-2 text-sm text-white">
{m.text}
</div>
</div>
);
}
if (m.role === "error") {
return (
<div key={m.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl bg-red-50 px-4 py-2 text-sm text-red-700 ring-1 ring-red-200">
⚠ {m.text}
</div>
</div>
);
}
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2 text-sm text-slate-800">
{m.text}
</div>
{m.generated && (
<span className="pl-1 text-[11px] font-medium text-emerald-600">
✨ AI answer · grounded in the source below
</span>
)}
{m.source && (
<details className="max-w-[85%] rounded-lg bg-emerald-50 px-3 py-2 text-xs text-slate-600 ring-1 ring-emerald-100">
<summary className="cursor-pointer font-medium text-emerald-700">
Source passage
</summary>
<p className="mt-1 whitespace-pre-wrap leading-relaxed">{m.source}</p>
</details>
)}
{m.interactionId && (
<div className="flex items-center gap-1.5 pl-1">
<span className="text-xs text-slate-400">Helpful?</span>
<FeedbackButton
active={m.feedback === "up"}
dimmed={m.feedback === "down"}
activeClass="bg-emerald-100 text-emerald-700 ring-emerald-200"
label="Yes, helpful"
onClick={() => rate(m.id, m.interactionId, "up")}
>
👍
</FeedbackButton>
<FeedbackButton
active={m.feedback === "down"}
dimmed={m.feedback === "up"}
activeClass="bg-red-100 text-red-700 ring-red-200"
label="Not helpful"
onClick={() => rate(m.id, m.interactionId, "down")}
>
👎
</FeedbackButton>
</div>
)}
</div>
);
})}
{busy && (
<div className="flex justify-start">
<div className="flex gap-1 rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-3">
<Dot /> <Dot delay="150ms" /> <Dot delay="300ms" />
</div>
</div>
)}
<div ref={endRef} />
</div>
<form onSubmit={send} className="flex gap-2 border-t border-slate-200 p-3">
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask a question…"
className="flex-1 rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm outline-none transition focus:border-slate-400 focus:bg-white"
/>
<button
type="submit"
disabled={busy || !question.trim()}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-300"
>
Ask
</button>
</form>
</div>
);
}
function Dot({ delay = "0ms" }) {
return (
<span
className="inline-block h-2 w-2 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: delay }}
/>
);
}
function FeedbackButton({ active, dimmed, activeClass, label, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
className={`rounded-md px-1.5 py-0.5 text-sm ring-1 transition ${
active
? activeClass
: `ring-transparent hover:bg-slate-100 ${dimmed ? "opacity-40" : ""}`
}`}
>
{children}
</button>
);
}
|