canvas-website/src/shapes/PromptShapeUtil.tsx

458 lines
14 KiB
TypeScript

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 = () => (
<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
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<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) {
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) => {
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<IPrompt>({
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<IPrompt>({
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<IPrompt>({
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<TLGeoShape>(sourceShape, "geo")) {
processedPrompt = processedPrompt.replace(
pattern,
sourceShape.props.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")
// 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}: ${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 (
<HTMLContainer
style={{
borderRadius: 6,
border: "1px solid lightgrey",
padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
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.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",
}}>
<div style={{
display: "flex",
gap: "5px"
}}>
<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,
}}
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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handlePrompt()
}
}}
/>
<button
style={{
width: 100,
height: "40px",
pointerEvents: "all",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
type="button"
onClick={handlePrompt}
>
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>
</HTMLContainer>
)
}
// Override the default indicator behavior
// TODO: FIX SECOND INDICATOR UX GLITCH
override indicator(shape: IPrompt) {
return (
<rect
width={shape.props.w}
height={this.FIXED_HEIGHT}
rx={6}
/>
)
}
}