ai-prompt.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. "use client";
  2. import { useState, useRef, useMemo } from "react";
  3. import { MessageSquare, X, ArrowUp, Loader2 } from "lucide-react";
  4. import { deepMergeSpec } from "@json-render/core";
  5. import type { Spec } from "@json-render/core";
  6. import { parse } from "yaml";
  7. import { useEditorStore } from "@/lib/store";
  8. import { useIsMobile } from "@/lib/use-mobile";
  9. import { sceneToSpec } from "@/lib/scene-to-spec";
  10. import { specToSceneObjects } from "@/lib/spec-to-scene";
  11. function stripFences(text: string): string {
  12. return text
  13. .replace(/```yaml-edit\s*\n?/g, "")
  14. .replace(/```yaml-spec\s*\n?/g, "")
  15. .replace(/```\s*$/gm, "")
  16. .trim();
  17. }
  18. export function AIPrompt() {
  19. const [open, setOpen] = useState(false);
  20. const [prompt, setPrompt] = useState("");
  21. const [isProcessing, setIsProcessing] = useState(false);
  22. const [message, setMessage] = useState("");
  23. const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
  24. const inputRef = useRef<HTMLInputElement>(null);
  25. const isMobile = useIsMobile();
  26. const scenes = useEditorStore((s) => s.scenes);
  27. const activeSceneId = useEditorStore((s) => s.activeSceneId);
  28. const replaceSceneObjects = useEditorStore((s) => s.replaceSceneObjects);
  29. const setIsPromptOpen = useEditorStore((s) => s.setIsPromptOpen);
  30. const activeScene = useMemo(
  31. () => scenes.find((s) => s.id === activeSceneId),
  32. [scenes, activeSceneId],
  33. );
  34. const handleSubmit = async () => {
  35. if (!prompt.trim() || isProcessing || !activeScene) return;
  36. setIsProcessing(true);
  37. setMessage("Thinking...");
  38. setIsPromptOpen(true);
  39. try {
  40. const currentSpec = sceneToSpec(activeScene);
  41. const res = await fetch("/api/ai", {
  42. method: "POST",
  43. headers: { "Content-Type": "application/json" },
  44. body: JSON.stringify({
  45. prompt,
  46. spec: currentSpec,
  47. previousPrompts,
  48. }),
  49. });
  50. if (!res.ok) {
  51. setMessage("Error: " + res.statusText);
  52. return;
  53. }
  54. const reader = res.body?.getReader();
  55. if (!reader) return;
  56. const decoder = new TextDecoder();
  57. let accumulated = "";
  58. let lastAppliedJson = "";
  59. const baseSpec = JSON.parse(JSON.stringify(currentSpec));
  60. while (true) {
  61. const { done, value } = await reader.read();
  62. if (done) break;
  63. const chunk = decoder.decode(value, { stream: true });
  64. accumulated += chunk;
  65. if (!chunk.includes("\n")) continue;
  66. const stripped = stripFences(accumulated);
  67. if (!stripped) continue;
  68. try {
  69. const editObj = parse(stripped);
  70. if (
  71. editObj &&
  72. typeof editObj === "object" &&
  73. !Array.isArray(editObj)
  74. ) {
  75. const editJson = JSON.stringify(editObj);
  76. if (editJson === lastAppliedJson) continue;
  77. lastAppliedJson = editJson;
  78. const merged = deepMergeSpec(
  79. JSON.parse(JSON.stringify(baseSpec)),
  80. editObj as Record<string, unknown>,
  81. );
  82. const patchedSpec = merged as unknown as Spec;
  83. const newObjects = specToSceneObjects(patchedSpec);
  84. if (newObjects.length > 0) {
  85. replaceSceneObjects(newObjects);
  86. }
  87. }
  88. } catch {
  89. // Incomplete YAML — wait for more data
  90. }
  91. }
  92. // Final parse to catch any remaining content
  93. const stripped = stripFences(accumulated);
  94. if (stripped) {
  95. try {
  96. const editObj = parse(stripped);
  97. if (
  98. editObj &&
  99. typeof editObj === "object" &&
  100. !Array.isArray(editObj)
  101. ) {
  102. const merged = deepMergeSpec(
  103. JSON.parse(JSON.stringify(baseSpec)),
  104. editObj as Record<string, unknown>,
  105. );
  106. const patchedSpec = merged as unknown as Spec;
  107. const newObjects = specToSceneObjects(patchedSpec);
  108. if (newObjects.length > 0) {
  109. replaceSceneObjects(newObjects);
  110. }
  111. }
  112. } catch {
  113. // Parse failed
  114. }
  115. }
  116. setPreviousPrompts((prev) => [...prev, prompt]);
  117. setMessage("Done");
  118. setPrompt("");
  119. } catch {
  120. setMessage("Error occurred");
  121. } finally {
  122. setIsProcessing(false);
  123. setIsPromptOpen(false);
  124. setTimeout(() => setMessage(""), 3000);
  125. }
  126. };
  127. if (!open) {
  128. return (
  129. <button
  130. onClick={() => {
  131. setOpen(true);
  132. setTimeout(() => inputRef.current?.focus(), 100);
  133. }}
  134. className={`absolute bottom-4 right-4 z-10 ${isMobile ? "w-11 h-11" : "w-9 h-9"} flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white transition-colors backdrop-blur-sm`}
  135. title="AI Scene Editor"
  136. >
  137. <MessageSquare size={isMobile ? 18 : 16} />
  138. </button>
  139. );
  140. }
  141. return (
  142. <div
  143. className={`absolute bottom-4 right-4 z-10 ${isMobile ? "left-4 right-4 w-auto" : "w-80"}`}
  144. >
  145. <div className="bg-[#141414] border border-[#2a2a2a] rounded-lg shadow-xl overflow-hidden">
  146. <div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
  147. <span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
  148. AI Scene Editor
  149. </span>
  150. <button
  151. onClick={() => setOpen(false)}
  152. className="p-1.5 text-[#666] hover:text-white active:text-white"
  153. >
  154. <X size={14} />
  155. </button>
  156. </div>
  157. <div className="p-3">
  158. <div className="flex gap-2">
  159. <input
  160. ref={inputRef}
  161. type="text"
  162. value={prompt}
  163. onChange={(e) => setPrompt(e.target.value)}
  164. onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
  165. placeholder="Describe scene changes..."
  166. disabled={isProcessing}
  167. className={`flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2.5 ${isMobile ? "py-2.5 text-base" : "py-1.5 text-xs"} text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444] disabled:opacity-50`}
  168. />
  169. <button
  170. onClick={handleSubmit}
  171. disabled={isProcessing || !prompt.trim()}
  172. className={`${isMobile ? "p-2.5" : "p-1.5"} rounded bg-white/10 hover:bg-white/20 active:bg-white/30 text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors`}
  173. >
  174. {isProcessing ? (
  175. <Loader2 size={14} className="animate-spin" />
  176. ) : (
  177. <ArrowUp size={14} />
  178. )}
  179. </button>
  180. </div>
  181. {message && (
  182. <div className="mt-2 text-[10px] text-[#666] flex items-center gap-1.5">
  183. {isProcessing && <Loader2 size={10} className="animate-spin" />}
  184. {message}
  185. </div>
  186. )}
  187. </div>
  188. </div>
  189. </div>
  190. );
  191. }