Skip to content
Draft
75 changes: 74 additions & 1 deletion css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,77 @@ textarea {
opacity: 1;
transform: translateY(0);
}
}
}
.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;
}
49 changes: 49 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
<script src="js/formDataToJson.js"></script>
<script src="js/exemptionQuizHandler.js"></script>
<script src="js/autoGenerateFields.js"></script>

<!-- In-browser AI field suggestions -->
<script src="js/ai/aiContext.js"></script>
<script src="js/ai/determinations.js"></script>
<script src="js/ai/aiEngine.js"></script>
<script src="js/ai/aiReviewPanel.js"></script>
<script src="js/ai/aiOrchestrator.js"></script>

<!-- We participate in the US government's analytics program. See the data at analytics.usa.gov. -->
<script async type="text/javascript" src="https://dap.digitalgov.gov/Universal-Federated-Analytics-Min.js?agency=HHS&subagency=CMS&sitetopic=metadata&siteplatform=GitHubPages" id="_fed_an_ua_tag"></script>
Expand Down Expand Up @@ -304,9 +311,51 @@ <h3 class="usa-heading margin-top-neg-05 margin-bottom-1" id="quiz-subheading">
<label class="usa-label" for="repo-url">GitHub Repository URL</label>
<input class="usa-input" id="repo-url" name="repo-url" />
<button class="usa-button margin-top-2" id="repo-url-button">Submit</button>
<button class="usa-button usa-button--outline margin-top-2" type="button" id="ai-enhance"
style="display:none;">Enhance with AI</button>
</form>
</div>

<!-- AI Field Suggestions -->
<div class="auto-generation" id="ai-panel" style="display:none;">
<div class="auto-generation-header">
<div class="step-header">
<div class="step-number">2b</div>
<h2>Draft the remaining fields with AI</h2>
</div>
<h3 class="usa-heading margin-top-neg-05 margin-bottom-1">Llama 3.2 1B reads your
repository's README and drafts the fields that could not be worked out from the
repository metadata alone. It runs entirely on your computer so nothing is sent to
any server. The first run downloads about 880 MB and caches it for later visits, so keep
this tab open while it loads.</h3>
</div>

<div id="ai-controls">
<button class="usa-button margin-top-2" type="button" id="ai-run" disabled></button>
<button class="usa-button usa-button--outline margin-top-2" type="button" id="ai-cancel"
style="display:none;">Cancel</button>
<button class="usa-button usa-button--unstyled margin-top-2" type="button"
id="ai-clear-cache">Clear cached model files</button>
</div>

<div id="ai-progress-wrap" style="display:none;">
<label class="usa-label" for="ai-progress" id="ai-progress-label">Loading model</label>
<progress id="ai-progress" max="100" value="0" aria-labelledby="ai-progress-label"></progress>
<p class="usa-hint" id="ai-progress-text" aria-live="polite"></p>
<pre class="ai-stream" id="ai-stream" aria-live="polite"></pre>
</div>

<div id="ai-review" style="display:none;">
<h3 class="usa-heading margin-bottom-05">Review AI drafts</h3>
<p class="usa-hint margin-top-0">These are drafted by the model, not read from your
repository. Uncheck anything you do not want, edit the text, then apply.
<strong>Verify every value before you submit.</strong></p>
<fieldset class="usa-fieldset" id="ai-review-list"></fieldset>
<button class="usa-button margin-top-2" type="button" id="ai-apply">Apply selected to form</button>
<button class="usa-button usa-button--outline margin-top-2" type="button" id="ai-discard">Discard</button>
</div>
</div>

<!-- Form header -->
<div class="form-subheader">
<div class="step-header">
Expand Down
202 changes: 202 additions & 0 deletions js/ai/aiContext.js
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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
};
})();
Loading
Loading