diff --git a/js/ai/aiContext.js b/js/ai/aiContext.js new file mode 100644 index 0000000..3bf3a57 --- /dev/null +++ b/js/ai/aiContext.js @@ -0,0 +1,202 @@ +// 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 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 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 = stripHtmlCommentsFully(markdown) + .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 [readme, latestRelease] = await Promise.all([ + getReadme(repoInfo), + getLatestRelease(repoInfo) + ]); + + const context = { + repoInfo, + repoData: prefetched.repoData, + languages: prefetched.languages || {}, + rootFiles: prefetched.rootFiles || [], + readme, + latestRelease + }; + + context.facts = buildFactsBlock(context); + contextCache.set(cacheKey, context); + + return context; + } + + window.AIContext = { + gather, + condenseReadme, + buildFactsBlock, + ghHeaders, + getGitHubToken, + checkRateLimit + }; +})(); diff --git a/js/ai/aiEngine.js b/js/ai/aiEngine.js new file mode 100644 index 0000000..80d163f --- /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 = "aiOrchestrator.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/aiOrchestrator.js b/js/ai/aiOrchestrator.js new file mode 100644 index 0000000..a7db6ec --- /dev/null +++ b/js/ai/aiOrchestrator.js @@ -0,0 +1,225 @@ +// 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"); + } + + // ---- 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, + validateValue + }; +})(); 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 + }; +})(); 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); +};