| 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 | }); |