mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 11:37:47 +01:00
style: adjust search & chat ui (#8)
This commit is contained in:
@@ -21,12 +21,19 @@
|
|||||||
"i18next": "^23.16.2",
|
"i18next": "^23.16.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
|
"mermaid": "^11.4.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hotkeys-hook": "^4.5.1",
|
"react-hotkeys-hook": "^4.5.1",
|
||||||
"react-i18next": "^15.1.0",
|
"react-i18next": "^15.1.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
|
"rehype-highlight": "^7.0.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36,6 +43,7 @@
|
|||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@types/react-i18next": "^8.1.0",
|
"@types/react-i18next": "^8.1.0",
|
||||||
|
"@types/react-katex": "^3.0.4",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
|||||||
1433
pnpm-lock.yaml
generated
1433
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { SendHorizontal, OctagonX } from "lucide-react";
|
import { SendHorizontal, OctagonX, Filter, Upload } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
@@ -6,17 +6,20 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import ChatSwitch from "../SearchChat/ChatSwitch";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
disabledChange: (disabled: boolean) => void;
|
disabledChange: (disabled: boolean) => void;
|
||||||
|
changeMode: (isChatMode: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
disabledChange,
|
disabledChange,
|
||||||
|
changeMode,
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -87,9 +90,20 @@ export function ChatInput({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex justify-between items-center p-2 rounded-xl overflow-hidden">
|
||||||
Press Enter to send, Shift + Enter for new line
|
<div className="flex gap-3 text-xs">
|
||||||
</p>
|
<button className="inline-flex items-center px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300">
|
||||||
|
<Filter className="w-4 h-4 mr-2" />问 Coco
|
||||||
|
</button>
|
||||||
|
<button className="inline-flex items-center px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Switch */}
|
||||||
|
<ChatSwitch isChat={true} changeMode={changeMode} />
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { Message } from "./types";
|
import type { Message } from "./types";
|
||||||
import { TypingAnimation } from "./TypingAnimation";
|
import { TypingAnimation } from "./TypingAnimation";
|
||||||
|
// import { Markdown } from "./Markdown";
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -54,6 +55,12 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
|
|||||||
text={message._source?.message || ""}
|
text={message._source?.message || ""}
|
||||||
onComplete={() => setIsAnimationComplete(true)}
|
onComplete={() => setIsAnimationComplete(true)}
|
||||||
/>
|
/>
|
||||||
|
{/* <Markdown
|
||||||
|
key={isTyping ? "loading" : "done"}
|
||||||
|
content={(message._source?.message || "")}
|
||||||
|
loading={isTyping}
|
||||||
|
onDoubleClickCapture={() => {}}
|
||||||
|
/> */}
|
||||||
{!isAnimationComplete && (
|
{!isAnimationComplete && (
|
||||||
<span className="inline-block w-1.5 h-4 ml-0.5 -mb-0.5 bg-current animate-pulse" />
|
<span className="inline-block w-1.5 h-4 ml-0.5 -mb-0.5 bg-current animate-pulse" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
37
src/components/ChatAI/FullScreen.tsx
Normal file
37
src/components/ChatAI/FullScreen.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { Button } from "@headlessui/react";
|
||||||
|
import { Minimize2, Maximize2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function FullScreen(props: any) {
|
||||||
|
const { children, right = 10, top = 10, ...rest } = props;
|
||||||
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
const [fullScreen, setFullScreen] = useState(false);
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
ref.current?.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScreenChange = (e: any) => {
|
||||||
|
if (e.target === ref.current) {
|
||||||
|
setFullScreen(!!document.fullscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: "relative" }} {...rest}>
|
||||||
|
<div style={{ position: "absolute", right, top }}>
|
||||||
|
<Button onClick={toggleFullscreen}>
|
||||||
|
{fullScreen ? <Minimize2 /> : <Maximize2 />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/components/ChatAI/Markdown.tsx
Normal file
314
src/components/ChatAI/Markdown.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import React, { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
// import "katex/dist/katex.min.css";
|
||||||
|
import RemarkMath from "remark-math";
|
||||||
|
import RemarkBreaks from "remark-breaks";
|
||||||
|
import RehypeKatex from "rehype-katex";
|
||||||
|
import RemarkGfm from "remark-gfm";
|
||||||
|
import RehypeHighlight from "rehype-highlight";
|
||||||
|
import mermaid from "mermaid";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
import { copyToClipboard, useWindowSize } from "../../utils";
|
||||||
|
|
||||||
|
export function Mermaid(props: { code: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.code && ref.current) {
|
||||||
|
mermaid
|
||||||
|
.run({
|
||||||
|
nodes: [ref.current],
|
||||||
|
suppressErrors: true,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setHasError(true);
|
||||||
|
console.error("[Mermaid] ", e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [props.code]);
|
||||||
|
|
||||||
|
function viewSvgInNewWindow() {
|
||||||
|
const svg = ref.current?.querySelector("svg");
|
||||||
|
if (!svg) return;
|
||||||
|
const text = new XMLSerializer().serializeToString(svg);
|
||||||
|
const blob = new Blob([text], { type: "image/svg+xml" });
|
||||||
|
// view img
|
||||||
|
// URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("no-dark", "mermaid")}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => viewSvgInNewWindow()}
|
||||||
|
>
|
||||||
|
{props.code}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7
|
||||||
|
export function PreCode(props: { children: any }) {
|
||||||
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
|
// const previewRef = useRef<HTMLPreviewHander>(null);
|
||||||
|
const [mermaidCode, setMermaidCode] = useState("");
|
||||||
|
const [htmlCode, setHtmlCode] = useState("");
|
||||||
|
const { height } = useWindowSize();
|
||||||
|
|
||||||
|
const renderArtifacts = useDebouncedCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||||
|
if (mermaidDom) {
|
||||||
|
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||||
|
}
|
||||||
|
const htmlDom = ref.current.querySelector("code.language-html");
|
||||||
|
const refText = ref.current.querySelector("code")?.innerText;
|
||||||
|
if (htmlDom) {
|
||||||
|
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||||
|
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||||
|
setHtmlCode(refText);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
const enableArtifacts = true;
|
||||||
|
|
||||||
|
//Wrap the paragraph for plain-text
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const codeElements = ref.current.querySelectorAll(
|
||||||
|
"code"
|
||||||
|
) as NodeListOf<HTMLElement>;
|
||||||
|
const wrapLanguages = [
|
||||||
|
"",
|
||||||
|
"md",
|
||||||
|
"markdown",
|
||||||
|
"text",
|
||||||
|
"txt",
|
||||||
|
"plaintext",
|
||||||
|
"tex",
|
||||||
|
"latex",
|
||||||
|
];
|
||||||
|
codeElements.forEach((codeElement) => {
|
||||||
|
let languageClass = codeElement.className.match(/language-(\w+)/);
|
||||||
|
let name = languageClass ? languageClass[1] : "";
|
||||||
|
if (wrapLanguages.includes(name)) {
|
||||||
|
codeElement.style.whiteSpace = "pre-wrap";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(renderArtifacts, 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<pre ref={ref}>
|
||||||
|
<span
|
||||||
|
className="copy-code-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (ref.current) {
|
||||||
|
copyToClipboard(
|
||||||
|
ref.current.querySelector("code")?.innerText ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{props.children}
|
||||||
|
</pre>
|
||||||
|
{mermaidCode.length > 0 && (
|
||||||
|
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6
|
||||||
|
function CustomCode(props: { children: any; className?: string }) {
|
||||||
|
const enableCodeFold = false;
|
||||||
|
|
||||||
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [showToggle, setShowToggle] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const codeHeight = ref.current.scrollHeight;
|
||||||
|
setShowToggle(codeHeight > 400);
|
||||||
|
ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [props.children]);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((collapsed) => !collapsed);
|
||||||
|
};
|
||||||
|
const renderShowMoreButton = () => {
|
||||||
|
if (showToggle && enableCodeFold && collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("show-hide-button", {
|
||||||
|
collapsed,
|
||||||
|
expanded: !collapsed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button onClick={toggleCollapsed}>{"NewChat More"}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
className={clsx(props?.className)}
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{renderShowMoreButton()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5
|
||||||
|
function escapeBrackets(text: string) {
|
||||||
|
const pattern =
|
||||||
|
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
|
||||||
|
return text.replace(
|
||||||
|
pattern,
|
||||||
|
(match, codeBlock, squareBracket, roundBracket) => {
|
||||||
|
if (codeBlock) {
|
||||||
|
return codeBlock;
|
||||||
|
} else if (squareBracket) {
|
||||||
|
return `$$${squareBracket}$$`;
|
||||||
|
} else if (roundBracket) {
|
||||||
|
return `$${roundBracket}$`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4
|
||||||
|
function tryWrapHtmlCode(text: string) {
|
||||||
|
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||||
|
return text
|
||||||
|
.replace(
|
||||||
|
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
||||||
|
(match, quoteStart, lang, newLine, doctype) => {
|
||||||
|
return !quoteStart ? "\n```html\n" + doctype : match;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
|
||||||
|
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
|
||||||
|
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3
|
||||||
|
function _MarkDownContent(props: { content: string }) {
|
||||||
|
const escapedContent = useMemo(() => {
|
||||||
|
return tryWrapHtmlCode(escapeBrackets(props.content));
|
||||||
|
}, [props.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||||
|
rehypePlugins={[
|
||||||
|
RehypeKatex,
|
||||||
|
[
|
||||||
|
RehypeHighlight,
|
||||||
|
{
|
||||||
|
detect: false,
|
||||||
|
ignoreMissing: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
components={{
|
||||||
|
pre: PreCode,
|
||||||
|
code: CustomCode,
|
||||||
|
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||||
|
a: (aProps) => {
|
||||||
|
const href = aProps.href || "";
|
||||||
|
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||||
|
return (
|
||||||
|
<figure>
|
||||||
|
<audio controls src={href}></audio>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||||
|
return (
|
||||||
|
<video controls width="99.9%">
|
||||||
|
<source src={href} />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isInternal = /^\/#/i.test(href);
|
||||||
|
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
||||||
|
return <a {...aProps} target={target} />;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{escapedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2
|
||||||
|
export const MarkdownContent = React.memo(_MarkDownContent);
|
||||||
|
|
||||||
|
// 1
|
||||||
|
export function Markdown(
|
||||||
|
props: {
|
||||||
|
content: string;
|
||||||
|
loading?: boolean;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
|
defaultShow?: boolean;
|
||||||
|
} & React.DOMAttributes<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="markdown-body"
|
||||||
|
style={{
|
||||||
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
|
fontFamily: props.fontFamily || "inherit",
|
||||||
|
}}
|
||||||
|
ref={mdRef}
|
||||||
|
onContextMenu={props.onContextMenu}
|
||||||
|
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{props.loading ? (
|
||||||
|
<div className="flex gap-2 items-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.2s]" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.4s]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownContent content={props.content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
interface TypingAnimationProps {
|
interface TypingAnimationProps {
|
||||||
@@ -31,5 +32,13 @@ export function TypingAnimation({
|
|||||||
|
|
||||||
// console.log("text", text);
|
// console.log("text", text);
|
||||||
|
|
||||||
return <ReactMarkdown>{text}</ReactMarkdown>;
|
// return <ReactMarkdown>{text}</ReactMarkdown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
className="prose" // 使用 Tailwind 的 `prose` 类来美化 Markdown
|
||||||
|
children={text}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Menu, Loader } from "lucide-react";
|
import {
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
|
MessageSquarePlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
// import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { ChatMessage } from "./ChatMessage";
|
import { ChatMessage } from "./ChatMessage";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import type { Chat, Message } from "./types";
|
import type { Chat, Message } from "./types";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../ThemeProvider";
|
||||||
import ChatSwitch from "../SearchChat/ChatSwitch";
|
|
||||||
import { Footer } from "../SearchChat/Footer";
|
import { Footer } from "../SearchChat/Footer";
|
||||||
import { tauriFetch } from "../../api/tauriFetchClient";
|
import { tauriFetch } from "../../api/tauriFetchClient";
|
||||||
import { useWebSocket } from "../../hooks/useWebSocket";
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
||||||
@@ -55,7 +59,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// websocket
|
|
||||||
// websocket
|
// websocket
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0 || !activeChat?._id) return;
|
if (messages.length === 0 || !activeChat?._id) return;
|
||||||
@@ -220,27 +223,35 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen pb-8 rounded-xl overflow-hidden">
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="h-screen pb-8 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
<div className="h-[100%] flex">
|
<div className="h-[100%] flex">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
{isSidebarOpen ? (
|
||||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
|
<div
|
||||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
|
||||||
} transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block ${
|
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
theme === "dark" ? "bg-gray-800" : "bg-gray-100"
|
} transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block ${
|
||||||
}`}
|
theme === "dark" ? "bg-gray-800" : "bg-gray-100"
|
||||||
>
|
}`}
|
||||||
{activeChat ? (
|
>
|
||||||
<Sidebar
|
{activeChat ? (
|
||||||
chats={chats}
|
<Sidebar
|
||||||
activeChat={activeChat}
|
chats={chats}
|
||||||
isDark={theme === "dark"}
|
activeChat={activeChat}
|
||||||
onNewChat={createNewChat}
|
isDark={theme === "dark"}
|
||||||
onSelectChat={onSelectChat}
|
onNewChat={createNewChat}
|
||||||
onDeleteChat={deleteChat}
|
onSelectChat={onSelectChat}
|
||||||
/>
|
onDeleteChat={deleteChat}
|
||||||
) : null}
|
/>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div
|
<div
|
||||||
@@ -248,31 +259,45 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
theme === "dark" ? "bg-gray-900" : "bg-white"
|
theme === "dark" ? "bg-gray-900" : "bg-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<motion.div
|
||||||
<header
|
initial={{ opacity: 0, y: -20 }}
|
||||||
className={`flex items-center justify-between p-4 border-b ${
|
animate={{ opacity: 1, y: 0 }}
|
||||||
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
exit={{ opacity: 0, y: -20 }}
|
||||||
}`}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<button
|
<header
|
||||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
className={`flex items-center justify-between p-4 border-b ${
|
||||||
className={`md:hidden p-2 rounded-lg transition-colors ${
|
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
||||||
theme === "dark"
|
|
||||||
? "hover:bg-gray-800 text-gray-300"
|
|
||||||
: "hover:bg-gray-100 text-gray-600"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Menu className="h-6 w-6" />
|
<button
|
||||||
</button>
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
<div className="flex-1">
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
<ChatSwitch isChat={true} changeMode={changeMode} />
|
theme === "dark"
|
||||||
</div>
|
? "hover:bg-gray-800 text-gray-300"
|
||||||
|
: "hover:bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<PanelRightClose className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<PanelRightOpen className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<ThemeToggle />
|
{/* <ThemeToggle /> */}
|
||||||
</header>
|
<MessageSquarePlus className="cursor-pointer" onClick={createNewChat} />
|
||||||
|
</header>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Chat messages */}
|
{/* Chat messages */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="flex-1 overflow-y-auto custom-scrollbar"
|
||||||
|
>
|
||||||
{activeChat?.messages?.map((message, index) => (
|
{activeChat?.messages?.map((message, index) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message._id + index}
|
key={message._id + index}
|
||||||
@@ -298,11 +323,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{/* {isTyping && (
|
|
||||||
<div className="flex pt-0 pb-4 pl-20">
|
|
||||||
<Loader className="animate-spin h-5 w-5 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
{isTyping && (
|
{isTyping && (
|
||||||
<div className="flex pt-0 pb-4 pl-20 gap-2 items-center text-gray-500 dark:text-gray-400">
|
<div className="flex pt-0 pb-4 pl-20 gap-2 items-center text-gray-500 dark:text-gray-400">
|
||||||
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
|
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
|
||||||
@@ -311,10 +331,14 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div
|
<motion.div
|
||||||
|
initial={{ y: 100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: 100 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
className={`border-t p-4 ${
|
className={`border-t p-4 ${
|
||||||
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
||||||
}`}
|
}`}
|
||||||
@@ -323,12 +347,13 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
|||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isTyping}
|
disabled={isTyping}
|
||||||
disabledChange={setIsTyping}
|
disabledChange={setIsTyping}
|
||||||
|
changeMode={changeMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer isChat={true} />
|
<Footer isChat={true} />
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getCurrentWebviewWindow,
|
getCurrentWebviewWindow,
|
||||||
} from "@tauri-apps/api/webviewWindow";
|
} from "@tauri-apps/api/webviewWindow";
|
||||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { SearchResults } from "./SearchResults";
|
import { SearchResults } from "./SearchResults";
|
||||||
import { Footer } from "./Footer";
|
import { Footer } from "./Footer";
|
||||||
@@ -62,13 +63,17 @@ function Search({ changeMode }: SearchProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
className={`min-h-screen flex items-start justify-center ${
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
className={`min-h-screen bg-opacity-0 flex items-start justify-center rounded-xl overflow-hidden ${
|
||||||
tags.length > 0 ? "pb-8" : ""
|
tags.length > 0 ? "pb-8" : ""
|
||||||
} rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-900`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-full space-y-4 rounded-xl overflow-hidden">
|
<div className="w-full rounded-xl overflow-hidden">
|
||||||
<div className="border b-t-none border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
<div className="border b-t-none bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-2 focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-900 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-all">
|
<div className="flex items-center bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-2 focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-900 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-all">
|
||||||
@@ -123,11 +128,29 @@ function Search({ changeMode }: SearchProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Results Panel */}
|
{/* Search Results Panel */}
|
||||||
{tags.length > 0 ? <SearchResults /> : null}
|
{tags.length > 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<SearchResults />
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tags.length > 0 ? <Footer isChat={false} /> : null}
|
{tags.length > 0 ? (
|
||||||
</div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Footer isChat={false} />
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const SearchResults: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mt-4 overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mt-4 overflow-hidden">
|
||||||
<div className="flex h-[calc(100vh-220px)]">
|
<div className="flex h-[calc(100vh-130px)]">
|
||||||
{/* Left Panel */}
|
{/* Left Panel */}
|
||||||
<div className="w-[420px] border-r border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
|
<div className="w-[420px] border-r border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
|
||||||
<div className="px-4 flex-shrink-0">
|
<div className="px-4 flex-shrink-0">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, Fragment } from "react";
|
import { useState } from "react";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||||
|
import { AnimatePresence, LayoutGroup } from "framer-motion";
|
||||||
|
|
||||||
import Search from "./Search";
|
import Search from "./Search";
|
||||||
import ChatAI from "../ChatAI";
|
import ChatAI from "../ChatAI";
|
||||||
@@ -9,20 +10,22 @@ export default function SearchChat() {
|
|||||||
const [isChatMode, setIsChatMode] = useState(true);
|
const [isChatMode, setIsChatMode] = useState(true);
|
||||||
|
|
||||||
async function changeMode(value: boolean) {
|
async function changeMode(value: boolean) {
|
||||||
|
setIsChatMode(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 800));
|
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 800));
|
||||||
} else {
|
} else {
|
||||||
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 110));
|
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 110));
|
||||||
}
|
}
|
||||||
setIsChatMode(value);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<LayoutGroup>
|
||||||
{isChatMode ? (
|
<AnimatePresence mode="wait">
|
||||||
<ChatAI changeMode={changeMode} />
|
{isChatMode ? (
|
||||||
) : (
|
<ChatAI key="ChatAI" changeMode={changeMode} />
|
||||||
<Search changeMode={changeMode} />
|
) : (
|
||||||
)}
|
<Search key="Search" changeMode={changeMode} />
|
||||||
</Fragment>
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</LayoutGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/index.d.ts
vendored
32
src/index.d.ts
vendored
@@ -20,3 +20,35 @@ declare global {
|
|||||||
__TAURI__: Record<string, unknown>;
|
__TAURI__: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare interface Window {
|
||||||
|
__TAURI__?: {
|
||||||
|
writeText(text: string): Promise<void>;
|
||||||
|
invoke(command: string, payload?: Record<string, unknown>): Promise<any>;
|
||||||
|
dialog: {
|
||||||
|
save(options?: Record<string, unknown>): Promise<string | null>;
|
||||||
|
};
|
||||||
|
fs: {
|
||||||
|
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
|
||||||
|
writeTextFile(path: string, data: string): Promise<void>;
|
||||||
|
};
|
||||||
|
notification: {
|
||||||
|
requestPermission(): Promise<Permission>;
|
||||||
|
isPermissionGranted(): Promise<boolean>;
|
||||||
|
sendNotification(options: string | Options): void;
|
||||||
|
};
|
||||||
|
updater: {
|
||||||
|
checkUpdate(): Promise<UpdateResult>;
|
||||||
|
installUpdate(): Promise<void>;
|
||||||
|
onUpdaterEvent(
|
||||||
|
handler: (status: UpdateStatusResult) => void,
|
||||||
|
): Promise<UnlistenFn>;
|
||||||
|
};
|
||||||
|
http: {
|
||||||
|
fetch<T>(
|
||||||
|
url: string,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
): Promise<Response<T>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@
|
|||||||
@apply box-border border-[--border];
|
@apply box-border border-[--border];
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body,
|
||||||
|
#root {
|
||||||
@apply text-gray-900 rounded-lg shadow-lg overflow-hidden antialiased;
|
@apply text-gray-900 rounded-lg shadow-lg overflow-hidden antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
.dark body,
|
||||||
|
.dark #root{
|
||||||
@apply text-gray-100 rounded-lg shadow-lg overflow-hidden antialiased;
|
@apply text-gray-100 rounded-lg shadow-lg overflow-hidden antialiased;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/utils/index.ts
Normal file
52
src/utils/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// 1
|
||||||
|
export async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
if (window.__TAURI__) {
|
||||||
|
window.__TAURI__.writeText(text);
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("Copy Success");
|
||||||
|
} catch (error) {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
console.info("Copy Success");
|
||||||
|
} catch (error) {
|
||||||
|
console.info("Copy Failed");
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2
|
||||||
|
export function useWindowSize() {
|
||||||
|
const [size, setSize] = useState({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => {
|
||||||
|
setSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
@@ -5,14 +5,12 @@
|
|||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user