prompt shape working, fix indicator & scroll later

This commit is contained in:
Jeff-Emmett 2025-02-25 17:53:36 -05:00
parent 1126fc4a1c
commit e7e911c5bb
1 changed files with 113 additions and 24 deletions

View File

@ -8,7 +8,7 @@ import {
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils" import { llm } from "@/utils/llmUtils"
import { isShapeOfType } from "@/propagators/utils" import { isShapeOfType } from "@/propagators/utils"
import React from "react" import React, { useState } from "react"
type IPrompt = TLBaseShape< type IPrompt = TLBaseShape<
"Prompt", "Prompt",
@ -28,6 +28,12 @@ const CopyIcon = () => (
</svg> </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> { export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const static override type = "Prompt" as const
@ -83,42 +89,69 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
}, {} as Record<string, TLShape>) }, {} as Record<string, TLShape>)
const generateText = async (prompt: string) => { const generateText = async (prompt: string) => {
// Get existing conversation history, ensure it ends with a newline if not empty
const conversationHistory = shape.props.value ? shape.props.value + '\n' : '' const conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\n/g, '\\n') const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const newPrompt = `{"role": "user", "content": "${escapedPrompt}"}\n{"role": "assistant", "content": "` const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
const fullPrompt = `${conversationHistory}${newPrompt}`
// Update with user message and trigger scroll
// First update: Show the user's prompt while preserving history
this.editor.updateShape<IPrompt>({ this.editor.updateShape<IPrompt>({
id: shape.id, id: shape.id,
type: "Prompt", type: "Prompt",
props: { props: {
value: fullPrompt, value: conversationHistory + userMessage,
agentBinding: "someone" agentBinding: "someone"
}, },
}) })
// Send just the new prompt to the LLM let fullResponse = ''
let lastResponseLength = 0
let fullResponse = '' // Track the complete response
await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => { await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => {
if (partial) { if (partial) {
fullResponse = partial // Store the complete response fullResponse = partial
// Only get the new content by slicing from the last response length const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const newContent = partial.slice(lastResponseLength) const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
lastResponseLength = partial.length
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>({ this.editor.updateShape<IPrompt>({
id: shape.id, id: shape.id,
type: "Prompt", type: "Prompt",
props: { props: {
value: `${fullPrompt}${fullResponse}"}`, // Use the complete response value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: done ? null : "someone" agentBinding: null
}, },
}) })
} catch (error) {
console.error('Invalid JSON in final message:', error)
} }
}) }
} }
const handlePrompt = () => { const handlePrompt = () => {
@ -148,6 +181,32 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
// Add state for copy button text // Add state for copy button text
const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation") 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 () => { const handleCopy = async () => {
try { try {
// Parse and format each message // Parse and format each message
@ -179,6 +238,9 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
} }
}; };
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isHovering, setIsHovering] = useState(false)
return ( return (
<HTMLContainer <HTMLContainer
style={{ style={{
@ -187,7 +249,7 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
padding: this.PADDING, padding: this.PADDING,
height: this.FIXED_HEIGHT, height: this.FIXED_HEIGHT,
width: shape.props.w, width: shape.props.w,
pointerEvents: "all", pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef", backgroundColor: "#efefef",
overflow: "visible", overflow: "visible",
display: "flex", display: "flex",
@ -196,8 +258,22 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
alignItems: "stretch", alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none", 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 <div
ref={chatContainerRef}
style={{ style={{
padding: "4px 8px", padding: "4px 8px",
flex: 1, flex: 1,
@ -208,6 +284,7 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
overflowY: "auto", overflowY: "auto",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
fontFamily: "monospace", fontFamily: "monospace",
pointerEvents: isSelected || isHovering ? "all" : "none",
}} }}
> >
{shape.props.value ? ( {shape.props.value ? (
@ -263,7 +340,10 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
onClick={async () => { onClick={async () => {
try { try {
await navigator.clipboard.writeText(parsed.content) await navigator.clipboard.writeText(parsed.content)
// Optional: Show a brief "Copied!" tooltip setCopiedIndex(index)
setTimeout(() => {
setCopiedIndex(null)
}, 2000)
} catch (err) { } catch (err) {
console.error('Failed to copy text:', err) console.error('Failed to copy text:', err)
} }
@ -275,7 +355,7 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
e.currentTarget.style.opacity = '0.7' e.currentTarget.style.opacity = '0.7'
}} }}
> >
<CopyIcon /> {copiedIndex === index ? <CheckIcon /> : <CopyIcon />}
</button> </button>
</div> </div>
</div> </div>
@ -292,7 +372,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "5px", gap: "5px",
marginTop: "auto" marginTop: "auto",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}> }}>
<div style={{ <div style={{
display: "flex", display: "flex",
@ -362,7 +443,15 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
) )
} }
indicator(shape: IPrompt) { // Override the default indicator behavior
return <rect width={shape.props.w} height={shape.props.h} rx={5} /> // TODO: FIX SECOND INDICATOR UX GLITCH
override indicator(shape: IPrompt) {
return (
<rect
width={shape.props.w}
height={this.FIXED_HEIGHT}
rx={6}
/>
)
} }
} }