newpr 0.4.0 → 0.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  "@types/react-dom": "^19.2.3"
54
54
  },
55
55
  "peerDependencies": {
56
- "typescript": "^5"
56
+ "typescript": "^5.9.3"
57
57
  },
58
58
  "dependencies": {
59
59
  "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -5,6 +5,46 @@ import type { FileChange, FileGroup, NewprOutput, PrSummary } from "../types/out
5
5
  import type { ExplorationResult } from "../workspace/types.ts";
6
6
  import type { AgentToolName } from "../workspace/types.ts";
7
7
  import { parseDiff } from "../diff/parser.ts";
8
+
9
+ function annotateDiffWithLineNumbers(rawDiff: string): string {
10
+ const lines = rawDiff.split("\n");
11
+ const result: string[] = [];
12
+ let oldNum = 0;
13
+ let newNum = 0;
14
+
15
+ for (const line of lines) {
16
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
17
+ if (hunkMatch) {
18
+ oldNum = Number(hunkMatch[1]);
19
+ newNum = Number(hunkMatch[2]);
20
+ result.push(line);
21
+ continue;
22
+ }
23
+
24
+ if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("\\")) {
25
+ result.push(line);
26
+ continue;
27
+ }
28
+
29
+ if (line.startsWith("+")) {
30
+ result.push(`L${newNum} + ${line.slice(1)}`);
31
+ newNum++;
32
+ } else if (line.startsWith("-")) {
33
+ result.push(` - ${line.slice(1)}`);
34
+ oldNum++;
35
+ } else {
36
+ const text = line.startsWith(" ") ? line.slice(1) : line;
37
+ if (oldNum > 0 || newNum > 0) {
38
+ result.push(`L${newNum} ${text}`);
39
+ oldNum++;
40
+ newNum++;
41
+ } else {
42
+ result.push(line);
43
+ }
44
+ }
45
+ }
46
+ return result.join("\n");
47
+ }
8
48
  import { chunkDiff } from "../diff/chunker.ts";
9
49
  import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
10
50
  import { fetchPrDiff } from "../github/fetch-diff.ts";
@@ -257,7 +297,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
257
297
  progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
258
298
  const fileDiffs = chunks.slice(0, 30).map((c) => ({
259
299
  path: c.file_path,
260
- diff: c.diff_content.length > 3000 ? `${c.diff_content.slice(0, 3000)}\n... (truncated)` : c.diff_content,
300
+ diff: annotateDiffWithLineNumbers(c.diff_content.length > 3000 ? c.diff_content.slice(0, 3000) : c.diff_content),
261
301
  }));
262
302
  const narrativePrompt = exploration
263
303
  ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
@@ -298,6 +338,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
298
338
  pr_body: prData.body || undefined,
299
339
  pr_url: prData.url,
300
340
  pr_state: prData.state,
341
+ pr_updated_at: prData.updated_at,
301
342
  base_branch: prData.base_branch,
302
343
  head_branch: prData.head_branch,
303
344
  author: prData.author,
@@ -13,6 +13,7 @@ export interface StoredConfig {
13
13
  concurrency?: number;
14
14
  language?: string;
15
15
  agent?: string;
16
+ enabled_plugins?: string[];
16
17
  }
17
18
 
