Package library as @moduletrace/twui npm package

- Move components/ to src/ and compile to dist/ with tsc
- Add root barrel export plus deep-import subpath exports and base.css export
- Post-compile step: copy base.css, add .js import extensions, mark client modules
- Add publish.sh and .npmrc for the Gitea npm registry
- Fix type errors, guard TinyMCE env access, drop ace-builds type dependency
- Rename clashing props type names, remove Next.js scaffolding and unused deps

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 10:17:54 +01:00
co-authored by Claude Opus 5.5
parent d19d412066
commit 11da9b89e7
148 changed files with 937 additions and 500 deletions
+108
View File
@@ -0,0 +1,108 @@
import { Send, X } from "lucide-react";
import React, { type Dispatch, type SetStateAction } from "react";
import type { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
import AIVoiceTranscription from "./AIVoiceTranscription";
import type { VoiceTranscriptionParams } from "./types";
type Props = VoiceTranscriptionParams & {
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>;
user_image_url?: string;
last_response?: string;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
user_image_url,
transcribing,
voice_transcription_fn,
last_response,
cancel_voice_transcription_fn,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4 px-2">
<AIVoiceTranscription
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
history={history}
last_response={last_response}
/>
{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>
)}
<AIPromptHistoryModal
history={history}
user_image_url={user_image_url}
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
/>
</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" }}
disabled={transcribing}
/>
</Row>
</Row>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
import AIPromptBlockInputSection from "./AIPromptBlockInputSection";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: 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-px"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
loading={loading}
/>
<AIPromptBlockInputSection
{...{
model,
promptFn,
history,
loading,
mdRes,
setMdRes,
placeholder,
}}
/>
</Stack>
</Paper>
);
}
@@ -0,0 +1,73 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Stack from "../../layout/Stack";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlockInputSection({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={
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.shiftKey) {
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>
);
}
@@ -0,0 +1,77 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Stack from "../../layout/Stack";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import Img from "../../layout/Img";
import type { VoiceTranscriptionParams } from "./types";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import AIVoiceTranscription from "./AIVoiceTranscription";
import Span from "../../layout/Span";
import CopySlug from "../CopySlug";
import EmptyContent from "../EmptyContent";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam;
user_image_url?: string;
};
export default function AIPromptHistoryAssistantCard({
history,
user_image_url,
cancel_voice_transcription_fn,
transcribing,
voice_transcription_fn,
}: Props) {
const text_content = history.content?.toString();
if (!text_content) {
return <EmptyContent title="No Text response content" />;
}
return (
<Row 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>
<Stack className="grow items-stretch">
<Card className="w-full overflow-x-auto xl:p-8">
<Stack className="twui-llm-chat-block">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{history.content?.toString()}
</ReactMarkdown>
</Stack>
</Card>{" "}
<Row>
<AIVoiceTranscription
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
history={[history]}
last_response={history.content?.toString()}
/>
<CopySlug
slugText={text_content}
justIcon
iconProps={{ size: 18, opacity: 0.7 }}
title="Copy Content"
/>
</Row>
</Stack>
</Row>
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { History } 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 type { VoiceTranscriptionParams } from "./types";
import AIPromptHistoryUserCard from "./AIPromptHistoryUserCard";
import AIPromptHistoryAssistantCard from "./AIPromptHistoryAssistantCard";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam[];
user_image_url?: string;
};
export default function AIPromptHistoryModal({
history,
user_image_url,
...props
}: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="ghost"
className="p-1!"
>
<History size={20} opacity={0.7} />
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
>
<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 (
<AIPromptHistoryUserCard
history={hst}
{...props}
key={index}
user_image_url={user_image_url}
/>
);
}
return (
<AIPromptHistoryAssistantCard
history={hst}
{...props}
key={index}
user_image_url={user_image_url}
/>
);
})}
</Stack>
</Modal>
);
}
@@ -0,0 +1,48 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { User } from "lucide-react";
import Stack from "../../layout/Stack";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import Img from "../../layout/Img";
import type { VoiceTranscriptionParams } from "./types";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam;
user_image_url?: string;
};
export default function AIPromptHistoryUserCard({
history,
user_image_url,
...props
}: Props) {
return (
<Row className="w-full items-start justify-end flex-nowrap">
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light",
"twui-llm-user-prompt-history-block",
)}
>
<Stack className="twui-llm-chat-block">
{history.content?.toString()}
</Stack>
</Card>
{user_image_url ? (
<Img
alt="Prompting user"
size={30}
circle
src={user_image_url}
/>
) : (
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
)}
</Row>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Divider from "../../layout/Divider";
import Stack from "../../layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
import type { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
loading?: boolean;
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
loading,
}: 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 (loading || !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>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { Speech, Voicemail, X } from "lucide-react";
import Button from "../../layout/Button";
import type { ChatCompletionMessageParam } from "openai/resources";
import Row from "../../layout/Row";
import Loading from "../Loading";
import Border from "../Border";
type Props = {
voice_transcription_fn?: (params: { last_message?: string }) => void;
transcribing?: boolean;
history?: ChatCompletionMessageParam[];
last_response?: string;
cancel_voice_transcription_fn?: () => void;
};
export default function AIVoiceTranscription({
transcribing,
voice_transcription_fn,
history,
last_response,
cancel_voice_transcription_fn,
}: Props) {
if (!voice_transcription_fn) return null;
if (transcribing) {
return (
<Border className="px-1.5! py-0.5!">
<Row className="gap-2!">
<Loading size="small" />
<Button
title="Cancel Transcription"
className="p-1!"
onClick={(e) => {
e.preventDefault();
cancel_voice_transcription_fn?.();
}}
variant="ghost"
>
<X size={20} opacity={0.7} />
</Button>
</Row>
</Border>
);
}
return (
<Button
title="Listen to Speech"
size="smaller"
color="gray"
variant="ghost"
loading={transcribing}
onClick={(e) => {
e.preventDefault();
const history_last_response = history?.find(
(h) => h.role == "assistant",
);
voice_transcription_fn({
last_message:
last_response ||
history_last_response?.content?.toString(),
});
}}
className="p-1!"
>
<Speech size={20} opacity={0.7} />
</Button>
);
}
+5
View File
@@ -0,0 +1,5 @@
export type VoiceTranscriptionParams = {
transcribing?: boolean;
cancel_voice_transcription_fn?: () => void;
voice_transcription_fn?: (params: { last_message?: string }) => void;
};