main
js 255 lines 7.8 KB
Raw
1 import { marked } from "/vendor/marked/marked.esm.js";
2 import { createStore } from "/js/AlpineStore.js";
3 import * as api from "/js/api.js";
4 import { openModal } from "/js/modals.js";
5 import { getUserTimezone } from "/js/time-utils.js";
6 import { toastFrontendError } from "/components/notifications/notification-store.js";
7
8 const BASE = "/plugins/_plugin_scan/webui";
9
10 /** @type {{ ratings: Record<string, {icon:string,label:string}>, checks: Record<string, {label:string,detail:string,criteria:Record<string,string>}> } | null} */
11 let _config = null;
12 /** @type {string|null} */
13 let _templateCache = null;
14
15 async function fetchText(url, label) {
16 const response = await fetch(url);
17 if (!response.ok) {
18 const body = await response.text().catch(() => "");
19 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
20 }
21 return response.text();
22 }
23
24 async function fetchJson(url, label) {
25 const response = await fetch(url);
26 if (!response.ok) {
27 const body = await response.text().catch(() => "");
28 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
29 }
30 return response.json();
31 }
32
33 async function loadConfig() {
34 if (_config) return _config;
35 try {
36 _config = await fetchJson(`${BASE}/plugin-scan-checks.json`, "scan checks");
37 return _config;
38 } catch (error) {
39 _config = null;
40 throw error;
41 }
42 }
43
44 async function loadTemplate() {
45 if (_templateCache) return _templateCache;
46 try {
47 _templateCache = await fetchText(`${BASE}/plugin-scan-prompt.md`, "scan prompt template");
48 return _templateCache;
49 } catch (error) {
50 _templateCache = null;
51 throw error;
52 }
53 }
54
55 function formatCriteria(ratings, criteria) {
56 return Object.entries(criteria)
57 .map(([level, desc]) => `- ${ratings[level].icon} ${desc}`)
58 .join("\n");
59 }
60
61 function formatStatusLegend(ratings) {
62 return Object.entries(ratings)
63 .map(([, r]) => `- ${r.icon} **${r.label}**`)
64 .join("\n");
65 }
66
67 function formatRatingIcons(ratings) {
68 return Object.values(ratings).map((r) => r.icon).join("/");
69 }
70 let _pollGen = 0;
71 const POLL_INTERVAL = 2000;
72 const MAX_POLL_MS = 10 * 60 * 1000;
73 const SCAN_TITLE = "Plugin Scanner";
74
75 function formatErrorMessage(error) {
76 return error instanceof Error ? error.message : String(error);
77 }
78
79 export const store = createStore("pluginScan", {
80 gitUrl: "",
81 checks: {},
82 checksMeta: {},
83 prompt: "",
84 output: "",
85 scanning: false,
86 scanCtxId: "",
87
88 get renderedOutput() {
89 return this.output ? marked.parse(this.output, { breaks: true }) : "";
90 },
91
92 async init() {
93 const cfg = await loadConfig();
94 if (!cfg) return;
95 this.checksMeta = cfg.checks;
96 const initial = {};
97 for (const key of Object.keys(cfg.checks)) initial[key] = true;
98 this.checks = initial;
99 },
100
101 async onOpen(url) {
102 this.output = "";
103 this.scanning = false;
104 if (url) this.gitUrl = url;
105 const cfg = await loadConfig();
106 if (cfg && Object.keys(this.checks).length === 0) {
107 this.checksMeta = cfg.checks;
108 const initial = {};
109 for (const key of Object.keys(cfg.checks)) initial[key] = true;
110 this.checks = initial;
111 }
112 this.buildPrompt();
113 },
114
115 cleanup() {
116 _pollGen++;
117 },
118
119 async openModal(url) {
120 this.gitUrl = url || "";
121 await openModal("/plugins/_plugin_scan/webui/plugin-scan.html");
122 },
123
124 async buildPrompt() {
125 try {
126 const [cfg, template] = await Promise.all([loadConfig(), loadTemplate()]);
127 if (!cfg) return;
128 const { ratings, checks } = cfg;
129
130 let text = template;
131 text = text.replace(/\{\{GIT_URL\}\}/g, this.gitUrl || "<paste git URL here>");
132
133 const selected = Object.entries(this.checks)
134 .filter(([, v]) => v)
135 .map(([k]) => checks[k])
136 .filter(Boolean);
137
138 text = text.replace(
139 /\{\{SELECTED_CHECKS\}\}/g,
140 selected.length ? selected.map((c) => `- ${c.label}`).join("\n") : "- (no checks selected)",
141 );
142 text = text.replace(
143 /\{\{CHECK_DETAILS\}\}/g,
144 selected.length
145 ? selected.map((c) => `**${c.label}**: ${c.detail}\n${formatCriteria(ratings, c.criteria)}`).join("\n\n")
146 : "(no checks selected)",
147 );
148 text = text.replace(/\{\{STATUS_LEGEND\}\}/g, formatStatusLegend(ratings));
149 text = text.replace(/\{\{RATING_ICONS\}\}/g, formatRatingIcons(ratings));
150 text = text.replace(/\{\{RATING_PASS\}\}/g, ratings.pass.icon);
151 text = text.replace(/\{\{RATING_WARNING\}\}/g, ratings.warning.icon);
152 text = text.replace(/\{\{RATING_FAIL\}\}/g, ratings.fail.icon);
153
154 this.prompt = text;
155 } catch (/** @type {any} */ e) {
156 console.error("Failed to build prompt:", e);
157 void toastFrontendError(`Failed to build prompt: ${formatErrorMessage(e)}`, SCAN_TITLE);
158 }
159 },
160
161 async copyPrompt() {
162 try {
163 await navigator.clipboard.writeText(this.prompt);
164 } catch {
165 void toastFrontendError("Failed to copy the scan prompt", SCAN_TITLE);
166 }
167 },
168
169 /** Create a fresh context, log the prompt into it, and start the scan immediately. */
170 async runScan() {
171 if (!this.gitUrl.trim()) {
172 void toastFrontendError("Please enter a Git URL", SCAN_TITLE);
173 return;
174 }
175
176 await this.buildPrompt();
177 const capturedPrompt = this.prompt;
178 const gen = ++_pollGen;
179 this.output = "";
180
181 let ctxId;
182 try {
183 const resp = await api.callJsonApi("/chat_create", {});
184 if (!resp.ok) throw new Error("Failed to create chat context");
185 ctxId = resp.ctxid;
186 } catch (/** @type {any} */ e) {
187 void toastFrontendError(`Scan failed: ${formatErrorMessage(e)}`, SCAN_TITLE);
188 return;
189 }
190 this.scanCtxId = ctxId;
191
192 try {
193 await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt });
194 } catch { /* best-effort */ }
195 this.scanning = true;
196 this._runNext(gen, ctxId, capturedPrompt);
197 },
198
199 /** @param {number} gen @param {string} ctxId @param {string} prompt */
200 async _runNext(gen, ctxId, prompt) {
201 try {
202 await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_start", { text: prompt, context: ctxId });
203 await this._pollLoop(gen, ctxId);
204 } catch (/** @type {any} */ e) {
205 if (gen === _pollGen) {
206 void toastFrontendError(`Scan failed: ${formatErrorMessage(e)}`, SCAN_TITLE);
207 this.scanning = false;
208 }
209 }
210 },
211
212 /** @param {number} gen @param {string} ctxId */
213 async _pollLoop(gen, ctxId) {
214 let started = false;
215 const deadline = Date.now() + MAX_POLL_MS;
216 while (true) {
217 if (Date.now() >= deadline) {
218 if (gen === _pollGen) {
219 this.scanning = false;
220 void toastFrontendError("Scan timed out while waiting for the agent response", SCAN_TITLE);
221 console.error(`Scan poll timed out for context ${ctxId}`);
222 }
223 return;
224 }
225 await new Promise((r) => setTimeout(r, POLL_INTERVAL));
226 try {
227 const snap = await api.callJsonApi("/poll", {
228 context: ctxId, log_from: 0, notifications_from: 0,
229 timezone: getUserTimezone(),
230 });
231
232 if (gen === _pollGen && snap.logs?.length) {
233 const last = snap.logs.filter((/** @type {any} */ l) => l.type === "response" && l.no > 0).pop();
234 if (last) this.output = last.content || "";
235 }
236
237 if (snap.log_progress_active) started = true;
238 if (started && !snap.log_progress_active) {
239 if (gen === _pollGen) this.scanning = false;
240 return;
241 }
242 if (snap.deselect_chat) return;
243 } catch (/** @type {any} */ e) {
244 if (gen === _pollGen) console.error("Poll error:", e);
245 }
246 }
247 },
248
249 openChatInNewWindow() {
250 if (!this.scanCtxId) return;
251 const url = new URL(window.location.href);
252 url.searchParams.set("ctxid", this.scanCtxId);
253 window.open(url.toString(), "_blank");
254 },
255 });