This commit is contained in:
2025-10-02 08:16:11 +01:00
parent 6d833c7d3b
commit 979728e6c8
30 changed files with 695 additions and 100 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Card({
ref={elRef}
{...props}
className={twMerge(
"flex flex-row items-center p-4 rounded-default bg-white dark:bg-white/10",
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
"border border-slate-200 dark:border-white/10 border-solid",
noHover ? "" : "twui-card",
props.className
+59
View File
@@ -0,0 +1,59 @@
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import { Copy, LucideProps } from "lucide-react";
import Button from "../layout/Button";
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
slugText: string;
justIcon?: boolean;
noIcon?: boolean;
title?: string;
outlined?: boolean;
successMsg?: string | ReactNode;
icon?: ReactNode;
iconProps?: LucideProps;
setToastOpen?: Dispatch<SetStateAction<boolean>>;
};
export default function CopySlug({
slugText,
justIcon,
noIcon,
title,
outlined,
successMsg,
iconProps,
icon,
setToastOpen,
...props
}: Props) {
return (
<Button
title={title || slugText}
size="smaller"
variant="ghost"
color="gray"
{...props}
onClick={(e) => {
navigator.clipboard.writeText(slugText).then(() => {
setToastOpen?.(false);
setTimeout(() => {
setToastOpen?.(true);
}, 100);
});
props.onClick?.(e);
}}
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
>
{noIcon
? null
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
{!justIcon && (title ? title : "Copy Slug")}
</Button>
);
}
@@ -103,8 +103,8 @@ export default function HeaderNavLinkComponent({
<Dropdown
target={mainLinkComponent}
position="bottom-right"
// hoverOpen
position="center"
hoverOpen
className="hidden xl:flex"
>
{dropdown ? (
+2 -1
View File
@@ -130,7 +130,8 @@ export default function LinkList({
{...link.linkProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className
linkProps?.className,
link.linkProps?.className
)}
strict={link.strict}
onClick={(e) => {
+1 -1
View File
@@ -23,7 +23,7 @@ export default function Paper({
{...props}
ref={componentRef as any}
className={twMerge(
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full",
"relative",
"twui-paper",
+3 -1
View File
@@ -39,7 +39,9 @@ export default function Search<KeyType extends string>({
placeholder,
...props
}: SearchProps<KeyType>) {
const [input, setInput] = React.useState("");
const [input, setInput] = React.useState(
props.defaultValue?.toString() || ""
);
React.useEffect(() => {
clearTimeout(timeout);
+5
View File
@@ -26,6 +26,7 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
*/
switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
changeHandler?: (value: TWUITabsObject) => void;
};
/**
@@ -43,6 +44,7 @@ export default function Tabs({
debounce = 100,
switchComponent,
setActiveValue: existingSetActiveValue,
changeHandler,
...props
}: TWUI_TOGGLE_PROPS) {
const finalTabsContentArray = tabsContentArray
@@ -70,6 +72,9 @@ export default function Tabs({
React.useEffect(() => {
existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) {
changeHandler?.(targetContent);
}
}, [activeValue]);
return (
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Tag({
? "bg-orange-700 outline-orange-700"
: color == "gray"
? twMerge(
"bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20",
"bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
"text-slate-800 dark:text-white"
)
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
@@ -0,0 +1,81 @@
import { Send, X } from "lucide-react";
import React, { Dispatch, SetStateAction } from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
type Props = {
streamRes: string;
setStreamRes: Dispatch<SetStateAction<string>>;
setPrompt: Dispatch<SetStateAction<string>>;
loading: boolean;
promptFn: (prompt: string) => void;
history: ChatCompletionMessageParam[];
prompt: string;
currentPromptRef: React.MutableRefObject<string>;
promptInputRef: React.RefObject<HTMLTextAreaElement>;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4">
{streamRes.match(/./) && (
<React.Fragment>
<Button
title="Clear AI Result"
variant="ghost"
size="smaller"
color="gray"
className="px-0"
beforeIcon={<X size={20} />}
onClick={() => {
setStreamRes("");
}}
/>
<CopySlug
slugText={streamRes}
justIcon
iconProps={{ size: 18 }}
title="Copy Content"
/>
</React.Fragment>
)}
</Row>
<Row>
<AIPromptHistoryModal history={history} />
</Row>
<Row>
<Button
title="Send Prompt"
beforeIcon={<Send size={20} />}
loading={loading}
className="p-2"
onClick={() => {
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}}
loadingProps={{ size: "smaller" }}
/>
</Row>
</Row>
);
}
@@ -0,0 +1,98 @@
import { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
<Stack className="w-full">
{currentPromptRef.current && (
<Row className="w-full justify-end">
<Card className="py-1.5 px-2.5 text-xs">
<Row>
<Span>{currentPromptRef.current}</Span>
<MessageCircleMore
size={15}
opacity={0.5}
className="-mt-[1px]"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
/>
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={model ? `Prompt ${model}` : "Prompt AI"}
wrapperProps={{ className: "outline-none" }}
wrapperWrapperProps={{ className: "w-full" }}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey) {
e.preventDefault();
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}
}}
componentRef={promptInputRef}
autoFocus
/>
<AIPromptActionSection
loading={loading}
promptFn={promptFn}
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
prompt={prompt}
setPrompt={setPrompt}
currentPromptRef={currentPromptRef}
promptInputRef={promptInputRef as any}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,99 @@
import React from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Modal from "../Modal";
import Button from "../../layout/Button";
import Stack from "../../layout/Stack";
import H2 from "../../layout/H2";
import Span from "../../layout/Span";
import Divider from "../../layout/Divider";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
type Props = {
history: ChatCompletionMessageParam[];
};
export default function AIPromptHistoryModal({ history }: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="outlined"
>
View History
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-10"
>
<Stack className="gap-10 w-full">
<Stack className="gap-1">
<H2 className="!text-xl m-0">Chat History</H2>
<Span className="text-xs">
AI chat history for this session.
</Span>
</Stack>
<Divider />
{history.map((hst, index) => {
if (hst.role == "user") {
return (
<Row
key={index}
className="w-full items-start justify-end"
>
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light"
)}
>
{hst.content?.toString()}
</Card>
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
</Row>
);
}
return (
<Row
key={index}
className="w-full items-start flex-nowrap"
>
<Stack>
<Border
className={twMerge(
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
"dark:bg-background-dark"
)}
>
<Bot />
</Border>
</Stack>
<Card className="grow overflow-x-auto xl:p-8">
<MarkdownEditorPreviewComponent
value={hst.content?.toString() || ""}
maxHeight="none"
wrapperProps={{
className:
"border-none p-0 ai-response-content w-full",
}}
/>
</Card>
</Row>
);
})}
</Stack>
</Modal>
);
}
@@ -0,0 +1,51 @@
import Divider from "@/src/components/twui/layout/Divider";
import Stack from "@/src/components/twui/layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "@/src/components/twui/mdx/markdown/MarkdownEditorPreviewComponent";
import { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
}: Props) {
const responseContentRef = React.useRef<HTMLDivElement>(null);
const isContentInterrupted = React.useRef(false);
React.useEffect(() => {
if (isContentInterrupted.current) return;
if (responseContentRef.current) {
responseContentRef.current.scrollTop =
responseContentRef.current.scrollHeight;
}
}, [streamRes]);
if (!streamRes?.match(/./)) return null;
return (
<Stack className="w-full">
<MarkdownEditorPreviewComponent
value={streamRes}
maxHeight="40vh"
wrapperProps={{
className: "border-none p-0 ai-response-content",
componentRef: responseContentRef as any,
onMouseEnter: () => {
isContentInterrupted.current = true;
},
onMouseLeave: () => {
isContentInterrupted.current = false;
},
}}
/>
<Divider />
</Stack>
);
}