diff --git a/shared/types.ts b/shared/types.ts index f6dacb1..b5e5cf9 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -37,6 +37,8 @@ export type Content = { // For streaming support - shows in-progress tool calls toolCalls?: ToolCall[]; thinking?: boolean; + // AI-generated follow-up suggestions + suggestions?: string[]; }; export type MeshFileType = string; @@ -57,6 +59,7 @@ export type ParametricArtifact = { version: string; code: string; parameters: Parameter[]; + suggestions?: string[]; }; export type ParameterOption = { value: string | number; label: string }; diff --git a/src/components/chat/ChatSection.tsx b/src/components/chat/ChatSection.tsx index 0a31c05..077f8f4 100644 --- a/src/components/chat/ChatSection.tsx +++ b/src/components/chat/ChatSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Message, Model } from '@shared/types'; import TextAreaChat from '@/components/TextAreaChat'; +import { SuggestionPills } from '@/components/chat/SuggestionPills'; import { AssistantMessage } from '@/components/chat/AssistantMessage'; import { UserMessage } from '@/components/chat/UserMessage'; import { useConversation } from '@/services/conversationService'; @@ -9,6 +10,7 @@ import { AssistantLoading } from '@/components/chat/AssistantLoading'; import { ChatTitle } from '@/components/chat/ChatTitle'; import { TreeNode } from '@shared/Tree'; import { PARAMETRIC_MODELS } from '@/lib/utils'; +import { supabase } from '@/lib/supabase'; import { useIsLoading, useSendContentMutation, @@ -28,7 +30,9 @@ export function ChatSection({ messages }: ChatSectionProps) { // Sync model selection with the conversation history (last used model) useEffect(() => { if (messages.length > 0) { - const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); + const lastAssistantMessage = [...messages] + .reverse() + .find((m) => m.role === 'assistant'); if (lastAssistantMessage?.content?.model) { setModel(lastAssistantMessage.content.model); } @@ -67,6 +71,85 @@ export function ChatSection({ messages }: ChatSectionProps) { return messages[messages.length - 1]; }, [messages, conversation.current_message_leaf_id]); + // Generate suggestions based on the last user message + const [suggestions, setSuggestions] = useState([]); + const lastUserMessage = useMemo(() => { + return [...messages].reverse().find((m) => m.role === 'user'); + }, [messages]); + + // Generate suggestions when loading completes and we have a new assistant response + useEffect(() => { + // Don't generate while loading + if (isLoading) { + setSuggestions([]); + return; + } + + // Need a user message to base suggestions on + const userPrompt = lastUserMessage?.content?.text; + if (!userPrompt) { + setSuggestions([]); + return; + } + + // Check if the last message is an assistant message with an artifact (model was generated) + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role !== 'assistant' || !lastMsg?.content?.artifact) { + setSuggestions([]); + return; + } + + // Get the generated code for context + const generatedCode = lastMsg.content.artifact.code; + + // Track if this effect has been cancelled (for race condition handling) + let cancelled = false; + + // Generate suggestions + const generateSuggestions = async () => { + try { + const { data, error } = await supabase.functions.invoke( + 'suggestion-generator', + { + body: { + userPrompt, + generatedCode, + }, + }, + ); + + // Don't update state if this request was superseded + if (cancelled) return; + + if (error) throw error; + if (data?.suggestions) { + setSuggestions(data.suggestions); + } + } catch (err) { + if (cancelled) return; + console.error('Failed to generate suggestions:', err); + setSuggestions([]); + } + }; + + generateSuggestions(); + + // Cleanup: cancel this request if effect re-runs + return () => { + cancelled = true; + }; + }, [isLoading, lastUserMessage, messages]); + + const handleSuggestionSelect = useCallback( + (suggestion: string) => { + sendMessage({ + text: suggestion, + model: model, + }); + }, + [model, sendMessage], + ); + // Get the current version number based on assistant messages only const getCurrentVersion = useCallback( (index: number) => { @@ -111,6 +194,11 @@ export function ChatSection({ messages }: ChatSectionProps) {
+ void; + disabled?: boolean; +} + +export function SuggestionPills({ + disabled, + suggestions, + onSelect, +}: SuggestionPillsProps) { + if (!suggestions.length) return null; + + return ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ); +} diff --git a/supabase/config.toml b/supabase/config.toml index 74766a5..16007fd 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -29,3 +29,7 @@ verify_jwt = true [functions.title-generator] enabled = true verify_jwt = true + +[functions.suggestion-generator] +enabled = true +verify_jwt = true diff --git a/supabase/functions/suggestion-generator/deno.json b/supabase/functions/suggestion-generator/deno.json new file mode 100644 index 0000000..64dc332 --- /dev/null +++ b/supabase/functions/suggestion-generator/deno.json @@ -0,0 +1,10 @@ +{ + "imports": { + "@shared/": "../../../shared/" + }, + "lint": { + "rules": { + "exclude": ["no-import-prefix", "no-unversioned-import"] + } + } +} diff --git a/supabase/functions/suggestion-generator/index.ts b/supabase/functions/suggestion-generator/index.ts new file mode 100644 index 0000000..3c26797 --- /dev/null +++ b/supabase/functions/suggestion-generator/index.ts @@ -0,0 +1,149 @@ +import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; +import { corsHeaders } from '../_shared/cors.ts'; +import { getAnonSupabaseClient } from '../_shared/supabaseClient.ts'; + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const OPENROUTER_API_KEY = Deno.env.get('OPENROUTER_API_KEY') ?? ''; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + if (req.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + // Authenticate user + const supabaseClient = getAnonSupabaseClient({ + global: { + headers: { Authorization: req.headers.get('Authorization') ?? '' }, + }, + }); + + const { data: userData, error: userError } = + await supabaseClient.auth.getUser(); + + if (!userData.user) { + return new Response( + JSON.stringify({ error: { message: 'Unauthorized' } }), + { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ); + } + + if (userError) { + return new Response( + JSON.stringify({ error: { message: userError.message } }), + { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ); + } + + try { + const { userPrompt, generatedCode } = await req.json(); + + if (!userPrompt) { + return new Response(JSON.stringify({ suggestions: [] }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const suggestionPrompt = `You are helping a user iterate on a 3D CAD model. + +USER REQUEST: "${userPrompt}" + +GENERATED CODE: +\`\`\`openscad +${generatedCode?.slice(0, 1500) || 'No code available'} +\`\`\` + +Suggest exactly 2 NEW FEATURES to add to this model. Focus on structural additions, not parameter tweaks. + +Good suggestions ADD something new: +- "Add handle" - adds a new part +- "Add mounting holes" - adds functional feature +- "Add lid" - adds new component +- "Hollow it out" - structural change +- "Add feet" - adds new elements +- "Round the edges" - adds fillets/chamfers + +DO NOT suggest: +- Parameter adjustments (taller, wider, thicker, bigger, smaller) +- Generic improvements ("Add detail", "Improve design") +- Exporting, rendering, or colors +- Things already visible in the code + +Return exactly 2 suggestions (2-4 words each): +First new feature +Second new feature`; + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + 'HTTP-Referer': 'https://adam-cad.com', + 'X-Title': 'Adam CAD', + }, + body: JSON.stringify({ + model: 'anthropic/claude-3.5-haiku', + max_tokens: 100, + messages: [ + { + role: 'user', + content: suggestionPrompt, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`OpenRouter API error: ${response.statusText}`); + } + + const data = await response.json(); + let suggestions: string[] = []; + + if (data.choices && data.choices[0]?.message?.content) { + const responseText = data.choices[0].message.content; + const suggestionRegex = /(.*?)<\/suggestion>/gi; + const matches = responseText.matchAll(suggestionRegex); + + suggestions = Array.from( + new Set( + Array.from(matches) + .map(([, text]) => { + if (!text) return null; + const cleaned = text + .trim() + .replace(/[""'']/g, '') + .replace(/^["']|["']$/g, '') + .trim(); + const words = cleaned.split(/\s+/); + if (words.length > 5) return null; + return words + .map( + (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), + ) + .join(' '); + }) + .filter((s): s is string => s !== null && s.length > 0), + ), + ).slice(0, 2); + } + + return new Response(JSON.stringify({ suggestions }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error generating suggestions:', error); + return new Response(JSON.stringify({ suggestions: [] }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +});