vaalachat/pages/index.tsx
2023-05-08 15:27:31 +08:00

564 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Head from "next/head";
import Image from "next/image";
import { Inter } from "@next/font/google";
import styles from "@/styles/Home.module.css";
import {
QueryClientProvider,
QueryClient,
useQuery,
useMutation,
} from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useKey } from "react-use";
import cn from "clsx";
// import rehypePrettyCode from "rehype-pretty-code";
// import * as shiki from "shiki";
import remarkGfm from "remark-gfm";
import styled from "styled-components";
// import rehypeHighlight from "rehype-highlight";
// @ts-ignore
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
// @ts-ignore
import theme from "react-syntax-highlighter/dist/cjs/styles/prism/vs-dark";
// const CDN_BASE = "https://npm.elemecdn.com/";
// shiki.setCDN(`${CDN_BASE}/shiki@0.14.1/`);
const SMD = styled(ReactMarkdown)`
blockquote,
hr,
p {
margin-block: 1rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
margin-block: 1rem;
}
h1 {
font-size: 3rem;
color: var(--h1-color);
}
h2 {
font-size: 2.5rem;
color: var(--h2-color);
}
h3 {
font-size: 2rem;
color: var(--h3-color);
}
h4 {
font-size: 1.5rem;
color: var(--h4-color);
}
h5 {
font-size: 1rem;
color: var(--h5-color);
}
h6 {
font-size: 0.9rem;
color: var(--h6-color);
}
img {
display: block;
margin-left: auto;
margin-right: auto;
}
.img-alt {
display: block;
margin: 0 0 1rem 0;
font-size: 16px;
text-align: center;
}
th {
font-weight: 600;
}
thead {
border-bottom: 2px solid var(--background-modifier-border);
}
tr {
line-height: normal;
padding: 0 4px;
background-color: var(--pre-code);
}
tr:nth-child(0) {
padding-top: 4px;
}
th,
td {
padding: 0.5em 1em;
}
td {
border-bottom: 1px solid var(--background-modifier-border);
}
td:not(:last-child) {
border-right: 1px solid var(--background-modifier-border);
}
strong {
font-weight: 600;
}
a {
color: var(--text-a);
text-decoration: none;
}
a:hover {
color: var(--text-a-hover);
text-decoration: none;
}
blockquote {
margin: 1rem 0;
padding-inline: 2ch;
padding-block: 0.5rem;
background-color: var(--pre-code);
border-left: 0.5ch solid var(--interactive-accent);
}
blockquote:has(> blockquote) {
padding-bottom: 0;
}
blockquote > blockquote {
margin-bottom: 0;
}
blockquote:not(:has(> blockquote)) {
margin-bottom: 1rem;
}
hr {
background-color: var(--background-modifier-border);
height: 1px;
border: 0;
}
ul {
list-style-type: revert;
}
ol {
list-style-type: decimal;
}
ul:not(.contains-task-list),
ol:not(.contains-task-list) {
padding-left: 2em;
}
ul.contains-task-list,
ol.contains-task-list {
margin-left: 0;
list-style-type: none;
}
`;
const TextTyper = ({
// now the phrase, interval and HTML element desired will come via props and we have some default values here
text = "",
skip = 0,
onFinish = () => {},
}) => {
const [typedText, setTypedText] = useState("");
const interval = 50;
const step = Math.ceil(text.length / 100);
// @ts-ignore
const typingRender = (text, updater, interval) => {
let localTypingIndex = skip;
let localTyping = text.slice(0, skip);
if (text) {
let printer = setInterval(() => {
if (localTypingIndex < text.length) {
updater(
(localTyping += text.slice(
localTypingIndex,
localTypingIndex + step
))
);
localTypingIndex += step;
document.querySelector("#anchor")?.scrollIntoView();
} else {
localTypingIndex = 0;
localTyping = "";
clearInterval(printer);
onFinish();
}
}, interval);
}
};
useEffect(() => {
typingRender(text, setTypedText, interval);
}, [text, interval]);
return <div className="whitespace-pre-wrap">{typedText}</div>;
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
const p429 = "请求过多,请稍后再试";
const rehypePlugins: any[] = [
// [
// rehypePrettyCode,
// {
// // theme: {
// // dark: "github-dark",
// // light: "github-light",
// // },
// theme: "github-dark",
// keepBackground: true,
// },
// ],
// rehypeHighlight,
];
const remarkPlugins = [remarkGfm];
const components = {
// @ts-ignore
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={theme}
language={match[1] || "clike"}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
};
const Markdown = ({ children }: { children: string }) => {
return (
<SMD
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
// components={components}
>
{children}
</SMD>
);
};
const ask = async (
query: string,
context: [string, string][],
model: string,
jwt?: string
) => {
const res = await fetch(`/ai/ask`, {
method: "POST",
headers: {
Authorization: jwt,
} as Record<string, string>,
body: JSON.stringify({
query: query,
context: context
.slice(1)
.filter((c) => c[1] !== p429)
.map((c) => ({ query: c[0], answer: c[1] })),
model,
}),
});
if (res.status === 429) {
return { code: 1, msg: p429 };
}
return (await res.json()) as { code: number; msg: string };
};
let contexts = [
[
"hello",
"您好!我是小Vaala。我可以回答您的问题、写文章、写作业、翻译对于一些法律等领域的问题我也可以给你提供信息。",
],
];
const models = ["newbing", "chatvaala"];
function Home() {
const [model, setModel] = useState(models[0]);
const [query, setQuery] = useState("");
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState("");
const [context, setContext] = useState<[string, string][]>(
contexts as [string, string][]
);
const { data: jwt } = useQuery(["jwt"], async () => {
const resp = await fetch(`/auth/jwt`);
const res = (await resp.json())["authorization"];
return res;
});
useKey("Enter", (e) => {
if (!e.shiftKey && !e.metaKey && !e.ctrlKey && !e.isComposing) {
e.preventDefault();
handleAsk.mutate();
}
});
const handleTypeFinish = useCallback(() => {
setQuestion("");
setContext((v) => [...v, [question, answer]]);
setAnswer("");
document.querySelector("#anchor")?.scrollIntoView();
}, [query, answer]);
const handleAsk = useMutation(async () => {
if (query.length === 0) return;
setQuestion(query);
setQuery("");
setTimeout(() => document.querySelector("#anchor")?.scrollIntoView(), 100);
(async () => {
const ans = await ask(query, context, model, jwt || "").finally(() => {
setTimeout(
() => document.querySelector("#anchor")?.scrollIntoView(),
300
);
});
if (ans.code) alert(ans.msg);
else setAnswer(ans.msg);
})();
});
return (
<>
<Head>
<title>Vaala Chat</title>
<meta name="description" content="Chat to small vaala" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<style
dangerouslySetInnerHTML={{
__html: `.lds-dual-ring {
display: inline-block;
width: 18px;
height: 18px;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 18px;
height: 18px;
margin: 0 0 0 8px;
border-radius: 50%;
border: 2px solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
pre > code {
white-space: pre-wrap;
}
`,
}}
/>
</Head>
<main
className={cn(styles.main, "px-2 md:px-16 xl:px-64")}
style={{ width: "100dvw", height: "100dvh" }}
>
<div className="flex justify-end items-center w-full h-12">
<label
htmlFor="model"
className="mr-2 block text-sm font-medium text-gray-900 dark:text-white"
>
Model:
</label>
<select
id="model"
style={{
// @ts-ignore
"-webkit-appearance": "none",
"-moz-appearance": "none",
appearance: "none",
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%238C98F2'><polygon points='0,0 100,0 50,50'/></svg>")`,
backgroundSize: "14px",
backgroundPosition: "calc(100% - 10px) 9px",
backgroundRepeat: "no-repeat",
}}
className="w-48 bg-gray-50 border border-gray-300 text-gray-900 text-sm focus:ring-blue-500 focus:border-blue-500 block w-full py-1 pl-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
onChange={(e) => setModel(e.target.value)}
>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
<div
style={{
maxHeight: "calc(100dvh - 123px)",
overflowY: "auto",
}}
>
{context?.map((qa, i) => {
return (
<div
key={i}
className="flex w-full flex-col items-stretch justify-start"
>
<div className="p-4 w-full bg-white dark:bg-slate-800">
<Markdown>{`ME: ${qa[0]}`}</Markdown>
</div>
<div className="p-4 w-full bg-slate-50 dark:bg-slate-900">
{qa[1] === p429 ? (
<span className="text-red-500">{qa[1]}</span>
) : (
<Markdown
// remarkPlugins={[remarkGfm]}
>
{`🐳: ${qa[1]}`}
</Markdown>
)}
</div>
</div>
);
})}
{question || answer ? (
<>
<div className="p-4 w-full bg-white dark:bg-slate-800">
<Markdown>{`ME: ${question}`}</Markdown>
</div>
<div
className="p-4 w-full flex items-center bg-slate-50 dark:bg-slate-900"
style={{ lineHeight: "18px" }}
>
{answer ? (
<TextTyper
skip={3}
text={`🐳: ${answer}`}
onFinish={handleTypeFinish}
/>
) : (
<>
<div>{`🐳: 思考中`}</div>
<svg
// xmlns="http://www.w3.org/2000/svg"
// xmlns:xlink="http://www.w3.org/1999/xlink"
// style="margin: auto; background: rgb(241, 242, 243); display: block;"
width="18px"
height="18px"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<circle cx="27.5" cy="57.5" r="5" fill="#85a2b6">
<animate
attributeName="cy"
calcMode="spline"
keySplines="0 0.5 0.5 1;0.5 0 1 0.5;0.5 0.5 0.5 0.5"
repeatCount="indefinite"
values="57.5;42.5;57.5;57.5"
keyTimes="0;0.3;0.6;1"
dur="1s"
begin="-0.6s"
></animate>
</circle>{" "}
<circle cx="42.5" cy="57.5" r="5" fill="#bbcedd">
<animate
attributeName="cy"
calcMode="spline"
keySplines="0 0.5 0.5 1;0.5 0 1 0.5;0.5 0.5 0.5 0.5"
repeatCount="indefinite"
values="57.5;42.5;57.5;57.5"
keyTimes="0;0.3;0.6;1"
dur="1s"
begin="-0.44999999999999996s"
></animate>
</circle>{" "}
<circle cx="57.5" cy="57.5" r="5" fill="#dce4eb">
<animate
attributeName="cy"
calcMode="spline"
keySplines="0 0.5 0.5 1;0.5 0 1 0.5;0.5 0.5 0.5 0.5"
repeatCount="indefinite"
values="57.5;42.5;57.5;57.5"
keyTimes="0;0.3;0.6;1"
dur="1s"
begin="-0.3s"
></animate>
</circle>{" "}
<circle cx="72.5" cy="57.5" r="5" fill="#fdfdfd">
<animate
attributeName="cy"
calcMode="spline"
keySplines="0 0.5 0.5 1;0.5 0 1 0.5;0.5 0.5 0.5 0.5"
repeatCount="indefinite"
values="57.5;42.5;57.5;57.5"
keyTimes="0;0.3;0.6;1"
dur="1s"
begin="-0.15s"
></animate>
</circle>
</svg>
</>
)}
</div>
</>
) : null}
<div id="anchor" className="w-full h-1"></div>
</div>
<div
className="fixed bottom-0 p-4 w-full flex justify-center items-center bg-gray-50 dark:bg-gray-900 border-t border-solid border-sky-400"
style={{ width: "100vw" }}
>
<textarea
rows={1}
className="flex-1 border-sky-500 border border-solid focus:border-sky-300 focus:outline-none rounded p-2"
placeholder="..."
value={query}
onChange={(e) => setQuery(e.target.value)}
></textarea>
<button
type="submit"
className="text-white bg-sky-500 border-sky-500 border border-solid rounded h-12 p-2 ml-4 hover:border-sky-600 hover:bg-sky-600"
onClick={() => handleAsk.mutate()}
onSubmit={() => handleAsk.mutate()}
>
Send
</button>
<button
className="text-white bg-sky-500 border-sky-500 border border-solid rounded h-12 p-2 ml-4 hover:border-sky-600 hover:bg-sky-600"
onClick={() => setContext(contexts as any)}
>
Clear
</button>
</div>
</main>
</>
);
}
export default function Wrapper() {
return (
<QueryClientProvider client={queryClient}>
<Home />
</QueryClientProvider>
);
}