Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- README.md +24 -0
- caracat_persona.md +29 -0
- index.html +479 -20
README.md
CHANGED
|
@@ -72,6 +72,8 @@ the default provider, so such a proxy works without any change here.
|
|
| 72 |
| Compare two models side by side | ✅ |
|
| 73 |
| Attach files from your device | ✅ — through the browser's file picker |
|
| 74 |
| Keep conversations | ✅ — in this browser, not on a server |
|
|
|
|
|
|
|
| 75 |
| Browse a project directory | ❌ — that needs a server with your files on it |
|
| 76 |
| Run code | ❌ — that needs a server too |
|
| 77 |
| Fetch web pages | ❌ — a browser is not allowed to read other sites |
|
|
@@ -87,6 +89,28 @@ python scripts/serve_interface.py --project-dir ~/your-project
|
|
| 87 |
Then the key stays in that server process and never reaches the page, the
|
| 88 |
project directory is readable, and Python can be run under limits.
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
**Attached files are checked before they are sent.** A file that looks like it
|
| 91 |
holds a credential is refused, and the message names the line, never the value.
|
| 92 |
Sending a key to a provider cannot be undone.
|
|
|
|
| 72 |
| Compare two models side by side | ✅ |
|
| 73 |
| Attach files from your device | ✅ — through the browser's file picker |
|
| 74 |
| Keep conversations | ✅ — in this browser, not on a server |
|
| 75 |
+
| Read GitHub repositories | ✅ — public ones, no token needed |
|
| 76 |
+
| Propose changes as pull requests | ✅ — with a GitHub token, and only on a press |
|
| 77 |
| Browse a project directory | ❌ — that needs a server with your files on it |
|
| 78 |
| Run code | ❌ — that needs a server too |
|
| 79 |
| Fetch web pages | ❌ — a browser is not allowed to read other sites |
|
|
|
|
| 89 |
Then the key stays in that server process and never reaches the page, the
|
| 90 |
project directory is readable, and Python can be run under limits.
|
| 91 |
|
| 92 |
+
## Working with GitHub
|
| 93 |
+
|
| 94 |
+
Add repositories in **Settings** as `owner/name`, one per line. Public ones need
|
| 95 |
+
no token, and each becomes its own section in the sidebar — attach a file and it
|
| 96 |
+
carries the repository in its name, so a conversation about two projects stays
|
| 97 |
+
clear about which file is which.
|
| 98 |
+
|
| 99 |
+
This works here, unlike fetching an ordinary web page, because GitHub is one of
|
| 100 |
+
the few APIs that permits a web page to call it.
|
| 101 |
+
|
| 102 |
+
**To let it propose changes**, add a GitHub token in Settings. Make it
|
| 103 |
+
fine-grained, limited to exactly those repositories, with *Contents* and *Pull
|
| 104 |
+
requests* on read+write and nothing else. Then a block the model marks with a
|
| 105 |
+
file gets a **Propose…** button:
|
| 106 |
+
|
| 107 |
+
- pressing it creates a branch and opens a pull request — never a commit to the
|
| 108 |
+
default branch;
|
| 109 |
+
- opening the confirmation panel sends nothing at all; only the button does;
|
| 110 |
+
- the model can propose but cannot act. That separation is the point.
|
| 111 |
+
|
| 112 |
+
The token lives in this browser, like the API key, with the same *Forget* button.
|
| 113 |
+
|
| 114 |
**Attached files are checked before they are sent.** A file that looks like it
|
| 115 |
holds a credential is refused, and the message names the line, never the value.
|
| 116 |
Sending a key to a provider cannot be undone.
|
caracat_persona.md
CHANGED
|
@@ -107,6 +107,35 @@ Stay warm about it. A narrow focus is not an excuse to be curt.
|
|
| 107 |
- Be concise. Length is not thoroughness, and padding wastes the reader's time.
|
| 108 |
- Say what you did not check, when it matters.
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
## About yourself
|
| 111 |
|
| 112 |
You are based on Qwen3-Coder-Next by Qwen. You were not trained from scratch,
|
|
|
|
| 107 |
- Be concise. Length is not thoroughness, and padding wastes the reader's time.
|
| 108 |
- Say what you did not check, when it matters.
|
| 109 |
|
| 110 |
+
## Changing a repository
|
| 111 |
+
|
| 112 |
+
When repositories are connected, a fenced block can name the file it belongs to,
|
| 113 |
+
and the person is then offered a button that opens a pull request:
|
| 114 |
+
|
| 115 |
+
```python file=src/app.py
|
| 116 |
+
...the complete new contents of that file...
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Four things follow from that, and they are not optional.
|
| 120 |
+
|
| 121 |
+
**Write the whole file, not a fragment.** What the block contains is what the
|
| 122 |
+
file will contain. A snippet with "..." in the middle would be committed exactly
|
| 123 |
+
like that.
|
| 124 |
+
|
| 125 |
+
**Say which repository when more than one is connected**, by adding
|
| 126 |
+
`repo=owner/name` to the same line. If you are not sure which one a change
|
| 127 |
+
belongs to, ask -- that is the first rule of this file applied to the case where
|
| 128 |
+
guessing wrong writes to the wrong project.
|
| 129 |
+
|
| 130 |
+
**Only mark a block that way when a change is actually wanted.** An example, an
|
| 131 |
+
illustration, a sketch of an idea is ordinary code and stays ordinary code. A
|
| 132 |
+
`file=` turns an answer into something someone can act on with one press.
|
| 133 |
+
|
| 134 |
+
**You never write anything yourself.** You propose; the person decides. Do not
|
| 135 |
+
describe a change as done, made or pushed. It becomes a pull request when they
|
| 136 |
+
press the button, and not before -- so say what the change would do, and leave
|
| 137 |
+
the deciding to them.
|
| 138 |
+
|
| 139 |
## About yourself
|
| 140 |
|
| 141 |
You are based on Qwen3-Coder-Next by Qwen. You were not trained from scratch,
|
index.html
CHANGED
|
@@ -174,6 +174,15 @@
|
|
| 174 |
<input id="apikey" type="password" spellcheck="false" autocomplete="off" placeholder="paste your provider key">
|
| 175 |
<button id="forget-key" class="small">Forget the key on this device</button>
|
| 176 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
|
| 179 |
<div class="layout">
|
|
@@ -298,6 +307,218 @@
|
|
| 298 |
return null;
|
| 299 |
}
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
// ---- the two backends ----------------------------------------------
|
| 302 |
//
|
| 303 |
// This page runs in two places. On your own machine it talks to its own
|
|
@@ -334,6 +555,20 @@
|
|
| 334 |
}),
|
| 335 |
listFiles: () => api("/api/files").then((d) => d.entries),
|
| 336 |
readFile: (path) => api("/api/file?path=" + encodeURIComponent(path)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
run: (code, files) => api("/api/run", {
|
| 338 |
method: "POST",
|
| 339 |
headers: { "Content-Type": "application/json" },
|
|
@@ -385,7 +620,9 @@
|
|
| 385 |
can_run_code: false, // no server, nothing to run code on
|
| 386 |
can_browse_project: false, // no server, no directory to browse
|
| 387 |
can_fetch: false, // other sites refuse a browser from here
|
| 388 |
-
can_save_conversations: true
|
|
|
|
|
|
|
| 389 |
};
|
| 390 |
},
|
| 391 |
|
|
@@ -452,6 +689,17 @@
|
|
| 452 |
|
| 453 |
async listFiles() { return []; },
|
| 454 |
async readFile() { throw new Error("There is no project directory without a server."); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
async run() { throw new Error("Running code needs a server. Start the interface on your own machine."); },
|
| 456 |
async fetchUrl() { throw new Error("Fetching other sites needs a server; a browser is not allowed to."); }
|
| 457 |
};
|
|
@@ -510,7 +758,9 @@
|
|
| 510 |
return;
|
| 511 |
}
|
| 512 |
const newline = part.indexOf("\n");
|
| 513 |
-
const
|
|
|
|
|
|
|
| 514 |
const code = newline === -1 ? part : part.slice(newline + 1);
|
| 515 |
|
| 516 |
const wrap = el("div", "codewrap");
|
|
@@ -533,11 +783,104 @@
|
|
| 533 |
run.addEventListener("click", () => runCode(code, wrap, run));
|
| 534 |
bar.append(run);
|
| 535 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
wrap.append(pre, bar);
|
| 537 |
target.append(wrap);
|
| 538 |
});
|
| 539 |
}
|
| 540 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
function showError(threadId, text) {
|
| 542 |
addMessage(threadId, "error", "error").textContent = text;
|
| 543 |
scrollDown();
|
|
@@ -641,40 +984,118 @@
|
|
| 641 |
} catch (err) { showError("thread", err.message); }
|
| 642 |
}
|
| 643 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
async function refreshFiles() {
|
|
|
|
|
|
|
| 645 |
const panel = $("panel-files");
|
| 646 |
panel.textContent = "";
|
| 647 |
|
| 648 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
// No server means no directory to walk. The browser's own picker still
|
| 650 |
// reaches the device's files, which is the part that matters on a tablet.
|
| 651 |
const pick = el("button", "small", "Choose files…");
|
| 652 |
pick.addEventListener("click", () => $("filepicker").click());
|
| 653 |
panel.append(pick);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
panel.append(el("div", "empty",
|
| 655 |
-
"
|
| 656 |
-
"
|
| 657 |
-
"A file that looks like it holds a credential is refused."));
|
| 658 |
return;
|
| 659 |
}
|
| 660 |
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
const
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
row.append(el("div", "meta", entry.size > 1024
|
| 672 |
? Math.round(entry.size / 1024) + " kB" : entry.size + " B"));
|
| 673 |
-
row.
|
| 674 |
-
row.
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
}
|
| 679 |
|
| 680 |
async function attachFile(path) {
|
|
@@ -1022,12 +1443,44 @@
|
|
| 1022 |
$("apikey").value = "";
|
| 1023 |
showNote("thread", "The key was removed from this browser. Revoke it at your provider as well if it may have been seen.");
|
| 1024 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
$("filepicker").addEventListener("change", (event) => {
|
| 1026 |
attachPickedFiles(event.target.files);
|
| 1027 |
event.target.value = ""; // so choosing the same file again still fires
|
| 1028 |
});
|
| 1029 |
|
| 1030 |
$("system").value = store.get("system", "");
|
|
|
|
|
|
|
|
|
|
| 1031 |
$("temperature").value = store.get("temperature", "0.2");
|
| 1032 |
$("maxtokens").value = store.get("maxtokens", "4096");
|
| 1033 |
if (store.get("sidebar", "shown") === "hidden") $("sidebar").classList.add("hidden");
|
|
@@ -1064,6 +1517,12 @@
|
|
| 1064 |
// a reload -- overwriting it would throw the edit away silently.
|
| 1065 |
if (store.get("system", null) === null) $("system").value = defaultSystemPrompt;
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
$("intro-key").textContent = backend.mode === "static"
|
| 1068 |
? "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."
|
| 1069 |
: "Your API key stays in the local server and never reaches this page.";
|
|
|
|
| 174 |
<input id="apikey" type="password" spellcheck="false" autocomplete="off" placeholder="paste your provider key">
|
| 175 |
<button id="forget-key" class="small">Forget the key on this device</button>
|
| 176 |
</div>
|
| 177 |
+
<div class="field grow">
|
| 178 |
+
<label for="ghrepos">GitHub repositories · public only, owner/name, one per line</label>
|
| 179 |
+
<textarea id="ghrepos" spellcheck="false" placeholder="owner/name other-owner/other-name@branch"></textarea>
|
| 180 |
+
</div>
|
| 181 |
+
<div class="field static-only grow">
|
| 182 |
+
<label for="ghtoken">GitHub token · only to propose changes; kept in this browser</label>
|
| 183 |
+
<input id="ghtoken" type="password" spellcheck="false" autocomplete="off" placeholder="optional — reading needs none">
|
| 184 |
+
<button id="forget-ghtoken" class="small">Forget the GitHub token</button>
|
| 185 |
+
</div>
|
| 186 |
</div>
|
| 187 |
|
| 188 |
<div class="layout">
|
|
|
|
| 307 |
return null;
|
| 308 |
}
|
| 309 |
|
| 310 |
+
// ---- GitHub --------------------------------------------------------
|
| 311 |
+
//
|
| 312 |
+
// Two hosts, written here, never taken from anywhere. GitHub is one of the
|
| 313 |
+
// few APIs that lets a web page call it, which is what makes this work on a
|
| 314 |
+
// static host where fetching an arbitrary site does not.
|
| 315 |
+
//
|
| 316 |
+
// The lists below mirror src/caracat_code/github.py. They are duplicated
|
| 317 |
+
// rather than shared because there is no server in this mode to share them
|
| 318 |
+
// with -- if you change one, change the other.
|
| 319 |
+
|
| 320 |
+
const GITHUB_API = "https://api.github.com";
|
| 321 |
+
const GITHUB_RAW = "https://raw.githubusercontent.com";
|
| 322 |
+
const GITHUB_MAX_FILE_BYTES = 256 * 1024;
|
| 323 |
+
|
| 324 |
+
const REPO_PART = /^[A-Za-z0-9_](?:[A-Za-z0-9._-]*[A-Za-z0-9_])?$/;
|
| 325 |
+
const BRANCH_PART = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,98}[A-Za-z0-9]$/;
|
| 326 |
+
|
| 327 |
+
const EXCLUDED_DIRS = new Set([".git", ".hg", ".svn", ".idea", ".vscode",
|
| 328 |
+
".mypy_cache", ".pytest_cache", ".ruff_cache", "__pycache__", "node_modules",
|
| 329 |
+
".venv", "venv", "env", "dist", "build", ".next", ".tox"]);
|
| 330 |
+
|
| 331 |
+
const NEVER_READABLE_NAMES = [/^\.env(\..*)?$/, /\.pem$/, /\.key$/, /\.p12$/,
|
| 332 |
+
/\.pfx$/, /^id_rsa/, /^id_ed25519/, /^credentials\.json$/,
|
| 333 |
+
/^secrets\.ya?ml$/, /^\.npmrc$/, /^\.pypirc$/, /^\.netrc$/];
|
| 334 |
+
|
| 335 |
+
const TEXT_SUFFIXES = [".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx",
|
| 336 |
+
".jsx", ".json", ".md", ".rst", ".txt", ".yml", ".yaml", ".toml", ".ini",
|
| 337 |
+
".cfg", ".html", ".css", ".scss", ".sh", ".bash", ".sql", ".go", ".rs",
|
| 338 |
+
".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".rb", ".php", ".swift",
|
| 339 |
+
".xml", ".csv", ".gitignore", ".dockerignore"];
|
| 340 |
+
const TEXT_NAMES = ["Dockerfile", "Makefile", "LICENSE", "NOTICE"];
|
| 341 |
+
|
| 342 |
+
function parseRepo(spec) {
|
| 343 |
+
const text = String(spec || "").trim();
|
| 344 |
+
if (!text) throw new Error("no repository given; expected owner/name");
|
| 345 |
+
if (text.includes("://") || text.startsWith("github.com")) {
|
| 346 |
+
throw new Error(`"${text}" looks like a URL. Use just owner/name.`);
|
| 347 |
+
}
|
| 348 |
+
let body = text, ref = "main";
|
| 349 |
+
const at = text.indexOf("@");
|
| 350 |
+
if (at !== -1) {
|
| 351 |
+
body = text.slice(0, at);
|
| 352 |
+
ref = text.slice(at + 1);
|
| 353 |
+
if (!BRANCH_PART.test(ref)) throw new Error(`"${ref}" is not a usable branch name`);
|
| 354 |
+
}
|
| 355 |
+
const parts = body.split("/");
|
| 356 |
+
if (parts.length !== 2 || !REPO_PART.test(parts[0]) || !REPO_PART.test(parts[1])) {
|
| 357 |
+
throw new Error(`"${text}" should be owner/name — two parts, one slash`);
|
| 358 |
+
}
|
| 359 |
+
return { owner: parts[0], name: parts[1], ref: ref,
|
| 360 |
+
slug: parts[0] + "/" + parts[1], label: parts[0] + "/" + parts[1] + "@" + ref };
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function parseRepoList(text) {
|
| 364 |
+
const specs = String(text || "").split(/[\s,]+/).filter(Boolean);
|
| 365 |
+
const repos = [];
|
| 366 |
+
for (const spec of specs) {
|
| 367 |
+
try { repos.push(parseRepo(spec)); }
|
| 368 |
+
catch (err) { /* a half-typed repo is not an error worth shouting about */ }
|
| 369 |
+
}
|
| 370 |
+
return repos;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
function isInterestingPath(path) {
|
| 374 |
+
const parts = path.split("/");
|
| 375 |
+
if (parts.slice(0, -1).some((part) => EXCLUDED_DIRS.has(part))) return false;
|
| 376 |
+
const name = parts[parts.length - 1];
|
| 377 |
+
if (NEVER_READABLE_NAMES.some((pattern) => pattern.test(name))) return false;
|
| 378 |
+
if (TEXT_NAMES.includes(name)) return true;
|
| 379 |
+
return TEXT_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
async function githubCall(url, token, options) {
|
| 383 |
+
const headers = { "Accept": "application/vnd.github+json",
|
| 384 |
+
"X-GitHub-Api-Version": "2022-11-28" };
|
| 385 |
+
if (token) headers["Authorization"] = "Bearer " + token;
|
| 386 |
+
let response;
|
| 387 |
+
try {
|
| 388 |
+
response = await fetch(url, Object.assign({}, options,
|
| 389 |
+
{ headers: Object.assign(headers, (options || {}).headers) }));
|
| 390 |
+
} catch (err) {
|
| 391 |
+
// A browser reports a CORS refusal as a bare TypeError. Saying so beats
|
| 392 |
+
// "Failed to fetch", which sends people looking for a network problem.
|
| 393 |
+
throw new Error(
|
| 394 |
+
"The browser would not send that request to GitHub" +
|
| 395 |
+
((options && options.method && options.method !== "GET")
|
| 396 |
+
? " — writing to GitHub from a hosted page may not be permitted. " +
|
| 397 |
+
"Reading works; run the interface on your own machine to make changes."
|
| 398 |
+
: ".") + " (" + err.message + ")");
|
| 399 |
+
}
|
| 400 |
+
if (!response.ok) {
|
| 401 |
+
let detail = "";
|
| 402 |
+
try { detail = ((await response.json()).message) || ""; } catch (e) {}
|
| 403 |
+
if (response.status === 404) {
|
| 404 |
+
throw new Error("GitHub says that does not exist. Check the owner and " +
|
| 405 |
+
"name; a private repository is not reachable here. " + detail);
|
| 406 |
+
}
|
| 407 |
+
if (response.status === 403 && /rate limit/i.test(detail)) {
|
| 408 |
+
throw new Error("GitHub's hourly allowance for anonymous requests is " +
|
| 409 |
+
"used up. It refills on the hour. " + detail);
|
| 410 |
+
}
|
| 411 |
+
throw new Error("GitHub returned " + response.status + ". " + detail);
|
| 412 |
+
}
|
| 413 |
+
return response;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
async function githubTree(repo, token) {
|
| 417 |
+
const url = `${GITHUB_API}/repos/${repo.owner}/${repo.name}` +
|
| 418 |
+
`/git/trees/${encodeURIComponent(repo.ref)}?recursive=1`;
|
| 419 |
+
const data = await (await githubCall(url, token)).json();
|
| 420 |
+
return (data.tree || [])
|
| 421 |
+
.filter((item) => item.type === "blob" && isInterestingPath(item.path || "")
|
| 422 |
+
&& (item.size || 0) <= GITHUB_MAX_FILE_BYTES)
|
| 423 |
+
.map((item) => ({ path: item.path, size: item.size || 0 }))
|
| 424 |
+
.sort((a, b) => a.path.localeCompare(b.path));
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
async function githubFile(repo, path, token) {
|
| 428 |
+
const clean = String(path || "").trim();
|
| 429 |
+
if (!clean || clean.startsWith("/") || clean.split("/").includes("..")) {
|
| 430 |
+
throw new Error(`"${path}" does not stay inside the repository`);
|
| 431 |
+
}
|
| 432 |
+
const url = `${GITHUB_RAW}/${repo.owner}/${repo.name}/` +
|
| 433 |
+
`${encodeURIComponent(repo.ref)}/` +
|
| 434 |
+
clean.split("/").map(encodeURIComponent).join("/");
|
| 435 |
+
const text = await (await githubCall(url, token)).text();
|
| 436 |
+
if (text.length > GITHUB_MAX_FILE_BYTES) {
|
| 437 |
+
throw new Error(`${clean} is too large to attach.`);
|
| 438 |
+
}
|
| 439 |
+
if (text.includes("\u0000")) {
|
| 440 |
+
throw new Error(`${clean} looks like a binary file.`);
|
| 441 |
+
}
|
| 442 |
+
// The same check a project file gets. A public repository is not a reason
|
| 443 |
+
// to forward a key to an inference provider.
|
| 444 |
+
const finding = findSecret(text);
|
| 445 |
+
if (finding) {
|
| 446 |
+
throw new Error(`${clean} was not attached: line ${finding.line} looks ` +
|
| 447 |
+
`like a ${finding.pattern}. The value is not repeated here.`);
|
| 448 |
+
}
|
| 449 |
+
return { repo: repo.slug, path: clean, text: text };
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
// Opening a pull request from the browser. Mirrors open_pull_request in
|
| 453 |
+
// src/caracat_code/github.py, including the one refusal that matters: never
|
| 454 |
+
// the default branch. Whether a browser is allowed to do this at all is
|
| 455 |
+
// answered by the first attempt -- githubCall says so plainly if not.
|
| 456 |
+
async function githubOpenPull(repo, changes, options) {
|
| 457 |
+
const token = options.token;
|
| 458 |
+
if (!token) {
|
| 459 |
+
throw new Error("Changing a repository needs a GitHub token with " +
|
| 460 |
+
"Contents and Pull requests permission for exactly that repository.");
|
| 461 |
+
}
|
| 462 |
+
if (!changes.length) throw new Error("There is nothing to change.");
|
| 463 |
+
if (changes.length > 20) {
|
| 464 |
+
throw new Error(changes.length + " files is more than one pull request " +
|
| 465 |
+
"should carry. Split it up.");
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
const api = (path, init) =>
|
| 469 |
+
githubCall(`${GITHUB_API}/repos/${repo.owner}/${repo.name}${path}`, token, init);
|
| 470 |
+
const send = (path, method, payload) => api(path, {
|
| 471 |
+
method: method,
|
| 472 |
+
headers: { "Content-Type": "application/json" },
|
| 473 |
+
body: JSON.stringify(payload)
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
+
const base = (await (await api("", {})).json()).default_branch || "main";
|
| 477 |
+
if (options.branch === base) {
|
| 478 |
+
throw new Error(`Refusing to write to "${base}" directly. Changes go on ` +
|
| 479 |
+
"their own branch and through a pull request.");
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
for (const change of changes) {
|
| 483 |
+
const finding = findSecret(change.text);
|
| 484 |
+
if (finding) {
|
| 485 |
+
throw new Error(`${change.path} was not written: line ${finding.line} ` +
|
| 486 |
+
`looks like a ${finding.pattern}. A key committed to a public ` +
|
| 487 |
+
"repository is public immediately.");
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
const tip = (await (await api(
|
| 492 |
+
"/git/ref/heads/" + encodeURIComponent(base), {})).json()).object.sha;
|
| 493 |
+
await send("/git/refs", "POST",
|
| 494 |
+
{ ref: "refs/heads/" + options.branch, sha: tip });
|
| 495 |
+
|
| 496 |
+
for (const change of changes) {
|
| 497 |
+
const where = "/contents/" +
|
| 498 |
+
change.path.split("/").map(encodeURIComponent).join("/");
|
| 499 |
+
let existing = null;
|
| 500 |
+
try {
|
| 501 |
+
existing = (await (await api(
|
| 502 |
+
where + "?ref=" + encodeURIComponent(options.branch), {})).json()).sha;
|
| 503 |
+
} catch (err) { /* absent means a new file, which is fine */ }
|
| 504 |
+
const payload = {
|
| 505 |
+
message: options.title,
|
| 506 |
+
// btoa cannot take characters above 255, so the text is turned into
|
| 507 |
+
// UTF-8 bytes first. Without this an umlaut breaks the commit.
|
| 508 |
+
content: btoa(String.fromCharCode(...new TextEncoder().encode(change.text))),
|
| 509 |
+
branch: options.branch
|
| 510 |
+
};
|
| 511 |
+
if (existing) payload.sha = existing;
|
| 512 |
+
await send(where, "PUT", payload);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
const opened = await (await send("/pulls", "POST", {
|
| 516 |
+
title: options.title, body: options.body,
|
| 517 |
+
head: options.branch, base: base
|
| 518 |
+
})).json();
|
| 519 |
+
return opened.html_url;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
// ---- the two backends ----------------------------------------------
|
| 523 |
//
|
| 524 |
// This page runs in two places. On your own machine it talks to its own
|
|
|
|
| 555 |
}),
|
| 556 |
listFiles: () => api("/api/files").then((d) => d.entries),
|
| 557 |
readFile: (path) => api("/api/file?path=" + encodeURIComponent(path)),
|
| 558 |
+
repoTree: (repo) =>
|
| 559 |
+
api("/api/github/tree?repo=" + encodeURIComponent(repo.label))
|
| 560 |
+
.then((d) => d.entries),
|
| 561 |
+
repoFile: (repo, path) =>
|
| 562 |
+
api("/api/github/file?repo=" + encodeURIComponent(repo.label) +
|
| 563 |
+
"&path=" + encodeURIComponent(path)),
|
| 564 |
+
openPull: (repo, changes, options) => api("/api/github/pull", {
|
| 565 |
+
method: "POST",
|
| 566 |
+
headers: { "Content-Type": "application/json" },
|
| 567 |
+
body: JSON.stringify({
|
| 568 |
+
repo: repo.label, changes: changes,
|
| 569 |
+
title: options.title, body: options.body, branch: options.branch
|
| 570 |
+
})
|
| 571 |
+
}).then((d) => d.url),
|
| 572 |
run: (code, files) => api("/api/run", {
|
| 573 |
method: "POST",
|
| 574 |
headers: { "Content-Type": "application/json" },
|
|
|
|
| 620 |
can_run_code: false, // no server, nothing to run code on
|
| 621 |
can_browse_project: false, // no server, no directory to browse
|
| 622 |
can_fetch: false, // other sites refuse a browser from here
|
| 623 |
+
can_save_conversations: true, // in this browser, not on a server
|
| 624 |
+
github_repos: parseRepoList(store.get("repos", "")).map((r) => r.label),
|
| 625 |
+
can_change_github: Boolean(store.get("ghToken", "").trim())
|
| 626 |
};
|
| 627 |
},
|
| 628 |
|
|
|
|
| 689 |
|
| 690 |
async listFiles() { return []; },
|
| 691 |
async readFile() { throw new Error("There is no project directory without a server."); },
|
| 692 |
+
|
| 693 |
+
// GitHub is reached directly, which is possible because GitHub is one of
|
| 694 |
+
// the few APIs that permits a web page to call it.
|
| 695 |
+
repoTree(repo) { return githubTree(repo, store.get("ghToken", "").trim()); },
|
| 696 |
+
repoFile(repo, path) {
|
| 697 |
+
return githubFile(repo, path, store.get("ghToken", "").trim());
|
| 698 |
+
},
|
| 699 |
+
openPull(repo, changes, options) {
|
| 700 |
+
return githubOpenPull(repo, changes,
|
| 701 |
+
Object.assign({ token: store.get("ghToken", "").trim() }, options));
|
| 702 |
+
},
|
| 703 |
async run() { throw new Error("Running code needs a server. Start the interface on your own machine."); },
|
| 704 |
async fetchUrl() { throw new Error("Fetching other sites needs a server; a browser is not allowed to."); }
|
| 705 |
};
|
|
|
|
| 758 |
return;
|
| 759 |
}
|
| 760 |
const newline = part.indexOf("\n");
|
| 761 |
+
const info = (newline === -1 ? "" : part.slice(0, newline)).trim();
|
| 762 |
+
const language = info.split(/\s+/)[0].toLowerCase();
|
| 763 |
+
const blockTarget = parseBlockTarget(info); // not `target`: that is this function's parameter
|
| 764 |
const code = newline === -1 ? part : part.slice(newline + 1);
|
| 765 |
|
| 766 |
const wrap = el("div", "codewrap");
|
|
|
|
| 783 |
run.addEventListener("click", () => runCode(code, wrap, run));
|
| 784 |
bar.append(run);
|
| 785 |
}
|
| 786 |
+
|
| 787 |
+
// A block that names a file can become a pull request -- but only when
|
| 788 |
+
// someone presses this. The model writes the proposal; it never sends it.
|
| 789 |
+
if (blockTarget && config.can_change_github && connectedRepos().length) {
|
| 790 |
+
const propose = el("button", "small", "Propose…");
|
| 791 |
+
propose.addEventListener("click", () => showProposal(code, blockTarget, wrap, propose));
|
| 792 |
+
bar.append(propose);
|
| 793 |
+
}
|
| 794 |
wrap.append(pre, bar);
|
| 795 |
target.append(wrap);
|
| 796 |
});
|
| 797 |
}
|
| 798 |
|
| 799 |
+
// A fenced block can say where it belongs: ```python file=src/app.py repo=owner/name
|
| 800 |
+
// Without a file= there is nothing to propose, which is the safe default:
|
| 801 |
+
// ordinary code in an answer stays ordinary code.
|
| 802 |
+
function parseBlockTarget(info) {
|
| 803 |
+
const path = /(?:^|\s)(?:file|path)=("[^"]+"|\S+)/.exec(info);
|
| 804 |
+
if (!path) return null;
|
| 805 |
+
const repo = /(?:^|\s)repo=("[^"]+"|\S+)/.exec(info);
|
| 806 |
+
const unquote = (value) => value.replace(/^"|"$/g, "");
|
| 807 |
+
return { path: unquote(path[1]), repo: repo ? unquote(repo[1]) : null };
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
function branchNameFor(path) {
|
| 811 |
+
const stem = path.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
| 812 |
+
.slice(0, 40).toLowerCase() || "change";
|
| 813 |
+
return "caracat/" + stem + "-" + Date.now().toString(36).slice(-5);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
// Everything about the change, shown before anything happens. The button is
|
| 817 |
+
// the only thing that reaches GitHub, and it is the person who presses it.
|
| 818 |
+
function showProposal(code, target, container, button) {
|
| 819 |
+
const previous = container.querySelector(".runout");
|
| 820 |
+
if (previous) previous.remove();
|
| 821 |
+
|
| 822 |
+
const repos = connectedRepos();
|
| 823 |
+
const chosen = target.repo
|
| 824 |
+
? repos.find((r) => r.slug === target.repo || r.label === target.repo)
|
| 825 |
+
: (repos.length === 1 ? repos[0] : null);
|
| 826 |
+
|
| 827 |
+
const panel = el("div", "runout");
|
| 828 |
+
container.append(panel);
|
| 829 |
+
|
| 830 |
+
if (!chosen) {
|
| 831 |
+
panel.classList.add("bad");
|
| 832 |
+
panel.append(el("div", "head", target.repo
|
| 833 |
+
? `${target.repo} is not one of the connected repositories.`
|
| 834 |
+
: "Two repositories are connected, so the block has to say which one: "
|
| 835 |
+
+ "add repo=owner/name to its opening line."));
|
| 836 |
+
return;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
const branch = branchNameFor(target.path);
|
| 840 |
+
panel.append(el("div", "head",
|
| 841 |
+
`${chosen.slug} — a new branch ${branch}, then a pull request against ` +
|
| 842 |
+
`${chosen.ref}. Nothing is written to ${chosen.ref} itself.`));
|
| 843 |
+
|
| 844 |
+
const title = el("input");
|
| 845 |
+
title.type = "text";
|
| 846 |
+
title.value = "Update " + target.path;
|
| 847 |
+
title.style.width = "100%";
|
| 848 |
+
panel.append(title);
|
| 849 |
+
|
| 850 |
+
const row = el("div", "codebar");
|
| 851 |
+
row.style.position = "static";
|
| 852 |
+
row.style.marginTop = "8px";
|
| 853 |
+
const go = el("button", "small primary", "Open pull request");
|
| 854 |
+
const cancel = el("button", "small", "Cancel");
|
| 855 |
+
cancel.addEventListener("click", () => panel.remove());
|
| 856 |
+
row.append(go, cancel);
|
| 857 |
+
panel.append(row);
|
| 858 |
+
|
| 859 |
+
go.addEventListener("click", async () => {
|
| 860 |
+
go.disabled = true;
|
| 861 |
+
go.textContent = "Opening…";
|
| 862 |
+
try {
|
| 863 |
+
const url = await backend.openPull(chosen, [{ path: target.path, text: code }], {
|
| 864 |
+
title: title.value || ("Update " + target.path),
|
| 865 |
+
body: "Proposed in Caracat Code and reviewed before opening.\n\n"
|
| 866 |
+
+ "File: " + target.path,
|
| 867 |
+
branch: branch
|
| 868 |
+
});
|
| 869 |
+
panel.textContent = "";
|
| 870 |
+
panel.append(el("div", "head", "Pull request opened:"));
|
| 871 |
+
const link = el("div", null, url);
|
| 872 |
+
panel.append(link);
|
| 873 |
+
button.textContent = "Proposed";
|
| 874 |
+
button.disabled = true;
|
| 875 |
+
} catch (err) {
|
| 876 |
+
panel.classList.add("bad");
|
| 877 |
+
const message = el("div", "head", err.message);
|
| 878 |
+
panel.textContent = "";
|
| 879 |
+
panel.append(message);
|
| 880 |
+
}
|
| 881 |
+
});
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
function showError(threadId, text) {
|
| 885 |
addMessage(threadId, "error", "error").textContent = text;
|
| 886 |
scrollDown();
|
|
|
|
| 984 |
} catch (err) { showError("thread", err.message); }
|
| 985 |
}
|
| 986 |
|
| 987 |
+
// Which repositories are connected right now. Read from the settings field
|
| 988 |
+
// rather than kept in a second place, so what is shown is what is typed.
|
| 989 |
+
function connectedRepos() {
|
| 990 |
+
return parseRepoList($("ghrepos").value);
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
const openRepos = new Set(); // which sections are expanded
|
| 994 |
+
|
| 995 |
+
// Rendering the sidebar waits on the network, so two quick clicks start two
|
| 996 |
+
// runs. Without this, the slower one carries on appending to a panel the
|
| 997 |
+
// faster one has already cleared, and the sidebar ends up showing a state
|
| 998 |
+
// nobody asked for. Each run takes a ticket and stops as soon as it is stale.
|
| 999 |
+
let filesRender = 0;
|
| 1000 |
+
|
| 1001 |
async function refreshFiles() {
|
| 1002 |
+
const ticket = ++filesRender;
|
| 1003 |
+
const current = () => ticket === filesRender;
|
| 1004 |
const panel = $("panel-files");
|
| 1005 |
panel.textContent = "";
|
| 1006 |
|
| 1007 |
+
if (config.can_browse_project) {
|
| 1008 |
+
panel.append(el("div", "row dir", "Project"));
|
| 1009 |
+
let entries = [];
|
| 1010 |
+
try { entries = await backend.listFiles(); }
|
| 1011 |
+
catch (err) { panel.append(el("div", "empty", err.message)); }
|
| 1012 |
+
if (!current()) return;
|
| 1013 |
+
entries.forEach((entry) => {
|
| 1014 |
+
const depth = entry.path.split("/").length - 1;
|
| 1015 |
+
const row = el("div", "row" + (entry.is_dir ? " dir" : ""));
|
| 1016 |
+
row.style.paddingLeft = 7 + depth * 11 + "px";
|
| 1017 |
+
row.append(el("div", "name", entry.path.split("/").pop()));
|
| 1018 |
+
if (!entry.is_dir) {
|
| 1019 |
+
row.append(el("div", "meta", entry.size > 1024
|
| 1020 |
+
? Math.round(entry.size / 1024) + " kB" : entry.size + " B"));
|
| 1021 |
+
row.addEventListener("click", () => attachFile(entry.path));
|
| 1022 |
+
row.title = "Attach " + entry.path;
|
| 1023 |
+
}
|
| 1024 |
+
panel.append(row);
|
| 1025 |
+
});
|
| 1026 |
+
} else if (backend.mode === "static") {
|
| 1027 |
// No server means no directory to walk. The browser's own picker still
|
| 1028 |
// reaches the device's files, which is the part that matters on a tablet.
|
| 1029 |
const pick = el("button", "small", "Choose files…");
|
| 1030 |
pick.addEventListener("click", () => $("filepicker").click());
|
| 1031 |
panel.append(pick);
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
const repos = connectedRepos();
|
| 1035 |
+
if (!repos.length) {
|
| 1036 |
panel.append(el("div", "empty",
|
| 1037 |
+
"No repositories connected. Open Settings and add one as owner/name — " +
|
| 1038 |
+
"public repositories need no token."));
|
|
|
|
| 1039 |
return;
|
| 1040 |
}
|
| 1041 |
|
| 1042 |
+
for (const repo of repos) {
|
| 1043 |
+
const header = el("div", "row dir");
|
| 1044 |
+
const open = openRepos.has(repo.label);
|
| 1045 |
+
header.append(el("div", "name", (open ? "▾ " : "▸ ") + repo.slug));
|
| 1046 |
+
header.append(el("div", "meta", repo.ref));
|
| 1047 |
+
header.style.cursor = "pointer";
|
| 1048 |
+
header.title = "Show the files in " + repo.label;
|
| 1049 |
+
header.addEventListener("click", () => {
|
| 1050 |
+
if (openRepos.has(repo.label)) openRepos.delete(repo.label);
|
| 1051 |
+
else openRepos.add(repo.label);
|
| 1052 |
+
refreshFiles();
|
| 1053 |
+
});
|
| 1054 |
+
panel.append(header);
|
| 1055 |
|
| 1056 |
+
if (!open) continue;
|
| 1057 |
+
|
| 1058 |
+
const loading = el("div", "empty", "loading…");
|
| 1059 |
+
panel.append(loading);
|
| 1060 |
+
let entries = [];
|
| 1061 |
+
try {
|
| 1062 |
+
entries = await backend.repoTree(repo);
|
| 1063 |
+
} catch (err) {
|
| 1064 |
+
if (!current()) return;
|
| 1065 |
+
loading.textContent = err.message;
|
| 1066 |
+
continue;
|
| 1067 |
+
}
|
| 1068 |
+
if (!current()) return;
|
| 1069 |
+
loading.remove();
|
| 1070 |
+
if (!entries.length) {
|
| 1071 |
+
panel.append(el("div", "empty", "nothing attachable in this repository"));
|
| 1072 |
+
continue;
|
| 1073 |
+
}
|
| 1074 |
+
entries.forEach((entry) => {
|
| 1075 |
+
const row = el("div", "row");
|
| 1076 |
+
row.style.paddingLeft = "18px";
|
| 1077 |
+
row.append(el("div", "name", entry.path));
|
| 1078 |
row.append(el("div", "meta", entry.size > 1024
|
| 1079 |
? Math.round(entry.size / 1024) + " kB" : entry.size + " B"));
|
| 1080 |
+
row.title = "Attach " + repo.slug + " ▸ " + entry.path;
|
| 1081 |
+
row.addEventListener("click", () => attachRepoFile(repo, entry.path));
|
| 1082 |
+
panel.append(row);
|
| 1083 |
+
});
|
| 1084 |
+
}
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
// Attachments carry their repository in the name, so a conversation about two
|
| 1088 |
+
// projects never leaves it unclear which file came from where.
|
| 1089 |
+
async function attachRepoFile(repo, path) {
|
| 1090 |
+
const label = repo.slug + " ▸ " + path;
|
| 1091 |
+
if (attached.some((file) => file.path === label)) return;
|
| 1092 |
+
try {
|
| 1093 |
+
const data = await backend.repoFile(repo, path);
|
| 1094 |
+
attached.push({ path: label, text: data.text, repo: repo.label, file: path });
|
| 1095 |
+
renderChips();
|
| 1096 |
+
} catch (err) {
|
| 1097 |
+
showError("thread", err.message);
|
| 1098 |
+
}
|
| 1099 |
}
|
| 1100 |
|
| 1101 |
async function attachFile(path) {
|
|
|
|
| 1443 |
$("apikey").value = "";
|
| 1444 |
showNote("thread", "The key was removed from this browser. Revoke it at your provider as well if it may have been seen.");
|
| 1445 |
});
|
| 1446 |
+
// A "change" event fires when the field loses focus, which is exactly what
|
| 1447 |
+
// tapping a repository in the sidebar does -- and rebuilding the sidebar
|
| 1448 |
+
// right then takes the row out from under the finger, so the first tap does
|
| 1449 |
+
// nothing and the second works. Re-rendering only when the list really
|
| 1450 |
+
// changed keeps that from happening.
|
| 1451 |
+
let lastRepoSignature = null;
|
| 1452 |
+
|
| 1453 |
+
function repoListChanged() {
|
| 1454 |
+
const signature = connectedRepos().map((repo) => repo.label).join("\n");
|
| 1455 |
+
if (signature === lastRepoSignature) return false;
|
| 1456 |
+
lastRepoSignature = signature;
|
| 1457 |
+
return true;
|
| 1458 |
+
}
|
| 1459 |
+
|
| 1460 |
+
$("ghrepos").addEventListener("change", () => {
|
| 1461 |
+
store.set("repos", $("ghrepos").value);
|
| 1462 |
+
if (repoListChanged()) refreshFiles();
|
| 1463 |
+
});
|
| 1464 |
+
$("ghtoken").addEventListener("change", () => {
|
| 1465 |
+
store.set("ghToken", $("ghtoken").value.trim());
|
| 1466 |
+
config.can_change_github = Boolean($("ghtoken").value.trim());
|
| 1467 |
+
});
|
| 1468 |
+
$("forget-ghtoken").addEventListener("click", () => {
|
| 1469 |
+
store.set("ghToken", "");
|
| 1470 |
+
$("ghtoken").value = "";
|
| 1471 |
+
config.can_change_github = false;
|
| 1472 |
+
showNote("thread", "The GitHub token was removed from this browser. " +
|
| 1473 |
+
"Revoke it on GitHub as well if it may have been seen.");
|
| 1474 |
+
});
|
| 1475 |
$("filepicker").addEventListener("change", (event) => {
|
| 1476 |
attachPickedFiles(event.target.files);
|
| 1477 |
event.target.value = ""; // so choosing the same file again still fires
|
| 1478 |
});
|
| 1479 |
|
| 1480 |
$("system").value = store.get("system", "");
|
| 1481 |
+
$("ghrepos").value = store.get("repos", "");
|
| 1482 |
+
lastRepoSignature = null; // set for real once the backend is known
|
| 1483 |
+
$("ghtoken").value = store.get("ghToken", "");
|
| 1484 |
$("temperature").value = store.get("temperature", "0.2");
|
| 1485 |
$("maxtokens").value = store.get("maxtokens", "4096");
|
| 1486 |
if (store.get("sidebar", "shown") === "hidden") $("sidebar").classList.add("hidden");
|
|
|
|
| 1517 |
// a reload -- overwriting it would throw the edit away silently.
|
| 1518 |
if (store.get("system", null) === null) $("system").value = defaultSystemPrompt;
|
| 1519 |
|
| 1520 |
+
if (store.get("repos", null) === null && (config.github_repos || []).length) {
|
| 1521 |
+
$("ghrepos").value = config.github_repos.join("\n");
|
| 1522 |
+
store.set("repos", $("ghrepos").value);
|
| 1523 |
+
}
|
| 1524 |
+
repoListChanged(); // record what is there now, so a blur alone is not a change
|
| 1525 |
+
|
| 1526 |
$("intro-key").textContent = backend.mode === "static"
|
| 1527 |
? "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."
|
| 1528 |
: "Your API key stays in the local server and never reaches this page.";
|