first commit

This commit is contained in:
Vaala Cat
2024-02-02 23:21:43 +08:00
commit 0b86be3426
52 changed files with 4390 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/search/popover";
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Source } from "@/types/source";
import { BookOpenText } from "lucide-react";
import { FC } from "react";
import Markdown from "react-markdown";
export const Answer: FC<{ markdown: string; sources: Source[] }> = ({
markdown,
sources,
}) => {
return (
<Wrapper
title={
<>
<BookOpenText></BookOpenText>
</>
}
content={
markdown && markdown.trim().length > 0 ? (
<div className="prose prose-sm max-w-full">
<Markdown
components={{
a: ({ node: _, ...props }) => {
if (!props.href) return <></>;
const source = sources[+props.href - 1];
if (!source) return <></>;
return (
<span className="inline-block w-4">
<Popover>
<PopoverTrigger asChild>
<span
title={source.name}
className="inline-block cursor-pointer transform scale-[60%] no-underline font-medium bg-zinc-300 hover:bg-zinc-400 w-6 text-center h-6 rounded-full origin-top-left"
>
{props.href}
</span>
</PopoverTrigger>
<PopoverContent
align={"start"}
className="max-w-screen-md flex flex-col gap-2 bg-white shadow-transparent ring-zinc-50 ring-4 text-xs"
>
<div className="text-ellipsis overflow-hidden whitespace-nowrap font-medium">
{source.name}
</div>
<div className="flex gap-4">
{source.primaryImageOfPage?.thumbnailUrl && (
<div className="flex-none">
<img
className="rounded h-16 w-16"
width={source.primaryImageOfPage?.width}
height={source.primaryImageOfPage?.height}
src={source.primaryImageOfPage?.thumbnailUrl}
/>
</div>
)}
<div className="flex-1">
<div className="line-clamp-4 text-zinc-500 break-words">
{source.snippet}
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="flex-1 overflow-hidden">
<div className="text-ellipsis text-blue-500 overflow-hidden whitespace-nowrap">
<a
title={source.name}
href={source.url}
target="_blank"
>
{source.url}
</a>
</div>
</div>
<div className="flex-none flex items-center relative">
<img
className="h-3 w-3"
alt={source.url}
src={`https://www.google.com/s2/favicons?domain=${source.url}&sz=${16}`}
/>
</div>
</div>
</PopoverContent>
</Popover>
</span>
);
},
}}
>
{markdown}
</Markdown>
</div>
) : (
<div className="flex flex-col gap-2">
<Skeleton className="max-w-sm h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-lg h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-2xl h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-lg h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-xl h-4 bg-zinc-200"></Skeleton>
</div>
)
}
></Wrapper>
);
};

View File

