newpr 0.6.2 → 0.6.5

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.
@@ -0,0 +1,114 @@
1
+ declare global {
2
+ interface Window {
3
+ gtag?: (...args: unknown[]) => void;
4
+ dataLayer?: unknown[];
5
+ }
6
+ }
7
+
8
+ const GA_ID = "G-L3SL6T6JQ1";
9
+ const CONSENT_KEY = "newpr-analytics-consent";
10
+
11
+ export type ConsentState = "granted" | "denied" | "pending";
12
+
13
+ export function getConsent(): ConsentState {
14
+ const stored = localStorage.getItem(CONSENT_KEY);
15
+ if (stored === "granted" || stored === "denied") return stored;
16
+ return "pending";
17
+ }
18
+
19
+ export function setConsent(state: "granted" | "denied"): void {
20
+ localStorage.setItem(CONSENT_KEY, state);
21
+ if (state === "granted") {
22
+ loadGA();
23
+ } else {
24
+ disableGA();
25
+ }
26
+ }
27
+
28
+ let gaLoaded = false;
29
+
30
+ function loadGA(): void {
31
+ if (gaLoaded) return;
32
+ gaLoaded = true;
33
+
34
+ const script = document.createElement("script");
35
+ script.async = true;
36
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
37
+ document.head.appendChild(script);
38
+
39
+ window.dataLayer = window.dataLayer || [];
40
+ window.gtag = function (...args: unknown[]) {
41
+ window.dataLayer!.push(args);
42
+ };
43
+ window.gtag("js", new Date());
44
+ window.gtag("config", GA_ID);
45
+ }
46
+
47
+ function disableGA(): void {
48
+ (window as unknown as Record<string, unknown>)[`ga-disable-${GA_ID}`] = true;
49
+ }
50
+
51
+ export function initAnalytics(): void {
52
+ if (getConsent() === "granted") {
53
+ loadGA();
54
+ }
55
+ }
56
+
57
+ function gtag(command: string, ...args: unknown[]): void {
58
+ if (getConsent() !== "granted") return;
59
+ window.gtag?.(command, ...args);
60
+ }
61
+
62
+ function trackEvent(name: string, params?: Record<string, string | number | boolean>): void {
63
+ gtag("event", name, params);
64
+ }
65
+
66
+ export const analytics = {
67
+ analysisStarted: (fileCount: number) =>
68
+ trackEvent("analysis_started", { file_count: fileCount }),
69
+
70
+ analysisCompleted: (fileCount: number, durationSec: number) =>
71
+ trackEvent("analysis_completed", { file_count: fileCount, duration_sec: durationSec }),
72
+
73
+ analysisError: (errorType: string) =>
74
+ trackEvent("analysis_error", { error_type: errorType }),
75
+
76
+ tabChanged: (tab: string) =>
77
+ trackEvent("tab_changed", { tab }),
78
+
79
+ chatSent: () =>
80
+ trackEvent("chat_sent"),
81
+
82
+ chatCompleted: (durationSec: number, hasTools: boolean) =>
83
+ trackEvent("chat_completed", { duration_sec: durationSec, has_tools: hasTools }),
84
+
85
+ detailOpened: (kind: string) =>
86
+ trackEvent("detail_opened", { kind }),
87
+
88
+ themeChanged: (theme: string) =>
89
+ trackEvent("theme_changed", { theme }),
90
+
91
+ settingsOpened: () =>
92
+ trackEvent("settings_opened"),
93
+
94
+ settingsChanged: (field: string) =>
95
+ trackEvent("settings_changed", { field }),
96
+
97
+ sessionLoaded: () =>
98
+ trackEvent("session_loaded"),
99
+
100
+ reviewSubmitted: (event: string) =>
101
+ trackEvent("review_submitted", { review_event: event }),
102
+
103
+ agentUsed: (agent: string) =>
104
+ trackEvent("agent_used", { agent }),
105
+
106
+ updateClicked: () =>
107
+ trackEvent("update_clicked"),
108
+
109
+ featureUsed: (feature: string) =>
110
+ trackEvent("feature_used", { feature }),
111
+
112
+ sponsorClicked: (sponsor: string) =>
113
+ trackEvent("sponsor_clicked", { sponsor }),
114
+ };
@@ -12,7 +12,7 @@ import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSession
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
13
  import { generateSlides } from "../../llm/slides.ts";
