diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts new file mode 100644 index 0000000..db270df --- /dev/null +++ b/src/app/api/ai/chat/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; + +type ContextBlock = { + id: string; + title?: string; + blockType?: string; + content?: string; +}; + +const summarizeContext = (context: ContextBlock[]) => + context + .map( + (block, index) => + `#${index + 1} [${block.blockType || "block"}] ${block.title || "Untitled"}\n${(block.content || "").slice(0, 500)}`, + ) + .join("\n\n"); + +export async function POST(req: Request) { + try { + const body = (await req.json()) as { + prompt?: string; + context?: ContextBlock[]; + }; + + const prompt = (body.prompt || "").trim(); + const context = Array.isArray(body.context) ? body.context : []; + + if (!prompt) { + return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return NextResponse.json({ + response: `I received your prompt: "${prompt}". Set OPENAI_API_KEY to enable live AI responses.`, + }); + } + + const input = [ + { + role: "system", + content: + "You are an assistant helping with project blocks. Use provided context blocks if relevant.", + }, + { + role: "user", + content: `Prompt:\n${prompt}\n\nContext blocks:\n${summarizeContext(context)}`, + }, + ]; + + const response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: process.env.AI_CHAT_MODEL || "gpt-4.1-mini", + input, + }), + }); + + if (!response.ok) { + const text = await response.text(); + return NextResponse.json({ error: text || "AI request failed" }, { status: 502 }); + } + + const data = (await response.json()) as { + output_text?: string; + }; + + return NextResponse.json({ response: data.output_text || "No response." }); + } catch { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } +} diff --git a/src/app/components/project/AIChatBlock.tsx b/src/app/components/project/AIChatBlock.tsx new file mode 100644 index 0000000..80d63c4 --- /dev/null +++ b/src/app/components/project/AIChatBlock.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { memo, useMemo, useState } from "react"; +import { + Handle, + Position, + type NodeProps, + type Node, + useEdges, +} from "@xyflow/react"; +import { Loader2, MessageSquare } from "lucide-react"; +import { useI18n } from "@providers/I18nProvider"; +import { BlockData } from "./CanvasBlock"; +import { BlockFooter } from "./BlockFooter"; +import { parseJsonRecord } from "@lib/metadata-parsers"; +import "./ai-chat-block.css"; + +type AIChatBlockProps = NodeProps>; + +type ChatMessage = { + role: "user" | "assistant"; + content: string; + createdAt: string; + contextBlockIds?: string[]; +}; + +const AIChatBlock = memo(({ id, data, selected }: AIChatBlockProps) => { + const { dict, lang } = useI18n(); + const edges = useEdges(); + const metadata = useMemo(() => parseJsonRecord(data.metadata), [data.metadata]); + const messages = (Array.isArray(metadata.messages) + ? metadata.messages + : []) as ChatMessage[]; + + const [prompt, setPrompt] = useState(data.content || ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const includeSelectedContext = + typeof metadata.includeSelectedContext === "boolean" + ? metadata.includeSelectedContext + : true; + const expandLinkedNodes = metadata.expandLinkedNodes === true; + + const selectedContextBlockIds = useMemo(() => { + const allBlocks = data.allBlocks || []; + const selectedIds = allBlocks + .filter((block) => block.selected && block.id !== id) + .map((block) => block.id); + + if (!expandLinkedNodes) return selectedIds; + + const linked = new Set(selectedIds); + for (const selectedId of selectedIds) { + for (const edge of data.allLinks || []) { + if (edge.source === selectedId && edge.target !== id) linked.add(edge.target); + if (edge.target === selectedId && edge.source !== id) linked.add(edge.source); + } + } + + return Array.from(linked); + }, [data.allBlocks, data.allLinks, expandLinkedNodes, id]); + + const selectedContext = useMemo(() => { + if (!includeSelectedContext) return []; + const allBlocks = data.allBlocks || []; + const idSet = new Set(selectedContextBlockIds); + return allBlocks + .filter((block) => idSet.has(block.id)) + .map((block) => ({ + id: block.id, + title: block.data.title || "", + blockType: block.data.blockType, + content: block.data.content || "", + })); + }, [data.allBlocks, includeSelectedContext, selectedContextBlockIds]); + + const persist = async (next: { + content?: string; + messages?: ChatMessage[]; + includeSelectedContext?: boolean; + expandLinkedNodes?: boolean; + }) => { + const now = new Date().toISOString(); + const editor = + data.currentUser?.displayName || + data.currentUser?.username || + dict.project.anonymous; + + const nextMeta = { + ...metadata, + messages: next.messages ?? messages, + includeSelectedContext: + next.includeSelectedContext ?? includeSelectedContext, + expandLinkedNodes: next.expandLinkedNodes ?? expandLinkedNodes, + }; + + await data.onContentChange?.( + id, + next.content ?? prompt, + now, + editor, + JSON.stringify(nextMeta), + data.title, + data.reactions, + ); + }; + + const submit = async () => { + if (!prompt.trim() || loading) return; + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/ai/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: prompt.trim(), + context: selectedContext, + }), + }); + + if (!res.ok) throw new Error("chat_request_failed"); + const body = (await res.json()) as { response?: string }; + const assistantContent = body.response || "No response generated."; + const now = new Date().toISOString(); + const nextMessages: ChatMessage[] = [ + ...messages, + { + role: "user", + content: prompt.trim(), + createdAt: now, + contextBlockIds: selectedContextBlockIds, + }, + { role: "assistant", content: assistantContent, createdAt: now }, + ]; + + await persist({ content: "", messages: nextMessages }); + setPrompt(""); + } catch { + setError(dict.common.error || "Request failed"); + } finally { + setLoading(false); + } + }; + + const isHandleConnected = (handleId: string) => + edges.some( + (e) => + (e.source === id && e.sourceHandle === handleId) || + (e.target === id && e.targetHandle === handleId), + ); + + return ( +
+ + + +
+ + {dict.blocks.blockTypeChat || "AI Chat"} +
+ +
+ + +
+ +