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 +2 -2
- package/src/analyzer/pipeline.ts +42 -1
- package/src/config/store.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +37 -17
- package/src/llm/slides.ts +381 -0
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +7 -1
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +241 -37
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +37 -3
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +407 -5
- package/src/web/server.ts +30 -0
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newpr",
|
|
3
|
-
"version": "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",
|
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -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 ?
|
|
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,
|
package/src/config/store.ts
CHANGED
package/src/github/fetch-pr.ts
CHANGED
|
@@ -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",
|
package/src/history/store.ts
CHANGED
|
@@ -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
|
}
|
package/src/llm/prompts.ts
CHANGED
|
@@ -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
|
|
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
|
-
###
|
|
205
|
-
When describing a function or class, use anchors at TWO granularity levels:
|
|
208
|
+
### ANCHOR DENSITY — THIS IS THE MOST IMPORTANT RULE
|
|
206
209
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
-
|
|
215
|
-
|
|
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 (
|
|
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
|
-
-
|
|
230
|
-
- No anchors
|
|
231
|
-
- One big anchor
|
|
232
|
-
- Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]"
|
|
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
|
+
}
|