diff --git a/.gitignore b/.gitignore index 44b92146b..79ffb42d7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ todos/*.md output/ forge.code-workspace .worktrees + +# Local devcontainer scratch (e.g., bind-mounted pgdata) +.tmp/ diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 5d66f044e..21724acf3 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -32,3 +32,25 @@ APPLE_CLIENT_SECRET= OKTA_CLIENT_ID= OKTA_CLIENT_SECRET= OKTA_ISSUER= + +# Unit 10+ — AI provider access for server-side drafting / embeddings. +# OpenRouter is preferred; OpenAI is the fallback. +OPENROUTER_API_KEY= +OPENAI_API_KEY= +# Optional when using OpenAI directly. +OPENAI_BASE_URL=https://api.openai.com/v1 +# Optional local-only embedding provider for Experience AI video search. +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_EMBEDDING_MODEL=embeddinggemma + +# AI drafting and experience embeddings use these provider keys. +OPENROUTER_API_KEY= +OPENAI_API_KEY= +OPENAI_BASE_URL= + +# Optional: allow the local codex CLI fallback for Experience AI drafting +# when neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set. Defaults to +# false so production environments fail fast with NOT_CONFIGURED rather +# than spawning a CLI at request time. Set to "true" on developer +# machines without an API key to keep AI drafting available locally. +EXPERIENCE_AI_ALLOW_CODEX_FALLBACK=false diff --git a/apps/admin/package.json b/apps/admin/package.json index 948618020..22f03252c 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -16,7 +16,8 @@ "db:migrate:dev": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:studio": "prisma studio", - "refresh:core-id-mapping": "tsx src/scripts/refresh-core-id-mapping.ts" + "refresh:core-id-mapping": "tsx src/scripts/refresh-core-id-mapping.ts", + "index:local-video-embeddings": "tsx src/scripts/index-local-video-candidate-embeddings.ts" }, "dependencies": { "@better-auth/prisma-adapter": "1.6.2", diff --git a/apps/admin/prisma/schema.prisma b/apps/admin/prisma/schema.prisma index 6d1134180..9ed8d8b98 100644 --- a/apps/admin/prisma/schema.prisma +++ b/apps/admin/prisma/schema.prisma @@ -492,7 +492,7 @@ model Video { source SourceTier @default(CORE) slug String @unique label VideoLabel? - videoSource VideoSource? + videoSource VideoSource? @map("video_source") locked Boolean @default(false) noIndex Boolean @default(false) @map("no_index") /// True when AI-generated metadata has been applied to this video. diff --git a/apps/admin/src/app/dashboard/experiences/[id]/page.tsx b/apps/admin/src/app/dashboard/experiences/[id]/page.tsx index 3958331e7..19f1574ab 100644 --- a/apps/admin/src/app/dashboard/experiences/[id]/page.tsx +++ b/apps/admin/src/app/dashboard/experiences/[id]/page.tsx @@ -2,6 +2,7 @@ import type { RevisionStatus } from "@prisma/client" import { notFound } from "next/navigation" import { revalidatePath } from "next/cache" import { ExperienceEditor } from "@/app/dashboard/experiences/experience-editor" +import { runGenerateDraftAction } from "@/app/dashboard/experiences/generate-draft-action" import { loadVideoRows } from "@/app/dashboard/live-data" import { requireSession } from "@/auth/session" import { prisma } from "@/db/client" @@ -484,6 +485,30 @@ export default async function ExperienceEditorPage({ return { ok: true } } + async function generateDraftAction(input: { + prompt: string + currentTitle: string + currentMetaDescription: string + }) { + "use server" + + const user = await requireSession() + + return runGenerateDraftAction( + { + prisma, + user, + }, + { + localeId: selectedLocale.id, + locale: selectedLocale.locale, + prompt: input.prompt, + currentTitle: input.currentTitle, + currentMetaDescription: input.currentMetaDescription, + }, + ) + } + return (
) diff --git a/apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx b/apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx index dd1136039..fc6a688e2 100644 --- a/apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx +++ b/apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx @@ -1,7 +1,12 @@ +// @vitest-environment jsdom + +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" import { renderToStaticMarkup } from "react-dom/server" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { ExperienceEditor, + applyGeneratedDraftToEditorState, cleanLocaleCode, cleanRoutePart, } from "./experience-editor" @@ -13,16 +18,37 @@ vi.mock("next/navigation", () => ({ }), })) +vi.mock("@/config/env", () => ({ + env: { + NEXT_PUBLIC_APP_NAME: "forge-admin", + NEXT_PUBLIC_WATCH_URL: "http://localhost:3000", + }, +})) + const action = vi.fn(async () => ({ ok: true })) +const generateDraftAction = vi.fn(async () => ({ + ok: true as const, + draft: { + title: "Generated", + metaDescription: "Generated description", + blocks: [{ t: "text", heading: "Generated" }], + }, +})) -function renderEditor( +function renderEditorElement( blocks: unknown[], - options: { isTemplate?: boolean } = {}, + options: { + isTemplate?: boolean + saveAction?: typeof action + publishAction?: typeof action + generateDraftAction?: typeof generateDraftAction + hasPublishedVersion?: boolean + } = {}, ) { - return renderToStaticMarkup( + return ( , + generateDraftAction={options.generateDraftAction ?? generateDraftAction} + /> + ) +} + +function renderEditor( + blocks: unknown[], + options: Parameters[1] = {}, +) { + return renderToStaticMarkup(renderEditorElement(blocks, options)) +} + +function renderEditorDom( + blocks: unknown[], + options: Parameters[1] = {}, +) { + const container = document.createElement("div") + document.body.appendChild(container) + const root: Root = createRoot(container) + + act(() => { + root.render(renderEditorElement(blocks, options)) + }) + + return { + container, + cleanup() { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +function findButtonByText(container: HTMLElement, label: string) { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes(label), + ) + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set + + act(() => { + setter?.call(textarea, value) + textarea.dispatchEvent(new Event("input", { bubbles: true })) + textarea.dispatchEvent(new Event("change", { bubbles: true })) + }) +} + +function deferredResult() { + let resolve!: (value: Awaited>) => void + const promise = new Promise>>( + (nextResolve) => { + resolve = nextResolve + }, ) + return { promise, resolve } } describe("ExperienceEditor", () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = "" + ;( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + window.requestAnimationFrame ??= ((callback: FrameRequestCallback) => + window.setTimeout( + () => callback(performance.now()), + 0, + )) as typeof window.requestAnimationFrame + window.cancelAnimationFrame ??= ((handle: number) => { + window.clearTimeout(handle) + }) as typeof window.cancelAnimationFrame + HTMLElement.prototype.scrollIntoView ??= vi.fn() + }) + it("normalizes route editor values to slug-compatible path parts", () => { expect(cleanRoutePart(" Easter Story 2026 ", true)).toBe( "easter-story-2026", @@ -132,6 +239,166 @@ describe("ExperienceEditor", () => { ) }) + it("shows Generate with AI only on an empty canvas", () => { + const emptyHtml = renderEditor([]) + const filledHtml = renderEditor([{ t: "text", heading: "Filled" }]) + + expect(emptyHtml).toContain("Generate with AI") + expect(emptyHtml).toContain("AI Draft") + expect(filledHtml).not.toContain("Generate with AI") + }) + + it("hides the AI entry point entirely when parsedBlocks is non-empty", () => { + const view = renderEditorDom([ + { t: "text", sectionKey: "intro", heading: "Existing content" }, + ]) + + try { + const aiButton = Array.from( + view.container.querySelectorAll("button"), + ).find((candidate) => candidate.textContent?.includes("Generate with AI")) + expect(aiButton).toBeUndefined() + expect(view.container.textContent).not.toContain("AI Draft") + expect(view.container.textContent).not.toContain("Empty Canvas") + } finally { + view.cleanup() + } + }) + + it("builds editor state from generated drafts with the first block selected", () => { + expect( + applyGeneratedDraftToEditorState({ + title: "Generated title", + metaDescription: "Generated description", + blocks: [{ t: "text", heading: "Generated section" }], + }), + ).toEqual({ + title: "Generated title", + metaDescription: "Generated description", + parsedBlocks: [{ t: "text", heading: "Generated section" }], + selectedBlockIndex: 0, + }) + }) + + it("submits the AI prompt once while pending and applies the returned draft locally", async () => { + const saveAction = vi.fn(async () => ({ ok: true })) + const publishAction = vi.fn(async () => ({ ok: true })) + const pending = deferredResult() + const generateDraftAction = vi.fn(() => pending.promise) + const view = renderEditorDom([], { + saveAction, + publishAction, + generateDraftAction, + }) + + act(() => { + findButtonByText(view.container, "Generate with AI").click() + }) + + const promptTextarea = view.container.querySelector( + "#ai-draft-prompt", + ) as HTMLTextAreaElement | null + expect(promptTextarea).not.toBeNull() + setTextareaValue(promptTextarea!, "Build a hopeful Easter welcome page.") + + const submitButton = findButtonByText(view.container, "Generate Draft") + await act(async () => { + submitButton.click() + }) + + expect(generateDraftAction).toHaveBeenCalledTimes(1) + expect(submitButton.disabled).toBe(true) + + await act(async () => { + submitButton.click() + }) + expect(generateDraftAction).toHaveBeenCalledTimes(1) + + await act(async () => { + pending.resolve({ + ok: true, + draft: { + title: "AI Easter Welcome", + metaDescription: "A generated welcome draft.", + blocks: [ + { + t: "text", + heading: "You are invited", + }, + ], + }, + }) + await pending.promise + }) + + const titleInput = view.container.querySelector( + 'input[placeholder="Untitled Experience"]', + ) as HTMLInputElement | null + const descriptionInput = view.container.querySelector( + 'textarea[aria-label="Description"]', + ) as HTMLTextAreaElement | null + + expect(titleInput?.value).toBe("AI Easter Welcome") + expect(descriptionInput?.value).toBe("A generated welcome draft.") + expect(view.container.textContent).toContain("A generated welcome draft.") + expect(view.container.textContent).toContain("Text") + expect(view.container.textContent).not.toContain("Empty Canvas") + expect(saveAction).not.toHaveBeenCalled() + expect(publishAction).not.toHaveBeenCalled() + + view.cleanup() + }) + + it("shows inline AI errors and allows retry", async () => { + const generateDraftAction = vi + .fn() + .mockResolvedValueOnce({ + ok: false as const, + error: + "No suitable in-catalog videos were found for this theme. Try broader wording.", + }) + .mockResolvedValueOnce({ + ok: true as const, + draft: { + title: "Recovered draft", + metaDescription: "Retry succeeded.", + blocks: [{ t: "text", heading: "Recovered block" }], + }, + }) + const view = renderEditorDom([], { generateDraftAction }) + + act(() => { + findButtonByText(view.container, "Generate with AI").click() + }) + + const promptTextarea = view.container.querySelector( + "#ai-draft-prompt", + ) as HTMLTextAreaElement | null + expect(promptTextarea).not.toBeNull() + setTextareaValue(promptTextarea!, "A very narrow prompt.") + + await act(async () => { + findButtonByText(view.container, "Generate Draft").click() + }) + + expect(view.container.textContent).toContain( + "No suitable in-catalog videos were found for this theme. Try broader wording.", + ) + + const retryButton = findButtonByText(view.container, "Generate Draft") + expect(retryButton.disabled).toBe(false) + + await act(async () => { + retryButton.click() + }) + + expect(generateDraftAction).toHaveBeenCalledTimes(2) + expect(view.container.textContent).toContain("Retry succeeded.") + expect(view.container.textContent).not.toContain("Empty Canvas") + + view.cleanup() + }) + it("renders a compact empty preview when a container has no slots", () => { const html = renderEditor([ { @@ -530,6 +797,37 @@ describe("ExperienceEditor", () => { expect(html).toContain("Publish") }) + it("renders preview instead of publish when nothing changed on a published locale", () => { + const html = renderEditor([], { hasPublishedVersion: true }) + expect(html).toContain("Preview") + expect(html).not.toContain("Open Published Page") + }) + + it("opens the published page from preview when a published version exists", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null) + const { container, cleanup } = renderEditorDom([], { + hasPublishedVersion: true, + }) + + try { + const previewButton = findButtonByText(container, "Preview") + expect(previewButton.disabled).toBe(false) + + await act(async () => { + previewButton.click() + }) + + expect(openSpy).toHaveBeenCalledWith( + "http://localhost:3000/watch/experience-title/en", + "_blank", + "noopener,noreferrer", + ) + } finally { + cleanup() + openSpy.mockRestore() + } + }) + it("gates route video block templates behind template mode", () => { const standardHtml = renderEditor([]) diff --git a/apps/admin/src/app/dashboard/experiences/experience-editor.tsx b/apps/admin/src/app/dashboard/experiences/experience-editor.tsx index 3f0899458..46370a8ad 100644 --- a/apps/admin/src/app/dashboard/experiences/experience-editor.tsx +++ b/apps/admin/src/app/dashboard/experiences/experience-editor.tsx @@ -79,7 +79,10 @@ import { } from "lucide-react" import { cx } from "@/components/admin-ui" import { ConfirmModal } from "@/components/confirm-modal" +import { env } from "@/config/env" import { ToastStack, useToastStack } from "@/components/toast-stack" +import type { GenerateDraftActionResult } from "./generate-draft-action" +import { AiDraftPanel } from "./experience-editor/ai-draft-panel" import { BackgroundColorPicker, normalizeHexColor, @@ -190,6 +193,16 @@ function stableSectionContentBlockKey(item: unknown, childIndex: number) { return nextKey } +export function applyGeneratedDraftToEditorState(draft: GeneratedDraftPayload) { + return { + title: draft.title, + metaDescription: draft.metaDescription, + parsedBlocks: Array.isArray(draft.blocks) ? draft.blocks : [], + selectedBlockIndex: + Array.isArray(draft.blocks) && draft.blocks.length > 0 ? 0 : null, + } +} + type BlockCategoryFilter = "All" | BlockTemplateDefinition["category"] type InsertedBlockAnimation = { key: string @@ -202,6 +215,11 @@ type PendingContainerSlotDelete = { blockCount: number } +type GeneratedDraftPayload = Extract< + GenerateDraftActionResult, + { ok: true } +>["draft"] + type NavigationDestinationPickerPosition = { top: number left: number @@ -846,6 +864,32 @@ function localizedVideoLabelFallback(label: string | null, localeCode: string) { return labels[label as keyof typeof labels] ?? "" } +function inferWatchBaseUrl() { + if (env.NEXT_PUBLIC_WATCH_URL) { + return env.NEXT_PUBLIC_WATCH_URL.replace(/\/$/, "") + } + + if (typeof window === "undefined") return "" + + const { protocol, hostname, origin } = window.location + if (hostname === "localhost" || hostname === "127.0.0.1") { + return `${protocol}//${hostname}:3000` + } + + return origin.replace(/\/$/, "") +} + +function buildPublishedWatchUrl(slug: string, locale: string) { + const normalizedSlug = cleanRoutePart(slug) + const normalizedLocale = cleanLocaleCode(locale) + if (!normalizedSlug || !normalizedLocale) return null + + const baseUrl = inferWatchBaseUrl() + if (!baseUrl) return null + + return `${baseUrl}/watch/${normalizedSlug}/${normalizedLocale}` +} + export function ExperienceEditor({ canPublish, hasPublishedVersion, @@ -858,6 +902,7 @@ export function ExperienceEditor({ publishAction, createLocaleAction, restoreAction, + generateDraftAction, }: { canPublish: boolean hasPublishedVersion: boolean @@ -882,9 +927,17 @@ export function ExperienceEditor({ publishAction: (localeId: string) => Promise createLocaleAction: (formData: FormData) => Promise restoreAction: (revisionId: string) => Promise + generateDraftAction: (input: { + prompt: string + currentTitle: string + currentMetaDescription: string + }) => Promise }) { const router = useRouter() const { toasts, pushToast, dismissToast } = useToastStack() + const [publishedSlug, setPublishedSlug] = useState( + hasPublishedVersion ? cleanRoutePart(initialValues.slug) : null, + ) const [editorDateSnapshot, setEditorDateSnapshot] = useState(calendarDate) const editorToday = parseEditorDateSnapshot(editorDateSnapshot) const [title, setTitle] = useState(initialValues.title) @@ -1025,6 +1078,10 @@ export function ExperienceEditor({ const [insertedBlockAnimation, setInsertedBlockAnimation] = useState(null) const [isPending, startTransition] = useTransition() + const [isGeneratingDraft, startDraftTransition] = useTransition() + const [aiDraftPanelOpen, setAiDraftPanelOpen] = useState(false) + const [aiDraftPrompt, setAiDraftPrompt] = useState("") + const [aiDraftError, setAiDraftError] = useState("") const blockCardRefs = useRef(new Map()) const navigationDestinationPopoverRef = useRef(null) const videoPickerPreviewContainerRef = useRef(null) @@ -1087,6 +1144,44 @@ export function ExperienceEditor({ setLocaleDrawerOpen(false) } + function applyGeneratedDraft(draft: GeneratedDraftPayload) { + const nextState = applyGeneratedDraftToEditorState(draft) + setTitle(nextState.title) + setMetaDescription(nextState.metaDescription) + setParsedBlocks(nextState.parsedBlocks) + setSelectedBlockIndex(nextState.selectedBlockIndex) + setAiDraftPanelOpen(false) + setAiDraftPrompt("") + setAiDraftError("") + } + + function handleGenerateDraft() { + if (isGeneratingDraft) return + + const prompt = aiDraftPrompt.trim() + if (!prompt) { + setAiDraftError("Enter a theme or story prompt first.") + return + } + + setAiDraftError("") + startDraftTransition(async () => { + const result = await generateDraftAction({ + prompt, + currentTitle: title, + currentMetaDescription: metaDescription, + }) + + if (!result.ok) { + setAiDraftError(result.error) + return + } + + applyGeneratedDraft(result.draft) + pushToast("AI draft applied to the canvas.", "success") + }) + } + const serializedBlocks = JSON.stringify(parsedBlocks) const normalizedParsedBlocks = normalizeEditorBlocks(parsedBlocks) const initialSerializedBlocks = JSON.stringify( @@ -1107,6 +1202,14 @@ export function ExperienceEditor({ 34, )}ch` const canPublishNow = canPublish && (!hasPublishedVersion || hasChanges) + const activeLocaleCode = + localeEntries.find((entry) => entry.active)?.code ?? "" + const publishedWatchUrl = buildPublishedWatchUrl( + publishedSlug ?? "", + activeLocaleCode, + ) + const canOpenPublishedPage = publishedWatchUrl !== null + const shouldShowPreviewAction = canOpenPublishedPage && !hasChanges const isFloatingDrawerOpen = inlineBlockLibraryOpen || revisionHistoryOpen || localeDrawerOpen const isAddingToContainerSlot = focusedContainerIndex !== null @@ -8980,17 +9083,37 @@ export function ExperienceEditor({ Save Draft - + {shouldShowPreviewAction ? ( + + ) : ( + + )} @@ -9839,12 +9962,34 @@ export function ExperienceEditor({ Start with a first block

- Pick a starter block below, or open the full block - library if you want to build from a different pattern. + Generate a first draft from a prompt, or pick a starter + block below if you want to build manually.

+
+ { + setAiDraftPrompt(value) + if (aiDraftError) setAiDraftError("") + }} + onOpen={() => { + setAiDraftPanelOpen(true) + setAiDraftError("") + }} + onCancel={() => { + setAiDraftPanelOpen(false) + setAiDraftError("") + }} + onGenerate={handleGenerateDraft} + /> +
+
{EMPTY_CANVAS_STARTERS.map((starterKey) => { const block = BLOCK_LIBRARY.find( @@ -9936,6 +10081,19 @@ export function ExperienceEditor({ ) return } + const nextPublishedSlug = cleanRoutePart(slug) + setPublishedSlug(nextPublishedSlug) + const nextPublishedWatchUrl = buildPublishedWatchUrl( + nextPublishedSlug, + activeLocaleCode, + ) + if (nextPublishedWatchUrl) { + window.open( + nextPublishedWatchUrl, + "_blank", + "noopener,noreferrer", + ) + } pushToast("Locale published.", "success") } else { pushToast("Locale saved.", "success") diff --git a/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx b/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx new file mode 100644 index 000000000..88ab2a8c7 --- /dev/null +++ b/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx @@ -0,0 +1,114 @@ +"use client" + +import { Sparkles, X } from "lucide-react" + +export function AiDraftPanel({ + open, + prompt, + pending, + error, + onPromptChange, + onOpen, + onCancel, + onGenerate, +}: { + open: boolean + prompt: string + pending: boolean + error: string + onPromptChange: (value: string) => void + onOpen: () => void + onCancel: () => void + onGenerate: () => void +}) { + if (!open) { + return ( + + ) + } + + return ( +
+
+
+
+ AI Draft +
+
+ Generate with AI +
+

+ Describe the theme, story, or angle you want. AI will draft title, + description, and blocks into this empty canvas. +

+
+ +
+ +