import { BaseBoxShapeUtil, Geometry2d, HTMLContainer, Rectangle2d, TLBaseShape, TLGeoShape, TLShape, createShapeId, } from "tldraw" import { getEdge } from "@/propagators/tlgraph" import { llm, getApiKey } from "@/utils/llmUtils" import { AI_PERSONALITIES } from "@/lib/settings" import { isShapeOfType } from "@/propagators/utils" import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils" import React, { useState } from "react" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" import { usePinnedToView } from "../hooks/usePinnedToView" import { useMaximize } from "../hooks/useMaximize" type IPrompt = TLBaseShape< "Prompt", { w: number h: number prompt: string value: string agentBinding: string | null personality?: string error?: string | null pinnedToView: boolean tags: string[] } > // 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 // LLM Prompt theme color: Pink/Magenta (Rainbow) static readonly PRIMARY_COLOR = "#ec4899" FIXED_HEIGHT = 500 as const MIN_WIDTH = 200 as const PADDING = 4 as const getDefaultProps(): IPrompt["props"] { return { w: 300, h: this.FIXED_HEIGHT, prompt: "", value: "", agentBinding: null, pinnedToView: false, tags: ['llm', 'prompt'], } } // Override getGeometry to ensure the selector box always matches the rendered component height getGeometry(shape: IPrompt): Geometry2d { // isFilled must be true for proper hit testing and nearestPoint calculation return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, this.FIXED_HEIGHT, 1), isFilled: true, }) } // 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) { // Ensure shape props exist with defaults const props = shape.props || {} const prompt = props.prompt || "" const value = props.value || "" const agentBinding = props.agentBinding || "" 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) => { // Clear any previous errors this.editor.updateShape({ id: shape.id, type: "Prompt", props: { error: null }, }) 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", error: null }, }) let fullResponse = '' try { await llm(prompt, (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", error: null }, }) }) } catch (error) { console.error('❌ Invalid JSON message:', error) } } }, shape.props.personality) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error("❌ Error in LLM function:", errorMessage); // Display error to user const userFriendlyError = errorMessage.includes('No valid API key') ? '❌ No valid API key found. Please configure your API keys in settings.' : errorMessage.includes('All AI providers failed') ? '❌ All API keys failed. Please check your API keys in settings.' : errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('Unauthorized') ? '❌ API key authentication failed. Your API key may be expired or invalid. Please check your API keys in settings.' : `❌ Error: ${errorMessage}`; this.editor.updateShape({ id: shape.id, type: "Prompt", props: { agentBinding: null, error: userFriendlyError }, }) } // 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, error: null // Clear any errors on success }, }) } 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.meta as any)?.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 to Knowledge Object") // 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 === 'user' ? 'User' : 'Assistant'}:**\n${parsed.content}`; } catch { return null; } }) .filter(Boolean) .join('\n\n---\n\n'); // Format the conversation as markdown content const conversationContent = `# Conversation History\n\n${messages}`; // Get the prompt shape's position to place the new shape nearby const promptShapeBounds = this.editor.getShapePageBounds(shape.id); const baseX = promptShapeBounds ? promptShapeBounds.x + promptShapeBounds.w + 20 : shape.x + shape.props.w + 20; const baseY = promptShapeBounds ? promptShapeBounds.y : shape.y; // Find a non-overlapping position for the new ObsNote shape const shapeWidth = 300; const shapeHeight = 200; const position = findNonOverlappingPosition( this.editor, baseX, baseY, shapeWidth, shapeHeight ); // Create a new ObsNote shape with the conversation content const obsNoteShape = this.editor.createShape({ type: 'ObsNote', x: position.x, y: position.y, props: { w: shapeWidth, h: shapeHeight, color: 'black', size: 'm', font: 'sans', textAlign: 'start', scale: 1, noteId: createShapeId(), title: 'Conversation History', content: conversationContent, tags: ['#conversation', '#llm'], showPreview: true, backgroundColor: '#ffffff', textColor: '#000000', isEditing: false, editingContent: '', isModified: false, originalContent: conversationContent, } }); // Select the newly created shape this.editor.setSelectedShapes([`shape:${obsNoteShape.id}`] as any); setCopyButtonText("Created!"); setTimeout(() => { setCopyButtonText("Copy Conversation to Knowledge Object"); }, 2000); } catch (err) { console.error('Failed to create knowledge object:', err); setCopyButtonText("Failed to create"); setTimeout(() => { setCopyButtonText("Copy Conversation to Knowledge Object"); }, 2000); } }; const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const [isHovering, setIsHovering] = useState(false) const [isMinimized, setIsMinimized] = useState(false) // Use the pinning hook usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: this.editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'Prompt', }) const handleClose = () => { this.editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handlePinToggle = () => { this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, pinnedToView: !shape.props.pinnedToView, }, }) } return ( { this.editor.updateShape({ id: shape.id, type: 'Prompt', props: { ...shape.props, tags: newTags, } }) }} tagsEditable={true} >
setIsHovering(true)} onPointerLeave={() => setIsHovering(false)} onWheel={(e) => { if (isSelected || isHovering) { e.preventDefault() e.stopPropagation() if (chatContainerRef.current) { chatContainerRef.current.scrollTop += e.deltaY } } }} >
{shape.props.error && (
⚠️ {shape.props.error}
)} {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..." )}
{/* AI Personality Selector */}
{ this.editor.updateShape({ id: shape.id, type: "Prompt", props: { prompt: text.target.value }, }) }} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (shape.props.prompt.trim() && !shape.props.agentBinding) { handlePrompt() } } }} onPointerDown={(e) => { e.stopPropagation() }} onClick={(e) => { e.stopPropagation() }} onFocus={(e) => { e.stopPropagation() }} />
) } // Override the default indicator behavior to match the actual rendered size override indicator(shape: IPrompt) { // Use Math.max to ensure the indicator covers the full component height // This handles both new shapes (h = FIXED_HEIGHT) and old shapes (h might be smaller) return ( ) } }