newpr 0.4.0 → 0.5.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.
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/slides.ts +381 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +5 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DiffViewer.tsx +187 -3
- package/src/web/client/components/ResultsScreen.tsx +32 -2
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +185 -2
- package/src/web/server.ts +15 -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.0",
|
|
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
|
@@ -298,6 +298,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
298
298
|
pr_body: prData.body || undefined,
|
|
299
299
|
pr_url: prData.url,
|
|
300
300
|
pr_state: prData.state,
|
|
301
|
+
pr_updated_at: prData.updated_at,
|
|
301
302
|
base_branch: prData.base_branch,
|
|
302
303
|
head_branch: prData.head_branch,
|
|
303
304
|
author: prData.author,
|
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
|
}
|
|
@@ -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
|
+
}
|
package/src/types/github.ts
CHANGED
package/src/types/output.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface PrMeta {
|
|
|
19
19
|
pr_body?: string;
|
|
20
20
|
pr_url: string;
|
|
21
21
|
pr_state?: PrStateLabel;
|
|
22
|
+
pr_updated_at?: string;
|
|
22
23
|
base_branch: string;
|
|
23
24
|
head_branch: string;
|
|
24
25
|
author: string;
|
|
@@ -106,6 +107,31 @@ export interface ChatMessage {
|
|
|
106
107
|
timestamp: string;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
export interface SlideImage {
|
|
111
|
+
index: number;
|
|
112
|
+
imageBase64: string;
|
|
113
|
+
mimeType: string;
|
|
114
|
+
title: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SlideSpec {
|
|
118
|
+
index: number;
|
|
119
|
+
title: string;
|
|
120
|
+
contentPrompt: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SlidePlan {
|
|
124
|
+
stylePrompt: string;
|
|
125
|
+
slides: SlideSpec[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SlideDeck {
|
|
129
|
+
slides: SlideImage[];
|
|
130
|
+
plan?: SlidePlan;
|
|
131
|
+
failedIndices?: number[];
|
|
132
|
+
generatedAt: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
109
135
|
export interface NewprOutput {
|
|
110
136
|
meta: PrMeta;
|
|
111
137
|
summary: PrSummary;
|
package/src/web/client/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
|
13
13
|
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
14
14
|
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
15
15
|
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
16
|
+
import { requestNotificationPermission } from "./lib/notify.ts";
|
|
16
17
|
|
|
17
18
|
function getUrlParam(key: string): string | null {
|
|
18
19
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -38,6 +39,8 @@ export function App() {
|
|
|
38
39
|
const features = useFeatures();
|
|
39
40
|
const bgAnalyses = useBackgroundAnalyses();
|
|
40
41
|
const initialLoadDone = useRef(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => { requestNotificationPermission(); }, []);
|
|
41
44
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
42
45
|
|
|
43
46
|
useEffect(() => {
|
|
@@ -145,7 +148,7 @@ export function App() {
|
|
|
145
148
|
}, [analysis.result]);
|
|
146
149
|
|
|
147
150
|
return (
|
|
148
|
-
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
151
|
+
<ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
|
|
149
152
|
<AppShell
|
|
150
153
|
theme={themeCtx.theme}
|
|
151
154
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -184,6 +187,7 @@ export function App() {
|
|
|
184
187
|
cartoonEnabled={features.cartoon}
|
|
185
188
|
sessionId={diffSessionId}
|
|
186
189
|
onTabChange={setActiveTab}
|
|
190
|
+
onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
|
|
187
191
|
/>
|
|
188
192
|
)}
|
|
189
193
|
{analysis.phase === "error" && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
2
2
|
import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
|
|
3
3
|
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
4
4
|
import { Markdown } from "./Markdown.tsx";
|
|
@@ -17,14 +17,15 @@ export interface ChatState {
|
|
|
17
17
|
interface ChatContextValue {
|
|
18
18
|
state: ChatState;
|
|
19
19
|
anchorItems?: AnchorItem[];
|
|
20
|
+
analyzedAt?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
23
24
|
|
|
24
25
|
export { useChatStore as useChatState };
|
|
25
26
|
|
|
26
|
-
export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
|
|
27
|
-
const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
|
|
27
|
+
export function ChatProvider({ state, anchorItems, analyzedAt, children }: { state: ChatState; anchorItems?: AnchorItem[]; analyzedAt?: string; children: React.ReactNode }) {
|
|
28
|
+
const value = useMemo(() => ({ state, anchorItems, analyzedAt }), [state, anchorItems, analyzedAt]);
|
|
28
29
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -71,6 +72,33 @@ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
|
|
|
71
72
|
return segs;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
|
|
76
|
+
content: string;
|
|
77
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
78
|
+
activeId?: string | null;
|
|
79
|
+
}) {
|
|
80
|
+
const [rendered, setRendered] = useState(content);
|
|
81
|
+
const pendingRef = useRef(content);
|
|
82
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
pendingRef.current = content;
|
|
86
|
+
if (!timerRef.current) {
|
|
87
|
+
timerRef.current = setTimeout(() => {
|
|
88
|
+
setRendered(pendingRef.current);
|
|
89
|
+
timerRef.current = null;
|
|
90
|
+
}, 150);
|
|
91
|
+
}
|
|
92
|
+
return () => {};
|
|
93
|
+
}, [content]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
|
|
75
103
|
segments: ChatSegment[];
|
|
76
104
|
activeToolName?: string;
|
|
@@ -86,11 +114,16 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
|
|
|
86
114
|
if (seg.type === "tool_call") {
|
|
87
115
|
return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
|
|
88
116
|
}
|
|
89
|
-
|
|
117
|
+
if (!seg.content) return null;
|
|
118
|
+
return (
|
|
90
119
|
<div key={`text-${i}`} className="text-xs leading-relaxed">
|
|
91
|
-
|
|
120
|
+
{isStreaming ? (
|
|
121
|
+
<ThrottledMarkdown content={seg.content} onAnchorClick={onAnchorClick} activeId={activeId} />
|
|
122
|
+
) : (
|
|
123
|
+
<Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
|
|
124
|
+
)}
|
|
92
125
|
</div>
|
|
93
|
-
)
|
|
126
|
+
);
|
|
94
127
|
})}
|
|
95
128
|
{activeToolName && (
|
|
96
129
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
|
|
@@ -149,6 +182,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
149
182
|
|
|
150
183
|
if (!ctx) return null;
|
|
151
184
|
const { messages, streaming, loaded, loading } = ctx.state;
|
|
185
|
+
const { analyzedAt } = ctx;
|
|
152
186
|
const hasMessages = messages.length > 0 || loading;
|
|
153
187
|
|
|
154
188
|
if (!hasMessages && loaded) {
|
|
@@ -162,26 +196,51 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
162
196
|
|
|
163
197
|
if (!hasMessages) return null;
|
|
164
198
|
|
|
199
|
+
let shownOutdatedDivider = false;
|
|
200
|
+
|
|
165
201
|
return (
|
|
166
202
|
<div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
|
|
167
203
|
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
|
|
168
204
|
{messages.map((msg, i) => {
|
|
205
|
+
const isFromPreviousAnalysis = analyzedAt && msg.timestamp && msg.timestamp < analyzedAt;
|
|
206
|
+
let divider = null;
|
|
207
|
+
if (isFromPreviousAnalysis && !shownOutdatedDivider) {
|
|
208
|
+
shownOutdatedDivider = true;
|
|
209
|
+
divider = (
|
|
210
|
+
<div className="flex items-center gap-2 py-1">
|
|
211
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
212
|
+
<span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">Previous analysis</span>
|
|
213
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
169
217
|
if (msg.role === "user") {
|
|
170
218
|
return (
|
|
171
|
-
<div key={`user-${i}`}
|
|
172
|
-
|
|
173
|
-
|
|
219
|
+
<div key={`user-${i}`}>
|
|
220
|
+
{divider}
|
|
221
|
+
<div className="flex justify-end">
|
|
222
|
+
<div className={`max-w-[80%] rounded-xl rounded-br-sm px-3.5 py-2 text-[11px] leading-relaxed ${
|
|
223
|
+
isFromPreviousAnalysis
|
|
224
|
+
? "bg-foreground/60 text-background"
|
|
225
|
+
: "bg-foreground text-background"
|
|
226
|
+
}`}>
|
|
227
|
+
{msg.content}
|
|
228
|
+
</div>
|
|
174
229
|
</div>
|
|
175
230
|
</div>
|
|
176
231
|
);
|
|
177
232
|
}
|
|
178
233
|
return (
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
234
|
+
<div key={`assistant-${i}`}>
|
|
235
|
+
{divider}
|
|
236
|
+
<div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
|
|
237
|
+
<AssistantMessage
|
|
238
|
+
segments={segmentsFromMessage(msg)}
|
|
239
|
+
onAnchorClick={onAnchorClick}
|
|
240
|
+
activeId={activeId}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
185
244
|
);
|
|
186
245
|
})}
|
|
187
246
|
|