mirror of
https://github.com/wassname/stampy-chat.git
synced 2026-09-11 12:50:34 +08:00
@@ -0,0 +1,223 @@
|
||||
import { API_URL, STAMPY_URL, STAMPY_CONTENT_URL } from "../settings";
|
||||
import type {
|
||||
Citation,
|
||||
Entry,
|
||||
AssistantEntry,
|
||||
ErrorMessage,
|
||||
StampyMessage,
|
||||
CurrentSearch,
|
||||
Followup,
|
||||
SearchResult,
|
||||
} from "../types";
|
||||
|
||||
const MAX_FOLLOWUPS = 4;
|
||||
const DATA_HEADER = "data: "
|
||||
const EVENT_END_HEADER = "event: close\n"
|
||||
|
||||
type HistoryEntry = {
|
||||
role: "error" | "stampy" | "assistant" | "user";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export async function* iterateData(res: Response) {
|
||||
const reader = res.body!.getReader();
|
||||
var message = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) return;
|
||||
|
||||
const chunk = new TextDecoder("utf-8").decode(value);
|
||||
if (chunk.startsWith(EVENT_END_HEADER)) return;
|
||||
|
||||
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_HEADER)) {
|
||||
message += line.slice(DATA_HEADER.length);
|
||||
// Fixes #43
|
||||
} else if (line !== "") {
|
||||
message += line;
|
||||
} else if (message !== "") {
|
||||
yield JSON.parse(message);
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractAnswer = async (
|
||||
res: Response,
|
||||
baseReferencesIndex: number,
|
||||
setCurrent: (e: CurrentSearch) => void
|
||||
): Promise<SearchResult> => {
|
||||
var result: AssistantEntry = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
citations: [],
|
||||
base_count: baseReferencesIndex,
|
||||
};
|
||||
var followups: Followup[] = [];
|
||||
for await (var data of iterateData(res)) {
|
||||
switch (data.state) {
|
||||
case "loading":
|
||||
// display loading phases, once citations are available toss them
|
||||
// into the current item.
|
||||
result = {
|
||||
...result,
|
||||
citations: data?.citations || result?.citations || [],
|
||||
};
|
||||
setCurrent({ phase: data.phase, ...result });
|
||||
break;
|
||||
|
||||
case "streaming":
|
||||
// incrementally build up the response
|
||||
result = {
|
||||
role: "assistant",
|
||||
content: (result?.content || "") + data.content,
|
||||
citations: result?.citations || [],
|
||||
base_count: result?.base_count || baseReferencesIndex,
|
||||
};
|
||||
setCurrent({ phase: "streaming", ...result });
|
||||
break;
|
||||
|
||||
case "done":
|
||||
// add any potential followup questions
|
||||
const followups = Object.entries(data)
|
||||
.filter(([key]) => key.startsWith("followup_"))
|
||||
.map(([k, value]) => value as Followup);
|
||||
return { result, followups };
|
||||
case "error":
|
||||
throw data.error;
|
||||
}
|
||||
}
|
||||
return { result, followups };
|
||||
};
|
||||
|
||||
const fetchLLM = async (
|
||||
query: string,
|
||||
mode: string,
|
||||
history: HistoryEntry[]
|
||||
): Promise<Response> =>
|
||||
fetch(API_URL + "/chat", {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
|
||||
body: JSON.stringify({ query, mode, history }),
|
||||
});
|
||||
|
||||
export const queryLLM = async (
|
||||
query: string,
|
||||
mode: string,
|
||||
history: HistoryEntry[],
|
||||
baseReferencesIndex: number,
|
||||
setCurrent: (e?: CurrentSearch) => void
|
||||
): Promise<SearchResult> => {
|
||||
// do SSE on a POST request.
|
||||
const res = await fetchLLM(query, mode, history);
|
||||
|
||||
if (!res.ok) {
|
||||
return { result: { role: "error", content: "POST Error: " + res.status } };
|
||||
}
|
||||
|
||||
try {
|
||||
return await extractAnswer(res, baseReferencesIndex, setCurrent);
|
||||
} catch (e) {
|
||||
return {
|
||||
result: { role: "error", content: e ? e.toString() : "unknown error" },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const cleanStampyContent = (contents: string) => contents.replace(
|
||||
/<a(.*?)href="\/\?state=([a-zA-Z0-9]+.*?)"(.*?)<\/a>/g,
|
||||
(_, pre, linkParts, post) => `<a${pre}href="${STAMPY_URL}/?state=${linkParts}"${post}</a>`
|
||||
);
|
||||
|
||||
export const getStampyContent = async (
|
||||
questionId: string
|
||||
): Promise<SearchResult> => {
|
||||
const res = await fetch(`${STAMPY_CONTENT_URL}/${questionId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { result: { role: "error", content: "POST Error: " + res.status } };
|
||||
}
|
||||
|
||||
const data = (await res.json()).data;
|
||||
|
||||
let result = {
|
||||
role: "stampy",
|
||||
content: cleanStampyContent(data.text),
|
||||
url: `${STAMPY_URL}/?state=${data.pageid}`,
|
||||
} as StampyMessage;
|
||||
|
||||
// re-enable the searchbox, with the question that was just answered
|
||||
// removed from the list of possible followups.
|
||||
|
||||
// create an array of new followup questions from the data
|
||||
const f_new = data.relatedQuestions.map((f: any) => ({
|
||||
pageid: f.pageid!,
|
||||
text: f.title!,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
const fpids = new Set(f_new.map((f: Followup) => f.pageid));
|
||||
const followups = (f_old: Followup[]): Followup[] => {
|
||||
const f_old_filtered = f_old.filter(
|
||||
(f) => f.pageid !== data.pageid && !fpids.has(f.pageid)
|
||||
);
|
||||
return [...f_new, ...f_old_filtered].slice(0, MAX_FOLLOWUPS);
|
||||
};
|
||||
|
||||
return { followups, result };
|
||||
};
|
||||
|
||||
export const runSearch = async (
|
||||
query: string,
|
||||
query_source: "search" | "followups",
|
||||
mode: string,
|
||||
baseReferencesIndex: number,
|
||||
entries: Entry[],
|
||||
setCurrent: (c: CurrentSearch) => void
|
||||
): SearchResult => {
|
||||
if (query_source === "search") {
|
||||
const history = entries
|
||||
.filter((entry) => entry.role !== "error")
|
||||
.map((entry) => ({
|
||||
role: entry.role,
|
||||
content: entry.content.trim(),
|
||||
}));
|
||||
|
||||
return await queryLLM(
|
||||
query,
|
||||
mode,
|
||||
history,
|
||||
baseReferencesIndex,
|
||||
setCurrent
|
||||
);
|
||||
} else {
|
||||
// ----------------- HUMAN AUTHORED CONTENT RETRIEVAL ------------------
|
||||
const [questionId] = query.split("\n", 2);
|
||||
if (questionId) {
|
||||
return await getStampyContent(questionId);
|
||||
}
|
||||
const result = {
|
||||
role: "error",
|
||||
content: "Could not extract Stampy id from " + query,
|
||||
};
|
||||
return { result } as SearchResult;
|
||||
}
|
||||
};
|
||||
+49
-206
@@ -5,6 +5,7 @@ import Image from 'next/image';
|
||||
|
||||
import Page from "../components/page"
|
||||
import { API_URL } from "../settings"
|
||||
import { queryLLM, getStampyContent, runSearch } from "../hooks/useSearch";
|
||||
import type { Citation, Entry, UserEntry, AssistantEntry, ErrorMessage, StampyMessage } from "../types";
|
||||
import { SearchBox, Followup } from "../components/searchbox";
|
||||
import { GlossarySpan } from "../components/glossary";
|
||||
@@ -32,18 +33,21 @@ type Mode = "rookie" | "concise" | "default";
|
||||
// smooth-scroll to the bottom of the window if we're already less than 30% a screen away
|
||||
// note: finicky interaction with "smooth" - maybe fix later.
|
||||
function scroll30() {
|
||||
if (document.documentElement.scrollHeight - window.scrollY > window.innerHeight * 1.3) return;
|
||||
window.scrollTo({top: document.body.scrollHeight, behavior: "smooth"});
|
||||
if (
|
||||
document.documentElement.scrollHeight - window.scrollY >
|
||||
window.innerHeight * 1.3
|
||||
)
|
||||
return;
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const Home: NextPage = () => {
|
||||
|
||||
const [ entries, setEntries ] = useState<Entry[]>([]);
|
||||
const [ runningIndex, setRunningIndex ] = useState(0);
|
||||
const [ loadState, setLoadState ] = useState<State>({state: "idle"});
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [runningIndex, setRunningIndex] = useState(0);
|
||||
const [current, setCurrent] = useState<CurrentSearch>();
|
||||
|
||||
// [state, ready to save to localstorage]
|
||||
const [ mode, setMode ] = useState<[Mode, boolean]>(["default", false]);
|
||||
const [mode, setMode] = useState<[Mode, boolean]>(["default", false]);
|
||||
|
||||
// store mode in localstorage
|
||||
useEffect(() => {
|
||||
@@ -56,6 +60,12 @@ const Home: NextPage = () => {
|
||||
setMode([mode, true]);
|
||||
}, []);
|
||||
|
||||
const updateCurrent = (current: CurrentSearch) => {
|
||||
setCurrent(current);
|
||||
if (current?.phase === "streaming") {
|
||||
scroll30();
|
||||
}
|
||||
};
|
||||
|
||||
const search = async (
|
||||
query: string,
|
||||
@@ -65,214 +75,47 @@ const Home: NextPage = () => {
|
||||
) => {
|
||||
|
||||
// clear the query box, append to entries
|
||||
const userEntry: Entry = {
|
||||
role: "user",
|
||||
content: query_source === "search" ? query : query.split("\n", 2)[1]!,
|
||||
};
|
||||
setEntries((prev) => [...prev, userEntry]);
|
||||
disable();
|
||||
|
||||
const old_entries = entries;
|
||||
const new_entries: Entry[] = [...old_entries, {
|
||||
role: "user",
|
||||
content: query_source === "search" ? query : query.split("\n", 2)[1]!,
|
||||
}];
|
||||
setEntries(new_entries);
|
||||
disable();
|
||||
const { result, followups } = await runSearch(
|
||||
query,
|
||||
query_source,
|
||||
mode[0],
|
||||
runningIndex,
|
||||
entries,
|
||||
updateCurrent
|
||||
);
|
||||
setCurrent(undefined);
|
||||
|
||||
|
||||
// ----------------------------- LLM BASED -----------------------------
|
||||
if (query_source === "search") {
|
||||
// do SSE on a POST request.
|
||||
const res = await fetch(API_URL + "/chat", {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"Allow-Control-Allow-Origin": "*"
|
||||
},
|
||||
|
||||
body: JSON.stringify({query: query, mode: mode[0], history:
|
||||
old_entries.filter((entry) => entry.role !== "error")
|
||||
.map((entry) => {
|
||||
return {
|
||||
"role" : entry.role,
|
||||
"content" : entry.content.trim(),
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
enable([]);
|
||||
setLoadState({state: "idle"});
|
||||
setEntries([...new_entries, {role: "error", content: "POST Error: " + res.status}]);
|
||||
return;
|
||||
if (query_source === "search") {
|
||||
setRunningIndex(runningIndex + ProcessText(result.content, 0)[1].size);
|
||||
}
|
||||
|
||||
// read back the SSE stream
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
var message = "";
|
||||
var followups: Followup[] = [];
|
||||
read: while (true) {
|
||||
|
||||
const {done, value} = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
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. Lots of json overhead.
|
||||
|
||||
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);
|
||||
// Fixes #43
|
||||
if (!line.startsWith("data: ") && line !== "") message += line;
|
||||
if (line === "") {
|
||||
if (message !== "") {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
switch (data.state) {
|
||||
|
||||
case "loading":
|
||||
|
||||
// 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: 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
|
||||
}};
|
||||
});
|
||||
|
||||
scroll30();
|
||||
break;
|
||||
|
||||
case "done":
|
||||
|
||||
// append the response to the entries, reset to normal
|
||||
setLoadState((s) => {
|
||||
if (s.state === "streaming") {
|
||||
setEntries([...new_entries, s.response]);
|
||||
setRunningIndex((i) => (i + ProcessText(s.response.content, 0)[1].size));
|
||||
}
|
||||
|
||||
return {state: "idle"};
|
||||
});
|
||||
|
||||
// add any potential followup questions
|
||||
var i = 0;
|
||||
while ('followup_' + i in data) {
|
||||
followups = [...followups, data['followup_' + i]];
|
||||
i++;
|
||||
}
|
||||
|
||||
break read;
|
||||
|
||||
case "error":
|
||||
setEntries([...new_entries, {role: "error", content: data.error}]);
|
||||
setLoadState({state: "idle"});
|
||||
break read;
|
||||
|
||||
}
|
||||
}
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enable(followups);
|
||||
setEntries((prev) => [...prev, result]);
|
||||
enable(followups || []);
|
||||
scroll30();
|
||||
|
||||
} else {
|
||||
// ----------------- HUMAN AUTHORED CONTENT RETRIEVAL ------------------
|
||||
const query_id = query.split("\n", 2)[0];
|
||||
|
||||
const res = await fetch(API_URL + "/human/" + query_id, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Allow-Control-Allow-Origin": "*"
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
enable([]);
|
||||
setLoadState({state: "idle"});
|
||||
setEntries([...new_entries, {role: "error", content: "POST Error: " + res.status}]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()).data;
|
||||
|
||||
setEntries([...new_entries, {
|
||||
role: "stampy",
|
||||
content: data.text,
|
||||
url: "https://aisafety.info/?state=" + data.pageid,
|
||||
}]);
|
||||
|
||||
// re-enable the searchbox, with the question that was just answered
|
||||
// removed from the list of possible followups.
|
||||
|
||||
// create an array of new followup questions from the data
|
||||
const f_new = data.relatedQuestions.map((f: any) => { return {
|
||||
pageid: f.pageid!,
|
||||
text: f.title!,
|
||||
score: 0
|
||||
};});
|
||||
|
||||
const fpids = new Set(f_new.map((f: Followup) => f.pageid));
|
||||
|
||||
enable((f_old: Followup[]) => {
|
||||
const f_old_filtered = f_old.filter((f) => f.pageid !== data.pageid && !fpids.has(f.pageid));
|
||||
return [...f_new, ...f_old_filtered].slice(0, MAX_FOLLOWUPS); // this is correct, it's N and not N-1 in javascript fsr
|
||||
});
|
||||
|
||||
scroll30();
|
||||
}
|
||||
};
|
||||
|
||||
var last_entry = <></>;
|
||||
if (loadState.state === "loading") {
|
||||
switch (loadState.phase) {
|
||||
case "semantic": last_entry = <p>Loading: Performing semantic search...</p>; break;
|
||||
case "prompt": last_entry = <p>Loading: Creating prompt...</p>; break;
|
||||
case "llm": last_entry = <p>Loading: Waiting for LLM...</p>; break;
|
||||
}
|
||||
} else if (loadState.state === "streaming") {
|
||||
last_entry = <ShowAssistantEntry entry={loadState.response}/>;
|
||||
switch (current?.phase) {
|
||||
case "semantic":
|
||||
last_entry = <p>Loading: Performing semantic search...</p>;
|
||||
break;
|
||||
case "prompt":
|
||||
last_entry = <p>Loading: Creating prompt...</p>;
|
||||
break;
|
||||
case "llm":
|
||||
last_entry = <p>Loading: Waiting for LLM...</p>;
|
||||
break;
|
||||
case "streaming":
|
||||
last_entry = <ShowAssistantEntry entry={current} />;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Page page="index">
|
||||
<Controls mode={mode} setMode={setMode} />
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
|
||||
export const STAMPY_URL = process.env.STAMPY_URL || "https://aisafety.info"
|
||||
export const STAMPY_CONTENT_URL = process.env.STAMPY_CONTENT_URL || `${API_URL}/human`
|
||||
|
||||
Reference in New Issue
Block a user