demo-dev 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +174 -0
  2. package/bin/demo-cli.js +26 -0
  3. package/bin/demo-dev.js +26 -0
  4. package/demo.dev.config.example.json +20 -0
  5. package/dist/index.d.ts +392 -0
  6. package/dist/index.js +2116 -0
  7. package/package.json +76 -0
  8. package/skills/demo-dev/SKILL.md +153 -0
  9. package/skills/demo-dev/references/configuration.md +102 -0
  10. package/skills/demo-dev/references/recipes.md +83 -0
  11. package/src/ai/provider.ts +254 -0
  12. package/src/auth/bootstrap.ts +72 -0
  13. package/src/browser/session.ts +43 -0
  14. package/src/capture/continuous-capture.ts +739 -0
  15. package/src/cli.ts +337 -0
  16. package/src/config/project.ts +183 -0
  17. package/src/github/comment.ts +134 -0
  18. package/src/index.ts +10 -0
  19. package/src/lib/data-uri.ts +21 -0
  20. package/src/lib/fs.ts +7 -0
  21. package/src/lib/git.ts +59 -0
  22. package/src/lib/media.ts +23 -0
  23. package/src/orchestrate.ts +166 -0
  24. package/src/planner/heuristic.ts +180 -0
  25. package/src/planner/index.ts +26 -0
  26. package/src/planner/llm.ts +85 -0
  27. package/src/planner/openai.ts +77 -0
  28. package/src/planner/prompt.ts +331 -0
  29. package/src/planner/refine.ts +155 -0
  30. package/src/planner/schema.ts +62 -0
  31. package/src/presentation/polish.ts +84 -0
  32. package/src/probe/page-probe.ts +225 -0
  33. package/src/render/browser-frame.ts +176 -0
  34. package/src/render/ffmpeg-compose.ts +779 -0
  35. package/src/render/visual-plan.ts +422 -0
  36. package/src/setup/doctor.ts +158 -0
  37. package/src/setup/init.ts +90 -0
  38. package/src/types.ts +105 -0
  39. package/src/voice/script.ts +42 -0
  40. package/src/voice/tts.ts +286 -0
  41. package/tsconfig.json +16 -0
