From 928452744177aa39e0738ffb0ee953f87ba64473 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 2 Sep 2026 16:09:08 -0400 Subject: [PATCH 01/11] adding context needed for model connection --- js/ai/aiContext.js | 214 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 js/ai/aiContext.js diff --git a/js/ai/aiContext.js b/js/ai/aiContext.js new file mode 100644 index 0000000..ad63e02 --- /dev/null +++ b/js/ai/aiContext.js @@ -0,0 +1,214 @@ +// gathers everything the AI suggestions need to know about a repository. +(function () { + const contextCache = new Map(); + + function getGitHubToken() { + try { + const value = window.formIOInstance.getComponent("gh_api_key").getValue(); + if (value && String(value).trim()) { + return String(value).trim(); + } + } catch (error) {} + return window.gh_api_key || null; + } + + function ghHeaders(accept) { + const headers = { "X-GitHub-Api-Version": "2022-11-28" }; + + if (accept) { + headers.Accept = accept; + } + + const token = getGitHubToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + return { headers }; + } + + function checkRateLimit(response) { + const remaining = Number(response.headers.get("x-ratelimit-remaining")); + + if (!Number.isFinite(remaining) || remaining > 10 || getGitHubToken()) { + return; + } + + window.showErrorNotification( + `GitHub API: ${remaining} requests left this hour. Add a GitHub API Key at ` + + `the bottom of the form to raise the limit from 60 to 5,000.` + ); + } + + async function getRootFiles(repoInfo) { + const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/contents`; + + try { + const response = await fetch(endpoint, ghHeaders()); + if (!response.ok) { + return []; + } + const files = await response.json(); + return Array.isArray(files) ? files : []; + } catch (error) { + console.error("Could not list repository root:", error.message); + return []; + } + } + + async function getReadme(repoInfo) { + const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/readme`; + + try { + const response = await fetch(endpoint, ghHeaders("application/vnd.github.raw")); + + // 404 just means the repository has no README + if (!response.ok) { + return ""; + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("json")) { + return await response.text(); + } + + const payload = await response.json(); + const encoded = (payload.content || "").replace(/\s/g, ""); + const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + return new TextDecoder("utf-8").decode(bytes); + } catch (error) { + console.error("Could not fetch README:", error.message); + return ""; + } + } + + async function getLatestRelease(repoInfo) { + const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/releases/latest`; + + try { + const response = await fetch(endpoint, ghHeaders()); + // 404 is the common case + return response.ok ? await response.json() : null; + } catch (error) { + console.error("Could not fetch latest release:", error.message); + return null; + } + } + + const BOILERPLATE_HEADING = /^#{1,4}\s*(license|licence|code of conduct|contributing|security|contributors|acknowledge?ments?|table of contents|changelog|badges|citation)\b/i; + + function condenseReadme(markdown, maxChars) { + if (!markdown) { + return ""; + } + + let text = markdown + .replace(//g, "") + .replace(/^(.+)\n={3,}\s*$/gm, "# $1") + .replace(/^(.+)\n-{3,}\s*$/gm, "## $1") + .replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$/gm, "") + .replace(/!\[[^\]]*\]\(https?:\/\/(img\.shields\.io|badge)[^)]*\)/g, "") + .replace(/```[\w-]*\n[\s\S]*?```/g, "[code example]") + .replace(/^\s*\|.*\|\s*$/gm, "") + .replace(/\n{3,}/g, "\n\n"); + + const sections = text + .split(/(?=^#{1,4}\s)/m) + .filter((section) => !BOILERPLATE_HEADING.test(section)); + + text = sections.join("").trim(); + + if (text.length <= maxChars) { + return text; + } + + const head = Math.floor(maxChars * 0.7); + const tail = maxChars - head - 20; + return `${text.slice(0, head)}\n\n...\n\n${text.slice(-tail)}`; + } + + function languagePercentages(languages) { + if (!languages) { + return "unknown"; + } + + const entries = Object.entries(languages); + const total = entries.reduce((sum, entry) => sum + entry[1], 0); + + if (!total) { + return "unknown"; + } + + return entries + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name, bytes]) => `${name} ${Math.round((bytes / total) * 100)}%`) + .join(", "); + } + + function shortDate(value) { + return value ? String(value).slice(0, 10) : "unknown"; + } + + function buildFactsBlock(context) { + const repo = context.repoData; + const release = context.latestRelease; + const fileNames = context.rootFiles.map((file) => file.name); + + const lines = [ + `Repository: ${repo.full_name || repo.name}`, + `Description: ${repo.description || "(none)"}`, + `Topics: ${(repo.topics || []).join(", ") || "(none)"}`, + `Languages by bytes: ${languagePercentages(context.languages)}`, + `Homepage: ${repo.homepage || "(none)"}`, + `Archived: ${repo.archived ? "yes" : "no"} | Fork: ${repo.fork ? "yes" : "no"} | ` + + `GitHub Pages: ${repo.has_pages ? "yes" : "no"} | Open issues: ${repo.open_issues_count || 0}`, + `Latest release: ${release ? `${release.tag_name} (${shortDate(release.published_at)})` : "(none)"}`, + `Last push: ${shortDate(repo.pushed_at)} | Created: ${shortDate(repo.created_at)}`, + `Root files: ${fileNames.join(", ") || "(none)"}` + ]; + + return lines.join("\n"); + } + + async function gather(repoInfo, prefetched) { + const cacheKey = `${repoInfo.organization}/${repoInfo.repository}`; + + if (contextCache.has(cacheKey)) { + return contextCache.get(cacheKey); + } + + const rootFilesPromise = prefetched.rootFiles + ? Promise.resolve(prefetched.rootFiles) + : getRootFiles(repoInfo); + + const [rootFiles, readme, latestRelease] = await Promise.all([ + rootFilesPromise, + getReadme(repoInfo), + getLatestRelease(repoInfo) + ]); + + const context = { + repoInfo, + repoData: prefetched.repoData, + languages: prefetched.languages || {}, + rootFiles, + readme, + latestRelease + }; + + context.facts = buildFactsBlock(context); + contextCache.set(cacheKey, context); + + return context; + } + + window.AIContext = { + gather, + condenseReadme, + buildFactsBlock, + ghHeaders, + getGitHubToken, + checkRateLimit + }; +})(); From 6b2362f79bc2c8dd64b1e592140b0d7a44788900 Mon Sep 17 00:00:00 2001 From: Sachin Panayil <79382140+sachin-panayil@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:11:12 -0400 Subject: [PATCH 02/11] Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- js/ai/aiContext.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/js/ai/aiContext.js b/js/ai/aiContext.js index ad63e02..e82667d 100644 --- a/js/ai/aiContext.js +++ b/js/ai/aiContext.js @@ -97,13 +97,22 @@ const BOILERPLATE_HEADING = /^#{1,4}\s*(license|licence|code of conduct|contributing|security|contributors|acknowledge?ments?|table of contents|changelog|badges|citation)\b/i; + function stripHtmlCommentsFully(input) { + let previous; + let current = input; + do { + previous = current; + current = current.replace(//g, ""); + } while (current !== previous); + return current; + } + function condenseReadme(markdown, maxChars) { if (!markdown) { return ""; } - let text = markdown - .replace(//g, "") + let text = stripHtmlCommentsFully(markdown) .replace(/^(.+)\n={3,}\s*$/gm, "# $1") .replace(/^(.+)\n-{3,}\s*$/gm, "## $1") .replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$/gm, "") From 5801d4bf4e723a92077a7afc69bb8b2c1e3f1fee Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 11:49:53 -0400 Subject: [PATCH 03/11] adding rule based field suggestions for less hallucinations --- js/ai/aiHeuristics.js | 210 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 js/ai/aiHeuristics.js diff --git a/js/ai/aiHeuristics.js b/js/ai/aiHeuristics.js new file mode 100644 index 0000000..365d7e4 --- /dev/null +++ b/js/ai/aiHeuristics.js @@ -0,0 +1,210 @@ +// rule based field suggestions derived from repository metadata and the root file listing +(function () { + function fileSet(rootFiles) { + return new Set((rootFiles || []).map((file) => file.name.toLowerCase())); + } + + function hasAny(names, candidates) { + return candidates.some((candidate) => names.has(candidate)); + } + + function maturityTier(rootFiles) { + const names = fileSet(rootFiles); + + const hasReadme = hasAny(names, ["readme.md", "readme", "readme.rst", "readme.txt"]); + if (!hasReadme) { + return 0; + } + + const hasLicense = [...names].some((name) => name.startsWith("license") || name.startsWith("licence")); + if (!hasLicense) { + return 1; + } + + const hasContributing = hasAny(names, ["contributing.md", "contributing"]); + const hasConduct = hasAny(names, ["code_of_conduct.md", "code-of-conduct.md"]); + if (!hasContributing || !hasConduct) { + return 2; + } + + const hasSecurity = hasAny(names, ["security.md"]); + const hasStewardship = hasAny(names, ["maintainers.md", "governance.md", "codeowners.md"]); + const hasAutomation = names.has(".github"); + if (!hasSecurity || !hasStewardship || !hasAutomation) { + return 3; + } + + return 4; + } + + function majorVersion(tagName) { + const match = String(tagName || "").match(/(\d+)\./); + return match ? Number(match[1]) : null; + } + + function monthsSince(dateString) { + if (!dateString) { + return Infinity; + } + const elapsed = Date.now() - new Date(dateString).getTime(); + return elapsed / (1000 * 60 * 60 * 24 * 30); + } + + function developmentStatus(repoData, release) { + if (repoData.archived) { + return "Archival"; + } + + const major = release ? majorVersion(release.tag_name) : null; + if (major !== null && major >= 1) { + return "Production"; + } + if (major !== null) { + return "Beta"; + } + + const idleMonths = monthsSince(repoData.pushed_at); + if (idleMonths > 12) { + return "Ideation"; + } + + return "Development"; + } + + const IOS_LANGUAGES = ["Swift", "Objective-C"]; + const DESKTOP_MARKERS = ["electron-builder.yml", "tauri.conf.json"]; + + function platforms(context) { + const names = fileSet(context.rootFiles); + const languages = Object.keys(context.languages || {}); + const selected = new Set(); + + const webMarkers = ["package.json", "index.html", "public", "src", "gemfile"]; + if (hasAny(names, webMarkers) || context.repoData.has_pages) { + selected.add("web"); + } + + if (languages.some((language) => IOS_LANGUAGES.includes(language))) { + selected.add("ios"); + } + if (languages.includes("Kotlin") || languages.includes("Java")) { + if (hasAny(names, ["build.gradle", "build.gradle.kts", "settings.gradle"])) { + selected.add("android"); + } + } + if (hasAny(names, ["dockerfile", "docker-compose.yml", "makefile"])) { + selected.add("linux"); + } + if (hasAny(names, DESKTOP_MARKERS)) { + selected.add("mac"); + selected.add("windows"); + } + + return [...selected]; + } + + function softwareType(context) { + const names = fileSet(context.rootFiles); + + if ([...names].some((name) => name.endsWith(".tf")) || names.has("terraform")) { + return "configurationFiles"; + } + if (hasAny(names, ["action.yml", "action.yaml"])) { + return "addon"; + } + if (hasAny(names, ["index.html", "public"]) || context.repoData.has_pages) { + return "standalone/web"; + } + if (hasAny(names, ["dockerfile", "docker-compose.yml"])) { + return "standalone/backend"; + } + if (hasAny(names, ["setup.py", "pyproject.toml", "gemspec", "go.mod"])) { + return "library"; + } + + return null; + } + + function repositoryType(context) { + const names = fileSet(context.rootFiles); + + if (hasAny(names, ["action.yml", "action.yaml"])) { + return "tools"; + } + if (context.repoData.has_pages || hasAny(names, ["index.html", "_config.yml"])) { + return "website"; + } + if (hasAny(names, ["openapi.yaml", "openapi.json", "swagger.yaml"])) { + return "APIs"; + } + if (hasAny(names, ["setup.py", "pyproject.toml", "go.mod"])) { + return "libraries"; + } + + return null; + } + + function contactEmail(readme) { + const matches = String(readme || "").match(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g); + if (!matches || !matches.length) { + return null; + } + + const unique = [...new Set(matches.map((address) => address.toLowerCase()))] + .filter((address) => !address.endsWith(".png") && !address.endsWith(".svg")); + + if (!unique.length) { + return null; + } + + return unique.find((address) => address.includes(".gov")) || unique[0]; + } + + function add(suggestions, field, value, why) { + const isEmptyArray = Array.isArray(value) && !value.length; + + if (value === null || value === undefined || value === "" || isEmptyArray) { + return; + } + + suggestions[field] = { value, source: "rule", why }; + } + + function suggest(context) { + const suggestions = {}; + const repo = context.repoData; + const release = context.latestRelease; + + add(suggestions, "status", developmentStatus(repo, release), + repo.archived ? "repository is archived" : "inferred from releases and recent activity"); + + add(suggestions, "maturityModelTier", maturityTier(context.rootFiles), + "based on the community health files present in the repository root"); + + if (release && release.tag_name) { + add(suggestions, "version", String(release.tag_name).replace(/^v/i, ""), + `latest release tag ${release.tag_name}`); + } + + if (repo.homepage && /^https?:\/\//i.test(repo.homepage)) { + add(suggestions, "homepageURL", repo.homepage, "homepage set on the GitHub repository"); + } + + add(suggestions, "platforms", platforms(context), "inferred from languages and root files"); + add(suggestions, "softwareType", softwareType(context), "inferred from root files"); + add(suggestions, "repositoryType", repositoryType(context), "inferred from root files"); + add(suggestions, "contact.email", contactEmail(context.readme), "email address found in the README"); + + return suggestions; + } + + window.AIHeuristics = { + suggest, + maturityTier, + developmentStatus, + platforms, + softwareType, + repositoryType, + contactEmail + }; +})(); From c3991c4a04b6434fd0569bbc7590a1bdfa692561 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 12:07:45 -0400 Subject: [PATCH 04/11] renamed determination file --- js/ai/determinations.js | 210 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 js/ai/determinations.js diff --git a/js/ai/determinations.js b/js/ai/determinations.js new file mode 100644 index 0000000..14c3d88 --- /dev/null +++ b/js/ai/determinations.js @@ -0,0 +1,210 @@ +// rule based field suggestions derived from repository metadata and the root file listing +(function () { + function fileSet(rootFiles) { + return new Set((rootFiles || []).map((file) => file.name.toLowerCase())); + } + + function hasAny(names, candidates) { + return candidates.some((candidate) => names.has(candidate)); + } + + function maturityTier(rootFiles) { + const names = fileSet(rootFiles); + + const hasReadme = hasAny(names, ["readme.md", "readme", "readme.rst", "readme.txt"]); + if (!hasReadme) { + return 0; + } + + const hasLicense = [...names].some((name) => name.startsWith("license") || name.startsWith("licence")); + if (!hasLicense) { + return 1; + } + + const hasContributing = hasAny(names, ["contributing.md", "contributing"]); + const hasConduct = hasAny(names, ["code_of_conduct.md", "code-of-conduct.md"]); + if (!hasContributing || !hasConduct) { + return 2; + } + + const hasSecurity = hasAny(names, ["security.md"]); + const hasStewardship = hasAny(names, ["maintainers.md", "governance.md", "codeowners.md"]); + const hasAutomation = names.has(".github"); + if (!hasSecurity || !hasStewardship || !hasAutomation) { + return 3; + } + + return 4; + } + + function majorVersion(tagName) { + const match = String(tagName || "").match(/(\d+)\./); + return match ? Number(match[1]) : null; + } + + function monthsSince(dateString) { + if (!dateString) { + return Infinity; + } + const elapsed = Date.now() - new Date(dateString).getTime(); + return elapsed / (1000 * 60 * 60 * 24 * 30); + } + + function developmentStatus(repoData, release) { + if (repoData.archived) { + return "Archival"; + } + + const major = release ? majorVersion(release.tag_name) : null; + if (major !== null && major >= 1) { + return "Production"; + } + if (major !== null) { + return "Beta"; + } + + const idleMonths = monthsSince(repoData.pushed_at); + if (idleMonths > 12) { + return "Ideation"; + } + + return "Development"; + } + + const IOS_LANGUAGES = ["Swift", "Objective-C"]; + const DESKTOP_MARKERS = ["electron-builder.yml", "tauri.conf.json"]; + + function platforms(context) { + const names = fileSet(context.rootFiles); + const languages = Object.keys(context.languages || {}); + const selected = new Set(); + + const webMarkers = ["package.json", "index.html", "public", "src", "gemfile"]; + if (hasAny(names, webMarkers) || context.repoData.has_pages) { + selected.add("web"); + } + + if (languages.some((language) => IOS_LANGUAGES.includes(language))) { + selected.add("ios"); + } + if (languages.includes("Kotlin") || languages.includes("Java")) { + if (hasAny(names, ["build.gradle", "build.gradle.kts", "settings.gradle"])) { + selected.add("android"); + } + } + if (hasAny(names, ["dockerfile", "docker-compose.yml", "makefile"])) { + selected.add("linux"); + } + if (hasAny(names, DESKTOP_MARKERS)) { + selected.add("mac"); + selected.add("windows"); + } + + return [...selected]; + } + + function softwareType(context) { + const names = fileSet(context.rootFiles); + + if ([...names].some((name) => name.endsWith(".tf")) || names.has("terraform")) { + return "configurationFiles"; + } + if (hasAny(names, ["action.yml", "action.yaml"])) { + return "addon"; + } + if (hasAny(names, ["index.html", "public"]) || context.repoData.has_pages) { + return "standalone/web"; + } + if (hasAny(names, ["dockerfile", "docker-compose.yml"])) { + return "standalone/backend"; + } + if (hasAny(names, ["setup.py", "pyproject.toml", "gemspec", "go.mod"])) { + return "library"; + } + + return null; + } + + function repositoryType(context) { + const names = fileSet(context.rootFiles); + + if (hasAny(names, ["action.yml", "action.yaml"])) { + return "tools"; + } + if (context.repoData.has_pages || hasAny(names, ["index.html", "_config.yml"])) { + return "website"; + } + if (hasAny(names, ["openapi.yaml", "openapi.json", "swagger.yaml"])) { + return "APIs"; + } + if (hasAny(names, ["setup.py", "pyproject.toml", "go.mod"])) { + return "libraries"; + } + + return null; + } + + function contactEmail(readme) { + const matches = String(readme || "").match(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g); + if (!matches || !matches.length) { + return null; + } + + const unique = [...new Set(matches.map((address) => address.toLowerCase()))] + .filter((address) => !address.endsWith(".png") && !address.endsWith(".svg")); + + if (!unique.length) { + return null; + } + + return unique.find((address) => address.includes(".gov")) || unique[0]; + } + + function add(suggestions, field, value, why) { + const isEmptyArray = Array.isArray(value) && !value.length; + + if (value === null || value === undefined || value === "" || isEmptyArray) { + return; + } + + suggestions[field] = { value, source: "rule", why }; + } + + function suggest(context) { + const suggestions = {}; + const repo = context.repoData; + const release = context.latestRelease; + + add(suggestions, "status", developmentStatus(repo, release), + repo.archived ? "repository is archived" : "inferred from releases and recent activity"); + + add(suggestions, "maturityModelTier", maturityTier(context.rootFiles), + "based on the community health files present in the repository root"); + + if (release && release.tag_name) { + add(suggestions, "version", String(release.tag_name).replace(/^v/i, ""), + `latest release tag ${release.tag_name}`); + } + + if (repo.homepage && /^https?:\/\//i.test(repo.homepage)) { + add(suggestions, "homepageURL", repo.homepage, "homepage set on the GitHub repository"); + } + + add(suggestions, "platforms", platforms(context), "inferred from languages and root files"); + add(suggestions, "softwareType", softwareType(context), "inferred from root files"); + add(suggestions, "repositoryType", repositoryType(context), "inferred from root files"); + add(suggestions, "contact.email", contactEmail(context.readme), "email address found in the README"); + + return suggestions; + } + + window.determinations = { + suggest, + maturityTier, + developmentStatus, + platforms, + softwareType, + repositoryType, + contactEmail + }; +})(); From 826338c4b7f8461a74a1e92bb0d8cc7ee6db6da5 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 12:45:34 -0400 Subject: [PATCH 05/11] removing dead code that never got hit --- js/ai/aiContext.js | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/js/ai/aiContext.js b/js/ai/aiContext.js index e82667d..3bf3a57 100644 --- a/js/ai/aiContext.js +++ b/js/ai/aiContext.js @@ -40,22 +40,6 @@ ); } - async function getRootFiles(repoInfo) { - const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/contents`; - - try { - const response = await fetch(endpoint, ghHeaders()); - if (!response.ok) { - return []; - } - const files = await response.json(); - return Array.isArray(files) ? files : []; - } catch (error) { - console.error("Could not list repository root:", error.message); - return []; - } - } - async function getReadme(repoInfo) { const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/readme`; @@ -187,12 +171,7 @@ return contextCache.get(cacheKey); } - const rootFilesPromise = prefetched.rootFiles - ? Promise.resolve(prefetched.rootFiles) - : getRootFiles(repoInfo); - - const [rootFiles, readme, latestRelease] = await Promise.all([ - rootFilesPromise, + const [readme, latestRelease] = await Promise.all([ getReadme(repoInfo), getLatestRelease(repoInfo) ]); @@ -201,7 +180,7 @@ repoInfo, repoData: prefetched.repoData, languages: prefetched.languages || {}, - rootFiles, + rootFiles: prefetched.rootFiles || [], readme, latestRelease }; From b350d01b31c8605b64a1b574fcdeb1097bc632ac Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 13:46:43 -0400 Subject: [PATCH 06/11] creating AI engine that runs the actual AI in browser --- js/ai/aiEngine.js | 241 ++++++++++++++++++++++++++++++++++++++++++ js/ai/aiHeuristics.js | 210 ------------------------------------ js/ai/webllmWorker.js | 7 ++ 3 files changed, 248 insertions(+), 210 deletions(-) create mode 100644 js/ai/aiEngine.js delete mode 100644 js/ai/aiHeuristics.js create mode 100644 js/ai/webllmWorker.js diff --git a/js/ai/aiEngine.js b/js/ai/aiEngine.js new file mode 100644 index 0000000..fa78108 --- /dev/null +++ b/js/ai/aiEngine.js @@ -0,0 +1,241 @@ +// WebLLM lifecycle: capability detection, lazy library load, model download +(function () { + + const LIBRARY_URL = "https://esm.run/@mlc-ai/web-llm@0.2.84"; + const FALLBACK_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.84/+esm"; + const CACHE_MARKER_KEY = "aiPrefill.cachedModel"; + + const MODEL = { + id: "Llama-3.2-1B-Instruct-q4f16_1-MLC", + sizeMB: 879, + readmeChars: 6000, + proseMaxTokens: 200 + }; + + let library = null; + let engine = null; + let worker = null; + let abortLoad = null; + let cancelled = false; + + // WebGPU needs a secure context, so this is false on a LAN IP even in Chrome + async function isSupported() { + if (!navigator.gpu) { + return { ok: false, reason: "no-webgpu" }; + } + + try { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + return { ok: false, reason: "no-adapter" }; + } + return { ok: true, adapter }; + } catch (error) { + return { ok: false, reason: "no-adapter" }; + } + } + + async function loadLibrary() { + if (library) { + return library; + } + + try { + library = await import(LIBRARY_URL); + } catch (error) { + console.warn("esm.run import failed, trying jsdelivr:", error); + library = await import(FALLBACK_LIBRARY_URL); + } + + return library; + } + + function isModelCached() { + try { + return localStorage.getItem(CACHE_MARKER_KEY) === MODEL.id; + } catch (error) { + return false; + } + } + + function markCached() { + try { + localStorage.setItem(CACHE_MARKER_KEY, MODEL.id); + } catch (error) { + } + } + + async function clearCache() { + const names = await caches.keys(); + + await Promise.all( + names.filter((name) => name.startsWith("webllm")).map((name) => caches.delete(name)) + ); + + try { + localStorage.removeItem(CACHE_MARKER_KEY); + } catch (error) { + } + + engine = null; + + if (worker) { + worker.terminate(); + worker = null; + } + } + + async function hasRoomFor() { + if (!navigator.storage || !navigator.storage.estimate) { + return true; + } + + try { + const { quota } = await navigator.storage.estimate(); + if (!quota) { + return true; + } + return quota > MODEL.sizeMB * 1.4 * 1e6; + } catch (error) { + return true; + } + } + + function workerURL() { + return new URL("js/ai/webLLMWorker.js", document.baseURI); + } + + // loads the model, reusing the engine if it is already resident. Racing + async function load(onProgress) { + cancelled = false; + + if (engine) { + return engine; + } + + const webllm = await loadLibrary(); + const initProgressCallback = (report) => onProgress(report); + const aborted = new Promise((resolve, reject) => { + abortLoad = reject; + }); + + try { + worker = new Worker(workerURL(), { type: "module" }); + engine = await Promise.race([ + webllm.CreateWebWorkerMLCEngine(worker, MODEL.id, { initProgressCallback }), + aborted + ]); + } catch (error) { + if (cancelled) { + throw error; + } + + console.warn("Worker engine failed, falling back to the main thread:", error); + + if (worker) { + worker.terminate(); + worker = null; + } + + engine = await Promise.race([ + webllm.CreateMLCEngine(MODEL.id, { initProgressCallback }), + aborted + ]); + } + + abortLoad = null; + markCached(); + + return engine; + } + + function cancel() { + cancelled = true; + + if (abortLoad) { + abortLoad(new DOMException("Aborted", "AbortError")); + abortLoad = null; + } + + if (worker) { + worker.terminate(); + worker = null; + } + + engine = null; + } + + function isCancelled() { + return cancelled; + } + + const MAX_PROSE_CHARS = 3000; + + async function stopGenerating() { + if (typeof engine.interruptGenerate === "function") { + try { + await engine.interruptGenerate(); + } catch (error) { + // Already stopped + } + } + } + + async function complete(messages, schema, options) { + const response = await engine.chat.completions.create({ + messages, + response_format: { type: "json_object", schema: JSON.stringify(schema) }, + temperature: options.temperature, + max_tokens: options.maxTokens + }); + + const content = response.choices[0].message.content; + + try { + return JSON.parse(content); + } catch (error) { + throw new Error("the model returned incomplete JSON"); + } + } + + async function completeStreamingText(messages, options, onToken) { + const stream = await engine.chat.completions.create({ + messages, + stream: true, + temperature: options.temperature, + max_tokens: options.maxTokens + }); + + let accumulated = ""; + + for await (const chunk of stream) { + if (cancelled) { + await stopGenerating(); + return null; + } + + accumulated += chunk.choices[0]?.delta?.content || ""; + onToken(accumulated); + + if (accumulated.length > MAX_PROSE_CHARS) { + await stopGenerating(); + break; + } + } + + return accumulated; + } + + window.AIEngine = { + MODEL, + isSupported, + hasRoomFor, + isModelCached, + clearCache, + load, + cancel, + isCancelled, + complete, + completeStreamingText + }; +})(); diff --git a/js/ai/aiHeuristics.js b/js/ai/aiHeuristics.js deleted file mode 100644 index 365d7e4..0000000 --- a/js/ai/aiHeuristics.js +++ /dev/null @@ -1,210 +0,0 @@ -// rule based field suggestions derived from repository metadata and the root file listing -(function () { - function fileSet(rootFiles) { - return new Set((rootFiles || []).map((file) => file.name.toLowerCase())); - } - - function hasAny(names, candidates) { - return candidates.some((candidate) => names.has(candidate)); - } - - function maturityTier(rootFiles) { - const names = fileSet(rootFiles); - - const hasReadme = hasAny(names, ["readme.md", "readme", "readme.rst", "readme.txt"]); - if (!hasReadme) { - return 0; - } - - const hasLicense = [...names].some((name) => name.startsWith("license") || name.startsWith("licence")); - if (!hasLicense) { - return 1; - } - - const hasContributing = hasAny(names, ["contributing.md", "contributing"]); - const hasConduct = hasAny(names, ["code_of_conduct.md", "code-of-conduct.md"]); - if (!hasContributing || !hasConduct) { - return 2; - } - - const hasSecurity = hasAny(names, ["security.md"]); - const hasStewardship = hasAny(names, ["maintainers.md", "governance.md", "codeowners.md"]); - const hasAutomation = names.has(".github"); - if (!hasSecurity || !hasStewardship || !hasAutomation) { - return 3; - } - - return 4; - } - - function majorVersion(tagName) { - const match = String(tagName || "").match(/(\d+)\./); - return match ? Number(match[1]) : null; - } - - function monthsSince(dateString) { - if (!dateString) { - return Infinity; - } - const elapsed = Date.now() - new Date(dateString).getTime(); - return elapsed / (1000 * 60 * 60 * 24 * 30); - } - - function developmentStatus(repoData, release) { - if (repoData.archived) { - return "Archival"; - } - - const major = release ? majorVersion(release.tag_name) : null; - if (major !== null && major >= 1) { - return "Production"; - } - if (major !== null) { - return "Beta"; - } - - const idleMonths = monthsSince(repoData.pushed_at); - if (idleMonths > 12) { - return "Ideation"; - } - - return "Development"; - } - - const IOS_LANGUAGES = ["Swift", "Objective-C"]; - const DESKTOP_MARKERS = ["electron-builder.yml", "tauri.conf.json"]; - - function platforms(context) { - const names = fileSet(context.rootFiles); - const languages = Object.keys(context.languages || {}); - const selected = new Set(); - - const webMarkers = ["package.json", "index.html", "public", "src", "gemfile"]; - if (hasAny(names, webMarkers) || context.repoData.has_pages) { - selected.add("web"); - } - - if (languages.some((language) => IOS_LANGUAGES.includes(language))) { - selected.add("ios"); - } - if (languages.includes("Kotlin") || languages.includes("Java")) { - if (hasAny(names, ["build.gradle", "build.gradle.kts", "settings.gradle"])) { - selected.add("android"); - } - } - if (hasAny(names, ["dockerfile", "docker-compose.yml", "makefile"])) { - selected.add("linux"); - } - if (hasAny(names, DESKTOP_MARKERS)) { - selected.add("mac"); - selected.add("windows"); - } - - return [...selected]; - } - - function softwareType(context) { - const names = fileSet(context.rootFiles); - - if ([...names].some((name) => name.endsWith(".tf")) || names.has("terraform")) { - return "configurationFiles"; - } - if (hasAny(names, ["action.yml", "action.yaml"])) { - return "addon"; - } - if (hasAny(names, ["index.html", "public"]) || context.repoData.has_pages) { - return "standalone/web"; - } - if (hasAny(names, ["dockerfile", "docker-compose.yml"])) { - return "standalone/backend"; - } - if (hasAny(names, ["setup.py", "pyproject.toml", "gemspec", "go.mod"])) { - return "library"; - } - - return null; - } - - function repositoryType(context) { - const names = fileSet(context.rootFiles); - - if (hasAny(names, ["action.yml", "action.yaml"])) { - return "tools"; - } - if (context.repoData.has_pages || hasAny(names, ["index.html", "_config.yml"])) { - return "website"; - } - if (hasAny(names, ["openapi.yaml", "openapi.json", "swagger.yaml"])) { - return "APIs"; - } - if (hasAny(names, ["setup.py", "pyproject.toml", "go.mod"])) { - return "libraries"; - } - - return null; - } - - function contactEmail(readme) { - const matches = String(readme || "").match(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g); - if (!matches || !matches.length) { - return null; - } - - const unique = [...new Set(matches.map((address) => address.toLowerCase()))] - .filter((address) => !address.endsWith(".png") && !address.endsWith(".svg")); - - if (!unique.length) { - return null; - } - - return unique.find((address) => address.includes(".gov")) || unique[0]; - } - - function add(suggestions, field, value, why) { - const isEmptyArray = Array.isArray(value) && !value.length; - - if (value === null || value === undefined || value === "" || isEmptyArray) { - return; - } - - suggestions[field] = { value, source: "rule", why }; - } - - function suggest(context) { - const suggestions = {}; - const repo = context.repoData; - const release = context.latestRelease; - - add(suggestions, "status", developmentStatus(repo, release), - repo.archived ? "repository is archived" : "inferred from releases and recent activity"); - - add(suggestions, "maturityModelTier", maturityTier(context.rootFiles), - "based on the community health files present in the repository root"); - - if (release && release.tag_name) { - add(suggestions, "version", String(release.tag_name).replace(/^v/i, ""), - `latest release tag ${release.tag_name}`); - } - - if (repo.homepage && /^https?:\/\//i.test(repo.homepage)) { - add(suggestions, "homepageURL", repo.homepage, "homepage set on the GitHub repository"); - } - - add(suggestions, "platforms", platforms(context), "inferred from languages and root files"); - add(suggestions, "softwareType", softwareType(context), "inferred from root files"); - add(suggestions, "repositoryType", repositoryType(context), "inferred from root files"); - add(suggestions, "contact.email", contactEmail(context.readme), "email address found in the README"); - - return suggestions; - } - - window.AIHeuristics = { - suggest, - maturityTier, - developmentStatus, - platforms, - softwareType, - repositoryType, - contactEmail - }; -})(); diff --git a/js/ai/webllmWorker.js b/js/ai/webllmWorker.js new file mode 100644 index 0000000..500505b --- /dev/null +++ b/js/ai/webllmWorker.js @@ -0,0 +1,7 @@ +import { WebWorkerMLCEngineHandler } from "https://esm.run/@mlc-ai/web-llm@0.2.84"; + +const handler = new WebWorkerMLCEngineHandler(); + +self.onmessage = (message) => { + handler.onmessage(message); +}; From c2c67880678434af64f2aad8f7e308be4b1ffc45 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 13:52:27 -0400 Subject: [PATCH 07/11] creating orchestator --- js/ai/aiOrchestrator.js | 155 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 js/ai/aiOrchestrator.js diff --git a/js/ai/aiOrchestrator.js b/js/ai/aiOrchestrator.js new file mode 100644 index 0000000..28030d9 --- /dev/null +++ b/js/ai/aiOrchestrator.js @@ -0,0 +1,155 @@ +// orchestrates AI-assisted field suggestions +(function () { + const AI_FIELDS = { + prose: ["longDescription"], + classify: [ + "status", "softwareType", "repositoryType", "maintenance", "platforms", + "userType", "subsetInHealthcare", "localisation", "userInput", "maturityModelTier" + ], + categories: ["categories"] + }; + + const NEVER_TOUCH = new Set([ + "group", "projects", "systems", "fismaLevel", "contractNumber", "AIUseCaseID", + "laborHours", "disclaimerText", "disclaimerURL", + "permissions", "organization", "repositoryURL", "repositoryVisibility", + "repositoryHost", "vcs", "name", "description", "languages", "tags", "date", + "reuseFrequency", "SBOM", "feedbackMechanism" + ]); + + const PUBLICCODE_CATEGORIES = [ + "accounting", "agile-project-management", "applicant-tracking", "application-development", + "appointment-scheduling", "backup", "billing-and-invoicing", "blog", "budgeting", + "business-intelligence", "business-process-management", "cad", "call-center-management", + "cloud-management", "collaboration", "communications", "compliance-management", + "contact-management", "content-management", "crm", "customer-service-and-support", + "data-analytics", "data-collection", "data-visualization", "design", "design-system", + "digital-asset-management", "digital-citizenship", "document-management", "donor-management", + "e-commerce", "e-signature", "educational-content", "email-management", "email-marketing", + "employee-management", "enterprise-project-management", "enterprise-social-networking", + "erp", "event-management", "facility-management", "feedback-and-reviews-management", + "financial-reporting", "fleet-management", "fundraising", "gamification", + "geographic-information-systems", "grant-management", "graphic-design", "help-desk", "hr", + "ide", "identity-management", "instant-messaging", "integrated-library-system", + "inventory-management", "it-asset-management", "it-development", "it-management", + "it-security", "it-service-management", "knowledge-management", "learning-management-system", + "marketing", "mind-mapping", "mobile-marketing", "mobile-payment", "network-management", + "office", "online-booking", "online-community", "payment-gateway", "payroll", + "predictive-analysis", "procurement", "productivity-suite", "project-collaboration", + "project-management", "property-management", "real-estate-management", + "regulations-and-directives", "remote-support", "resource-management", "sales-management", + "seo", "service-desk", "social-media-management", "survey", "talent-management", + "task-management", "taxes-management", "test-management", "time-management", + "time-tracking", "translation", "video-conferencing", "video-editing", "visitor-management", + "voip", "warehouse-management", "web-collaboration", "web-conferencing", "website-builder", + "whistleblowing", "workflow-management", "other" + ]; + + const SYSTEM_PROMPT = + "You are a metadata assistant. You classify a software repository and write short " + + "factual descriptions of it for a US government software inventory (code.json). " + + "Use ONLY facts present in the CONTEXT. Never invent URLs, people, versions, metrics " + + "or agency names. If the context does not support a value, choose the most " + + "conservative option. Reply with JSON only, no commentary."; + + const EXTRA_GUIDANCE = [ + "Extra guidance:", + "- status: archived -> \"Archival\"; a release tagged >= 1.0.0 or a live homepage ->", + " \"Production\"; only 0.x releases -> \"Beta\"; no releases but pushed in the last 90", + " days -> \"Development\"; no releases and no push in 12 months -> \"Ideation\".", + "- maturityModelTier: 0 = no README; 1 = README + LICENSE; 2 = also CONTRIBUTING and", + " CODE_OF_CONDUCT; 3 = also SECURITY, MAINTAINERS or GOVERNANCE plus CI workflows;", + " 4 = also community docs, a roadmap and public meetings. Use the Root files list.", + "- subsetInHealthcare: leave the array empty unless the context explicitly mentions", + " Medicare, Medicaid, health policy or healthcare operations.", + "- localisation: true only if the context mentions translations, i18n or multiple languages." + ].join("\n"); + + let schema = null; + let context = null; + let suggestions = {}; + let busy = false; + let modelAvailable = false; + let reviewing = false; + let hasApplied = false; + const attempted = new Set(); + + // ---- schema derivation ------------------------------------------------- + + function currentPage() { + const params = new URLSearchParams(window.location.search); + return params.get("page") || "gov"; + } + + function schemaFor(key) { + const [head, tail] = key.split("."); + const parent = schema.properties[head]; + + if (!parent) { + return null; + } + + return tail ? (parent.properties || {})[tail] || null : parent; + } + + function stripToGrammar(field) { + if (field.type === "array") { + return { type: "array", items: stripToGrammar(field.items), maxItems: 4 }; + } + + const stripped = { type: Array.isArray(field.type) ? "string" : field.type }; + + if (field.enum) { + stripped.enum = field.enum; + } + + return stripped; + } + + function subSchemaFor(keys, extraProperties) { + const properties = Object.assign({}, extraProperties); + + for (const key of keys) { + if (NEVER_TOUCH.has(key.split(".")[0])) { + continue; + } + + const field = schemaFor(key); + if (!field) { + continue; + } + + properties[key] = stripToGrammar(field); + } + + return { + type: "object", + properties, + required: Object.keys(properties), + additionalProperties: false + }; + } + + function fieldGuidance(keys) { + return keys + .filter((key) => schemaFor(key)) + .map((key) => { + const field = schemaFor(key); + const options = field.enum || (field.items && field.items.enum); + const choices = options + ? `\n choose from: ${options.join(" | ")}` + : "\n answer true or false"; + + return `- ${key}: ${field.description || ""}${choices}`; + }) + .join("\n"); + } + + window.AIOrchestrator = { + AI_FIELDS, + NEVER_TOUCH, + PUBLICCODE_CATEGORIES, + schemaFor, + subSchemaFor + }; +})(); From b9bf5401a9051bf4c02b395132743a5ac8aad584 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Thu, 3 Sep 2026 13:56:00 -0400 Subject: [PATCH 08/11] adding validation logic --- js/ai/aiEngine.js | 2 +- js/ai/aiOrchestrator.js | 72 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/js/ai/aiEngine.js b/js/ai/aiEngine.js index fa78108..80d163f 100644 --- a/js/ai/aiEngine.js +++ b/js/ai/aiEngine.js @@ -3,7 +3,7 @@ const LIBRARY_URL = "https://esm.run/@mlc-ai/web-llm@0.2.84"; const FALLBACK_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.84/+esm"; - const CACHE_MARKER_KEY = "aiPrefill.cachedModel"; + const CACHE_MARKER_KEY = "aiOrchestrator.cachedModel"; const MODEL = { id: "Llama-3.2-1B-Instruct-q4f16_1-MLC", diff --git a/js/ai/aiOrchestrator.js b/js/ai/aiOrchestrator.js index 28030d9..a7db6ec 100644 --- a/js/ai/aiOrchestrator.js +++ b/js/ai/aiOrchestrator.js @@ -145,11 +145,81 @@ .join("\n"); } + // ---- validation -------------------------------------------------------- + + function validateValue(field, value) { + const fail = (why) => ({ ok: false, why }); + + if (field.enum) { + const normalised = field.type === "integer" ? Number(value) : value; + return field.enum.includes(normalised) + ? { ok: true, value: normalised } + : fail(`"${value}" is not one of ${field.enum.join(", ")}`); + } + + if (field.type === "array") { + if (!Array.isArray(value)) { + return fail("expected an array"); + } + + const allowed = field.items && field.items.enum; + const unique = [...new Set(value.map((entry) => String(entry).trim()).filter(Boolean))]; + + if (!allowed) { + return unique.length ? { ok: true, value: unique.slice(0, 8) } : fail("empty"); + } + + const kept = unique.filter((entry) => allowed.includes(entry)); + const dropped = unique.filter((entry) => !allowed.includes(entry)); + + return kept.length + ? { ok: true, value: kept, dropped } + : fail("no valid options returned"); + } + + if (field.type === "boolean") { + if (typeof value === "boolean") { + return { ok: true, value }; + } + if (value === "true" || value === "false") { + return { ok: true, value: value === "true" }; + } + return fail("expected true or false"); + } + + if (field.type === "number" || field.type === "integer") { + const numeric = Number(value); + return Number.isFinite(numeric) ? { ok: true, value: numeric } : fail("not a number"); + } + + let text = String(value).replace(/\s+/g, " ").trim(); + + if (!text) { + return fail("empty"); + } + if (field.format === "uri" && !/^https?:\/\//i.test(text)) { + return fail("not a URL"); + } + if (field.format === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text)) { + return fail("not an email address"); + } + if (field.maxLength) { + text = text.slice(0, field.maxLength); + } + + const warn = field.minLength && text.length < field.minLength + ? `below the ${field.minLength}-character minimum` + : undefined; + + return { ok: true, value: text, warn }; + } + window.AIOrchestrator = { AI_FIELDS, NEVER_TOUCH, PUBLICCODE_CATEGORIES, schemaFor, - subSchemaFor + subSchemaFor, + validateValue }; })(); From f4d350823c004861c621889a8539c11744fa4a5b Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 9 Sep 2026 11:31:25 -0400 Subject: [PATCH 09/11] adding panel and run steps --- js/ai/aiOrchestrator.js | 487 +++++++++++++++++++++++++++++++++++++++- js/ai/aiReviewPanel.js | 325 +++++++++++++++++++++++++++ 2 files changed, 808 insertions(+), 4 deletions(-) create mode 100644 js/ai/aiReviewPanel.js diff --git a/js/ai/aiOrchestrator.js b/js/ai/aiOrchestrator.js index a7db6ec..722d95f 100644 --- a/js/ai/aiOrchestrator.js +++ b/js/ai/aiOrchestrator.js @@ -67,11 +67,8 @@ let schema = null; let context = null; - let suggestions = {}; let busy = false; let modelAvailable = false; - let reviewing = false; - let hasApplied = false; const attempted = new Set(); // ---- schema derivation ------------------------------------------------- @@ -214,12 +211,494 @@ return { ok: true, value: text, warn }; } + // ---- form.io interop --------------------------------------------------- + + function toWidgetValue(field, value) { + switch (determineType(field)) { + case "selectboxes": { + const dictionary = {}; + for (const option of field.items.enum) { + dictionary[option] = false; + } + for (const entry of value) { + if (Object.prototype.hasOwnProperty.call(dictionary, entry)) { + dictionary[entry] = true; + } + } + return dictionary; + } + case "select-boolean": + return value === true || value === "true" ? "true" : "false"; + case "radio": + return String(value); + case "tags": + return Array.isArray(value) ? value : []; + case "number": + case "integer": + return Number(value); + default: + return value; + } + } + + function isFieldEmpty(key) { + const form = window.formIOInstance; + const [head, tail] = key.split("."); + const component = form.getComponent(head); + + if (!component) { + return false; + } + + const current = component.getValue(); + const value = tail ? (current || {})[tail] : current; + + if (value === null || value === undefined || value === "") { + return true; + } + if (Array.isArray(value)) { + return !value.length; + } + if (typeof value === "object") { + return Object.values(value).every((entry) => !entry); + } + + return false; + } + + function setSchemaValue(key, value) { + const form = window.formIOInstance; + const [head, tail] = key.split("."); + const component = form.getComponent(head); + + if (!component) { + throw new Error(`no component "${head}"`); + } + + if (!tail) { + component.setValue(value); + return; + } + + const current = component.getValue() || {}; + current[tail] = value; + component.setValue(current); + } + + // ---- prompts ----------------------------------------------------------- + + function baseMessages() { + const readme = window.AIContext.condenseReadme(context.readme, window.AIEngine.MODEL.readmeChars); + + return [ + { role: "system", content: SYSTEM_PROMPT }, + { + role: "user", + content: `CONTEXT\n=======\n${context.facts}\n\nREADME (condensed)\n==================\n${readme}` + } + ]; + } + + const PROSE_TASK = [ + "Write the \"longDescription\" field for this repository's code.json entry.", + "", + "Requirements:", + "- Exactly 2 or 3 sentences, between 150 and 400 characters. Be concise.", + "- Plain prose. No markdown, no bullet lists, no repetition.", + "- Say what the software does and who would use it. Nothing else.", + "- Use only facts from the CONTEXT above. Do not invent features, agencies, URLs,", + " people or metrics. Do not mention this prompt.", + "- Do not start with \"This repository\" - or the project name.", + "- Do not copy headings, titles, underlines or any other formatting from the README.", + "- Reply with the description only. No preamble, no heading, no quotation marks." + ].join("\n"); + + function stripArtifacts(text) { + return String(text || "") + .replace(/^\s*[^\n]{0,60}\r?\n[=\-]{3,}[^\n]*\r?\n/, "") + .replace(/^\s*#{1,6}\s+[^\n]{0,80}\r?\n/, "") + .replace(/^\s*(?:long\s*description|description|summary)\s*[:\-]+\s*/i, "") + .replace(/^\s*(?:sure|certainly|of course|okay|ok)\b[,!.:]?\s*/i, "") + .replace(/^\s*here(?:'s| is)\b[^:\n]{0,80}:\s*/i, "") + .replace(/^[\s"'`]+|[\s"'`]+$/g, "") + .replace(/[=]{3,}|[-]{3,}/g, " ") + .replace(/[*_#`]/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + function cleanProse(text) { + const cleaned = stripArtifacts(text); + const lastSentenceEnd = cleaned.lastIndexOf("."); + + if (lastSentenceEnd > 150 && lastSentenceEnd < cleaned.length - 1) { + return cleaned.slice(0, lastSentenceEnd + 1); + } + + return cleaned; + } + + // ---- generation -------------------------------------------------------- + + function eligible(keys) { + return keys.filter((key) => + schemaFor(key) && isFieldEmpty(key) && !window.AIReviewPanel.has(key) && !attempted.has(key)); + } + + async function generateProse(onToken) { + const keys = eligible(AI_FIELDS.prose); + if (!keys.length) { + return; + } + + const messages = baseMessages().concat({ role: "user", content: PROSE_TASK }); + const text = await window.AIEngine.completeStreamingText( + messages, + { temperature: 0.5, maxTokens: window.AIEngine.MODEL.proseMaxTokens }, + (accumulated) => onToken(accumulated) + ); + + keys.forEach((key) => attempted.add(key)); + + if (text !== null) { + window.AIReviewPanel.record("longDescription", cleanProse(text)); + } + } + + async function generateClassifications() { + const keys = eligible(AI_FIELDS.classify); + if (!keys.length) { + return; + } + + const responseSchema = subSchemaFor(keys, {}); + const task = `Classify this repository. Fields:\n${fieldGuidance(keys)}\n\n${EXTRA_GUIDANCE}`; + + const messages = baseMessages().concat({ role: "user", content: task }); + const result = await window.AIEngine.complete(messages, responseSchema, { + temperature: 0.2, + maxTokens: 400 + }); + + keys.forEach((key) => attempted.add(key)); + + for (const key of keys) { + window.AIReviewPanel.record(key, result[key]); + } + } + + async function generateCategories() { + const keys = eligible(AI_FIELDS.categories); + if (!keys.length) { + return; + } + + const responseSchema = { + type: "object", + properties: { + categories: { + type: "array", + items: { type: "string", enum: PUBLICCODE_CATEGORIES }, + maxItems: 3 + } + }, + required: ["categories"], + additionalProperties: false + }; + + const task = "Choose up to three categories that best describe what this software does."; + const messages = baseMessages().concat({ role: "user", content: task }); + const result = await window.AIEngine.complete(messages, responseSchema, { + temperature: 0.2, + maxTokens: 128 + }); + + keys.forEach((key) => attempted.add(key)); + + window.AIReviewPanel.record("categories", result.categories); + } + + // ---- run --------------------------------------------------------------- + + function element(id) { + return document.getElementById(id); + } + + function show(id, visible) { + element(id).style.display = visible ? "" : "none"; + } + + function formatSize(sizeMB) { + return sizeMB >= 1000 ? `${(sizeMB / 1000).toFixed(1)} GB` : `${sizeMB} MB`; + } + + function setStatus(text) { + element("ai-progress-text").textContent = text; + } + + function setProgress(fraction) { + element("ai-progress").value = Math.round((fraction || 0) * 100); + } + + function draftableFields() { + const all = AI_FIELDS.prose.concat(AI_FIELDS.classify, AI_FIELDS.categories); + return all.filter((key) => schemaFor(key) && isFieldEmpty(key)); + } + + function remainingModelFields() { + return eligible(draftableFields()).length; + } + + function updateRunButton() { + const button = element("ai-run"); + + if (!modelAvailable) { + return; + } + + if (busy) { + button.disabled = true; + return; + } + + if (window.AIReviewPanel.isReviewing()) { + button.disabled = true; + button.textContent = window.AIReviewPanel.hasBeenApplied() + ? "Discard the drafts below to draft again" + : "Apply or discard the drafts below"; + return; + } + + const draftable = (context && schema) ? draftableFields() : []; + + if (!draftable.length) { + button.disabled = true; + button.textContent = "Nothing left to draft"; + return; + } + + const remaining = remainingModelFields(); + const count = remaining || draftable.length; + const plural = count === 1 ? "field" : "fields"; + + button.disabled = false; + + if (!remaining) { + button.textContent = `Draft ${count} ${plural} again`; + return; + } + + button.textContent = window.AIEngine.isModelCached() + ? `Draft ${count} ${plural}` + : `Download model and draft ${count} ${plural} (~${formatSize(window.AIEngine.MODEL.sizeMB)})`; + } + + function revealPanel() { + const panel = element("ai-panel"); + + panel.style.display = ""; + panel.classList.add("ai-reveal"); + show("ai-enhance", false); + panel.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + async function onRepoContextReady(event) { + try { + if (!schema) { + schema = await retrieveFile(`schemas/${currentPage()}/schema.json`); + } + + context = await window.AIContext.gather(event.detail.repoInfo, event.detail); + + window.AIReviewPanel.reset(); + attempted.clear(); + + window.AIReviewPanel.applyRules(window.determinations.suggest(context)); + + if (modelAvailable && remainingModelFields()) { + if (element("ai-panel").style.display === "none") { + show("ai-enhance", true); + } + updateRunButton(); + } + } catch (error) { + console.error("AI context gathering failed:", error); + } + } + + function describeError(error) { + const message = String((error && error.message) || error); + + if (/out of memory|device lost|OOM|createBuffer/i.test(message)) { + return "Your GPU ran out of memory. Try the Llama 3.2 1B model, or close other tabs."; + } + if (/QuotaExceeded/i.test(message)) { + return "Your browser ran out of storage for the model. Free up disk space, and note " + + "that private/incognito windows cannot cache it."; + } + if (/huggingface|jsdelivr|esm\.run|raw\.githubusercontent|Failed to fetch|NetworkError/i.test(message)) { + return "Could not download the model. This needs access to huggingface.co, " + + "raw.githubusercontent.com and cdn.jsdelivr.net - if you are on a managed " + + "network, those hosts may need to be allowed."; + } + + return `In-browser AI failed: ${message}`; + } + + async function runStep(label, failed, work) { + try { + await work(); + } catch (error) { + if (window.AIEngine.isCancelled() || (error && error.name === "AbortError")) { + throw error; + } + console.error(`AI step failed (${label}):`, error); + failed.push(label); + } + } + + async function run() { + if (!remainingModelFields()) { + attempted.clear(); + } + + if (!(await window.AIEngine.hasRoomFor())) { + window.showErrorNotification( + "Your browser has less storage available than this model needs. " + + "Private/incognito windows cannot cache it - try a normal window." + ); + return; + } + + busy = true; + updateRunButton(); + show("ai-progress-wrap", true); + show("ai-cancel", true); + element("ai-stream").textContent = ""; + setProgress(0); + setStatus("Preparing the model..."); + + if (typeof gas4 === "function") { + gas4("ai_generation_started", { + form_name: "code.json form", + form_id: "formio", + form_destination: window.location.pathname, + ai_model: window.AIEngine.MODEL.id + }); + } + + try { + await window.AIEngine.load((report) => { + setProgress(report.progress); + setStatus(report.text || ""); + }); + + if (window.AIEngine.isCancelled()) { + return; + } + + setProgress(1); + + const failed = []; + + await runStep("the long description", failed, () => + generateProse((partial) => { + setStatus("Writing the long description (1 of 3)..."); + element("ai-stream").textContent = stripArtifacts(partial).slice(-800); + })); + + setStatus("Classifying fields (2 of 3)..."); + await runStep("the classification fields", failed, generateClassifications); + + setStatus("Choosing categories (3 of 3)..."); + await runStep("the categories", failed, generateCategories); + + if (window.AIEngine.isCancelled()) { + return; + } + + window.AIReviewPanel.render(); + + const drafted = window.AIReviewPanel.count(); + + if (failed.length && drafted) { + window.showErrorNotification( + `Drafted ${drafted} field(s), but could not finish ${failed.join(" or ")}.` + ); + } else if (failed.length) { + window.showErrorNotification(`The model could not finish ${failed.join(" or ")}.`); + } + } catch (error) { + if (error && error.name === "AbortError") { + setStatus("Cancelled."); + } else { + console.error("AI generation failed:", error); + window.showErrorNotification(describeError(error)); + } + } finally { + busy = false; + show("ai-cancel", false); + show("ai-progress-wrap", false); + updateRunButton(); + } + } + + async function init() { + document.addEventListener("repo-context-ready", onRepoContextReady); + + const applyButton = element("ai-apply"); + applyButton.dataset.label = applyButton.textContent.trim(); + + const support = await window.AIEngine.isSupported(); + + if (!support.ok) { + console.info("In-browser AI unavailable:", support.reason); + return; + } + + modelAvailable = true; + + element("ai-enhance").addEventListener("click", revealPanel); + + applyButton.addEventListener("click", () => { + window.AIReviewPanel.apply(); + updateRunButton(); + }); + + element("ai-discard").addEventListener("click", () => { + window.AIReviewPanel.reset(); + updateRunButton(); + }); + + element("ai-run").addEventListener("click", run); + + element("ai-cancel").addEventListener("click", () => { + window.AIEngine.cancel(); + setStatus("Cancelling..."); + }); + + element("ai-clear-cache").addEventListener("click", async () => { + await window.AIEngine.clearCache(); + updateRunButton(); + }); + + updateRunButton(); + } + + document.addEventListener("DOMContentLoaded", init); + window.AIOrchestrator = { AI_FIELDS, NEVER_TOUCH, PUBLICCODE_CATEGORIES, schemaFor, subSchemaFor, - validateValue + validateValue, + toWidgetValue, + isFieldEmpty, + setSchemaValue, + cleanProse, + stripArtifacts, + run }; })(); diff --git a/js/ai/aiReviewPanel.js b/js/ai/aiReviewPanel.js new file mode 100644 index 0000000..1b654ea --- /dev/null +++ b/js/ai/aiReviewPanel.js @@ -0,0 +1,325 @@ +// renders AI suggestions for review and writes accepted values into the form +(function () { + const suggestions = {}; + let reviewing = false; + let hasApplied = false; + + function element(id) { + return document.getElementById(id); + } + + function show(id, visible) { + element(id).style.display = visible ? "" : "none"; + } + + function escapeHTML(text) { + return String(text).replace(/[&<>"]/g, (character) => ( + { "&": "&", "<": "<", ">": ">", '"': """ }[character] + )); + } + + const BUTTON_FLASH_MS = 1200; + + function flashButton(label) { + const button = element("ai-apply"); + + button.textContent = label; + button.setAttribute("aria-disabled", "true"); + + setTimeout(() => { + button.textContent = button.dataset.label; + button.removeAttribute("aria-disabled"); + }, BUTTON_FLASH_MS); + } + + function lockReviewPanel(count) { + const button = element("ai-apply"); + const plural = count === 1 ? "field" : "fields"; + + button.textContent = `✓ Applied ${count} ${plural}`; + button.setAttribute("aria-disabled", "true"); + button.classList.add("ai-button--applied"); + + element("ai-review-list").disabled = true; + } + + function reset() { + const button = element("ai-apply"); + + button.textContent = button.dataset.label; + button.removeAttribute("aria-disabled"); + button.classList.remove("ai-button--applied"); + element("ai-review-list").disabled = false; + + for (const key of Object.keys(suggestions)) { + delete suggestions[key]; + } + reviewing = false; + hasApplied = false; + show("ai-review", false); + } + + function editorFor(key, field, value) { + const identifier = `ai-f-${key.replace(/\./g, "-")}`; + + switch (determineType(field)) { + case "selectboxes": + return `
` + field.items.enum + .map((option, index) => ` +
+ + +
`) + .join("") + "
"; + + case "select-boolean": + return `"; + + case "radio": + return `"; + + case "tags": + return ``; + + case "number": + case "integer": + return ``; + + default: + return ``; + } + } + + function readRowValue(key, field) { + if (determineType(field) === "selectboxes") { + const container = document.querySelector(`.ai-options[data-field="${key}"]`); + return [...container.querySelectorAll("input:checked")] + .map((input) => input.dataset.option); + } + + const editor = document.querySelector(`.ai-edit[data-field="${key}"]`); + if (!editor) { + return suggestions[key].value; + } + + if (determineType(field) === "tags") { + return editor.value.split(",").map((entry) => entry.trim()).filter(Boolean); + } + + return editor.value; + } + + function suggestionRow(key, suggestion) { + const field = window.AIOrchestrator.schemaFor(key); + const identifier = `ai-f-${key.replace(/\./g, "-")}`; + + const warning = suggestion.warn + ? `${escapeHTML(suggestion.warn)}` + : ""; + const dropped = suggestion.dropped && suggestion.dropped.length + ? `
Dropped: ${escapeHTML(suggestion.dropped.join(", "))} (not valid options)
` + : ""; + const control = editorFor(key, field, suggestion.value); + + return ` +
+ + + ${control} + ${dropped} +
`; + } + + function render() { + const keys = Object.keys(suggestions); + + if (!keys.length) { + reviewing = false; + show("ai-review", false); + return; + } + + reviewing = true; + + element("ai-review-list").innerHTML = keys + .map((key) => suggestionRow(key, suggestions[key])) + .join(""); + + show("ai-review", true); + } + + function writeField(key, rawValue) { + if (window.AIOrchestrator.NEVER_TOUCH.has(key.split(".")[0])) { + return false; + } + + const field = window.AIOrchestrator.schemaFor(key); + if (!field) { + return false; + } + + const validated = window.AIOrchestrator.validateValue(field, rawValue); + if (!validated.ok) { + console.warn(`Skipped ${key}: ${validated.why}`); + return false; + } + + try { + window.AIOrchestrator.setSchemaValue(key, window.AIOrchestrator.toWidgetValue(field, validated.value)); + return true; + } catch (error) { + console.error("Could not set", key, error); + return false; + } + } + + function applyRules(rules) { + let applied = 0; + + for (const key of Object.keys(rules)) { + if (!window.AIOrchestrator.schemaFor(key) || !window.AIOrchestrator.isFieldEmpty(key)) { + continue; + } + if (writeField(key, rules[key].value)) { + applied++; + } + } + + return applied; + } + + function apply() { + const form = window.formIOInstance; + + if (element("ai-apply").getAttribute("aria-disabled") === "true") { + return; + } + + if (!form) { + window.showErrorNotification("Form interface not initialized. Please refresh and try again."); + return; + } + + let applied = 0; + const failed = []; + const written = []; + const checked = document.querySelectorAll("#ai-review-list .ai-row-select:checked"); + + checked.forEach((checkbox) => { + const key = checkbox.dataset.field; + + try { + const raw = readRowValue(key, window.AIOrchestrator.schemaFor(key)); + + if (Array.isArray(raw) && !raw.length) { + return; + } + + if (writeField(key, raw)) { + applied++; + written.push(key); + } else { + failed.push(key); + } + } catch (error) { + console.error("Could not read the review row for", key, error); + failed.push(key || "an unnamed row"); + } + }); + + if (typeof gas4 === "function") { + gas4("ai_suggestions_applied", { + form_name: "code.json form", + form_id: "formio", + form_destination: window.location.pathname, + fields_applied: applied + }); + } + + if (failed.length) { + window.showErrorNotification( + `Applied ${applied} field(s). Could not set: ${failed.join(", ")}.` + ); + } + + if (!applied) { + flashButton("Nothing selected"); + return; + } + + hasApplied = true; + lockReviewPanel(applied); + } + + function record(key, rawValue) { + if (window.AIOrchestrator.NEVER_TOUCH.has(key.split(".")[0]) || suggestions[key]) { + return; + } + + const field = window.AIOrchestrator.schemaFor(key); + if (!field || rawValue === null || rawValue === undefined) { + return; + } + + const validated = window.AIOrchestrator.validateValue(field, rawValue); + if (!validated.ok) { + console.warn(`Dropped AI suggestion for ${key}: ${validated.why}`); + return; + } + + suggestions[key] = { + value: validated.value, + warn: validated.warn, + dropped: validated.dropped + }; + } + + function has(key) { + return Boolean(suggestions[key]); + } + + function count() { + return Object.keys(suggestions).length; + } + + function isReviewing() { + return reviewing; + } + + function hasBeenApplied() { + return hasApplied; + } + + window.AIReviewPanel = { + record, + has, + count, + isReviewing, + hasBeenApplied, + applyRules, + render, + reset, + apply, + editorFor, + suggestionRow + }; +})(); From bb41c165df897068740b8353f0c86e5a7fe5d9d4 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 9 Sep 2026 15:54:21 -0400 Subject: [PATCH 10/11] integrating with rest of site --- css/styles.css | 75 +++++++++++++++++++++++++++++++++++++++- index.html | 48 +++++++++++++++++++++++++ js/autoGenerateFields.js | 53 +++++++++++++++++++++++----- 3 files changed, 166 insertions(+), 10 deletions(-) diff --git a/css/styles.css b/css/styles.css index c851cf3..d7315b6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -66,4 +66,77 @@ textarea { opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} +.ai-row { + padding: 8px 0; + border-bottom: 1px solid #dfe1e2; +} + +.ai-value { + margin: 4px 0 0 2.5rem; + font-family: monospace; + color: #1b1b1b; +} + +.ai-edit { + margin-left: 2.5rem; + max-width: 60rem; +} + +.ai-note { + margin: 2px 0 0 2.5rem; + font-size: 13px; + color: #71767a; +} + +.ai-warn { + color: #b50909; + font-size: 12px; + margin-left: 6px; +} + +.ai-stream { + background: #f0f0f0; + padding: 8px; + max-height: 160px; + overflow-y: auto; + white-space: pre-wrap; + font-size: 13px; +} + +#ai-progress { + width: 100%; + max-width: 1300px; + height: 18px; +} + +.ai-reveal { + animation: slideDown 0.3s ease; +} + +#ai-enhance { + margin-left: 8px; +} + +.ai-options { + margin: 4px 0 0 2.5rem; +} + +.ai-option { + display: inline-block; + margin-right: 16px; +} + +.ai-edit.usa-select, +.ai-edit.usa-input { + margin-left: 2.5rem; + max-width: 24rem; +} + +.usa-button.ai-button--applied, +.usa-button.ai-button--applied:hover, +.usa-button.ai-button--applied:focus, +.usa-button.ai-button--applied:active { + background-color: #00a91c; + pointer-events: none; +} diff --git a/index.html b/index.html index 3a112ad..a09f916 100644 --- a/index.html +++ b/index.html @@ -25,6 +25,12 @@ + + + + + + @@ -304,9 +310,51 @@

+ + + +
diff --git a/js/autoGenerateFields.js b/js/autoGenerateFields.js index 18d36e5..dd60a67 100644 --- a/js/autoGenerateFields.js +++ b/js/autoGenerateFields.js @@ -83,9 +83,14 @@ function setupFormHandler() { const repositoryInfo = await getRepoInformation(repoInfo); const languages = await getRepoLanguages(repoInfo) + const rootFiles = await getRepoRootFiles(repoInfo) if (repositoryInfo) { - preFillFields(repositoryInfo, languages); + await preFillFields(repositoryInfo, languages, rootFiles); + + document.dispatchEvent(new CustomEvent("repo-context-ready", { + detail: { repoInfo, repoData: repositoryInfo, languages, rootFiles } + })); notificationSystem.success("Repository data loaded successfully!"); } else { throw new Error("Could not fetch repository information. Please check the URL and try again."); @@ -116,17 +121,25 @@ function extractGitHubInfo(url) { return null; } +function githubRequestOptions() { + return window.AIContext ? window.AIContext.ghHeaders() : {}; +} + async function getRepoInformation(repoInfo) { const baseURL = "https://api.github.com/repos/"; const endpoint = `${baseURL}${repoInfo.organization}/${repoInfo.repository}`; try { - const response = await fetch(endpoint); + const response = await fetch(endpoint, githubRequestOptions()); if (!response.ok) { throw new Error(`GitHub API error (${response.status}): ${response.statusText}`); } + if (window.AIContext) { + window.AIContext.checkRateLimit(response); + } + return await response.json(); } catch (error) { console.error("Fetch error:", error.message); @@ -137,7 +150,7 @@ async function getRepoLanguages(repoInfo) { const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/languages` try { - const response = await fetch(endpoint); + const response = await fetch(endpoint, githubRequestOptions()); if (!response.ok) { throw new Error(`GitHub API error (${response.status}): ${response.statusText}`); @@ -149,16 +162,38 @@ async function getRepoLanguages(repoInfo) { } } -async function getLicenseURL(repoURL) { +// gives the strongest signal for softwareType and maturityModelTier. +async function getRepoRootFiles(repoInfo) { + const endpoint = `https://api.github.com/repos/${repoInfo.organization}/${repoInfo.repository}/contents` + + try { + const response = await fetch(endpoint, githubRequestOptions()); + + if (!response.ok) { + return [] + } + + const files = await response.json() + return Array.isArray(files) ? files : [] + } catch (error) { + console.error("Fetch error:", error.message); + return [] + } +} + +// files is the already-fetched root listing, so this does not ask GitHub twice +async function getLicenseURL(repoURL, files = null) { const urlParts = repoURL.replace('https://github.com/', '').split('/') const owner = urlParts[0] const repo = urlParts[1] try { - const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents` - const response = await fetch(apiUrl) + if (!files) { + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents` + const response = await fetch(apiUrl, githubRequestOptions()) - const files = await response.json() + files = await response.json() + } const licenseFile = files.find(file => { const fileName = file.name.toLowerCase() @@ -177,7 +212,7 @@ async function getLicenseURL(repoURL) { } } -async function preFillFields(repoData, languages) { +async function preFillFields(repoData, languages, rootFiles) { if (!window.formIOInstance) { notificationSystem.error("Form interface not initialized. Please refresh and try again."); return; @@ -231,7 +266,7 @@ async function preFillFields(repoData, languages) { const currentPermissions = permissionsComp.getValue() || {}; currentPermissions.licenses = currentPermissions.licenses || []; - const licenseURL = await getLicenseURL(repoData.html_url) + const licenseURL = await getLicenseURL(repoData.html_url, rootFiles) const licenseObj = { name: repoData.license.spdx_id, From 3f1dde345357f57d263ff7a042a4c603f09c1e0e Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 9 Sep 2026 16:17:15 -0400 Subject: [PATCH 11/11] adding confirmation alert for downloading --- index.html | 1 + js/ai/aiOrchestrator.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index a09f916..7e6931c 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,7 @@ + diff --git a/js/ai/aiOrchestrator.js b/js/ai/aiOrchestrator.js index 722d95f..474271a 100644 --- a/js/ai/aiOrchestrator.js +++ b/js/ai/aiOrchestrator.js @@ -70,6 +70,7 @@ let busy = false; let modelAvailable = false; const attempted = new Set(); + const CLEAR_CACHE_FLASH_MS = 1200; // ---- schema derivation ------------------------------------------------- @@ -557,6 +558,17 @@ } } + function confirmDownload() { + if (window.AIEngine.isModelCached()) { + return true; + } + + return window.confirm( + `This downloads the ${formatSize(window.AIEngine.MODEL.sizeMB)} AI model and runs it in this ` + + "tab. It's cached afterward so this only happens once. Continue?" + ); + } + async function run() { if (!remainingModelFields()) { attempted.clear(); @@ -570,6 +582,10 @@ return; } + if (!confirmDownload()) { + return; + } + busy = true; updateRunButton(); show("ai-progress-wrap", true); @@ -677,7 +693,18 @@ setStatus("Cancelling..."); }); - element("ai-clear-cache").addEventListener("click", async () => { + element("ai-clear-cache").addEventListener("click", async (event) => { + if (!window.AIEngine.isModelCached()) { + return; + } + + if (!window.confirm( + "This deletes the cached AI model. You'll need to download it again " + + "next time you draft fields. Continue?" + )) { + return; + } + await window.AIEngine.clearCache(); updateRunButton(); });