Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <meta name="referrer" content="no-referrer"> | |
| <!-- Nothing here is loaded from anywhere else, so the page says so rather than | |
| merely being written that way. This blocks a CDN script, a web font and a | |
| tracking pixel alike -- including one a later edit adds by accident. | |
| connect-src covers what the two modes actually need: the page's own server | |
| when there is one, an https provider when there is not, and a local runtime | |
| such as Ollama. --> | |
| <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'self' https: http://localhost:* http://127.0.0.1:*; form-action 'none'; base-uri 'none'"> | |
| <!-- An empty data URI stops the browser asking for /favicon.ico. No external | |
| request, no 404 in the console, no icon file to ship. --> | |
| <link rel="icon" href="data:,"> | |
| <title>Caracat Code</title> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| --bg: #fbfbfa; --panel: #ffffff; --sunk: #f4f4f2; | |
| --border: #e2e2df; --text: #1a1a19; --muted: #6b6b66; | |
| --accent: #2f6f5e; --user-bg: #eef2f0; --danger: #a33a2a; --warn: #8a6d1f; | |
| --radius: 10px; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg: #17181a; --panel: #1e2022; --sunk: #131416; | |
| --border: #32353a; --text: #e8e8e6; --muted: #9a9a95; | |
| --accent: #6fbfa5; --user-bg: #26292c; --danger: #e0806f; --warn: #d8b45f; | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| margin: 0; background: var(--bg); color: var(--text); | |
| font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| display: flex; flex-direction: column; | |
| } | |
| button, select, input, textarea { | |
| font: inherit; color: var(--text); background: var(--panel); | |
| border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; | |
| } | |
| button { cursor: pointer; } | |
| button:hover:not(:disabled) { border-color: var(--accent); } | |
| button:disabled { opacity: 0.5; cursor: default; } | |
| button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 500; } | |
| button.small { font-size: 12px; padding: 3px 8px; } | |
| button.link { border: none; background: none; padding: 2px 4px; color: var(--muted); } | |
| button.link:hover { color: var(--accent); } | |
| header { | |
| border-bottom: 1px solid var(--border); background: var(--panel); | |
| padding: 10px 14px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; | |
| } | |
| h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: -0.01em; } | |
| .sub { color: var(--muted); font-size: 12px; } | |
| .spacer { flex: 1; } | |
| label.toggle { display: flex; align-items: center; gap: 5px; font-size: 13px; color: var(--muted); } | |
| #settings { display: none; gap: 14px; flex-wrap: wrap; align-items: flex-end; | |
| padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); } | |
| #settings.open { display: flex; } | |
| .field { display: flex; flex-direction: column; gap: 4px; } | |
| .field label { font-size: 12px; color: var(--muted); } | |
| .field input[type="number"] { width: 100px; } | |
| .field.grow { flex: 1; min-width: 280px; } | |
| .field.grow textarea { width: 100%; min-height: 70px; resize: vertical; } | |
| .layout { flex: 1; display: flex; min-height: 0; } | |
| aside { | |
| width: 260px; border-right: 1px solid var(--border); background: var(--panel); | |
| display: flex; flex-direction: column; min-height: 0; | |
| } | |
| aside.hidden { display: none; } | |
| .tabs { display: flex; border-bottom: 1px solid var(--border); } | |
| .tabs button { flex: 1; border: none; border-radius: 0; background: none; color: var(--muted); padding: 9px 4px; } | |
| .tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); } | |
| .panel { flex: 1; overflow-y: auto; padding: 8px; display: none; } | |
| .panel.active { display: block; } | |
| .row { | |
| display: flex; align-items: center; gap: 6px; padding: 5px 7px; | |
| border-radius: 7px; font-size: 13px; cursor: pointer; | |
| } | |
| .row:hover { background: var(--sunk); } | |
| .row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .row .meta { color: var(--muted); font-size: 11px; } | |
| .row.dir { color: var(--muted); font-weight: 500; cursor: default; } | |
| .row.dir:hover { background: none; } | |
| .empty { color: var(--muted); font-size: 13px; padding: 10px 7px; } | |
| main { flex: 1; overflow-y: auto; padding: 18px 14px 6px; min-width: 0; } | |
| .columns { display: flex; gap: 14px; align-items: flex-start; max-width: 1400px; margin: 0 auto; } | |
| .column { flex: 1; min-width: 0; } | |
| .column h2 { font-size: 12px; color: var(--muted); font-weight: 600; margin: 0 0 8px; } | |
| .thread { max-width: 820px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; } | |
| .columns .thread { max-width: none; } | |
| .msg { display: flex; flex-direction: column; gap: 4px; } | |
| .who { font-size: 11px; color: var(--muted); font-weight: 500; } | |
| .body { white-space: pre-wrap; word-wrap: break-word; } | |
| .msg.user .body { background: var(--user-bg); padding: 9px 12px; border-radius: var(--radius); } | |
| .msg.error .body { color: var(--danger); } | |
| .msg.note .body { color: var(--muted); font-size: 13px; } | |
| pre { background: var(--sunk); border: 1px solid var(--border); border-radius: var(--radius); | |
| padding: 11px; overflow-x: auto; margin: 7px 0; white-space: pre; } | |
| code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } | |
| .codewrap { position: relative; } | |
| .codebar { position: absolute; top: 7px; right: 7px; display: flex; gap: 5px; opacity: 0.85; } | |
| .runout { border-left: 3px solid var(--accent); background: var(--sunk); border-radius: 0 var(--radius) var(--radius) 0; | |
| padding: 9px 11px; margin: 6px 0; font-size: 13px; } | |
| .runout .head { font-size: 11px; color: var(--muted); margin-bottom: 5px; } | |
| .runout pre { margin: 4px 0; border: none; background: none; padding: 0; } | |
| .runout.bad { border-left-color: var(--danger); } | |
| footer { border-top: 1px solid var(--border); background: var(--panel); padding: 10px 14px; } | |
| .composer { max-width: 1400px; margin: 0 auto; display: flex; gap: 9px; align-items: flex-end; } | |
| #prompt { flex: 1; resize: none; min-height: 44px; max-height: 200px; } | |
| .chips { max-width: 1400px; margin: 0 auto 7px; display: flex; gap: 6px; flex-wrap: wrap; } | |
| .chip { display: flex; align-items: center; gap: 5px; font-size: 12px; background: var(--sunk); | |
| border: 1px solid var(--border); border-radius: 20px; padding: 2px 4px 2px 10px; color: var(--muted); } | |
| .hint { max-width: 1400px; margin: 6px auto 0; font-size: 11.5px; color: var(--muted); } | |
| .personas { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; } | |
| .personas .hint { margin: 0; } | |
| .notice { max-width: 820px; margin: 0 auto 16px; border: 1px solid var(--border); | |
| border-left: 3px solid var(--accent); border-radius: var(--radius); padding: 11px 13px; | |
| font-size: 13.5px; color: var(--muted); } | |
| .notice.warn { border-left-color: var(--warn); } | |
| .blink::after { content: "▌"; color: var(--accent); animation: blink 1s steps(2, start) infinite; } | |
| @keyframes blink { to { visibility: hidden; } } | |
| /* Which mode the page is in is decided at startup, not at build time. */ | |
| .static-only, body.static .server-only { display: none; } | |
| body.static .field.static-only { display: flex; } | |
| body.static .static-only.block { display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Caracat Code</h1> | |
| <span class="sub" id="provider"></span> | |
| <span class="spacer"></span> | |
| <select id="model" aria-label="Model"></select> | |
| <select id="model-b" aria-label="Second model" hidden></select> | |
| <label class="toggle"><input type="checkbox" id="compare"> compare</label> | |
| <button id="toggle-sidebar" class="small">Sidebar</button> | |
| <button id="toggle-settings" class="small" aria-expanded="false">Settings</button> | |
| <button id="new-chat" class="small">New chat</button> | |
| </header> | |
| <div id="settings"> | |
| <div class="field grow"> | |
| <label for="system">System prompt · the personality, from <code>prompts/</code></label> | |
| <textarea id="system" spellcheck="false"></textarea> | |
| <div class="personas"> | |
| <span class="hint">Load a shipped personality:</span> | |
| <button id="persona-code" class="small" hidden>Caracat Code</button> | |
| <button id="persona-chat" class="small" hidden>Caracat AI</button> | |
| </div> | |
| </div> | |
| <div class="field"> | |
| <label for="temperature">Temperature</label> | |
| <input id="temperature" type="number" min="0" max="2" step="0.1" value="0.2"> | |
| </div> | |
| <div class="field"> | |
| <label for="maxtokens">Max output tokens</label> | |
| <input id="maxtokens" type="number" min="1" max="128000" step="256" value="4096"> | |
| </div> | |
| <div class="field"> | |
| <label for="modelfilter">Filter models</label> | |
| <input id="modelfilter" type="text" placeholder="qwen" spellcheck="false"> | |
| </div> | |
| <div class="field static-only grow"> | |
| <label for="apibase">Endpoint · the provider this page talks to</label> | |
| <input id="apibase" type="url" spellcheck="false" placeholder="https://router.huggingface.co/v1"> | |
| </div> | |
| <div class="field static-only grow"> | |
| <label for="apikey">API key · kept in this browser, sent only to the endpoint above</label> | |
| <input id="apikey" type="password" spellcheck="false" autocomplete="off" placeholder="paste your provider key"> | |
| <button id="forget-key" class="small">Forget the key on this device</button> | |
| </div> | |
| <div class="field grow"> | |
| <label for="ghrepos">GitHub repositories · public only, owner/name, one per line</label> | |
| <textarea id="ghrepos" spellcheck="false" placeholder="owner/name other-owner/other-name@branch"></textarea> | |
| </div> | |
| <div class="field static-only grow"> | |
| <label for="ghtoken">GitHub token · only to propose changes; kept in this browser</label> | |
| <input id="ghtoken" type="password" spellcheck="false" autocomplete="off" placeholder="optional — reading needs none"> | |
| <button id="forget-ghtoken" class="small">Forget the GitHub token</button> | |
| </div> | |
| </div> | |
| <div class="layout"> | |
| <aside id="sidebar"> | |
| <div class="tabs"> | |
| <button id="tab-chats" class="active">Chats</button> | |
| <button id="tab-files">Files</button> | |
| </div> | |
| <div class="panel active" id="panel-chats"></div> | |
| <div class="panel" id="panel-files"></div> | |
| </aside> | |
| <main> | |
| <div class="columns" id="columns"> | |
| <div class="column" id="col-a"> | |
| <h2 id="head-a" hidden></h2> | |
| <div class="thread" id="thread"> | |
| <div class="notice" id="intro"> | |
| Talking to the base model <strong>Qwen3-Coder-Next</strong> through your own | |
| provider. No Caracat weights have been trained yet, so there is nothing else | |
| to connect to. <span id="intro-key"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="column" id="col-b" hidden> | |
| <h2 id="head-b"></h2> | |
| <div class="thread" id="thread-b"></div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <footer> | |
| <div class="chips" id="chips"></div> | |
| <div class="composer"> | |
| <textarea id="prompt" placeholder="Ask something about code…" rows="1"></textarea> | |
| <button id="send" class="primary">Send</button> | |
| <button id="stop" hidden>Stop</button> | |
| </div> | |
| <div class="hint" id="hint">Enter sends · Shift+Enter for a new line</div> | |
| </footer> | |
| <!-- Without a server there is no project directory to browse, but the browser's | |
| own picker still reaches the files on the device -- iCloud and Files | |
| included on an iPad. --> | |
| <input id="filepicker" type="file" multiple hidden> | |
| <script> | |
| (function () { | |
| "use strict"; | |
| const $ = (id) => document.getElementById(id); | |
| const URL_PATTERN = /https?:\/\/[^\s<>"')]+/g; | |
| const store = { | |
| get(key, fallback) { | |
| try { const v = localStorage.getItem("caracat." + key); return v === null ? fallback : v; } | |
| catch (e) { return fallback; } | |
| }, | |
| set(key, value) { try { localStorage.setItem("caracat." + key, value); } catch (e) {} } | |
| }; | |
| const DEFAULT_API_BASE = "https://router.huggingface.co/v1"; | |
| const MAX_STORED_CHATS = 40; | |
| let config = {}; | |
| let backend = null; | |
| let messages = []; | |
| let messagesB = []; | |
| let controllers = []; | |
| let allModels = []; | |
| let defaultSystemPrompt = ""; | |
| let shippedPersonas = {}; // {code, chat} -- whichever actually loaded | |
| let attached = []; // {path, text} | |
| let modelLoadError = ""; // why the list is empty, if it is | |
| let conversationId = null; | |
| // ---- small helpers ------------------------------------------------- | |
| async function api(path, options) { | |
| const response = await fetch(path, options); | |
| const type = response.headers.get("Content-Type") || ""; | |
| const data = type.includes("json") ? await response.json().catch(() => ({})) : {}; | |
| if (!response.ok) throw new Error(data.error || ("HTTP " + response.status)); | |
| return data; | |
| } | |
| // ---- looking for credentials --------------------------------------- | |
| // | |
| // The same patterns the Python side applies before anything is sent | |
| // (src/caracat_code/data_prep.py). With a server, the server does this check. | |
| // Without one, it has to happen here: an attached file goes straight to the | |
| // provider, and a key that reaches them cannot be recalled afterwards. | |
| // | |
| // The finding names the line and the kind. It never carries the value. | |
| const SECRET_PATTERNS = [ | |
| ["private key block", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], | |
| ["OpenAI-style API key", /\bsk-[A-Za-z0-9_-]{20,}/], | |
| ["Hugging Face token", /\bhf_[A-Za-z0-9]{20,}/], | |
| ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{20,}/], | |
| ["AWS access key id", /\bAKIA[0-9A-Z]{16}\b/], | |
| ["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/], | |
| ["Slack token", /\bxox[baprs]-[A-Za-z0-9-]{10,}/], | |
| ["JSON web token", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\./], | |
| ["credential assignment", | |
| /\b(?:api[_-]?key|secret|password|passwd|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?([A-Za-z0-9/+_.-]{16,})/i] | |
| ]; | |
| const PLACEHOLDER_MARKERS = ["your", "example", "placeholder", "changeme", | |
| "change-me", "dummy", "fake", "sample", "redacted", "xxxx", "....", "<"]; | |
| function findSecret(text) { | |
| const lines = text.split("\n"); | |
| for (let index = 0; index < lines.length; index++) { | |
| for (const [name, pattern] of SECRET_PATTERNS) { | |
| const hit = lines[index].match(pattern); | |
| if (!hit) continue; | |
| const value = (hit[1] || hit[0]).toLowerCase(); | |
| if (PLACEHOLDER_MARKERS.some((marker) => value.includes(marker))) continue; | |
| return { pattern: name, line: index + 1 }; | |
| } | |
| } | |
| return null; | |
| } | |
| // ---- GitHub -------------------------------------------------------- | |
| // | |
| // Two hosts, written here, never taken from anywhere. GitHub is one of the | |
| // few APIs that lets a web page call it, which is what makes this work on a | |
| // static host where fetching an arbitrary site does not. | |
| // | |
| // The lists below mirror src/caracat_code/github.py. They are duplicated | |
| // rather than shared because there is no server in this mode to share them | |
| // with -- if you change one, change the other. | |
| const GITHUB_API = "https://api.github.com"; | |
| const GITHUB_RAW = "https://raw.githubusercontent.com"; | |
| const GITHUB_MAX_FILE_BYTES = 256 * 1024; | |
| const REPO_PART = /^[A-Za-z0-9_](?:[A-Za-z0-9._-]*[A-Za-z0-9_])?$/; | |
| const BRANCH_PART = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,98}[A-Za-z0-9]$/; | |
| const EXCLUDED_DIRS = new Set([".git", ".hg", ".svn", ".idea", ".vscode", | |
| ".mypy_cache", ".pytest_cache", ".ruff_cache", "__pycache__", "node_modules", | |
| ".venv", "venv", "env", "dist", "build", ".next", ".tox"]); | |
| const NEVER_READABLE_NAMES = [/^\.env(\..*)?$/, /\.pem$/, /\.key$/, /\.p12$/, | |
| /\.pfx$/, /^id_rsa/, /^id_ed25519/, /^credentials\.json$/, | |
| /^secrets\.ya?ml$/, /^\.npmrc$/, /^\.pypirc$/, /^\.netrc$/]; | |
| const TEXT_SUFFIXES = [".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", | |
| ".jsx", ".json", ".md", ".rst", ".txt", ".yml", ".yaml", ".toml", ".ini", | |
| ".cfg", ".html", ".css", ".scss", ".sh", ".bash", ".sql", ".go", ".rs", | |
| ".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".rb", ".php", ".swift", | |
| ".xml", ".csv", ".gitignore", ".dockerignore"]; | |
| const TEXT_NAMES = ["Dockerfile", "Makefile", "LICENSE", "NOTICE"]; | |
| function parseRepo(spec) { | |
| const text = String(spec || "").trim(); | |
| if (!text) throw new Error("no repository given; expected owner/name"); | |
| if (text.includes("://") || text.startsWith("github.com")) { | |
| throw new Error(`"${text}" looks like a URL. Use just owner/name.`); | |
| } | |
| let body = text, ref = "main"; | |
| const at = text.indexOf("@"); | |
| if (at !== -1) { | |
| body = text.slice(0, at); | |
| ref = text.slice(at + 1); | |
| if (!BRANCH_PART.test(ref)) throw new Error(`"${ref}" is not a usable branch name`); | |
| } | |
| const parts = body.split("/"); | |
| if (parts.length !== 2 || !REPO_PART.test(parts[0]) || !REPO_PART.test(parts[1])) { | |
| throw new Error(`"${text}" should be owner/name — two parts, one slash`); | |
| } | |
| return { owner: parts[0], name: parts[1], ref: ref, | |
| slug: parts[0] + "/" + parts[1], label: parts[0] + "/" + parts[1] + "@" + ref }; | |
| } | |
| function parseRepoList(text) { | |
| const specs = String(text || "").split(/[\s,]+/).filter(Boolean); | |
| const repos = []; | |
| for (const spec of specs) { | |
| try { repos.push(parseRepo(spec)); } | |
| catch (err) { /* a half-typed repo is not an error worth shouting about */ } | |
| } | |
| return repos; | |
| } | |
| function isInterestingPath(path) { | |
| const parts = path.split("/"); | |
| if (parts.slice(0, -1).some((part) => EXCLUDED_DIRS.has(part))) return false; | |
| const name = parts[parts.length - 1]; | |
| if (NEVER_READABLE_NAMES.some((pattern) => pattern.test(name))) return false; | |
| if (TEXT_NAMES.includes(name)) return true; | |
| return TEXT_SUFFIXES.some((suffix) => name.endsWith(suffix)); | |
| } | |
| async function githubCall(url, token, options) { | |
| const headers = { "Accept": "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28" }; | |
| if (token) headers["Authorization"] = "Bearer " + token; | |
| let response; | |
| try { | |
| response = await fetch(url, Object.assign({}, options, | |
| { headers: Object.assign(headers, (options || {}).headers) })); | |
| } catch (err) { | |
| // A browser reports a CORS refusal as a bare TypeError. Saying so beats | |
| // "Failed to fetch", which sends people looking for a network problem. | |
| throw new Error( | |
| "The browser would not send that request to GitHub" + | |
| ((options && options.method && options.method !== "GET") | |
| ? " — writing to GitHub from a hosted page may not be permitted. " + | |
| "Reading works; run the interface on your own machine to make changes." | |
| : ".") + " (" + err.message + ")"); | |
| } | |
| if (!response.ok) { | |
| let detail = ""; | |
| try { detail = ((await response.json()).message) || ""; } catch (e) {} | |
| if (response.status === 404) { | |
| throw new Error("GitHub says that does not exist. Check the owner and " + | |
| "name; a private repository is not reachable here. " + detail); | |
| } | |
| if (response.status === 403 && /rate limit/i.test(detail)) { | |
| throw new Error("GitHub's hourly allowance for anonymous requests is " + | |
| "used up. It refills on the hour. " + detail); | |
| } | |
| throw new Error("GitHub returned " + response.status + ". " + detail); | |
| } | |
| return response; | |
| } | |
| async function githubTree(repo, token) { | |
| const url = `${GITHUB_API}/repos/${repo.owner}/${repo.name}` + | |
| `/git/trees/${encodeURIComponent(repo.ref)}?recursive=1`; | |
| const data = await (await githubCall(url, token)).json(); | |
| return (data.tree || []) | |
| .filter((item) => item.type === "blob" && isInterestingPath(item.path || "") | |
| && (item.size || 0) <= GITHUB_MAX_FILE_BYTES) | |
| .map((item) => ({ path: item.path, size: item.size || 0 })) | |
| .sort((a, b) => a.path.localeCompare(b.path)); | |
| } | |
| async function githubFile(repo, path, token) { | |
| const clean = String(path || "").trim(); | |
| if (!clean || clean.startsWith("/") || clean.split("/").includes("..")) { | |
| throw new Error(`"${path}" does not stay inside the repository`); | |
| } | |
| const url = `${GITHUB_RAW}/${repo.owner}/${repo.name}/` + | |
| `${encodeURIComponent(repo.ref)}/` + | |
| clean.split("/").map(encodeURIComponent).join("/"); | |
| const text = await (await githubCall(url, token)).text(); | |
| if (text.length > GITHUB_MAX_FILE_BYTES) { | |
| throw new Error(`${clean} is too large to attach.`); | |
| } | |
| if (text.includes("\u0000")) { | |
| throw new Error(`${clean} looks like a binary file.`); | |
| } | |
| // The same check a project file gets. A public repository is not a reason | |
| // to forward a key to an inference provider. | |
| const finding = findSecret(text); | |
| if (finding) { | |
| throw new Error(`${clean} was not attached: line ${finding.line} looks ` + | |
| `like a ${finding.pattern}. The value is not repeated here.`); | |
| } | |
| return { repo: repo.slug, path: clean, text: text }; | |
| } | |
| // Opening a pull request from the browser. Mirrors open_pull_request in | |
| // src/caracat_code/github.py, including the one refusal that matters: never | |
| // the default branch. Whether a browser is allowed to do this at all is | |
| // answered by the first attempt -- githubCall says so plainly if not. | |
| async function githubOpenPull(repo, changes, options) { | |
| const token = options.token; | |
| if (!token) { | |
| throw new Error("Changing a repository needs a GitHub token with " + | |
| "Contents and Pull requests permission for exactly that repository."); | |
| } | |
| if (!changes.length) throw new Error("There is nothing to change."); | |
| if (changes.length > 20) { | |
| throw new Error(changes.length + " files is more than one pull request " + | |
| "should carry. Split it up."); | |
| } | |
| const api = (path, init) => | |
| githubCall(`${GITHUB_API}/repos/${repo.owner}/${repo.name}${path}`, token, init); | |
| const send = (path, method, payload) => api(path, { | |
| method: method, | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload) | |
| }); | |
| const base = (await (await api("", {})).json()).default_branch || "main"; | |
| if (options.branch === base) { | |
| throw new Error(`Refusing to write to "${base}" directly. Changes go on ` + | |
| "their own branch and through a pull request."); | |
| } | |
| for (const change of changes) { | |
| const finding = findSecret(change.text); | |
| if (finding) { | |
| throw new Error(`${change.path} was not written: line ${finding.line} ` + | |
| `looks like a ${finding.pattern}. A key committed to a public ` + | |
| "repository is public immediately."); | |
| } | |
| } | |
| const tip = (await (await api( | |
| "/git/ref/heads/" + encodeURIComponent(base), {})).json()).object.sha; | |
| await send("/git/refs", "POST", | |
| { ref: "refs/heads/" + options.branch, sha: tip }); | |
| for (const change of changes) { | |
| const where = "/contents/" + | |
| change.path.split("/").map(encodeURIComponent).join("/"); | |
| let existing = null; | |
| try { | |
| existing = (await (await api( | |
| where + "?ref=" + encodeURIComponent(options.branch), {})).json()).sha; | |
| } catch (err) { /* absent means a new file, which is fine */ } | |
| const payload = { | |
| message: options.title, | |
| // btoa cannot take characters above 255, so the text is turned into | |
| // UTF-8 bytes first. Without this an umlaut breaks the commit. | |
| content: btoa(String.fromCharCode(...new TextEncoder().encode(change.text))), | |
| branch: options.branch | |
| }; | |
| if (existing) payload.sha = existing; | |
| await send(where, "PUT", payload); | |
| } | |
| const opened = await (await send("/pulls", "POST", { | |
| title: options.title, body: options.body, | |
| head: options.branch, base: base | |
| })).json(); | |
| return opened.html_url; | |
| } | |
| // ---- the two backends ---------------------------------------------- | |
| // | |
| // This page runs in two places. On your own machine it talks to its own | |
| // server, which holds the API key, can read a project directory and can run | |
| // code. On a static host -- a Hugging Face Static Space, GitHub Pages -- there | |
| // is no server at all, so the browser talks to the provider directly and the | |
| // key lives here instead. | |
| // | |
| // Which one applies is *discovered*, not configured: if /api/config answers, | |
| // there is a server. Everything below this point is written against one | |
| // interface, so the rest of the page never asks which mode it is in. | |
| const serverBackend = { | |
| mode: "server", | |
| config: () => api("/api/config"), | |
| models: () => api("/api/models"), | |
| chat(payload, signal) { | |
| return fetch("/api/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| signal: signal, | |
| body: JSON.stringify(payload) | |
| }); | |
| }, | |
| listChats: () => api("/api/conversations").then((d) => d.conversations), | |
| getChat: (id) => api("/api/conversations?id=" + encodeURIComponent(id)), | |
| saveChat: (record) => api("/api/conversations", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(record) | |
| }), | |
| deleteChat: (id) => api("/api/conversations/delete?id=" + encodeURIComponent(id), { | |
| method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" | |
| }), | |
| listFiles: () => api("/api/files").then((d) => d.entries), | |
| readFile: (path) => api("/api/file?path=" + encodeURIComponent(path)), | |
| repoTree: (repo) => | |
| api("/api/github/tree?repo=" + encodeURIComponent(repo.label)) | |
| .then((d) => d.entries), | |
| repoFile: (repo, path) => | |
| api("/api/github/file?repo=" + encodeURIComponent(repo.label) + | |
| "&path=" + encodeURIComponent(path)), | |
| openPull: (repo, changes, options) => api("/api/github/pull", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| repo: repo.label, changes: changes, | |
| title: options.title, body: options.body, branch: options.branch | |
| }) | |
| }).then((d) => d.url), | |
| run: (code, files) => api("/api/run", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ code: code, files: files }) | |
| }), | |
| fetchUrl: (url) => api("/api/fetch", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ url: url }) | |
| }) | |
| }; | |
| const staticBackend = { | |
| mode: "static", | |
| endpoint() { | |
| return (store.get("apiBase", DEFAULT_API_BASE) || DEFAULT_API_BASE).replace(/\/+$/, ""); | |
| }, | |
| // The key is only added when there is one. An endpoint that is not the | |
| // default provider may well be something that holds the key itself -- a | |
| // small proxy of your own -- and forcing a key on that would be wrong. | |
| async provider(path, options, signal) { | |
| const base = this.endpoint(); | |
| const key = store.get("key", "").trim(); | |
| if (!/^https:\/\//i.test(base) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(base)) { | |
| throw new Error("The endpoint must use https, otherwise the key would travel in the clear. Only localhost may use plain http."); | |
| } | |
| if (!key && base === DEFAULT_API_BASE) { | |
| throw new Error("No API key yet. Open Settings and paste one — it stays in this browser and is sent only to the endpoint shown there."); | |
| } | |
| const headers = { "Content-Type": "application/json" }; | |
| if (key) headers["Authorization"] = "Bearer " + key; | |
| // headers and signal go last: a caller must not be able to drop the | |
| // Authorization header by passing headers of its own. | |
| return fetch(base + path, Object.assign({}, options, { headers: headers, signal: signal })); | |
| }, | |
| async config() { | |
| // Shipped beside index.html, so these are the same files the repository | |
| // holds. One that is missing is left out rather than failing the load -- | |
| // the other personality still works, and the page hides the button for | |
| // the one that did not arrive. | |
| const personas = {}; | |
| await Promise.all(Object.entries({ | |
| code: "caracat_persona.md", | |
| chat: "caracat_ai_persona.md", | |
| }).map(async ([name, file]) => { | |
| try { | |
| const text = (await (await fetch(file)).text()).trim(); | |
| if (text) personas[name] = text; | |
| } catch (err) { /* left out */ } | |
| })); | |
| return { | |
| api_base: this.endpoint(), | |
| default_model: "", | |
| default_system_prompt: personas.code || "", | |
| personas: personas, | |
| project_dir: null, | |
| can_run_code: false, // no server, nothing to run code on | |
| can_browse_project: false, // no server, no directory to browse | |
| can_fetch: false, // other sites refuse a browser from here | |
| can_save_conversations: true, // in this browser, not on a server | |
| github_repos: parseRepoList(store.get("repos", "")).map((r) => r.label), | |
| can_change_github: Boolean(store.get("ghToken", "").trim()) | |
| }; | |
| }, | |
| async models() { | |
| const response = await this.provider("/models", { method: "GET" }); | |
| if (!response.ok) throw new Error(await describeProviderError(response)); | |
| return response.json(); | |
| }, | |
| chat(payload, signal) { | |
| return this.provider("/chat/completions", { | |
| method: "POST", | |
| body: JSON.stringify(Object.assign({ stream: true }, payload)) | |
| }, signal); | |
| }, | |
| // Chats live in this browser's storage. There is no server to hold them, | |
| // and on a shared host a server-side store would be shared by everyone who | |
| // can open the page. | |
| readAll() { | |
| try { return JSON.parse(store.get("chats", "[]")) || []; } catch (e) { return []; } | |
| }, | |
| writeAll(list) { | |
| const trimmed = list.slice(0, MAX_STORED_CHATS); | |
| try { store.set("chats", JSON.stringify(trimmed)); return true; } | |
| catch (e) { | |
| // Storage is finite. Dropping the oldest chat is better than losing the | |
| // one being written. | |
| try { store.set("chats", JSON.stringify(trimmed.slice(0, Math.max(1, trimmed.length - 5)))); return true; } | |
| catch (e2) { return false; } | |
| } | |
| }, | |
| async listChats() { | |
| return this.readAll().map((chat) => ({ | |
| id: chat.id, title: chat.title, | |
| message_count: chat.messages.length, updated_at: chat.updated_at | |
| })); | |
| }, | |
| async getChat(id) { | |
| const found = this.readAll().find((chat) => chat.id === id); | |
| if (!found) throw new Error("That chat is no longer in this browser's storage."); | |
| return found; | |
| }, | |
| async saveChat(record) { | |
| const list = this.readAll(); | |
| const id = record.id || (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)); | |
| const existing = list.findIndex((chat) => chat.id === id); | |
| const entry = { | |
| id: id, | |
| title: (existing >= 0 ? list[existing].title : record.title) || "Untitled", | |
| model: record.model, | |
| messages: record.messages, | |
| updated_at: new Date().toISOString().slice(0, 16).replace("T", " ") | |
| }; | |
| if (existing >= 0) list.splice(existing, 1); | |
| list.unshift(entry); | |
| if (!this.writeAll(list)) throw new Error("This browser's storage is full."); | |
| return { id: id }; | |
| }, | |
| async deleteChat(id) { | |
| this.writeAll(this.readAll().filter((chat) => chat.id !== id)); | |
| return {}; | |
| }, | |
| async listFiles() { return []; }, | |
| async readFile() { throw new Error("There is no project directory without a server."); }, | |
| // GitHub is reached directly, which is possible because GitHub is one of | |
| // the few APIs that permits a web page to call it. | |
| repoTree(repo) { return githubTree(repo, store.get("ghToken", "").trim()); }, | |
| repoFile(repo, path) { | |
| return githubFile(repo, path, store.get("ghToken", "").trim()); | |
| }, | |
| openPull(repo, changes, options) { | |
| return githubOpenPull(repo, changes, | |
| Object.assign({ token: store.get("ghToken", "").trim() }, options)); | |
| }, | |
| async run() { throw new Error("Running code needs a server. Start the interface on your own machine."); }, | |
| async fetchUrl() { throw new Error("Fetching other sites needs a server; a browser is not allowed to."); } | |
| }; | |
| // A provider error is JSON in the good case and an HTML error page in the bad | |
| // one. Either way the key must not appear in what is shown. | |
| async function describeProviderError(response) { | |
| let detail = ""; | |
| try { | |
| const body = await response.json(); | |
| detail = (body.error && (body.error.message || body.error)) || body.message || ""; | |
| } catch (e) { detail = ""; } | |
| if (typeof detail !== "string") detail = JSON.stringify(detail); | |
| const key = store.get("key", "").trim(); | |
| if (key && detail.includes(key)) detail = detail.split(key).join("[key redacted]"); | |
| if (response.status === 401 || response.status === 403) { | |
| return "The provider rejected the key (HTTP " + response.status + "). " + | |
| "Check it in Settings. " + detail; | |
| } | |
| return detail ? "The provider returned " + response.status + ": " + detail | |
| : "The provider returned HTTP " + response.status + "."; | |
| } | |
| function el(tag, className, text) { | |
| const node = document.createElement(tag); | |
| if (className) node.className = className; | |
| if (text !== undefined) node.textContent = text; | |
| return node; | |
| } | |
| function scrollDown() { | |
| const main = document.querySelector("main"); | |
| main.scrollTop = main.scrollHeight; | |
| } | |
| // ---- rendering ----------------------------------------------------- | |
| function addMessage(threadId, role, cssClass) { | |
| const wrap = el("div", "msg " + (cssClass || role)); | |
| const labels = { user: "You", error: "Error", note: "" }; | |
| wrap.append(el("div", "who", labels[cssClass || role] !== undefined | |
| ? labels[cssClass || role] : "Caracat Code")); | |
| const body = el("div", "body"); | |
| wrap.append(body); | |
| $(threadId).append(wrap); | |
| return body; | |
| } | |
| // Model output is never inserted as HTML. Text becomes text nodes and fenced | |
| // blocks become <pre><code>, so nothing a model returns can execute here. | |
| function renderContent(target, text) { | |
| target.textContent = ""; | |
| text.split(/```/).forEach((part, index) => { | |
| if (index % 2 === 0) { | |
| if (part) target.append(document.createTextNode(part)); | |
| return; | |
| } | |
| const newline = part.indexOf("\n"); | |
| const info = (newline === -1 ? "" : part.slice(0, newline)).trim(); | |
| const language = info.split(/\s+/)[0].toLowerCase(); | |
| const blockTarget = parseBlockTarget(info); // not `target`: that is this function's parameter | |
| const code = newline === -1 ? part : part.slice(newline + 1); | |
| const wrap = el("div", "codewrap"); | |
| const pre = el("pre"); | |
| const codeEl = el("code", null, code); | |
| pre.append(codeEl); | |
| const bar = el("div", "codebar"); | |
| const copy = el("button", "small", "Copy"); | |
| copy.addEventListener("click", () => { | |
| navigator.clipboard.writeText(code).then( | |
| () => { copy.textContent = "Copied"; setTimeout(() => { copy.textContent = "Copy"; }, 1200); }, | |
| () => { copy.textContent = "Failed"; } | |
| ); | |
| }); | |
| bar.append(copy); | |
| if (config.can_run_code && (language === "python" || language === "py")) { | |
| const run = el("button", "small", "Run"); | |
| run.addEventListener("click", () => runCode(code, wrap, run)); | |
| bar.append(run); | |
| } | |
| // A block that names a file can become a pull request -- but only when | |
| // someone presses this. The model writes the proposal; it never sends it. | |
| if (blockTarget && config.can_change_github && connectedRepos().length) { | |
| const propose = el("button", "small", "Propose…"); | |
| propose.addEventListener("click", () => showProposal(code, blockTarget, wrap, propose)); | |
| bar.append(propose); | |
| } | |
| wrap.append(pre, bar); | |
| target.append(wrap); | |
| }); | |
| } | |
| // A fenced block can say where it belongs: ```python file=src/app.py repo=owner/name | |
| // Without a file= there is nothing to propose, which is the safe default: | |
| // ordinary code in an answer stays ordinary code. | |
| function parseBlockTarget(info) { | |
| const path = /(?:^|\s)(?:file|path)=("[^"]+"|\S+)/.exec(info); | |
| if (!path) return null; | |
| const repo = /(?:^|\s)repo=("[^"]+"|\S+)/.exec(info); | |
| const unquote = (value) => value.replace(/^"|"$/g, ""); | |
| return { path: unquote(path[1]), repo: repo ? unquote(repo[1]) : null }; | |
| } | |
| function branchNameFor(path) { | |
| const stem = path.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "") | |
| .slice(0, 40).toLowerCase() || "change"; | |
| return "caracat/" + stem + "-" + Date.now().toString(36).slice(-5); | |
| } | |
| // Everything about the change, shown before anything happens. The button is | |
| // the only thing that reaches GitHub, and it is the person who presses it. | |
| function showProposal(code, target, container, button) { | |
| const previous = container.querySelector(".runout"); | |
| if (previous) previous.remove(); | |
| const repos = connectedRepos(); | |
| const chosen = target.repo | |
| ? repos.find((r) => r.slug === target.repo || r.label === target.repo) | |
| : (repos.length === 1 ? repos[0] : null); | |
| const panel = el("div", "runout"); | |
| container.append(panel); | |
| if (!chosen) { | |
| panel.classList.add("bad"); | |
| panel.append(el("div", "head", target.repo | |
| ? `${target.repo} is not one of the connected repositories.` | |
| : "Two repositories are connected, so the block has to say which one: " | |
| + "add repo=owner/name to its opening line.")); | |
| return; | |
| } | |
| const branch = branchNameFor(target.path); | |
| panel.append(el("div", "head", | |
| `${chosen.slug} — a new branch ${branch}, then a pull request against ` + | |
| `${chosen.ref}. Nothing is written to ${chosen.ref} itself.`)); | |
| const title = el("input"); | |
| title.type = "text"; | |
| title.value = "Update " + target.path; | |
| title.style.width = "100%"; | |
| panel.append(title); | |
| const row = el("div", "codebar"); | |
| row.style.position = "static"; | |
| row.style.marginTop = "8px"; | |
| const go = el("button", "small primary", "Open pull request"); | |
| const cancel = el("button", "small", "Cancel"); | |
| cancel.addEventListener("click", () => panel.remove()); | |
| row.append(go, cancel); | |
| panel.append(row); | |
| go.addEventListener("click", async () => { | |
| go.disabled = true; | |
| go.textContent = "Opening…"; | |
| try { | |
| const url = await backend.openPull(chosen, [{ path: target.path, text: code }], { | |
| title: title.value || ("Update " + target.path), | |
| body: "Proposed in Caracat Code and reviewed before opening.\n\n" | |
| + "File: " + target.path, | |
| branch: branch | |
| }); | |
| panel.textContent = ""; | |
| panel.append(el("div", "head", "Pull request opened:")); | |
| const link = el("div", null, url); | |
| panel.append(link); | |
| button.textContent = "Proposed"; | |
| button.disabled = true; | |
| } catch (err) { | |
| panel.classList.add("bad"); | |
| const message = el("div", "head", err.message); | |
| panel.textContent = ""; | |
| panel.append(message); | |
| } | |
| }); | |
| } | |
| function showError(threadId, text) { | |
| addMessage(threadId, "error", "error").textContent = text; | |
| scrollDown(); | |
| } | |
| function showNote(threadId, text) { | |
| addMessage(threadId, "note", "note").textContent = text; | |
| } | |
| // ---- running code -------------------------------------------------- | |
| async function runCode(code, container, button) { | |
| button.disabled = true; | |
| button.textContent = "Running…"; | |
| const previous = container.querySelector(".runout"); | |
| if (previous) previous.remove(); | |
| const output = el("div", "runout"); | |
| output.append(el("div", "head", "Running in a temporary directory, on copies. Not a container — code runs as you.")); | |
| container.append(output); | |
| try { | |
| const result = await backend.run(code, attached.map((f) => f.path)); | |
| output.textContent = ""; | |
| output.classList.toggle("bad", !result.succeeded); | |
| const head = result.succeeded | |
| ? `finished in ${result.duration_seconds}s` | |
| : (result.timed_out ? "stopped at the time limit" : `exit code ${result.exit_code}`); | |
| output.append(el("div", "head", head)); | |
| if (result.stdout) output.append(el("pre", null, result.stdout)); | |
| if (result.stderr) output.append(el("pre", null, result.stderr)); | |
| if (!result.stdout && !result.stderr) output.append(el("div", "head", "(no output)")); | |
| if (result.produced_files.length) { | |
| output.append(el("div", "head", "new or changed files: " + result.produced_files.join(", "))); | |
| } | |
| } catch (err) { | |
| output.classList.add("bad"); | |
| output.textContent = err.message; | |
| } finally { | |
| button.disabled = false; | |
| button.textContent = "Run"; | |
| } | |
| } | |
| // ---- the sidebar --------------------------------------------------- | |
| function selectTab(which) { | |
| ["chats", "files"].forEach((name) => { | |
| $("tab-" + name).classList.toggle("active", name === which); | |
| $("panel-" + name).classList.toggle("active", name === which); | |
| }); | |
| store.set("tab", which); | |
| } | |
| async function refreshChats() { | |
| const panel = $("panel-chats"); | |
| panel.textContent = ""; | |
| if (!config.can_save_conversations) { | |
| panel.append(el("div", "empty", "Saving is switched off (--no-save).")); | |
| return; | |
| } | |
| let listed = []; | |
| try { listed = await backend.listChats(); } | |
| catch (err) { panel.append(el("div", "empty", err.message)); return; } | |
| if (!listed.length) { | |
| panel.append(el("div", "empty", backend.mode === "static" | |
| ? "No saved chats yet. They are saved in this browser once you send something — clearing the browser's data clears them too." | |
| : "No saved chats yet. They are saved automatically once you send something.")); | |
| return; | |
| } | |
| listed.forEach((item) => { | |
| const row = el("div", "row"); | |
| const name = el("div", "name", item.title); | |
| name.title = `${item.message_count} messages · ${item.updated_at}`; | |
| row.append(name); | |
| const remove = el("button", "link small", "×"); | |
| remove.title = "Delete"; | |
| remove.addEventListener("click", async (event) => { | |
| event.stopPropagation(); | |
| await backend.deleteChat(item.id); | |
| if (conversationId === item.id) newChat(); | |
| refreshChats(); | |
| }); | |
| row.append(remove); | |
| row.addEventListener("click", () => openChat(item.id)); | |
| panel.append(row); | |
| }); | |
| } | |
| async function openChat(id) { | |
| try { | |
| const data = await backend.getChat(id); | |
| newChat(true); | |
| conversationId = data.id; | |
| messages = data.messages.filter((m) => m.role !== "system"); | |
| messages.forEach((message) => { | |
| renderContent(addMessage("thread", message.role), message.content); | |
| }); | |
| scrollDown(); | |
| } catch (err) { showError("thread", err.message); } | |
| } | |
| // Which repositories are connected right now. Read from the settings field | |
| // rather than kept in a second place, so what is shown is what is typed. | |
| function connectedRepos() { | |
| return parseRepoList($("ghrepos").value); | |
| } | |
| const openRepos = new Set(); // which sections are expanded | |
| // Rendering the sidebar waits on the network, so two quick clicks start two | |
| // runs. Without this, the slower one carries on appending to a panel the | |
| // faster one has already cleared, and the sidebar ends up showing a state | |
| // nobody asked for. Each run takes a ticket and stops as soon as it is stale. | |
| let filesRender = 0; | |
| async function refreshFiles() { | |
| const ticket = ++filesRender; | |
| const current = () => ticket === filesRender; | |
| const panel = $("panel-files"); | |
| panel.textContent = ""; | |
| if (config.can_browse_project) { | |
| panel.append(el("div", "row dir", "Project")); | |
| let entries = []; | |
| try { entries = await backend.listFiles(); } | |
| catch (err) { panel.append(el("div", "empty", err.message)); } | |
| if (!current()) return; | |
| entries.forEach((entry) => { | |
| const depth = entry.path.split("/").length - 1; | |
| const row = el("div", "row" + (entry.is_dir ? " dir" : "")); | |
| row.style.paddingLeft = 7 + depth * 11 + "px"; | |
| row.append(el("div", "name", entry.path.split("/").pop())); | |
| if (!entry.is_dir) { | |
| row.append(el("div", "meta", entry.size > 1024 | |
| ? Math.round(entry.size / 1024) + " kB" : entry.size + " B")); | |
| row.addEventListener("click", () => attachFile(entry.path)); | |
| row.title = "Attach " + entry.path; | |
| } | |
| panel.append(row); | |
| }); | |
| } else if (backend.mode === "static") { | |
| // No server means no directory to walk. The browser's own picker still | |
| // reaches the device's files, which is the part that matters on a tablet. | |
| const pick = el("button", "small", "Choose files…"); | |
| pick.addEventListener("click", () => $("filepicker").click()); | |
| panel.append(pick); | |
| } | |
| const repos = connectedRepos(); | |
| if (!repos.length) { | |
| panel.append(el("div", "empty", | |
| "No repositories connected. Open Settings and add one as owner/name — " + | |
| "public repositories need no token.")); | |
| return; | |
| } | |
| for (const repo of repos) { | |
| const header = el("div", "row dir"); | |
| const open = openRepos.has(repo.label); | |
| header.append(el("div", "name", (open ? "▾ " : "▸ ") + repo.slug)); | |
| header.append(el("div", "meta", repo.ref)); | |
| header.style.cursor = "pointer"; | |
| header.title = "Show the files in " + repo.label; | |
| header.addEventListener("click", () => { | |
| if (openRepos.has(repo.label)) openRepos.delete(repo.label); | |
| else openRepos.add(repo.label); | |
| refreshFiles(); | |
| }); | |
| panel.append(header); | |
| if (!open) continue; | |
| const loading = el("div", "empty", "loading…"); | |
| panel.append(loading); | |
| let entries = []; | |
| try { | |
| entries = await backend.repoTree(repo); | |
| } catch (err) { | |
| if (!current()) return; | |
| loading.textContent = err.message; | |
| continue; | |
| } | |
| if (!current()) return; | |
| loading.remove(); | |
| if (!entries.length) { | |
| panel.append(el("div", "empty", "nothing attachable in this repository")); | |
| continue; | |
| } | |
| entries.forEach((entry) => { | |
| const row = el("div", "row"); | |
| row.style.paddingLeft = "18px"; | |
| row.append(el("div", "name", entry.path)); | |
| row.append(el("div", "meta", entry.size > 1024 | |
| ? Math.round(entry.size / 1024) + " kB" : entry.size + " B")); | |
| row.title = "Attach " + repo.slug + " ▸ " + entry.path; | |
| row.addEventListener("click", () => attachRepoFile(repo, entry.path)); | |
| panel.append(row); | |
| }); | |
| } | |
| } | |
| // Attachments carry their repository in the name, so a conversation about two | |
| // projects never leaves it unclear which file came from where. | |
| async function attachRepoFile(repo, path) { | |
| const label = repo.slug + " ▸ " + path; | |
| if (attached.some((file) => file.path === label)) return; | |
| try { | |
| const data = await backend.repoFile(repo, path); | |
| attached.push({ path: label, text: data.text, repo: repo.label, file: path }); | |
| renderChips(); | |
| } catch (err) { | |
| showError("thread", err.message); | |
| } | |
| } | |
| async function attachFile(path) { | |
| if (attached.some((file) => file.path === path)) return; | |
| try { | |
| const data = await backend.readFile(path); | |
| attached.push({ path: data.path, text: data.text }); | |
| renderChips(); | |
| } catch (err) { | |
| showError("thread", err.message); | |
| } | |
| } | |
| const MAX_PICKED_BYTES = 200_000; | |
| // Files chosen from the device. The server does the equivalent checks in | |
| // workspace.py; without a server they belong here, before anything leaves. | |
| async function attachPickedFiles(files) { | |
| for (const file of Array.from(files)) { | |
| if (attached.some((item) => item.path === file.name)) continue; | |
| if (file.size > MAX_PICKED_BYTES) { | |
| showNote("thread", `${file.name} is too large to attach (${Math.round(file.size / 1024)} kB).`); | |
| continue; | |
| } | |
| let text = ""; | |
| try { text = await file.text(); } | |
| catch (err) { showNote("thread", `could not read ${file.name}: ${err.message}`); continue; } | |
| if (text.includes("\u0000")) { | |
| showNote("thread", `${file.name} looks like a binary file, so it was not attached.`); | |
| continue; | |
| } | |
| const finding = findSecret(text); | |
| if (finding) { | |
| showNote("thread", | |
| `${file.name} was not attached: line ${finding.line} looks like a ${finding.pattern}. ` + | |
| `Sending it to the provider could not be undone.`); | |
| continue; | |
| } | |
| attached.push({ path: file.name, text: text }); | |
| } | |
| renderChips(); | |
| } | |
| function renderChips() { | |
| const bar = $("chips"); | |
| bar.textContent = ""; | |
| attached.forEach((file) => { | |
| const chip = el("div", "chip"); | |
| chip.append(document.createTextNode(file.path)); | |
| const remove = el("button", "link small", "×"); | |
| remove.addEventListener("click", () => { | |
| attached = attached.filter((f) => f.path !== file.path); | |
| renderChips(); | |
| }); | |
| chip.append(remove); | |
| bar.append(chip); | |
| }); | |
| } | |
| // ---- fetching ------------------------------------------------------ | |
| async function fetchMentionedUrls(text) { | |
| if (!config.can_fetch) return []; | |
| const urls = Array.from(new Set(text.match(URL_PATTERN) || [])).slice(0, 3); | |
| const fetched = []; | |
| for (const url of urls) { | |
| try { | |
| const data = await backend.fetchUrl(url); | |
| fetched.push(data); | |
| showNote("thread", `fetched ${data.final_url} (${data.text.length} characters${data.truncated ? ", cut off" : ""})`); | |
| } catch (err) { | |
| showNote("thread", `could not fetch ${url}: ${err.message}`); | |
| } | |
| } | |
| return fetched; | |
| } | |
| // ---- sending ------------------------------------------------------- | |
| function buildOutgoing(history) { | |
| const outgoing = []; | |
| const system = $("system").value.trim(); | |
| if (system) outgoing.push({ role: "system", content: system }); | |
| return outgoing.concat(history); | |
| } | |
| async function stream(model, threadId, history, signal) { | |
| const body = addMessage(threadId, "assistant"); | |
| body.classList.add("blink"); | |
| let answer = ""; | |
| try { | |
| const response = await backend.chat({ | |
| model: model, | |
| messages: buildOutgoing(history), | |
| temperature: Number($("temperature").value), | |
| max_tokens: Number($("maxtokens").value) | |
| }, signal); | |
| if (!response.ok) { | |
| if (backend.mode === "static") throw new Error(await describeProviderError(response)); | |
| const data = await response.json().catch(() => ({})); | |
| throw new Error(data.error || ("HTTP " + response.status)); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop(); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed.startsWith("data:")) continue; | |
| const raw = trimmed.slice(5).trim(); | |
| if (raw === "[DONE]") continue; | |
| let chunk; | |
| try { chunk = JSON.parse(raw); } catch (e) { continue; } | |
| if (chunk.error) throw new Error(chunk.error.message || String(chunk.error)); | |
| const delta = chunk.choices && chunk.choices[0] && chunk.choices[0].delta; | |
| if (delta && delta.content) { | |
| answer += delta.content; | |
| renderContent(body, answer); | |
| scrollDown(); | |
| } | |
| } | |
| } | |
| if (!answer) body.textContent = "(the model returned nothing)"; | |
| return answer; | |
| } catch (err) { | |
| if (err.name === "AbortError") { | |
| if (!answer) body.textContent = "(stopped)"; | |
| return answer; | |
| } | |
| if (!answer) body.parentElement.remove(); | |
| showError(threadId, err.message); | |
| return ""; | |
| } finally { | |
| body.classList.remove("blink"); | |
| } | |
| } | |
| async function send() { | |
| const text = $("prompt").value.trim(); | |
| if (!text || controllers.length) return; | |
| const model = $("model").value; | |
| if (!model) { | |
| showError("thread", modelLoadError | |
| ? "No model to send to, because the list could not be loaded: " + modelLoadError | |
| : "Select a model first."); | |
| return; | |
| } | |
| const comparing = $("compare").checked; | |
| const modelB = $("model-b").value; | |
| $("intro") && $("intro").remove(); | |
| $("prompt").value = ""; | |
| $("prompt").style.height = "auto"; | |
| renderContent(addMessage("thread", "user"), text); | |
| if (comparing) renderContent(addMessage("thread-b", "user"), text); | |
| scrollDown(); | |
| let content = text; | |
| const fetched = await fetchMentionedUrls(text); | |
| fetched.forEach((doc) => { | |
| content += `\n\n--- fetched from ${doc.final_url} ---\n${doc.text}`; | |
| }); | |
| attached.forEach((file) => { | |
| content += `\n\n--- ${file.path} ---\n${file.text}`; | |
| }); | |
| messages.push({ role: "user", content: content }); | |
| if (comparing) messagesB.push({ role: "user", content: content }); | |
| $("send").disabled = true; | |
| $("stop").hidden = false; | |
| const first = new AbortController(); | |
| controllers = [first]; | |
| const runs = [stream(model, "thread", messages, first.signal)]; | |
| if (comparing && modelB) { | |
| const second = new AbortController(); | |
| controllers.push(second); | |
| runs.push(stream(modelB, "thread-b", messagesB, second.signal)); | |
| } | |
| const [answerA, answerB] = await Promise.all(runs); | |
| if (answerA) messages.push({ role: "assistant", content: answerA }); | |
| if (answerB) messagesB.push({ role: "assistant", content: answerB }); | |
| controllers = []; | |
| $("send").disabled = false; | |
| $("stop").hidden = true; | |
| $("prompt").focus(); | |
| saveChat(text); | |
| } | |
| async function saveChat(firstLine) { | |
| if (!config.can_save_conversations || !messages.length) return; | |
| try { | |
| const saved = await backend.saveChat({ | |
| id: conversationId, | |
| title: conversationId ? undefined : firstLine.slice(0, 80), | |
| model: $("model").value, | |
| messages: messages | |
| }); | |
| conversationId = saved.id; | |
| refreshChats(); | |
| } catch (err) { /* saving must never break the conversation */ } | |
| } | |
| function newChat(keepSidebar) { | |
| messages = []; messagesB = []; conversationId = null; attached = []; | |
| $("thread").textContent = ""; $("thread-b").textContent = ""; | |
| renderChips(); | |
| if (!keepSidebar) $("prompt").focus(); | |
| } | |
| // ---- models -------------------------------------------------------- | |
| // Every word has to appear, in any order and anywhere in the identifier. | |
| // Typing what you are looking for -- "qwen coder next" -- has to find | |
| // Qwen/Qwen3-Coder-Next, which a plain substring match never would, because | |
| // the identifier separates those words with slashes and dashes rather than | |
| // spaces. Matching the whole string literally made the honest search return | |
| // nothing and look like a broken page. | |
| function matchesFilter(id, terms) { | |
| const haystack = id.toLowerCase(); | |
| return terms.every((term) => haystack.includes(term)); | |
| } | |
| function fillModels() { | |
| const terms = $("modelfilter").value.toLowerCase().split(/[\s,]+/).filter(Boolean); | |
| const shown = terms.length ? allModels.filter((id) => matchesFilter(id, terms)) : allModels; | |
| [["model", "model"], ["model-b", "modelB"]].forEach(([id, key]) => { | |
| const select = $(id); | |
| const wanted = store.get(key, ""); | |
| select.textContent = ""; | |
| if (!shown.length) { | |
| // An empty list has two very different causes, and saying which one | |
| // saves the person from hunting in the wrong place. The reason is kept | |
| // here rather than only in the thread, because "New chat" clears the | |
| // thread and the dropdown is where they are looking. | |
| const label = allModels.length | |
| ? "no match for filter" | |
| : (modelLoadError ? "could not load models \u2014 check the key" : "no models available"); | |
| const option = el("option", null, label); | |
| option.value = ""; | |
| if (modelLoadError) option.title = modelLoadError; | |
| select.append(option); | |
| return; | |
| } | |
| shown.forEach((modelId) => { | |
| const option = el("option", null, modelId); | |
| option.value = modelId; | |
| if (modelId === wanted) option.selected = true; | |
| select.append(option); | |
| }); | |
| }); | |
| } | |
| async function loadModels() { | |
| try { | |
| const data = await backend.models(); | |
| allModels = (data.data || data.models || []) | |
| .map((m) => (typeof m === "string" ? m : m.id)).filter(Boolean).sort(); | |
| modelLoadError = ""; | |
| } catch (err) { | |
| allModels = []; | |
| modelLoadError = err.message; | |
| showError("thread", "Could not load the model list: " + err.message); | |
| } | |
| if (config.default_model && !allModels.includes(config.default_model)) { | |
| allModels.unshift(config.default_model); | |
| } | |
| if (config.default_model && !store.get("model", "")) store.set("model", config.default_model); | |
| if (!$("modelfilter").value) $("modelfilter").value = store.get("filter", ""); | |
| fillModels(); | |
| } | |
| // ---- wiring -------------------------------------------------------- | |
| $("send").addEventListener("click", send); | |
| $("stop").addEventListener("click", () => controllers.forEach((c) => c.abort())); | |
| $("new-chat").addEventListener("click", () => newChat()); | |
| $("prompt").addEventListener("keydown", (event) => { | |
| if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } | |
| }); | |
| $("prompt").addEventListener("input", () => { | |
| $("prompt").style.height = "auto"; | |
| $("prompt").style.height = Math.min($("prompt").scrollHeight, 200) + "px"; | |
| }); | |
| $("toggle-settings").addEventListener("click", (event) => { | |
| const open = $("settings").classList.toggle("open"); | |
| event.target.setAttribute("aria-expanded", String(open)); | |
| }); | |
| $("toggle-sidebar").addEventListener("click", () => { | |
| const hidden = $("sidebar").classList.toggle("hidden"); | |
| store.set("sidebar", hidden ? "hidden" : "shown"); | |
| }); | |
| $("tab-chats").addEventListener("click", () => selectTab("chats")); | |
| $("tab-files").addEventListener("click", () => selectTab("files")); | |
| $("compare").addEventListener("change", () => { | |
| const on = $("compare").checked; | |
| $("model-b").hidden = !on; | |
| $("col-b").hidden = !on; | |
| $("head-a").hidden = !on; | |
| if (on) { $("head-a").textContent = $("model").value; $("head-b").textContent = $("model-b").value; } | |
| store.set("compare", on ? "1" : ""); | |
| }); | |
| $("model").addEventListener("change", () => { | |
| store.set("model", $("model").value); | |
| $("head-a").textContent = $("model").value; | |
| }); | |
| $("model-b").addEventListener("change", () => { | |
| store.set("modelB", $("model-b").value); | |
| $("head-b").textContent = $("model-b").value; | |
| }); | |
| $("modelfilter").addEventListener("input", () => { store.set("filter", $("modelfilter").value); fillModels(); }); | |
| $("system").addEventListener("change", () => store.set("system", $("system").value)); | |
| $("temperature").addEventListener("change", () => store.set("temperature", $("temperature").value)); | |
| $("maxtokens").addEventListener("change", () => store.set("maxtokens", $("maxtokens").value)); | |
| // One button per shipped personality. They are two different assistants on | |
| // two different base models, not two moods of one -- each file says which | |
| // model it belongs to, so switching the text switches the whole identity. | |
| [["persona-code", "code"], ["persona-chat", "chat"]].forEach(([id, name]) => { | |
| $(id).addEventListener("click", () => { | |
| const text = shippedPersonas[name] || ""; | |
| $("system").value = text; | |
| store.set("system", text); | |
| }); | |
| }); | |
| $("apibase").addEventListener("change", () => { | |
| store.set("apiBase", $("apibase").value.trim() || DEFAULT_API_BASE); | |
| config.api_base = $("apibase").value.trim() || DEFAULT_API_BASE; | |
| $("provider").textContent = config.api_base; | |
| loadModels(); | |
| }); | |
| $("apikey").addEventListener("change", () => { | |
| store.set("key", $("apikey").value.trim()); | |
| loadModels(); | |
| }); | |
| $("forget-key").addEventListener("click", () => { | |
| store.set("key", ""); | |
| $("apikey").value = ""; | |
| showNote("thread", "The key was removed from this browser. Revoke it at your provider as well if it may have been seen."); | |
| }); | |
| // A "change" event fires when the field loses focus, which is exactly what | |
| // tapping a repository in the sidebar does -- and rebuilding the sidebar | |
| // right then takes the row out from under the finger, so the first tap does | |
| // nothing and the second works. Re-rendering only when the list really | |
| // changed keeps that from happening. | |
| let lastRepoSignature = null; | |
| function repoListChanged() { | |
| const signature = connectedRepos().map((repo) => repo.label).join("\n"); | |
| if (signature === lastRepoSignature) return false; | |
| lastRepoSignature = signature; | |
| return true; | |
| } | |
| $("ghrepos").addEventListener("change", () => { | |
| store.set("repos", $("ghrepos").value); | |
| if (repoListChanged()) refreshFiles(); | |
| }); | |
| $("ghtoken").addEventListener("change", () => { | |
| store.set("ghToken", $("ghtoken").value.trim()); | |
| config.can_change_github = Boolean($("ghtoken").value.trim()); | |
| }); | |
| $("forget-ghtoken").addEventListener("click", () => { | |
| store.set("ghToken", ""); | |
| $("ghtoken").value = ""; | |
| config.can_change_github = false; | |
| showNote("thread", "The GitHub token was removed from this browser. " + | |
| "Revoke it on GitHub as well if it may have been seen."); | |
| }); | |
| $("filepicker").addEventListener("change", (event) => { | |
| attachPickedFiles(event.target.files); | |
| event.target.value = ""; // so choosing the same file again still fires | |
| }); | |
| $("system").value = store.get("system", ""); | |
| $("ghrepos").value = store.get("repos", ""); | |
| lastRepoSignature = null; // set for real once the backend is known | |
| $("ghtoken").value = store.get("ghToken", ""); | |
| $("temperature").value = store.get("temperature", "0.2"); | |
| $("maxtokens").value = store.get("maxtokens", "4096"); | |
| if (store.get("sidebar", "shown") === "hidden") $("sidebar").classList.add("hidden"); | |
| selectTab(store.get("tab", "chats")); | |
| // ---- startup: find out which mode this is -------------------------- | |
| async function detectBackend() { | |
| try { | |
| const cfg = await api("/api/config"); | |
| // A 404 is not the only way to have no server. Some static hosts answer | |
| // every unknown path with index.html and status 200, which would look | |
| // like success -- so the answer has to prove it came from the server. | |
| if (!cfg || typeof cfg.api_base !== "string") throw new Error("not the interface server"); | |
| return { | |
| backend: serverBackend, | |
| config: Object.assign({ can_fetch: true, can_browse_project: !!cfg.project_dir }, cfg) | |
| }; | |
| } catch (err) { | |
| // No server answered. That is the normal case on a static host, not a | |
| // failure -- so the page switches modes instead of showing an error. | |
| return { backend: staticBackend, config: await staticBackend.config() }; | |
| } | |
| } | |
| detectBackend().then(async (found) => { | |
| backend = found.backend; | |
| config = found.config; | |
| document.body.classList.toggle("static", backend.mode === "static"); | |
| $("provider").textContent = config.api_base; | |
| defaultSystemPrompt = config.default_system_prompt || ""; | |
| shippedPersonas = config.personas || {}; | |
| // A personality that did not load gets no button, rather than a | |
| // button that quietly empties the box when pressed. | |
| [["persona-code", "code"], ["persona-chat", "chat"]].forEach(([id, name]) => { | |
| $(id).hidden = !shippedPersonas[name]; | |
| }); | |
| // Only prefill when nothing was ever saved. An edited prompt has to survive | |
| // a reload -- overwriting it would throw the edit away silently. | |
| if (store.get("system", null) === null) $("system").value = defaultSystemPrompt; | |
| if (store.get("repos", null) === null && (config.github_repos || []).length) { | |
| $("ghrepos").value = config.github_repos.join("\n"); | |
| store.set("repos", $("ghrepos").value); | |
| } | |
| repoListChanged(); // record what is there now, so a blur alone is not a change | |
| $("intro-key").textContent = backend.mode === "static" | |
| ? "There is no server here, so this page talks to your provider directly and your API key is kept in this browser. Open Settings to enter it." | |
| : "Your API key stays in the local server and never reaches this page."; | |
| if (backend.mode === "static") { | |
| $("apibase").value = store.get("apiBase", DEFAULT_API_BASE); | |
| $("apikey").value = store.get("key", ""); | |
| if (!store.get("key", "")) $("settings").classList.add("open"); | |
| // A provider serves hundreds of models. Starting narrowed to the family | |
| // this project is built on beats scrolling to find it. | |
| if (store.get("filter", null) === null) store.set("filter", "qwen"); | |
| } | |
| const parts = []; | |
| if (config.project_dir) parts.push("project: " + config.project_dir); | |
| if (config.can_run_code) parts.push("running Python is possible — limits apply, but this is not a container"); | |
| if (!config.can_save_conversations) parts.push("chats are not saved"); | |
| if (backend.mode === "static") parts.push("no server: chats stay in this browser, code cannot be run"); | |
| if (parts.length) $("hint").textContent = "Enter sends · Shift+Enter for a new line · " + parts.join(" · "); | |
| if (store.get("compare", "")) { $("compare").checked = true; $("compare").dispatchEvent(new Event("change")); } | |
| await loadModels(); | |
| refreshChats(); | |
| refreshFiles(); | |
| }); | |
| $("prompt").focus(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |