newpr 0.1.3 → 0.3.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 +11 -1
- package/src/analyzer/pipeline.ts +37 -15
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +53 -1
- package/src/history/store.ts +107 -1
- package/src/history/types.ts +1 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +80 -19
- package/src/llm/response-parser.ts +13 -1
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +14 -0
- package/src/types/output.ts +50 -0
- package/src/web/client/App.tsx +33 -5
- package/src/web/client/components/AppShell.tsx +107 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +217 -77
- package/src/web/client/components/DiffViewer.tsx +713 -0
- package/src/web/client/components/InputScreen.tsx +178 -27
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +220 -41
- package/src/web/client/components/ResultsScreen.tsx +109 -73
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +62 -40
- package/src/web/client/panels/StoryPanel.tsx +43 -23
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +856 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +66 -4
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/src/web/server/routes.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { NewprConfig } from "../../types/config.ts";
|
|
2
|
-
import type { NewprOutput } from "../../types/output.ts";
|
|
2
|
+
import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
|
|
3
3
|
import { DEFAULT_CONFIG } from "../../types/config.ts";
|
|
4
|
-
import { listSessions, loadSession } from "../../history/store.ts";
|
|
4
|
+
import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
|
|
5
|
+
import type { DiffComment } from "../../types/output.ts";
|
|
6
|
+
import { fetchPrDiff } from "../../github/fetch-diff.ts";
|
|
7
|
+
import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
|
|
8
|
+
import { parseDiff } from "../../diff/parser.ts";
|
|
9
|
+
import { parsePrInput } from "../../github/parse-pr.ts";
|
|
5
10
|
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
6
11
|
import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
|
|
7
12
|
import { generateCartoon } from "../../llm/cartoon.ts";
|
|
13
|
+
import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
14
|
+
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
8
16
|
|
|
9
17
|
function json(data: unknown, status = 200): Response {
|
|
10
18
|
return new Response(JSON.stringify(data), {
|
|
@@ -13,11 +21,198 @@ function json(data: unknown, status = 200): Response {
|
|
|
13
21
|
});
|
|
14
22
|
}
|
|
15
23
|
|
|
24
|
+
import type { PreflightResult } from "../../cli/preflight.ts";
|
|
25
|
+
|
|
16
26
|
interface RouteOptions {
|
|
17
27
|
cartoon?: boolean;
|
|
28
|
+
preflight?: PreflightResult;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export function createRoutes(token: string, config: NewprConfig, options: RouteOptions = {}) {
|
|
32
|
+
const ghHeaders = {
|
|
33
|
+
Authorization: `token ${token}`,
|
|
34
|
+
Accept: "application/vnd.github.v3+json",
|
|
35
|
+
"User-Agent": "newpr-cli",
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function resolvePrUrl(sessionId: string): Promise<string | null> {
|
|
40
|
+
const stored = await loadSession(sessionId);
|
|
41
|
+
if (stored) return stored.meta.pr_url;
|
|
42
|
+
const live = getSession(sessionId);
|
|
43
|
+
if (live?.result?.meta?.pr_url) return live.result.meta.pr_url;
|
|
44
|
+
if (live?.historyId) {
|
|
45
|
+
const hist = await loadSession(live.historyId);
|
|
46
|
+
if (hist) return hist.meta.pr_url;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchHeadSha(pr: { owner: string; repo: string; number: number }): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, { headers: ghHeaders });
|
|
54
|
+
if (!res.ok) return null;
|
|
55
|
+
const data = await res.json() as { head?: { sha?: string } };
|
|
56
|
+
return data.head?.sha ?? null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchCurrentUser(): Promise<{ login: string; avatar_url?: string }> {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch("https://api.github.com/user", { headers: ghHeaders });
|
|
65
|
+
if (res.ok) {
|
|
66
|
+
const user = await res.json() as Record<string, unknown>;
|
|
67
|
+
return { login: user.login as string, avatar_url: user.avatar_url as string | undefined };
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
return { login: "anonymous" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildChatSystemPrompt(data: NewprOutput): string {
|
|
74
|
+
const fileSummaries = data.files
|
|
75
|
+
.map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
76
|
+
.join("\n");
|
|
77
|
+
const groupSummaries = data.groups
|
|
78
|
+
.map((g) => `- [${g.type}] ${g.name}: ${g.description}\n Files: ${g.files.join(", ")}`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
|
|
81
|
+
return `You are an expert code reviewer assistant for a Pull Request analysis tool called "newpr".
|
|
82
|
+
You have access to the full analysis of PR #${data.meta.pr_number} "${data.meta.pr_title}" in ${data.meta.pr_url}.
|
|
83
|
+
|
|
84
|
+
## Analysis Context
|
|
85
|
+
|
|
86
|
+
**Author**: ${data.meta.author}
|
|
87
|
+
**Branches**: ${data.meta.head_branch} → ${data.meta.base_branch}
|
|
88
|
+
**Stats**: ${data.meta.total_files_changed} files, +${data.meta.total_additions} -${data.meta.total_deletions}
|
|
89
|
+
**Risk**: ${data.summary.risk_level}
|
|
90
|
+
|
|
91
|
+
**Purpose**: ${data.summary.purpose}
|
|
92
|
+
**Scope**: ${data.summary.scope}
|
|
93
|
+
**Impact**: ${data.summary.impact}
|
|
94
|
+
|
|
95
|
+
## File Changes
|
|
96
|
+
${fileSummaries}
|
|
97
|
+
|
|
98
|
+
## Change Groups
|
|
99
|
+
${groupSummaries}
|
|
100
|
+
|
|
101
|
+
## Narrative
|
|
102
|
+
${data.narrative}
|
|
103
|
+
|
|
104
|
+
${data.meta.pr_body ? `## PR Description\n${data.meta.pr_body}` : ""}
|
|
105
|
+
|
|
106
|
+
## Anchor Syntax (CRITICAL)
|
|
107
|
+
You MUST use these anchors. They become clickable links in the UI.
|
|
108
|
+
|
|
109
|
+
1. Group: [[group:Exact Group Name]] — renders as a clickable chip.
|
|
110
|
+
2. File: [[file:exact/path/here.ts]] — renders as a clickable chip.
|
|
111
|
+
3. Line reference: [[line:exact/path/here.ts#L42-L50]](descriptive text) — the "descriptive text" becomes an underlined link that opens the diff scrolled to that line. The line info is NOT shown — only the text is visible.
|
|
112
|
+
|
|
113
|
+
RULES:
|
|
114
|
+
- Use EXACT paths and names from the lists above.
|
|
115
|
+
- For line references, ALWAYS use [[line:path#L-L]](text). NEVER bare [[line:...]] without (text).
|
|
116
|
+
- The (text) should describe what the code does, NOT show file names or line numbers.
|
|
117
|
+
- Do NOT place [[file:...]] and [[line:...]] adjacent for the same file.
|
|
118
|
+
- Use the get_file_diff tool to find exact line numbers before referencing them.
|
|
119
|
+
- Aim for most statements about code to include at least one line reference.
|
|
120
|
+
|
|
121
|
+
## Math / LaTeX
|
|
122
|
+
When expressing mathematical formulas, algorithms, or complexity analysis, use LaTeX syntax:
|
|
123
|
+
- Inline: $O(n \\log n)$, $\\sum_{i=1}^{n} x_i$
|
|
124
|
+
- Block:
|
|
125
|
+
$$
|
|
126
|
+
f(x) = \\int_{a}^{b} g(t) \\, dt
|
|
127
|
+
$$
|
|
128
|
+
|
|
129
|
+
## Instructions
|
|
130
|
+
- Answer questions about this PR thoroughly and precisely.
|
|
131
|
+
- Use your tools to fetch additional context when needed (file diffs, comments, reviews).
|
|
132
|
+
- When referencing code, include relevant snippets from the diff.
|
|
133
|
+
- Be concise but thorough. Use markdown formatting.
|
|
134
|
+
- If the user asks in Korean, respond in Korean. Match the user's language.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildChatTools(): ChatTool[] {
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
type: "function",
|
|
141
|
+
function: {
|
|
142
|
+
name: "get_file_diff",
|
|
143
|
+
description: "Get the full unified diff for a specific file in this PR. Use this to see exact code changes.",
|
|
144
|
+
parameters: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
path: { type: "string", description: "File path (e.g. 'src/index.ts')" },
|
|
148
|
+
},
|
|
149
|
+
required: ["path"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: "function",
|
|
155
|
+
function: {
|
|
156
|
+
name: "list_files",
|
|
157
|
+
description: "List all changed files in this PR with their status, line counts, and summaries.",
|
|
158
|
+
parameters: { type: "object", properties: {} },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "function",
|
|
163
|
+
function: {
|
|
164
|
+
name: "get_pr_comments",
|
|
165
|
+
description: "Get all issue comments (discussion) on this PR from GitHub.",
|
|
166
|
+
parameters: { type: "object", properties: {} },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: "function",
|
|
171
|
+
function: {
|
|
172
|
+
name: "get_review_comments",
|
|
173
|
+
description: "Get all inline review comments on specific lines of code in this PR from GitHub.",
|
|
174
|
+
parameters: { type: "object", properties: {} },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: "function",
|
|
179
|
+
function: {
|
|
180
|
+
name: "get_pr_details",
|
|
181
|
+
description: "Get PR metadata from GitHub: state, mergeable status, labels, requested reviewers, etc.",
|
|
182
|
+
parameters: { type: "object", properties: {} },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
type: "function",
|
|
187
|
+
function: {
|
|
188
|
+
name: "web_search",
|
|
189
|
+
description: "Search the web for documentation, library references, best practices, or any technical question. Returns top search results with snippets.",
|
|
190
|
+
parameters: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
query: { type: "string", description: "Search query (e.g. 'React useEffect cleanup pattern', 'zod discriminated union')" },
|
|
194
|
+
},
|
|
195
|
+
required: ["query"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
type: "function",
|
|
201
|
+
function: {
|
|
202
|
+
name: "web_fetch",
|
|
203
|
+
description: "Fetch the text content of a web page. Use this to read documentation pages, blog posts, or API references found via web_search.",
|
|
204
|
+
parameters: {
|
|
205
|
+
type: "object",
|
|
206
|
+
properties: {
|
|
207
|
+
url: { type: "string", description: "URL to fetch (must start with https://)" },
|
|
208
|
+
},
|
|
209
|
+
required: ["url"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
21
216
|
return {
|
|
22
217
|
"POST /api/analysis": async (req: Request) => {
|
|
23
218
|
const body = await req.json() as { pr: string };
|
|
@@ -45,6 +240,7 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
45
240
|
finishedAt: session.finishedAt,
|
|
46
241
|
error: session.error,
|
|
47
242
|
result: session.result,
|
|
243
|
+
historyId: session.historyId,
|
|
48
244
|
});
|
|
49
245
|
},
|
|
50
246
|
|
|
@@ -121,6 +317,130 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
121
317
|
return json(data);
|
|
122
318
|
},
|
|
123
319
|
|
|
320
|
+
"GET /api/sessions/:id/diff": async (req: Request) => {
|
|
321
|
+
const url = new URL(req.url);
|
|
322
|
+
const segments = url.pathname.split("/");
|
|
323
|
+
const id = segments[segments.length - 2]!;
|
|
324
|
+
const filePath = url.searchParams.get("path");
|
|
325
|
+
if (!filePath) return json({ error: "Missing 'path' query parameter" }, 400);
|
|
326
|
+
|
|
327
|
+
const patch = await loadSinglePatch(id, filePath);
|
|
328
|
+
if (patch) return json({ patch, path: filePath });
|
|
329
|
+
|
|
330
|
+
let prUrl: string | null = null;
|
|
331
|
+
let storeId = id;
|
|
332
|
+
|
|
333
|
+
const storedSession = await loadSession(id);
|
|
334
|
+
if (storedSession) {
|
|
335
|
+
prUrl = storedSession.meta.pr_url;
|
|
336
|
+
} else {
|
|
337
|
+
const liveSession = getSession(id);
|
|
338
|
+
if (liveSession?.result?.meta?.pr_url) {
|
|
339
|
+
prUrl = liveSession.result.meta.pr_url;
|
|
340
|
+
if (liveSession.historyId) storeId = liveSession.historyId;
|
|
341
|
+
} else if (liveSession?.historyId) {
|
|
342
|
+
const histPatch = await loadSinglePatch(liveSession.historyId, filePath);
|
|
343
|
+
if (histPatch) return json({ patch: histPatch, path: filePath });
|
|
344
|
+
|
|
345
|
+
const histSession = await loadSession(liveSession.historyId);
|
|
346
|
+
if (histSession) {
|
|
347
|
+
prUrl = histSession.meta.pr_url;
|
|
348
|
+
storeId = liveSession.historyId;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!prUrl) return json({ error: "Session not found" }, 404);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const pr = parsePrInput(prUrl);
|
|
357
|
+
const rawDiff = await fetchPrDiff(pr, token);
|
|
358
|
+
const parsed = parseDiff(rawDiff);
|
|
359
|
+
|
|
360
|
+
const allPatches: Record<string, string> = {};
|
|
361
|
+
for (const file of parsed.files) {
|
|
362
|
+
allPatches[file.path] = file.raw;
|
|
363
|
+
}
|
|
364
|
+
await savePatchesSidecar(storeId, allPatches).catch(() => {});
|
|
365
|
+
|
|
366
|
+
const backfilledPatch = allPatches[filePath];
|
|
367
|
+
if (!backfilledPatch) return json({ error: "File not found in diff" }, 404);
|
|
368
|
+
return json({ patch: backfilledPatch, path: filePath });
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
371
|
+
return json({ error: `Failed to fetch diff: ${msg}` }, 500);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
"GET /api/sessions/:id/discussion": async (req: Request) => {
|
|
376
|
+
const url = new URL(req.url);
|
|
377
|
+
const segments = url.pathname.split("/");
|
|
378
|
+
const id = segments[segments.length - 2]!;
|
|
379
|
+
|
|
380
|
+
let prUrl: string | null = null;
|
|
381
|
+
let body: string | null = null;
|
|
382
|
+
|
|
383
|
+
const storedSession = await loadSession(id);
|
|
384
|
+
if (storedSession) {
|
|
385
|
+
prUrl = storedSession.meta.pr_url;
|
|
386
|
+
body = storedSession.meta.pr_body ?? null;
|
|
387
|
+
} else {
|
|
388
|
+
const liveSession = getSession(id);
|
|
389
|
+
if (liveSession?.result?.meta?.pr_url) {
|
|
390
|
+
prUrl = liveSession.result.meta.pr_url;
|
|
391
|
+
body = liveSession.result.meta.pr_body ?? null;
|
|
392
|
+
} else if (liveSession?.historyId) {
|
|
393
|
+
const histSession = await loadSession(liveSession.historyId);
|
|
394
|
+
if (histSession) {
|
|
395
|
+
prUrl = histSession.meta.pr_url;
|
|
396
|
+
body = histSession.meta.pr_body ?? null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!prUrl) return json({ error: "Session not found" }, 404);
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const pr = parsePrInput(prUrl);
|
|
405
|
+
if (body === null) {
|
|
406
|
+
body = await fetchPrBody(pr, token);
|
|
407
|
+
}
|
|
408
|
+
const comments = await fetchPrComments(pr, token);
|
|
409
|
+
return json({ body, comments });
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
412
|
+
return json({ error: `Failed to fetch discussion: ${msg}` }, 500);
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
"GET /api/proxy": async (req: Request) => {
|
|
417
|
+
const url = new URL(req.url);
|
|
418
|
+
const target = url.searchParams.get("url");
|
|
419
|
+
if (!target) return json({ error: "Missing 'url' query parameter" }, 400);
|
|
420
|
+
|
|
421
|
+
const allowed = target.startsWith("https://github.com/") || target.startsWith("https://user-images.githubusercontent.com/");
|
|
422
|
+
if (!allowed) return json({ error: "URL not allowed" }, 403);
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const headers: Record<string, string> = { "User-Agent": "newpr-cli" };
|
|
426
|
+
if (token && target.startsWith("https://github.com/")) {
|
|
427
|
+
headers.Authorization = `token ${token}`;
|
|
428
|
+
}
|
|
429
|
+
const res = await fetch(target, { headers, redirect: "follow" });
|
|
430
|
+
if (!res.ok) return new Response(null, { status: res.status });
|
|
431
|
+
|
|
432
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
433
|
+
return new Response(res.body, {
|
|
434
|
+
headers: {
|
|
435
|
+
"Content-Type": contentType,
|
|
436
|
+
"Cache-Control": "public, max-age=86400, immutable",
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
} catch {
|
|
440
|
+
return new Response(null, { status: 502 });
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
|
|
124
444
|
"GET /api/me": async () => {
|
|
125
445
|
try {
|
|
126
446
|
const res = await fetch("https://api.github.com/user", {
|
|
@@ -209,6 +529,535 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
209
529
|
return json({ cartoon: !!options.cartoon });
|
|
210
530
|
},
|
|
211
531
|
|
|
532
|
+
"POST /api/review": async (req: Request) => {
|
|
533
|
+
const body = await req.json() as { pr_url: string; event: string; body?: string };
|
|
534
|
+
if (!body.pr_url || !body.event) return json({ error: "Missing pr_url or event" }, 400);
|
|
535
|
+
|
|
536
|
+
const validEvents = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
|
|
537
|
+
if (!validEvents.includes(body.event)) return json({ error: `Invalid event: ${body.event}` }, 400);
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const pr = parsePrInput(body.pr_url);
|
|
541
|
+
const res = await fetch(
|
|
542
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews`,
|
|
543
|
+
{
|
|
544
|
+
method: "POST",
|
|
545
|
+
headers: ghHeaders,
|
|
546
|
+
body: JSON.stringify({
|
|
547
|
+
body: body.body ?? "",
|
|
548
|
+
event: body.event,
|
|
549
|
+
}),
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
const errBody = await res.json().catch(() => ({})) as { message?: string };
|
|
554
|
+
return json({ error: errBody.message ?? `GitHub API error: ${res.status}` }, res.status);
|
|
555
|
+
}
|
|
556
|
+
const data = await res.json() as { id: number; state: string; html_url: string };
|
|
557
|
+
return json({ ok: true, id: data.id, state: data.state, html_url: data.html_url });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
"GET /api/preflight": () => {
|
|
564
|
+
return json(options.preflight ?? null);
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
"GET /api/sessions/:id/comments": async (req: Request) => {
|
|
568
|
+
const url = new URL(req.url);
|
|
569
|
+
const segments = url.pathname.split("/");
|
|
570
|
+
const id = segments[3]!;
|
|
571
|
+
const filePath = url.searchParams.get("path");
|
|
572
|
+
|
|
573
|
+
const comments = await loadCommentsSidecar(id) ?? [];
|
|
574
|
+
const filtered = filePath ? comments.filter((c) => c.filePath === filePath) : comments;
|
|
575
|
+
return json(filtered);
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
"POST /api/sessions/:id/comments": async (req: Request) => {
|
|
579
|
+
const url = new URL(req.url);
|
|
580
|
+
const segments = url.pathname.split("/");
|
|
581
|
+
const sessionId = segments[3]!;
|
|
582
|
+
|
|
583
|
+
const body = await req.json() as { filePath?: string; line?: number; startLine?: number; side?: string; body?: string };
|
|
584
|
+
if (!body.filePath || body.line == null || !body.side || !body.body?.trim()) {
|
|
585
|
+
return json({ error: "Missing required fields" }, 400);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const user = await fetchCurrentUser();
|
|
589
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
590
|
+
|
|
591
|
+
let githubCommentId: number | undefined;
|
|
592
|
+
let githubCommentUrl: string | undefined;
|
|
593
|
+
if (prUrl) {
|
|
594
|
+
try {
|
|
595
|
+
const pr = parsePrInput(prUrl);
|
|
596
|
+
const sha = await fetchHeadSha(pr);
|
|
597
|
+
if (sha) {
|
|
598
|
+
const ghSide = body.side === "old" ? "LEFT" : "RIGHT";
|
|
599
|
+
const ghBody: Record<string, unknown> = {
|
|
600
|
+
commit_id: sha,
|
|
601
|
+
path: body.filePath,
|
|
602
|
+
line: body.line,
|
|
603
|
+
side: ghSide,
|
|
604
|
+
body: body.body.trim(),
|
|
605
|
+
};
|
|
606
|
+
if (body.startLine != null && body.startLine !== body.line) {
|
|
607
|
+
ghBody.start_line = body.startLine;
|
|
608
|
+
ghBody.start_side = ghSide;
|
|
609
|
+
}
|
|
610
|
+
const res = await fetch(
|
|
611
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments`,
|
|
612
|
+
{
|
|
613
|
+
method: "POST",
|
|
614
|
+
headers: ghHeaders,
|
|
615
|
+
body: JSON.stringify(ghBody),
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
if (res.ok) {
|
|
619
|
+
const data = await res.json() as { id?: number; html_url?: string };
|
|
620
|
+
githubCommentId = data.id;
|
|
621
|
+
githubCommentUrl = data.html_url;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch {}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const hasRange = body.startLine != null && body.startLine !== body.line;
|
|
628
|
+
const comment: DiffComment = {
|
|
629
|
+
id: randomBytes(8).toString("hex"),
|
|
630
|
+
sessionId,
|
|
631
|
+
filePath: body.filePath,
|
|
632
|
+
line: body.line,
|
|
633
|
+
...(hasRange ? { startLine: body.startLine } : {}),
|
|
634
|
+
side: body.side as "old" | "new",
|
|
635
|
+
body: body.body.trim(),
|
|
636
|
+
author: user.login,
|
|
637
|
+
authorAvatar: user.avatar_url,
|
|
638
|
+
createdAt: new Date().toISOString(),
|
|
639
|
+
githubUrl: githubCommentUrl,
|
|
640
|
+
githubCommentId,
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
644
|
+
existing.push(comment);
|
|
645
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
646
|
+
|
|
647
|
+
return json(comment, 201);
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
"PATCH /api/sessions/:id/comments/:commentId": async (req: Request) => {
|
|
651
|
+
const url = new URL(req.url);
|
|
652
|
+
const segments = url.pathname.split("/");
|
|
653
|
+
const sessionId = segments[3]!;
|
|
654
|
+
const commentId = segments[5]!;
|
|
655
|
+
|
|
656
|
+
const body = await req.json() as { body?: string };
|
|
657
|
+
if (!body.body?.trim()) return json({ error: "Missing body" }, 400);
|
|
658
|
+
|
|
659
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
660
|
+
const comment = existing.find((c) => c.id === commentId);
|
|
661
|
+
if (!comment) return json({ error: "Comment not found" }, 404);
|
|
662
|
+
|
|
663
|
+
comment.body = body.body.trim();
|
|
664
|
+
|
|
665
|
+
if (comment.githubCommentId) {
|
|
666
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
667
|
+
if (prUrl) {
|
|
668
|
+
try {
|
|
669
|
+
const pr = parsePrInput(prUrl);
|
|
670
|
+
await fetch(
|
|
671
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${comment.githubCommentId}`,
|
|
672
|
+
{
|
|
673
|
+
method: "PATCH",
|
|
674
|
+
headers: ghHeaders,
|
|
675
|
+
body: JSON.stringify({ body: comment.body }),
|
|
676
|
+
},
|
|
677
|
+
);
|
|
678
|
+
} catch {}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
683
|
+
return json(comment);
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
"DELETE /api/sessions/:id/comments/:commentId": async (req: Request) => {
|
|
687
|
+
const url = new URL(req.url);
|
|
688
|
+
const segments = url.pathname.split("/");
|
|
689
|
+
const sessionId = segments[3]!;
|
|
690
|
+
const commentId = segments[5]!;
|
|
691
|
+
|
|
692
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
693
|
+
const idx = existing.findIndex((c) => c.id === commentId);
|
|
694
|
+
if (idx === -1) return json({ error: "Comment not found" }, 404);
|
|
695
|
+
|
|
696
|
+
const removed = existing[idx]!;
|
|
697
|
+
if (removed.githubCommentId) {
|
|
698
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
699
|
+
if (prUrl) {
|
|
700
|
+
try {
|
|
701
|
+
const pr = parsePrInput(prUrl);
|
|
702
|
+
await fetch(
|
|
703
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${removed.githubCommentId}`,
|
|
704
|
+
{ method: "DELETE", headers: ghHeaders },
|
|
705
|
+
);
|
|
706
|
+
} catch {}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
existing.splice(idx, 1);
|
|
711
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
712
|
+
|
|
713
|
+
return json({ ok: true });
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
"GET /api/sessions/:id/chat": async (req: Request) => {
|
|
717
|
+
const url = new URL(req.url);
|
|
718
|
+
const segments = url.pathname.split("/");
|
|
719
|
+
const id = segments[3]!;
|
|
720
|
+
const messages = await loadChatSidecar(id) ?? [];
|
|
721
|
+
return json(messages);
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
"POST /api/sessions/:id/chat/undo": async (req: Request) => {
|
|
725
|
+
const url = new URL(req.url);
|
|
726
|
+
const segments = url.pathname.split("/");
|
|
727
|
+
const sessionId = segments[3]!;
|
|
728
|
+
const chatHistory = await loadChatSidecar(sessionId) ?? [];
|
|
729
|
+
if (chatHistory.length === 0) return json({ ok: true, removed: 0 });
|
|
730
|
+
const lastAssistantIdx = chatHistory.findLastIndex((m) => m.role === "assistant");
|
|
731
|
+
if (lastAssistantIdx === -1) return json({ ok: true, removed: 0 });
|
|
732
|
+
const lastUserIdx = chatHistory.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
|
|
733
|
+
const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
|
|
734
|
+
const removed = chatHistory.length - removeFrom;
|
|
735
|
+
const updated = chatHistory.slice(0, removeFrom);
|
|
736
|
+
await saveChatSidecar(sessionId, updated);
|
|
737
|
+
return json({ ok: true, removed });
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
"POST /api/sessions/:id/chat": async (req: Request) => {
|
|
741
|
+
const url = new URL(req.url);
|
|
742
|
+
const segments = url.pathname.split("/");
|
|
743
|
+
const sessionId = segments[3]!;
|
|
744
|
+
|
|
745
|
+
if (!config.openrouter_api_key) {
|
|
746
|
+
return json({ error: "OpenRouter API key required for chat" }, 400);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const body = await req.json() as { message: string };
|
|
750
|
+
if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
|
|
751
|
+
|
|
752
|
+
const sessionData = await loadSession(sessionId);
|
|
753
|
+
if (!sessionData) return json({ error: "Session not found" }, 404);
|
|
754
|
+
|
|
755
|
+
const chatHistory = await loadChatSidecar(sessionId) ?? [];
|
|
756
|
+
|
|
757
|
+
const userMsg: ChatMessage = {
|
|
758
|
+
role: "user",
|
|
759
|
+
content: body.message.trim(),
|
|
760
|
+
timestamp: new Date().toISOString(),
|
|
761
|
+
};
|
|
762
|
+
chatHistory.push(userMsg);
|
|
763
|
+
await saveChatSidecar(sessionId, chatHistory);
|
|
764
|
+
|
|
765
|
+
const systemPrompt = buildChatSystemPrompt(sessionData);
|
|
766
|
+
|
|
767
|
+
const apiMessages: Array<{ role: string; content?: string | null; tool_calls?: unknown[]; tool_call_id?: string }> = [
|
|
768
|
+
{ role: "system", content: systemPrompt },
|
|
769
|
+
];
|
|
770
|
+
for (const msg of chatHistory) {
|
|
771
|
+
if (msg.role === "user") {
|
|
772
|
+
apiMessages.push({ role: "user", content: msg.content });
|
|
773
|
+
} else if (msg.role === "assistant") {
|
|
774
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
775
|
+
apiMessages.push({
|
|
776
|
+
role: "assistant",
|
|
777
|
+
content: msg.content || null,
|
|
778
|
+
tool_calls: msg.toolCalls.map((tc) => ({
|
|
779
|
+
id: tc.id,
|
|
780
|
+
type: "function",
|
|
781
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
782
|
+
})),
|
|
783
|
+
});
|
|
784
|
+
for (const tc of msg.toolCalls) {
|
|
785
|
+
if (tc.result !== undefined) {
|
|
786
|
+
apiMessages.push({
|
|
787
|
+
role: "tool",
|
|
788
|
+
content: tc.result,
|
|
789
|
+
tool_call_id: tc.id,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
apiMessages.push({ role: "assistant", content: msg.content });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const chatTools = buildChatTools();
|
|
800
|
+
|
|
801
|
+
const patches = await loadPatchesSidecar(sessionId);
|
|
802
|
+
|
|
803
|
+
const executeTool = async (name: string, args: Record<string, unknown>): Promise<string> => {
|
|
804
|
+
switch (name) {
|
|
805
|
+
case "get_file_diff": {
|
|
806
|
+
const filePath = args.path as string;
|
|
807
|
+
if (!filePath) return "Error: path argument required";
|
|
808
|
+
if (patches?.[filePath]) return patches[filePath];
|
|
809
|
+
const patch = await loadSinglePatch(sessionId, filePath);
|
|
810
|
+
if (patch) return patch;
|
|
811
|
+
try {
|
|
812
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
813
|
+
const rawDiff = await fetchPrDiff(pr, token);
|
|
814
|
+
const parsed = parseDiff(rawDiff);
|
|
815
|
+
const file = parsed.files.find((f) => f.path === filePath);
|
|
816
|
+
return file?.raw ?? `File "${filePath}" not found in diff`;
|
|
817
|
+
} catch (err) {
|
|
818
|
+
return `Error fetching diff: ${err instanceof Error ? err.message : String(err)}`;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
case "list_files": {
|
|
822
|
+
return sessionData.files
|
|
823
|
+
.map((f) => `${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
824
|
+
.join("\n");
|
|
825
|
+
}
|
|
826
|
+
case "get_pr_comments": {
|
|
827
|
+
try {
|
|
828
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
829
|
+
const comments = await fetchPrComments(pr, token);
|
|
830
|
+
if (comments.length === 0) return "No comments on this PR.";
|
|
831
|
+
return comments.map((c) => `@${c.author} (${c.created_at}):\n${c.body}`).join("\n\n---\n\n");
|
|
832
|
+
} catch (err) {
|
|
833
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
case "get_review_comments": {
|
|
837
|
+
try {
|
|
838
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
839
|
+
const res = await fetch(
|
|
840
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments?per_page=100`,
|
|
841
|
+
{ headers: ghHeaders },
|
|
842
|
+
);
|
|
843
|
+
if (!res.ok) return `GitHub API error: ${res.status}`;
|
|
844
|
+
const reviews = await res.json() as Array<{ user?: { login?: string }; path?: string; body?: string; created_at?: string; line?: number }>;
|
|
845
|
+
if (reviews.length === 0) return "No review comments on this PR.";
|
|
846
|
+
return reviews.map((r) =>
|
|
847
|
+
`@${r.user?.login ?? "unknown"} on ${r.path ?? "?"}${r.line ? `:${r.line}` : ""} (${r.created_at}):\n${r.body ?? ""}`,
|
|
848
|
+
).join("\n\n---\n\n");
|
|
849
|
+
} catch (err) {
|
|
850
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
case "get_pr_details": {
|
|
854
|
+
try {
|
|
855
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
856
|
+
const res = await fetch(
|
|
857
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
|
|
858
|
+
{ headers: ghHeaders },
|
|
859
|
+
);
|
|
860
|
+
if (!res.ok) return `GitHub API error: ${res.status}`;
|
|
861
|
+
const data = await res.json() as Record<string, unknown>;
|
|
862
|
+
return JSON.stringify({
|
|
863
|
+
title: data.title,
|
|
864
|
+
body: data.body,
|
|
865
|
+
state: data.state,
|
|
866
|
+
merged: data.merged,
|
|
867
|
+
mergeable: data.mergeable,
|
|
868
|
+
additions: data.additions,
|
|
869
|
+
deletions: data.deletions,
|
|
870
|
+
changed_files: data.changed_files,
|
|
871
|
+
labels: (data.labels as Array<{ name: string }>)?.map((l) => l.name),
|
|
872
|
+
requested_reviewers: (data.requested_reviewers as Array<{ login: string }>)?.map((r) => r.login),
|
|
873
|
+
}, null, 2);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
case "web_search": {
|
|
879
|
+
const query = args.query as string;
|
|
880
|
+
if (!query) return "Error: query argument required";
|
|
881
|
+
const agents = await detectAgents();
|
|
882
|
+
if (agents.length > 0) {
|
|
883
|
+
try {
|
|
884
|
+
const result = await runAgent(agents[0]!, process.cwd(), `Search the web for: "${query}"\n\nReturn the top results with titles, URLs, and brief descriptions. Be concise.`, { timeout: 30_000 });
|
|
885
|
+
if (result.answer.trim()) return result.answer;
|
|
886
|
+
} catch {}
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
const encoded = encodeURIComponent(query);
|
|
890
|
+
const res = await fetch(`https://html.duckduckgo.com/html/?q=${encoded}`, {
|
|
891
|
+
headers: { "User-Agent": "newpr-cli/0.2.0" },
|
|
892
|
+
});
|
|
893
|
+
if (!res.ok) return `Search failed: HTTP ${res.status}`;
|
|
894
|
+
const html = await res.text();
|
|
895
|
+
const results: string[] = [];
|
|
896
|
+
const resultRe = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
897
|
+
let m;
|
|
898
|
+
while ((m = resultRe.exec(html)) !== null && results.length < 8) {
|
|
899
|
+
const href = m[1]?.replace(/&/g, "&") ?? "";
|
|
900
|
+
const title = (m[2] ?? "").replace(/<[^>]+>/g, "").trim();
|
|
901
|
+
const snippet = (m[3] ?? "").replace(/<[^>]+>/g, "").trim();
|
|
902
|
+
if (title && href) results.push(`${title}\n${href}\n${snippet}`);
|
|
903
|
+
}
|
|
904
|
+
if (results.length === 0) return `No results found for "${query}"`;
|
|
905
|
+
return results.join("\n\n---\n\n");
|
|
906
|
+
} catch (err) {
|
|
907
|
+
return `Search error: ${err instanceof Error ? err.message : String(err)}`;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
case "web_fetch": {
|
|
911
|
+
const url = args.url as string;
|
|
912
|
+
if (!url?.startsWith("https://")) return "Error: url must start with https://";
|
|
913
|
+
const agents = await detectAgents();
|
|
914
|
+
if (agents.length > 0) {
|
|
915
|
+
try {
|
|
916
|
+
const result = await runAgent(agents[0]!, process.cwd(), `Fetch and summarize the content of this URL: ${url}\n\nReturn the key information from the page. Be thorough but concise.`, { timeout: 30_000 });
|
|
917
|
+
if (result.answer.trim()) return result.answer;
|
|
918
|
+
} catch {}
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
const controller = new AbortController();
|
|
922
|
+
const t = setTimeout(() => controller.abort(), 15000);
|
|
923
|
+
const res = await fetch(url, {
|
|
924
|
+
signal: controller.signal,
|
|
925
|
+
headers: { "User-Agent": "newpr-cli/0.2.0", Accept: "text/html,text/plain,application/json" },
|
|
926
|
+
redirect: "follow",
|
|
927
|
+
});
|
|
928
|
+
clearTimeout(t);
|
|
929
|
+
if (!res.ok) return `Fetch failed: HTTP ${res.status}`;
|
|
930
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
931
|
+
const text = await res.text();
|
|
932
|
+
if (contentType.includes("json")) return text.slice(0, 15000);
|
|
933
|
+
const stripped = text
|
|
934
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
935
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
936
|
+
.replace(/<nav[\s\S]*?<\/nav>/gi, "")
|
|
937
|
+
.replace(/<footer[\s\S]*?<\/footer>/gi, "")
|
|
938
|
+
.replace(/<header[\s\S]*?<\/header>/gi, "")
|
|
939
|
+
.replace(/<[^>]+>/g, " ")
|
|
940
|
+
.replace(/\s+/g, " ")
|
|
941
|
+
.trim();
|
|
942
|
+
return stripped.slice(0, 15000) + (stripped.length > 15000 ? "\n\n... (truncated)" : "");
|
|
943
|
+
} catch (err) {
|
|
944
|
+
return `Fetch error: ${err instanceof Error ? err.message : String(err)}`;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
default:
|
|
948
|
+
return `Unknown tool: ${name}`;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const encoder = new TextEncoder();
|
|
953
|
+
const stream = new ReadableStream({
|
|
954
|
+
async start(controller) {
|
|
955
|
+
const send = (eventType: string, data: string) => {
|
|
956
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
let fullText = "";
|
|
960
|
+
const collectedToolCalls: ChatToolCall[] = [];
|
|
961
|
+
const orderedSegments: ChatSegment[] = [];
|
|
962
|
+
let lastSegmentWasText = false;
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
await chatWithTools(
|
|
966
|
+
{
|
|
967
|
+
api_key: config.openrouter_api_key,
|
|
968
|
+
model: config.model,
|
|
969
|
+
timeout: config.timeout,
|
|
970
|
+
},
|
|
971
|
+
apiMessages as Parameters<typeof chatWithTools>[1],
|
|
972
|
+
chatTools,
|
|
973
|
+
executeTool,
|
|
974
|
+
(event: ChatStreamEvent) => {
|
|
975
|
+
switch (event.type) {
|
|
976
|
+
case "text":
|
|
977
|
+
fullText += event.content ?? "";
|
|
978
|
+
if (lastSegmentWasText && orderedSegments.length > 0) {
|
|
979
|
+
const last = orderedSegments[orderedSegments.length - 1]!;
|
|
980
|
+
if (last.type === "text") {
|
|
981
|
+
last.content += event.content ?? "";
|
|
982
|
+
}
|
|
983
|
+
} else {
|
|
984
|
+
orderedSegments.push({ type: "text", content: event.content ?? "" });
|
|
985
|
+
lastSegmentWasText = true;
|
|
986
|
+
}
|
|
987
|
+
send("text", JSON.stringify({ content: event.content }));
|
|
988
|
+
break;
|
|
989
|
+
case "tool_call":
|
|
990
|
+
if (event.toolCall) {
|
|
991
|
+
let args: Record<string, unknown> = {};
|
|
992
|
+
try { args = JSON.parse(event.toolCall.arguments); } catch {}
|
|
993
|
+
const tc: ChatToolCall = {
|
|
994
|
+
id: event.toolCall.id,
|
|
995
|
+
name: event.toolCall.name,
|
|
996
|
+
arguments: args,
|
|
997
|
+
};
|
|
998
|
+
collectedToolCalls.push(tc);
|
|
999
|
+
orderedSegments.push({ type: "tool_call", toolCall: tc });
|
|
1000
|
+
lastSegmentWasText = false;
|
|
1001
|
+
send("tool_call", JSON.stringify({
|
|
1002
|
+
id: event.toolCall.id,
|
|
1003
|
+
name: event.toolCall.name,
|
|
1004
|
+
arguments: args,
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
break;
|
|
1008
|
+
case "tool_result":
|
|
1009
|
+
if (event.toolResult) {
|
|
1010
|
+
const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
|
|
1011
|
+
if (tc) tc.result = event.toolResult.result;
|
|
1012
|
+
send("tool_result", JSON.stringify(event.toolResult));
|
|
1013
|
+
}
|
|
1014
|
+
break;
|
|
1015
|
+
case "error":
|
|
1016
|
+
send("chat_error", JSON.stringify({ message: event.error }));
|
|
1017
|
+
break;
|
|
1018
|
+
case "done":
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
const assistantMsg: ChatMessage = {
|
|
1025
|
+
role: "assistant",
|
|
1026
|
+
content: fullText,
|
|
1027
|
+
toolCalls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
|
|
1028
|
+
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
1029
|
+
timestamp: new Date().toISOString(),
|
|
1030
|
+
};
|
|
1031
|
+
chatHistory.push(assistantMsg);
|
|
1032
|
+
await saveChatSidecar(sessionId, chatHistory);
|
|
1033
|
+
|
|
1034
|
+
send("done", JSON.stringify({}));
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
|
|
1037
|
+
} finally {
|
|
1038
|
+
controller.close();
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
return new Response(stream, {
|
|
1044
|
+
headers: {
|
|
1045
|
+
"Content-Type": "text/event-stream",
|
|
1046
|
+
"Cache-Control": "no-cache",
|
|
1047
|
+
"Connection": "keep-alive",
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
"GET /api/sessions/:id/cartoon": async (req: Request) => {
|
|
1053
|
+
const url = new URL(req.url);
|
|
1054
|
+
const segments = url.pathname.split("/");
|
|
1055
|
+
const id = segments[3]!;
|
|
1056
|
+
const cartoon = await loadCartoonSidecar(id);
|
|
1057
|
+
if (!cartoon) return json(null);
|
|
1058
|
+
return json(cartoon);
|
|
1059
|
+
},
|
|
1060
|
+
|
|
212
1061
|
"POST /api/cartoon": async (req: Request) => {
|
|
213
1062
|
if (!options.cartoon) return json({ error: "Cartoon mode not enabled. Start with --cartoon flag." }, 403);
|
|
214
1063
|
if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required for cartoon generation" }, 400);
|
|
@@ -226,18 +1075,11 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
226
1075
|
const result = await generateCartoon(config.openrouter_api_key, data, config.language);
|
|
227
1076
|
|
|
228
1077
|
if (sessionId) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
generatedAt: new Date().toISOString(),
|
|
235
|
-
};
|
|
236
|
-
const { join } = await import("node:path");
|
|
237
|
-
const { homedir } = await import("node:os");
|
|
238
|
-
const sessionsDir = join(homedir(), ".newpr", "history", "sessions");
|
|
239
|
-
await Bun.write(join(sessionsDir, `${sessionId}.json`), JSON.stringify(sessionData, null, 2));
|
|
240
|
-
}
|
|
1078
|
+
await saveCartoonSidecar(sessionId, {
|
|
1079
|
+
imageBase64: result.imageBase64,
|
|
1080
|
+
mimeType: result.mimeType,
|
|
1081
|
+
generatedAt: new Date().toISOString(),
|
|
1082
|
+
});
|
|
241
1083
|
}
|
|
242
1084
|
|
|
243
1085
|
return json(result);
|