mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-30 18:10:28 +08:00
5882f60c32
* update package locks to avoid conflict * Refactor AI Generation with AI SDK v5 (#210) * refactor(wip): Whole Chat implementation * fix: Deps * chore: Update types and create tool to handle theme generation * chore: Add hook and component to handle feedback text * chore: Check for themeStyles in asssiatant metadata * feat: Improve messages styles and feedback * chore: Update react-hook-form deps * chore: Improve Messages display * chore: Show a banner when theme is generating * chore: Optimize the Messages syncing * feat: Add debug button to MessageActions in dev mode * chore: messages * chore: Improve System prompt + tools + utils * fix: correctly display User messages with only images * feat: Handle errors in UI and adapt to AI sdk * feat: Use tool output instead of custom data parts * chore: System prompt and schema context * chore: Gemini 2.5 pro as the default model * refactor: Change name to Chat Context and apply generated theme automatically * chore: Update ai packages to latest version * feat: Stop ongoing request before starting a new chat * feat: Refactor logic for Scroll start/end sentinels * feat: Improve UI streaming with AI Elements and Stream Text utils * chore: Generate theme types and logic * feat: Allow customize the speed of streaming text * refactor: Theme generation utils * fix: Avoid duplicated Mention references * styles: Improve Chat error banner * feat: Enhance Prompt core implementation * feat: Implement enhance prompt in components * chore: Add TODOS comments * chore: Remove Openai provider * chore: Update AI adk packages * fix: Free request constant --------- Co-authored-by: Sahaj Jain <82111591+jnsahaj@users.noreply.github.com> * update lockfile * fix import * fix types * chore: Trigger theme generation before transitioning to editor page * refactor: Move AI sdk specific code into Lib and update imports * chore: Provide model ID to record usage util * refactor: Multi model provider convention * fix types * refactor: Use Zustand persist middleware versioning * feat: Improve prompts and context * feat: Optimize streaming JSONContent into text area * chore: Improve Theme Generation prompt * feat: Improve prompt * feat: Add subscription checks for enhancing prompt and Posthog tracking --------- Co-authored-by: Luis Llanes <137589205+llanesluis@users.noreply.github.com> Co-authored-by: Luis Llanes <luisllaboj@gmail.com>
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { suggestion } from "@/components/editor/mention-suggestion";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import { cn } from "@/lib/utils";
|
|
import CharacterCount from "@tiptap/extension-character-count";
|
|
import Mention from "@tiptap/extension-mention";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import { EditorContent, JSONContent, useEditor } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import { useEffect } from "react";
|
|
|
|
interface CustomTextareaProps {
|
|
className?: string;
|
|
disabled?: boolean;
|
|
canSubmit?: boolean;
|
|
onContentChange: (jsonContent: JSONContent) => void;
|
|
onSubmit: () => void;
|
|
onImagesPaste?: (files: File[]) => void;
|
|
characterLimit?: number;
|
|
initialEditorContent?: JSONContent | null;
|
|
externalEditorContent?: JSONContent | null;
|
|
isStreamingContent?: boolean;
|
|
}
|
|
|
|
export default function CustomTextarea({
|
|
className,
|
|
disabled = false,
|
|
canSubmit = false,
|
|
onContentChange,
|
|
onSubmit,
|
|
onImagesPaste,
|
|
characterLimit,
|
|
initialEditorContent,
|
|
externalEditorContent,
|
|
isStreamingContent = false,
|
|
}: CustomTextareaProps) {
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
editable: !disabled,
|
|
extensions: [
|
|
StarterKit,
|
|
Mention.configure({
|
|
HTMLAttributes: {
|
|
class: "mention",
|
|
},
|
|
suggestion: suggestion,
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder: "Describe your theme...",
|
|
emptyEditorClass:
|
|
"cursor-text before:content-[attr(data-placeholder)] before:absolute before:inset-x-1 before:top-1 before:opacity-50 before-pointer-events-none",
|
|
}),
|
|
CharacterCount.configure({
|
|
limit: characterLimit,
|
|
}),
|
|
],
|
|
autofocus: !disabled,
|
|
editorProps: {
|
|
attributes: {
|
|
class: cn(
|
|
"min-w-0 min-h-[50px] max-h-[120px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full bg-background p-1 text-sm focus-visible:outline-none max-sm:text-[16px]! transition-all",
|
|
disabled && "opacity-75 pointer-events-none",
|
|
className
|
|
),
|
|
},
|
|
handleKeyDown: (view, event) => {
|
|
if (disabled) {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
if (event.key === "Enter" && !event.shiftKey && !disabled && canSubmit) {
|
|
const mentionPluginKey = Mention.options.suggestion.pluginKey;
|
|
|
|
if (!mentionPluginKey) {
|
|
console.error("Mention plugin key not found.");
|
|
return false;
|
|
}
|
|
|
|
const { state } = view;
|
|
const mentionState = mentionPluginKey.getState(state);
|
|
|
|
if (mentionState?.active) {
|
|
return false;
|
|
} else {
|
|
event.preventDefault();
|
|
onSubmit();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
handlePaste: (_view, event) => {
|
|
if (disabled) {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
if (!characterLimit) return false;
|
|
|
|
const clipboardData = event.clipboardData;
|
|
if (!clipboardData) return false;
|
|
|
|
// Check for image files
|
|
if (onImagesPaste) {
|
|
const files = Array.from(clipboardData.files);
|
|
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
|
|
|
|
if (imageFiles.length > 0) {
|
|
event.preventDefault();
|
|
onImagesPaste(imageFiles);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const pastedText = clipboardData.getData("text/plain");
|
|
const currentCharacterCount = editor?.storage.characterCount.characters() || 0;
|
|
const totalCharacters = currentCharacterCount + pastedText.length;
|
|
|
|
if (totalCharacters > characterLimit) {
|
|
event.preventDefault();
|
|
toast({
|
|
title: "Text too long",
|
|
description: `The pasted content would exceed the ${characterLimit} character limit.`,
|
|
variant: "destructive",
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
},
|
|
content: initialEditorContent || "",
|
|
onCreate: ({ editor }) => {
|
|
if (disabled) return;
|
|
editor.commands.focus("end");
|
|
},
|
|
onUpdate: ({ editor }) => {
|
|
const jsonContent = editor.getJSON();
|
|
onContentChange(jsonContent);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
editor.setEditable(!disabled);
|
|
if (disabled) editor.commands.blur();
|
|
else editor.commands.focus("end");
|
|
}, [disabled, editor]);
|
|
|
|
// Stream external content into the editor
|
|
useEffect(() => {
|
|
if (!editor || !externalEditorContent || !isStreamingContent) return;
|
|
|
|
try {
|
|
const currentContent = JSON.stringify(editor.getJSON());
|
|
const nextContent = JSON.stringify(externalEditorContent);
|
|
if (currentContent === nextContent) return;
|
|
|
|
// Preserve cursor position at the end
|
|
editor.commands.setContent(externalEditorContent, false);
|
|
editor.commands.focus("end");
|
|
} catch (_e) {
|
|
// If setContent fails for any reason, silently ignore; user can keep typing
|
|
}
|
|
}, [externalEditorContent, editor, isStreamingContent]);
|
|
|
|
if (!editor) {
|
|
return null;
|
|
}
|
|
|
|
const characterCount = editor.storage.characterCount.characters();
|
|
const isLimitExceeded = characterLimit && characterCount > characterLimit;
|
|
const shouldShowCount = characterLimit && characterCount >= characterLimit * 0.9;
|
|
|
|
return (
|
|
<div className="relative isolate">
|
|
<EditorContent editor={editor} aria-disabled={disabled} />
|
|
{shouldShowCount && (
|
|
<div className="absolute right-3 bottom-2 z-10 flex text-xs hover:opacity-0">
|
|
<span
|
|
className={cn(
|
|
"bg-background/10 pointer-events-none rounded-full px-0.5 backdrop-blur-xs",
|
|
isLimitExceeded ? "text-destructive" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
{characterCount} / {characterLimit}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|