package/src/lib/git.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { DiffContext } from "../types.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ const runGit = async (args: string[]) => {
8
+ const { stdout } = await execFileAsync("git", args, { maxBuffer: 1024 * 1024 * 10 });
9
+ return stdout.trim();
10
+ };
11
+
12
+ const resolveRange = async (baseRef: string) => {
13
+ try {
14
+ await runGit(["rev-parse", "--verify", baseRef]);
15
+ return `${baseRef}...HEAD`;
16
+ } catch {
17
+ try {
18
+ await runGit(["rev-parse", "--verify", "HEAD~1"]);
19
+ return "HEAD~1...HEAD";
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ };
25
+
26
+ export const getCurrentBranch = async () => {
27
+ return runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
28
+ };
29
+
30
+ export const getChangedFiles = async (baseRef: string) => {
31
+ const range = await resolveRange(baseRef);
32
+ const output = range
33
+ ? await runGit(["diff", "--name-only", range])
34
+ : await runGit(["status", "--porcelain"]);
35
+
36
+ return output
37
+ .split("\n")
38
+ .map((line) => (range ? line : line.slice(3)).trim())
39
+ .filter(Boolean);
40
+ };
41
+
42
+ export const getDiffPreview = async (baseRef: string, maxChars = 12000) => {
43
+ const range = await resolveRange(baseRef);
44
+ const diff = range ? await runGit(["diff", "--no-color", range]) : await runGit(["diff", "--no-color"]);
45
+ return diff.length > maxChars ? `${diff.slice(0, maxChars)}\n... (truncated)` : diff;
46
+ };
47
+
48
+ export const buildDiffContext = async (baseRef: string): Promise<DiffContext> => {
49
+ const currentBranch = await getCurrentBranch();
50
+ const changedFiles = await getChangedFiles(baseRef);
51
+ const diffPreview = await getDiffPreview(baseRef);
52
+
53
+ return {
54
+ currentBranch,
55
+ baseRef,
56
+ changedFiles,
57
+ diffPreview,
58
+ };
59
+ };
@@ -0,0 +1,23 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export const getMediaDurationMs = async (path: string): Promise<number | undefined> => {
7
+ try {
8
+ const { stdout } = await execFileAsync("ffprobe", [
9
+ "-v",
10
+ "error",
11
+ "-show_entries",
12
+ "format=duration",
13
+ "-of",
14
+ "default=noprint_wrappers=1:nokey=1",
15
+ path,
16
+ ]);
17
+ const seconds = Number(stdout.trim());
18
+ if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
19
+ return Math.round(seconds * 1000);
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ };
@@ -0,0 +1,166 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { buildDiffContext } from "./lib/git.js";
4
+ import { writeJson } from "./lib/fs.js";
5
+ import type { DemoPlan } from "./types.js";
6
+ import { buildDemoPlan } from "./planner/index.js";
7
+ import { refineDemoPlan } from "./planner/refine.js";
8
+ import { probePlanScenes } from "./probe/page-probe.js";
9
+ import { capturePlanContinuous } from "./capture/continuous-capture.js";
10
+ import { buildVoiceScript } from "./voice/script.js";
11
+ import { synthesizeVoice } from "./voice/tts.js";
12
+ import { buildVisualPlan } from "./render/visual-plan.js";
13
+ import { composeVideo } from "./render/ffmpeg-compose.js";
14
+ import { polishPresentationCopy } from "./presentation/polish.js";
15
+ import { buildPromptPlan } from "./planner/prompt.js";
16
+ import type { ProjectConfig } from "./config/project.js";
17
+
18
+ export interface RunPipelineOptions {
19
+ baseRef: string;
20
+ baseUrl: string;
21
+ outputDir: string;
22
+ projectConfig?: ProjectConfig;
23
+ renderVideo?: boolean;
24
+ /** When set, skip diff-based planning and use prompt-driven planning instead. */
25
+ prompt?: string;
26
+ /** Video quality: "draft", "standard", "high". */
27
+ quality?: "draft" | "standard" | "high";
28
+ /** Wrap video in a browser frame with gradient background. */
29
+ frame?: boolean;
30
+ }
31
+
32
+ export const buildExecutablePlan = async (options: {
33
+ baseRef: string;
34
+ baseUrl: string;
35
+ outputDir: string;
36
+ projectConfig?: ProjectConfig;
37
+ }) => {
38
+ const context = await buildDiffContext(options.baseRef);
39
+ await writeJson(join(options.outputDir, "demo-context.json"), context);
40
+
41
+ const initialPlan = await buildDemoPlan(context, options.projectConfig);
42
+ await writeJson(join(options.outputDir, "demo-plan.initial.json"), initialPlan);
43
+
44
+ const probes = await probePlanScenes(initialPlan, {
45
+ baseUrl: options.baseUrl,
46
+ outputDir: options.outputDir,
47
+ });
48
+ await writeJson(join(options.outputDir, "page-probes.json"), probes);
49
+
50
+ const refinedPlan = await refineDemoPlan({ initialPlan, probes });
51
+ await writeJson(join(options.outputDir, "demo-plan.json"), refinedPlan);
52
+
53
+ return { context, initialPlan, probes, plan: refinedPlan };
54
+ };
55
+
56
+ export const runPipelineFromPrompt = async (options: {
57
+ prompt: string;
58
+ baseUrl: string;
59
+ outputDir: string;
60
+ projectConfig?: ProjectConfig;
61
+ renderVideo?: boolean;
62
+ quality?: "draft" | "standard" | "high";
63
+ frame?: boolean;
64
+ }) => {
65
+ await mkdir(options.outputDir, { recursive: true });
66
+
67
+ const plan = await buildPromptPlan({
68
+ prompt: options.prompt,
69
+ baseUrl: options.baseUrl,
70
+ outputDir: options.outputDir,
71
+ projectConfig: options.projectConfig,
72
+ });
73
+ await writeJson(join(options.outputDir, "demo-plan.json"), plan);
74
+
75
+ return runPipelineWithPlan({ plan, baseUrl: options.baseUrl, outputDir: options.outputDir, renderVideo: options.renderVideo, quality: options.quality, frame: options.frame });
76
+ };
77
+
78
+ export const runPipeline = async ({
79
+ baseRef,
80
+ baseUrl,
81
+ outputDir,
82
+ projectConfig,
83
+ renderVideo = true,
84
+ prompt,
85
+ quality,
86
+ frame,
87
+ }: RunPipelineOptions) => {
88
+ await mkdir(outputDir, { recursive: true });
89
+
90
+ if (prompt) {
91
+ return runPipelineFromPrompt({ prompt, baseUrl, outputDir, projectConfig, renderVideo, quality, frame });
92
+ }
93
+
94
+ const { context, initialPlan, probes, plan } = await buildExecutablePlan({
95
+ baseRef,
96
+ baseUrl,
97
+ outputDir,
98
+ projectConfig,
99
+ });
100
+
101
+ return runPipelineWithPlan({ plan, baseUrl, outputDir, renderVideo, quality, frame });
102
+ };
103
+
104
+ const runPipelineWithPlan = async (options: {
105
+ plan: DemoPlan;
106
+ baseUrl: string;
107
+ outputDir: string;
108
+ renderVideo?: boolean;
109
+ quality?: "draft" | "standard" | "high";
110
+ frame?: boolean;
111
+ }) => {
112
+ const { plan, baseUrl, outputDir, renderVideo = true } = options;
113
+
114
+ const captureResult = await capturePlanContinuous(plan, {
115
+ baseUrl,
116
+ outputDir: join(outputDir, "captures"),
117
+ });
118
+ await writeJson(join(outputDir, "continuous-capture.json"), {
119
+ videoPath: captureResult.videoPath,
120
+ sceneMarkers: captureResult.sceneMarkers,
121
+ interactions: captureResult.interactions,
122
+ totalDurationMs: captureResult.totalDurationMs,
123
+ viewport: captureResult.viewport,
124
+ cursorSamples: captureResult.cursorLog.length,
125
+ });
126
+
127
+ const visualPlan = buildVisualPlan(captureResult);
128
+ await writeJson(join(outputDir, "visual-plan.json"), visualPlan);
129
+
130
+ const polishedPlan = await polishPresentationCopy(plan);
131
+ const voiceScript = buildVoiceScript(polishedPlan);
132
+ const voicedLines = await synthesizeVoice(voiceScript, {
133
+ outputDir: join(outputDir, "audio"),
134
+ });
135
+ await writeJson(join(outputDir, "voice-script.json"), voicedLines);
136
+
137
+ const bgmPath = process.env.DEMO_BGM_PATH;
138
+ const bgmVolume = process.env.DEMO_BGM_VOLUME
139
+ ? Number(process.env.DEMO_BGM_VOLUME)
140
+ : undefined;
141
+
142
+ const videoPath = renderVideo
143
+ ? await composeVideo({
144
+ videoPath: captureResult.videoPath,
145
+ outputPath: join(outputDir, "pr-demo.mp4"),
146
+ visualPlan,
147
+ capture: captureResult,
148
+ voiceLines: voicedLines,
149
+ bgm: bgmPath ? { path: bgmPath, volume: bgmVolume } : undefined,
150
+ title: plan.title,
151
+ width: captureResult.viewport.width,
152
+ height: captureResult.viewport.height,
153
+ quality: options.quality,
154
+ frame: options.frame || undefined,
155
+ }).catch((error) => {
156
+ console.warn("FFmpeg composition failed:", error);
157
+ return undefined;
158
+ })
159
+ : undefined;
160
+
161
+ return {
162
+ plan: polishedPlan,
163
+ voiceScript: voicedLines,
164
+ videoPath,
165
+ };
166
+ };
@@ -0,0 +1,180 @@
1
+ import type { ProjectConfig } from "../config/project.js";
2
+ import type { DemoPlan, DemoScene, DiffContext } from "../types.js";
3
+
4
+ const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
5
+
6
+ const isUserFacingFile = (file: string) => {
7
+ const normalized = file.replaceAll("\\", "/").toLowerCase();
8
+ if (normalized.startsWith(".")) return false;
9
+ if (/package(-lock)?\.json$/.test(normalized)) return false;
10
+ if (/readme\.md$/.test(normalized)) return false;
11
+ if (/^(src\/)?(lib|utils|server|scripts)\//.test(normalized)) return false;
12
+ return /(^app\/|^pages\/|^src\/app\/|^src\/pages\/|component|route|screen|view|layout)/.test(normalized);
13
+ };
14
+
15
+ const normalizeRoute = (route: string) => {
16
+ if (!route.startsWith("/")) return `/${route}`.replace(/\/+/g, "/");
17
+ return route.replace(/\/+/g, "/");
18
+ };
19
+
20
+ const routeFromFile = (file: string, projectConfig?: ProjectConfig) => {
21
+ const normalized = file.replaceAll("\\", "/");
22
+ const routeLike = normalized
23
+ .replace(/^src\//, "")
24
+ .replace(/^app\//, "")
25
+ .replace(/^pages\//, "")
26
+ .replace(/\/page\.(tsx|ts|jsx|js)$/, "")
27
+ .replace(/\.(tsx|ts|jsx|js|mdx)$/, "")
28
+ .replace(/\/index$/, "");
29
+
30
+ if (!routeLike || routeLike.startsWith("components/") || routeLike.startsWith("lib/")) {
31
+ return "/";
32
+ }
33
+
34
+ const inferredRoute = normalizeRoute(routeLike);
35
+ const hintedRoute = projectConfig?.preferredRoutes?.find((route) => inferredRoute.startsWith(normalizeRoute(route)));
36
+ return hintedRoute ?? inferredRoute;
37
+ };
38
+
39
+ const classifyScene = (file: string) => {
40
+ const lower = file.toLowerCase();
41
+
42
+ if (/(login|signin|auth)/.test(lower)) {
43
+ return {
44
+ title: "Authentication flow",
45
+ goal: "Show the user-facing authentication changes introduced by this PR.",
46
+ narration: "This update tightens the authentication experience so users can get into the product faster.",
47
+ };
48
+ }
49
+
50
+ if (/(signup|register|onboarding)/.test(lower)) {
51
+ return {
52
+ title: "First-run onboarding",
53
+ goal: "Show how the first-run user journey has improved.",
54
+ narration: "This scene focuses on a smoother first-run experience.",
55
+ };
56
+ }
57
+
58
+ if (/(dashboard|home|overview)/.test(lower)) {
59
+ return {
60
+ title: "Core workspace",
61
+ goal: "Show the main workspace where users feel the biggest product changes.",
62
+ narration: "This scene highlights the most visible product changes in the core workspace.",
63
+ };
64
+ }
65
+
66
+ if (/(settings|profile|account)/.test(lower)) {
67
+ return {
68
+ title: "Settings and personalization",
69
+ goal: "Show how configuration and account flows have improved.",
70
+ narration: "This section emphasizes clearer, more controllable settings.",
71
+ };
72
+ }
73
+
74
+ if (/(search|discover|explore)/.test(lower)) {
75
+ return {
76
+ title: "Search and discovery",
77
+ goal: "Show the updated discovery path for users.",
78
+ narration: "This scene focuses on how search and discovery feel better in the updated product.",
79
+ };
80
+ }
81
+
82
+ return {
83
+ title: "Feature overview",
84
+ goal: "Show the visible product changes introduced by this PR.",
85
+ narration: "This segment quickly summarizes the feature update.",
86
+ };
87
+ };
88
+
89
+ const dedupeByUrl = (scenes: DemoScene[]) => {
90
+ const seen = new Set<string>();
91
+ return scenes.filter((scene) => {
92
+ if (seen.has(scene.url)) return false;
93
+ seen.add(scene.url);
94
+ return true;
95
+ });
96
+ };
97
+
98
+ const buildSceneFromRoute = (route: string, index: number, hint?: string): DemoScene => ({
99
+ id: `scene-${String(index + 1).padStart(2, "0")}`,
100
+ title: hint ? `${hint} walkthrough` : "Feature walkthrough",
101
+ goal: hint ? `Show the ${hint} experience in the product.` : "Show a meaningful product surface from this PR.",
102
+ url: normalizeRoute(route),
103
+ viewport: DEFAULT_VIEWPORT,
104
+ actions: [
105
+ { type: "navigate", url: normalizeRoute(route) },
106
+ { type: "wait", ms: 1200 },
107
+ { type: "scroll", y: 320 },
108
+ { type: "wait", ms: 400 },
109
+ ],
110
+ narration: hint
111
+ ? `This scene highlights ${hint} inside the product.`
112
+ : "This scene captures a key product surface from the updated app.",
113
+ caption: hint ? `${hint} · ${normalizeRoute(route)}` : `Feature walkthrough · ${normalizeRoute(route)}`,
114
+ durationMs: 4200,
115
+ evidenceHints: hint ? [hint] : [],
116
+ });
117
+
118
+ export const buildHeuristicPlan = (context: DiffContext, projectConfig?: ProjectConfig): DemoPlan => {
119
+ const candidateFiles = context.changedFiles.filter(isUserFacingFile);
120
+
121
+ const scenes = dedupeByUrl(
122
+ candidateFiles.slice(0, 5).map((file, index) => {
123
+ const classification = classifyScene(file);
124
+ const url = routeFromFile(file, projectConfig);
125
+
126
+ return {
127
+ id: `scene-${String(index + 1).padStart(2, "0")}`,
128
+ title: classification.title,
129
+ goal: classification.goal,
130
+ url,
131
+ viewport: DEFAULT_VIEWPORT,
132
+ actions: [
133
+ { type: "navigate", url },
134
+ { type: "wait", ms: 1200 },
135
+ { type: "scroll", y: 420 },
136
+ { type: "wait", ms: 500 },
137
+ ],
138
+ narration: classification.narration,
139
+ caption: `${classification.title} · ${url}`,
140
+ durationMs: 4500,
141
+ evidenceHints: [file],
142
+ } satisfies DemoScene;
143
+ }),
144
+ );
145
+
146
+ const hintedScenes = scenes.length === 0
147
+ ? (projectConfig?.preferredRoutes ?? []).slice(0, 4).map((route, index) => buildSceneFromRoute(route, index, projectConfig?.featureHints?.[index]))
148
+ : scenes;
149
+
150
+ return {
151
+ title: `PR Demo · ${context.currentBranch}`,
152
+ summary:
153
+ hintedScenes.length > 0
154
+ ? `Generated a demo plan from ${hintedScenes.length} user-facing route${hintedScenes.length === 1 ? "" : "s"}.`
155
+ : "Could not infer a specific route from the diff, so the plan falls back to the home page.",
156
+ branch: context.currentBranch,
157
+ generatedAt: new Date().toISOString(),
158
+ scenes:
159
+ hintedScenes.length > 0
160
+ ? hintedScenes
161
+ : [
162
+ {
163
+ id: "scene-01",
164
+ title: "Home overview",
165
+ goal: "Capture the home surface when no stronger route signal is available.",
166
+ url: "/",
167
+ viewport: DEFAULT_VIEWPORT,
168
+ actions: [
169
+ { type: "navigate", url: "/" },
170
+ { type: "wait", ms: 1200 },
171
+ { type: "scroll", y: 300 },
172
+ ],
173
+ narration: "The diff does not expose a clear product route, so the demo starts from the home surface.",
174
+ caption: "Home overview",
175
+ durationMs: 4000,
176
+ evidenceHints: context.changedFiles,
177
+ },
178
+ ],
179
+ };
180
+ };
@@ -0,0 +1,26 @@
1
+ import type { ProjectConfig } from "../config/project.js";
2
+ import type { DemoPlan, DiffContext } from "../types.js";
3
+ import { buildHeuristicPlan } from "./heuristic.js";
4
+ import { buildLlmPlan } from "./llm.js";
5
+
6
+ const allowHeuristicFallback = process.env.DEMO_AI_MANDATORY === "false";
7
+
8
+ export const buildDemoPlan = async (context: DiffContext, projectConfig?: ProjectConfig): Promise<DemoPlan> => {
9
+ try {
10
+ const llmPlan = await buildLlmPlan(context, projectConfig);
11
+ if (llmPlan) {
12
+ return llmPlan;
13
+ }
14
+ } catch (error) {
15
+ if (!allowHeuristicFallback) {
16
+ throw error;
17
+ }
18
+ console.warn("LLM planner failed, falling back to the heuristic planner", error);
19
+ }
20
+
21
+ if (!allowHeuristicFallback) {
22
+ throw new Error("AI provider did not return a plan. Set DEMO_AI_MANDATORY=false to allow heuristic fallback.");
23
+ }
24
+
25
+ return buildHeuristicPlan(context, projectConfig);
26
+ };
@@ -0,0 +1,85 @@
1
+ import type { ProjectConfig } from "../config/project.js";
2
+ import { summarizeProjectHints } from "../config/project.js";
3
+ import type { DemoPlan, DiffContext } from "../types.js";
4
+ import { requestAiJson } from "../ai/provider.js";
5
+ import { demoPlanSchema } from "./schema.js";
6
+
7
+ const buildPlannerPrompt = (context: DiffContext, projectConfig?: ProjectConfig) => {
8
+ return [
9
+ "You are a product demo director, not a QA engineer.",
10
+ "Goal: turn a git diff into a concise, recordable, voice-friendly demo plan.",
11
+ "Requirements:",
12
+ "1. Pick only 2-4 scenes that best represent visible user-facing changes.",
13
+ "2. Keep actions stable and executable. Prefer navigate / click / fill / waitForText / waitForUrl / scroll.",
14
+ "3. If the diff does not reveal specific elements, do not invent complex actions. Fall back to page-level presentation.",
15
+ "4. narration should sound like product storytelling, not a test report.",
16
+ "5. Output strict JSON only, with no markdown explanation.",
17
+ "6. All URLs must be in-app relative paths such as /dashboard.",
18
+ "7. Keep captions short enough for on-screen usage.",
19
+ "8. Keep durationMs between 3000 and 8000.",
20
+ "9. Prefer routes and feature surfaces hinted by the project config when relevant.",
21
+ "",
22
+ "Supported target examples:",
23
+ JSON.stringify(
24
+ {
25
+ strategy: "role",
26
+ role: "button",
27
+ name: "Sign in",
28
+ },
29
+ null,
30
+ 2,
31
+ ),
32
+ JSON.stringify(
33
+ {
34
+ strategy: "label",
35
+ value: "Email",
36
+ },
37
+ null,
38
+ 2,
39
+ ),
40
+ JSON.stringify(
41
+ {
42
+ strategy: "text",
43
+ value: "Welcome",
44
+ },
45
+ null,
46
+ 2,
47
+ ),
48
+ "",
49
+ "Project hints:",
50
+ JSON.stringify(summarizeProjectHints(projectConfig ?? {}), null, 2),
51
+ "",
52
+ "Plan from the following context:",
53
+ JSON.stringify(
54
+ {
55
+ branch: context.currentBranch,
56
+ baseRef: context.baseRef,
57
+ changedFiles: context.changedFiles.slice(0, 20),
58
+ diffPreview: context.diffPreview,
59
+ },
60
+ null,
61
+ 2,
62
+ ),
63
+ ].join("\n");
64
+ };
65
+
66
+ export const buildLlmPlan = async (context: DiffContext, projectConfig?: ProjectConfig): Promise<DemoPlan | null> => {
67
+ const normalized = await requestAiJson({
68
+ system: "You create concise, production-ready demo plans for product videos. Output strict JSON only.",
69
+ prompt: buildPlannerPrompt(context, projectConfig),
70
+ schema: demoPlanSchema,
71
+ temperature: 0.4,
72
+ });
73
+
74
+ if (!normalized) return null;
75
+
76
+ return {
77
+ ...normalized,
78
+ branch: context.currentBranch,
79
+ generatedAt: new Date().toISOString(),
80
+ scenes: normalized.scenes.map((scene) => ({
81
+ ...scene,
82
+ evidenceHints: scene.evidenceHints ?? [],
83
+ })),
84
+ };
85
+ };
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+
3
+ const responseSchema = z.object({
4
+ choices: z
5
+ .array(
6
+ z.object({
7
+ message: z.object({
8
+ content: z.string().nullable().optional(),
9
+ }),
10
+ }),
11
+ )
12
+ .min(1),
13
+ });
14
+
15
+ export const getPlannerConfig = () => {
16
+ const apiKey = process.env.DEMO_OPENAI_API_KEY;
17
+ const baseUrl = process.env.DEMO_OPENAI_BASE_URL ?? "https://api.openai.com/v1";
18
+ const model = process.env.DEMO_OPENAI_MODEL ?? "gpt-4.1-mini";
19
+ return { apiKey, baseUrl, model };
20
+ };
21
+
22
+ const extractJson = (text: string) => {
23
+ const fenced = text.match(/```json\s*([\s\S]*?)```/i)?.[1];
24
+ if (fenced) return fenced.trim();
25
+
26
+ const firstBrace = text.indexOf("{");
27
+ const lastBrace = text.lastIndexOf("}");
28
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
29
+ return text.slice(firstBrace, lastBrace + 1);
30
+ }
31
+
32
+ return text;
33
+ };
34
+
35
+ export const requestPlannerJson = async <T>(options: {
36
+ system: string;
37
+ prompt: string;
38
+ schema: z.ZodType<T>;
39
+ temperature?: number;
40
+ }) => {
41
+ const config = getPlannerConfig();
42
+ if (!config.apiKey) return null;
43
+
44
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
45
+ method: "POST",
46
+ headers: {
47
+ "content-type": "application/json",
48
+ authorization: `Bearer ${config.apiKey}`,
49
+ },
50
+ body: JSON.stringify({
51
+ model: config.model,
52
+ temperature: options.temperature ?? 0.4,
53
+ response_format: { type: "json_object" },
54
+ messages: [
55
+ {
56
+ role: "system",
57
+ content: options.system,
58
+ },
59
+ {
60
+ role: "user",
61
+ content: options.prompt,
62
+ },
63
+ ],
64
+ }),
65
+ });
66
+
67
+ if (!response.ok) {
68
+ const errorText = await response.text();
69
+ throw new Error(`planner request failed: ${response.status} ${errorText}`);
70
+ }
71
+
72
+ const payload = responseSchema.parse(await response.json());
73
+ const content = payload.choices[0]?.message.content;
74
+ if (!content) return null;
75
+
76
+ return options.schema.parse(JSON.parse(extractJson(content)));
77
+ };