18
19
  function ensureDir(): void {
@@ -20,6 +20,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
20
20
  body: (json.body as string) ?? "",
21
21
  url: json.html_url as string,
22
22
  state,
23
+ updated_at: (json.updated_at as string) ?? new Date().toISOString(),
23
24
  base_branch: (base?.ref as string) ?? "unknown",
24
25
  head_branch: (head?.ref as string) ?? "unknown",
25
26
  author: (user?.login as string) ?? "unknown",
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { mkdirSync, rmSync, existsSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
- import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
5
+ import type { NewprOutput, DiffComment, ChatMessage, CartoonImage, SlideDeck } from "../types/output.ts";
6
6
  import type { SessionRecord } from "./types.ts";
7
7
 
8
8
  const HISTORY_DIR = join(homedir(), ".newpr", "history");
@@ -197,6 +197,30 @@ export async function loadCartoonSidecar(
197
197
  }
198
198
  }
199
199
 
200
+ export async function saveSlidesSidecar(
201
+ id: string,
202
+ deck: SlideDeck,
203
+ ): Promise<void> {
204
+ ensureDirs();
205
+ await Bun.write(
206
+ join(SESSIONS_DIR, `${id}.slides.json`),
207
+ JSON.stringify(deck),
208
+ );
209
+ }
210
+
211
+ export async function loadSlidesSidecar(
212
+ id: string,
213
+ ): Promise<SlideDeck | null> {
214
+ try {
215
+ const filePath = join(SESSIONS_DIR, `${id}.slides.json`);
216
+ const file = Bun.file(filePath);
217
+ if (!(await file.exists())) return null;
218
+ return JSON.parse(await file.text()) as SlideDeck;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
200
224
  export function getHistoryPath(): string {
201
225
  return HISTORY_DIR;
202
226
  }
@@ -145,7 +145,12 @@ export function buildNarrativePrompt(
145
145
  .join("\n\n");
146
146
 
147
147
  const diffContext = fileDiffs && fileDiffs.length > 0
148
- ? `\n\n--- FILE DIFFS (use these line numbers for [[line:...]] anchors) ---\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
148
+ ? `\n\n--- FILE DIFFS (LINE NUMBERS ARE PRE-COMPUTED — use them directly for [[line:...]] anchors) ---
149
+ Each line is prefixed with its new-file line number:
150
+ "L42 + code" = added line at L42
151
+ " - code" = removed line (no new line number)
152
+ "L42 code" = unchanged context at L42
153
+ Use the L-numbers EXACTLY as shown. Do NOT compute line numbers yourself.\n\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
149
154
  : "";
150
155
 
151
156
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
@@ -178,6 +183,7 @@ There are THREE anchor types. You MUST use ALL of them.
178
183
  - Format: [[group:Exact Group Name]]
179
184
  - Renders as a clickable blue chip.
180
185
  - You MUST reference EVERY group from the Change Groups list at least once. No exceptions.
186
+ - Use the EXACT group name ONLY — do NOT append the type in parentheses. Write [[group:Auth Flow]], NOT [[group:Auth Flow (refactor)]].
181
187
  - Use group anchors when introducing a topic area or explaining what a set of changes accomplishes together.
182
188
  - Example: "The [[group:Auth Flow]] group introduces session management."
183
189
 
@@ -196,24 +202,37 @@ There are THREE anchor types. You MUST use ALL of them.
196
202
  ### Usage Rules:
197
203
  - ALWAYS use [[line:path#Lstart-Lend]](text) with BOTH start and end lines. Single lines: [[line:path#L42-L42]](text).
198
204
  - The (text) must describe WHAT the code does, not WHERE it is. Bad: "lines 42-50". Good: "the new rate limiter middleware".
199
- - Wrap EVERY specific code mention in a line anchor. If you mention a function, class, type, constant, config change, or import — anchor it.
200
- - Interleave anchors naturally within sentences. They should feel like hyperlinks in a wiki article.
201
205
  - Do NOT pair [[file:...]] with [[line:...]] for the same file. The line anchor already opens the file.
202
206
  - Use the diff context provided to find accurate line numbers. If unsure of exact lines, use [[file:...]] instead.
203
207
 
204
- ### Anchor DensityTWO levels:
205
- When describing a function or class, use anchors at TWO granularity levels:
208
+ ### ANCHOR DENSITYTHIS IS THE MOST IMPORTANT RULE
206
209
 
207
- **Level 1 Declaration**: Anchor the function/class name itself to its full definition range.
208
- **Level 2 — Implementation details**: When explaining what the code does, anchor EACH distinct piece of logic to its specific lines within the function.
210
+ Every sentence that describes code MUST contain at least one [[line:...]](...) anchor. A sentence without an anchor is a FAILURE.
209
211
 
210
- Example with two levels:
212
+ Think of this like writing a Wikipedia article: almost every claim links to its source. In your narrative, the "source" is the specific line range in the diff.
213
+
214
+ **What MUST be anchored:**
215
+ - Every function, method, or class mentioned → anchor its declaration
216
+ - Every implementation detail (what a function does) → anchor the specific lines
217
+ - Every type, interface, or schema → anchor its definition
218
+ - Every config change, constant, or environment variable → anchor it
219
+ - Every import, export, or wiring between modules → anchor it
220
+ - Every conditional logic, error handling, or edge case → anchor the specific branch
221
+ - Every before/after comparison → anchor both the old and new code
222
+
223
+ **Two-level anchoring for functions:**
224
+ - Level 1: Anchor the function/class NAME to its full range (e.g., L15-L50)
225
+ - Level 2: Inside the same paragraph, anchor EACH logical step to its sub-range (e.g., L18-L22, L24-L30, L32-L40)
226
+ - A function description without Level 2 sub-anchors is TOO SPARSE
227
+
228
+ **Target density: 3-6 line anchors per paragraph.** If a paragraph has fewer than 2 line anchors, you are not anchoring enough.
229
+
230
+ Example (CORRECT density — 5 anchors in one paragraph):
211
231
  "[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles the full JWT lifecycle. It [[line:src/auth/session.ts#L18-L22]](extracts the token from the Authorization header), [[line:src/auth/session.ts#L24-L30]](verifies the signature against the configured secret), and [[line:src/auth/session.ts#L32-L40]](checks the expiration timestamp). If validation fails, [[line:src/auth/session.ts#L42-L48]](it throws a typed AuthError with a specific error code)."
212
232
 
213
- Key principles:
214
- - The first anchor covers the entire function (L15-L50). Subsequent anchors zoom into specific parts within it.
215
- - Each sub-anchor should cover 2-10 lines one logical step.
216
- - Descriptive text for sub-anchors should explain the step, not name the function again.
233
+ Example (TOO SPARSE — only 1 anchor, rest is unlinked prose):
234
+ "[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles JWT parsing. It extracts the token, verifies the signature, and checks expiration. If validation fails, it throws an error."
235
+ This is BAD because "extracts the token", "verifies the signature", "checks expiration", and "throws an error" should ALL be separate line anchors.
217
236
 
218
237
  ### Line Anchor Granularity:
219
238
  - Anchor individual functions, not entire files: [[line:auth.ts#L15-L30]](validateToken) not [[line:auth.ts#L1-L200]](auth module)
@@ -222,14 +241,15 @@ Key principles:
222
241
  - Anchor imports and exports that wire things together: [[line:index.ts#L3-L3]](re-exported from the barrel file)
223
242
  - For multi-part changes, anchor each part separately
224
243
 
225
- GOOD example (uses all 3 anchor types + two-level density):
244
+ GOOD example (all 3 anchor types + high density):
226
245
  "The [[group:Auth Flow]] group introduces session management. [[line:src/auth/session.ts#L15-L50]](The new validateToken function) handles JWT parsing: [[line:src/auth/session.ts#L18-L22]](it extracts the token from the header), then [[line:src/auth/session.ts#L24-L35]](verifies the signature and checks expiration). [[line:src/auth/middleware.ts#L8-L20]](The auth middleware) invokes it on every request, [[line:src/auth/middleware.ts#L15-L18]](rejecting invalid tokens with a 401). Supporting configuration lives in [[file:src/auth/constants.ts]]."
227
246
 
228
247
  BAD examples:
229
- - No group anchors: "The auth changes introduce session management." (MUST use [[group:Auth Flow]])
230
- - No anchors in implementation details: "The validateToken function extracts the token, verifies the signature, and checks expiration." (MUST anchor each step separately)
231
- - One big anchor for everything: "[[line:session.ts#L15-L50]](The function extracts tokens, verifies signatures, and checks expiration)" (MUST split into sub-anchors)
232
- - Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" (MUST have (text) after it)
248
+ - Unanchored prose: "The function extracts the token, verifies the signature, and checks expiration." MUST anchor EACH action
249
+ - No group anchors: "The auth changes introduce session management." MUST use [[group:Auth Flow]]
250
+ - One big anchor: "[[line:session.ts#L15-L50]](The function extracts tokens, verifies signatures, and checks expiration)" MUST split into sub-anchors
251
+ - Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" MUST have (text) after it
252
+ - Low density paragraph: A paragraph with only 1 line anchor and 4+ sentences of plain text → MUST add more anchors
233
253
 
234
254
  ${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and anchor tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}`,
235
255
  user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
@@ -0,0 +1,381 @@
1
+ import type { NewprOutput, SlideImage, SlideDeck, SlidePlan, SlideSpec } from "../types/output.ts";
2
+
3
+ function getSystemLanguage(): string {
4
+ const env = process.env.LANG ?? process.env.LANGUAGE ?? process.env.LC_ALL ?? "";
5
+ if (env.startsWith("ko")) return "Korean";
6
+ if (env.startsWith("ja")) return "Japanese";
7
+ if (env.startsWith("zh")) return "Chinese";
8
+ if (env.startsWith("es")) return "Spanish";
9
+ if (env.startsWith("fr")) return "French";
10
+ if (env.startsWith("de")) return "German";
11
+ return "English";
12
+ }
13
+
14
+ function buildPrContext(data: NewprOutput): string {
15
+ const groupDetails = data.groups.map((g) => `- ${g.name} (${g.type}): ${g.description}\n Files: ${g.files.join(", ")}${g.key_changes ? `\n Key changes: ${g.key_changes.join("; ")}` : ""}${g.risk ? `\n Risk: ${g.risk}` : ""}`).join("\n");
16
+ const fileSummaries = data.files.slice(0, 30).map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`).join("\n");
17
+
18
+ return `PR: #${data.meta.pr_number} "${data.meta.pr_title}"
19
+ Author: ${data.meta.author}
20
+ Repo: ${data.meta.pr_url.split("/pull/")[0]?.split("github.com/")[1] ?? ""}
21
+ Branches: ${data.meta.head_branch} → ${data.meta.base_branch}
22
+ Stats: ${data.meta.total_files_changed} files, +${data.meta.total_additions} -${data.meta.total_deletions}
23
+ Risk: ${data.summary.risk_level}
24
+ State: ${data.meta.pr_state ?? "open"}
25
+
26
+ Purpose: ${data.summary.purpose}
27
+ Scope: ${data.summary.scope}
28
+ Impact: ${data.summary.impact}
29
+
30
+ Change Groups:
31
+ ${groupDetails}
32
+
33
+ File Summaries:
34
+ ${fileSummaries}
35
+
36
+ Narrative:
37
+ ${data.narrative.slice(0, 3000)}`;
38
+ }
39
+
40
+ function buildStylePrompt(data: NewprOutput): { system: string; user: string } {
41
+ return {
42
+ system: `You are a world-class presentation designer specializing in technical presentations for software engineering teams.
43
+
44
+ Your task is to design a comprehensive visual style system for a slide deck about a Pull Request. This style will be used as a prompt for an image generation model (Gemini) to render each slide, so you must be EXTREMELY specific and detailed.
45
+
46
+ Output a single string — the style prompt. It must cover ALL of the following in exhaustive detail:
47
+
48
+ ## Color System
49
+ - Exact hex codes for: primary background, secondary background, text primary, text secondary, accent color, code block background, code text, highlight/emphasis, success (green), warning (yellow), danger (red)
50
+ - Gradient specifications if any (direction, stops)
51
+ - How colors interact (contrast ratios, when to use each)
52
+
53
+ ## Typography
54
+ - Primary font family (suggest a specific well-known font)
55
+ - Title: exact size in px, weight, letter-spacing, color
56
+ - Subtitle: exact size, weight, color
57
+ - Body text: exact size, line-height, color
58
+ - Code/monospace: font family, size, color, background
59
+ - Captions/labels: size, weight, color, text-transform
60
+ - Bullet points: style, indentation, spacing between items
61
+
62
+ ## Layout Grid
63
+ - Slide dimensions: 1920x1080 (16:9)
64
+ - Margins: top, bottom, left, right in px
65
+ - Title position: x, y coordinates and alignment
66
+ - Content area: bounds in px
67
+ - Column layouts: when to use 1-col, 2-col, 3-col, and exact widths
68
+ - Spacing between elements in px
69
+
70
+ ## Visual Elements
71
+ - Code blocks: border-radius, padding, syntax highlighting theme, line numbers yes/no
72
+ - Diagrams/flowcharts: node style, arrow style, colors
73
+ - Icons: style (outline/filled), size, color
74
+ - Dividers/separators: style, color, thickness
75
+ - Cards/boxes: background, border, border-radius, shadow, padding
76
+ - Badges/tags: shape, colors for different types (feature, bugfix, refactor, etc.)
77
+
78
+ ## Slide-Type Templates
79
+ - Title slide: layout, where logo/branding goes, title size, subtitle placement
80
+ - Content slide: title bar height, content area layout
81
+ - Code slide: how to display code with explanations
82
+ - Comparison slide: before/after or side-by-side layout
83
+ - Summary slide: key points layout, call-to-action area
84
+
85
+ ## Mood & Aesthetic
86
+ - Overall feel (e.g., "modern dark engineering dashboard", "clean Apple keynote", "Linear-inspired minimal")
87
+ - What to AVOID (e.g., no clip art, no heavy shadows, no busy backgrounds)
88
+ - Professional tone appropriate for engineering team review
89
+
90
+ The style prompt should be a single continuous text paragraph (not JSON, not markdown) that an image generation AI can follow. Be specific enough that any slide generated with this style will look like it belongs to the same deck.
91
+
92
+ Respond with ONLY the style prompt string. No JSON wrapping, no explanation.`,
93
+ user: buildPrContext(data),
94
+ };
95
+ }
96
+
97
+ function buildSlidesPrompt(data: NewprOutput, stylePrompt: string, language?: string): { system: string; user: string } {
98
+ const lang = language ?? getSystemLanguage();
99
+ return {
100
+ system: `You are a presentation content planner. You will receive a PR analysis and a visual style description. Your job is to plan the CONTENT of each slide.
101
+
102
+ Output a JSON object with:
103
+ - "slides": An array of slide specifications. Each slide has:
104
+ - "index": slide number (0-based)
105
+ - "title": slide title text (in ${lang})
106
+ - "contentPrompt": A VERY detailed description of what should be on this slide — exact text content, layout within the slide, visual elements, code snippets if any. All visible text must be in ${lang}. Be extremely specific about positioning, what goes where, and what text to show.
107
+
108
+ Decide the number of slides based on the PR complexity:
109
+ - Small PR (1-3 groups, <10 files): 4-6 slides
110
+ - Medium PR (3-6 groups, 10-30 files): 6-10 slides
111
+ - Large PR (6+ groups, 30+ files): 8-14 slides
112
+
113
+ Typical slide structure:
114
+ - Slide 0: Title slide with PR name, author, repo, key stats
115
+ - Slide 1: Overview/motivation — why this PR exists
116
+ - Middle slides: One or more slides per major change group, showing key changes with specific code examples
117
+ - Near-end: Architecture/dependency impact if relevant
118
+ - Final slide: Summary with risk assessment and review notes
119
+
120
+ Each contentPrompt should be detailed enough that an image generation model can render the slide given the style guide. Include exact text, numbers, file paths, and code snippets to display.
121
+
122
+ Respond ONLY with the JSON object. No markdown, no explanation.`,
123
+ user: `Visual Style:\n${stylePrompt}\n\n${buildPrContext(data)}`,
124
+ };
125
+ }
126
+
127
+ function buildImagePrompt(stylePrompt: string, slide: SlideSpec): string {
128
+ return `Generate a presentation slide image. 16:9 aspect ratio (1920x1080 pixels).
129
+
130
+ VISUAL STYLE (apply consistently):
131
+ ${stylePrompt}
132
+
133
+ THIS SLIDE (slide ${slide.index + 1}):
134
+ Title: "${slide.title}"
135
+
136
+ CONTENT AND LAYOUT:
137
+ ${slide.contentPrompt}
138
+
139
+ CRITICAL REQUIREMENTS:
140
+ - This is a real presentation slide, not an illustration or diagram
141
+ - All text must be clearly readable
142
+ - Use proper text hierarchy (title, subtitle, body, captions)
143
+ - Maintain consistent margins and alignment
144
+ - The slide should look professional and polished
145
+ - Render all text exactly as specified — do not paraphrase or translate`;
146
+ }
147
+
148
+ async function callOpenRouter(apiKey: string, model: string, system: string, user: string, maxTokens: number, timeoutMs: number): Promise<string> {
149
+ const controller = new AbortController();
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
151
+ try {
152
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
153
+ method: "POST",
154
+ signal: controller.signal,
155
+ headers: {
156
+ Authorization: `Bearer ${apiKey}`,
157
+ "Content-Type": "application/json",
158
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
159
+ "X-Title": "newpr",
160
+ },
161
+ body: JSON.stringify({
162
+ model,
163
+ messages: [
164
+ { role: "system", content: system },
165
+ { role: "user", content: user },
166
+ ],
167
+ temperature: 0.4,
168
+ max_tokens: maxTokens,
169
+ }),
170
+ });
171
+ if (!res.ok) {
172
+ const body = await res.text().catch(() => "");
173
+ throw new Error(`OpenRouter API ${res.status}: ${body.slice(0, 200)}`);
174
+ }
175
+ const json = await res.json() as { choices: Array<{ message: { content: string } }> };
176
+ return json.choices[0]?.message?.content ?? "";
177
+ } catch (err) {
178
+ if ((err as Error).name === "AbortError") throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
179
+ throw err;
180
+ } finally {
181
+ clearTimeout(timer);
182
+ }
183
+ }
184
+
185
+ async function renderSlides(
186
+ apiKey: string,
187
+ plan: SlidePlan,
188
+ specsToRender: SlideSpec[],
189
+ onProgress?: (msg: string, current: number, total: number) => void,
190
+ onSlideComplete?: (completed: SlideImage[]) => void,
191
+ ): Promise<{ completed: SlideImage[]; failed: number[] }> {
192
+ const totalPlan = plan.slides.length;
193
+ const completed: SlideImage[] = [];
194
+ const failed: number[] = [];
195
+
196
+ const conversationHistory: Array<{ role: string; content: string }> = [
197
+ {
198
+ role: "user",
199
+ content: `You are generating a series of ${totalPlan} presentation slides. All slides MUST follow this exact visual style consistently:\n\n${plan.stylePrompt}\n\nI will now ask you to generate each slide one by one. Maintain EXACTLY the same visual style, colors, fonts, and layout principles across every slide. Respond with the slide image for each request.`,
200
+ },
201
+ ];
202
+
203
+ for (const spec of specsToRender) {
204
+ onProgress?.(`Rendering slide ${spec.index + 1}/${totalPlan}: "${spec.title}"`, completed.length, specsToRender.length);
205
+
206
+ const slidePrompt = `Generate slide ${spec.index + 1} of ${totalPlan}. 16:9 aspect ratio (1920x1080).
207
+
208
+ Title: "${spec.title}"
209
+
210
+ CONTENT AND LAYOUT:
211
+ ${spec.contentPrompt}
212
+
213
+ IMPORTANT: Use the EXACT SAME visual style as all previous slides. Same colors, fonts, spacing, background.`;
214
+
215
+ conversationHistory.push({ role: "user", content: slidePrompt });
216
+
217
+ try {
218
+ const controller = new AbortController();
219
+ const timeout = setTimeout(() => controller.abort(), 120_000);
220
+
221
+ try {
222
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
223
+ method: "POST",
224
+ signal: controller.signal,
225
+ headers: {
226
+ Authorization: `Bearer ${apiKey}`,
227
+ "Content-Type": "application/json",
228
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
229
+ "X-Title": "newpr",
230
+ },
231
+ body: JSON.stringify({
232
+ model: "google/gemini-3-pro-image-preview",
233
+ messages: conversationHistory,
234
+ modalities: ["image", "text"],
235
+ }),
236
+ });
237
+
238
+ if (!res.ok) {
239
+ const body = await res.text();
240
+ throw new Error(`Gemini API ${res.status}: ${body.slice(0, 200)}`);
241
+ }
242
+
243
+ const json = await res.json() as {
244
+ choices?: Array<{
245
+ message?: {
246
+ content?: string | Array<{ type?: string; image_url?: { url?: string }; text?: string }>;
247
+ images?: Array<{ image_url?: { url?: string } }>;
248
+ };
249
+ }>;
250
+ };
251
+
252
+ const msg = json.choices?.[0]?.message;
253
+ let imageData: { base64: string; mimeType: string } | null = null;
254
+
255
+ if (msg?.images) {
256
+ for (const img of msg.images) {
257
+ if (img.image_url?.url) {
258
+ const match = img.image_url.url.match(/data:([^;]+);base64,(.+)/s);
259
+ if (match) { imageData = { mimeType: match[1]!, base64: match[2]! }; break; }
260
+ }
261
+ }
262
+ }
263
+
264
+ if (!imageData) {
265
+ const content = msg?.content;
266
+ if (typeof content === "string") {
267
+ const match = content.match(/data:([^;]+);base64,(.+)/s);
268
+ if (match) imageData = { mimeType: match[1]!, base64: match[2]! };
269
+ }
270
+ if (!imageData && Array.isArray(content)) {
271
+ for (const part of content) {
272
+ if (part.type === "image_url" && part.image_url?.url) {
273
+ const match = part.image_url.url.match(/data:([^;]+);base64,(.+)/s);
274
+ if (match) { imageData = { mimeType: match[1]!, base64: match[2]! }; break; }
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ if (!imageData) throw new Error(`No image in response for slide ${spec.index + 1}`);
281
+
282
+ conversationHistory.push({ role: "assistant", content: `[Slide ${spec.index + 1} image generated successfully]` });
283
+
284
+ completed.push({
285
+ index: spec.index,
286
+ imageBase64: imageData.base64,
287
+ mimeType: imageData.mimeType,
288
+ title: spec.title,
289
+ });
290
+ onProgress?.(`Slide ${spec.index + 1}/${totalPlan} done (${completed.length}/${specsToRender.length})`, completed.length, specsToRender.length);
291
+ onSlideComplete?.([...completed]);
292
+ } finally {
293
+ clearTimeout(timeout);
294
+ }
295
+ } catch (err) {
296
+ failed.push(spec.index);
297
+ conversationHistory.push({ role: "assistant", content: `[Slide ${spec.index + 1} failed]` });
298
+ onProgress?.(`Slide ${spec.index + 1}/${totalPlan} failed: ${err instanceof Error ? err.message : String(err)}`, completed.length + failed.length, specsToRender.length);
299
+ }
300
+ }
301
+
302
+ return { completed, failed };
303
+ }
304
+
305
+ export async function generateSlides(
306
+ apiKey: string,
307
+ data: NewprOutput,
308
+ _planModel?: string,
309
+ language?: string,
310
+ onProgress?: (msg: string, current: number, total: number) => void,
311
+ existingDeck?: SlideDeck | null,
312
+ onPlan?: (plan: SlidePlan, imagePrompts: Array<{ index: number; prompt: string }>) => void,
313
+ onSlideComplete?: (partialDeck: SlideDeck) => void,
314
+ ): Promise<SlideDeck> {
315
+ let plan: SlidePlan;
316
+
317
+ if (existingDeck?.plan && existingDeck.plan.slides.length > 0) {
318
+ plan = existingDeck.plan;
319
+ onProgress?.(`Resuming with existing plan (${plan.slides.length} slides)...`, 0, plan.slides.length);
320
+ } else {
321
+ const OPUS_MODEL = "anthropic/claude-opus-4.6";
322
+
323
+ onProgress?.("Step 1/2: Designing visual style (Opus)...", 0, 1);
324
+ const styleP = buildStylePrompt(data);
325
+ const styleContent = await callOpenRouter(apiKey, OPUS_MODEL, styleP.system, styleP.user, 4096, 120_000);
326
+ const stylePrompt = styleContent.trim();
327
+ onProgress?.(`Style designed (${stylePrompt.length} chars)`, 0, 1);
328
+
329
+ onProgress?.("Step 2/2: Planning slide content (Opus)...", 0, 1);
330
+ const slidesP = buildSlidesPrompt(data, stylePrompt, language);
331
+ const slidesContent = await callOpenRouter(apiKey, OPUS_MODEL, slidesP.system, slidesP.user, 8192, 120_000);
332
+
333
+ let rawContent = slidesContent.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
334
+ const jsonStart = rawContent.indexOf("{");
335
+ const jsonEnd = rawContent.lastIndexOf("}");
336
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
337
+ rawContent = rawContent.slice(jsonStart, jsonEnd + 1);
338
+ }
339
+ const parsed = JSON.parse(rawContent) as { slides: SlideSpec[] };
340
+ if (!parsed.slides || parsed.slides.length === 0) throw new Error("Empty slide plan");
341
+
342
+ plan = { stylePrompt, slides: parsed.slides };
343
+ onProgress?.(`Plan ready: ${plan.slides.length} slides`, 0, plan.slides.length);
344
+ }
345
+
346
+ const imagePrompts = plan.slides.map((spec) => ({
347
+ index: spec.index,
348
+ prompt: buildImagePrompt(plan.stylePrompt, spec),
349
+ }));
350
+ onPlan?.(plan, imagePrompts);
351
+
352
+ const existingSlides = existingDeck?.slides ?? [];
353
+ const existingIndices = new Set(existingSlides.map((s) => s.index));
354
+ const specsToRender = existingDeck?.failedIndices && existingDeck.failedIndices.length > 0
355
+ ? plan.slides.filter((s) => existingDeck.failedIndices!.includes(s.index))
356
+ : plan.slides.filter((s) => !existingIndices.has(s.index));
357
+
358
+ if (specsToRender.length === 0 && existingSlides.length > 0) {
359
+ onProgress?.("All slides already generated", existingSlides.length, existingSlides.length);
360
+ return { slides: existingSlides.sort((a, b) => a.index - b.index), plan, generatedAt: existingDeck?.generatedAt ?? new Date().toISOString() };
361
+ }
362
+
363
+ onProgress?.(`Generating ${specsToRender.length} slide${specsToRender.length > 1 ? "s" : ""}...`, 0, specsToRender.length);
364
+ const { completed, failed } = await renderSlides(apiKey, plan, specsToRender, onProgress, (partialCompleted) => {
365
+ const allSlides = [...existingSlides.filter((s) => !partialCompleted.some((c) => c.index === s.index)), ...partialCompleted].sort((a, b) => a.index - b.index);
366
+ const remaining = specsToRender.filter((s) => !partialCompleted.some((c) => c.index === s.index)).map((s) => s.index);
367
+ onSlideComplete?.({ slides: allSlides, plan, failedIndices: remaining.length > 0 ? remaining : undefined, generatedAt: new Date().toISOString() });
368
+ });
369
+
370
+ const allSlides = [...existingSlides.filter((s) => !completed.some((c) => c.index === s.index)), ...completed]
371
+ .sort((a, b) => a.index - b.index);
372
+
373
+ if (allSlides.length === 0) throw new Error("All slide generations failed");
374
+
375
+ return {
376
+ slides: allSlides,
377
+ plan,
378
+ failedIndices: failed.length > 0 ? failed : undefined,
379
+ generatedAt: new Date().toISOString(),
380
+ };
381
+ }
@@ -0,0 +1,34 @@
1
+ import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
2
+ import type { CartoonImage } from "../types/output.ts";
3
+ import { generateCartoon } from "../llm/cartoon.ts";
4
+ import { saveCartoonSidecar, loadCartoonSidecar } from "../history/store.ts";
5
+
6
+ export const cartoonPlugin: GeneratorPlugin = {
7
+ id: "cartoon",
8
+ name: "Comic Strip",
9
+ description: "Generate a 4-panel comic strip that visualizes the key changes in this PR.",
10
+ icon: "Sparkles",
11
+ tabLabel: "Comic",
12
+
13
+ isAvailable: (ctx) => !!ctx.apiKey,
14
+
15
+ async generate(ctx: PluginContext, onProgress?: PluginProgressCallback): Promise<PluginResult> {
16
+ onProgress?.({ message: "Generating comic strip...", current: 0, total: 1 });
17
+ const result = await generateCartoon(ctx.apiKey, ctx.data, ctx.language);
18
+ const cartoon: CartoonImage = {
19
+ imageBase64: result.imageBase64,
20
+ mimeType: result.mimeType,
21
+ generatedAt: new Date().toISOString(),
22
+ };
23
+ onProgress?.({ message: "Comic strip done", current: 1, total: 1 });
24
+ return { type: "cartoon", data: cartoon };
25
+ },
26
+
27
+ async save(sessionId: string, data: unknown): Promise<void> {
28
+ await saveCartoonSidecar(sessionId, data as CartoonImage);
29
+ },
30
+
31
+ async load(sessionId: string): Promise<CartoonImage | null> {
32
+ return loadCartoonSidecar(sessionId);
33
+ },
34
+ };
@@ -0,0 +1,20 @@
1
+ import type { GeneratorPlugin } from "./types.ts";
2
+ import { cartoonPlugin } from "./cartoon.ts";
3
+ import { slidesPlugin } from "./slides.ts";
4
+
5
+ const plugins: GeneratorPlugin[] = [
6
+ slidesPlugin,
7
+ cartoonPlugin,
8
+ ];
9
+
10
+ export function getPlugin(id: string): GeneratorPlugin | undefined {
11
+ return plugins.find((p) => p.id === id);
12
+ }
13
+
14
+ export function getAllPlugins(): GeneratorPlugin[] {
15
+ return plugins;
16
+ }
17
+
18
+ export function getPluginIds(): string[] {
19
+ return plugins.map((p) => p.id);
20
+ }