diff --git a/api/chat.py b/api/chat.py
index 25ebc95..10fbf1d 100644
--- a/api/chat.py
+++ b/api/chat.py
@@ -126,6 +126,8 @@ def talk_to_robot(index, query: str, history: List[Dict[str, str]], k: int = STA
yield json.dumps({"state": "loading", "phase": "semantic"})
top_k_blocks = get_top_k_blocks(index, query, k)
+ yield json.dumps({"state": "loading", "phase": "semantic", 'citations': [{'title': block.title, 'author': block.author, 'date': block.date, 'url': block.url} for block in top_k_blocks]})
+
# 2. Generate a prompt
yield json.dumps({"state": "loading", "phase": "prompt"})
prompt = construct_prompt(query, history, top_k_blocks)
diff --git a/web/src/pages/index.tsx b/web/src/pages/index.tsx
index 9870807..a770c5d 100644
--- a/web/src/pages/index.tsx
+++ b/web/src/pages/index.tsx
@@ -3,7 +3,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
import Head from "next/head";
import React from "react";
import { type NextPage } from "next";
-import { useState, useMemo } from "react";
+import { useState } from "react";
import Header from "../header";
import SearchBox from "../searchbox";
@@ -24,6 +24,7 @@ type AssistantEntry = {
role: "assistant";
content: string;
citations: Citation[];
+ base_count: number; // the number to start counting citations at
}
type ErrorMessage = {
@@ -83,23 +84,17 @@ const ShowEntry: React.FC<{entry: Entry}> = ({entry}) => {
return (
{entry.content}
);
}
- // error message
- if (entry.role === "error") {
- return ( {entry.content}
);
- }
+
+
- // robot message
- const res = useMemo(() => ShowAssistantEntry(entry), [entry]);
- return res;
-};
-const ShowAssistantEntry = (entry: AssistantEntry) => {
- const in_text_citation_regex = /\[([0-9]+)\]/g;
+// todo: memoize this if too slow.
+const ProcessText: (text: string, base_count: number) => [string, Map] = (text, base_count) => {
// ---------------------- normalize citation form ----------------------
// transform all things that look like [a, b, c] into [a][b][c]
- let response = entry.content.replace(
+ let response = text.replace(
/\[((?:[a-z]+,\s*)*[a-z]+)\]/g, // identify groups of this form
@@ -134,7 +129,6 @@ const ShowAssistantEntry = (entry: AssistantEntry) => {
// figure out what citations are in the response, and map them appropriately
const cite_map = new Map();
- // let cite_count = runningIndex;
let cite_count = 0;
// scan a regex for [x] over the response. If x isn't in the map, add it.
@@ -143,16 +137,22 @@ const ShowAssistantEntry = (entry: AssistantEntry) => {
let response_copy = ""
while ((match = regex.exec(response)) !== null) {
if (!cite_map.has(match[1]!)) {
- cite_map.set(match[1]!, cite_count++);
+ cite_map.set(match[1]!, base_count + cite_count++);
}
// replace [x] with [i]
response_copy += response.slice(response_copy.length, match.index) + `[${cite_map.get(match[1]!)! + 1}]`;
}
- // setRunningIndex(cite_count);
- // TODO
-
response = response_copy + response.slice(response_copy.length);
+
+ return [response, cite_map]
+}
+
+
+const ShowAssistantEntry: React.FC<{entry: AssistantEntry}> = ({entry}) => {
+ const in_text_citation_regex = /\[([0-9]+)\]/g;
+
+ let [response, cite_map] = ProcessText(entry.content, entry.base_count);
// ----------------- create the ordered citation array -----------------
@@ -165,11 +165,11 @@ const ShowAssistantEntry = (entry: AssistantEntry) => {
citations.set(value, entry.citations[index]!);
}
});
-
+
return (
{ // split into paragraphs
- entry.content.split("\n").map(paragraph => (
{
+ response.split("\n").map(paragraph => (
{
paragraph.split(in_text_citation_regex).map((text, i) => {
if (i % 2 === 0) {
return text.trim();
@@ -183,7 +183,7 @@ const ShowAssistantEntry = (entry: AssistantEntry) => {
})
}
))
}
-
+
{ // show citations
Array.from(citations.entries()).map(([i, citation]) => (
-
@@ -195,13 +195,17 @@ const ShowAssistantEntry = (entry: AssistantEntry) => {
);
};
-
+
+
+
+
type State = {
state: "idle";
} | {
state: "loading";
phase: "semantic" | "prompt" | "llm";
+ citations: Citation[];
} | {
state: "streaming";
response: AssistantEntry;
@@ -254,11 +258,12 @@ const Home: NextPage = () => {
if (!res.ok) {
setLoading(false);
- console.log("load failure: " + res.status);
+ setLoadState({state: "idle"});
+ setEntries([...new_entries, {role: "error", content: "POST Error: " + res.status}]);
return;
}
- // read back sse stream
+ // read back the SSE stream
const reader = res.body!.getReader();
var message = "";
@@ -270,7 +275,13 @@ const Home: NextPage = () => {
const chunk = new TextDecoder("utf-8").decode(value);
if (chunk.startsWith("event: close\n")) break;
+ // note: this form isn't even remotely close to optimal in terms of network usage.
+
for (const line of chunk.split('\n')) {
+
+ // Most times, it seems that a single read() call will be one SSE "message",
+ // but I'll do the proper aggregation spec thing in case that's not always true.
+
if (line.startsWith("data: ")) message += line.slice(6);
if (line === "") {
if (message !== "") {
@@ -279,23 +290,50 @@ const Home: NextPage = () => {
switch (data.state) {
case "loading":
- setLoadState({state: "loading", phase: data.phase});
+
+ // display loading phases, once citations are available toss them
+ // into the loading state.
+
+ setLoadState((s) => {
+ var citations = s.state === "loading" ? s.citations : [];
+ if (data.citations !== undefined) {
+ citations = data.citations;
+ }
+ return {state: "loading", phase: data.phase, citations: citations};
+ });
+
break;
case "streaming":
+
+ // incrementally build up the response
+
setLoadState((s) => {
- const response = s.state === "streaming" ? s.response : {role: "assistant", content: "", citations: []};
+ const response = s.state === "streaming" ? s.response :
+ {role: "assistant",
+ content: "",
+ citations: s.state === "loading" ? s.citations : [],
+ base_count: runningIndex
+ };
+
return {state: "streaming", response: {
role: "assistant",
content: response.content + data.content,
citations: response.citations,
+ base_count: response.base_count
}};
});
break;
case "done":
+
+ // append the response to the entries, reset to normal
+
setLoadState((s) => {
- if (s.state === "streaming") setEntries([...new_entries, s.response]);
+ if (s.state === "streaming") {
+ setEntries([...new_entries, s.response]);
+ setRunningIndex((i) => (i + ProcessText(s.response.content, 0)[1].size));
+ }
return {state: "idle"};
});
break read;
@@ -314,20 +352,6 @@ const Home: NextPage = () => {
setLoading(false);
setLoadState({state: "idle"});
- //
- // const data = await res.json();
- //
- // // -------------------------- error checking ---------------------------
- //
- // if (data.error) {
- // setEntries([...new_entries, {role: "error", content: data.error}]);
- // setLoading(false);
- // return;
- // }
- //
-
- setLoading(false);
-
};
return (
@@ -338,24 +362,40 @@ const Home: NextPage = () => {
- {entries.map((entry, i) => (
- -
-
-
- ))}
-
- {(() => {
- if (loadState.state === "loading") {
- switch (loadState.phase) {
- case "semantic": return Loading: Performing semantic search...
;
- case "prompt": return Loading: Creating prompt...
;
- case "llm": return Loading: Waiting for LLM...
;
+ {entries.map((entry, i) => {
+ if (entry.role === "user") {
+ return -
+
{entry.content}
+
}
- } else if (loadState.state === "streaming") {
- return ;
- }
- return <>>;
- })()}
+ if (entry.role === "error") {
+ return -
+
{entry.content}
+
+ }
+ if (entry.role === "assistant") {
+ return -
+
+
+ }
+ return <>>
+ })}
+
+
+
+ {(() => {
+ if (loadState.state === "loading") {
+ switch (loadState.phase) {
+ case "semantic": return Loading: Performing semantic search...
;
+ case "prompt": return Loading: Creating prompt...
;
+ case "llm": return Loading: Waiting for LLM...
;
+ }
+ } else if (loadState.state === "streaming") {
+ return ;
+ }
+ return <>>;
+ })()}
+
>