mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 03:27:43 +01:00
style: adjust search & chat ui (#8)
This commit is contained in:
@@ -21,12 +21,19 @@
|
||||
"i18next": "^23.16.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.453.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.5.1",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36,6 +43,7 @@
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-i18next": "^8.1.0",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"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 {
|
||||
useState,
|
||||
type FormEvent,
|
||||
@@ -6,17 +6,20 @@ import {
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import ChatSwitch from "../SearchChat/ChatSwitch";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled: boolean;
|
||||
disabledChange: (disabled: boolean) => void;
|
||||
changeMode: (isChatMode: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
disabledChange,
|
||||
changeMode,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -87,9 +90,20 @@ export function ChatInput({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Press Enter to send, Shift + Enter for new line
|
||||
</p>
|
||||
<div className="flex justify-between items-center p-2 rounded-xl overflow-hidden">
|
||||
<div className="flex gap-3 text-xs">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
|
||||
import type { Message } from "./types";
|
||||
import { TypingAnimation } from "./TypingAnimation";
|
||||
// import { Markdown } from "./Markdown";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
@@ -54,6 +55,12 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
|
||||
text={message._source?.message || ""}
|
||||
onComplete={() => setIsAnimationComplete(true)}
|
||||
/>
|
||||
{/* <Markdown
|
||||
key={isTyping ? "loading" : "done"}
|
||||
content={(message._source?.message || "")}
|
||||
loading={isTyping}
|
||||
onDoubleClickCapture={() => {}}
|
||||
/> */}
|
||||
{!isAnimationComplete && (
|
||||
<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 ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import "./index.css";
|
||||
interface TypingAnimationProps {
|
||||
@@ -31,5 +32,13 @@ export function TypingAnimation({
|
||||
|
||||
// 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 { 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 { ChatInput } from "./ChatInput";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import type { Chat, Message } from "./types";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import ChatSwitch from "../SearchChat/ChatSwitch";
|
||||
import { Footer } from "../SearchChat/Footer";
|
||||
import { tauriFetch } from "../../api/tauriFetchClient";
|
||||
import { useWebSocket } from "../../hooks/useWebSocket";
|
||||
@@ -55,7 +59,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
}
|
||||
);
|
||||
|
||||
// websocket
|
||||
// websocket
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 || !activeChat?._id) return;
|
||||
@@ -220,27 +223,35 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
};
|
||||
|
||||
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">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
|
||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
} 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
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
isDark={theme === "dark"}
|
||||
onNewChat={createNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{isSidebarOpen ? (
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
|
||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
} 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
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
isDark={theme === "dark"}
|
||||
onNewChat={createNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
@@ -248,31 +259,45 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
theme === "dark" ? "bg-gray-900" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={`flex items-center justify-between p-4 border-b ${
|
||||
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
||||
}`}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className={`md:hidden p-2 rounded-lg transition-colors ${
|
||||
theme === "dark"
|
||||
? "hover:bg-gray-800 text-gray-300"
|
||||
: "hover:bg-gray-100 text-gray-600"
|
||||
<header
|
||||
className={`flex items-center justify-between p-4 border-b ${
|
||||
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<ChatSwitch isChat={true} changeMode={changeMode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
theme === "dark"
|
||||
? "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 />
|
||||
</header>
|
||||
{/* <ThemeToggle /> */}
|
||||
<MessageSquarePlus className="cursor-pointer" onClick={createNewChat} />
|
||||
</header>
|
||||
</motion.div>
|
||||
|
||||
{/* 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) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
@@ -298,11 +323,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
/>
|
||||
) : null}
|
||||
{/* Loading */}
|
||||
{/* {isTyping && (
|
||||
<div className="flex pt-0 pb-4 pl-20">
|
||||
<Loader className="animate-spin h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
)} */}
|
||||
{isTyping && (
|
||||
<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" />
|
||||
@@ -311,10 +331,14 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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 ${
|
||||
theme === "dark" ? "border-gray-800" : "border-gray-200"
|
||||
}`}
|
||||
@@ -323,12 +347,13 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
|
||||
onSend={handleSendMessage}
|
||||
disabled={isTyping}
|
||||
disabledChange={setIsTyping}
|
||||
changeMode={changeMode}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer isChat={true} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCurrentWebviewWindow,
|
||||
} from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { Footer } from "./Footer";
|
||||
@@ -62,13 +63,17 @@ function Search({ changeMode }: SearchProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen flex items-start justify-center ${
|
||||
<motion.div
|
||||
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" : ""
|
||||
} rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-900`}
|
||||
}`}
|
||||
>
|
||||
<div className="w-full space-y-4 rounded-xl overflow-hidden">
|
||||
<div className="border b-t-none border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="w-full 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 */}
|
||||
<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">
|
||||
@@ -123,11 +128,29 @@ function Search({ changeMode }: SearchProps) {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{tags.length > 0 ? <Footer isChat={false} /> : null}
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<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 (
|
||||
<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 */}
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, Fragment } from "react";
|
||||
import { useState } from "react";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
import { AnimatePresence, LayoutGroup } from "framer-motion";
|
||||
|
||||
import Search from "./Search";
|
||||
import ChatAI from "../ChatAI";
|
||||
@@ -9,20 +10,22 @@ export default function SearchChat() {
|
||||
const [isChatMode, setIsChatMode] = useState(true);
|
||||
|
||||
async function changeMode(value: boolean) {
|
||||
setIsChatMode(value);
|
||||
if (value) {
|
||||
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 800));
|
||||
} else {
|
||||
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 110));
|
||||
}
|
||||
setIsChatMode(value);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{isChatMode ? (
|
||||
<ChatAI changeMode={changeMode} />
|
||||
) : (
|
||||
<Search changeMode={changeMode} />
|
||||
)}
|
||||
</Fragment>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence mode="wait">
|
||||
{isChatMode ? (
|
||||
<ChatAI key="ChatAI" changeMode={changeMode} />
|
||||
) : (
|
||||
<Search key="Search" changeMode={changeMode} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/index.d.ts
vendored
32
src/index.d.ts
vendored
@@ -20,3 +20,35 @@ declare global {
|
||||
__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];
|
||||
}
|
||||
|
||||
body {
|
||||
body,
|
||||
#root {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
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"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
Reference in New Issue
Block a user