564 lines
15 KiB
TypeScript
564 lines
15 KiB
TypeScript
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", "chatyuan", "chatglm"];
|
||
|
||
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>
|
||
);
|
||
}
|