newpr 0.3.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/README.md +135 -103
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +1 -4
- package/src/cli/args.ts +1 -1
- package/src/cli/index.ts +2 -1
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +82 -27
- package/src/llm/slides.ts +381 -0
- package/src/types/config.ts +1 -1
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/version.ts +23 -0
- package/src/web/client/App.tsx +51 -1
- package/src/web/client/components/AppShell.tsx +173 -45
- package/src/web/client/components/ChatSection.tsx +76 -185
- package/src/web/client/components/DetailPane.tsx +1 -0
- package/src/web/client/components/DiffViewer.tsx +200 -4
- package/src/web/client/components/InputScreen.tsx +3 -0
- package/src/web/client/components/Markdown.tsx +66 -16
- package/src/web/client/components/ResultsScreen.tsx +32 -2
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
- package/src/web/client/hooks/useChatStore.ts +247 -0
- package/src/web/client/hooks/useFeatures.ts +2 -1
- 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/index.html +1 -0
- package/src/web/server/routes.ts +226 -4
- package/src/web/server/session-manager.ts +34 -0
- package/src/web/server.ts +20 -1
- package/src/web/styles/built.css +1 -1
- package/src/workspace/explore.ts +39 -6
- package/src/workspace/types.ts +1 -0
|
@@ -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/config.ts
CHANGED
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/version.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolve, dirname } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
let cached: string | null = null;
|
|
5
|
+
|
|
6
|
+
export function getVersion(): string {
|
|
7
|
+
if (cached) return cached;
|
|
8
|
+
try {
|
|
9
|
+
let dir = dirname(new URL(import.meta.url).pathname);
|
|
10
|
+
for (let i = 0; i < 5; i++) {
|
|
11
|
+
try {
|
|
12
|
+
const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8")) as { version?: string };
|
|
13
|
+
if (pkg.version) {
|
|
14
|
+
cached = pkg.version;
|
|
15
|
+
return cached;
|
|
16
|
+
}
|
|
17
|
+
} catch {}
|
|
18
|
+
dir = dirname(dir);
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
cached = "0.0.0";
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
package/src/web/client/App.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useTheme } from "./hooks/useTheme.ts";
|
|
|
4
4
|
import { useSessions } from "./hooks/useSessions.ts";
|
|
5
5
|
import { useGithubUser } from "./hooks/useGithubUser.ts";
|
|
6
6
|
import { useFeatures } from "./hooks/useFeatures.ts";
|
|
7
|
+
import { useBackgroundAnalyses } from "./hooks/useBackgroundAnalyses.ts";
|
|
7
8
|
import { AppShell } from "./components/AppShell.tsx";
|
|
8
9
|
import { InputScreen } from "./components/InputScreen.tsx";
|
|
9
10
|
import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
|
|
@@ -12,6 +13,7 @@ import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
|
12
13
|
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
13
14
|
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
14
15
|
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
16
|
+
import { requestNotificationPermission } from "./lib/notify.ts";
|
|
15
17
|
|
|
16
18
|
function getUrlParam(key: string): string | null {
|
|
17
19
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -35,7 +37,10 @@ export function App() {
|
|
|
35
37
|
const { sessions, refresh: refreshSessions } = useSessions();
|
|
36
38
|
const githubUser = useGithubUser();
|
|
37
39
|
const features = useFeatures();
|
|
40
|
+
const bgAnalyses = useBackgroundAnalyses();
|
|
38
41
|
const initialLoadDone = useRef(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => { requestNotificationPermission(); }, []);
|
|
39
44
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
40
45
|
|
|
41
46
|
useEffect(() => {
|
|
@@ -59,9 +64,24 @@ export function App() {
|
|
|
59
64
|
}
|
|
60
65
|
}, [analysis.phase, analysis.sessionId]);
|
|
61
66
|
|
|
67
|
+
const bgDoneCount = bgAnalyses.analyses.filter((a) => a.status === "done").length;
|
|
68
|
+
const prevBgDoneCount = useRef(bgDoneCount);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (bgDoneCount > prevBgDoneCount.current) refreshSessions();
|
|
71
|
+
prevBgDoneCount.current = bgDoneCount;
|
|
72
|
+
}, [bgDoneCount, refreshSessions]);
|
|
73
|
+
|
|
74
|
+
const scrollGuardRef = useRef<number | null>(null);
|
|
62
75
|
const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
|
|
63
76
|
const key = `${kind}:${id}`;
|
|
77
|
+
const main = document.querySelector("main");
|
|
78
|
+
const savedScroll = main?.scrollTop ?? 0;
|
|
79
|
+
scrollGuardRef.current = savedScroll;
|
|
64
80
|
setActiveId((prev) => prev === key ? null : key);
|
|
81
|
+
const restore = () => { if (main && scrollGuardRef.current !== null) main.scrollTop = scrollGuardRef.current; };
|
|
82
|
+
requestAnimationFrame(restore);
|
|
83
|
+
setTimeout(restore, 50);
|
|
84
|
+
setTimeout(() => { scrollGuardRef.current = null; }, 200);
|
|
65
85
|
}, []);
|
|
66
86
|
|
|
67
87
|
const detailTarget = useMemo(() => {
|
|
@@ -71,17 +91,41 @@ export function App() {
|
|
|
71
91
|
return resolveDetail(kind as "group" | "file" | "line", id, analysis.result.groups, analysis.result.files);
|
|
72
92
|
}, [activeId, analysis.result]);
|
|
73
93
|
|
|
94
|
+
const moveToBackground = useCallback(() => {
|
|
95
|
+
if (analysis.phase === "loading" && analysis.sessionId && analysis.lastPrInput) {
|
|
96
|
+
bgAnalyses.track(analysis.sessionId, analysis.lastPrInput);
|
|
97
|
+
}
|
|
98
|
+
}, [analysis.phase, analysis.sessionId, analysis.lastPrInput, bgAnalyses]);
|
|
99
|
+
|
|
74
100
|
function handleSessionSelect(id: string) {
|
|
101
|
+
moveToBackground();
|
|
75
102
|
setActiveId(null);
|
|
76
103
|
analysis.loadStoredSession(id);
|
|
77
104
|
setUrlParams({ session: id, tab: null });
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
function handleNewAnalysis() {
|
|
108
|
+
moveToBackground();
|
|
81
109
|
setActiveId(null);
|
|
82
110
|
analysis.reset();
|
|
83
111
|
}
|
|
84
112
|
|
|
113
|
+
const handleBgClick = useCallback((sessionId: string) => {
|
|
114
|
+
const bg = bgAnalyses.analyses.find((a) => a.sessionId === sessionId);
|
|
115
|
+
if (!bg) return;
|
|
116
|
+
if (bg.status === "done" && bg.historyId) {
|
|
117
|
+
bgAnalyses.dismiss(sessionId);
|
|
118
|
+
setActiveId(null);
|
|
119
|
+
analysis.loadStoredSession(bg.historyId);
|
|
120
|
+
setUrlParams({ session: bg.historyId, tab: null });
|
|
121
|
+
refreshSessions();
|
|
122
|
+
} else if (bg.status === "done" && bg.result) {
|
|
123
|
+
bgAnalyses.dismiss(sessionId);
|
|
124
|
+
} else if (bg.status === "error") {
|
|
125
|
+
bgAnalyses.dismiss(sessionId);
|
|
126
|
+
}
|
|
127
|
+
}, [bgAnalyses, analysis, refreshSessions]);
|
|
128
|
+
|
|
85
129
|
const diffSessionId = analysis.historyId ?? analysis.sessionId;
|
|
86
130
|
const prUrl = analysis.result?.meta.pr_url;
|
|
87
131
|
const detailPanel = detailTarget ? (
|
|
@@ -104,7 +148,7 @@ export function App() {
|
|
|
104
148
|
}, [analysis.result]);
|
|
105
149
|
|
|
106
150
|
return (
|
|
107
|
-
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
151
|
+
<ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
|
|
108
152
|
<AppShell
|
|
109
153
|
theme={themeCtx.theme}
|
|
110
154
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -115,12 +159,17 @@ export function App() {
|
|
|
115
159
|
detailPanel={detailPanel}
|
|
116
160
|
bottomBar={analysis.phase === "done" && activeTab === "story" ? <ChatInput /> : undefined}
|
|
117
161
|
activeSessionId={diffSessionId}
|
|
162
|
+
version={features.version}
|
|
163
|
+
bgAnalyses={bgAnalyses.analyses}
|
|
164
|
+
onBgClick={handleBgClick}
|
|
165
|
+
onBgDismiss={bgAnalyses.dismiss}
|
|
118
166
|
>
|
|
119
167
|
{analysis.phase === "idle" && (
|
|
120
168
|
<InputScreen
|
|
121
169
|
onSubmit={(pr) => analysis.start(pr)}
|
|
122
170
|
sessions={sessions}
|
|
123
171
|
onSessionSelect={handleSessionSelect}
|
|
172
|
+
version={features.version}
|
|
124
173
|
/>
|
|
125
174
|
)}
|
|
126
175
|
{analysis.phase === "loading" && (
|
|
@@ -138,6 +187,7 @@ export function App() {
|
|
|
138
187
|
cartoonEnabled={features.cartoon}
|
|
139
188
|
sessionId={diffSessionId}
|
|
140
189
|
onTabChange={setActiveTab}
|
|
190
|
+
onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
|
|
141
191
|
/>
|
|
142
192
|
)}
|
|
143
193
|
{analysis.phase === "error" && (
|