Spaces:
Running
Running
/** | |
* Copyright 2024 Google LLC | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
import { SchemaType } from "@google/generative-ai"; | |
import { useEffect, useRef, memo, useState, useCallback } from "react"; | |
import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; | |
import type { ToolCall } from "../../multimodal-live-types"; | |
import { Editor } from "@monaco-editor/react"; | |
import "./p5-with-editor.scss"; | |
import { Send, Code } from "lucide-react"; | |
// Add type definitions for window functions | |
declare global { | |
interface Window { | |
initSketch: (container: HTMLElement) => void; | |
updateSketch: (code: string, container: HTMLElement) => boolean; | |
removeSketch: () => void; | |
mySketch: any; | |
} | |
} | |
interface SketchArgs { | |
sketch: string; | |
} | |
function P5WithEditorComponent() { | |
const containerRef = useRef<HTMLDivElement>(null); | |
const editorPanelRef = useRef<HTMLDivElement>(null); | |
const { client, setConfig } = useLiveAPIContext(); | |
const [editorContent, setEditorContent] = useState<string>(""); | |
const [displayedContent, setDisplayedContent] = useState<string>(""); | |
const [isResizing, setIsResizing] = useState(false); | |
const [editorWidth, setEditorWidth] = useState(40); // percentage | |
const [editorHeight, setEditorHeight] = useState(50); // percentage for mobile | |
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); | |
const [isSending, setIsSending] = useState(false); | |
const [isAnimatingCode, setIsAnimatingCode] = useState(false); | |
const [cursorPosition, setCursorPosition] = useState<{ lineNumber: number, column: number } | null>(null); | |
const [isFirstQuestion, setIsFirstQuestion] = useState(true); // Track if this is the first question | |
const editorRef = useRef<any>(null); | |
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
// Store test sketch reference | |
const testSketchRef = useRef<string>(""); | |
// Function to handle editor mounting | |
const handleEditorDidMount = (editor: any) => { | |
editorRef.current = editor; | |
}; | |
// Helper function to calculate cursor position from text position | |
const calculateCursorPosition = (text: string, position: number): { lineNumber: number, column: number } => { | |
const textUpToPosition = text.substring(0, position); | |
const lines = textUpToPosition.split('\n'); | |
const lineNumber = lines.length; | |
const column = lines[lines.length - 1].length + 1; | |
return { lineNumber, column }; | |
}; | |
// Helper function to animate code reveal | |
const animateCodeReveal = (newCode: string, currentPosition = 0, chunkSize = 3) => { | |
if (currentPosition >= newCode.length) { | |
// Add a small delay before finishing to let the user see the completed code | |
setTimeout(() => { | |
setIsAnimatingCode(false); | |
setCursorPosition(null); | |
// Clear any decorations when animation is complete | |
if (editorRef.current) { | |
try { | |
const model = editorRef.current.getModel(); | |
if (model) { | |
editorRef.current.deltaDecorations([], []); | |
} | |
} catch (e) { | |
console.error("Error clearing decorations:", e); | |
} | |
} | |
}, 500); | |
return; | |
} | |
setIsAnimatingCode(true); | |
// Calculate next chunk to reveal | |
const nextPosition = Math.min(currentPosition + chunkSize, newCode.length); | |
const nextChunk = newCode.substring(0, nextPosition); | |
// Update displayed content | |
setDisplayedContent(nextChunk); | |
// Update cursor position | |
const cursorPos = calculateCursorPosition(nextChunk, nextPosition); | |
setCursorPosition(cursorPos); | |
// Move editor cursor to the current position if editor is available | |
if (editorRef.current) { | |
try { | |
editorRef.current.setPosition(cursorPos); | |
editorRef.current.revealPositionInCenter(cursorPos); | |
// Add decoration to highlight the current line | |
const model = editorRef.current.getModel(); | |
if (model) { | |
const decorations = [{ | |
range: { | |
startLineNumber: cursorPos.lineNumber, | |
startColumn: 1, | |
endLineNumber: cursorPos.lineNumber, | |
endColumn: model.getLineMaxColumn(cursorPos.lineNumber) | |
}, | |
options: { | |
isWholeLine: true, | |
className: 'current-line-highlight', | |
glyphMarginClassName: 'current-line-glyph' | |
} | |
}]; | |
editorRef.current.deltaDecorations([], decorations); | |
} | |
} catch (e) { | |
console.error("Error setting cursor position or decorations:", e); | |
} | |
} | |
// Schedule next chunk reveal with variable speed | |
// Start faster, then slow down a bit for longer code | |
const baseDelay = 5; // Base delay in ms | |
const progressFactor = Math.min(currentPosition / newCode.length * 3, 1); // Slow down as we progress | |
const randomFactor = Math.random() * 10; // Add some randomness | |
// Calculate delay: faster at start, slower as we progress | |
const delay = baseDelay + (progressFactor * 15) + randomFactor; | |
animationTimeoutRef.current = setTimeout(() => { | |
// Increase chunk size as we progress for a more natural typing effect | |
const newChunkSize = Math.floor(chunkSize + (progressFactor * 5)); | |
animateCodeReveal(newCode, nextPosition, newChunkSize); | |
}, delay); | |
}; | |
// Clean up animation timeout on unmount | |
useEffect(() => { | |
return () => { | |
if (animationTimeoutRef.current) { | |
clearTimeout(animationTimeoutRef.current); | |
} | |
}; | |
}, []); | |
// Helper function to format code for the model | |
const formatCodeForModel = (code: string): string => { | |
// Trim whitespace and ensure it's not too long | |
const trimmedCode = code.trim(); | |
// If code is very long, add a note about it | |
if (trimmedCode.length > 5000) { | |
const truncatedCode = trimmedCode.substring(0, 5000); | |
return `${truncatedCode}\n\n// Note: Code was truncated as it was too long. This is just the first part of the code.`; | |
} | |
return trimmedCode; | |
}; | |
useEffect(() => { | |
const handleResize = () => { | |
setIsMobile(window.innerWidth < 768); | |
}; | |
window.addEventListener('resize', handleResize); | |
return () => window.removeEventListener('resize', handleResize); | |
}, []); | |
// Intercept messages to include the current editor content | |
useEffect(() => { | |
// Store the original send method | |
const originalSend = client.send.bind(client); | |
// Override the send method to include editor content | |
client.send = (parts: any, turnComplete = true) => { | |
// Convert to array if it's not already | |
const partsArray = Array.isArray(parts) ? parts : [parts]; | |
// Check if there's text content | |
const hasTextContent = partsArray.some(part => part.text); | |
if (hasTextContent) { | |
// Show sending indicator | |
setIsSending(true); | |
if (isFirstQuestion) { | |
// For the first question, include the initial test sketch code | |
const sketchToUse = testSketchRef.current || editorContent; | |
const formattedCode = formatCodeForModel(sketchToUse); | |
// Add the test sketch as context for the first question | |
partsArray.push({ | |
text: `\n\n--- CURRENT P5.JS CODE IN EDITOR ---\n\`\`\`javascript\n${formattedCode}\n\`\`\`\n\nPlease use this code as a starting point. You can modify or improve it based on my request.` | |
}); | |
// Mark first question as used | |
setIsFirstQuestion(false); | |
} else if (editorContent) { | |
// For subsequent questions, use the current editor content | |
const formattedCode = formatCodeForModel(editorContent); | |
partsArray.push({ | |
text: `\n\n--- CURRENT P5.JS CODE IN EDITOR ---\n\`\`\`javascript\n${formattedCode}\n\`\`\`\n\nPlease consider this code when responding to my request. If I'm asking for changes or improvements, use this as the starting point.` | |
}); | |
} | |
// Hide sending indicator after a short delay | |
setTimeout(() => setIsSending(false), 1000); | |
} | |
// Call the original send method with our modified parts | |
return originalSend(partsArray, turnComplete); | |
}; | |
// Cleanup: restore the original method when component unmounts | |
return () => { | |
client.send = originalSend; | |
}; | |
}, [client, editorContent, isFirstQuestion]); | |
useEffect(() => { | |
if (containerRef.current) { | |
window.initSketch(containerRef.current); | |
// Add a test sketch to verify the component is working | |
const testSketch = ` | |
// Press ▶ Play to connect to the | |
// Gemini 2.0 Live API | |
// and start chatting to change this code | |
let boids = []; | |
function setup() { | |
createCanvas(containerWidth, containerHeight); | |
// Create 30 boids with random positions and velocities | |
for (let i = 0; i < 30; i++) { | |
boids.push({ | |
pos: createVector(random(width), random(height)), | |
vel: p5.Vector.random2D().mult(random(2, 3)), | |
col: "#2d2d2d" | |
}); | |
} | |
} | |
function draw() { | |
background(30, 30, 30); | |
let mouse = createVector(mouseX, mouseY); | |
for (let boid of boids) { | |
// Update velocity based on simple rules | |
let sep = createVector(); | |
let coh = createVector(); | |
let ali = createVector(); | |
let count = 0; | |
// Calculate all forces in one loop | |
for (let other of boids) { | |
if (other === boid) continue; | |
let d = p5.Vector.dist(boid.pos, other.pos); | |
if (d < 50) { | |
// Separation (avoid others) | |
let diff = p5.Vector.sub(boid.pos, other.pos); | |
diff.div(d); | |
sep.add(diff); | |
// Alignment (match velocity) | |
ali.add(other.vel); | |
// Cohesion (move toward others) | |
coh.add(other.pos); | |
count++; | |
} | |
} | |
if (count > 0) { | |
// Apply flocking behavior | |
sep.mult(0.3); | |
ali.div(count).mult(0.2); | |
coh.div(count).sub(boid.pos).mult(0.1); | |
boid.vel.add(sep).add(ali).add(coh); | |
// Add subtle mouse attraction | |
let mouseForce = p5.Vector.sub(mouse, boid.pos); | |
mouseForce.mult(0.01); | |
boid.vel.add(mouseForce); | |
} | |
// Limit speed and update position | |
boid.vel.limit(4); | |
boid.pos.add(boid.vel); | |
// Wrap around edges | |
boid.pos.x = (boid.pos.x + width) % width; | |
boid.pos.y = (boid.pos.y + height) % height; | |
// Draw boid | |
push(); | |
translate(boid.pos.x, boid.pos.y); | |
rotate(boid.vel.heading()); | |
fill(boid.col); | |
noStroke(); | |
triangle(16, 0, -8, 6, -8, -6); | |
pop(); | |
} | |
} | |
function windowResized() { | |
resizeCanvas(containerWidth, containerHeight); | |
} | |
`; | |
// Store test sketch in the ref for later use | |
testSketchRef.current = testSketch; | |
// Set the editor content and displayed content | |
setEditorContent(testSketch); | |
setDisplayedContent(testSketch); | |
// Update the sketch with the test code | |
window.updateSketch(testSketch, containerRef.current); | |
// Configure the model with the test sketch included in the system instruction | |
setConfig({ | |
model: "models/gemini-2.0-flash-exp", | |
generationConfig: { | |
temperature: 0.1, | |
responseModalities: "audio", | |
speechConfig: { | |
voiceConfig: { | |
prebuiltVoiceConfig: { | |
voiceName: "Puck" | |
} | |
} | |
} | |
}, | |
systemInstruction: { | |
parts: [ | |
{ | |
text: `You are a P5.js creative coding expert that helps users create interactive sketches. | |
INITIAL CODE IN EDITOR: | |
\`\`\`javascript | |
${testSketch} | |
\`\`\` | |
When the user asks their first question, use the above code as a starting point. Modify it based on their request. | |
Your responses should be direct, helpful, and technically accurate. Users are looking for creative coding help with p5.js sketches. | |
When a user requests a sketch: | |
1. Always use the updateSketch function to create or modify sketches | |
2. NEVER output code directly in the response - only use the function | |
3. After the sketch is created, explain what the sketch does and how to interact with it | |
4. If the user's request is unclear, just take your best guess to create a sketch | |
You can create sketches using: | |
- Basic shapes, colors, and animations (do not ever try to import a photo or image) | |
- Mouse and keyboard interaction | |
- Sound effects (p5.sound library) | |
- Sprite-based games (p5.play library) | |
- Full window canvas with automatic resizing | |
In the setup() function, ALWAYS use the createCanvas(containerWidth, containerHeight) function to create the canvas. DO NOT EVER use windowWidth or windowHeight. | |
Focus on creating visually engaging and interactive experiences. As soon as the user requests a sketch, confirm you heard them, THEN you should create the sketch and then explain what it does and how to interact with it. Be EXTREMELY brief and pithy.` | |
}, | |
], | |
}, | |
tools: [ | |
{ | |
functionDeclarations: [ | |
{ | |
name: "updateSketch", | |
description: "Create or update the P5.js sketch with new code. The sketch will run in a full-window canvas.", | |
parameters: { | |
type: SchemaType.OBJECT, | |
properties: { | |
sketch: { | |
type: SchemaType.STRING, | |
description: "Complete P5.js sketch code including all variable declarations, setup(), draw(), and any additional functions needed. The code should be a complete, self-contained sketch. In the setup() function, ALWAYS use the createCanvas(containerWidth, containerHeight) function to create the canvas. DO NOT EVER use windowWidth or windowHeight." | |
} | |
}, | |
required: ["sketch"] | |
} | |
} | |
], | |
}, | |
], | |
}); | |
} | |
// Cleanup on unmount | |
return () => { | |
window.removeSketch(); | |
}; | |
}, [setConfig]); | |
useEffect(() => { | |
const onToolCall = async (toolCall: ToolCall) => { | |
console.log("Received tool call:", toolCall); | |
if (!toolCall.functionCalls || toolCall.functionCalls.length === 0) { | |
console.error("No function calls in tool call"); | |
return; | |
} | |
for (const fc of toolCall.functionCalls) { | |
if (fc.name === "updateSketch") { | |
if (!containerRef.current) { | |
console.error("Container ref is not available"); | |
await client.sendToolResponse({ | |
functionResponses: [ | |
{ | |
response: { | |
success: false, | |
message: "Container not available" | |
}, | |
id: fc.id, | |
}, | |
], | |
}); | |
continue; | |
} | |
try { | |
// Parse the arguments from the function call | |
if (!fc.args) { | |
throw new Error("No arguments provided"); | |
} | |
const args = fc.args as SketchArgs; | |
if (!args.sketch) { | |
throw new Error("No sketch code provided"); | |
} | |
console.log("Sketch code received:", args.sketch); | |
// First update the editor content state (but not displayed yet) | |
setEditorContent(args.sketch); | |
// Clear any existing animation | |
if (animationTimeoutRef.current) { | |
clearTimeout(animationTimeoutRef.current); | |
} | |
// Start the animation to reveal the code | |
animateCodeReveal(args.sketch); | |
// Update the sketch immediately (don't wait for animation) | |
const result = window.updateSketch(args.sketch, containerRef.current); | |
console.log("Sketch update result:", result); | |
// Send the function response back to Gemini | |
await client.sendToolResponse({ | |
functionResponses: [ | |
{ | |
response: { | |
success: result, | |
message: result ? "Sketch updated successfully" : "Failed to update sketch" | |
}, | |
id: fc.id, | |
}, | |
], | |
}); | |
} catch (error) { | |
console.error("Error handling updateSketch function call:", error); | |
// Send error response back to Gemini | |
await client.sendToolResponse({ | |
functionResponses: [ | |
{ | |
response: { | |
success: false, | |
message: `Error: ${error}` | |
}, | |
id: fc.id, | |
}, | |
], | |
}); | |
} | |
} else { | |
console.error("Unhandled function call:", fc.name); | |
} | |
} | |
}; | |
client.on("toolcall", onToolCall); | |
return () => { | |
client.off("toolcall", onToolCall); | |
}; | |
}, [client]); | |
const handleEditorChange = (value: string | undefined) => { | |
if (!value) { | |
console.warn("Editor value is undefined or empty"); | |
return; | |
} | |
// Always update editor content immediately for responsiveness | |
setEditorContent(value); | |
setDisplayedContent(value); | |
// Clear any existing timeout | |
if (updateTimeoutRef.current) { | |
clearTimeout(updateTimeoutRef.current); | |
} | |
// Debounce the sketch update | |
updateTimeoutRef.current = setTimeout(() => { | |
if (!containerRef.current) { | |
console.warn("Container ref is not available"); | |
return; | |
} | |
try { | |
console.log("Updating sketch from editor"); | |
const result = window.updateSketch(value, containerRef.current); | |
console.log("Editor update result:", result); | |
} catch (error) { | |
console.error("Error updating sketch from editor:", error); | |
} | |
}, 1000); // Wait for 1 second of no changes before updating | |
}; | |
const startResizing = useCallback((e: React.MouseEvent) => { | |
setIsResizing(true); | |
e.preventDefault(); | |
}, []); | |
const stopResizing = useCallback(() => { | |
setIsResizing(false); | |
}, []); | |
const resize = useCallback((e: MouseEvent) => { | |
if (isResizing && editorPanelRef.current) { | |
if (isMobile) { | |
// Vertical resizing for mobile | |
const containerHeight = editorPanelRef.current.parentElement?.clientHeight || 0; | |
const newHeight = (e.clientY / containerHeight) * 100; | |
setEditorHeight(Math.min(Math.max(20, newHeight), 80)); // Limit between 20% and 80% | |
} else { | |
// Horizontal resizing for desktop | |
const containerWidth = editorPanelRef.current.parentElement?.clientWidth || 0; | |
const newWidth = (e.clientX / containerWidth) * 100; | |
setEditorWidth(Math.min(Math.max(20, newWidth), 80)); // Limit between 20% and 80% | |
// Update sketch dimensions directly if needed | |
if (containerRef.current && window.mySketch) { | |
window.mySketch.containerWidth = containerRef.current.clientWidth; | |
window.mySketch.containerHeight = containerRef.current.clientHeight; | |
window.mySketch.resizeCanvas(window.mySketch.containerWidth, window.mySketch.containerHeight); | |
} | |
} | |
} | |
}, [isResizing, isMobile]); | |
useEffect(() => { | |
if (isResizing) { | |
window.addEventListener('mousemove', resize); | |
window.addEventListener('mouseup', stopResizing); | |
} | |
return () => { | |
window.removeEventListener('mousemove', resize); | |
window.removeEventListener('mouseup', stopResizing); | |
}; | |
}, [isResizing, resize, stopResizing]); | |
// Add a new useEffect to handle editor width changes | |
useEffect(() => { | |
if (!isMobile && containerRef.current) { | |
// Force p5.js to update its dimensions when editor width changes | |
const event = new Event('resize'); | |
window.dispatchEvent(event); | |
} | |
}, [editorWidth, isMobile]); | |
return ( | |
<div className="p5-with-editor"> | |
{/* Editor Panel */} | |
<div | |
ref={editorPanelRef} | |
className="editor-panel" | |
style={isMobile ? { height: `${editorHeight}%` } : { width: `${editorWidth}%` }} | |
> | |
<Editor | |
height="100%" | |
defaultLanguage="javascript" | |
value={displayedContent} | |
onChange={handleEditorChange} | |
onMount={handleEditorDidMount} | |
theme="vs-dark" | |
options={{ | |
minimap: { enabled: false }, | |
fontSize: 14, | |
wordWrap: 'on', | |
automaticLayout: true, | |
readOnly: isAnimatingCode, // Make editor read-only during animation | |
cursorBlinking: isAnimatingCode ? 'smooth' : 'blink', | |
cursorStyle: isAnimatingCode ? 'line' : 'line', | |
cursorWidth: isAnimatingCode ? 3 : 1 | |
}} | |
/> | |
<div | |
className="resize-handle" | |
onMouseDown={startResizing} | |
/> | |
{isSending && ( | |
<div className="sending-indicator"> | |
<Send size={16} /> | |
<span>Sending code</span> | |
</div> | |
)} | |
{isAnimatingCode && ( | |
<div className="animation-indicator"> | |
<Code size={16} /> | |
<span>Receiving code</span> | |
</div> | |
)} | |
</div> | |
{/* Sketch Panel */} | |
<div className="sketch-panel"> | |
<div | |
ref={containerRef} | |
className="sketch-container" | |
/> | |
</div> | |
</div> | |
); | |
} | |
export const P5WithEditor = memo(P5WithEditorComponent); |