@@ -0,0 +1,37 @@
import { Mails } from "lucide-react";
import { FC } from "react";
export const Footer: FC = () => {
return (
<div className="text-center flex flex-col items-center text-xs text-zinc-700 gap-1">
<div className="text-zinc-400">
VaalaAI不为您提供任何保证
</div>
<div className="text-zinc-400">
Vaala/VaalaAI ,
<a className="text-blue-500" href="https://github.com/leptonai/search_with_lepton">Lepton AI</a>
</div>
<div className="flex gap-2 justify-center">
<div></div>
<div>
<a
className="text-blue-500 font-medium inline-flex gap-1 items-center flex-nowrap text-nowrap"
href="mailto:me@vaala.cat"
>
<Mails size={8} />
</a>
</div>
</div>
<div className="flex items-center justify-center flex-wrap gap-x-4 gap-y-2 mt-2 text-zinc-400">
<a className="hover:text-zinc-950" href="https://api.vaa.la/">
VaalaAI
</a>
<a className="hover:text-zinc-950" href="https://vaala.cat/">
Blog
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { FC } from "react";
export const Logo: FC = () => {
return (
<div className="flex gap-4 items-center justify-center cursor-default select-none relative">
<img src="https://vaala.cat/favicon.ico" alt="vaala logo" className="h-10 w-10"></img>
<div className="text-center font-medium text-2xl md:text-3xl text-zinc-950 relative text-nowrap">
VaalaAI Search
</div>
<div className="transform scale-75 origin-left border items-center rounded-lg bg-gray-100 px-2 py-1 text-xs font-medium text-zinc-600">
beta
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/utils/cn";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,17 @@
import { getSearchUrl } from "@/utils/get-search-url";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
import { FC } from "react";
export const PresetQuery: FC<{ query: string }> = ({ query }) => {
const router = useRouter()
return (
<div
title={query}
className="border border-zinc-200/50 text-ellipsis overflow-hidden text-nowrap items-center rounded-lg bg-zinc-100 hover:bg-zinc-200/80 hover:text-zinc-950 px-2 py-1 text-xs font-medium text-zinc-600"
onClick={() => { router.push(getSearchUrl(encodeURIComponent(query), nanoid())) }}
>
{query}
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { PresetQuery } from "@/components/search/preset-query";
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Relate } from "@/types/relate";
import { MessageSquareQuote } from "lucide-react";
import { FC } from "react";
export const Relates: FC<{ relates: Relate[] | null }> = ({ relates }) => {
return (
<Wrapper
title={
<>
<MessageSquareQuote></MessageSquareQuote>
</>
}
content={
<div className="flex gap-2 flex-col">
{relates !== null ? (
relates.length > 0 ? (
relates.map(({ question }) => (
<PresetQuery key={question} query={question}></PresetQuery>
))
) : (
<div className="text-sm">.</div>
)
) : (
<>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
</>
)}
<div className="text-xs text-zinc-500 text-center mt-4">
<div>VaalaAI回答完成后</div>
<div>20</div>
</div>
</div>
}
></Wrapper>
);
};

View File

@@ -0,0 +1,74 @@
"use client";
import { Answer } from "@/components/search/answer";
import { Relates } from "@/components/search/relates";
import { Sources } from "@/components/search/sources";
import { Relate } from "@/types/relate";
import { Source } from "@/types/source";
import { parseStreaming } from "@/utils/parse-streaming";
import { Annoyed, Mails } from "lucide-react";
import { FC, useEffect, useState } from "react";
export const Result: FC<{ query: string; rid: string, updateQuery: (query: string) => void }> = ({ query, rid, updateQuery }) => {
const [sources, setSources] = useState<Source[]>([]);
const [markdown, setMarkdown] = useState<string>("");
const [relates, setRelates] = useState<Relate[] | null>(null);
const [error, setError] = useState<number | null>(null);
const [serverQuery, setServerQuery] = useState("");
useEffect(() => {
const controller = new AbortController();
void parseStreaming(
controller,
query,
rid,
"query",
setSources,
setMarkdown,
setRelates,
setServerQuery,
setError,
);
return () => {
controller.abort();
};
}, [query, rid]);
useEffect(() => {
updateQuery(serverQuery);
}, [serverQuery, updateQuery]);
return (
<div className="flex flex-col gap-8" >
<Answer markdown={markdown} sources={sources}></Answer>
<Sources sources={sources}></Sources>
<Relates relates={relates}></Relates>
{error && (
<div className="absolute inset-4 flex items-center justify-center bg-white/40 backdrop-blur-sm">
<div className="p-4 bg-white shadow-2xl rounded text-blue-500 font-medium flex gap-4">
<Annoyed></Annoyed>
{error === 429
? "抱歉您的请求过于频繁Vaala被累死了QwQ请稍后再试吧。"
: <div className="text-center">{
error === 400 ?
<div></div>
: (error === 401 ?
<div></div>
: (error === 410 ?
(true ? <div></div> : <div></div>)
: (<div>Vaala紧急修复中
<div>
<a
className="text-blue-500 font-medium inline-flex gap-1 items-center flex-nowrap text-nowrap"
href="mailto:me@vaala.cat"
>
<Mails size={8} />
</a>
</div>
</div>)))
}</div>}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,43 @@
"use client";
import { getSearchUrl } from "@/utils/get-search-url";
import { ArrowRight } from "lucide-react";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
import { FC, useState } from "react";
export const Search: FC<{ autofucs?: boolean }> = ({ autofucs }) => {
const [value, setValue] = useState("");
const router = useRouter()
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (value) {
setValue("");
router.push(getSearchUrl(encodeURIComponent(value), nanoid()));
}
}}
>
<label
className="relative bg-white flex items-center justify-center border ring-8 ring-zinc-300/20 py-2 px-2 rounded-lg gap-2 focus-within:border-zinc-300"
htmlFor="search-bar"
>
<input
id="search-bar"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus={autofucs}
placeholder="询问Vaala任何问题 ..."
className="px-2 pr-6 text-sm w-full rounded-md flex-1 outline-none bg-white"
/>
<button
type="submit"
key={Math.random()}
className="w-auto py-1 px-2 bg-black border-black text-white fill-white active:scale-95 border overflow-hidden relative rounded-xl"
>
<ArrowRight size={16} />
</button>
</label>
</form>
);
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/utils/cn";
import { HTMLAttributes } from "react";
function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,70 @@
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Source } from "@/types/source";
import { BookText } from "lucide-react";
import { FC } from "react";
const SourceItem: FC<{ source: Source; index: number }> = ({
source,
index,
}) => {
const { id, name, url } = source;
const domain = new URL(url).hostname;
return (
<div
className="relative text-xs py-3 px-3 bg-zinc-100 hover:bg-zinc-200 rounded-lg flex flex-col gap-2"
key={id}
>
<a href={url} target="_blank" className="absolute inset-0"></a>
<div className="font-medium text-zinc-950 text-ellipsis overflow-hidden whitespace-nowrap break-words">
{name}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1 overflow-hidden">
<div className="text-ellipsis whitespace-nowrap break-all text-zinc-400 overflow-hidden w-full">
{index + 1} - {domain}
</div>
</div>
<div className="flex-none flex items-center">
<img
className="h-3 w-3"
alt={domain}
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=${16}`}
/>
</div>
</div>
</div>
);
};
export const Sources: FC<{ sources: Source[] }> = ({ sources }) => {
return (
<Wrapper
title={
<>
<BookText></BookText>
</>
}
content={
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{sources.length > 0 ? (
sources.map((item, index) => (
<SourceItem
key={item.id}
index={index}
source={item}
></SourceItem>
))
) : (
<>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
</>
)}
</div>
}
></Wrapper>
);
};

View File

@@ -0,0 +1,30 @@
"use client";
import { getSearchUrl } from "@/utils/get-search-url";
import { RefreshCcw } from "lucide-react";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
export const Title = ({ query }: { query: string }) => {
const router = useRouter()
return (
<div className="flex items-center pb-4 mb-6 border-b gap-4">
<div
className="flex-1 text-lg sm:text-xl text-black text-ellipsis overflow-hidden whitespace-nowrap"
title={query}
>
{query}
</div>
<div className="flex-none">
<button
onClick={() => {
router.push(getSearchUrl(encodeURIComponent(query), nanoid()));
}}
type="button"
className="rounded flex gap-2 items-center bg-transparent px-2 py-1 text-xs font-semibold text-blue-500 hover:bg-zinc-100"
>
<RefreshCcw size={12}></RefreshCcw>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,13 @@
import { FC, ReactNode } from "react";
export const Wrapper: FC<{
title: ReactNode;
content: ReactNode;
}> = ({ title, content }) => {
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-2 text-blue-500">{title}</div>
{content}
</div>
);
};