newpr 0.5.0 → 0.5.2
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/analyzer/pipeline.ts +41 -1
- package/src/cli/update-check.ts +20 -19
- package/src/config/store.ts +1 -0
- package/src/llm/prompts.ts +37 -17
- 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/web/client/App.tsx +2 -0
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +56 -36
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +9 -5
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/server/routes.ts +222 -3
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
|
@@ -240,7 +240,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
240
240
|
className={`underline decoration-1 underline-offset-[3px] cursor-pointer transition-colors ${
|
|
241
241
|
isActive
|
|
242
242
|
? "decoration-blue-500 dark:decoration-blue-400 bg-blue-500/5 rounded-sm"
|
|
243
|
-
: "decoration-foreground/
|
|
243
|
+
: "decoration-foreground/30 hover:decoration-foreground/60"
|
|
244
244
|
}`}
|
|
245
245
|
>
|
|
246
246
|
{children}
|
|
@@ -248,7 +248,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
248
248
|
);
|
|
249
249
|
}
|
|
250
250
|
if (lineRef) {
|
|
251
|
-
return <span className="underline decoration-foreground/
|
|
251
|
+
return <span className="underline decoration-foreground/25 decoration-1 underline-offset-[3px]">{children}</span>;
|
|
252
252
|
}
|
|
253
253
|
const { node, ...rest } = allProps as Record<string, unknown> & { node?: unknown };
|
|
254
254
|
return <span {...rest as React.HTMLAttributes<HTMLSpanElement>}>{children}</span>;
|
|
@@ -49,6 +49,7 @@ export function ResultsScreen({
|
|
|
49
49
|
sessionId,
|
|
50
50
|
onTabChange,
|
|
51
51
|
onReanalyze,
|
|
52
|
+
enabledPlugins,
|
|
52
53
|
}: {
|
|
53
54
|
data: NewprOutput;
|
|
54
55
|
onBack: () => void;
|
|
@@ -58,6 +59,7 @@ export function ResultsScreen({
|
|
|
58
59
|
sessionId?: string | null;
|
|
59
60
|
onTabChange?: (tab: string) => void;
|
|
60
61
|
onReanalyze?: (prUrl: string) => void;
|
|
62
|
+
enabledPlugins?: string[];
|
|
61
63
|
}) {
|
|
62
64
|
const { meta, summary } = data;
|
|
63
65
|
const [tab, setTab] = useState<TabValue>(getInitialTab);
|
|
@@ -234,11 +236,13 @@ export function ResultsScreen({
|
|
|
234
236
|
<FolderTree className="h-3 w-3 shrink-0" />
|
|
235
237
|
Files
|
|
236
238
|
</TabsTrigger>
|
|
237
|
-
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
239
|
+
{(!enabledPlugins || enabledPlugins.includes("slides")) && (
|
|
240
|
+
<TabsTrigger value="slides">
|
|
241
|
+
<Presentation className="h-3 w-3 shrink-0" />
|
|
242
|
+
Slides
|
|
243
|
+
</TabsTrigger>
|
|
244
|
+
)}
|
|
245
|
+
{(!enabledPlugins || enabledPlugins.includes("cartoon")) && (
|
|
242
246
|
<TabsTrigger value="cartoon">
|
|
243
247
|
<Sparkles className="h-3 w-3 shrink-0" />
|
|
244
248
|
Comic
|
|
@@ -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,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
|
}
|
|
@@ -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
|
|
package/src/web/server/routes.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { fetchPrDiff } from "../../github/fetch-diff.ts";
|
|
|
7
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
|
-
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
10
|
+
import { readStoredConfig, writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
11
11
|
import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
|
|
12
12
|
import { generateCartoon } from "../../llm/cartoon.ts";
|
|
13
13
|
import { generateSlides } from "../../llm/slides.ts";
|
|
14
|
+
import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
|
|
14
15
|
import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
15
16
|
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
16
17
|
import { randomBytes } from "node:crypto";
|
|
@@ -81,6 +82,14 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
81
82
|
}
|
|
82
83
|
const slideJobs = new Map<string, SlideJob>();
|
|
83
84
|
|
|
85
|
+
interface PluginJob {
|
|
86
|
+
status: "running" | "done" | "error";
|
|
87
|
+
message: string;
|
|
88
|
+
current: number;
|
|
89
|
+
total: number;
|
|
90
|
+
}
|
|
91
|
+
const pluginJobs = new Map<string, PluginJob>();
|
|
92
|
+
|
|
84
93
|
function buildChatSystemPrompt(data: NewprOutput): string {
|
|
85
94
|
const fileSummaries = data.files
|
|
86
95
|
.map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
@@ -229,6 +238,38 @@ $$
|
|
|
229
238
|
},
|
|
230
239
|
},
|
|
231
240
|
},
|
|
241
|
+
{
|
|
242
|
+
type: "function",
|
|
243
|
+
function: {
|
|
244
|
+
name: "create_review_comment",
|
|
245
|
+
description: "Create an inline review comment on a specific line or line range of a file in this PR. The comment will be posted to GitHub. Use this when the user asks to leave a comment, suggestion, or feedback on specific code.",
|
|
246
|
+
parameters: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
path: { type: "string", description: "File path (e.g. 'src/auth/session.ts')" },
|
|
250
|
+
line: { type: "number", description: "Line number to comment on (end line if range)" },
|
|
251
|
+
start_line: { type: "number", description: "Start line for multi-line comment (optional)" },
|
|
252
|
+
body: { type: "string", description: "Comment body in markdown" },
|
|
253
|
+
},
|
|
254
|
+
required: ["path", "line", "body"],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
type: "function",
|
|
260
|
+
function: {
|
|
261
|
+
name: "submit_review",
|
|
262
|
+
description: "Submit a PR review with a verdict: APPROVE, REQUEST_CHANGES, or COMMENT. Use when the user asks to approve or request changes on the PR.",
|
|
263
|
+
parameters: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"], description: "Review action" },
|
|
267
|
+
body: { type: "string", description: "Optional review summary message" },
|
|
268
|
+
},
|
|
269
|
+
required: ["event"],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
232
273
|
];
|
|
233
274
|
}
|
|
234
275
|
|
|
@@ -483,7 +524,38 @@ $$
|
|
|
483
524
|
}
|
|
484
525
|
},
|
|
485
526
|
|
|
527
|
+
"GET /api/models": async () => {
|
|
528
|
+
if (!config.openrouter_api_key) return json([]);
|
|
529
|
+
try {
|
|
530
|
+
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
|
531
|
+
headers: { Authorization: `Bearer ${config.openrouter_api_key}` },
|
|
532
|
+
});
|
|
533
|
+
if (!res.ok) return json([]);
|
|
534
|
+
const data = await res.json() as { data?: Array<{ id: string; name: string; created?: number; context_length?: number }> };
|
|
535
|
+
const models = (data.data ?? [])
|
|
536
|
+
.filter((m) => m.id && !m.id.includes(":free") && !m.id.includes(":extended"))
|
|
537
|
+
.map((m) => ({
|
|
538
|
+
id: m.id,
|
|
539
|
+
name: m.name ?? m.id,
|
|
540
|
+
provider: m.id.split("/")[0] ?? "",
|
|
541
|
+
created: m.created ?? 0,
|
|
542
|
+
contextLength: m.context_length,
|
|
543
|
+
}))
|
|
544
|
+
.sort((a, b) => {
|
|
545
|
+
const provCmp = a.provider.localeCompare(b.provider);
|
|
546
|
+
if (provCmp !== 0) return provCmp;
|
|
547
|
+
return b.created - a.created;
|
|
548
|
+
});
|
|
549
|
+
return json(models);
|
|
550
|
+
} catch {
|
|
551
|
+
return json([]);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
|
|
486
555
|
"GET /api/config": async () => {
|
|
556
|
+
const stored = await readStoredConfig();
|
|
557
|
+
const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
|
|
558
|
+
const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
|
|
487
559
|
return json({
|
|
488
560
|
model: config.model,
|
|
489
561
|
agent: config.agent ?? null,
|
|
@@ -493,6 +565,8 @@ $$
|
|
|
493
565
|
concurrency: config.concurrency,
|
|
494
566
|
has_api_key: !!config.openrouter_api_key,
|
|
495
567
|
has_github_token: !!token,
|
|
568
|
+
enabled_plugins: enabledPlugins,
|
|
569
|
+
available_plugins: pluginList,
|
|
496
570
|
defaults: {
|
|
497
571
|
model: DEFAULT_CONFIG.model,
|
|
498
572
|
language: DEFAULT_CONFIG.language,
|
|
@@ -540,14 +614,20 @@ $$
|
|
|
540
614
|
update.concurrency = body.concurrency;
|
|
541
615
|
config.concurrency = body.concurrency;
|
|
542
616
|
}
|
|
617
|
+
if ((body as Record<string, unknown>).enabled_plugins !== undefined) {
|
|
618
|
+
update.enabled_plugins = (body as Record<string, unknown>).enabled_plugins as string[];
|
|
619
|
+
}
|
|
543
620
|
|
|
544
621
|
await writeStoredConfig(update);
|
|
545
622
|
return json({ ok: true });
|
|
546
623
|
},
|
|
547
624
|
|
|
548
|
-
"GET /api/features": () => {
|
|
625
|
+
"GET /api/features": async () => {
|
|
549
626
|
const { getVersion } = require("../../version.ts");
|
|
550
|
-
|
|
627
|
+
const stored = await readStoredConfig();
|
|
628
|
+
const allPluginIds = getAllPlugins().map((p) => p.id);
|
|
629
|
+
const enabledPlugins = stored.enabled_plugins ?? allPluginIds;
|
|
630
|
+
return json({ cartoon: !!options.cartoon, version: getVersion(), enabledPlugins });
|
|
551
631
|
},
|
|
552
632
|
|
|
553
633
|
"POST /api/review": async (req: Request) => {
|
|
@@ -1096,6 +1176,67 @@ $$
|
|
|
1096
1176
|
return `Fetch error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1097
1177
|
}
|
|
1098
1178
|
}
|
|
1179
|
+
case "create_review_comment": {
|
|
1180
|
+
const filePath = args.path as string;
|
|
1181
|
+
const line = args.line as number;
|
|
1182
|
+
const startLine = args.start_line as number | undefined;
|
|
1183
|
+
const body = args.body as string;
|
|
1184
|
+
if (!filePath || !line || !body) return "Error: path, line, and body are required";
|
|
1185
|
+
try {
|
|
1186
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
1187
|
+
const sha = await fetchHeadSha(pr);
|
|
1188
|
+
if (!sha) return "Error: could not determine HEAD SHA";
|
|
1189
|
+
const ghBody: Record<string, unknown> = {
|
|
1190
|
+
commit_id: sha,
|
|
1191
|
+
path: filePath,
|
|
1192
|
+
line,
|
|
1193
|
+
side: "RIGHT",
|
|
1194
|
+
body,
|
|
1195
|
+
};
|
|
1196
|
+
if (startLine && startLine !== line) {
|
|
1197
|
+
ghBody.start_line = startLine;
|
|
1198
|
+
ghBody.start_side = "RIGHT";
|
|
1199
|
+
}
|
|
1200
|
+
const res = await fetch(
|
|
1201
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments`,
|
|
1202
|
+
{ method: "POST", headers: ghHeaders, body: JSON.stringify(ghBody) },
|
|
1203
|
+
);
|
|
1204
|
+
if (!res.ok) {
|
|
1205
|
+
const errBody = await res.text();
|
|
1206
|
+
return `GitHub API error ${res.status}: ${errBody.slice(0, 200)}`;
|
|
1207
|
+
}
|
|
1208
|
+
const data = await res.json() as { id?: number; html_url?: string };
|
|
1209
|
+
return `Comment created on ${filePath}:${startLine && startLine !== line ? `${startLine}-` : ""}${line}. ${data.html_url ?? ""}`;
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
case "submit_review": {
|
|
1215
|
+
const event = args.event as string;
|
|
1216
|
+
const body = (args.body as string) ?? "";
|
|
1217
|
+
if (!event || !["APPROVE", "REQUEST_CHANGES", "COMMENT"].includes(event)) {
|
|
1218
|
+
return "Error: event must be APPROVE, REQUEST_CHANGES, or COMMENT";
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
1222
|
+
const res = await fetch(
|
|
1223
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews`,
|
|
1224
|
+
{
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
headers: ghHeaders,
|
|
1227
|
+
body: JSON.stringify({ body, event }),
|
|
1228
|
+
},
|
|
1229
|
+
);
|
|
1230
|
+
if (!res.ok) {
|
|
1231
|
+
const errBody = await res.text();
|
|
1232
|
+
return `GitHub API error ${res.status}: ${errBody.slice(0, 200)}`;
|
|
1233
|
+
}
|
|
1234
|
+
const data = await res.json() as { html_url?: string; state?: string };
|
|
1235
|
+
return `Review submitted: ${data.state ?? event}. ${data.html_url ?? ""}`;
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1099
1240
|
default:
|
|
1100
1241
|
return `Unknown tool: ${name}`;
|
|
1101
1242
|
}
|
|
@@ -1310,5 +1451,83 @@ $$
|
|
|
1310
1451
|
if (!job) return json({ status: "idle" });
|
|
1311
1452
|
return json(job);
|
|
1312
1453
|
},
|
|
1454
|
+
|
|
1455
|
+
"GET /api/plugins": () => {
|
|
1456
|
+
const plugins = getAllPlugins().map((p) => ({
|
|
1457
|
+
id: p.id,
|
|
1458
|
+
name: p.name,
|
|
1459
|
+
description: p.description,
|
|
1460
|
+
icon: p.icon,
|
|
1461
|
+
tabLabel: p.tabLabel,
|
|
1462
|
+
}));
|
|
1463
|
+
return json(plugins);
|
|
1464
|
+
},
|
|
1465
|
+
|
|
1466
|
+
"GET /api/plugins/:id/data": async (req: Request) => {
|
|
1467
|
+
const url = new URL(req.url);
|
|
1468
|
+
const segments = url.pathname.split("/");
|
|
1469
|
+
const pluginId = segments[3]!;
|
|
1470
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1471
|
+
if (!sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1472
|
+
const plugin = getPlugin(pluginId);
|
|
1473
|
+
if (!plugin) return json({ error: `Unknown plugin: ${pluginId}` }, 404);
|
|
1474
|
+
const data = await plugin.load(sessionId);
|
|
1475
|
+
return json(data);
|
|
1476
|
+
},
|
|
1477
|
+
|
|
1478
|
+
"POST /api/plugins/:id/generate": async (req: Request) => {
|
|
1479
|
+
const url = new URL(req.url);
|
|
1480
|
+
const segments = url.pathname.split("/");
|
|
1481
|
+
const pluginId = segments[3]!;
|
|
1482
|
+
const body = await req.json() as { sessionId?: string; resume?: boolean };
|
|
1483
|
+
const sessionId = body.sessionId;
|
|
1484
|
+
if (!sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1485
|
+
if (!config.openrouter_api_key) return json({ error: "API key required" }, 400);
|
|
1486
|
+
|
|
1487
|
+
const plugin = getPlugin(pluginId);
|
|
1488
|
+
if (!plugin) return json({ error: `Unknown plugin: ${pluginId}` }, 404);
|
|
1489
|
+
|
|
1490
|
+
const data = await loadSession(sessionId);
|
|
1491
|
+
if (!data) return json({ error: "Session not found" }, 404);
|
|
1492
|
+
|
|
1493
|
+
const jobKey = `${pluginId}:${sessionId}`;
|
|
1494
|
+
if (pluginJobs.has(jobKey) && pluginJobs.get(jobKey)!.status === "running") {
|
|
1495
|
+
return json({ status: "already_running" });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const job: PluginJob = { status: "running", message: "Starting...", current: 0, total: 0 };
|
|
1499
|
+
pluginJobs.set(jobKey, job);
|
|
1500
|
+
|
|
1501
|
+
const existingData = body.resume ? await plugin.load(sessionId) : null;
|
|
1502
|
+
|
|
1503
|
+
(async () => {
|
|
1504
|
+
try {
|
|
1505
|
+
const result = await plugin.generate(
|
|
1506
|
+
{ apiKey: config.openrouter_api_key, sessionId, data, language: config.language },
|
|
1507
|
+
(event) => { job.message = event.message; job.current = event.current; job.total = event.total; },
|
|
1508
|
+
existingData,
|
|
1509
|
+
);
|
|
1510
|
+
await plugin.save(sessionId, result.data);
|
|
1511
|
+
job.status = "done";
|
|
1512
|
+
job.message = "Complete";
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
job.status = "error";
|
|
1515
|
+
job.message = err instanceof Error ? err.message : String(err);
|
|
1516
|
+
}
|
|
1517
|
+
})();
|
|
1518
|
+
|
|
1519
|
+
return json({ status: "started" });
|
|
1520
|
+
},
|
|
1521
|
+
|
|
1522
|
+
"GET /api/plugins/:id/status": async (req: Request) => {
|
|
1523
|
+
const url = new URL(req.url);
|
|
1524
|
+
const segments = url.pathname.split("/");
|
|
1525
|
+
const pluginId = segments[3]!;
|
|
1526
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1527
|
+
if (!sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1528
|
+
const job = pluginJobs.get(`${pluginId}:${sessionId}`);
|
|
1529
|
+
if (!job) return json({ status: "idle" });
|
|
1530
|
+
return json(job);
|
|
1531
|
+
},
|
|
1313
1532
|
};
|
|
1314
1533
|
}
|