newpr 0.4.0 → 0.5.1
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 +2 -2
- package/src/analyzer/pipeline.ts +42 -1
- package/src/config/store.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +37 -17
- package/src/llm/slides.ts +381 -0
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +7 -1
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +241 -37
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +37 -3
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +407 -5
- package/src/web/server.ts +30 -0
- package/src/web/styles/built.css +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
2
|
-
import { X, Check, Loader2 } from "lucide-react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
interface ConfigData {
|
|
5
5
|
model: string;
|
|
@@ -10,6 +10,8 @@ interface ConfigData {
|
|
|
10
10
|
concurrency: number;
|
|
11
11
|
has_api_key: boolean;
|
|
12
12
|
has_github_token: boolean;
|
|
13
|
+
enabled_plugins: string[];
|
|
14
|
+
available_plugins: Array<{ id: string; name: string }>;
|
|
13
15
|
defaults: {
|
|
14
16
|
model: string;
|
|
15
17
|
language: string;
|
|
@@ -19,13 +21,13 @@ interface ConfigData {
|
|
|
19
21
|
};
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
interface ModelInfo {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
created?: number;
|
|
29
|
+
contextLength?: number;
|
|
30
|
+
}
|
|
29
31
|
|
|
30
32
|
const AGENTS = [
|
|
31
33
|
{ value: "", label: "Auto" },
|
|
@@ -39,17 +41,26 @@ const LANGUAGES = [
|
|
|
39
41
|
"Spanish", "French", "German", "Portuguese",
|
|
40
42
|
];
|
|
41
43
|
|
|
42
|
-
export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
44
|
+
export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => void; onFeaturesChange?: () => void }) {
|
|
43
45
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
|
44
46
|
const [saving, setSaving] = useState(false);
|
|
45
47
|
const [saved, setSaved] = useState(false);
|
|
46
48
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
|
47
49
|
const [showApiKeyField, setShowApiKeyField] = useState(false);
|
|
50
|
+
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
48
51
|
|
|
49
52
|
useEffect(() => {
|
|
50
53
|
fetch("/api/config")
|
|
51
54
|
.then((r) => r.json())
|
|
52
|
-
.then((data) =>
|
|
55
|
+
.then((data) => {
|
|
56
|
+
setConfig(data as ConfigData);
|
|
57
|
+
if ((data as ConfigData).has_api_key) {
|
|
58
|
+
fetch("/api/models")
|
|
59
|
+
.then((r) => r.json())
|
|
60
|
+
.then((m) => setModels(m as ModelInfo[]))
|
|
61
|
+
.catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
})
|
|
53
64
|
.catch(() => {});
|
|
54
65
|
}, []);
|
|
55
66
|
|
|
@@ -67,10 +78,11 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
67
78
|
setConfig(data as ConfigData);
|
|
68
79
|
setSaved(true);
|
|
69
80
|
setTimeout(() => setSaved(false), 2000);
|
|
81
|
+
if (update.enabled_plugins !== undefined) onFeaturesChange?.();
|
|
70
82
|
} finally {
|
|
71
83
|
setSaving(false);
|
|
72
84
|
}
|
|
73
|
-
}, []);
|
|
85
|
+
}, [onFeaturesChange]);
|
|
74
86
|
|
|
75
87
|
if (!config) {
|
|
76
88
|
return (
|
|
@@ -162,15 +174,15 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
162
174
|
|
|
163
175
|
<Section title="Model">
|
|
164
176
|
<Row label="LLM">
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
{config.has_api_key ? (
|
|
178
|
+
<ModelSelect
|
|
179
|
+
value={config.model}
|
|
180
|
+
models={models}
|
|
181
|
+
onChange={(id: string) => save({ model: id })}
|
|
182
|
+
/>
|
|
183
|
+
) : (
|
|
184
|
+
<span className="text-[11px] text-muted-foreground/40">Set API key first</span>
|
|
185
|
+
)}
|
|
174
186
|
</Row>
|
|
175
187
|
<Row label="Agent">
|
|
176
188
|
<div className="flex gap-px rounded-md border p-0.5">
|
|
@@ -217,11 +229,151 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
217
229
|
<NumberInput value={config.concurrency} onChange={(v) => save({ concurrency: v })} />
|
|
218
230
|
</Row>
|
|
219
231
|
</Section>
|
|
232
|
+
|
|
233
|
+
{config.available_plugins.length > 0 && (
|
|
234
|
+
<Section title="Plugins">
|
|
235
|
+
<div className="space-y-1">
|
|
236
|
+
{config.available_plugins.map((p) => {
|
|
237
|
+
const enabled = config.enabled_plugins.includes(p.id);
|
|
238
|
+
return (
|
|
239
|
+
<div key={p.id} className="flex items-center justify-between gap-3 py-1.5">
|
|
240
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
241
|
+
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${enabled ? "bg-green-500" : "bg-muted-foreground/20"}`} />
|
|
242
|
+
<span className="text-[11px] truncate">{p.name}</span>
|
|
243
|
+
</div>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => {
|
|
247
|
+
const next = enabled
|
|
248
|
+
? config.enabled_plugins.filter((id) => id !== p.id)
|
|
249
|
+
: [...config.enabled_plugins, p.id];
|
|
250
|
+
save({ enabled_plugins: next });
|
|
251
|
+
}}
|
|
252
|
+
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
|
|
253
|
+
enabled ? "bg-foreground" : "bg-muted"
|
|
254
|
+
}`}
|
|
255
|
+
>
|
|
256
|
+
<span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
|
|
257
|
+
enabled ? "translate-x-3.5" : "translate-x-0.5"
|
|
258
|
+
}`} />
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</Section>
|
|
265
|
+
)}
|
|
220
266
|
</div>
|
|
221
267
|
</div>
|
|
222
268
|
);
|
|
223
269
|
}
|
|
224
270
|
|
|
271
|
+
function ModelSelect({ value, models: initialModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
|
|
272
|
+
const [open, setOpen] = useState(false);
|
|
273
|
+
const [search, setSearch] = useState("");
|
|
274
|
+
const [models, setModels] = useState<ModelInfo[]>(initialModels);
|
|
275
|
+
const [loading, setLoading] = useState(false);
|
|
276
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
277
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
278
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
279
|
+
|
|
280
|
+
useEffect(() => { setModels(initialModels); }, [initialModels]);
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!open) return;
|
|
284
|
+
const handler = (e: MouseEvent) => {
|
|
285
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
286
|
+
};
|
|
287
|
+
document.addEventListener("mousedown", handler);
|
|
288
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
289
|
+
}, [open]);
|
|
290
|
+
|
|
291
|
+
const fetchModels = useCallback((q: string) => {
|
|
292
|
+
setLoading(true);
|
|
293
|
+
fetch(`/api/models${q ? `?q=${encodeURIComponent(q)}` : ""}`)
|
|
294
|
+
.then((r) => r.json())
|
|
295
|
+
.then((data) => setModels(data as ModelInfo[]))
|
|
296
|
+
.catch(() => {})
|
|
297
|
+
.finally(() => setLoading(false));
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (open) {
|
|
302
|
+
setSearch("");
|
|
303
|
+
fetchModels("");
|
|
304
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
305
|
+
}
|
|
306
|
+
}, [open, fetchModels]);
|
|
307
|
+
|
|
308
|
+
const handleSearch = useCallback((q: string) => {
|
|
309
|
+
setSearch(q);
|
|
310
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
311
|
+
debounceRef.current = setTimeout(() => fetchModels(q), 300);
|
|
312
|
+
}, [fetchModels]);
|
|
313
|
+
|
|
314
|
+
const displayName = value.split("/").pop() ?? value;
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div ref={ref} className="relative">
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setOpen(!open)}
|
|
321
|
+
className="flex items-center gap-1.5 h-7 rounded-md border bg-background px-2.5 text-[11px] font-mono hover:border-foreground/20 transition-colors max-w-[220px]"
|
|
322
|
+
>
|
|
323
|
+
<span className="truncate flex-1 text-left">{displayName}</span>
|
|
324
|
+
<ChevronDown className={`h-3 w-3 text-muted-foreground/40 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
325
|
+
</button>
|
|
326
|
+
{open && (
|
|
327
|
+
<div className="absolute right-0 top-8 z-50 w-[320px] rounded-lg border bg-background shadow-lg">
|
|
328
|
+
<div className="p-1.5 border-b">
|
|
329
|
+
<div className="flex items-center gap-1.5 px-2 h-7 rounded-md bg-muted/50">
|
|
330
|
+
<Search className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
|
331
|
+
<input
|
|
332
|
+
ref={inputRef}
|
|
333
|
+
type="text"
|
|
334
|
+
value={search}
|
|
335
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
336
|
+
placeholder="Search models..."
|
|
337
|
+
className="flex-1 bg-transparent text-[11px] focus:outline-none placeholder:text-muted-foreground/30"
|
|
338
|
+
/>
|
|
339
|
+
{loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/30 shrink-0" />}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div className="max-h-[280px] overflow-y-auto p-1">
|
|
343
|
+
{models.length === 0 && !loading && (
|
|
344
|
+
<div className="px-2 py-3 text-center text-[11px] text-muted-foreground/40">No models found</div>
|
|
345
|
+
)}
|
|
346
|
+
{models.slice(0, 80).map((m, i) => {
|
|
347
|
+
const isSelected = m.id === value;
|
|
348
|
+
const provider = m.id.split("/")[0] ?? "";
|
|
349
|
+
const name = m.id.split("/").slice(1).join("/");
|
|
350
|
+
const prevProvider = i > 0 ? models[i - 1]!.id.split("/")[0] : null;
|
|
351
|
+
const showHeader = provider !== prevProvider;
|
|
352
|
+
return (
|
|
353
|
+
<div key={m.id}>
|
|
354
|
+
{showHeader && (
|
|
355
|
+
<div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/30 uppercase tracking-wider">{provider}</div>
|
|
356
|
+
)}
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
onClick={() => { onChange(m.id); setOpen(false); }}
|
|
360
|
+
className={`w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
|
|
361
|
+
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
|
362
|
+
}`}
|
|
363
|
+
>
|
|
364
|
+
<span className="text-[11px] font-mono truncate flex-1">{name}</span>
|
|
365
|
+
{isSelected && <Check className="h-3 w-3 text-foreground shrink-0" />}
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
})}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
225
377
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
226
378
|
return (
|
|
227
379
|
<div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import type { ProgressEvent } from "../../../analyzer/progress.ts";
|
|
3
3
|
import type { NewprOutput } from "../../../types/output.ts";
|
|
4
|
+
import { sendNotification } from "../lib/notify.ts";
|
|
4
5
|
|
|
5
6
|
export type BgStatus = "running" | "done" | "error";
|
|
6
7
|
|
|
@@ -89,19 +90,22 @@ export function useBackgroundAnalyses() {
|
|
|
89
90
|
try {
|
|
90
91
|
const res = await fetch(`/api/analysis/${sessionId}`);
|
|
91
92
|
const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
|
|
92
|
-
setAnalyses((prev) =>
|
|
93
|
-
prev.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
setAnalyses((prev) => {
|
|
94
|
+
const a = prev.find((x) => x.sessionId === sessionId);
|
|
95
|
+
sendNotification("Analysis complete", a?.prTitle ?? prInput);
|
|
96
|
+
return prev.map((x) =>
|
|
97
|
+
x.sessionId === sessionId
|
|
98
|
+
? { ...x, status: "done" as const, result: data.result, historyId: data.historyId }
|
|
99
|
+
: x,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
99
102
|
} catch {
|
|
100
|
-
setAnalyses((prev) =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
setAnalyses((prev) => {
|
|
104
|
+
sendNotification("Analysis complete", prInput);
|
|
105
|
+
return prev.map((a) =>
|
|
106
|
+
a.sessionId === sessionId ? { ...a, status: "done" as const } : a,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
105
109
|
}
|
|
106
110
|
});
|
|
107
111
|
|
|
@@ -110,6 +114,7 @@ export function useBackgroundAnalyses() {
|
|
|
110
114
|
eventSourcesRef.current.delete(sessionId);
|
|
111
115
|
let msg = "Analysis failed";
|
|
112
116
|
try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
|
|
117
|
+
sendNotification("Analysis failed", msg);
|
|
113
118
|
setAnalyses((prev) =>
|
|
114
119
|
prev.map((a) =>
|
|
115
120
|
a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { sendNotification } from "../lib/notify.ts";
|
|
2
3
|
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
3
4
|
|
|
4
5
|
interface ChatSessionState {
|
|
@@ -24,6 +25,12 @@ class ChatStore {
|
|
|
24
25
|
return s;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
private update(sessionId: string, patch: Partial<ChatSessionState>) {
|
|
29
|
+
const s = this.getOrCreate(sessionId);
|
|
30
|
+
this.sessions.set(sessionId, { ...s, ...patch });
|
|
31
|
+
this.notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
private notify() {
|
|
28
35
|
for (const l of this.listeners) l();
|
|
29
36
|
}
|
|
@@ -55,12 +62,10 @@ class ChatStore {
|
|
|
55
62
|
try {
|
|
56
63
|
const res = await fetch(`/api/sessions/${sessionId}/chat`);
|
|
57
64
|
const data = await res.json() as ChatMessage[];
|
|
58
|
-
|
|
59
|
-
s.loaded = true;
|
|
65
|
+
this.update(sessionId, { messages: data, loaded: true });
|
|
60
66
|
} catch {
|
|
61
|
-
|
|
67
|
+
this.update(sessionId, { loaded: true });
|
|
62
68
|
}
|
|
63
|
-
this.notify();
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
async sendMessage(sessionId: string, text: string): Promise<void> {
|
|
@@ -68,10 +73,7 @@ class ChatStore {
|
|
|
68
73
|
if (s.loading) return;
|
|
69
74
|
|
|
70
75
|
const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
|
|
71
|
-
|
|
72
|
-
s.loading = true;
|
|
73
|
-
s.streaming = { segments: [] };
|
|
74
|
-
this.notify();
|
|
76
|
+
this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
|
|
75
77
|
|
|
76
78
|
const controller = new AbortController();
|
|
77
79
|
this.abortControllers.set(sessionId, controller);
|
|
@@ -122,23 +124,20 @@ class ChatStore {
|
|
|
122
124
|
} else {
|
|
123
125
|
orderedSegments.push({ type: "text", content: data.content ?? "" });
|
|
124
126
|
}
|
|
125
|
-
|
|
126
|
-
this.notify();
|
|
127
|
+
this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
|
|
127
128
|
break;
|
|
128
129
|
}
|
|
129
130
|
case "tool_call": {
|
|
130
131
|
const tc: ChatToolCall = { id: data.id, name: data.name, arguments: data.arguments ?? {} };
|
|
131
132
|
allToolCalls.push(tc);
|
|
132
133
|
orderedSegments.push({ type: "tool_call", toolCall: tc });
|
|
133
|
-
|
|
134
|
-
this.notify();
|
|
134
|
+
this.update(sessionId, { streaming: { segments: [...orderedSegments], activeToolName: data.name } });
|
|
135
135
|
break;
|
|
136
136
|
}
|
|
137
137
|
case "tool_result": {
|
|
138
138
|
const tc = allToolCalls.find((c) => c.id === data.id);
|
|
139
139
|
if (tc) tc.result = data.result;
|
|
140
|
-
|
|
141
|
-
this.notify();
|
|
140
|
+
this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
|
|
142
141
|
break;
|
|
143
142
|
}
|
|
144
143
|
case "done": break;
|
|
@@ -151,26 +150,31 @@ class ChatStore {
|
|
|
151
150
|
}
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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, {
|
|
153
|
+
const cur = this.getOrCreate(sessionId);
|
|
154
|
+
this.update(sessionId, {
|
|
155
|
+
messages: [...cur.messages, {
|
|
164
156
|
role: "assistant",
|
|
165
|
-
content:
|
|
157
|
+
content: fullText,
|
|
158
|
+
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
|
|
159
|
+
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
166
160
|
timestamp: new Date().toISOString(),
|
|
167
|
-
}]
|
|
161
|
+
}],
|
|
162
|
+
});
|
|
163
|
+
sendNotification("Chat response ready", fullText.slice(0, 100));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if ((err as Error).name !== "AbortError") {
|
|
166
|
+
const cur = this.getOrCreate(sessionId);
|
|
167
|
+
this.update(sessionId, {
|
|
168
|
+
messages: [...cur.messages, {
|
|
169
|
+
role: "assistant",
|
|
170
|
+
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
171
|
+
timestamp: new Date().toISOString(),
|
|
172
|
+
}],
|
|
173
|
+
});
|
|
168
174
|
}
|
|
169
175
|
} finally {
|
|
170
|
-
|
|
171
|
-
s.streaming = null;
|
|
176
|
+
this.update(sessionId, { loading: false, streaming: null });
|
|
172
177
|
this.abortControllers.delete(sessionId);
|
|
173
|
-
this.notify();
|
|
174
178
|
}
|
|
175
179
|
}
|
|
176
180
|
|
|
@@ -180,8 +184,7 @@ class ChatStore {
|
|
|
180
184
|
if (lastAssistantIdx === -1) return;
|
|
181
185
|
const lastUserIdx = s.messages.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
|
|
182
186
|
const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
|
|
183
|
-
|
|
184
|
-
this.notify();
|
|
187
|
+
this.update(sessionId, { messages: s.messages.slice(0, removeFrom) });
|
|
185
188
|
await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
|
|
186
189
|
}
|
|
187
190
|
}
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
|
|
3
3
|
interface Features {
|
|
4
4
|
cartoon: boolean;
|
|
5
5
|
version: string;
|
|
6
|
+
enabledPlugins: string[];
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
export function useFeatures(): Features {
|
|
9
|
-
const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
|
|
9
|
+
export function useFeatures(): Features & { refresh: () => void } {
|
|
10
|
+
const [features, setFeatures] = useState<Features>({ cartoon: false, version: "", enabledPlugins: [] });
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
const refresh = useCallback(() => {
|
|
12
13
|
fetch("/api/features")
|
|
13
14
|
.then((r) => r.json())
|
|
14
15
|
.then((data) => setFeatures(data as Features))
|
|
15
16
|
.catch(() => {});
|
|
16
17
|
}, []);
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
20
|
+
|
|
21
|
+
return { ...features, refresh };
|
|
19
22
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface OutdatedInfo {
|
|
4
|
+
outdated: boolean;
|
|
5
|
+
currentTitle?: string;
|
|
6
|
+
currentState?: string;
|
|
7
|
+
analyzedAt?: string;
|
|
8
|
+
currentUpdatedAt?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useOutdatedCheck(sessionId?: string | null): OutdatedInfo | null {
|
|
12
|
+
const [info, setInfo] = useState<OutdatedInfo | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setInfo(null);
|
|
16
|
+
if (!sessionId) return;
|
|
17
|
+
fetch(`/api/sessions/${sessionId}/outdated`)
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((data) => {
|
|
20
|
+
const d = data as {
|
|
21
|
+
outdated?: boolean;
|
|
22
|
+
current_title?: string;
|
|
23
|
+
current_state?: string;
|
|
24
|
+
analyzed_at?: string;
|
|
25
|
+
current_updated_at?: string;
|
|
26
|
+
};
|
|
27
|
+
if (d.outdated !== undefined) {
|
|
28
|
+
setInfo({
|
|
29
|
+
outdated: d.outdated,
|
|
30
|
+
currentTitle: d.current_title,
|
|
31
|
+
currentState: d.current_state,
|
|
32
|
+
analyzedAt: d.analyzed_at,
|
|
33
|
+
currentUpdatedAt: d.current_updated_at,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch(() => {});
|
|
38
|
+
}, [sessionId]);
|
|
39
|
+
|
|
40
|
+
return info;
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
let permissionRequested = false;
|
|
2
|
+
|
|
3
|
+
export function requestNotificationPermission(): void {
|
|
4
|
+
if (permissionRequested || typeof Notification === "undefined") return;
|
|
5
|
+
permissionRequested = true;
|
|
6
|
+
if (Notification.permission === "default") {
|
|
7
|
+
Notification.requestPermission();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sendNotification(title: string, body?: string): void {
|
|
12
|
+
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
|
13
|
+
if (document.hasFocus()) return;
|
|
14
|
+
try {
|
|
15
|
+
new Notification(title, {
|
|
16
|
+
body,
|
|
17
|
+
icon: "/favicon.ico",
|
|
18
|
+
tag: "newpr",
|
|
19
|
+
});
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
@@ -6,6 +6,10 @@ export const SHIKI_LANGS = [
|
|
|
6
6
|
"yaml", "html", "bash", "java", "c",
|
|
7
7
|
"cpp", "ruby", "php", "swift", "kotlin",
|
|
8
8
|
"sql", "markdown", "toml", "xml",
|
|
9
|
+
"csharp", "dart", "lua", "zig", "graphql",
|
|
10
|
+
"dockerfile", "prisma", "svelte", "vue",
|
|
11
|
+
"scss", "less", "r", "scala", "elixir",
|
|
12
|
+
"haskell", "ocaml", "perl",
|
|
9
13
|
] as const;
|
|
10
14
|
|
|
11
15
|
export type ShikiLang = (typeof SHIKI_LANGS)[number];
|
|
@@ -35,20 +39,41 @@ const EXT_TO_LANG: Record<string, string> = {
|
|
|
35
39
|
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
36
40
|
py: "python", pyi: "python",
|
|
37
41
|
go: "go", rs: "rust",
|
|
38
|
-
css: "css", scss: "
|
|
42
|
+
css: "css", scss: "scss", less: "less",
|
|
39
43
|
json: "json", jsonc: "json",
|
|
40
44
|
yaml: "yaml", yml: "yaml",
|
|
41
|
-
html: "html", htm: "html",
|
|
45
|
+
html: "html", htm: "html",
|
|
46
|
+
vue: "vue", svelte: "svelte",
|
|
42
47
|
sh: "bash", bash: "bash", zsh: "bash",
|
|
43
48
|
java: "java", kt: "kotlin",
|
|
44
49
|
c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp",
|
|
50
|
+
cs: "csharp",
|
|
45
51
|
rb: "ruby", php: "php", swift: "swift",
|
|
46
52
|
sql: "sql", md: "markdown", mdx: "markdown",
|
|
47
|
-
toml: "toml",
|
|
53
|
+
toml: "toml", xml: "xml",
|
|
54
|
+
dart: "dart", lua: "lua", zig: "zig",
|
|
55
|
+
graphql: "graphql", gql: "graphql",
|
|
56
|
+
prisma: "prisma",
|
|
57
|
+
dockerfile: "dockerfile",
|
|
58
|
+
r: "r", R: "r",
|
|
59
|
+
scala: "scala", sc: "scala",
|
|
60
|
+
ex: "elixir", exs: "elixir",
|
|
61
|
+
hs: "haskell", ml: "ocaml",
|
|
62
|
+
pl: "perl", pm: "perl",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const NAME_TO_LANG: Record<string, string> = {
|
|
66
|
+
Dockerfile: "dockerfile",
|
|
67
|
+
Makefile: "bash",
|
|
68
|
+
Gemfile: "ruby",
|
|
69
|
+
Rakefile: "ruby",
|
|
48
70
|
};
|
|
49
71
|
|
|
50
72
|
export function detectShikiLang(filePath: string): ShikiLang | null {
|
|
51
|
-
const
|
|
73
|
+
const fileName = filePath.split("/").pop() ?? "";
|
|
74
|
+
const nameLang = NAME_TO_LANG[fileName];
|
|
75
|
+
if (nameLang) return nameLang as ShikiLang;
|
|
76
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
52
77
|
return (EXT_TO_LANG[ext] as ShikiLang | undefined) ?? null;
|
|
53
78
|
}
|
|
54
79
|
|