newpr 0.6.2 → 0.6.4
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/client/App.tsx +14 -2
- package/src/web/client/components/AnalyticsConsent.tsx +88 -0
- package/src/web/client/components/AppShell.tsx +3 -2
- 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 +111 -0
- package/src/web/server/routes.ts +144 -92
package/package.json
CHANGED
package/src/cli/preflight.ts
CHANGED
|
@@ -62,16 +62,18 @@ async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export async function runPreflight(): Promise<PreflightResult> {
|
|
65
|
-
const [github, claude, opencode, codex] = await Promise.all([
|
|
65
|
+
const [github, claude, cursor, gemini, opencode, codex] = await Promise.all([
|
|
66
66
|
checkGithubCli(),
|
|
67
67
|
checkAgent("claude"),
|
|
68
|
+
checkAgent("cursor"),
|
|
69
|
+
checkAgent("gemini"),
|
|
68
70
|
checkAgent("opencode"),
|
|
69
71
|
checkAgent("codex"),
|
|
70
72
|
]);
|
|
71
73
|
|
|
72
74
|
return {
|
|
73
75
|
github,
|
|
74
|
-
agents: [claude, opencode, codex],
|
|
76
|
+
agents: [claude, cursor, gemini, opencode, codex],
|
|
75
77
|
openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
|
|
76
78
|
};
|
|
77
79
|
}
|
|
@@ -115,11 +117,14 @@ export function printPreflight(result: PreflightResult): void {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
const hasAgent = result.agents.some((a) => a.installed);
|
|
118
121
|
if (result.openrouterKey) {
|
|
119
122
|
console.log(` ${check} OpenRouter API key`);
|
|
123
|
+
} else if (hasAgent) {
|
|
124
|
+
console.log(` ${dim("·")} OpenRouter API key ${dim("· not configured (using agent as LLM fallback)")}`);
|
|
120
125
|
} else {
|
|
121
126
|
console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
|
|
122
|
-
console.log(` ${dim("run: newpr auth")}`);
|
|
127
|
+
console.log(` ${dim("run: newpr auth —or— install an agent (claude, gemini, etc.)")}`);
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
console.log("");
|
package/src/llm/client.ts
CHANGED
|
@@ -239,6 +239,10 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
|
|
|
239
239
|
return create(options.timeout);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
export function hasApiKey(options: LlmClientOptions): boolean {
|
|
243
|
+
return !!options.api_key;
|
|
244
|
+
}
|
|
245
|
+
|
|
242
246
|
export interface ChatTool {
|
|
243
247
|
type: "function";
|
|
244
248
|
function: {
|
package/src/web/client/App.tsx
CHANGED
|
@@ -14,6 +14,8 @@ import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
|
14
14
|
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
15
15
|
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
16
16
|
import { requestNotificationPermission } from "./lib/notify.ts";
|
|
17
|
+
import { analytics, initAnalytics, getConsent } from "./lib/analytics.ts";
|
|
18
|
+
import { AnalyticsConsent } from "./components/AnalyticsConsent.tsx";
|
|
17
19
|
|
|
18
20
|
function getUrlParam(key: string): string | null {
|
|
19
21
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -39,8 +41,12 @@ export function App() {
|
|
|
39
41
|
const features = useFeatures();
|
|
40
42
|
const bgAnalyses = useBackgroundAnalyses();
|
|
41
43
|
const initialLoadDone = useRef(false);
|
|
44
|
+
const [showConsent, setShowConsent] = useState(() => getConsent() === "pending");
|
|
42
45
|
|
|
43
|
-
useEffect(() => {
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
requestNotificationPermission();
|
|
48
|
+
initAnalytics();
|
|
49
|
+
}, []);
|
|
44
50
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
45
51
|
|
|
46
52
|
useEffect(() => {
|
|
@@ -73,6 +79,7 @@ export function App() {
|
|
|
73
79
|
|
|
74
80
|
const scrollGuardRef = useRef<number | null>(null);
|
|
75
81
|
const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
|
|
82
|
+
analytics.detailOpened(kind);
|
|
76
83
|
const key = `${kind}:${id}`;
|
|
77
84
|
const main = document.querySelector("main");
|
|
78
85
|
const savedScroll = main?.scrollTop ?? 0;
|
|
@@ -133,6 +140,10 @@ export function App() {
|
|
|
133
140
|
) : null;
|
|
134
141
|
|
|
135
142
|
const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
|
|
143
|
+
const handleTabChange = useCallback((tab: string) => {
|
|
144
|
+
analytics.tabChanged(tab);
|
|
145
|
+
setActiveTab(tab);
|
|
146
|
+
}, []);
|
|
136
147
|
const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
|
|
137
148
|
|
|
138
149
|
const anchorItems = useMemo<AnchorItem[]>(() => {
|
|
@@ -149,6 +160,7 @@ export function App() {
|
|
|
149
160
|
|
|
150
161
|
return (
|
|
151
162
|
<ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
|
|
163
|
+
{showConsent && <AnalyticsConsent onDone={() => setShowConsent(false)} />}
|
|
152
164
|
<AppShell
|
|
153
165
|
theme={themeCtx.theme}
|
|
154
166
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -187,7 +199,7 @@ export function App() {
|
|
|
187
199
|
onAnchorClick={handleAnchorClick}
|
|
188
200
|
cartoonEnabled={features.cartoon}
|
|
189
201
|
sessionId={diffSessionId}
|
|
190
|
-
onTabChange={
|
|
202
|
+
onTabChange={handleTabChange}
|
|
191
203
|
onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
|
|
192
204
|
enabledPlugins={features.enabledPlugins}
|
|
193
205
|
/>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { BarChart3, Shield } from "lucide-react";
|
|
3
|
+
import { getConsent, setConsent, type ConsentState } from "../lib/analytics.ts";
|
|
4
|
+
|
|
5
|
+
export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
|
|
6
|
+
const [state] = useState<ConsentState>(() => getConsent());
|
|
7
|
+
|
|
8
|
+
if (state !== "pending") return null;
|
|
9
|
+
|
|
10
|
+
const handleAccept = () => {
|
|
11
|
+
setConsent("granted");
|
|
12
|
+
onDone();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const handleDecline = () => {
|
|
16
|
+
setConsent("denied");
|
|
17
|
+
onDone();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
22
|
+
<div className="fixed inset-0 bg-background/70 backdrop-blur-sm" />
|
|
23
|
+
<div className="relative z-10 w-full max-w-md mx-4 rounded-2xl border bg-background shadow-2xl overflow-hidden">
|
|
24
|
+
<div className="px-6 pt-6 pb-4">
|
|
25
|
+
<div className="flex items-center gap-3 mb-4">
|
|
26
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
|
|
27
|
+
<BarChart3 className="h-5 w-5 text-blue-500" />
|
|
28
|
+
</div>
|
|
29
|
+
<div>
|
|
30
|
+
<h2 className="text-sm font-semibold">Help improve newpr</h2>
|
|
31
|
+
<p className="text-[11px] text-muted-foreground">Anonymous usage analytics</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<p className="text-xs text-muted-foreground leading-relaxed mb-3">
|
|
36
|
+
We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
|
|
40
|
+
<div className="flex items-start gap-2">
|
|
41
|
+
<Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
|
42
|
+
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
|
43
|
+
<p className="font-medium text-foreground/80 mb-1">What we collect:</p>
|
|
44
|
+
<ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
|
|
45
|
+
<li>Feature usage (which tabs, buttons, and actions you use)</li>
|
|
46
|
+
<li>Performance metrics (analysis duration, error rates)</li>
|
|
47
|
+
<li>Basic device info (browser, screen size)</li>
|
|
48
|
+
</ul>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex items-start gap-2 pt-1">
|
|
52
|
+
<Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
|
53
|
+
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
|
54
|
+
<p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
|
|
55
|
+
<ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
|
|
56
|
+
<li>PR content, code, or commit messages</li>
|
|
57
|
+
<li>Chat messages or review comments</li>
|
|
58
|
+
<li>API keys, tokens, or personal data</li>
|
|
59
|
+
</ul>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<p className="text-[10px] text-muted-foreground/50 mb-4">
|
|
65
|
+
Powered by Google Analytics. You can change this anytime in Settings.
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="flex border-t">
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={handleDecline}
|
|
73
|
+
className="flex-1 px-4 py-3 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
74
|
+
>
|
|
75
|
+
Decline
|
|
76
|
+
</button>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={handleAccept}
|
|
80
|
+
className="flex-1 px-4 py-3 text-xs font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
|
|
81
|
+
>
|
|
82
|
+
Accept
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { SessionRecord } from "../../../history/types.ts";
|
|
|
7
7
|
import type { GithubUser } from "../hooks/useGithubUser.ts";
|
|
8
8
|
import { SettingsPanel } from "./SettingsPanel.tsx";
|
|
9
9
|
import { ResizeHandle } from "./ResizeHandle.tsx";
|
|
10
|
+
import { analytics } from "../lib/analytics.ts";
|
|
10
11
|
|
|
11
12
|
type Theme = "light" | "dark" | "system";
|
|
12
13
|
|
|
@@ -276,7 +277,7 @@ export function AppShell({
|
|
|
276
277
|
<button
|
|
277
278
|
type="button"
|
|
278
279
|
disabled={update.updating}
|
|
279
|
-
onClick={update.doUpdate}
|
|
280
|
+
onClick={() => { analytics.updateClicked(); update.doUpdate(); }}
|
|
280
281
|
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-blue-500 hover:bg-blue-600 text-white text-[11px] font-medium py-1.5 transition-colors disabled:opacity-50"
|
|
281
282
|
>
|
|
282
283
|
{update.updating ? (
|
|
@@ -375,7 +376,7 @@ export function AppShell({
|
|
|
375
376
|
<div className="flex-1" />
|
|
376
377
|
<button
|
|
377
378
|
type="button"
|
|
378
|
-
onClick={() => setSettingsOpen(true)}
|
|
379
|
+
onClick={() => { analytics.settingsOpened(); setSettingsOpen(true); }}
|
|
379
380
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:bg-accent/40 hover:text-foreground transition-colors"
|
|
380
381
|
title="Settings"
|
|
381
382
|
>
|
|
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from "react";
|
|
|
2
2
|
import { X, Check, MessageSquare, Loader2, AlertCircle, ExternalLink } from "lucide-react";
|
|
3
3
|
import { TipTapEditor, getTextWithAnchors } from "./TipTapEditor.tsx";
|
|
4
4
|
import type { useEditor } from "@tiptap/react";
|
|
5
|
+
import { analytics } from "../lib/analytics.ts";
|
|
5
6
|
|
|
6
7
|
type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
|
|
7
8
|
|
|
@@ -53,6 +54,7 @@ export function ReviewModal({ prUrl, onClose }: ReviewModalProps) {
|
|
|
53
54
|
});
|
|
54
55
|
const data = await res.json() as { ok?: boolean; html_url?: string; error?: string };
|
|
55
56
|
if (data.ok) {
|
|
57
|
+
analytics.reviewSubmitted(event);
|
|
56
58
|
setResult({ ok: true, html_url: data.html_url });
|
|
57
59
|
} else {
|
|
58
60
|
setResult({ ok: false, error: data.error ?? "Failed to submit review" });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
|
|
3
|
+
import { analytics, getConsent, setConsent } from "../lib/analytics.ts";
|
|
3
4
|
|
|
4
5
|
interface ConfigData {
|
|
5
6
|
model: string;
|
|
@@ -69,6 +70,8 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
|
|
|
69
70
|
const save = useCallback(async (update: Record<string, unknown>) => {
|
|
70
71
|
setSaving(true);
|
|
71
72
|
setSaved(false);
|
|
73
|
+
const field = Object.keys(update).filter((k) => k !== "openrouter_api_key").join(",");
|
|
74
|
+
if (field) analytics.settingsChanged(field);
|
|
72
75
|
try {
|
|
73
76
|
await fetch("/api/config", {
|
|
74
77
|
method: "PUT",
|
|
@@ -265,11 +268,47 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
|
|
|
265
268
|
</div>
|
|
266
269
|
</Section>
|
|
267
270
|
)}
|
|
271
|
+
<Section title="Privacy">
|
|
272
|
+
<AnalyticsToggle />
|
|
273
|
+
</Section>
|
|
268
274
|
</div>
|
|
269
275
|
</div>
|
|
270
276
|
);
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
function AnalyticsToggle() {
|
|
280
|
+
const [consent, setLocal] = useState(() => getConsent());
|
|
281
|
+
const enabled = consent === "granted";
|
|
282
|
+
|
|
283
|
+
const toggle = () => {
|
|
284
|
+
const next = enabled ? "denied" : "granted";
|
|
285
|
+
setConsent(next);
|
|
286
|
+
setLocal(next);
|
|
287
|
+
analytics.settingsChanged("analytics");
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<Row label="Usage Analytics">
|
|
292
|
+
<div className="flex items-center gap-2">
|
|
293
|
+
<span className="text-[11px] text-muted-foreground/50">
|
|
294
|
+
{enabled ? "Enabled" : "Disabled"}
|
|
295
|
+
</span>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={toggle}
|
|
299
|
+
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
|
|
300
|
+
enabled ? "bg-foreground" : "bg-muted"
|
|
301
|
+
}`}
|
|
302
|
+
>
|
|
303
|
+
<span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
|
|
304
|
+
enabled ? "translate-x-3.5" : "translate-x-0.5"
|
|
305
|
+
}`} />
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</Row>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
273
312
|
function ModelSelect({ value, models: allModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
|
|
274
313
|
const [open, setOpen] = useState(false);
|
|
275
314
|
const [search, setSearch] = useState("");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import type { ProgressEvent } from "../../../analyzer/progress.ts";
|
|
3
3
|
import type { NewprOutput } from "../../../types/output.ts";
|
|
4
|
+
import { analytics } from "../lib/analytics.ts";
|
|
4
5
|
|
|
5
6
|
type Phase = "idle" | "loading" | "done" | "error";
|
|
6
7
|
|
|
@@ -29,6 +30,7 @@ export function useAnalysis() {
|
|
|
29
30
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
30
31
|
|
|
31
32
|
const start = useCallback(async (prInput: string) => {
|
|
33
|
+
analytics.analysisStarted(0);
|
|
32
34
|
setState({
|
|
33
35
|
phase: "loading",
|
|
34
36
|
sessionId: null,
|
|
@@ -79,12 +81,16 @@ export function useAnalysis() {
|
|
|
79
81
|
eventSourceRef.current = null;
|
|
80
82
|
const resultRes = await fetch(`/api/analysis/${sessionId}`);
|
|
81
83
|
const data = await resultRes.json() as { result?: NewprOutput; historyId?: string };
|
|
82
|
-
setState((s) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
setState((s) => {
|
|
85
|
+
const durationSec = s.startedAt ? Math.round((Date.now() - s.startedAt) / 1000) : 0;
|
|
86
|
+
analytics.analysisCompleted(data.result?.files.length ?? 0, durationSec);
|
|
87
|
+
return {
|
|
88
|
+
...s,
|
|
89
|
+
phase: "done",
|
|
90
|
+
result: data.result ?? null,
|
|
91
|
+
historyId: data.historyId ?? null,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
es.addEventListener("analysis_error", (e) => {
|
|
@@ -92,6 +98,7 @@ export function useAnalysis() {
|
|
|
92
98
|
eventSourceRef.current = null;
|
|
93
99
|
let msg = "Analysis failed";
|
|
94
100
|
try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
|
|
101
|
+
analytics.analysisError(msg.slice(0, 100));
|
|
95
102
|
setState((s) => ({ ...s, phase: "error", error: msg }));
|
|
96
103
|
});
|
|
97
104
|
|
|
@@ -125,6 +132,7 @@ export function useAnalysis() {
|
|
|
125
132
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
126
133
|
if (!res.ok) throw new Error("Session not found");
|
|
127
134
|
const data = await res.json() as NewprOutput;
|
|
135
|
+
analytics.sessionLoaded();
|
|
128
136
|
setState((s) => ({
|
|
129
137
|
...s,
|
|
130
138
|
phase: "done",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useCallback, useSyncExternalStore } from "react";
|
|
2
2
|
import { sendNotification } from "../lib/notify.ts";
|
|
3
|
+
import { analytics } from "../lib/analytics.ts";
|
|
3
4
|
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
4
5
|
|
|
5
6
|
interface ChatSessionState {
|
|
@@ -73,6 +74,7 @@ class ChatStore {
|
|
|
73
74
|
if (s.loading) return;
|
|
74
75
|
|
|
75
76
|
const startTime = Date.now();
|
|
77
|
+
analytics.chatSent();
|
|
76
78
|
const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
|
|
77
79
|
this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
|
|
78
80
|
|
|
@@ -152,6 +154,8 @@ class ChatStore {
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
const cur = this.getOrCreate(sessionId);
|
|
157
|
+
const durationMs = Date.now() - startTime;
|
|
158
|
+
analytics.chatCompleted(Math.round(durationMs / 1000), allToolCalls.length > 0);
|
|
155
159
|
this.update(sessionId, {
|
|
156
160
|
messages: [...cur.messages, {
|
|
157
161
|
role: "assistant",
|
|
@@ -159,7 +163,7 @@ class ChatStore {
|
|
|
159
163
|
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
|
|
160
164
|
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
161
165
|
timestamp: new Date().toISOString(),
|
|
162
|
-
durationMs
|
|
166
|
+
durationMs,
|
|
163
167
|
}],
|
|
164
168
|
});
|
|
165
169
|
sendNotification("Chat response ready", fullText.slice(0, 100));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { analytics } from "../lib/analytics.ts";
|
|
2
3
|
|
|
3
4
|
type Theme = "light" | "dark" | "system";
|
|
4
5
|
|
|
@@ -28,6 +29,7 @@ export function useTheme() {
|
|
|
28
29
|
const setTheme = useCallback((t: Theme) => {
|
|
29
30
|
localStorage.setItem("newpr-theme", t);
|
|
30
31
|
setThemeState(t);
|
|
32
|
+
analytics.themeChanged(t);
|
|
31
33
|
}, []);
|
|
32
34
|
|
|
33
35
|
return { theme, setTheme };
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
};
|
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",
|