canvas-website/src/shapes/PromptShapeUtil.tsx

755 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
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 = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/>
</svg>
)
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
)
export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
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 {
return new Rectangle2d({
width: shape.props.w,
height: Math.max(shape.props.h, this.FIXED_HEIGHT),
isFilled: false,
})
}
// override onResize: TLResizeHandle<IPrompt> = (
// 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<string, TLShape>)
const generateText = async (prompt: string) => {
console.log("🎯 generateText called with prompt:", prompt);
// Clear any previous errors
this.editor.updateShape<IPrompt>({
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}"}`
console.log("💬 User message:", userMessage);
console.log("📚 Conversation history:", conversationHistory);
// Update with user message and trigger scroll
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage,
agentBinding: "someone",
error: null
},
})
let fullResponse = ''
console.log("🚀 Calling llm function...");
try {
await llm(prompt, (partial: string, done?: boolean) => {
console.log(`📝 LLM callback received - partial: "${partial}", done: ${done}`);
if (partial) {
fullResponse = partial
const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
console.log("🤖 Assistant message:", assistantMessage);
try {
JSON.parse(assistantMessage)
// Use requestAnimationFrame to ensure smooth scrolling during streaming
requestAnimationFrame(() => {
console.log("🔄 Updating shape with partial response...");
this.editor.updateShape<IPrompt>({
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)
console.log("✅ LLM function completed successfully");
} 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<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
agentBinding: null,
error: userFriendlyError
},
})
}
// Ensure the final message is saved after streaming is complete
if (fullResponse) {
console.log("💾 Saving final response:", 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<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: null,
error: null // Clear any errors on success
},
})
console.log("✅ Final response saved successfully");
} 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<TLGeoShape>(sourceShape, "geo")) {
processedPrompt = processedPrompt.replace(
pattern,
(sourceShape.meta as any)?.text || "",
)
}
}
}
generateText(processedPrompt)
this.editor.updateShape<IPrompt>({
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<number | null>(null)
// Add ref for the chat container
const chatContainerRef = React.useRef<HTMLDivElement>(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)
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="LLM Prompt"
primaryColor={PromptShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: 'Prompt',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div
style={{
height: '100%',
width: '100%',
padding: this.PADDING,
pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
//TODO: FIX SCROLL IN PROMPT CHAT WHEN HOVERING OVER ELEMENT
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
onWheel={(e) => {
if (isSelected || isHovering) {
e.preventDefault()
e.stopPropagation()
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop += e.deltaY
}
}
}}
>
<div
ref={chatContainerRef}
style={{
padding: "4px 8px",
flex: 1,
backgroundColor: "white",
borderRadius: "4px",
marginBottom: "4px",
fontSize: "14px",
overflowY: "auto",
whiteSpace: "pre-wrap",
fontFamily: "monospace",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}
>
{shape.props.error && (
<div
style={{
padding: "12px 16px",
backgroundColor: "#fee",
border: "1px solid #fcc",
borderRadius: "8px",
color: "#c33",
marginBottom: "8px",
fontSize: "13px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span style={{ fontSize: "18px" }}></span>
<span>{shape.props.error}</span>
<button
onClick={() => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { error: null },
})
}}
style={{
marginLeft: "auto",
padding: "4px 8px",
backgroundColor: "#fcc",
border: "1px solid #c99",
borderRadius: "4px",
cursor: "pointer",
fontSize: "11px",
}}
>
Dismiss
</button>
</div>
)}
{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 (
<div
key={index}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
margin: '8px 0',
maxWidth: '100%',
position: 'relative',
}}
>
<div
style={{
padding: '12px 16px',
maxWidth: '80%',
backgroundColor: isUser ? '#007AFF' : '#f0f0f0',
color: isUser ? 'white' : 'black',
borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}}
>
{parsed.content}
<button
style={{
position: 'absolute',
bottom: '-20px',
right: isUser ? '0' : 'auto',
left: isUser ? 'auto' : '0',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
color: '#666',
opacity: 0.7,
transition: 'opacity 0.2s',
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={async () => {
try {
await navigator.clipboard.writeText(parsed.content)
setCopiedIndex(index)
setTimeout(() => {
setCopiedIndex(null)
}, 2000)
} catch (err) {
console.error('Failed to copy text:', err)
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
>
{copiedIndex === index ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
</div>
);
} catch {
return null; // Skip invalid JSON
}
})
) : (
"Chat history will appear here..."
)}
</div>
<div style={{
display: "flex",
flexDirection: "column",
gap: "5px",
marginTop: "auto",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}>
{/* AI Personality Selector */}
<div style={{
display: "flex",
flexDirection: "column",
gap: "3px",
marginBottom: "5px"
}}>
<label style={{
fontSize: "12px",
fontWeight: "500",
color: "#666",
marginBottom: "2px"
}}>
AI Personality:
</label>
<select
value={shape.props.personality || 'web-developer'}
onChange={(e) => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { personality: e.target.value },
})
}}
style={{
padding: "4px 8px",
border: "1px solid rgba(0, 0, 0, 0.1)",
borderRadius: "4px",
fontSize: "12px",
backgroundColor: "rgba(255, 255, 255, 0.9)",
cursor: "pointer",
height: "28px"
}}
>
{AI_PERSONALITIES.map((personality) => (
<option key={personality.id} value={personality.id}>
{personality.name}
</option>
))}
</select>
</div>
<div style={{
display: "flex",
gap: "5px",
position: "relative",
zIndex: 1000,
pointerEvents: "all",
}}>
<input
style={{
width: "100%",
height: "40px",
overflow: "visible",
backgroundColor: "rgba(0, 0, 0, 0.05)",
border: "1px solid rgba(0, 0, 0, 0.05)",
borderRadius: 6 - this.PADDING,
fontSize: 16,
padding: "0 8px",
position: "relative",
zIndex: 1000,
pointerEvents: "all",
}}
type="text"
placeholder="Enter prompt..."
value={shape.props.prompt}
onChange={(text) => {
this.editor.updateShape<IPrompt>({
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()
}}
/>
<button
style={{
width: 100,
height: "40px",
pointerEvents: "all",
cursor: shape.props.prompt.trim() && !shape.props.agentBinding ? "pointer" : "not-allowed",
backgroundColor: shape.props.prompt.trim() && !shape.props.agentBinding ? "#007AFF" : "#ccc",
color: "white",
border: "none",
borderRadius: "4px",
fontWeight: "500",
position: "relative",
zIndex: 1000,
opacity: shape.props.prompt.trim() && !shape.props.agentBinding ? 1 : 0.6,
}}
onPointerDown={(e) => {
e.stopPropagation()
e.preventDefault()
if (shape.props.prompt.trim() && !shape.props.agentBinding) {
handlePrompt()
}
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (shape.props.prompt.trim() && !shape.props.agentBinding) {
handlePrompt()
}
}}
type="button"
>
Prompt
</button>
</div>
<button
style={{
width: "100%",
height: "30px",
pointerEvents: "all",
backgroundColor: "#f0f0f0",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={handleCopy}
>
{copyButtonText}
</button>
</div>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
// 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 (
<rect
width={shape.props.w}
height={Math.max(shape.props.h, this.FIXED_HEIGHT)}
rx={6}
/>
)
}
}