14
14
  import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
15
- import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
15
+ import { chatWithTools, createLlmClient, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
16
16
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
17
17
  import { randomBytes } from "node:crypto";
18
18
 
@@ -72,6 +72,33 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
72
72
  return { login: "anonymous" };
73
73
  }
74
74
 
75
+ function buildFallbackPrompt(
76
+ systemPrompt: string,
77
+ chatHistory: ChatMessage[],
78
+ patches?: Record<string, string> | null,
79
+ ): string {
80
+ const parts: string[] = [systemPrompt];
81
+
82
+ if (patches && Object.keys(patches).length > 0) {
83
+ const patchSummary = Object.entries(patches)
84
+ .map(([path, diff]) => `### ${path}\n\`\`\`diff\n${diff.slice(0, 3000)}\n\`\`\``)
85
+ .join("\n\n");
86
+ parts.push(`\n\n<file_diffs>\n${patchSummary}\n</file_diffs>`);
87
+ }
88
+
89
+ for (const msg of chatHistory) {
90
+ if (msg.isCompactSummary) {
91
+ parts.push(`\n[Conversation summary]: ${msg.content}`);
92
+ } else if (msg.role === "user") {
93
+ parts.push(`\nUser: ${msg.content}`);
94
+ } else if (msg.role === "assistant") {
95
+ parts.push(`\nAssistant: ${msg.content}`);
96
+ }
97
+ }
98
+
99
+ return parts.join("\n");
100
+ }
101
+
75
102
  interface SlideJob {
76
103
  status: "running" | "done" | "error";
77
104
  message: string;
@@ -580,6 +607,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
580
607
  const stored = await readStoredConfig();
581
608
  const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
582
609
  const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
610
+ const agents = await detectAgents();
583
611
  return json({
584
612
  model: config.model,
585
613
  agent: config.agent ?? null,
@@ -588,6 +616,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
588
616
  timeout: config.timeout,
589
617
  concurrency: config.concurrency,
590
618
  has_api_key: !!config.openrouter_api_key,
619
+ has_agent_fallback: agents.length > 0,
591
620
  has_github_token: !!token,
592
621
  enabled_plugins: enabledPlugins,
593
622
  available_plugins: pluginList,
@@ -885,10 +914,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
885
914
  const segments = url.pathname.split("/");
886
915
  const sessionId = segments[3]!;
887
916
 
888
- if (!config.openrouter_api_key) {
889
- return json({ error: "OpenRouter API key required" }, 400);
890
- }
891
-
892
917
  const body = await req.json() as { message: string };
893
918
  if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
894
919
 
@@ -908,31 +933,44 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
908
933
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
909
934
  };
910
935
  try {
911
- await chatWithTools(
912
- { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
913
- apiMessages as Parameters<typeof chatWithTools>[1],
914
- buildChatTools(),
915
- async (name: string, args: Record<string, unknown>): Promise<string> => {
916
- if (name === "get_file_diff") {
917
- const filePath = args.path as string;
918
- if (!filePath) return "Error: path required";
919
- const patches = await loadPatchesSidecar(sessionId);
920
- if (patches?.[filePath]) return patches[filePath];
921
- const patch = await loadSinglePatch(sessionId, filePath);
922
- if (patch) return patch;
923
- return `File "${filePath}" not found`;
924
- }
925
- if (name === "list_files") {
926
- return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
927
- }
928
- return `Tool ${name} not available in inline mode`;
929
- },
930
- (event: ChatStreamEvent) => {
931
- if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
932
- else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
933
- else if (event.type === "done") send("done", JSON.stringify({}));
934
- },
935
- );
936
+ if (config.openrouter_api_key) {
937
+ await chatWithTools(
938
+ { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
939
+ apiMessages as Parameters<typeof chatWithTools>[1],
940
+ buildChatTools(),
941
+ async (name: string, args: Record<string, unknown>): Promise<string> => {
942
+ if (name === "get_file_diff") {
943
+ const filePath = args.path as string;
944
+ if (!filePath) return "Error: path required";
945
+ const inlinePatches = await loadPatchesSidecar(sessionId);
946
+ if (inlinePatches?.[filePath]) return inlinePatches[filePath];
947
+ const patch = await loadSinglePatch(sessionId, filePath);
948
+ if (patch) return patch;
949
+ return `File "${filePath}" not found`;
950
+ }
951
+ if (name === "list_files") {
952
+ return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
953
+ }
954
+ return `Tool ${name} not available in inline mode`;
955
+ },
956
+ (event: ChatStreamEvent) => {
957
+ if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
958
+ else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
959
+ else if (event.type === "done") send("done", JSON.stringify({}));
960
+ },
961
+ );
962
+ } else {
963
+ const llm = createLlmClient({ api_key: "", model: config.model, timeout: config.timeout });
964
+ const inlinePatches = await loadPatchesSidecar(sessionId);
965
+ const fallbackPrompt = buildFallbackPrompt(systemPrompt, [{ role: "user", content: body.message.trim(), timestamp: new Date().toISOString() }], inlinePatches);
966
+ await llm.completeStream(
967
+ "You are a helpful PR review assistant. Answer based on the provided context.",
968
+ fallbackPrompt,
969
+ (chunk: string) => {
970
+ send("text", JSON.stringify({ content: chunk }));
971
+ },
972
+ );
973
+ }
936
974
  send("done", JSON.stringify({}));
937
975
  } catch (err) {
938
976
  send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
@@ -1010,10 +1048,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1010
1048
  const segments = url.pathname.split("/");
1011
1049
  const sessionId = segments[3]!;
1012
1050
 
1013
- if (!config.openrouter_api_key) {
1014
- return json({ error: "OpenRouter API key required for chat" }, 400);
1015
- }
1016
-
1017
1051
  const body = await req.json() as { message: string };
1018
1052
  if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
1019
1053
 
@@ -1046,7 +1080,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1046
1080
 
1047
1081
  try {
1048
1082
  const compactPrompt = `Summarize the following conversation concisely for continuation. Focus on: what was discussed, key decisions made, actions taken (tool calls and their outcomes), and any unresolved topics. Be thorough but concise.\n\n${summaryLines.join("\n")}`;
1049
- const { createLlmClient } = require("../../llm/client.ts") as typeof import("../../llm/client.ts");
1050
1083
  const llm = createLlmClient({ api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout });
1051
1084
  const result = await llm.complete("You are a conversation summarizer. Output a concise summary.", compactPrompt);
1052
1085
 
@@ -1377,64 +1410,83 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1377
1410
  let lastSegmentWasText = false;
1378
1411
 
1379
1412
  try {
1380
- await chatWithTools(
1381
- {
1382
- api_key: config.openrouter_api_key,
1383
- model: config.model,
1384
- timeout: config.timeout,
1385
- },
1386
- apiMessages as Parameters<typeof chatWithTools>[1],
1387
- chatTools,
1388
- executeTool,
1389
- (event: ChatStreamEvent) => {
1390
- switch (event.type) {
1391
- case "text":
1392
- fullText += event.content ?? "";
1393
- if (lastSegmentWasText && orderedSegments.length > 0) {
1394
- const last = orderedSegments[orderedSegments.length - 1]!;
1395
- if (last.type === "text") {
1396
- last.content += event.content ?? "";
1413
+ if (config.openrouter_api_key) {
1414
+ await chatWithTools(
1415
+ {
1416
+ api_key: config.openrouter_api_key,
1417
+ model: config.model,
1418
+ timeout: config.timeout,
1419
+ },
1420
+ apiMessages as Parameters<typeof chatWithTools>[1],
1421
+ chatTools,
1422
+ executeTool,
1423
+ (event: ChatStreamEvent) => {
1424
+ switch (event.type) {
1425
+ case "text":
1426
+ fullText += event.content ?? "";
1427
+ if (lastSegmentWasText && orderedSegments.length > 0) {
1428
+ const last = orderedSegments[orderedSegments.length - 1]!;
1429
+ if (last.type === "text") {
1430
+ last.content += event.content ?? "";
1431
+ }
1432
+ } else {
1433
+ orderedSegments.push({ type: "text", content: event.content ?? "" });
1434
+ lastSegmentWasText = true;
1397
1435
  }
1398
- } else {
1399
- orderedSegments.push({ type: "text", content: event.content ?? "" });
1400
- lastSegmentWasText = true;
1401
- }
1402
- send("text", JSON.stringify({ content: event.content }));
1403
- break;
1404
- case "tool_call":
1405
- if (event.toolCall) {
1406
- let args: Record<string, unknown> = {};
1407
- try { args = JSON.parse(event.toolCall.arguments); } catch {}
1408
- const tc: ChatToolCall = {
1409
- id: event.toolCall.id,
1410
- name: event.toolCall.name,
1411
- arguments: args,
1412
- };
1413
- collectedToolCalls.push(tc);
1414
- orderedSegments.push({ type: "tool_call", toolCall: tc });
1415
- lastSegmentWasText = false;
1416
- send("tool_call", JSON.stringify({
1417
- id: event.toolCall.id,
1418
- name: event.toolCall.name,
1419
- arguments: args,
1420
- }));
1421
- }
1422
- break;
1423
- case "tool_result":
1424
- if (event.toolResult) {
1425
- const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
1426
- if (tc) tc.result = event.toolResult.result;
1427
- send("tool_result", JSON.stringify(event.toolResult));
1428
- }
1429
- break;
1430
- case "error":
1431
- send("chat_error", JSON.stringify({ message: event.error }));
1432
- break;
1433
- case "done":
1434
- break;
1435
- }
1436
- },
1437
- );
1436
+ send("text", JSON.stringify({ content: event.content }));
1437
+ break;
1438
+ case "tool_call":
1439
+ if (event.toolCall) {
1440
+ let args: Record<string, unknown> = {};
1441
+ try { args = JSON.parse(event.toolCall.arguments); } catch {}
1442
+ const tc: ChatToolCall = {
1443
+ id: event.toolCall.id,
1444
+ name: event.toolCall.name,
1445
+ arguments: args,
1446
+ };
1447
+ collectedToolCalls.push(tc);
1448
+ orderedSegments.push({ type: "tool_call", toolCall: tc });
1449
+ lastSegmentWasText = false;
1450
+ send("tool_call", JSON.stringify({
1451
+ id: event.toolCall.id,
1452
+ name: event.toolCall.name,
1453
+ arguments: args,
1454
+ }));
1455
+ }
1456
+ break;
1457
+ case "tool_result":
1458
+ if (event.toolResult) {
1459
+ const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
1460
+ if (tc) tc.result = event.toolResult.result;
1461
+ send("tool_result", JSON.stringify(event.toolResult));
1462
+ }
1463
+ break;
1464
+ case "error":
1465
+ send("chat_error", JSON.stringify({ message: event.error }));
1466
+ break;
1467
+ case "done":
1468
+ break;
1469
+ }
1470
+ },
1471
+ );
1472
+ } else {
1473
+ const llm = createLlmClient({ api_key: "", model: config.model, timeout: config.timeout });
1474
+ const prompt = buildFallbackPrompt(
1475
+ systemPrompt,
1476
+ chatHistory,
1477
+ patches,
1478
+ );
1479
+ const result = await llm.completeStream(
1480
+ "You are a helpful PR review assistant. Answer based on the provided context.",
1481
+ prompt,
1482
+ (chunk: string) => {
1483
+ fullText += chunk;
1484
+ send("text", JSON.stringify({ content: chunk }));
1485
+ },
1486
+ );
1487
+ fullText = result.content;
1488
+ orderedSegments.push({ type: "text", content: fullText });
1489
+ }
1438
1490
 
1439
1491
  const assistantMsg: ChatMessage = {
1440
1492
  role: "assistant",
package/src/web/server.ts CHANGED
@@ -84,6 +84,20 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
84
84
  const url = new URL(req.url);
85
85
  const path = url.pathname;
86
86
 
87
+ if (path.startsWith("/assets/")) {
88
+ const webDir = dirname(Bun.resolveSync("./src/web/index.html", process.cwd()));
89
+ const filePath = join(webDir, path);
90
+ const file = Bun.file(filePath);
91
+ return file.exists().then((exists) => {
92
+ if (exists) {
93
+ return new Response(file, {
94
+ headers: { "cache-control": "public, max-age=31536000, immutable" },
95
+ });
96
+ }
97
+ return new Response("Not Found", { status: 404 });
98
+ });
99
+ }
100
+
87
101
  if (path.match(/^\/api\/analysis\/[^/]+\/events$/) && req.method === "GET") {
88
102
  return routes["GET /api/analysis/:id/events"](req);
89
103
  }