first commit
This commit is contained in:
111
src/components/search/answer.tsx
Normal file
111
src/components/search/answer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
src/components/search/footer.tsx
Normal file
37
src/components/search/footer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/components/search/logo.tsx
Normal file
15
src/components/search/logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/components/search/popover.tsx
Normal file
31
src/components/search/popover.tsx
Normal 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 };
|
||||
17
src/components/search/preset-query.tsx
Normal file
17
src/components/search/preset-query.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
src/components/search/relates.tsx
Normal file
41
src/components/search/relates.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
src/components/search/result.tsx
Normal file
74
src/components/search/result.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
src/components/search/search.tsx
Normal file
43
src/components/search/search.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/components/search/skeleton.tsx
Normal file
13
src/components/search/skeleton.tsx
Normal 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 };
|
||||
70
src/components/search/sources.tsx
Normal file
70
src/components/search/sources.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
src/components/search/title.tsx
Normal file
30
src/components/search/title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/components/search/wrapper.tsx
Normal file
13
src/components/search/wrapper.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user