newpr 0.3.0 → 0.4.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/README.md +135 -103
- package/package.json +1 -1
- package/src/analyzer/pipeline.ts +0 -4
- package/src/cli/args.ts +1 -1
- package/src/cli/index.ts +2 -1
- package/src/llm/prompts.ts +82 -27
- package/src/types/config.ts +1 -1
- package/src/version.ts +23 -0
- package/src/web/client/App.tsx +46 -0
- package/src/web/client/components/AppShell.tsx +173 -45
- package/src/web/client/components/ChatSection.tsx +2 -170
- package/src/web/client/components/DetailPane.tsx +1 -0
- package/src/web/client/components/DiffViewer.tsx +13 -1
- package/src/web/client/components/InputScreen.tsx +3 -0
- package/src/web/client/components/Markdown.tsx +66 -16
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/hooks/useBackgroundAnalyses.ts +147 -0
- package/src/web/client/hooks/useChatStore.ts +244 -0
- package/src/web/client/hooks/useFeatures.ts +2 -1
- package/src/web/index.html +1 -0
- package/src/web/server/routes.ts +41 -2
- package/src/web/server/session-manager.ts +34 -0
- package/src/web/server.ts +5 -1
- package/src/web/styles/built.css +1 -1
- package/src/workspace/explore.ts +39 -6
- package/src/workspace/types.ts +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import type { ProgressEvent } from "../../../analyzer/progress.ts";
|
|
3
|
+
import type { NewprOutput } from "../../../types/output.ts";
|
|
4
|
+
|
|
5
|
+
export type BgStatus = "running" | "done" | "error";
|
|
6
|
+
|
|
7
|
+
export interface BackgroundAnalysis {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
prInput: string;
|
|
10
|
+
prTitle?: string;
|
|
11
|
+
prNumber?: number;
|
|
12
|
+
status: BgStatus;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
lastStage?: string;
|
|
15
|
+
lastMessage?: string;
|
|
16
|
+
result?: NewprOutput;
|
|
17
|
+
historyId?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useBackgroundAnalyses() {
|
|
22
|
+
const [analyses, setAnalyses] = useState<BackgroundAnalysis[]>([]);
|
|
23
|
+
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
|
24
|
+
const restoredRef = useRef(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (restoredRef.current) return;
|
|
28
|
+
restoredRef.current = true;
|
|
29
|
+
fetch("/api/active-analyses")
|
|
30
|
+
.then((r) => r.json())
|
|
31
|
+
.then((data) => {
|
|
32
|
+
const active = data as Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
prInput: string;
|
|
35
|
+
status: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
prTitle?: string;
|
|
38
|
+
prNumber?: number;
|
|
39
|
+
lastStage?: string;
|
|
40
|
+
lastMessage?: string;
|
|
41
|
+
}>;
|
|
42
|
+
for (const a of active) {
|
|
43
|
+
if (!eventSourcesRef.current.has(a.id)) {
|
|
44
|
+
trackInternal(a.id, a.prInput, a.prTitle, a.prNumber, a.lastMessage);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {});
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const trackInternal = useCallback((sessionId: string, prInput: string, initTitle?: string, initNumber?: number, initMessage?: string) => {
|
|
52
|
+
if (eventSourcesRef.current.has(sessionId)) return;
|
|
53
|
+
|
|
54
|
+
const entry: BackgroundAnalysis = {
|
|
55
|
+
sessionId,
|
|
56
|
+
prInput,
|
|
57
|
+
status: "running",
|
|
58
|
+
startedAt: Date.now(),
|
|
59
|
+
prTitle: initTitle,
|
|
60
|
+
prNumber: initNumber,
|
|
61
|
+
lastMessage: initMessage,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
setAnalyses((prev) => [...prev.filter((a) => a.sessionId !== sessionId), entry]);
|
|
65
|
+
|
|
66
|
+
const es = new EventSource(`/api/analysis/${sessionId}/events`);
|
|
67
|
+
eventSourcesRef.current.set(sessionId, es);
|
|
68
|
+
|
|
69
|
+
es.addEventListener("progress", (e) => {
|
|
70
|
+
const event = JSON.parse(e.data) as ProgressEvent;
|
|
71
|
+
setAnalyses((prev) =>
|
|
72
|
+
prev.map((a) =>
|
|
73
|
+
a.sessionId === sessionId
|
|
74
|
+
? {
|
|
75
|
+
...a,
|
|
76
|
+
lastStage: event.stage,
|
|
77
|
+
lastMessage: event.message,
|
|
78
|
+
prTitle: event.pr_title ?? a.prTitle,
|
|
79
|
+
prNumber: event.pr_number ?? a.prNumber,
|
|
80
|
+
}
|
|
81
|
+
: a,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
es.addEventListener("done", async () => {
|
|
87
|
+
es.close();
|
|
88
|
+
eventSourcesRef.current.delete(sessionId);
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`/api/analysis/${sessionId}`);
|
|
91
|
+
const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
|
|
92
|
+
setAnalyses((prev) =>
|
|
93
|
+
prev.map((a) =>
|
|
94
|
+
a.sessionId === sessionId
|
|
95
|
+
? { ...a, status: "done", result: data.result, historyId: data.historyId }
|
|
96
|
+
: a,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
setAnalyses((prev) =>
|
|
101
|
+
prev.map((a) =>
|
|
102
|
+
a.sessionId === sessionId ? { ...a, status: "done" } : a,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
es.addEventListener("analysis_error", (e) => {
|
|
109
|
+
es.close();
|
|
110
|
+
eventSourcesRef.current.delete(sessionId);
|
|
111
|
+
let msg = "Analysis failed";
|
|
112
|
+
try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
|
|
113
|
+
setAnalyses((prev) =>
|
|
114
|
+
prev.map((a) =>
|
|
115
|
+
a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
es.onerror = () => {
|
|
121
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
122
|
+
eventSourcesRef.current.delete(sessionId);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const dismiss = useCallback((sessionId: string) => {
|
|
128
|
+
const es = eventSourcesRef.current.get(sessionId);
|
|
129
|
+
if (es) {
|
|
130
|
+
es.close();
|
|
131
|
+
eventSourcesRef.current.delete(sessionId);
|
|
132
|
+
}
|
|
133
|
+
setAnalyses((prev) => prev.filter((a) => a.sessionId !== sessionId));
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
return () => {
|
|
138
|
+
for (const es of eventSourcesRef.current.values()) es.close();
|
|
139
|
+
};
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const track = useCallback((sessionId: string, prInput: string) => {
|
|
143
|
+
trackInternal(sessionId, prInput);
|
|
144
|
+
}, [trackInternal]);
|
|
145
|
+
|
|
146
|
+
return { analyses, track, dismiss };
|
|
147
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useEffect, useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
3
|
+
|
|
4
|
+
interface ChatSessionState {
|
|
5
|
+
messages: ChatMessage[];
|
|
6
|
+
loading: boolean;
|
|
7
|
+
streaming: { segments: ChatSegment[]; activeToolName?: string } | null;
|
|
8
|
+
loaded: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Listener = () => void;
|
|
12
|
+
|
|
13
|
+
class ChatStore {
|
|
14
|
+
private sessions = new Map<string, ChatSessionState>();
|
|
15
|
+
private listeners = new Set<Listener>();
|
|
16
|
+
private abortControllers = new Map<string, AbortController>();
|
|
17
|
+
|
|
18
|
+
private getOrCreate(sessionId: string): ChatSessionState {
|
|
19
|
+
let s = this.sessions.get(sessionId);
|
|
20
|
+
if (!s) {
|
|
21
|
+
s = { messages: [], loading: false, streaming: null, loaded: false };
|
|
22
|
+
this.sessions.set(sessionId, s);
|
|
23
|
+
}
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private notify() {
|
|
28
|
+
for (const l of this.listeners) l();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
subscribe(listener: Listener): () => void {
|
|
32
|
+
this.listeners.add(listener);
|
|
33
|
+
return () => this.listeners.delete(listener);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getState(sessionId: string): ChatSessionState | null {
|
|
37
|
+
return this.sessions.get(sessionId) ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isLoading(sessionId: string): boolean {
|
|
41
|
+
return this.sessions.get(sessionId)?.loading ?? false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getLoadingSessions(): Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> {
|
|
45
|
+
const result: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = [];
|
|
46
|
+
for (const [id, s] of this.sessions) {
|
|
47
|
+
if (s.loading) result.push({ sessionId: id, streaming: s.streaming });
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async loadHistory(sessionId: string): Promise<void> {
|
|
53
|
+
const s = this.getOrCreate(sessionId);
|
|
54
|
+
if (s.loaded) return;
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`/api/sessions/${sessionId}/chat`);
|
|
57
|
+
const data = await res.json() as ChatMessage[];
|
|
58
|
+
s.messages = data;
|
|
59
|
+
s.loaded = true;
|
|
60
|
+
} catch {
|
|
61
|
+
s.loaded = true;
|
|
62
|
+
}
|
|
63
|
+
this.notify();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async sendMessage(sessionId: string, text: string): Promise<void> {
|
|
67
|
+
const s = this.getOrCreate(sessionId);
|
|
68
|
+
if (s.loading) return;
|
|
69
|
+
|
|
70
|
+
const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
|
|
71
|
+
s.messages = [...s.messages, userMsg];
|
|
72
|
+
s.loading = true;
|
|
73
|
+
s.streaming = { segments: [] };
|
|
74
|
+
this.notify();
|
|
75
|
+
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
this.abortControllers.set(sessionId, controller);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`/api/sessions/${sessionId}/chat`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ message: text }),
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const err = await res.json() as { error?: string };
|
|
89
|
+
throw new Error(err.error ?? `HTTP ${res.status}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reader = res.body!.getReader();
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
let buffer = "";
|
|
95
|
+
let fullText = "";
|
|
96
|
+
const orderedSegments: ChatSegment[] = [];
|
|
97
|
+
const allToolCalls: ChatToolCall[] = [];
|
|
98
|
+
let pendingEvent = "";
|
|
99
|
+
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) break;
|
|
103
|
+
|
|
104
|
+
buffer += decoder.decode(value, { stream: true });
|
|
105
|
+
const lines = buffer.split("\n");
|
|
106
|
+
buffer = lines.pop() ?? "";
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed) { pendingEvent = ""; continue; }
|
|
111
|
+
if (trimmed.startsWith("event: ")) { pendingEvent = trimmed.slice(7); continue; }
|
|
112
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const data = JSON.parse(trimmed.slice(6));
|
|
116
|
+
switch (pendingEvent) {
|
|
117
|
+
case "text": {
|
|
118
|
+
fullText += data.content ?? "";
|
|
119
|
+
const lastSeg = orderedSegments[orderedSegments.length - 1];
|
|
120
|
+
if (lastSeg && lastSeg.type === "text") {
|
|
121
|
+
lastSeg.content += data.content ?? "";
|
|
122
|
+
} else {
|
|
123
|
+
orderedSegments.push({ type: "text", content: data.content ?? "" });
|
|
124
|
+
}
|
|
125
|
+
s.streaming = { segments: [...orderedSegments] };
|
|
126
|
+
this.notify();
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "tool_call": {
|
|
130
|
+
const tc: ChatToolCall = { id: data.id, name: data.name, arguments: data.arguments ?? {} };
|
|
131
|
+
allToolCalls.push(tc);
|
|
132
|
+
orderedSegments.push({ type: "tool_call", toolCall: tc });
|
|
133
|
+
s.streaming = { segments: [...orderedSegments], activeToolName: data.name };
|
|
134
|
+
this.notify();
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "tool_result": {
|
|
138
|
+
const tc = allToolCalls.find((c) => c.id === data.id);
|
|
139
|
+
if (tc) tc.result = data.result;
|
|
140
|
+
s.streaming = { segments: [...orderedSegments] };
|
|
141
|
+
this.notify();
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "done": break;
|
|
145
|
+
case "chat_error": throw new Error(data.message ?? "Chat error");
|
|
146
|
+
}
|
|
147
|
+
} catch (parseErr) {
|
|
148
|
+
if (parseErr instanceof Error && parseErr.message === "Chat error") throw parseErr;
|
|
149
|
+
}
|
|
150
|
+
pendingEvent = "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
s.messages = [...s.messages, {
|
|
155
|
+
role: "assistant",
|
|
156
|
+
content: fullText,
|
|
157
|
+
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
|
|
158
|
+
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
}];
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if ((err as Error).name !== "AbortError") {
|
|
163
|
+
s.messages = [...s.messages, {
|
|
164
|
+
role: "assistant",
|
|
165
|
+
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
+
timestamp: new Date().toISOString(),
|
|
167
|
+
}];
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
s.loading = false;
|
|
171
|
+
s.streaming = null;
|
|
172
|
+
this.abortControllers.delete(sessionId);
|
|
173
|
+
this.notify();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async undo(sessionId: string): Promise<void> {
|
|
178
|
+
const s = this.getOrCreate(sessionId);
|
|
179
|
+
const lastAssistantIdx = s.messages.findLastIndex((m) => m.role === "assistant");
|
|
180
|
+
if (lastAssistantIdx === -1) return;
|
|
181
|
+
const lastUserIdx = s.messages.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
|
|
182
|
+
const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
|
|
183
|
+
s.messages = s.messages.slice(0, removeFrom);
|
|
184
|
+
this.notify();
|
|
185
|
+
await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const chatStore = new ChatStore();
|
|
190
|
+
|
|
191
|
+
const subscribeFn = (cb: () => void) => chatStore.subscribe(cb);
|
|
192
|
+
|
|
193
|
+
const EMPTY_STATE: ChatSessionState = { messages: [], loading: false, streaming: null, loaded: false };
|
|
194
|
+
const EMPTY_LOADING: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = [];
|
|
195
|
+
|
|
196
|
+
export function useChatStore(sessionId?: string | null) {
|
|
197
|
+
const stableId = sessionId ?? "";
|
|
198
|
+
|
|
199
|
+
const getSnapshot = useCallback(
|
|
200
|
+
() => (stableId ? chatStore.getState(stableId) : null) ?? EMPTY_STATE,
|
|
201
|
+
[stableId],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const state = useSyncExternalStore(subscribeFn, getSnapshot);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (stableId) chatStore.loadHistory(stableId);
|
|
208
|
+
}, [stableId]);
|
|
209
|
+
|
|
210
|
+
const sendMessage = useCallback((text?: string) => {
|
|
211
|
+
const msg = text?.trim();
|
|
212
|
+
if (!stableId || !msg) return;
|
|
213
|
+
if (msg.replace(/\n/g, "").trim() === "/undo") {
|
|
214
|
+
chatStore.undo(stableId);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
chatStore.sendMessage(stableId, msg);
|
|
218
|
+
}, [stableId]);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
messages: state.messages,
|
|
222
|
+
loading: state.loading,
|
|
223
|
+
streaming: state.streaming,
|
|
224
|
+
loaded: state.loaded,
|
|
225
|
+
sendMessage,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let lastLoadingSnapshot: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = EMPTY_LOADING;
|
|
230
|
+
|
|
231
|
+
function getLoadingSnapshot() {
|
|
232
|
+
const current = chatStore.getLoadingSessions();
|
|
233
|
+
if (current.length === 0 && lastLoadingSnapshot.length === 0) return lastLoadingSnapshot;
|
|
234
|
+
if (
|
|
235
|
+
current.length === lastLoadingSnapshot.length &&
|
|
236
|
+
current.every((c, i) => c.sessionId === lastLoadingSnapshot[i]?.sessionId)
|
|
237
|
+
) return lastLoadingSnapshot;
|
|
238
|
+
lastLoadingSnapshot = current;
|
|
239
|
+
return lastLoadingSnapshot;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function useChatLoadingIndicator(): Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> {
|
|
243
|
+
return useSyncExternalStore(subscribeFn, getLoadingSnapshot);
|
|
244
|
+
}
|
|
@@ -2,10 +2,11 @@ import { useState, useEffect } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface Features {
|
|
4
4
|
cartoon: boolean;
|
|
5
|
+
version: string;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export function useFeatures(): Features {
|
|
8
|
-
const [features, setFeatures] = useState<Features>({ cartoon: false });
|
|
9
|
+
const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
|
|
9
10
|
|
|
10
11
|
useEffect(() => {
|
|
11
12
|
fetch("/api/features")
|
package/src/web/index.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>newpr</title>
|
|
7
7
|
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.28/dist/katex.min.css" crossorigin />
|
|
8
9
|
<script>document.head.appendChild(Object.assign(document.createElement("link"),{rel:"stylesheet",href:"/styles.css"}))</script>
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
package/src/web/server/routes.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
|
|
|
8
8
|
import { parseDiff } from "../../diff/parser.ts";
|
|
9
9
|
import { parsePrInput } from "../../github/parse-pr.ts";
|
|
10
10
|
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
11
|
-
import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
|
|
11
|
+
import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
|
|
12
12
|
import { generateCartoon } from "../../llm/cartoon.ts";
|
|
13
13
|
import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
14
14
|
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
@@ -182,6 +182,14 @@ $$
|
|
|
182
182
|
parameters: { type: "object", properties: {} },
|
|
183
183
|
},
|
|
184
184
|
},
|
|
185
|
+
{
|
|
186
|
+
type: "function",
|
|
187
|
+
function: {
|
|
188
|
+
name: "run_react_doctor",
|
|
189
|
+
description: "Run react-doctor on the PR's codebase to get a React code quality score (0-100) and diagnostics for security, performance, correctness, and architecture issues. Only useful for React/JSX/TSX projects.",
|
|
190
|
+
parameters: { type: "object", properties: {} },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
185
193
|
{
|
|
186
194
|
type: "function",
|
|
187
195
|
function: {
|
|
@@ -526,7 +534,8 @@ $$
|
|
|
526
534
|
},
|
|
527
535
|
|
|
528
536
|
"GET /api/features": () => {
|
|
529
|
-
|
|
537
|
+
const { getVersion } = require("../../version.ts");
|
|
538
|
+
return json({ cartoon: !!options.cartoon, version: getVersion() });
|
|
530
539
|
},
|
|
531
540
|
|
|
532
541
|
"POST /api/review": async (req: Request) => {
|
|
@@ -564,6 +573,10 @@ $$
|
|
|
564
573
|
return json(options.preflight ?? null);
|
|
565
574
|
},
|
|
566
575
|
|
|
576
|
+
"GET /api/active-analyses": () => {
|
|
577
|
+
return json(listActiveSessions());
|
|
578
|
+
},
|
|
579
|
+
|
|
567
580
|
"GET /api/sessions/:id/comments": async (req: Request) => {
|
|
568
581
|
const url = new URL(req.url);
|
|
569
582
|
const segments = url.pathname.split("/");
|
|
@@ -875,6 +888,32 @@ $$
|
|
|
875
888
|
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
876
889
|
}
|
|
877
890
|
}
|
|
891
|
+
case "run_react_doctor": {
|
|
892
|
+
const agents = await detectAgents();
|
|
893
|
+
if (agents.length > 0) {
|
|
894
|
+
try {
|
|
895
|
+
const result = await runAgent(
|
|
896
|
+
agents[0]!,
|
|
897
|
+
process.cwd(),
|
|
898
|
+
"Run react-doctor on this project:\n\nnpx -y react-doctor@latest . --verbose\n\nReturn the FULL output including the score and all diagnostics.",
|
|
899
|
+
{ timeout: 60_000 },
|
|
900
|
+
);
|
|
901
|
+
if (result.answer.trim()) return result.answer;
|
|
902
|
+
} catch {}
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
const proc = Bun.spawn(["npx", "-y", "react-doctor@latest", ".", "--verbose"], {
|
|
906
|
+
cwd: process.cwd(),
|
|
907
|
+
stdout: "pipe",
|
|
908
|
+
stderr: "pipe",
|
|
909
|
+
});
|
|
910
|
+
const output = await new Response(proc.stdout).text();
|
|
911
|
+
const stderr = await new Response(proc.stderr).text();
|
|
912
|
+
return output.trim() || stderr.trim() || "react-doctor produced no output";
|
|
913
|
+
} catch (err) {
|
|
914
|
+
return `Error running react-doctor: ${err instanceof Error ? err.message : String(err)}`;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
878
917
|
case "web_search": {
|
|
879
918
|
const query = args.query as string;
|
|
880
919
|
if (!query) return "Error: query argument required";
|
|
@@ -9,6 +9,7 @@ type SessionStatus = "running" | "done" | "error" | "canceled";
|
|
|
9
9
|
|
|
10
10
|
interface AnalysisSession {
|
|
11
11
|
id: string;
|
|
12
|
+
prInput: string;
|
|
12
13
|
status: SessionStatus;
|
|
13
14
|
events: ProgressEvent[];
|
|
14
15
|
result?: NewprOutput;
|
|
@@ -16,6 +17,8 @@ interface AnalysisSession {
|
|
|
16
17
|
error?: string;
|
|
17
18
|
startedAt: number;
|
|
18
19
|
finishedAt?: number;
|
|
20
|
+
prTitle?: string;
|
|
21
|
+
prNumber?: number;
|
|
19
22
|
abortController: AbortController;
|
|
20
23
|
subscribers: Set<(event: ProgressEvent | { type: "done" | "error"; data?: string }) => void>;
|
|
21
24
|
}
|
|
@@ -53,6 +56,7 @@ export function startAnalysis(
|
|
|
53
56
|
|
|
54
57
|
const session: AnalysisSession = {
|
|
55
58
|
id,
|
|
59
|
+
prInput,
|
|
56
60
|
status: "running",
|
|
57
61
|
events: [],
|
|
58
62
|
startedAt: Date.now(),
|
|
@@ -84,6 +88,8 @@ async function runPipeline(
|
|
|
84
88
|
onProgress: (event: ProgressEvent) => {
|
|
85
89
|
const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
|
|
86
90
|
session.events.push(stamped);
|
|
91
|
+
if (event.pr_title) session.prTitle = event.pr_title;
|
|
92
|
+
if (event.pr_number) session.prNumber = event.pr_number;
|
|
87
93
|
for (const sub of session.subscribers) {
|
|
88
94
|
sub(stamped);
|
|
89
95
|
}
|
|
@@ -129,6 +135,34 @@ export function cancelAnalysis(id: string): boolean {
|
|
|
129
135
|
return true;
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
export function listActiveSessions(): Array<{
|
|
139
|
+
id: string;
|
|
140
|
+
prInput: string;
|
|
141
|
+
status: SessionStatus;
|
|
142
|
+
startedAt: number;
|
|
143
|
+
prTitle?: string;
|
|
144
|
+
prNumber?: number;
|
|
145
|
+
lastStage?: string;
|
|
146
|
+
lastMessage?: string;
|
|
147
|
+
}> {
|
|
148
|
+
const result: ReturnType<typeof listActiveSessions> = [];
|
|
149
|
+
for (const s of sessions.values()) {
|
|
150
|
+
if (s.status !== "running") continue;
|
|
151
|
+
const lastEvent = s.events[s.events.length - 1];
|
|
152
|
+
result.push({
|
|
153
|
+
id: s.id,
|
|
154
|
+
prInput: s.prInput,
|
|
155
|
+
status: s.status,
|
|
156
|
+
startedAt: s.startedAt,
|
|
157
|
+
prTitle: s.prTitle,
|
|
158
|
+
prNumber: s.prNumber,
|
|
159
|
+
lastStage: lastEvent?.stage,
|
|
160
|
+
lastMessage: lastEvent?.message,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
132
166
|
export function subscribe(
|
|
133
167
|
id: string,
|
|
134
168
|
callback: (event: ProgressEvent | { type: "done" | "error"; data?: string }) => void,
|
package/src/web/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createRoutes } from "./server/routes.ts";
|
|
|
4
4
|
import index from "./index.html";
|
|
5
5
|
|
|
6
6
|
import type { PreflightResult } from "../cli/preflight.ts";
|
|
7
|
+
import { getVersion } from "../version.ts";
|
|
7
8
|
|
|
8
9
|
interface WebServerOptions {
|
|
9
10
|
port: number;
|
|
@@ -125,6 +126,9 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
|
|
|
125
126
|
if (path === "/api/preflight" && req.method === "GET") {
|
|
126
127
|
return routes["GET /api/preflight"]();
|
|
127
128
|
}
|
|
129
|
+
if (path === "/api/active-analyses" && req.method === "GET") {
|
|
130
|
+
return routes["GET /api/active-analyses"]();
|
|
131
|
+
}
|
|
128
132
|
if (path === "/api/cartoon" && req.method === "POST") {
|
|
129
133
|
return routes["POST /api/cartoon"](req);
|
|
130
134
|
}
|
|
@@ -148,7 +152,7 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
|
|
|
148
152
|
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
149
153
|
|
|
150
154
|
console.log("");
|
|
151
|
-
console.log(` ${bold("newpr")} ${dim(
|
|
155
|
+
console.log(` ${bold("newpr")} ${dim(`v${getVersion()}`)}`);
|
|
152
156
|
console.log("");
|
|
153
157
|
console.log(` ${dim("→")} Local ${cyan(url)}`);
|
|
154
158
|
console.log(` ${dim("→")} Model ${dim(config.model)}`);
|