import { BaseBoxShapeUtil, HTMLContainer, TLBaseShape, TLGeoShape, TLShape, } from "tldraw" import { getEdge } from "@/propagators/tlgraph" import { llm } from "@/utils/llmUtils" import { isShapeOfType } from "@/propagators/utils" import React, { useState } from "react" type IPrompt = TLBaseShape< "Prompt", { w: number h: number prompt: string value: string agentBinding: string | null } > // Add this SVG copy icon component at the top level of the file const CopyIcon = () => ( ) const CheckIcon = () => ( ) export class PromptShape extends BaseBoxShapeUtil { static override type = "Prompt" as const FIXED_HEIGHT = 500 as const MIN_WIDTH = 200 as const PADDING = 4 as const getDefaultProps(): IPrompt["props"] { return { w: 300, h: 50, prompt: "", value: "", agentBinding: null, } } // override onResize: TLResizeHandle = ( // shape, // { scaleX, initialShape }, // ) => { // const { x, y } = shape // const w = initialShape.props.w * scaleX // return { // x, // y, // props: { // ...shape.props, // w: Math.max(Math.abs(w), this.MIN_WIDTH), // h: this.FIXED_HEIGHT, // }, // } // } component(shape: IPrompt) { const arrowBindings = this.editor.getBindingsInvolvingShape( shape.id, "arrow", ) const arrows = arrowBindings.map((binding) => this.editor.getShape(binding.fromId), ) const inputMap = arrows.reduce((acc, arrow) => { const edge = getEdge(arrow, this.editor) if (edge) { const sourceShape = this.editor.getShape(edge.from) if (sourceShape && edge.text) { acc[edge.text] = sourceShape } } return acc }, {} as Record) const generateText = async (prompt: string) => { const conversationHistory = shape.props.value ? shape.props.value + '\n' : '' const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const userMessage = `{"role": "user", "content": "${escapedPrompt}"}` // Update with user message and trigger scroll this.editor.updateShape({ id: shape.id, type: "Prompt", props: { value: conversationHistory + userMessage, agentBinding: "someone" }, }) let fullResponse = '' await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => { if (partial) { fullResponse = partial const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` try { JSON.parse(assistantMessage) // Use requestAnimationFrame to ensure smooth scrolling during streaming requestAnimationFrame(() => { this.editor.updateShape({ id: shape.id, type: "Prompt", props: { value: conversationHistory + userMessage + '\n' + assistantMessage, agentBinding: done ? null : "someone" }, }) }) } catch (error) { console.error('Invalid JSON message:', error) } } }) // Ensure the final message is saved after streaming is complete if (fullResponse) { const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` try { // Verify the final message is valid JSON before updating JSON.parse(assistantMessage) this.editor.updateShape({ id: shape.id, type: "Prompt", props: { value: conversationHistory + userMessage + '\n' + assistantMessage, agentBinding: null }, }) } catch (error) { console.error('Invalid JSON in final message:', error) } } } const handlePrompt = () => { if (shape.props.agentBinding) { return } let processedPrompt = shape.props.prompt for (const [key, sourceShape] of Object.entries(inputMap)) { const pattern = `{${key}}` if (processedPrompt.includes(pattern)) { if (isShapeOfType(sourceShape, "geo")) { processedPrompt = processedPrompt.replace( pattern, sourceShape.props.text, ) } } } generateText(processedPrompt) this.editor.updateShape({ id: shape.id, type: "Prompt", props: { prompt: "" }, }) } // Add state for copy button text const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation") // In the component function, add state for tracking copy success const [isCopied, setIsCopied] = React.useState(false) // In the component function, update the state to track which message was copied const [copiedIndex, setCopiedIndex] = React.useState(null) // Add ref for the chat container const chatContainerRef = React.useRef(null) // Add function to scroll to bottom const scrollToBottom = () => { if (chatContainerRef.current) { // Use requestAnimationFrame for smooth scrolling requestAnimationFrame(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight } }) } } // Use both value and agentBinding as dependencies to catch all updates React.useEffect(() => { scrollToBottom() }, [shape.props.value, shape.props.agentBinding]) const handleCopy = async () => { try { // Parse and format each message const messages = shape.props.value .split('\n') .filter(line => line.trim()) .map(line => { try { const parsed = JSON.parse(line); return `${parsed.role}: ${parsed.content}`; } catch { return null; } }) .filter(Boolean) .join('\n\n'); await navigator.clipboard.writeText(messages); setCopyButtonText("Copied!"); setTimeout(() => { setCopyButtonText("Copy Conversation"); }, 2000); } catch (err) { console.error('Failed to copy text:', err); setCopyButtonText("Failed to copy"); setTimeout(() => { setCopyButtonText("Copy Conversation"); }, 2000); } }; const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const [isHovering, setIsHovering] = useState(false) return ( setIsHovering(true)} onPointerLeave={() => setIsHovering(false)} onWheel={(e) => { if (isSelected || isHovering) { e.preventDefault() e.stopPropagation() if (chatContainerRef.current) { chatContainerRef.current.scrollTop += e.deltaY } } }} >
{shape.props.value ? ( shape.props.value.split('\n').map((message, index) => { if (!message.trim()) return null; try { const parsed = JSON.parse(message); const isUser = parsed.role === "user"; return (
{parsed.content}
); } catch { return null; // Skip invalid JSON } }) ) : ( "Chat history will appear here..." )}
{ this.editor.updateShape({ id: shape.id, type: "Prompt", props: { prompt: text.target.value }, }) }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handlePrompt() } }} />
) } // Override the default indicator behavior // TODO: FIX SECOND INDICATOR UX GLITCH override indicator(shape: IPrompt) { return ( ) } }