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.
- package/package.json +1 -1
- package/src/cli/preflight.ts +8 -3
- package/src/llm/client.ts +4 -0
- package/src/web/assets/sionic-hero-bg.png +0 -0
- package/src/web/assets/sionic-logo.png +0 -0
- package/src/web/client/App.tsx +14 -2
- package/src/web/client/components/AnalyticsConsent.tsx +88 -0
- package/src/web/client/components/AppShell.tsx +5 -2
- package/src/web/client/components/InputScreen.tsx +111 -71
- package/src/web/client/components/ReviewModal.tsx +2 -0
- package/src/web/client/components/SettingsPanel.tsx +39 -0
- package/src/web/client/hooks/useAnalysis.ts +14 -6
- package/src/web/client/hooks/useChatStore.ts +5 -1
- package/src/web/client/hooks/useTheme.ts +2 -0
- package/src/web/client/lib/analytics.ts +114 -0
- package/src/web/server/routes.ts +144 -92
- package/src/web/server.ts +14 -0
- package/src/web/styles/built.css +1 -1
|
@@ -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
|
+
};
|
package/src/web/server/routes.ts
CHANGED
|
@@ -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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
last.
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
send("
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
}
|