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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { Loader2, Presentation, RefreshCw, Download, AlertCircle, ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import type { NewprOutput, SlideDeck } from "../../../types/output.ts";
|
|
4
|
+
import { sendNotification } from "../lib/notify.ts";
|
|
5
|
+
|
|
6
|
+
export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
|
|
7
|
+
const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
|
|
8
|
+
const [deck, setDeck] = useState<SlideDeck | null>(null);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [progress, setProgress] = useState("");
|
|
11
|
+
const [progressDetail, setProgressDetail] = useState<{ current: number; total: number } | null>(null);
|
|
12
|
+
const [currentSlide, setCurrentSlide] = useState(0);
|
|
13
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
14
|
+
|
|
15
|
+
const stopPolling = useCallback(() => {
|
|
16
|
+
if (pollRef.current) {
|
|
17
|
+
clearInterval(pollRef.current);
|
|
18
|
+
pollRef.current = null;
|
|
19
|
+
}
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!sessionId) return;
|
|
24
|
+
fetch(`/api/sessions/${sessionId}/slides`)
|
|
25
|
+
.then((r) => r.json())
|
|
26
|
+
.then((d) => {
|
|
27
|
+
if (d?.slides?.length > 0) {
|
|
28
|
+
setDeck(d as SlideDeck);
|
|
29
|
+
setState("done");
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {});
|
|
33
|
+
return stopPolling;
|
|
34
|
+
}, [sessionId, stopPolling]);
|
|
35
|
+
|
|
36
|
+
const startPolling = useCallback(() => {
|
|
37
|
+
if (!sessionId || pollRef.current) return;
|
|
38
|
+
pollRef.current = setInterval(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const [statusRes, slidesRes] = await Promise.all([
|
|
41
|
+
fetch(`/api/slides/status?sessionId=${sessionId}`),
|
|
42
|
+
fetch(`/api/sessions/${sessionId}/slides`),
|
|
43
|
+
]);
|
|
44
|
+
const job = await statusRes.json() as { status: string; message?: string; current?: number; total?: number };
|
|
45
|
+
if (job.message) setProgress(job.message);
|
|
46
|
+
if (job.total && job.total > 0) setProgressDetail({ current: job.current ?? 0, total: job.total });
|
|
47
|
+
|
|
48
|
+
const partial = slidesRes.ok ? await slidesRes.json() as SlideDeck | null : null;
|
|
49
|
+
if (partial?.slides?.length) setDeck(partial);
|
|
50
|
+
|
|
51
|
+
if (job.status === "done") {
|
|
52
|
+
stopPolling();
|
|
53
|
+
if (partial?.slides?.length) {
|
|
54
|
+
setDeck(partial);
|
|
55
|
+
setState("done");
|
|
56
|
+
sendNotification("Slides ready", `${partial.slides.length} slides generated`);
|
|
57
|
+
}
|
|
58
|
+
} else if (job.status === "error") {
|
|
59
|
+
stopPolling();
|
|
60
|
+
setError(job.message ?? "Generation failed");
|
|
61
|
+
if (partial?.slides?.length) {
|
|
62
|
+
setDeck(partial);
|
|
63
|
+
setState("done");
|
|
64
|
+
} else {
|
|
65
|
+
setState("error");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
}, 1000);
|
|
70
|
+
}, [sessionId, stopPolling]);
|
|
71
|
+
|
|
72
|
+
const generate = useCallback(async (resume = false) => {
|
|
73
|
+
if (!sessionId) return;
|
|
74
|
+
setState("loading");
|
|
75
|
+
setError(null);
|
|
76
|
+
setProgress(resume ? "Resuming failed slides..." : "Starting...");
|
|
77
|
+
setProgressDetail(null);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch("/api/slides", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ sessionId, resume }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.json() as { error?: string };
|
|
87
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
startPolling();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
92
|
+
setState("error");
|
|
93
|
+
}
|
|
94
|
+
}, [sessionId, startPolling]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!sessionId || state !== "idle") return;
|
|
98
|
+
fetch(`/api/slides/status?sessionId=${sessionId}`)
|
|
99
|
+
.then((r) => r.json())
|
|
100
|
+
.then((job) => {
|
|
101
|
+
if ((job as { status: string }).status === "running") {
|
|
102
|
+
setState("loading");
|
|
103
|
+
setProgress((job as { message?: string }).message ?? "");
|
|
104
|
+
startPolling();
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
}, [sessionId, state, startPolling]);
|
|
109
|
+
|
|
110
|
+
const downloadAll = useCallback(() => {
|
|
111
|
+
if (!deck) return;
|
|
112
|
+
for (const slide of deck.slides) {
|
|
113
|
+
const a = document.createElement("a");
|
|
114
|
+
a.href = `data:${slide.mimeType};base64,${slide.imageBase64}`;
|
|
115
|
+
a.download = `newpr-slide-${slide.index + 1}-${data.meta.pr_number}.png`;
|
|
116
|
+
a.click();
|
|
117
|
+
}
|
|
118
|
+
}, [deck, data.meta.pr_number]);
|
|
119
|
+
|
|
120
|
+
if (state === "idle") {
|
|
121
|
+
return (
|
|
122
|
+
<div className="pt-8 flex flex-col items-center">
|
|
123
|
+
<div className="w-full max-w-sm space-y-6">
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<h3 className="text-xs font-medium">Slide Deck</h3>
|
|
126
|
+
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
|
127
|
+
Generate a presentation that explains this PR to your team. The number of slides is automatically determined based on PR complexity.
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => generate()}
|
|
133
|
+
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-foreground text-background text-xs font-medium hover:opacity-90 transition-opacity"
|
|
134
|
+
>
|
|
135
|
+
<Presentation className="h-3.5 w-3.5" />
|
|
136
|
+
Generate Slides
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (state === "loading") {
|
|
144
|
+
const pct = progressDetail && progressDetail.total > 0 ? Math.round((progressDetail.current / progressDetail.total) * 100) : 0;
|
|
145
|
+
const partialSlides = deck?.slides ?? [];
|
|
146
|
+
return (
|
|
147
|
+
<div className="pt-5 space-y-4">
|
|
148
|
+
<div className="flex items-center gap-3">
|
|
149
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40 shrink-0" />
|
|
150
|
+
<p className="text-xs text-muted-foreground/60 line-clamp-1 flex-1">{progress}</p>
|
|
151
|
+
{progressDetail && progressDetail.total > 0 && (
|
|
152
|
+
<span className="text-[10px] text-muted-foreground/30 tabular-nums shrink-0">{progressDetail.current}/{progressDetail.total}</span>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
{progressDetail && progressDetail.total > 0 && (
|
|
156
|
+
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
|
157
|
+
<div className="h-full bg-foreground/40 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{partialSlides.length > 0 && (
|
|
161
|
+
<div className="space-y-3">
|
|
162
|
+
<div className="rounded-lg border overflow-hidden bg-black">
|
|
163
|
+
<img
|
|
164
|
+
src={`data:${partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.mimeType};base64,${partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.imageBase64}`}
|
|
165
|
+
alt={partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.title}
|
|
166
|
+
className="w-full aspect-video object-contain"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
170
|
+
{partialSlides.map((s, i) => (
|
|
171
|
+
<button
|
|
172
|
+
key={s.index}
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => setCurrentSlide(i)}
|
|
175
|
+
className={`shrink-0 rounded-md overflow-hidden border-2 transition-colors ${
|
|
176
|
+
i === (currentSlide >= partialSlides.length ? 0 : currentSlide) ? "border-foreground" : "border-transparent hover:border-border"
|
|
177
|
+
}`}
|
|
178
|
+
>
|
|
179
|
+
<img src={`data:${s.mimeType};base64,${s.imageBase64}`} alt={s.title} className="h-12 aspect-video object-cover" />
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
{partialSlides.length === 0 && !progressDetail && (
|
|
186
|
+
<p className="text-[10px] text-muted-foreground/30 text-center">This may take a few minutes</p>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (state === "error") {
|
|
193
|
+
return (
|
|
194
|
+
<div className="pt-8 flex flex-col items-center">
|
|
195
|
+
<div className="w-full max-w-sm space-y-4">
|
|
196
|
+
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 flex items-start gap-2.5">
|
|
197
|
+
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
|
198
|
+
<div className="space-y-1 min-w-0">
|
|
199
|
+
<p className="text-xs text-destructive font-medium">Generation failed</p>
|
|
200
|
+
<p className="text-[11px] text-destructive/70 break-words">{error}</p>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={() => generate(true)}
|
|
206
|
+
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg border text-xs text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
|
207
|
+
>
|
|
208
|
+
<RefreshCw className="h-3 w-3" />
|
|
209
|
+
Try again
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!deck || deck.slides.length === 0) return null;
|
|
217
|
+
const slide = deck.slides[currentSlide];
|
|
218
|
+
if (!slide) return null;
|
|
219
|
+
const total = deck.slides.length;
|
|
220
|
+
const hasFailed = deck.failedIndices && deck.failedIndices.length > 0;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="pt-5 space-y-3">
|
|
224
|
+
{hasFailed && (
|
|
225
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
|
|
226
|
+
<AlertCircle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
|
227
|
+
<span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
|
|
228
|
+
{deck.failedIndices!.length} slide{deck.failedIndices!.length > 1 ? "s" : ""} failed to generate
|
|
229
|
+
</span>
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={() => generate(true)}
|
|
233
|
+
className="flex items-center gap-1 text-[11px] font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 shrink-0 transition-colors"
|
|
234
|
+
>
|
|
235
|
+
<RefreshCw className="h-3 w-3" />
|
|
236
|
+
Retry failed
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
<div className="rounded-lg border overflow-hidden bg-black">
|
|
242
|
+
<img
|
|
243
|
+
src={`data:${slide.mimeType};base64,${slide.imageBase64}`}
|
|
244
|
+
alt={slide.title}
|
|
245
|
+
className="w-full aspect-video object-contain"
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="flex items-center justify-between">
|
|
250
|
+
<div className="flex items-center gap-2">
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => setCurrentSlide((i) => Math.max(0, i - 1))}
|
|
254
|
+
disabled={currentSlide === 0}
|
|
255
|
+
className="flex h-7 w-7 items-center justify-center rounded-md border text-muted-foreground/60 hover:text-foreground hover:border-foreground/20 disabled:opacity-20 transition-colors"
|
|
256
|
+
>
|
|
257
|
+
<ChevronLeft className="h-3.5 w-3.5" />
|
|
258
|
+
</button>
|
|
259
|
+
<span className="text-[11px] text-muted-foreground/50 tabular-nums min-w-[60px] text-center">
|
|
260
|
+
{currentSlide + 1} / {total}
|
|
261
|
+
</span>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={() => setCurrentSlide((i) => Math.min(total - 1, i + 1))}
|
|
265
|
+
disabled={currentSlide === total - 1}
|
|
266
|
+
className="flex h-7 w-7 items-center justify-center rounded-md border text-muted-foreground/60 hover:text-foreground hover:border-foreground/20 disabled:opacity-20 transition-colors"
|
|
267
|
+
>
|
|
268
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="text-[11px] text-muted-foreground/40 truncate max-w-[50%]">
|
|
273
|
+
{slide.title}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className="flex items-center gap-1.5">
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={downloadAll}
|
|
280
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
281
|
+
>
|
|
282
|
+
<Download className="h-3 w-3" />
|
|
283
|
+
Download
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={() => generate()}
|
|
288
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
289
|
+
>
|
|
290
|
+
<RefreshCw className="h-3 w-3" />
|
|
291
|
+
Regenerate
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
297
|
+
{deck.slides.map((s, i) => (
|
|
298
|
+
<button
|
|
299
|
+
key={s.index}
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={() => setCurrentSlide(i)}
|
|
302
|
+
className={`shrink-0 rounded-md overflow-hidden border-2 transition-colors ${
|
|
303
|
+
i === currentSlide ? "border-foreground" : "border-transparent hover:border-border"
|
|
304
|
+
}`}
|
|
305
|
+
>
|
|
306
|
+
<img
|
|
307
|
+
src={`data:${s.mimeType};base64,${s.imageBase64}`}
|
|
308
|
+
alt={s.title}
|
|
309
|
+
className="h-12 aspect-video object-cover"
|
|
310
|
+
/>
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
package/src/web/server/routes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { NewprConfig } from "../../types/config.ts";
|
|
2
2
|
import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
|
|
3
3
|
import { DEFAULT_CONFIG } from "../../types/config.ts";
|
|
4
|
-
import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
|
|
4
|
+
import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar, saveSlidesSidecar, loadSlidesSidecar } from "../../history/store.ts";
|
|
5
5
|
import type { DiffComment } from "../../types/output.ts";
|
|
6
6
|
import { fetchPrDiff } from "../../github/fetch-diff.ts";
|
|
7
7
|
import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
|
|
@@ -10,6 +10,7 @@ import { parsePrInput } from "../../github/parse-pr.ts";
|
|
|
10
10
|
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
11
11
|
import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
|
|
12
12
|
import { generateCartoon } from "../../llm/cartoon.ts";
|
|
13
|
+
import { generateSlides } from "../../llm/slides.ts";
|
|
13
14
|
import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
14
15
|
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
15
16
|
import { randomBytes } from "node:crypto";
|
|
@@ -70,6 +71,16 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
70
71
|
return { login: "anonymous" };
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
interface SlideJob {
|
|
75
|
+
status: "running" | "done" | "error";
|
|
76
|
+
message: string;
|
|
77
|
+
current: number;
|
|
78
|
+
total: number;
|
|
79
|
+
plan?: { stylePrompt: string; slides: Array<{ index: number; title: string; contentPrompt: string }> };
|
|
80
|
+
imagePrompts?: Array<{ index: number; prompt: string }>;
|
|
81
|
+
}
|
|
82
|
+
const slideJobs = new Map<string, SlideJob>();
|
|
83
|
+
|
|
73
84
|
function buildChatSystemPrompt(data: NewprOutput): string {
|
|
74
85
|
const fileSummaries = data.files
|
|
75
86
|
.map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
@@ -223,7 +234,7 @@ $$
|
|
|
223
234
|
|
|
224
235
|
return {
|
|
225
236
|
"POST /api/analysis": async (req: Request) => {
|
|
226
|
-
const body = await req.json() as { pr: string };
|
|
237
|
+
const body = await req.json() as { pr: string; reuseSessionId?: string };
|
|
227
238
|
if (!body.pr) return json({ error: "Missing 'pr' field" }, 400);
|
|
228
239
|
|
|
229
240
|
const result = startAnalysis(body.pr, token, config);
|
|
@@ -231,6 +242,7 @@ $$
|
|
|
231
242
|
|
|
232
243
|
return json({
|
|
233
244
|
sessionId: result.sessionId,
|
|
245
|
+
reuseSessionId: body.reuseSessionId,
|
|
234
246
|
eventsUrl: `/api/analysis/${result.sessionId}/events`,
|
|
235
247
|
});
|
|
236
248
|
},
|
|
@@ -726,6 +738,107 @@ $$
|
|
|
726
738
|
return json({ ok: true });
|
|
727
739
|
},
|
|
728
740
|
|
|
741
|
+
"POST /api/sessions/:id/ask-inline": async (req: Request) => {
|
|
742
|
+
const url = new URL(req.url);
|
|
743
|
+
const segments = url.pathname.split("/");
|
|
744
|
+
const sessionId = segments[3]!;
|
|
745
|
+
|
|
746
|
+
if (!config.openrouter_api_key) {
|
|
747
|
+
return json({ error: "OpenRouter API key required" }, 400);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const body = await req.json() as { message: string };
|
|
751
|
+
if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
|
|
752
|
+
|
|
753
|
+
const sessionData = await loadSession(sessionId);
|
|
754
|
+
if (!sessionData) return json({ error: "Session not found" }, 404);
|
|
755
|
+
|
|
756
|
+
const systemPrompt = buildChatSystemPrompt(sessionData);
|
|
757
|
+
const apiMessages = [
|
|
758
|
+
{ role: "system" as const, content: systemPrompt },
|
|
759
|
+
{ role: "user" as const, content: body.message.trim() },
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
const encoder = new TextEncoder();
|
|
763
|
+
const stream = new ReadableStream({
|
|
764
|
+
async start(controller) {
|
|
765
|
+
const send = (eventType: string, data: string) => {
|
|
766
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
|
|
767
|
+
};
|
|
768
|
+
try {
|
|
769
|
+
await chatWithTools(
|
|
770
|
+
{ api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
|
|
771
|
+
apiMessages as Parameters<typeof chatWithTools>[1],
|
|
772
|
+
buildChatTools(),
|
|
773
|
+
async (name: string, args: Record<string, unknown>): Promise<string> => {
|
|
774
|
+
if (name === "get_file_diff") {
|
|
775
|
+
const filePath = args.path as string;
|
|
776
|
+
if (!filePath) return "Error: path required";
|
|
777
|
+
const patches = await loadPatchesSidecar(sessionId);
|
|
778
|
+
if (patches?.[filePath]) return patches[filePath];
|
|
779
|
+
const patch = await loadSinglePatch(sessionId, filePath);
|
|
780
|
+
if (patch) return patch;
|
|
781
|
+
return `File "${filePath}" not found`;
|
|
782
|
+
}
|
|
783
|
+
if (name === "list_files") {
|
|
784
|
+
return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
|
|
785
|
+
}
|
|
786
|
+
return `Tool ${name} not available in inline mode`;
|
|
787
|
+
},
|
|
788
|
+
(event: ChatStreamEvent) => {
|
|
789
|
+
if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
|
|
790
|
+
else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
|
|
791
|
+
else if (event.type === "done") send("done", JSON.stringify({}));
|
|
792
|
+
},
|
|
793
|
+
);
|
|
794
|
+
send("done", JSON.stringify({}));
|
|
795
|
+
} catch (err) {
|
|
796
|
+
send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
|
|
797
|
+
} finally {
|
|
798
|
+
controller.close();
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
return new Response(stream, {
|
|
804
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
805
|
+
});
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
"GET /api/sessions/:id/outdated": async (req: Request) => {
|
|
809
|
+
const url = new URL(req.url);
|
|
810
|
+
const segments = url.pathname.split("/");
|
|
811
|
+
const id = segments[3]!;
|
|
812
|
+
const sessionData = await loadSession(id);
|
|
813
|
+
if (!sessionData) return json({ error: "Session not found" }, 404);
|
|
814
|
+
|
|
815
|
+
const prUrl = sessionData.meta.pr_url;
|
|
816
|
+
const analyzedUpdatedAt = sessionData.meta.pr_updated_at;
|
|
817
|
+
if (!analyzedUpdatedAt) return json({ outdated: false, reason: "no_baseline" });
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const pr = parsePrInput(prUrl);
|
|
821
|
+
const res = await fetch(
|
|
822
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
|
|
823
|
+
{ headers: ghHeaders },
|
|
824
|
+
);
|
|
825
|
+
if (!res.ok) return json({ outdated: false, reason: "api_error" });
|
|
826
|
+
const data = await res.json() as { updated_at?: string; title?: string; state?: string; merged?: boolean; draft?: boolean };
|
|
827
|
+
const currentUpdatedAt = data.updated_at ?? "";
|
|
828
|
+
const outdated = currentUpdatedAt !== analyzedUpdatedAt;
|
|
829
|
+
return json({
|
|
830
|
+
outdated,
|
|
831
|
+
analyzed_at: sessionData.meta.analyzed_at,
|
|
832
|
+
analyzed_updated_at: analyzedUpdatedAt,
|
|
833
|
+
current_updated_at: currentUpdatedAt,
|
|
834
|
+
current_title: data.title,
|
|
835
|
+
current_state: data.draft ? "draft" : data.merged ? "merged" : data.state === "closed" ? "closed" : "open",
|
|
836
|
+
});
|
|
837
|
+
} catch {
|
|
838
|
+
return json({ outdated: false, reason: "fetch_error" });
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
|
|
729
842
|
"GET /api/sessions/:id/chat": async (req: Request) => {
|
|
730
843
|
const url = new URL(req.url);
|
|
731
844
|
const segments = url.pathname.split("/");
|
|
@@ -1127,5 +1240,75 @@ $$
|
|
|
1127
1240
|
return json({ error: msg }, 500);
|
|
1128
1241
|
}
|
|
1129
1242
|
},
|
|
1243
|
+
"GET /api/sessions/:id/slides": async (req: Request) => {
|
|
1244
|
+
const url = new URL(req.url);
|
|
1245
|
+
const segments = url.pathname.split("/");
|
|
1246
|
+
const id = segments[3]!;
|
|
1247
|
+
const deck = await loadSlidesSidecar(id);
|
|
1248
|
+
if (!deck) return json(null);
|
|
1249
|
+
return json(deck);
|
|
1250
|
+
},
|
|
1251
|
+
|
|
1252
|
+
"POST /api/slides": async (req: Request) => {
|
|
1253
|
+
if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required" }, 400);
|
|
1254
|
+
|
|
1255
|
+
const body = await req.json() as { sessionId?: string; language?: string; resume?: boolean };
|
|
1256
|
+
const sessionId = body.sessionId;
|
|
1257
|
+
if (!sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1258
|
+
|
|
1259
|
+
const data = await loadSession(sessionId);
|
|
1260
|
+
if (!data) return json({ error: "Session not found" }, 404);
|
|
1261
|
+
|
|
1262
|
+
if (slideJobs.has(sessionId) && slideJobs.get(sessionId)!.status === "running") {
|
|
1263
|
+
return json({ status: "already_running" });
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const existingDeck = body.resume ? await loadSlidesSidecar(sessionId) : null;
|
|
1267
|
+
const job: SlideJob = { status: "running", message: "Planning slide deck...", current: 0, total: 0 };
|
|
1268
|
+
slideJobs.set(sessionId, job);
|
|
1269
|
+
|
|
1270
|
+
(async () => {
|
|
1271
|
+
try {
|
|
1272
|
+
const deck = await generateSlides(
|
|
1273
|
+
config.openrouter_api_key,
|
|
1274
|
+
data,
|
|
1275
|
+
config.model,
|
|
1276
|
+
body.language ?? config.language,
|
|
1277
|
+
(msg, current, total) => {
|
|
1278
|
+
job.message = msg;
|
|
1279
|
+
job.current = current;
|
|
1280
|
+
job.total = total;
|
|
1281
|
+
},
|
|
1282
|
+
existingDeck,
|
|
1283
|
+
(plan, prompts) => {
|
|
1284
|
+
job.plan = plan;
|
|
1285
|
+
job.imagePrompts = prompts;
|
|
1286
|
+
},
|
|
1287
|
+
(partialDeck) => {
|
|
1288
|
+
saveSlidesSidecar(sessionId, partialDeck).catch(() => {});
|
|
1289
|
+
},
|
|
1290
|
+
);
|
|
1291
|
+
await saveSlidesSidecar(sessionId, deck);
|
|
1292
|
+
job.status = "done";
|
|
1293
|
+
job.message = `Generated ${deck.slides.length} slides`;
|
|
1294
|
+
job.total = deck.slides.length;
|
|
1295
|
+
job.current = deck.slides.length;
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
job.status = "error";
|
|
1298
|
+
job.message = err instanceof Error ? err.message : String(err);
|
|
1299
|
+
}
|
|
1300
|
+
})();
|
|
1301
|
+
|
|
1302
|
+
return json({ status: "started" });
|
|
1303
|
+
},
|
|
1304
|
+
|
|
1305
|
+
"GET /api/slides/status": async (req: Request) => {
|
|
1306
|
+
const url = new URL(req.url);
|
|
1307
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1308
|
+
if (!sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1309
|
+
const job = slideJobs.get(sessionId);
|
|
1310
|
+
if (!job) return json({ status: "idle" });
|
|
1311
|
+
return json(job);
|
|
1312
|
+
},
|
|
1130
1313
|
};
|
|
1131
1314
|
}
|
package/src/web/server.ts
CHANGED
|
@@ -102,6 +102,12 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
|
|
|
102
102
|
if (path.match(/^\/api\/sessions\/[^/]+\/comments$/) && req.method === "POST") {
|
|
103
103
|
return routes["POST /api/sessions/:id/comments"](req);
|
|
104
104
|
}
|
|
105
|
+
if (path.match(/^\/api\/sessions\/[^/]+\/ask-inline$/) && req.method === "POST") {
|
|
106
|
+
return routes["POST /api/sessions/:id/ask-inline"](req);
|
|
107
|
+
}
|
|
108
|
+
if (path.match(/^\/api\/sessions\/[^/]+\/outdated$/) && req.method === "GET") {
|
|
109
|
+
return routes["GET /api/sessions/:id/outdated"](req);
|
|
110
|
+
}
|
|
105
111
|
if (path.match(/^\/api\/sessions\/[^/]+\/chat\/undo$/) && req.method === "POST") {
|
|
106
112
|
return routes["POST /api/sessions/:id/chat/undo"](req);
|
|
107
113
|
}
|
|
@@ -132,6 +138,15 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
|
|
|
132
138
|
if (path === "/api/cartoon" && req.method === "POST") {
|
|
133
139
|
return routes["POST /api/cartoon"](req);
|
|
134
140
|
}
|
|
141
|
+
if (path.match(/^\/api\/sessions\/[^/]+\/slides$/) && req.method === "GET") {
|
|
142
|
+
return routes["GET /api/sessions/:id/slides"](req);
|
|
143
|
+
}
|
|
144
|
+
if (path === "/api/slides" && req.method === "POST") {
|
|
145
|
+
return routes["POST /api/slides"](req);
|
|
146
|
+
}
|
|
147
|
+
if (path === "/api/slides/status" && req.method === "GET") {
|
|
148
|
+
return routes["GET /api/slides/status"](req);
|
|
149
|
+
}
|
|
135
150
|
if (path === "/api/review" && req.method === "POST") {
|
|
136
151
|
return routes["POST /api/review"](req);
|
|
137
152
|
}
|