Chinook416 commited on
Commit
61d8daa
·
verified ·
1 Parent(s): 36751fb

Sync from GitHub via hub-sync

Browse files
Files changed (3) hide show
  1. README.md +24 -0
  2. caracat_persona.md +29 -0
  3. 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 // in this browser, not on a server
 
 
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 language = (newline === -1 ? "" : part.slice(0, newline)).trim().toLowerCase();
 
 
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 (!config.can_browse_project) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "Without a server there is no project to browse. Pick files here instead — " +
656
- "they are read in this browser and attached to your next message. " +
657
- "A file that looks like it holds a credential is refused."));
658
  return;
659
  }
660
 
661
- let entries = [];
662
- try { entries = await backend.listFiles(); }
663
- catch (err) { panel.append(el("div", "empty", err.message)); return; }
 
 
 
 
 
 
 
 
 
 
664
 
665
- entries.forEach((entry) => {
666
- const depth = entry.path.split("/").length - 1;
667
- const row = el("div", "row" + (entry.is_dir ? " dir" : ""));
668
- row.style.paddingLeft = 7 + depth * 11 + "px";
669
- row.append(el("div", "name", entry.path.split("/").pop()));
670
- if (!entry.is_dir) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  row.append(el("div", "meta", entry.size > 1024
672
  ? Math.round(entry.size / 1024) + " kB" : entry.size + " B"));
673
- row.addEventListener("click", () => attachFile(entry.path));
674
- row.title = "Attach " + entry.path;
675
- }
676
- panel.append(row);
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 &middot; public only, owner/name, one per line</label>
179
+ <textarea id="ghrepos" spellcheck="false" placeholder="owner/name&#10;other-owner/other-name@branch"></textarea>
180
+ </div>
181
+ <div class="field static-only grow">
182
+ <label for="ghtoken">GitHub token &middot; 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.";