bitpull 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bitpull.cjs +13 -5
- package/package.json +1 -1
package/bitpull.cjs
CHANGED
|
@@ -21807,8 +21807,16 @@ var RemoteControl = class {
|
|
|
21807
21807
|
this.connected = null;
|
|
21808
21808
|
if (!this.stopped && this.settings.enabled) this.timer = setTimeout(() => this.start(), 2e3);
|
|
21809
21809
|
};
|
|
21810
|
+
let answered = true;
|
|
21811
|
+
const keepalive = setInterval(() => {
|
|
21812
|
+
if (!answered) return ws.terminate();
|
|
21813
|
+
answered = false;
|
|
21814
|
+
if (ws.readyState === wrapper_default.OPEN) ws.ping();
|
|
21815
|
+
}, this.o.keepaliveMs ?? 25e3);
|
|
21816
|
+
ws.on("pong", () => answered = true);
|
|
21817
|
+
ws.on("close", () => clearInterval(keepalive));
|
|
21810
21818
|
ws.on("close", again);
|
|
21811
|
-
ws.on("error", () => ws.
|
|
21819
|
+
ws.on("error", () => ws.terminate());
|
|
21812
21820
|
}
|
|
21813
21821
|
stop() {
|
|
21814
21822
|
this.stopped = true;
|
|
@@ -22848,7 +22856,7 @@ async function heartbeat(ctx) {
|
|
|
22848
22856
|
boxKey: ctx.config.boxKeys.publicKey,
|
|
22849
22857
|
...ctx.groups ? { groupStates: ctx.groups.reports() } : {},
|
|
22850
22858
|
// Development runs have no version number.
|
|
22851
|
-
...true ? { agentVersion: "0.1.
|
|
22859
|
+
...true ? { agentVersion: "0.1.27" } : {}
|
|
22852
22860
|
});
|
|
22853
22861
|
}
|
|
22854
22862
|
async function startGroup(ctx, modelId, custom3) {
|
|
@@ -26088,7 +26096,7 @@ async function doctor(cfg) {
|
|
|
26088
26096
|
}
|
|
26089
26097
|
|
|
26090
26098
|
// apps/agent/src/control.ts
|
|
26091
|
-
var APP_VERSION = true ? "0.1.
|
|
26099
|
+
var APP_VERSION = true ? "0.1.27" : "dev";
|
|
26092
26100
|
var UPDATE_MANIFEST = "https://github.com/SamuealPK/bitpull-releases/releases/latest/download/latest.json";
|
|
26093
26101
|
var inDesktopApp = () => /bitpull-agent(\.exe)?$/i.test(process.execPath);
|
|
26094
26102
|
function newerVersion(a, b) {
|
|
@@ -26108,7 +26116,7 @@ async function dashboardStrings(lang) {
|
|
|
26108
26116
|
return (0, import_promises22.readFile)((0, import_node_path20.join)((0, import_node_path20.dirname)((0, import_node_url2.fileURLToPath)(__bitpull_import_meta_url)), "..", "ui", "i18n", `${lang}.json`), "utf8").catch(() => null);
|
|
26109
26117
|
}
|
|
26110
26118
|
async function dashboardHtml() {
|
|
26111
|
-
if (true) return '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>BitPull</title>\n<style>\n :root { --bg:#fafaf7; --surface:#fff; --text:#1d1d1b; --muted:#6b6b66; --line:#e4e2dc; --accent:#2f5bd3; --accent-ink:#fff; --good:#1f7a4d; --warn:#9a6700; --bad:#b3261e; }\n @media (prefers-color-scheme: dark) { :root { --bg:#151514; --surface:#1e1e1c; --text:#ecebe6; --muted:#a2a19b; --line:#33322f; --accent:#7c9cf2; --accent-ink:#10131c; --good:#5cc28f; --warn:#e3b341; --bad:#f2867c; } }\n * { box-sizing: border-box; }\n body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 system-ui,-apple-system,"Segoe UI","Noto Sans",sans-serif; }\n header { min-width:0; display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:16px; border-bottom:1px solid var(--line); background:var(--surface); }\n header h1 { font-size:18px; margin:0; }\n .head-right { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }\n .pills { display:flex; flex-wrap:wrap; gap:8px; }\n .pill { border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:13px; color:var(--muted); }\n .pill.good { color:var(--good); border-color:currentColor; } .pill.warn { color:var(--warn); border-color:currentColor; } .pill.bad { color:var(--bad); border-color:currentColor; }\n main { max-width:1100px; margin:0 auto; padding:16px; display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); }\n section { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:16px; min-width:0; }\n section h2 { font-size:16px; margin:0 0 4px; } section p.hint { margin:0 0 12px; color:var(--muted); font-size:13px; }\n .stat { font-size:28px; font-weight:700; }\n label { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:6px 0 2px; }\n .field { border-bottom:1px solid var(--line); padding-bottom:6px; }\n .field p.help { margin:0; color:var(--muted); font-size:12.5px; line-height:1.45; }\n .check-row { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; padding:10px 0; border-bottom:1px solid var(--line); }\n .check-row .status { font-size:13px; color:var(--muted); }\n .ok { color:var(--good); font-weight:600; } .missing { color:var(--warn); font-weight:600; }\n progress { width:100%; height:8px; }\n input[type=number], input[type=text], select { width:110px; padding:6px 8px; border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--text); font:inherit; }\n select#lang { width:auto; }\n input.wide { width:100%; }\n button { background:var(--accent); color:var(--accent-ink); border:0; border-radius:8px; padding:8px 12px; font:inherit; font-weight:600; cursor:pointer; }\n button.secondary { background:transparent; color:var(--text); border:1px solid var(--line); }\n button:disabled { opacity:.6; cursor:progress; }\n button.danger { background:var(--bad); color:#fff; }\n .row { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }\n ul.list { list-style:none; padding:0; margin:8px 0 0; }\n ul.list li { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 0; border-bottom:1px solid var(--line); font-size:14px; }\n ul.list li span.path { overflow-wrap:anywhere; }\n #ai-limits { overflow-x:auto; } table.limits { border-collapse:collapse; } table.limits td, table.limits th { padding:4px 8px; text-align:start; }\n code { font:13px ui-monospace,Menlo,monospace; overflow-wrap:anywhere; direction:ltr; unicode-bidi:embed; }\n textarea { width:100%; min-height:70px; padding:8px; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--text); font:inherit; }\n pre { white-space:pre-wrap; background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px; margin:8px 0 0; max-height:260px; overflow:auto; font-size:13px; }\n .msg { min-height:1.4em; font-size:13px; margin-top:6px; } .msg.error { color:var(--bad); } .msg.ok { color:var(--good); }\n .full { grid-column:1/-1; }\n .live { display:flex; flex-wrap:wrap; gap:4px 14px; align-items:center; padding:8px 16px; font-size:13px; color:var(--muted); border-bottom:1px solid var(--line); background:var(--surface); }\n .live strong { color:var(--text); } .live .dot { width:8px; height:8px; border-radius:50%; background:var(--good); }\n .plans { display:grid; gap:10px; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); margin-top:10px; }\n .plan { border:1px solid var(--line); border-radius:10px; padding:10px; font-size:13px; display:flex; flex-direction:column; }\n .plan.current { border-color:var(--accent); }\n .plan h3 { font-size:14px; margin:0; } .plan .price { font-size:18px; font-weight:700; margin:4px 0; }\n .plan ul { padding-inline-start:16px; margin:4px 0 8px; color:var(--muted); flex:1; } .plan button { width:100%; }\n .badge { font-size:11px; padding:1px 7px; border-radius:99px; background:var(--accent); color:var(--accent-ink); margin-inline-start:6px; }\n .summary { border:1px solid var(--accent); border-radius:10px; padding:10px 12px; margin-top:10px; font-size:13px; }\n .summary h3 { margin:2px 0; font-size:15px; } .summary ul { padding-inline-start:16px; margin:4px 0; }\n .account-row { display:flex; flex-wrap:wrap; justify-content:space-between; gap:8px; align-items:center; margin-top:12px; padding-top:10px; border-top:1px solid var(--line); font-size:13px; color:var(--muted); }\n #locked { max-width:520px; margin:80px auto; padding:0 16px; text-align:center; }\n .views { display:flex; gap:4px; } .views button { background:transparent; color:var(--muted); padding:5px 12px; } .views button.on { background:var(--bg); color:var(--text); border:1px solid var(--line); }\n #chat-view { display:grid; grid-template-columns:270px minmax(0,1fr); height:calc(100vh - var(--head,67px)); }\n #chat-side { border-inline-end:1px solid var(--line); background:var(--surface); display:flex; flex-direction:column; gap:8px; padding:12px; min-height:0; }\n #chat-side button.new { width:100%; text-align:start; }\n #chat-search { width:100%; }\n #chat-list { list-style:none; margin:0; padding:0; overflow:auto; flex:1; min-height:0; }\n #chat-list li { display:flex; align-items:center; gap:4px; border-radius:8px; }\n #chat-list li.on, #chat-list li:hover { background:var(--bg); }\n #chat-list button.open { flex:1; min-width:0; background:transparent; color:var(--text); font-weight:400; text-align:start; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:7px 8px; }\n #chat-list button.del { background:transparent; color:var(--muted); padding:2px 8px; visibility:hidden; } #chat-list li:hover button.del, #chat-list li.on button.del, #chat-list button.del:focus { visibility:visible; }\n #chat-main { display:flex; flex-direction:column; min-width:0; min-height:0; }\n #chat-top { display:none; padding:8px 12px; border-bottom:1px solid var(--line); }\n #chat-scroll { flex:1; overflow:auto; min-height:0; padding:16px; }\n #chat-log { max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:18px; }\n #chat-hello { max-width:760px; margin:18vh auto 0; text-align:center; } #chat-hello h2 { font-size:28px; font-weight:600; margin:0 0 6px; } #chat-hello p { color:var(--muted); margin:0; }\n .m-user { align-self:flex-end; max-width:85%; background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:8px 14px; white-space:pre-wrap; overflow-wrap:anywhere; }\n .m-ai { overflow-wrap:anywhere; line-height:1.6; } .m-ai.error { color:var(--bad); }\n .m-ai h3,.m-ai h4 { margin:12px 0 4px; } .m-ai p { margin:8px 0; } .m-ai ul,.m-ai ol { margin:8px 0; padding-inline-start:22px; }\n .m-ai img { max-width:100%; border-radius:10px; display:block; margin-top:8px; } .m-ai audio { width:100%; margin-top:8px; } .m-user img { max-width:220px; border-radius:8px; display:block; }\n .m-ai pre { max-height:none; position:relative; direction:ltr; } .m-ai details { color:var(--muted); font-size:13px; margin:6px 0; }\n .m-foot { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:6px; font-size:12.5px; color:var(--muted); } .m-foot button { background:transparent; color:var(--muted); border:1px solid var(--line); padding:2px 9px; font-size:12.5px; font-weight:500; }\n #chat-box { max-width:760px; width:100%; margin:0 auto 14px; padding:0 16px; position:relative; }\n #chat-composer { border:1px solid var(--line); border-radius:18px; background:var(--surface); padding:10px 12px; }\n #chat-input { border:0; background:transparent; min-height:44px; max-height:220px; resize:none; padding:4px; outline:none; display:block; }\n #chat-tools { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } #chat-tools .grow { flex:1; }\n #chat-tools button.icon { width:34px; height:34px; padding:0; border-radius:10px; font-size:18px; line-height:1; }\n select#chat-model { width:auto; max-width:230px; border:0; background:transparent; }\n #chat-menu { position:absolute; bottom:calc(100% + 6px); inset-inline-start:16px; width:min(340px,calc(100% - 32px)); background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:10px 12px; box-shadow:0 8px 28px rgba(0,0,0,.25); z-index:5; }\n #chat-menu label { font-size:14px; } #chat-menu textarea { min-height:64px; font-size:13px; } #chat-menu input[type=range] { width:120px; }\n #chat-menu button.line { width:100%; text-align:start; background:transparent; color:var(--text); font-weight:500; padding:6px 0; }\n #chat-attached { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }\n .att { display:inline-flex; align-items:center; gap:6px; max-width:260px; font-size:12.5px; border:1px solid var(--line); border-radius:8px; padding:3px 6px; color:var(--muted); }\n .att img { width:32px; height:32px; object-fit:cover; border-radius:5px; } .att-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .att button { padding:0 7px; font-size:13px; }\n #chat-view.dropping #chat-composer { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }\n #chat-where { text-align:center; color:var(--muted); font-size:12.5px; min-height:1.3em; }\n #chat-hint { text-align:center; color:var(--muted); font-size:12.5px; min-height:1.3em; margin-top:6px; }\n @media (max-width:720px) { #chat-view { grid-template-columns:minmax(0,1fr); } #chat-side { display:none; } #chat-view.side #chat-side { display:flex; } #chat-view.side #chat-main { display:none; } #chat-top { display:block; } }\n [hidden] { display:none !important; }\n #tip { position:fixed; z-index:50; max-width:320px; padding:8px 10px; border-radius:8px; background:var(--text); color:var(--bg); font-size:13px; line-height:1.45; box-shadow:0 4px 16px rgba(0,0,0,.18); pointer-events:none; }\n</style>\n</head>\n<body>\n<div id="locked" hidden>\n <h1>BitPull</h1>\n <p data-i18n="locked.text"></p>\n</div>\n<div id="loggedout" hidden>\n <h1>BitPull</h1>\n <p data-i18n="logout.done"></p>\n <p><code>bitpull login --device</code></p>\n</div>\n<div id="app" hidden>\n<header>\n <h1 data-i18n="app.title"></h1>\n <nav class="views"><button id="view-chat" data-i18n="view.chat" data-tip="tip.view.chat"></button><button id="view-projects" data-i18n="view.projects" data-tip="tip.view.projects"></button><button id="view-panel" data-i18n="view.panel" data-tip="tip.view.panel"></button></nav>\n <div class="head-right">\n <div class="pills" id="pills"></div>\n <select id="lang" aria-label="Language" data-tip="tip.lang"></select>\n </div>\n</header>\n<div class="live" id="live" hidden></div>\n<div id="chat-view" hidden>\n <aside id="chat-side">\n <button class="new" id="chat-new" data-i18n="chat.new" data-tip="tip.chat.new"></button>\n <input type="text" id="chat-search" data-i18n-placeholder="chat.search">\n <ul id="chat-list"></ul>\n </aside>\n <div id="chat-main">\n <div id="chat-top"><button class="secondary" id="chat-side-toggle" data-i18n="chat.history" data-tip="tip.chat.history"></button></div>\n <div id="chat-scroll">\n <div id="chat-hello"><h2 data-i18n="chat.hello"></h2><p id="chat-hello-hint"></p></div>\n <div id="chat-log"></div>\n </div>\n <div id="chat-box">\n <div id="chat-menu" hidden>\n <button class="line" id="chat-attach" data-i18n="chat.attach" data-tip="tip.chat.attach"></button>\n <input type="file" id="chat-file" multiple hidden>\n <label data-tip="tip.ask.mine"><span data-i18n="ask.mine"></span> <input type="checkbox" id="chat-mine"></label>\n <label data-tip="tip.ai.control"><span data-i18n="ai.control"></span> <input type="checkbox" id="chat-control" checked></label>\n <label data-tip="tip.ai.auto"><span data-i18n="ai.auto"></span> <input type="checkbox" id="chat-auto"></label>\n <label data-tip="tip.chat.temp"><span data-i18n="chat.temp"></span> <input type="range" id="chat-temp" min="0" max="1.5" step="0.1" value="0.7"></label>\n <label for="chat-system" data-tip="tip.chat.system"><span data-i18n="chat.system"></span></label>\n <textarea id="chat-system" data-i18n-placeholder="chat.system.placeholder"></textarea>\n </div>\n <div id="chat-composer">\n <div id="chat-attached" hidden></div>\n <textarea id="chat-input" rows="1" data-i18n-placeholder="chat.placeholder"></textarea>\n <div id="chat-tools">\n <button class="secondary icon" id="chat-more" aria-label="+" data-tip="tip.chat.more">+</button>\n <span class="grow"></span>\n <select id="chat-model" data-tip="tip.chat.model"></select>\n <button id="chat-send" data-i18n="chat.send" data-tip="tip.chat.send"></button>\n <button class="secondary" id="chat-stop" data-i18n="chat.stop" data-tip="tip.chat.stop" hidden></button>\n </div>\n </div>\n <div id="chat-hint"></div>\n <div class="row" style="justify-content:center;margin-top:4px"><button class="secondary" id="chat-use" data-tip="tip.chat.use" hidden></button></div>\n <div id="chat-where"></div>\n </div>\n </div>\n</div>\n<main>\n <section>\n <h2 data-i18n="credits.title"></h2>\n <div class="stat" id="balance">\u2013</div>\n <p class="hint" id="plan"></p>\n <div class="plans" id="plan-cards"></div>\n <div class="summary" id="buy-summary" hidden></div>\n <div class="msg" id="buy-msg"></div>\n <div class="account-row">\n <span id="account-line"></span>\n <button class="secondary" id="logout" data-i18n="logout.button" data-tip="tip.logout"></button>\n </div>\n <div class="summary" id="logout-confirm" hidden>\n <p id="logout-text"></p>\n <div class="row">\n <button class="danger" id="logout-yes" data-i18n="logout.yes" data-tip="tip.logout.yes"></button>\n <button class="secondary" id="logout-no" data-i18n="plans.cancel" data-tip="tip.logout.no"></button>\n </div>\n </div>\n </section>\n\n <section>\n <h2 data-i18n="lend.title"></h2>\n <p class="hint" data-i18n="lend.hint"></p>\n <div class="stat" id="worker-state">\u2013</div>\n <p class="hint" id="worker-detail"></p>\n <p class="status" id="worker-visible"></p>\n <div class="row"><button id="pause" class="danger"></button></div>\n <div class="msg" id="pause-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="mynet.title"></h2>\n <p class="hint" data-i18n="mynet.hint"></p>\n <ul class="list" id="mynet"></ul>\n <div class="msg" id="mynet-msg"></div>\n <h3 data-i18n="mygroup.title"></h3>\n <p class="hint" data-i18n="mygroup.hint"></p>\n <p class="hint" id="group-fit"></p>\n <ul class="list" id="group-plan"></ul>\n <div class="row" id="group-start"><select id="group-model" style="width:auto;max-width:100%" data-tip="tip.mygroup.model"></select><button id="group-go" data-i18n="mygroup.start" data-tip="tip.mygroup.start"></button></div>\n <details id="group-custom"><summary data-i18n="mygroup.custom"></summary>\n <p class="hint" data-i18n="mygroup.custom.hint"></p>\n <div class="row"><input class="wide" type="text" id="hub-query" data-i18n-placeholder="mygroup.custom.search" style="flex:1"><button class="secondary" id="hub-find" data-i18n="mygroup.custom.find" data-tip="tip.mygroup.custom.find"></button></div>\n <div class="row" id="hub-pick" hidden><select id="hub-repo" style="width:auto;max-width:100%" data-tip="tip.mygroup.custom.repo"></select><select id="hub-file" style="width:auto;max-width:100%" data-tip="tip.mygroup.custom.file"></select><button id="hub-use" data-i18n="mygroup.start" data-tip="tip.mygroup.custom.use"></button></div>\n </details>\n <ul class="list" id="group-status"></ul>\n <p class="hint" id="group-traffic" hidden></p>\n <p class="hint" id="group-speed" hidden></p>\n <h3 data-i18n="mygroup.folders"></h3>\n <p class="hint" data-i18n="mygroup.folders.hint"></p>\n <div class="row"><input class="wide" type="text" id="model-folder" data-i18n-placeholder="folders.placeholder" style="flex:1"><button class="secondary" id="model-folder-add" data-i18n="mygroup.folders.add" data-tip="tip.mygroup.folders.add"></button></div>\n <ul class="list" id="model-folders"></ul>\n <div id="group-ask" hidden>\n <div class="row"><button id="group-send" data-i18n="chat.open" data-tip="tip.chat.group"></button><button class="secondary" id="group-stop" data-i18n="mygroup.stop" data-tip="tip.mygroup.stop"></button></div>\n <label data-tip="tip.mygroup.share"><span data-i18n="mygroup.share"></span> <input type="checkbox" id="group-share"></label>\n <p class="hint" data-i18n="mygroup.share.hint"></p>\n <div class="summary" id="group-share-confirm" hidden>\n <p data-i18n="mygroup.share.confirm"></p>\n <div class="row"><button id="group-share-yes" data-i18n="mygroup.share.yes" data-tip="tip.mygroup.share.yes"></button><button class="secondary" id="group-share-no" data-i18n="plans.cancel" data-tip="tip.mygroup.share.no"></button></div>\n </div>\n </div>\n <div class="msg" id="group-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="setup.title"></h2>\n <p class="hint" data-i18n="setup.hint"></p>\n <div id="setup"><p class="hint" data-i18n="setup.checking"></p></div>\n <div class="msg" id="setup-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="models.title"></h2>\n <p class="hint" data-i18n="models.hint"></p>\n <div id="ai-models"><p class="hint" data-i18n="setup.checking"></p></div>\n <h3 data-i18n="limits.title"></h3>\n <p class="hint" data-i18n="limits.hint"></p>\n <div id="ai-limits"></div>\n <div class="row"><button id="save-limits" data-i18n="limits.save" data-tip="tip.limits.save"></button></div>\n <div class="msg" id="models-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="rules.title"></h2>\n <p class="help" id="rules-machine"></p>\n <p class="help"><strong data-i18n="sec.del.title"></strong> <span data-i18n="sec.del.lender"></span></p>\n <form id="rules">\n <div class="field"><label><span data-i18n="rules.enabled"></span> <input type="checkbox" name="enabled"></label><p class="help" data-i18n="rules.enabled.help"></p></div>\n <div class="field"><label><span data-i18n="rules.cpus"></span> <input type="number" name="maxCpus" min="0.25" step="0.25"></label><p class="help" data-i18n="rules.cpus.help"></p><p class="help" id="rules-cpus-max"></p></div>\n <div class="field"><label><span data-i18n="rules.memory"></span> <input type="number" name="maxMemoryMb" min="256" step="256"></label><p class="help" data-i18n="rules.memory.help"></p><p class="help" id="rules-memory-max"></p></div>\n <div class="field"><label><span data-i18n="rules.disk"></span> <input type="number" name="maxDiskGb" min="1" step="1"></label><p class="help" data-i18n="rules.disk.help"></p><p class="help" id="rules-disk-max"></p></div>\n <div class="field"><label><span data-i18n="rules.gpu"></span> <select name="gpu"><option value="none" data-i18n="rules.gpu.none"></option><option value="consumer" data-i18n="rules.gpu.consumer"></option><option value="high" data-i18n="rules.gpu.high"></option><option value="datacenter" data-i18n="rules.gpu.datacenter"></option></select></label><p class="help" data-i18n="rules.gpu.help"></p><p class="help" id="rules-gpu-found"></p></div>\n <div class="field"><label><span data-i18n="rules.containers"></span> <input type="checkbox" name="containers"></label><p class="help" data-i18n="rules.containers.help"></p></div>\n <div class="field"><label><span data-i18n="rules.models"></span> <input type="checkbox" name="serveModels"></label><p class="help" data-i18n="rules.models.help"></p></div>\n <div class="field"><label><span data-i18n="rules.idle"></span> <input type="number" name="idleMinutes" min="0"></label><p class="help" data-i18n="rules.idle.help"></p></div>\n <div class="field"><label><span data-i18n="rules.ac"></span> <input type="checkbox" name="onlyOnAc"></label><p class="help" data-i18n="rules.ac.help"></p></div>\n <div class="field"><label><span data-i18n="rules.awake"></span> <input type="checkbox" name="keepAwake"></label><p class="help" data-i18n="rules.awake.help"></p></div>\n <div class="field"><label><span data-i18n="rules.hours"></span> <span><input type="number" name="hourFrom" min="0" max="23" style="width:56px"> <input type="number" name="hourTo" min="0" max="24" style="width:56px"></span></label><p class="help" data-i18n="rules.hours.help"></p></div>\n <div class="field"><label><span data-i18n="rules.cache"></span> <input type="number" name="cacheMax" min="0"></label><p class="help" data-i18n="rules.cache.help"></p></div>\n <div class="row"><button type="submit" data-i18n="rules.save" data-tip="tip.rules.save"></button></div>\n <div class="msg" id="rules-msg"></div>\n </form>\n </section>\n\n <section>\n <h2 data-i18n="folders.title"></h2>\n <p class="hint" data-i18n="folders.hint"></p>\n <div class="row"><input class="wide" type="text" id="folder-path" data-i18n-placeholder="folders.placeholder"><button id="folder-add" data-i18n="folders.add" data-tip="tip.folder.add"></button></div>\n <ul class="list" id="folders"></ul>\n <div class="msg" id="folder-msg"></div>\n </section>\n\n <section id="remote-section">\n <h2 data-i18n="remote.title"></h2>\n <p class="hint" data-i18n="remote.help"></p>\n <label data-tip="tip.remote.enabled"><span data-i18n="remote.enabled"></span> <input type="checkbox" id="remote-enabled"></label>\n <div class="row" style="margin-top:8px"><button id="remote-add" data-i18n="remote.add" data-tip="tip.remote.add"></button></div>\n <div id="remote-code" hidden><div id="remote-qr" style="max-width:220px;background:#fff;padding:6px;border-radius:8px;margin:8px 0"></div><p class="hint" id="remote-scan"></p><code id="remote-link" style="word-break:break-all"></code></div>\n <ul class="list" id="remote-phones"></ul>\n <div class="msg" id="remote-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="repos.title"></h2>\n <p class="hint" data-i18n="repos.hint"></p>\n <ul class="list" id="repos"></ul>\n <div class="msg" id="repos-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="ask.title"></h2>\n <p class="hint" id="models-hint"></p>\n <div class="row">\n <button id="ask" data-i18n="chat.open" data-tip="tip.chat.go"></button>\n <label data-tip="tip.wait"><span data-i18n="ask.wait"></span> <input type="number" id="wait-minutes" min="1" max="120" step="1" style="width:70px"></label>\n <button class="secondary" id="save-wait" data-i18n="ask.wait.save" data-tip="tip.wait"></button>\n </div>\n <div class="msg" id="wait-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="tools.title"></h2>\n <p class="hint" data-i18n="tools.hint"></p>\n <div id="tools"></div>\n <div class="msg" id="tools-msg"></div>\n <p class="hint" style="margin-top:12px"><span data-i18n="tools.openai"></span> <code id="oai-url"></code> \xB7 <span data-i18n="tools.key"></span> <code id="oai-key">\u2022\u2022\u2022\u2022\u2022\u2022</code> <button class="secondary" id="show-key" data-i18n="tools.show" data-tip="tip.show_key"></button></p>\n </section>\n\n <section class="full">\n <h2 data-i18n="update.title"></h2>\n <p class="hint" id="update-info"></p>\n <div class="row"><button id="update-btn" data-i18n="update.button" data-tip="tip.update"></button></div>\n <div class="msg" id="update-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="ai.log.title"></h2>\n <p class="hint" data-i18n="ai.log.hint"></p>\n <ul class="list" id="ai-log"></ul>\n <div class="msg" id="ai-log-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="jobs.title"></h2>\n <p class="help"><strong data-i18n="sec.del.title"></strong> <span data-i18n="sec.del.text"></span></p>\n <p class="help" id="file-limits" hidden></p>\n <ul class="list" id="jobs"></ul>\n </section>\n</main>\n</div>\n<script>\nconst LANGS = { en: "English", zh: "\u4E2D\u6587", hi: "\u0939\u093F\u0928\u094D\u0926\u0940", es: "Espa\xF1ol", ar: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", fr: "Fran\xE7ais", bn: "\u09AC\u09BE\u0982\u09B2\u09BE", pt: "Portugu\xEAs", ru: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439", id: "Bahasa Indonesia", de: "Deutsch", it: "Italiano" };\nconst $ = (id) => document.getElementById(id);\nconst esc = (s) => String(s ?? "").replace(/[&<>"\']/g, (c) => ({ "&":"&","<":"<",">":">",\'"\':""","\'":"'" })[c]);\n\nconst params = new URLSearchParams(location.hash.slice(1));\nlet token = params.get("token");\nconst langFromApp = params.get("lang");\n// The app\'s own login page, to go back to after logging out (only the app\'s own addresses).\nlet backTo = params.get("back");\ntry { if (token) sessionStorage.setItem("bp_token", token); else token = sessionStorage.getItem("bp_token"); } catch {}\ntry { if (backTo) sessionStorage.setItem("bp_back", backTo); else backTo = sessionStorage.getItem("bp_back"); } catch {}\nif (!/^(tauri:\\/\\/localhost|https?:\\/\\/tauri\\.localhost)$/.test(backTo || "")) backTo = null;\nif (location.hash) history.replaceState(null, "", location.pathname);\n\n// ---- languages ------------------------------------------------------------------------\nlet lang = "en", dict = {}, fallback = {};\nconst t = (key, vars = {}) => String(dict[key] ?? fallback[key] ?? key).replace(/\\{(\\w+)\\}/g, (_, k) => (vars[k] ?? `{${k}}`));\nconst fmt = (n) => Number(n).toLocaleString(lang, { maximumFractionDigits: 2 });\n\nasync function loadLang(l, save) {\n lang = LANGS[l] ? l : "en";\n if (!Object.keys(fallback).length) fallback = await (await fetch("/i18n/en.json")).json();\n dict = lang === "en" ? fallback : await fetch(`/i18n/${lang}.json`).then((r) => (r.ok ? r.json() : fallback)).catch(() => fallback);\n document.documentElement.lang = lang;\n document.documentElement.dir = lang === "ar" ? "rtl" : "ltr";\n document.querySelectorAll("[data-i18n]").forEach((e) => (e.textContent = t(e.dataset.i18n)));\n document.querySelectorAll("[data-i18n-placeholder]").forEach((e) => (e.placeholder = t(e.dataset.i18nPlaceholder)));\n $("lang").value = lang;\n if (save) api("/api/language", { language: lang }).catch(() => {});\n if (state) render();\n refreshSetup();\n if (update) refreshUpdate();\n if (tools.length) refreshTools();\n}\n$("lang").innerHTML = Object.entries(LANGS).map(([k, v]) => `<option value="${k}">${v}</option>`).join("");\n$("lang").addEventListener("change", (e) => loadLang(e.target.value, true));\n\nasync function api(path, body) {\n const res = await fetch(path, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-bitpull-token": token || "" }, body: body ? JSON.stringify(body) : undefined });\n const data = await res.json().catch(() => ({}));\n if (!res.ok) throw new Error(data.error || res.status);\n return data;\n}\n\n// ---- status -----------------------------------------------------------------------------\nlet state = null;\n/** The user changed the lending rules and has not saved them yet. */\nlet rulesDirty = false;\nconst fmtBytes = (b) => (b >= 1e9 ? `${fmt(Math.round((b / 1e9) * 10) / 10)} GB` : `${fmt(Math.max(1, Math.round(b / 1e6)))} MB`);\n\n/** What this computer really has, next to the rules: nobody lends more than exists. */\nfunction renderMachine(m) {\n if (!m) return;\n const gb = (mb) => fmt(Math.round((mb / 1024) * 10) / 10);\n const gpuText = !m.gpuKnown ? t("rules.machine.gpu_unknown") : m.gpus.length ? m.gpus.map((g) => g.name + (g.memoryMb ? ` ${gb(g.memoryMb)} GB` : "") + (g.cores ? ` \xB7 ${t("rules.machine.cores", { n: g.cores })}` : "")).join(", ") : t("rules.machine.gpu_none");\n const disk = m.freeDiskBytes == null ? "?" : fmt(Math.floor(m.freeDiskBytes / 1024 ** 3));\n $("rules-machine").textContent = t("rules.machine", { cpus: m.cpus, cpu: m.cpuModel || "CPU", ram: gb(m.memoryMb), gpu: gpuText, disk });\n const f = $("rules").elements;\n f.maxCpus.max = m.cpus;\n f.maxMemoryMb.max = m.lendableMemoryMb;\n $("rules-cpus-max").textContent = t("rules.cpus.max", { n: m.cpus });\n $("rules-memory-max").textContent = t("rules.memory.max", { n: fmt(m.lendableMemoryMb) });\n const room = m.freeDiskBytes == null ? null : Math.max(0, Math.floor(m.freeDiskBytes / 1024 ** 3) - 5);\n if (room !== null) f.maxDiskGb.max = Math.max(1, room);\n $("rules-disk-max").textContent = room === null ? "" : t("rules.disk.max", { n: fmt(room) });\n $("rules-gpu-found").textContent = m.gpuKnown ? t("rules.gpu.found", { cls: t(`rules.gpu.${m.gpuClass}`) }) : "";\n}\n\n// ---- the person\'s phones as screen and keyboard of this computer ----------------------------------\n// Shown on a phone too, but changed only here: the computer refuses these settings from a phone.\nconst onPhone = token === "phone";\nif (onPhone) $("remote-section").hidden = true;\nfunction renderRemote(r) {\n $("remote-enabled").checked = !!r.enabled;\n $("remote-add").disabled = !r.enabled;\n $("remote-phones").innerHTML = r.phones.length\n ? r.phones.map((p) => `<li><span>${esc(p.name)} <span class="hint">\xB7 ${esc(t("remote.added", { date: new Date(p.added).toLocaleDateString(lang) }))}${r.connected === p.id ? ` \xB7 ${esc(t("remote.connected"))}` : ""}</span></span><button class="secondary" data-unphone="${esc(p.id)}" data-tip="tip.remote.remove">${esc(t("remote.remove"))}</button></li>`).join("")\n : `<li class="hint">${esc(t("remote.none"))}</li>`;\n}\nasync function refreshRemote() {\n if (onPhone) return;\n try { renderRemote(await api("/api/remote")); } catch {}\n}\n$("remote-enabled").addEventListener("change", async (e) => {\n try { renderRemote(await api("/api/remote/settings", { enabled: e.target.checked })); if (!e.target.checked) $("remote-code").hidden = true; }\n catch (err) { e.target.checked = !e.target.checked; $("remote-msg").className = "msg error"; $("remote-msg").textContent = err.message; }\n});\n$("remote-add").addEventListener("click", async () => {\n try {\n const r = await api("/api/remote/pair", { name: "" });\n $("remote-qr").innerHTML = r.svg;\n $("remote-scan").textContent = t("remote.scan", { minutes: r.minutes });\n $("remote-link").textContent = r.link;\n $("remote-code").hidden = false;\n } catch (err) { $("remote-msg").className = "msg error"; $("remote-msg").textContent = err.message; }\n});\n$("remote-phones").addEventListener("click", async (e) => {\n const id = e.target.dataset && e.target.dataset.unphone;\n if (!id) return;\n try { renderRemote(await api("/api/remote/remove", { id })); } catch {}\n});\n\nasync function refresh() {\n try { state = await api("/api/status"); } catch (e) { if (String(e.message) === "unauthorized") { $("app").hidden = true; $("locked").hidden = false; } return; }\n $("app").hidden = false; $("locked").hidden = true;\n render();\n}\n\nfunction render() {\n const s = state, n = s.node;\n const pills = [\n `<span class="pill ${n.online ? "good" : "bad"}">${esc(t(n.online ? "pill.online" : "pill.offline"))}</span>`,\n `<span class="pill ${n.relay === "disconnected" ? "warn" : "good"}">${esc(t(n.relay === "direct" ? "pill.direct" : n.relay === "connected" ? "pill.relay_on" : "pill.relay_off"))}</span>`,\n `<span class="pill">${esc(t("pill.cached", { n: n.cached }))}</span>`,\n ];\n if (s.me) pills.unshift(`<span class="pill">${esc(s.me.email)}</span>`);\n $("pills").innerHTML = pills.join("");\n const net = s.network;\n if (net) {\n $("live").hidden = false;\n $("live").innerHTML = `<span class="dot"></span><strong>${esc(t("net.title"))}</strong>` +\n [["net.online", net.computersOnline], ["net.lending", net.computersLending], ["net.cpu", net.cpus], ["net.ram", net.memoryGb], ["net.gpu", net.gpus.total]]\n .map(([k, v]) => `<span>${esc(t(k, { n: fmt(v) }))}</span>`).join("") +\n `<span title="${esc(net.models.join(", "))}">${esc(t("net.models", { n: fmt(net.models.length) }))}</span><span>${esc(t("net.jobs", { n: fmt(net.jobsRunning) }))}</span>`;\n }\n if (s.me) {\n $("balance").textContent = fmt(s.me.balance);\n $("plan").textContent = t("credits.plan.name", { plan: t(`plan.${s.me.plan}`) }) + (s.me.heldCredits > 0 ? ` \xB7 ${t("credits.held", { n: fmt(s.me.heldCredits) })}` : "");\n const via = (s.me.logins || []).map((m) => (m === "password" ? t("account.via.email") : m === "github" ? "GitHub" : "Google")).join(", ");\n $("account-line").textContent = t("account.line", { email: s.me.email }) + (via ? ` \xB7 ${via}` : "");\n renderPlans(s.me.plan);\n }\n const w = n.worker;\n $("worker-state").textContent = t(`lend.state.${w.state}`);\n $("worker-detail").textContent = w.currentJob\n ? t("lend.running", { kind: t(w.currentJob.kind === "inference" ? "jobs.model" : "jobs.container"), time: new Date(w.currentJob.since).toLocaleTimeString(lang) })\n : t("lend.detail", { reason: w.reason && !w.reason.startsWith("error") ? t(`lend.reason.${w.reason}`) : (w.reason || ""), n: w.completed, cpus: s.capacity.cpus, ram: fmt(s.capacity.memoryMb / 1024) }) +\n (s.capacity.containers ? t("lend.detail.sandbox", { name: w.sandbox || "" }) : "") + (s.capacity.models.length ? t("lend.detail.models", { n: s.capacity.models.length }) : "");\n // What the network actually sees of this computer, and why nothing can run when that is the case.\n const seen = s.me && (s.me.nodes || []).find((x) => x.id === s.nodeId);\n const usable = s.capacity.containers || s.capacity.models.length > 0;\n const tried = (s.sandbox && s.sandbox.tried || []).join(" \xB7 ");\n // Known problems in the person\'s language; anything else as reported by the system.\n const sbProblem = /Docker Hub login is no longer valid/.test(tried) ? t("lend.docker_login") : tried;\n $("worker-visible").className = "status " + (seen && seen.online && seen.capacity && usable ? "ok" : "missing");\n // Lending switched off: say so plainly, with what to do; Pause/Resume does not turn it on.\n $("pause").hidden = !s.lending.enabled;\n if (!s.lending.enabled) {\n $("worker-visible").className = "status missing";\n $("worker-visible").textContent = t("lend.off_hint", { enabled: t("rules.enabled"), rules: t("rules.title"), save: t("rules.save") });\n } else if (s.lendingNow.ok && s.capacity.memoryMb < 1024 && usable) {\n $("worker-visible").className = "status missing";\n $("worker-visible").textContent = t("lend.low_memory", { mb: s.capacity.memoryMb, rules: t("rules.title"), memory: t("rules.memory"), save: t("rules.save") });\n } else $("worker-visible").textContent = s.lending.paused || !s.lendingNow.ok ? ""\n : !usable ? t("lend.unusable", { reason: s.lending.containers ? (sbProblem || t("lend.unusable.docker")) : t("lend.unusable.off") })\n : seen && seen.online && seen.capacity ? t("lend.visible", { cpus: seen.capacity.cpus, ram: fmt(seen.capacity.memoryMb / 1024) })\n : t("lend.invisible");\n $("pause").textContent = t(s.lending.paused ? "lend.resume" : "lend.pause");\n $("pause").dataset.tip = s.lending.paused ? "tip.resume" : "tip.pause";\n $("pause").className = s.lending.paused ? "" : "danger";\n\n // Never overwrite changes the user has not saved yet (on macOS a clicked checkbox does not take focus).\n if (!rulesDirty && (!document.activeElement || !$("rules").contains(document.activeElement))) {\n const f = $("rules").elements, l = s.lending;\n for (const k of ["enabled", "containers", "serveModels", "onlyOnAc"]) f[k].checked = !!l[k];\n f.keepAwake.checked = l.keepAwake !== false;\n for (const k of ["maxCpus", "maxMemoryMb", "maxDiskGb", "idleMinutes"]) f[k].value = l[k] ?? "";\n f.gpu.value = l.gpu; f.cacheMax.value = s.cacheMax;\n f.hourFrom.value = l.hours ? l.hours[0] : ""; f.hourTo.value = l.hours ? l.hours[1] : "";\n }\n renderMachine(s.machine);\n renderMyNetwork(s);\n renderGroups(s).catch(() => {});\n $("folders").innerHTML = s.folders.map((p) => `<li><span class="path">${esc(p)}</span><span><button class="secondary" data-draft="${esc(p)}" data-tip="tip.folder.draft">${esc(t("folders.draft"))}</button> <button data-publish="${esc(p)}" data-tip="tip.folder.publish">${esc(t("folders.publish"))}</button> <button class="secondary" data-unfolder="${esc(p)}" data-tip="tip.folder.remove">${esc(t("folders.remove"))}</button></span></li>`).join("") || `<li class="hint">${esc(t("folders.none"))}</li>`;\n $("repos").innerHTML = s.repos.map((r) => `<li><span><strong>${esc(r.name)}</strong><br><span class="path hint">${esc(r.path)}</span></span><button class="secondary" data-unpublish="${esc(r.id)}" data-tip="tip.repo.withdraw">${esc(t("repos.withdraw"))}</button></li>`).join("") || `<li class="hint">${esc(t("repos.none"))}</li>`;\n $("jobs").innerHTML = s.jobs.map((j) => `<li><span>${esc(t(j.kind === "inference" ? "jobs.model" : "jobs.container"))} \xB7 ${new Date(j.createdAt).toLocaleString(lang)}</span><span>${esc(t(`jobs.status.${j.status}`))}${j.cost != null ? ` \xB7 ${esc(t("jobs.cost", { n: fmt(j.cost) }))}` : ""}</span></li>`).join("") || `<li class="hint">${esc(t("jobs.none"))}</li>`;\n $("oai-url").textContent = s.openaiBaseUrl;\n const fl = s.fileLimits;\n $("file-limits").hidden = !fl || !fl.enabled;\n if (fl && fl.enabled) $("file-limits").textContent = t("files.limit", { size: fmtBytes(fl.maxBytes) });\n if (document.activeElement !== $("wait-minutes") && !$("wait-minutes").dataset.dirty) $("wait-minutes").value = s.answerWaitMinutes ?? 10;\n $("models-hint").textContent = t("ask.offers", { models: s.capacity.models.join(", ") || t("ask.offers_none") });\n}\n\n// ---- every button answers at once, and says clearly what went wrong ----------------------\nfunction explain(err) {\n const m = String((err && err.message) || err);\n if (/Failed to fetch|Load failed|NetworkError/.test(m)) return t("err.agent_down");\n if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|coordinator unreachable|timeout/i.test(m)) return t("err.server");\n if (/unauthorized/.test(m)) return t("err.session");\n if (/billing_not_configured/.test(m)) return t("err.billing");\n if (/no_network_computer/.test(m)) return t("mynet.none_ready");\n if (/no_shared_group/.test(m)) return t("chat.err.noshared");\n const notReady = /group_not_ready:\\s*(\\w+)\\s*(.*)/.exec(m);\n if (notReady) return t("chat.err.notready", { state: [t(`mygroup.state.${notReady[1]}`), notReady[2]].filter(Boolean).join(" ") });\n if (/context_too_long/.test(m)) return t("chat.err.context");\n if (/not_enough_memory/.test(m)) return t("mygroup.err.memory");\n if (/group_needs_two/.test(m)) return t("mygroup.err.two");\n if (/group_computer_too_small/.test(m)) return t("mygroup.err.small");\n if (/group_needs_update: /.test(m)) return t("mygroup.err.update.who", { names: m.replace(/^.*group_needs_update:\\s*/, "") });\n if (/group_needs_update/.test(m)) return t("mygroup.err.update");\n if (/custom_model_not_found/.test(m)) return t("mygroup.err.custom");\n if (/group_custom_private/.test(m)) return t("mygroup.err.private");\n if (/group_needs_plan/.test(m)) return t("mygroup.err.plan");\n if (/needs_paid_credits/.test(m)) return t("chat.err.paid");\n if (/attachments_too_large/.test(m)) return t("chat.err.toomany");\n if (/group_needs_this_computer/.test(m)) return t("mygroup.err.here");\n if (/not found on disk|ENOENT/.test(m)) return t("err.no_folder");\n if (/not a folder/.test(m)) return t("err.not_folder");\n if (/insufficient_credits/.test(m)) return `${t("err.credits")} ${m.replace(/^insufficient_credits:\\s*/, "")}`;\n if (/device_required/.test(m)) return t("err.device");\n if (/No open model is online/.test(m)) return t("err.no_model");\n if (/invalid_request|invalid JSON/.test(m)) return t("err.invalid");\n return m.replace(/^Error:\\s*/, "");\n}\n\n/** Runs a button\'s action: disables it at once, shows "Working\u2026", then the result next to it. */\n/** The person\'s computers: those in the network run their jobs first, for free. */\nlet myNetHtml = "";\nfunction renderMyNetwork(s) {\n const nodes = (s.me && s.me.nodes) || [];\n const html = nodes.length ? nodes.map((n) => {\n const name = n.name || n.id.slice(0, 8);\n const usable = n.capacity && (n.capacity.containers || (n.capacity.models || []).length > 0);\n const facts = [n.id === s.nodeId ? t("mynet.this") : "", t(n.online ? "mynet.online" : "mynet.offline"), n.inNetwork ? t("mynet.member") : "", usable ? "" : t("mynet.cannot")].filter(Boolean).join(" \xB7 ");\n const tip = n.inNetwork ? "tip.mynet.remove" : "tip.mynet.add";\n return `<li><span><strong>${esc(name)}</strong><br><span class="hint">${esc(facts)}</span></span><button class="secondary" data-net="${esc(n.id)}" data-net-on="${n.inNetwork ? "" : "1"}" data-tip="${tip}">${esc(t(n.inNetwork ? "mynet.remove" : "mynet.add"))}</button></li>`;\n }).join("") : `<li><span class="hint">${esc(t("mynet.none"))}</span></li>`;\n // Rewritten only when something changed: the refresh must not swallow a click.\n if (html !== myNetHtml) { myNetHtml = html; $("mynet").innerHTML = html; }\n}\n/** A model run by several of the person\'s computers together. */\nlet myGroup = null, groupModels = [];\nlet groupHtml = "";\nasync function renderGroups(s) {\n const { models, groups, speed } = await api("/api/groups");\n $("group-speed").hidden = !speed;\n if (speed) $("group-speed").textContent = t("mygroup.speed", { n: fmt(speed) });\n groupModels = models;\n if (!$("group-model").options.length) {\n const families = [...new Set(models.map((m) => m.family))];\n $("group-model").innerHTML = families.map((f) => `<optgroup label="${esc(f)}">${models.filter((m) => m.family === f).map((m) => `<option value="${esc(m.id)}">${esc(m.name)} \xB7 ${fmt(m.gb)} GB${m.frontier ? ` \xB7 ${esc(t("mygroup.frontier"))}` : ""}</option>`).join("")}</optgroup>`).join("");\n }\n myGroup = groups[0] || null;\n $("group-share").checked = !!(myGroup && myGroup.shared);\n $("group-start").hidden = !!myGroup;\n $("group-ask").hidden = !myGroup;\n const nodes = (s.me && s.me.nodes) || [];\n const html = myGroup ? [myGroup.hostNodeId, ...myGroup.workers].map((id) => {\n const n = nodes.find((x) => x.id === id);\n const st = myGroup.states[id] || { state: "waiting" };\n const detail = st.detail === "not_enough_disk" ? t("mygroup.err.disk") : st.detail === "download_stalled" ? t("mygroup.err.stalled") : st.detail || "";\n return `<li><span><strong>${esc((n && n.name) || id.slice(0, 8))}</strong><br><span class="hint">${esc(t(`mygroup.state.${st.state}`))}${detail ? ` \xB7 ${esc(detail)}` : ""}${st.link ? ` \xB7 ${esc(t(`mygroup.link.${st.link === "direct" ? "direct" : "server"}`))}` : ""}</span></span></li>`;\n }).join("") : "";\n if (html !== groupHtml) { groupHtml = html; $("group-status").innerHTML = html; }\n const ready = myGroup && (myGroup.states[myGroup.hostNodeId] || {}).state === "ready";\n $("group-send").disabled = !ready;\n const folderHtml = (s.modelFolders || []).map((p) => `<li><span class="path">${esc(p)}</span><button class="secondary" data-model-folder="${esc(p)}" data-tip="tip.mygroup.folders.remove">${esc(t("mygroup.folders.remove"))}</button></li>`).join("");\n if ($("model-folders").innerHTML !== folderHtml) $("model-folders").innerHTML = folderHtml;\n const traffic = s.me && s.me.traffic;\n $("group-traffic").hidden = !myGroup || !traffic;\n if (traffic) {\n $("group-traffic").className = traffic.blocked ? "msg error" : "hint";\n $("group-traffic").textContent = traffic.blocked ? t("mygroup.err.traffic") : t("mygroup.traffic", { used: fmt(traffic.usedGb), included: fmt(traffic.includedGb) });\n }\n $("group-share").disabled = !ready || !!(myGroup && myGroup.model.startsWith("group:custom:"));\n $("group-custom").hidden = !!myGroup;\n renderGroupFit();\n}\n/** Every model can be chosen: this says if the computers online today are enough for it. */\nlet groupPlanFor = "";\nasync function renderGroupFit() {\n const m = groupModels.find((x) => x.id === $("group-model").value);\n $("group-fit").hidden = !m || !!myGroup;\n $("group-plan").hidden = !m || !!myGroup;\n if (!m || !state || !state.me) return;\n const key = JSON.stringify([m.id, state.me.nodes.map((n) => [n.id, n.online, n.inNetwork, n.capacity && n.capacity.memoryMb])]);\n if (key !== groupPlanFor && !myGroup) {\n groupPlanFor = key;\n const p = await api("/api/groups/preview", { model: m.id }).catch(() => null);\n if (p && key === groupPlanFor) {\n const rows = p.shares.map((s) => `<li><span><strong>${esc(s.name)}</strong></span><span class="${s.fits ? "hint" : "missing"}">${esc(t(s.tooSmall ? "mygroup.plan.small" : "mygroup.plan.row", { percent: s.percent, gb: fmt(s.gb), have: fmt(s.memoryGb) }))}</span></li>`);\n if (p.disk.freeGb !== null) rows.push(`<li><span class="${p.disk.ok ? "hint" : "missing"}">${esc(t(p.disk.ok ? "mygroup.plan.disk" : "mygroup.plan.disk.no", { need: fmt(p.disk.needGb), free: fmt(p.disk.freeGb) }))}</span></li>`);\n $("group-plan").innerHTML = rows.join("");\n }\n }\n const have = state.me.nodes.filter((n) => n.inNetwork && n.online).reduce((sum, n) => sum + ((n.capacity && n.capacity.memoryMb) || 0), 0) / 1024;\n $("group-fit").textContent = t(have >= m.memoryGb ? "mygroup.fit.ok" : "mygroup.fit.no", { have: fmt(have), need: fmt(m.memoryGb), license: m.license });\n}\n$("group-go").addEventListener("click", () => act($("group-go"), $("group-msg"), async () => { await api("/api/groups", { model: $("group-model").value }); await refresh(); }));\n$("group-stop").addEventListener("click", () => act($("group-stop"), $("group-msg"), async () => { await api("/api/groups/stop", { id: myGroup.id }); await refresh(); }));\n$("group-send").addEventListener("click", () => { $("chat-model").dataset.want = `own:${myGroup.model}`; showView("chat"); });\nconst shareGroup = (btn, shared) => act(btn, $("group-msg"), async () => { await api("/api/groups/share", { id: myGroup.id, shared }); $("group-share-confirm").hidden = true; await refresh(); });\n$("group-share").addEventListener("change", (e) => {\n if (e.target.checked) { e.target.checked = false; $("group-share-confirm").hidden = false; }\n else shareGroup(e.target, false);\n});\n$("group-share-yes").addEventListener("click", () => shareGroup($("group-share-yes"), true));\n$("group-share-no").addEventListener("click", () => { $("group-share-confirm").hidden = true; });\n$("group-model").addEventListener("change", () => renderGroupFit());\nconst loadHubFiles = async () => {\n const { files } = await api("/api/hub/files", { repo: $("hub-repo").value });\n $("hub-file").innerHTML = files.map((f) => `<option value="${esc(f.file)}">${esc(f.file)} \xB7 ${fmt(f.gb)} GB</option>`).join("");\n};\n$("hub-find").addEventListener("click", () => act($("hub-find"), $("group-msg"), async () => {\n const { results } = await api("/api/hub/search", { query: $("hub-query").value.trim() });\n $("hub-repo").innerHTML = results.map((r) => `<option value="${esc(r.repo)}">${esc(r.repo)}</option>`).join("");\n $("hub-pick").hidden = !results.length;\n if (results.length) await loadHubFiles();\n}));\n$("hub-repo").addEventListener("change", () => act($("hub-find"), $("group-msg"), loadHubFiles));\n$("hub-use").addEventListener("click", () => act($("hub-use"), $("group-msg"), async () => { await api("/api/groups", { model: "custom", custom: { repo: $("hub-repo").value, file: $("hub-file").value } }); await refresh(); }));\n$("model-folder-add").addEventListener("click", () => act($("model-folder-add"), $("group-msg"), async () => { await api("/api/model-folders", { path: $("model-folder").value.trim() }); $("model-folder").value = ""; await refresh(); }));\n$("model-folders").addEventListener("click", (e) => {\n const b = e.target.closest("[data-model-folder]");\n if (b) act(b, $("group-msg"), async () => { await api("/api/model-folders/remove", { path: b.dataset.modelFolder }); await refresh(); });\n});\n$("mynet").addEventListener("click", (e) => {\n const b = e.target.closest("[data-net]");\n if (!b) return;\n act(b, $("mynet-msg"), async () => { await api("/api/network", { id: b.dataset.net, inNetwork: b.dataset.netOn === "1" }); await refresh(); });\n});\n\nasync function act(btn, msgEl, fn, okText) {\n if (btn.disabled) return;\n const label = btn.textContent;\n btn.disabled = true;\n btn.textContent = t("working");\n if (msgEl) { msgEl.className = "msg"; msgEl.textContent = ""; }\n try {\n const r = await fn();\n const text = typeof okText === "function" ? okText(r) : okText;\n if (msgEl && text) { msgEl.className = "msg ok"; msgEl.textContent = text; }\n return r;\n } catch (err) {\n if (msgEl) { msgEl.className = "msg error"; msgEl.textContent = explain(err); }\n } finally {\n btn.disabled = false;\n btn.textContent = label;\n }\n}\n\nfor (const ev of ["input", "change"]) $("rules").addEventListener(ev, () => { rulesDirty = true; });\n\n$("rules").addEventListener("submit", (e) => {\n e.preventDefault();\n const f = e.target.elements;\n if ((f.hourFrom.value === "") !== (f.hourTo.value === "")) { $("rules-msg").className = "msg error"; $("rules-msg").textContent = t("rules.hours_both"); return; }\n const hours = f.hourFrom.value !== "" ? [Number(f.hourFrom.value), Number(f.hourTo.value)] : null;\n act(e.submitter || e.target.querySelector("button"), $("rules-msg"), async () => {\n await api("/api/settings", { cacheMax: Number(f.cacheMax.value), lending: { enabled: f.enabled.checked, maxCpus: Number(f.maxCpus.value), maxMemoryMb: Number(f.maxMemoryMb.value), maxDiskGb: Number(f.maxDiskGb.value),\n gpu: f.gpu.value, containers: f.containers.checked, serveModels: f.serveModels.checked, idleMinutes: Number(f.idleMinutes.value), onlyOnAc: f.onlyOnAc.checked, keepAwake: f.keepAwake.checked, hours } });\n rulesDirty = false;\n refresh();\n }, t("rules.saved"));\n});\n\n$("pause").addEventListener("click", () => {\n const pausing = !state.lending.paused;\n act($("pause"), $("pause-msg"), async () => { await api("/api/pause", { paused: pausing }); await refresh(); }, t(pausing ? "lend.paused_ok" : "lend.resumed_ok"));\n});\n\n$("folder-add").addEventListener("click", () => {\n const path = $("folder-path").value.trim();\n if (!path) { $("folder-msg").className = "msg error"; $("folder-msg").textContent = t("folders.need_path"); return; }\n act($("folder-add"), $("folder-msg"), async () => { await api("/api/folders", { path }); $("folder-path").value = ""; await refresh(); }, t("folders.added"));\n});\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || b.closest("#setup")) return;\n const msg = $("folder-msg");\n if (b.dataset.unfolder) act(b, msg, async () => { await api("/api/folders/remove", { path: b.dataset.unfolder }); await refresh(); }, t("folders.removed"));\n if (b.dataset.draft) act(b, msg, () => api("/api/draft", { folder: b.dataset.draft }), (r) => t("folders.drafted", { n: r.draft.modules.length }));\n if (b.dataset.publish) act(b, msg, async () => {\n const r = await api("/api/publish", { folder: b.dataset.publish });\n if (!r.ok) throw new Error(r.errors.join(" ") + (r.secrets ? " " + t("folders.secrets_in", { where: r.secrets.map((x) => t("folders.line", { file: x.file, line: x.line })).join(", ") }) : ""));\n await refresh();\n return r;\n }, (r) => t("folders.published", { name: r.repo.name, n: r.files }));\n if (b.dataset.unpublish) act(b, $("repos-msg"), async () => { await api("/api/repos/remove", { id: b.dataset.unpublish }); await refresh(); }, t("repos.withdrawn"));\n if (b.dataset.buy) showSummary(b.dataset.buy);\n if (b.dataset.pay) act(b, $("buy-msg"), async () => { const { url } = await api("/api/checkout", { product: b.dataset.pay, lang, ...(b.dataset.pay === "recharge" ? { amountEur: rechargeEur() } : {}) }); await api("/api/open", { url }); $("buy-summary").hidden = true; }, t("buy.ok"));\n if (b.id === "buy-cancel") $("buy-summary").hidden = true;\n});\n\n// ---- plans: name, price and what each includes; a summary before the payment page ------------\nlet plansInfo = null, currentPlan = null;\nconst euro = (n) => Number(n).toLocaleString(lang, { style: "currency", currency: "EUR" });\nconst rechargeEur = () => Math.round(Number(($("recharge-eur") || {}).value)) || 0;\nconst rechargeOk = (n) => n >= plansInfo.recharge.minEur && n <= plansInfo.recharge.maxEur;\nconst planLines = (id) => (id === "recharge" ? [t("plans.recharge.1", { n: fmt(rechargeEur() * plansInfo.recharge.creditsPerEur) }), t("plans.recharge.2"), t("plans.recharge.3")] : [1, 2, 3].map((i) => t(`plan.${id}.${i}`)));\nconst planName = (id) => (id === "recharge" ? t("plans.recharge.title") : t(`plan.${id}`));\nasync function renderPlans(current) {\n currentPlan = current;\n if (!plansInfo) plansInfo = await api("/api/plans").catch(() => null);\n if (!plansInfo) return;\n const items = [...plansInfo.plans.map((p) => ({ id: p.id, price: euro(p.priceEurMonth), per: p.id === "free" ? "" : t("plan.month") })), { id: "recharge", price: t("plans.recharge.from", { price: euro(plansInfo.recharge.minEur) }), per: "" }];\n const rechargeBox = `<label>${esc(t("plans.recharge.amount"))} <input type="number" id="recharge-eur" min="${plansInfo.recharge.minEur}" max="${plansInfo.recharge.maxEur}" step="1" value="20" style="width:80px"></label><ul><li>${esc(t("plans.recharge.rate", { n: plansInfo.recharge.creditsPerEur }))}</li><li>${esc(t("plans.recharge.2"))}</li><li>${esc(t("plans.recharge.3"))}</li></ul>`;\n const keep = rechargeEur();\n const html = items.map((p) => `<div class="plan${p.id === current ? " current" : ""}"><h3>${esc(planName(p.id))}${p.id === current ? `<span class="badge">${esc(t("plans.current"))}</span>` : ""}</h3>` +\n `<div class="price">${esc(p.price)}<span class="hint">${esc(p.per)}</span></div>${p.id === "recharge" ? rechargeBox : `<ul>${planLines(p.id).map((l) => `<li>${esc(l)}</li>`).join("")}</ul>`}` +\n (p.id === "free" || p.id === current ? "" : `<button class="secondary" data-buy="${p.id}" data-tip="tip.plans.choose">${esc(t("plans.choose", { plan: planName(p.id) }))}</button>`) + `</div>`).join("");\n if ($("plan-cards").dataset.html !== html) {\n $("plan-cards").dataset.html = html;\n $("plan-cards").innerHTML = html;\n if (keep) $("recharge-eur").value = keep;\n }\n if (!plansInfo.payments && !$("buy-msg").textContent) $("buy-msg").textContent = t("plans.off");\n}\nfunction showSummary(id) {\n if (!plansInfo) return;\n const p = plansInfo.plans.find((x) => x.id === id);\n if (id === "recharge" && !rechargeOk(rechargeEur())) { $("buy-msg").className = "msg error"; $("buy-msg").textContent = t("plans.recharge.range", { min: plansInfo.recharge.minEur, max: plansInfo.recharge.maxEur }); return; }\n const price = id === "recharge" ? euro(rechargeEur()) : `${euro(p.priceEurMonth)}${t("plan.month")}`;\n const lines = id === "recharge" ? planLines(id) : [...planLines(id), t("plans.renew")];\n $("buy-summary").innerHTML = `<div class="hint">${esc(t("plans.summary"))}</div><h3>${esc(id === "recharge" ? planName(id) : `BitPull ${planName(id)}`)} \xB7 ${esc(price)}</h3>` +\n `<ul>${lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul><div class="row"><button data-pay="${id}" data-tip="tip.plans.pay"${plansInfo.payments ? "" : " disabled"}>${esc(t("plans.pay"))}</button>` +\n `<button class="secondary" id="buy-cancel" data-tip="tip.plans.cancel">${esc(t("plans.cancel"))}</button></div>`;\n $("buy-summary").hidden = false;\n}\n\n// ---- log out (e.g. to log in with another account) ----------------------------------------\n$("logout").addEventListener("click", () => {\n $("logout-text").textContent = t("logout.confirm", { email: state && state.me ? state.me.email : "" });\n $("logout-confirm").hidden = false;\n});\n$("logout-no").addEventListener("click", () => ($("logout-confirm").hidden = true));\n$("logout-yes").addEventListener("click", () => act($("logout-yes"), $("buy-msg"), async () => {\n await api("/api/logout", {});\n try { sessionStorage.removeItem("bp_token"); } catch {}\n // In the app: back to its login page. Elsewhere: how to log in again.\n if (backTo) { location.replace(`${backTo}/index.html?loggedout=1`); return; }\n $("app").hidden = true; $("loggedout").hidden = false;\n}));\n\n$("ask").addEventListener("click", () => showView("chat"));\n$("show-key").addEventListener("click", () => { $("oai-key").textContent = token; });\n$("wait-minutes").addEventListener("input", () => { $("wait-minutes").dataset.dirty = "1"; });\n$("save-wait").addEventListener("click", () => {\n const n = Math.round(Number($("wait-minutes").value));\n if (!(n >= 1 && n <= 120)) { $("wait-msg").className = "msg error"; $("wait-msg").textContent = t("ask.wait.range"); return; }\n act($("save-wait"), $("wait-msg"), async () => { await api("/api/settings", { answerWaitMinutes: n }); delete $("wait-minutes").dataset.dirty; }, t("ask.wait.saved", { n }));\n});\n\n// ---- "Set up this computer" -------------------------------------------------------------\nasync function refreshSetup() {\n if (!token) return;\n let st;\n try { st = await api("/api/setup"); } catch { return; }\n const task = (id) => st.tasks.find((x) => x.id === id);\n const progress = (x) => x ? (x.state === "running" ? `<progress max="100" value="${x.progress || 0}"></progress><div class="status">${esc(x.label)} ${x.progress ? x.progress + "%" : ""}</div>`\n : x.state === "failed" ? `<div class="status missing">${esc(x.detail || "")}</div>`\n : `<div class="status ok">${esc(x.detail && /[\\\\/]/.test(x.detail) ? t("setup.opened", { file: x.detail.split(/[\\\\/]/).pop() }) : t("setup.done"))}</div>`) : "";\n const NAMES = { docker: "Docker", ollama: "Ollama", "apple-container": "Apple Container", nvidia: "NVIDIA" };\n const install = (id, label) => {\n const inst = st.installers[id];\n if (!inst) return "";\n const vars = esc(JSON.stringify({ name: NAMES[id] || id }));\n if (inst.kind === "download") return `<button data-download="${id}" data-tip="tip.setup.download" data-tip-vars="${vars}">${esc(label)}</button>`;\n if (inst.kind === "page") return `<button class="secondary" data-open="${esc(inst.url)}" data-tip="tip.setup.open" data-tip-vars="${vars}">${esc(label)}</button>`;\n return `<div class="status">${esc(t("setup.terminal"))} <code>${esc(inst.command)}</code></div>`;\n };\n const row = (title, ok, okText, missingText, action, help) =>\n `<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(title)}</strong><div class="status">${esc(help)}</div></div><div style="text-align:end;min-width:220px">${ok ? `<span class="ok">\u2713 ${esc(okText)}</span>` : `<span class="missing">${esc(missingText)}</span><div>${action}</div>`}</div></div>`;\n const rows = [];\n rows.push(row("Docker", st.docker.running, t("setup.status.running"), t(st.docker.installed ? "setup.status.stopped" : "setup.status.missing"),\n st.docker.installed ? `<button data-start="docker" data-tip="tip.setup.start" data-tip-vars="${esc(JSON.stringify({ name: "Docker" }))}">${esc(t("setup.docker.start"))}</button>` : install("docker", t("setup.docker.download")) + progress(task("download-docker")),\n t("setup.docker.help") + (st.docker.problem ? ` \u2014 ${/Docker Hub login is no longer valid/.test(st.docker.problem) ? t("lend.docker_login") : st.docker.problem}` : "")));\n if (st.os === "darwin" && st.arch === "arm64")\n rows.push(row(t("setup.apple.title"), st.appleContainer.installed, t("setup.status.installed"), t("setup.status.missing"), install("apple-container", t("setup.apple.get")), t("setup.apple.help")));\n rows.push(row("Ollama", st.ollama.running, t("setup.status.running"), t(st.ollama.installed ? "setup.status.stopped" : "setup.status.missing"),\n st.ollama.installed ? `<button data-start="ollama" data-tip="tip.setup.start" data-tip-vars="${esc(JSON.stringify({ name: "Ollama" }))}">${esc(t("setup.ollama.start"))}</button>` : install("ollama", t("setup.ollama.download")) + progress(task("download-ollama")),\n t("setup.ollama.help")));\n const rec = st.recommendedModel;\n rows.push(row(t("setup.model.title"), st.ollama.models.length > 0, st.ollama.models.join(", "), t("setup.model.none"),\n st.ollama.running ? `<button data-pull="${esc(rec.name)}" data-tip="tip.setup.pull" data-tip-vars="${esc(JSON.stringify({ model: rec.name, size: rec.sizeGb }))}">${esc(t("setup.model.download", { model: rec.name, size: rec.sizeGb }))}</button>${progress(task("model-" + rec.name))}` : `<div class="status">${esc(t("setup.model.need_ollama"))}</div>`,\n t("setup.model.help", { ram: st.ramGb, model: rec.name })));\n if (st.nvidia !== null)\n rows.push(row(t("setup.nvidia.title"), st.nvidia, t("setup.status.found"), t("setup.status.not_found"), install("nvidia", t("setup.nvidia.get")), t("setup.nvidia.help")));\n $("setup").innerHTML = rows.join("") + `<div class="row"><button class="secondary" id="recheck" data-tip="tip.setup.recheck">${esc(t("setup.recheck"))}</button></div>`;\n renderModels(st, task, progress);\n if (st.tasks.some((x) => x.state === "running")) setTimeout(refreshSetup, 1500);\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#setup")) return;\n const m = $("setup-msg");\n if (b.dataset.download) act(b, m, () => api("/api/setup/download", { id: b.dataset.download }), t("setup.ok.download"));\n if (b.dataset.start) act(b, m, () => api("/api/setup/start", { id: b.dataset.start }), t("setup.ok.start"));\n if (b.dataset.pull) act(b, m, () => api("/api/setup/pull", { model: b.dataset.pull }), t("setup.ok.pull"));\n if (b.dataset.open) act(b, m, () => api("/api/open", { url: b.dataset.open }), t("setup.ok.page"));\n if (b.id === "recheck") act(b, m, () => api("/api/setup/recheck", {}), t("setup.ok.recheck"));\n setTimeout(refreshSetup, 1000);\n});\n\n// ---- open AI models to share, and the lender\'s limits ------------------------------------\nconst TASK_ORDER = ["chat", "vision", "ocr", "translate", "embed", "rerank", "image", "upscale", "stt", "tts", "music"];\nfunction renderModels(st, task, progress) {\n const lem = st.lemonade;\n const engine = !lem.supported\n ? `<span class="missing">${esc(t("models.engine.unsupported"))}</span>`\n : lem.running ? `<span class="ok">\u2713 ${esc(t("setup.status.running"))}</span>`\n : `<button data-lemonade="1" data-tip="tip.models.engine">${esc(t(lem.installed ? "models.engine.repair" : "models.engine.install"))}</button>${progress(task("download-lemonade"))}${lem.error ? `<div class="status missing">${esc(lem.error)}</div>` : ""}`;\n const installed = (m) => m.runtime === "ollama"\n ? st.ollama.models.some((x) => x === m.id || x === m.id + ":latest")\n : lem.models.includes(m.id);\n const ready = (m) => m.runtime === "ollama" ? st.ollama.running : lem.running;\n const html = [`<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(t("models.engine.title"))}</strong><div class="status">${esc(t("models.engine.help"))}</div></div><div style="text-align:end;min-width:220px">${engine}</div></div>`];\n for (const tk of TASK_ORDER) {\n const list = st.openModels.filter((m) => m.tasks[0] === tk);\n if (!list.length) continue;\n html.push(`<h3 style="margin:14px 0 4px">${esc(t("task." + tk))}</h3>`);\n for (const m of list) {\n const tip = esc(JSON.stringify({ model: m.name, size: m.sizeGb }));\n let action;\n if (installed(m)) action = `<span class="ok">\u2713 ${esc(t("models.shared"))}</span>`;\n else if (!m.available) action = `<span class="status">${esc(t("models.not_here"))}</span>`;\n else if (!ready(m)) action = `<span class="status">${esc(t(m.runtime === "ollama" ? "setup.model.need_ollama" : "models.need_engine"))}</span>`;\n else action = `<button class="${m.fits ? "" : "secondary"}" data-pull-model="${esc(m.id)}" data-tip="tip.models.download" data-tip-vars="${tip}">${esc(t("models.download", { size: m.sizeGb }))}</button>${m.fits ? "" : `<div class="status missing">${esc(t("models.needs_ram", { ram: m.minRamGb }))}</div>`}${progress(task("model-" + m.id))}`;\n html.push(`<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(m.name)}</strong>${m.recommended ? ` <span class="pill">${esc(t("models.recommended"))}</span>` : ""}<div class="status">${esc(m.sizeGb)} GB \xB7 ${esc(t("models.license"))}: ${esc(m.license)}</div></div><div style="text-align:end;min-width:220px">${action}</div></div>`);\n }\n }\n $("ai-models").innerHTML = html.join("");\n // Limits: keep what the user is typing; render only once per load.\n if (!$("ai-limits").dataset.ready) {\n const lim = st.aiLimits, u = st.aiUsage;\n const cell = (p, k) => `<input type="number" min="0" step="1" data-limit="${p}.${k}" value="${lim[p][k] ?? ""}" placeholder="${esc(t("limits.none"))}" style="width:120px">`;\n $("ai-limits").innerHTML = `<table class="limits"><tr><th></th><th>${esc(t("limits.requests"))}</th><th>${esc(t("limits.tokens"))}</th><th>${esc(t("limits.used"))}</th></tr>` +\n ["day", "week", "month"].map((p) => `<tr><td>${esc(t("limits." + p))}</td><td>${cell(p, "requests")}</td><td>${cell(p, "tokens")}</td><td class="status">${esc(t("limits.usage", { requests: u[p].requests, tokens: fmt(u[p].tokens) }))}</td></tr>`).join("") + `</table>`;\n $("ai-limits").dataset.ready = "1";\n }\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#ai-models")) return;\n const m = $("models-msg");\n if (b.dataset.lemonade) act(b, m, () => api("/api/setup/download", { id: "lemonade" }), t("models.ok.engine"));\n if (b.dataset.pullModel) act(b, m, () => api("/api/setup/pull", { model: b.dataset.pullModel }), t("setup.ok.pull"));\n setTimeout(refreshSetup, 1000);\n});\n\n$("save-limits").addEventListener("click", () => {\n const aiLimits = { day: {}, week: {}, month: {} };\n for (const input of document.querySelectorAll("[data-limit]")) {\n const [p, k] = input.dataset.limit.split(".");\n aiLimits[p][k] = input.value === "" ? null : Math.max(0, Math.floor(Number(input.value)));\n }\n act($("save-limits"), $("models-msg"), () => api("/api/settings", { lending: { aiLimits } }), t("limits.saved"));\n});\n\n// ---- connect AI tools -------------------------------------------------------------------\nlet tools = [];\nasync function refreshTools() {\n try { tools = (await api("/api/integrations")).tools; } catch { return; }\n $("tools").innerHTML = tools.map((x, i) => {\n const vars = esc(JSON.stringify({ name: x.name }));\n const status = x.connected ? `<span class="ok">\u2713 ${esc(t("tools.connected"))}</span>` : x.installed ? `<span class="status">${esc(t("tools.installed"))}</span>` : `<span class="status">${esc(t("tools.not_found"))}</span>`;\n const connect = x.installed && !x.connected ? `<button data-connect="${esc(x.id)}" data-tip="tip.tools.connect" data-tip-vars="${vars}">${esc(t("tools.connect"))}</button>` : "";\n const copy = `<button class="secondary" data-copy="${i}" data-tip="tip.tools.copy" data-tip-vars="${vars}">${esc(t("tools.copy"))}</button>`;\n const problem = x.problem === "config_invalid" ? `<div class="status missing">${esc(t("tools.config_invalid", { name: x.name }))}</div>` : "";\n return `<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(x.name)}</strong> ${status}${problem}</div><div class="row" style="margin:0">${connect}${copy}</div></div>`;\n }).join("");\n}\n\nasync function copyText(text) {\n try { await navigator.clipboard.writeText(text); return true; } catch {}\n const ta = document.createElement("textarea");\n ta.value = text; document.body.appendChild(ta); ta.select();\n let ok = false;\n try { ok = document.execCommand("copy"); } catch {}\n ta.remove();\n return ok;\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#tools")) return;\n const m = $("tools-msg");\n if (b.dataset.connect) {\n const tool = tools.find((x) => x.id === b.dataset.connect);\n act(b, m, async () => {\n try { await api("/api/integrations/connect", { id: b.dataset.connect }); }\n catch (err) { if (/config_invalid/.test(err.message)) throw new Error(t("tools.config_invalid", { name: tool.name })); throw err; }\n await refreshTools();\n }, t("tools.done", { name: tool.name }));\n }\n if (b.dataset.copy !== undefined) {\n const tool = tools[Number(b.dataset.copy)];\n act(b, m, async () => {\n if (!(await copyText(tool.manual))) throw new Error(`${t("tools.manual")} ${tool.manual}`);\n }, `${t("tools.copied")} ${tool.where ? `${t("tools.manual")} ${tool.where}` : ""}`);\n }\n});\n\n// ---- app updates ------------------------------------------------------------------------\nlet update = null;\nasync function refreshUpdate() {\n try { update = await api("/api/update"); } catch { return; }\n const u = update;\n $("update-info").textContent = `${t("update.current", { v: u.current })}${u.latest ? " \xB7 " + t("update.latest", { v: u.latest }) : ""}`;\n const btn = $("update-btn");\n if (!btn.disabled) btn.textContent = u.available ? t("update.install", { v: u.latest }) : t("update.button");\n const st = u.status;\n const fresh = st && Date.now() / 1000 - Number(st.at || 0) < 600;\n const m = $("update-msg");\n if (!u.inApp) { m.className = "msg"; m.textContent = t("update.not_in_app"); return; }\n if (fresh && st.state) {\n m.className = st.state === "error" ? "msg error" : st.state === "up_to_date" ? "msg ok" : "msg";\n m.textContent = t(`update.state.${st.state}`, { v: st.version || u.latest || "", msg: st.message || "" });\n if (["requested", "checking", "installing", "restarting"].includes(st.state)) setTimeout(refreshUpdate, 1500);\n } else if (u.available) { m.className = "msg"; m.textContent = t("update.available", { v: u.latest }); }\n}\n$("update-btn").addEventListener("click", () => {\n act($("update-btn"), null, async () => {\n try { await api("/api/update", {}); }\n catch (err) { $("update-msg").className = "msg error"; $("update-msg").textContent = /updates_only_in_app/.test(err.message) ? t("update.not_in_app") : explain(err); return; }\n setTimeout(refreshUpdate, 500);\n });\n});\n\n// ---- chat: one page to talk to every model ------------------------------------------------\n// renderMd:start\n/** Answers come from models and other people\'s computers: everything is escaped before formatting. */\nfunction renderMd(text) {\n const safe = (s) => String(s ?? "").replace(/[&<>"\']/g, (c) => ({ "&":"&","<":"<",">":">",\'"\':""","\'":"'" }[c]));\n const inline = (s) => safe(s)\n .replace(/`([^`\\n]+)`/g, "<code>$1</code>")\n .replace(/\\*\\*([^*\\n]+)\\*\\*/g, "<strong>$1</strong>")\n .replace(/(^|[\\s(])\\*([^*\\n]+)\\*/g, "$1<em>$2</em>")\n .replace(/\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, \'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>\');\n const out = [];\n const parts = String(text ?? "").split(/```/);\n parts.forEach((part, i) => {\n if (i % 2) { out.push(`<pre><code>${safe(part.replace(/^[^\\n]*\\n/, ""))}</code></pre>`); return; }\n let list = null;\n const close = () => { if (list) { out.push(`</${list}>`); list = null; } };\n for (const line of part.split("\\n")) {\n const h = /^(#{1,4})\\s+(.*)/.exec(line), u = /^\\s*[-*]\\s+(.*)/.exec(line), o = /^\\s*\\d+[.)]\\s+(.*)/.exec(line);\n if (u || o) {\n const want = u ? "ul" : "ol";\n if (list !== want) { close(); out.push(`<${want}>`); list = want; }\n out.push(`<li>${inline((u || o)[1])}</li>`);\n continue;\n }\n close();\n if (h) out.push(`<h${h[1].length > 2 ? 4 : 3}>${inline(h[2])}</h${h[1].length > 2 ? 4 : 3}>`);\n else if (line.trim()) out.push(`<p>${inline(line)}</p>`);\n }\n close();\n });\n return out.join("");\n}\n/** Only pictures and sounds that came from this app are shown. */\nconst safeMedia = (url) => /^data:(image\\/(png|jpeg)|audio\\/(mpeg|wav));base64,[A-Za-z0-9+/=]+$/.test(String(url));\n// renderMd:end\nconst CHAT_KEEP_CHARS = 12000;\n// chatlogic:start\n/** What the model does with a message that has no attachment: most chat, others make or read something. */\nfunction modelTask(m) {\n if (!m) return "chat";\n if (m.paid) return m.tasks.includes("image") ? "image" : "chat";\n if (m.tasks.includes("chat") || m.tasks.includes("vision")) return "chat";\n return m.tasks.find((k) => ["image", "music", "tts", "stt", "ocr", "upscale", "translate"].includes(k)) || "chat";\n}\nconst IMAGE_TASKS = ["vision", "ocr", "upscale"];\nconst readsImages = (m) => !!m && !m.paid && m.tasks.some((k) => IMAGE_TASKS.includes(k));\nconst hearsAudio = (m) => !!m && !m.paid && m.tasks.includes("stt");\n/** How this model can take these attachments, or what is missing: nothing is sent that it would ignore. */\nfunction attachmentPlan(m, files) {\n const images = files.flatMap((f) => (f.kind === "image" ? [f.base64] : f.kind === "video" ? f.frames : []));\n const audios = files.flatMap((f) => (f.kind === "audio" ? [f.base64] : []));\n const videoAudio = files.map((f) => f.kind === "video" && f.audio).find(Boolean);\n const plain = { task: modelTask(m), files: [], need: null };\n if (!images.length && !audios.length) return plain;\n if (m && m.paid) return { ...plain, need: "paid" };\n if ((images.length && audios.length) || audios.length > 1) return { ...plain, need: "mix" };\n if (images.length) {\n if (readsImages(m)) {\n const task = IMAGE_TASKS.find((k) => m.tasks.includes(k));\n return { task, files: task === "upscale" ? images.slice(0, 1) : images, need: null };\n }\n if (videoAudio && hearsAudio(m)) return { task: "stt", files: [videoAudio], need: null };\n return { ...plain, need: "vision" };\n }\n return hearsAudio(m) ? { task: "stt", files: audios, need: null } : { ...plain, need: "stt" };\n}\n/** The first usable model that can take what is missing. */\nconst modelFor = (need, models) => models.find((x) => x.available && (need === "vision" ? readsImages(x) : need === "stt" ? hearsAudio(x) : false)) || null;\n/** Why there is nothing to talk to, in this person\'s real situation. */\nfunction noModelReason(s) {\n if (s && s.groupState && s.groupState !== "ready") return "chat.none.group";\n if (s && s.computersOnline === 0) return "chat.none.offline";\n if (s && s.hasCommunity && !(s.balance > 0)) return "chat.none.credits";\n if (s && s.hasPaid) return "chat.none.paid";\n return "chat.none.empty";\n}\nconst OFFICE_FILE = /\\.(pdf|docx?|xlsx?|pptx?|odt|ods|odp|pages|numbers|key|rtf|zip|rar|7z|gz|tar|exe|dmg|iso|bin)$/i;\n/** What kind of attachment a file is, from its type and then its name. */\nfunction fileKind(name, type) {\n if (OFFICE_FILE.test(name)) return "unreadable";\n if (/^image\\//.test(type) || /\\.(png|jpe?g|webp|gif|bmp|heic|heif|avif|tiff?)$/i.test(name)) return "image";\n if (/^audio\\//.test(type) || /\\.(mp3|m4a|wav|ogg|oga|opus|flac|aac|aiff?)$/i.test(name)) return "audio";\n if (/^video\\//.test(type) || /\\.(mp4|m4v|mov|webm|mkv|avi)$/i.test(name)) return "video";\n return "text";\n}\n/** A file with zero bytes in it is not text, whatever its name says. */\nconst looksLikeText = (bytes) => !bytes.subarray(0, 8192).includes(0);\n/** 16-bit mono WAV, what the speech models read. */\nfunction wavBytes(samples, rate) {\n const out = new Uint8Array(44 + samples.length * 2), v = new DataView(out.buffer);\n const text = (at, s) => { for (let i = 0; i < s.length; i++) out[at + i] = s.charCodeAt(i); };\n text(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); text(8, "WAVE"); text(12, "fmt ");\n v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true);\n v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); text(36, "data"); v.setUint32(40, samples.length * 2, true);\n for (let i = 0; i < samples.length; i++) v.setInt16(44 + i * 2, Math.max(-1, Math.min(1, samples[i])) * 32767, true);\n return out;\n}\n// chatlogic:end\n/** Limits of what a job can carry: it travels encoded twice and the server takes 16 MB. */\nconst JOB_FILES_MAX_BYTES = 8_500_000;\nconst AUDIO_MAX_SECONDS = 240;\nconst SOURCE_MAX_MB = { image: 30, audio: 200, video: 200 };\nconst TEXT_MAX_KB = 300;\nconst IMAGE_MAX_SIDE = 1600;\nconst VIDEO_FRAME_SIDE = 1024;\nconst VIDEO_MAX_FRAMES = 6;\nconst MAX_ATTACHMENTS = 8;\nconst MODELS_REFRESH_MS = 15000;\nlet chat = null, chatList = [], chatModelsList = [], chatBusy = null, attachments = [], chatStatus = null, modelsHtml = "", lastSent = null, attachNote = "";\n\nconst newChat = () => ({ id: crypto.randomUUID(), title: "", updatedAt: Date.now(), messages: [], model: $("chat-model").value || undefined });\nconst selectedModel = () => chatModelsList.find((m) => m.key === $("chat-model").value && m.available) || null;\nconst chatPlan = () => attachmentPlan(selectedModel(), attachments.filter((a) => !a.busy));\nfunction renderChatHint() {\n const m = selectedModel(), plan = chatPlan();\n const usable = chatModelsList.some((x) => x.available);\n const fix = plan.need && modelFor(plan.need, chatModelsList);\n const text = attachNote || (!m ? t(usable ? "chat.pick" : noModelReason(chatStatus), { state: chatStatus && chatStatus.groupState ? t(`mygroup.state.${chatStatus.groupState}`) : "" })\n : plan.need ? t(`chat.need.${plan.need}`, { model: m.name }) : t(`chat.hint.${plan.task}`));\n $("chat-hint").textContent = text;\n $("chat-hello-hint").textContent = text;\n $("chat-use").hidden = !fix;\n if (fix) { $("chat-use").textContent = t("chat.use", { model: fix.name }); $("chat-use").dataset.key = fix.key; }\n $("chat-send").disabled = !m || !!plan.need || attachments.some((a) => a.busy);\n renderChatWhere();\n}\n/** Before sending, says who will read the question and the most it can cost. */\nfunction renderChatWhere() {\n const m = selectedModel();\n if (!m) { $("chat-where").textContent = ""; return; }\n const task = chatPlan().task;\n const chars = $("chat-input").value.length + (chat ? chat.messages.reduce((n, x) => n + Math.min((x.content || "").length, CHAT_KEEP_CHARS), 0) : 0);\n const input = (Math.min(chars, CHAT_KEEP_CHARS) / 4 + 50) / 1e6, output = 1024 / 1e6;\n const credits = m.paid ? input * (m.inRate || 0) + output * (m.outRate || 0) : (input + output) * (m.rate || 0);\n const cost = task !== "chat" ? t("chat.where.cost.task") : credits ? t("chat.where.cost", { n: fmt(Math.max(credits, 0.001).toFixed(3)) }) : "";\n const where = m.own || ($("chat-mine").checked && !m.paid) ? (m.own ? "own" : "mine") : m.paid ? "paid" : m.id.startsWith("group:") ? "shared" : "net";\n $("chat-where").textContent = where === "own" || where === "mine" ? t(`chat.where.${where}`) : `${t(`chat.where.${where}`)} ${cost}`;\n}\nasync function loadChatModels() {\n const { own, network, paid, status } = await api("/api/chat/models");\n const entry = (source, x) => ({ key: `${source}:${x.model || x.id}`, id: x.model || x.id, name: x.name || x.model || x.id, tasks: x.tasks || [], paid: source === "paid", own: source === "own", group: !!x.group, available: x.available !== false, rate: x.creditsPerMillion });\n const community = network.filter((x) => x.tasks.some((k) => !["embed", "rerank"].includes(k))).map((x) => entry("net", x));\n const groups = [\n ["chat.group.own", own.map((x) => entry("own", x)), ""],\n ["chat.group.network", community, "chat.off.credits"],\n ["chat.group.paid", paid.map((x) => ({ ...entry("paid", x), tasks: [x.modality === "image" ? "image" : "chat"], inRate: x.creditsPerMillionInputTokens, outRate: x.creditsPerMillionOutputTokens })), "chat.off.paid"],\n ];\n chatModelsList = groups.flatMap((g) => g[1]);\n chatStatus = { ...status, hasCommunity: community.length > 0, hasPaid: paid.length > 0 };\n const html = groups.filter((g) => g[1].length).map(([label, list, off]) => `<optgroup label="${esc(t(label))}">${list.map((m) => `<option value="${esc(m.key)}"${m.available ? "" : " disabled"}>${esc(m.name)}${m.available ? "" : ` \xB7 ${esc(t(off))}`}</option>`).join("")}</optgroup>`).join("");\n const keep = $("chat-model").value;\n if (html !== modelsHtml) { modelsHtml = html; $("chat-model").innerHTML = html; }\n $("chat-model").hidden = !chatModelsList.length;\n let saved = null;\n try { saved = localStorage.getItem("bitpull.chatModel"); } catch {}\n const want = [$("chat-model").dataset.want, chat && chat.model, keep, saved].find((k) => chatModelsList.some((m) => m.key === k && m.available));\n delete $("chat-model").dataset.want;\n const first = chatModelsList.find((m) => m.available);\n $("chat-model").value = want || (first ? first.key : "");\n renderChatHint();\n}\nfunction renderChatList() {\n const q = $("chat-search").value.trim().toLowerCase();\n $("chat-list").innerHTML = chatList.filter((c) => !q || c.title.toLowerCase().includes(q)).map((c) =>\n `<li class="${chat && c.id === chat.id ? "on" : ""}"><button class="open" data-open="${esc(c.id)}" data-tip="tip.chat.open">${esc(c.title || t("chat.new"))}</button><button class="del" data-del="${esc(c.id)}" aria-label="${esc(t("chat.delete"))}" data-tip="tip.chat.delete">\xD7</button></li>`).join("");\n}\n/** What an AI asks to change: shown as it is, and only a click here makes it happen. */\nfunction proposalHtml(msg, i) {\n const p = msg.proposal;\n if (!p) return "";\n const show = (v) => (v === undefined ? "\u2013" : esc(typeof v === "string" ? v : JSON.stringify(v)));\n const head = `<strong>${esc(t(`act.${p.action}`))}</strong>`;\n if (msg.proposalState) return `<div class="summary">${head}<div class="hint">${esc(t(`ai.state.${msg.proposalState}`))}</div></div>`;\n const rows = p.fields.map((f) => `<tr><td><code>${esc(f.field)}</code></td><td>${show(f.current)}</td><td>${show(f.next)}</td></tr>`).join("");\n return `<div class="summary"><div class="hint">${esc(t("ai.wants"))}</div>${head}` +\n (rows ? `<table class="limits"><tr><th></th><th>${esc(t("ai.now"))}</th><th>${esc(t("ai.then"))}</th></tr>${rows}</table>` : "") +\n (p.source === "community" ? `<p class="hint">${esc(t("ai.community"))}</p>` : p.always ? `<p class="hint">${esc(t("ai.always"))}</p>` : "") +\n `<div class="row"><button data-ai-yes="${i}" data-tip="tip.ai.yes">${esc(t("ai.yes"))}</button><button class="secondary" data-ai-no="${i}" data-tip="tip.ai.no">${esc(t("plans.cancel"))}</button></div></div>`;\n}\nfunction messageHtml(msg, i) {\n if (msg.role === "user") return `<div class="m-user">${(msg.media || []).filter(safeMedia).map((u) => `<img src="${u}" alt="">`).join("")}${esc(msg.content)}</div>`;\n const think = /^\\s*<think>([\\s\\S]*?)<\\/think>/.exec(msg.content || "");\n const body = think ? msg.content.slice(think[0].length) : msg.content;\n const media = (msg.media || []).filter(safeMedia).map((u) => (u.startsWith("data:image") ? `<img src="${u}" alt="">` : `<audio controls src="${u}"></audio>`)).join("");\n const facts = [msg.modelName, msg.cost ? t("ask.credits", { n: fmt(msg.cost) }) : msg.free ? t("chat.free") : "", msg.seconds ? `${msg.seconds} s` : "", msg.tokensPerSecond ? t("chat.speed", { n: fmt(msg.tokensPerSecond) }) : ""].filter(Boolean).map(esc).join(" \xB7 ");\n const last = i === chat.messages.length - 1;\n const did = (msg.did || []).map((a) => `<div class="hint">${esc(t("ai.auto.did", { action: t(`act.${a}`) }))}</div>`).join("");\n return `<div>${proposalHtml(msg, i)}${did}<div class="m-ai${msg.error ? " error" : ""}">${think ? `<details><summary>${esc(t("chat.reasoning"))}</summary>${renderMd(think[1])}</details>` : ""}${msg.error ? esc(msg.content) : renderMd(body)}${media}</div>\n <div class="m-foot"><span>${facts}</span>${msg.error ? "" : `<button data-copy="${i}" data-tip="tip.chat.copy">${esc(t("chat.copy"))}</button>`}${last ? `<button data-retry="1" data-tip="tip.chat.retry">${esc(t("chat.retry"))}</button>` : ""}</div></div>`;\n}\nfunction renderChat() {\n const msgs = chat ? chat.messages : [];\n $("chat-hello").hidden = msgs.length > 0 || !!chatBusy;\n $("chat-log").innerHTML = msgs.map(messageHtml).join("") + (chatBusy ? `<div class="m-ai hint" id="chat-wait"></div>` : "");\n tickWait();\n $("chat-scroll").scrollTop = $("chat-scroll").scrollHeight;\n $("chat-send").hidden = !!chatBusy;\n $("chat-stop").hidden = !chatBusy;\n renderAttachments();\n renderChatList();\n}\nfunction tickWait() {\n const el = $("chat-wait");\n if (el && chatBusy && !chatBusy.text) el.textContent = t("chat.thinking", { s: Math.round((Date.now() - chatBusy.since) / 1000) });\n}\nsetInterval(tickWait, 1000);\nasync function saveCurrentChat() {\n chat.updatedAt = Date.now();\n await api("/api/chats/save", chat).catch(() => {});\n chatList = (await api("/api/chats")).chats;\n renderChatList();\n}\n/** Keeps the end of the conversation, which is what fits in what a model can read at once. */\nfunction historyForModel() {\n const out = [];\n let chars = 0;\n for (const m of [...chat.messages].reverse()) {\n if (m.error || !m.content) continue;\n chars += m.content.length;\n if (out.length && chars > CHAT_KEEP_CHARS) break;\n out.unshift({ role: m.role, content: m.content });\n }\n return out;\n}\n/** Shows an answer as it arrives, one line of the stream at a time. */\nasync function readLive(res, busy) {\n const reader = res.body.getReader(), decoder = new TextDecoder();\n const out = { content: "" };\n let rest = "";\n for (;;) {\n const { value, done } = await reader.read();\n if (done) return out;\n rest += decoder.decode(value, { stream: true });\n const lines = rest.split("\\n");\n rest = lines.pop();\n for (const line of lines.filter(Boolean)) {\n const d = JSON.parse(line);\n if (d.error) throw new Error(d.error);\n if (d.text) out.content += d.text;\n if (d.done) out.tokensPerSecond = d.tokensPerSecond;\n }\n busy.text = out.content;\n const el = $("chat-wait");\n if (el && chatBusy === busy) { el.className = "m-ai"; el.innerHTML = renderMd(out.content); $("chat-scroll").scrollTop = $("chat-scroll").scrollHeight; }\n }\n}\nasync function askChat(plan) {\n const m = selectedModel();\n if (!m) return;\n const { task, files } = plan;\n lastSent = { chatId: chat.id, plan };\n const started = Date.now();\n const busy = { since: started, ctrl: new AbortController() };\n chatBusy = busy;\n renderChat();\n let reply;\n const headers = { "content-type": "application/json", "x-bitpull-token": token || "" };\n // An answer that may use the app arrives whole: it has to be read before anything is shown.\n const useControl = $("chat-control").checked && task === "chat" && !files.length;\n const body = JSON.stringify({\n model: m.id, paid: m.paid, task, system: $("chat-system").value.trim() || undefined, messages: historyForModel(),\n placement: m.own || $("chat-mine").checked ? "mine" : "auto", temperature: Number($("chat-temp").value), files: files.length ? files : undefined,\n appControl: useControl,\n });\n const done = (data) => ({ role: "assistant", content: data.content || "", proposal: data.proposal, did: (data.done || []).map((d) => d.action), media: data.media, cost: data.cost, free: !data.cost, modelName: m.name, seconds: Math.round((Date.now() - started) / 1000), tokensPerSecond: data.tokensPerSecond });\n try {\n // The person\'s own group answers word by word when this computer runs its engine.\n const live = m.group && task === "chat" && !useControl ? await fetch("/api/chat/stream", { method: "POST", signal: busy.ctrl.signal, headers, body }) : null;\n if (live && live.ok) {\n reply = done(await readLive(live, busy));\n } else {\n const res = await fetch("/api/chat", { method: "POST", signal: busy.ctrl.signal, headers, body });\n const data = await res.json().catch(() => ({}));\n if (!res.ok) throw new Error(data.error || res.status);\n reply = done(data);\n }\n } catch (err) {\n reply = { role: "assistant", error: true, content: err.name === "AbortError" ? t("chat.stopped") : explain(err) };\n }\n if (chatBusy !== busy) return;\n chatBusy = null;\n chat.messages.push(reply);\n renderChat();\n await saveCurrentChat();\n}\nasync function sendChat() {\n const text = $("chat-input").value.trim();\n const m = selectedModel();\n if (chatBusy || !m || attachments.some((a) => a.busy)) return;\n const files = attachments;\n if (!text && !files.length) return;\n const plan = attachmentPlan(m, files);\n if (plan.need) return renderChatHint();\n if (["ocr", "upscale", "stt"].includes(plan.task) && !plan.files.length) { $("chat-hint").textContent = t(`chat.hint.${plan.task}`); return; }\n if (plan.files.reduce((n, b) => n + b.length * 0.75, 0) > JOB_FILES_MAX_BYTES) { $("chat-hint").textContent = t("chat.err.toomany"); return; }\n if (!chat) chat = newChat();\n const pasted = files.filter((f) => f.kind === "text").map((f) => `${f.name}:\\n\\`\\`\\`\\n${f.text}\\n\\`\\`\\``);\n const notes = files.filter((f) => f.kind === "video").map((f) => t(plan.task === "stt" ? "chat.video.heard" : "chat.video.frames", { n: f.frames.length, name: f.name }));\n const asked = text || (plan.task === "vision" ? t("chat.ask.image") : files.map((f) => f.name).join(", "));\n const content = [asked, ...notes, ...pasted].join("\\n\\n");\n const media = files.flatMap((f) => (f.kind === "image" ? [f.url] : f.kind === "video" ? [f.url] : []));\n chat.messages.push({ role: "user", content, ...(media.length ? { media } : {}) });\n chat.title = chat.title || (text || files[0].name).slice(0, 60);\n chat.model = m.key;\n $("chat-input").value = "";\n $("chat-input").style.height = "";\n attachments = [];\n attachNote = "";\n renderAttachments();\n await saveCurrentChat();\n await askChat(plan);\n}\nconst bytesToBase64 = (bytes) => {\n let s = "";\n for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 32768));\n return btoa(s);\n};\nconst dataOf = (url) => url.slice(url.indexOf(",") + 1);\n/** Draws a picture or a video frame at a size a model can take, always as a JPEG. */\nfunction drawnJpeg(source, width, height, side) {\n const scale = Math.min(1, side / Math.max(width, height));\n const c = document.createElement("canvas");\n c.width = Math.max(1, Math.round(width * scale));\n c.height = Math.max(1, Math.round(height * scale));\n const g = c.getContext("2d");\n g.fillStyle = "#fff";\n g.fillRect(0, 0, c.width, c.height);\n g.drawImage(source, 0, 0, c.width, c.height);\n return c.toDataURL("image/jpeg", 0.85);\n}\nasync function readImage(file) {\n const src = URL.createObjectURL(file);\n try {\n const img = new Image();\n img.src = src;\n await img.decode();\n const url = drawnJpeg(img, img.naturalWidth, img.naturalHeight, IMAGE_MAX_SIDE);\n return { kind: "image", url, base64: dataOf(url) };\n } finally {\n URL.revokeObjectURL(src);\n }\n}\n/** Any sound the system can decode, as 16 kHz mono: what the speech models read. */\nasync function readAudio(file) {\n const RATE = 16000;\n const decoded = await new OfflineAudioContext(1, RATE, RATE).decodeAudioData(await file.arrayBuffer());\n const seconds = Math.min(decoded.duration, AUDIO_MAX_SECONDS);\n const mix = new OfflineAudioContext(1, Math.max(1, Math.ceil(seconds * RATE)), RATE);\n const source = mix.createBufferSource();\n source.buffer = decoded;\n source.connect(mix.destination);\n source.start();\n const rendered = await mix.startRendering();\n return { base64: bytesToBase64(wavBytes(rendered.getChannelData(0), RATE)), cut: decoded.duration > AUDIO_MAX_SECONDS };\n}\nconst waitFor = (el, event, ms = 15000) => new Promise((ok, no) => {\n const timer = setTimeout(() => no(new Error("timeout")), ms);\n el.addEventListener(event, () => { clearTimeout(timer); ok(); }, { once: true });\n el.addEventListener("error", () => { clearTimeout(timer); no(new Error("video")); }, { once: true });\n});\n/** A few frames spread along the video, and its sound when there is any. No model watches a whole video. */\nasync function readVideo(file) {\n const src = URL.createObjectURL(file);\n try {\n const v = document.createElement("video");\n v.muted = true;\n v.preload = "auto";\n v.src = src;\n await waitFor(v, "loadeddata");\n // Recordings often do not say how long they are: seeking far ahead makes the browser find out.\n if (!Number.isFinite(v.duration)) { v.currentTime = 1e7; await waitFor(v, "seeked").catch(() => {}); }\n const length = Number.isFinite(v.duration) ? v.duration : 0;\n const count = length ? Math.min(VIDEO_MAX_FRAMES, Math.max(3, Math.ceil(length / 10))) : 1;\n const urls = [];\n for (let i = 0; i < count; i++) {\n if (length) { v.currentTime = (length * (i + 0.5)) / count; await waitFor(v, "seeked"); }\n urls.push(drawnJpeg(v, v.videoWidth, v.videoHeight, VIDEO_FRAME_SIDE));\n }\n const sound = await readAudio(file).catch(() => null);\n return { kind: "video", url: urls[0], frames: urls.map(dataOf), audio: sound ? sound.base64 : null, cut: !!(sound && sound.cut) };\n } finally {\n URL.revokeObjectURL(src);\n }\n}\nasync function readAttachment(file) {\n const kind = fileKind(file.name, file.type);\n if (kind === "unreadable") throw new Error(t("chat.err.type", { name: file.name }));\n if (kind === "text") {\n if (file.size > TEXT_MAX_KB * 1024) throw new Error(t("chat.err.size", { name: file.name, limit: `${TEXT_MAX_KB} KB` }));\n const bytes = new Uint8Array(await file.arrayBuffer());\n if (!looksLikeText(bytes)) throw new Error(t("chat.err.type", { name: file.name }));\n return { kind, text: new TextDecoder().decode(bytes) };\n }\n if (file.size > SOURCE_MAX_MB[kind] * 1024 * 1024) throw new Error(t("chat.err.size", { name: file.name, limit: `${SOURCE_MAX_MB[kind]} MB` }));\n try {\n if (kind === "image") return await readImage(file);\n if (kind === "video") return await readVideo(file);\n const sound = await readAudio(file);\n return { kind, ...sound };\n } catch {\n throw new Error(t("chat.err.media", { name: file.name }));\n }\n}\n/** Every way in leads here: the button, dragging and pasting. What cannot be used is said, never dropped. */\nasync function addAttachments(list) {\n $("chat-menu").hidden = true;\n attachNote = "";\n for (const file of list) {\n if (attachments.length >= MAX_ATTACHMENTS) { attachNote = t("chat.err.toomany"); break; }\n const a = { id: crypto.randomUUID(), name: file.name || "file", busy: true };\n attachments.push(a);\n renderAttachments();\n try {\n Object.assign(a, await readAttachment(file), { busy: false });\n if (a.cut) attachNote = t("chat.att.cut", { n: AUDIO_MAX_SECONDS / 60 });\n } catch (err) {\n attachments = attachments.filter((x) => x !== a);\n attachNote = err.message;\n }\n renderAttachments();\n }\n}\nfunction renderAttachments() {\n $("chat-attached").hidden = !attachments.length;\n $("chat-attached").innerHTML = attachments.map((a) => `<span class="att">${a.url && safeMedia(a.url) ? `<img src="${a.url}" alt="">` : ""}<span class="att-name">${esc(a.busy ? t("chat.att.working", { name: a.name }) : a.name)}</span>${a.kind === "video" ? `<span class="hint">${esc(t("chat.att.frames", { n: a.frames.length }))}</span>` : ""}<button class="secondary" data-att="${esc(a.id)}" aria-label="${esc(t("chat.att.remove"))}" data-tip="tip.chat.att.remove">\xD7</button></span>`).join("");\n renderChatHint();\n}\n/** The header wraps on narrow windows: the chat takes exactly what is left. */\nconst fitChat = () => document.documentElement.style.setProperty("--head", `${document.querySelector("header").offsetHeight}px`);\nwindow.addEventListener("resize", fitChat);\nfunction showView(name) {\n // Projects open in their own page, the editor: the dashboard stays as it was.\n if (name === "projects") { location.assign(`/ide#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`); return; }\n const isChat = name === "chat", isProjects = false, isPanel = !isChat;\n $("chat-view").hidden = !isChat;\n document.querySelector("main").hidden = !isPanel;\n $("live").style.display = isPanel ? "" : "none";\n $("view-chat").classList.toggle("on", isChat);\n $("view-projects").classList.toggle("on", isProjects);\n $("view-panel").classList.toggle("on", isPanel);\n try { localStorage.setItem("bitpull.view", name); } catch {}\n fitChat();\n if (isChat) {\n loadChatModels().catch((err) => { $("chat-hint").textContent = explain(err); });\n api("/api/chats").then((r) => { chatList = r.chats; renderChat(); }).catch(() => {});\n }\n}\n$("view-chat").addEventListener("click", () => showView("chat"));\n$("view-projects").addEventListener("click", () => showView("projects"));\n$("view-panel").addEventListener("click", () => showView("panel"));\n$("chat-new").addEventListener("click", () => { chat = null; $("chat-view").classList.remove("side"); renderChat(); $("chat-input").focus(); });\n$("chat-side-toggle").addEventListener("click", () => $("chat-view").classList.toggle("side"));\n$("chat-search").addEventListener("input", renderChatList);\n$("chat-more").addEventListener("click", () => { $("chat-menu").hidden = !$("chat-menu").hidden; });\n$("chat-attach").addEventListener("click", () => $("chat-file").click());\n$("chat-attached").addEventListener("click", (e) => {\n const b = e.target.closest("[data-att]");\n if (!b) return;\n attachments = attachments.filter((a) => a.id !== b.dataset.att);\n attachNote = "";\n renderAttachments();\n});\n$("chat-use").addEventListener("click", (e) => { $("chat-model").value = e.target.dataset.key; $("chat-model").dispatchEvent(new Event("change")); });\n$("chat-model").addEventListener("change", () => {\n const key = $("chat-model").value;\n try { localStorage.setItem("bitpull.chatModel", key); } catch {}\n if (chat) { chat.model = key; saveCurrentChat(); }\n attachNote = "";\n renderChatHint();\n});\n// Dropping a file anywhere in the chat attaches it, and never opens it in place of the app.\nconst chatView = $("chat-view");\nfor (const type of ["dragenter", "dragover"]) chatView.addEventListener(type, (e) => { e.preventDefault(); chatView.classList.add("dropping"); });\nchatView.addEventListener("dragleave", (e) => { if (e.target === chatView || !chatView.contains(e.relatedTarget)) chatView.classList.remove("dropping"); });\nchatView.addEventListener("drop", (e) => {\n e.preventDefault();\n chatView.classList.remove("dropping");\n if (e.dataTransfer.files.length) addAttachments([...e.dataTransfer.files]);\n});\n$("chat-input").addEventListener("paste", (e) => {\n const list = [...e.clipboardData.files];\n if (list.length) { e.preventDefault(); addAttachments(list); }\n});\nsetInterval(() => {\n if ($("chat-view").hidden || chatBusy || document.activeElement === $("chat-model")) return;\n loadChatModels().catch(() => {});\n}, MODELS_REFRESH_MS);\n$("chat-send").addEventListener("click", sendChat);\n$("chat-stop").addEventListener("click", () => {\n const busy = chatBusy;\n if (!busy) return;\n chatBusy = null;\n busy.ctrl.abort();\n // What already arrived stays: stopping the answer does not lose it.\n chat.messages.push(busy.text ? { role: "assistant", content: busy.text, modelName: selectedModel()?.name, free: true } : { role: "assistant", error: true, content: t("chat.stopped") });\n renderChat();\n saveCurrentChat();\n});\n$("chat-input").addEventListener("input", (e) => { e.target.style.height = ""; e.target.style.height = `${e.target.scrollHeight}px`; renderChatWhere(); });\n$("chat-mine").addEventListener("change", renderChatWhere);\nconst saveAiSettings = () => api("/api/assistant/settings", { enabled: $("chat-control").checked, auto: $("chat-auto").checked }).catch((err) => { $("chat-hint").textContent = explain(err); });\n$("chat-control").addEventListener("change", saveAiSettings);\n$("chat-auto").addEventListener("change", saveAiSettings);\nlet aiLogHtml = "";\nasync function refreshAiLog() {\n const a = await api("/api/assistant").catch(() => null);\n if (!a) return;\n if (document.activeElement !== $("chat-control")) $("chat-control").checked = a.enabled;\n if (document.activeElement !== $("chat-auto")) $("chat-auto").checked = a.auto;\n const html = a.entries.slice(0, 20).map((e) => `<li><span>${esc(t(`act.${e.action}`))}<br><span class="hint">${esc(new Date(e.at).toLocaleString(lang))} \xB7 ${esc(t(`ai.how.${e.how}`))} \xB7 ${esc(e.model)}${e.ok ? "" : ` \xB7 ${esc(e.error || "")}`}</span></span>${e.undoable ? `<button class="secondary" data-ai-undo="${esc(e.id)}" data-tip="tip.ai.undo">${esc(t("ai.undo"))}</button>` : ""}</li>`).join("");\n if (html !== aiLogHtml) { aiLogHtml = html; $("ai-log").innerHTML = html || `<li class="hint">${esc(t("ai.log.none"))}</li>`; }\n}\n$("ai-log").addEventListener("click", (e) => {\n const b = e.target.closest("[data-ai-undo]");\n if (b) act(b, $("ai-log-msg"), async () => { await api("/api/assistant/undo", { id: b.dataset.aiUndo }); await refreshAiLog(); await refresh(); }, t("ai.undone"));\n});\n$("chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendChat(); } });\n$("chat-file").addEventListener("change", (e) => {\n const list = [...e.target.files];\n e.target.value = "";\n addAttachments(list);\n});\n$("chat-list").addEventListener("click", async (e) => {\n const open = e.target.closest("[data-open]"), del = e.target.closest("[data-del]");\n if (open && !chatBusy) {\n chat = await api("/api/chats/get", { id: open.dataset.open });\n if (chat.system !== undefined) $("chat-system").value = chat.system;\n const known = chatModelsList.some((m) => m.key === chat.model && m.available);\n if (known) $("chat-model").value = chat.model;\n attachNote = chat.model && !known ? t("chat.model.gone") : "";\n $("chat-view").classList.remove("side");\n renderChatHint();\n renderChat();\n }\n if (del) {\n await api("/api/chats/delete", { id: del.dataset.del });\n if (chat && chat.id === del.dataset.del) chat = null;\n chatList = (await api("/api/chats")).chats;\n renderChat();\n }\n});\nasync function answerProposal(i, route, state) {\n const msg = chat.messages[i];\n msg.proposalState = "working";\n renderChat();\n try {\n await api(route, { id: msg.proposal.id });\n msg.proposalState = state;\n } catch (err) {\n msg.proposalState = /proposal_gone/.test(String(err.message)) ? "expired" : "failed";\n }\n renderChat();\n await saveCurrentChat();\n refreshAiLog();\n refresh();\n}\n$("chat-log").addEventListener("click", (e) => {\n const yes = e.target.closest("[data-ai-yes]"), no = e.target.closest("[data-ai-no]");\n if (yes) answerProposal(Number(yes.dataset.aiYes), "/api/assistant/confirm", "done");\n if (no) answerProposal(Number(no.dataset.aiNo), "/api/assistant/reject", "refused");\n const copy = e.target.closest("[data-copy]"), retry = e.target.closest("[data-retry]");\n if (copy) copyText(chat.messages[Number(copy.dataset.copy)].content);\n if (retry && !chatBusy) {\n chat.messages.pop();\n askChat(lastSent && lastSent.chatId === chat.id ? lastSent.plan : attachmentPlan(selectedModel(), []));\n }\n});\n\n// ---- explanations on hover: after a short pause over a button, say what it does ----------\nconst tipBox = document.createElement("div");\ntipBox.id = "tip";\ntipBox.setAttribute("role", "tooltip");\ntipBox.hidden = true;\ndocument.body.appendChild(tipBox);\nlet tipTimer = null;\nconst TIP_DELAY_MS = 800;\nfunction showTip(el) {\n let vars = {};\n try { vars = JSON.parse(el.dataset.tipVars || "{}"); } catch {}\n tipBox.textContent = t(el.dataset.tip, vars);\n tipBox.hidden = false;\n const r = el.getBoundingClientRect(), w = tipBox.offsetWidth, h = tipBox.offsetHeight;\n const left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), window.innerWidth - w - 8);\n const top = r.bottom + 8 + h > window.innerHeight ? r.top - h - 8 : r.bottom + 8;\n tipBox.style.left = `${left}px`;\n tipBox.style.top = `${Math.max(8, top)}px`;\n}\nfunction hideTip() { clearTimeout(tipTimer); tipBox.hidden = true; }\nconst scheduleTip = (e) => {\n const el = e.target.closest && e.target.closest("[data-tip]");\n if (!el) return;\n clearTimeout(tipTimer);\n tipTimer = setTimeout(() => showTip(el), TIP_DELAY_MS);\n};\ndocument.addEventListener("mouseover", scheduleTip);\ndocument.addEventListener("focusin", scheduleTip);\ndocument.addEventListener("mouseout", (e) => { if (e.target.closest && e.target.closest("[data-tip]")) hideTip(); });\ndocument.addEventListener("focusout", hideTip);\ndocument.addEventListener("mousedown", hideTip);\ndocument.addEventListener("keydown", (e) => { if (e.key === "Escape") hideTip(); });\nwindow.addEventListener("scroll", hideTip, { passive: true });\n\n// ---- start --------------------------------------------------------------------------------\n(async () => {\n if (!token) { await loadLang(langFromApp || "en"); $("locked").hidden = false; return; }\n let saved = null;\n try { saved = (await api("/api/language")).language; } catch {}\n const browser = (navigator.languages || [navigator.language]).map((l) => l.slice(0, 2)).find((l) => LANGS[l]);\n await loadLang(langFromApp || saved || browser || "en", !!langFromApp && langFromApp !== saved);\n refresh();\n refreshAiLog();\n setInterval(refreshAiLog, 15000);\n refreshUpdate();\n refreshTools();\n refreshRemote();\n setInterval(() => { if (!document.hidden && !$("remote-code").hidden) refreshRemote(); }, 4000);\n let view = "panel";\n try { view = localStorage.getItem("bitpull.view") || "panel"; } catch {}\n if (view === "projects") view = "panel";\n showView(view);\n // A hidden window asks nothing: it updates as soon as it is seen again.\n setInterval(() => { if (!document.hidden) refresh(); }, 5000);\n document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });\n setInterval(refreshSetup, 20000);\n setInterval(refreshUpdate, 60000);\n})();\n</script>\n</body>\n</html>\n';
|
|
26119
|
+
if (true) return '<!doctype html>\n<html lang="en" translate="no">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>BitPull</title>\n<style>\n :root { --bg:#fafaf7; --surface:#fff; --text:#1d1d1b; --muted:#6b6b66; --line:#e4e2dc; --accent:#2f5bd3; --accent-ink:#fff; --good:#1f7a4d; --warn:#9a6700; --bad:#b3261e; }\n @media (prefers-color-scheme: dark) { :root { --bg:#151514; --surface:#1e1e1c; --text:#ecebe6; --muted:#a2a19b; --line:#33322f; --accent:#7c9cf2; --accent-ink:#10131c; --good:#5cc28f; --warn:#e3b341; --bad:#f2867c; } }\n * { box-sizing: border-box; }\n body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 system-ui,-apple-system,"Segoe UI","Noto Sans",sans-serif; }\n header { min-width:0; display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:16px; border-bottom:1px solid var(--line); background:var(--surface); }\n header h1 { font-size:18px; margin:0; }\n .head-right { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }\n .pills { display:flex; flex-wrap:wrap; gap:8px; }\n .pill { border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:13px; color:var(--muted); }\n .pill.good { color:var(--good); border-color:currentColor; } .pill.warn { color:var(--warn); border-color:currentColor; } .pill.bad { color:var(--bad); border-color:currentColor; }\n main { max-width:1100px; margin:0 auto; padding:16px; display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); }\n section { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:16px; min-width:0; }\n section h2 { font-size:16px; margin:0 0 4px; } section p.hint { margin:0 0 12px; color:var(--muted); font-size:13px; }\n .stat { font-size:28px; font-weight:700; }\n label { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:6px 0 2px; }\n .field { border-bottom:1px solid var(--line); padding-bottom:6px; }\n .field p.help { margin:0; color:var(--muted); font-size:12.5px; line-height:1.45; }\n .check-row { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; padding:10px 0; border-bottom:1px solid var(--line); }\n .check-row .status { font-size:13px; color:var(--muted); }\n .ok { color:var(--good); font-weight:600; } .missing { color:var(--warn); font-weight:600; }\n progress { width:100%; height:8px; }\n input[type=number], input[type=text], select { width:110px; padding:6px 8px; border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--text); font:inherit; }\n select#lang { width:auto; }\n input.wide { width:100%; }\n button { background:var(--accent); color:var(--accent-ink); border:0; border-radius:8px; padding:8px 12px; font:inherit; font-weight:600; cursor:pointer; }\n button.secondary { background:transparent; color:var(--text); border:1px solid var(--line); }\n button:disabled { opacity:.6; cursor:progress; }\n button.danger { background:var(--bad); color:#fff; }\n .row { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }\n ul.list { list-style:none; padding:0; margin:8px 0 0; }\n ul.list li { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 0; border-bottom:1px solid var(--line); font-size:14px; }\n ul.list li span.path { overflow-wrap:anywhere; }\n #ai-limits { overflow-x:auto; } table.limits { border-collapse:collapse; } table.limits td, table.limits th { padding:4px 8px; text-align:start; }\n code { font:13px ui-monospace,Menlo,monospace; overflow-wrap:anywhere; direction:ltr; unicode-bidi:embed; }\n textarea { width:100%; min-height:70px; padding:8px; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--text); font:inherit; }\n pre { white-space:pre-wrap; background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px; margin:8px 0 0; max-height:260px; overflow:auto; font-size:13px; }\n .msg { min-height:1.4em; font-size:13px; margin-top:6px; } .msg.error { color:var(--bad); } .msg.ok { color:var(--good); }\n .full { grid-column:1/-1; }\n .live { display:flex; flex-wrap:wrap; gap:4px 14px; align-items:center; padding:8px 16px; font-size:13px; color:var(--muted); border-bottom:1px solid var(--line); background:var(--surface); }\n .live strong { color:var(--text); } .live .dot { width:8px; height:8px; border-radius:50%; background:var(--good); }\n .plans { display:grid; gap:10px; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); margin-top:10px; }\n .plan { border:1px solid var(--line); border-radius:10px; padding:10px; font-size:13px; display:flex; flex-direction:column; }\n .plan.current { border-color:var(--accent); }\n .plan h3 { font-size:14px; margin:0; } .plan .price { font-size:18px; font-weight:700; margin:4px 0; }\n .plan ul { padding-inline-start:16px; margin:4px 0 8px; color:var(--muted); flex:1; } .plan button { width:100%; }\n .badge { font-size:11px; padding:1px 7px; border-radius:99px; background:var(--accent); color:var(--accent-ink); margin-inline-start:6px; }\n .summary { border:1px solid var(--accent); border-radius:10px; padding:10px 12px; margin-top:10px; font-size:13px; }\n .summary h3 { margin:2px 0; font-size:15px; } .summary ul { padding-inline-start:16px; margin:4px 0; }\n .account-row { display:flex; flex-wrap:wrap; justify-content:space-between; gap:8px; align-items:center; margin-top:12px; padding-top:10px; border-top:1px solid var(--line); font-size:13px; color:var(--muted); }\n #locked { max-width:520px; margin:80px auto; padding:0 16px; text-align:center; }\n .views { display:flex; gap:4px; } .views button { background:transparent; color:var(--muted); padding:5px 12px; } .views button.on { background:var(--bg); color:var(--text); border:1px solid var(--line); }\n #chat-view { display:grid; grid-template-columns:270px minmax(0,1fr); height:calc(100vh - var(--head,67px)); }\n #chat-side { border-inline-end:1px solid var(--line); background:var(--surface); display:flex; flex-direction:column; gap:8px; padding:12px; min-height:0; }\n #chat-side button.new { width:100%; text-align:start; }\n #chat-search { width:100%; }\n #chat-list { list-style:none; margin:0; padding:0; overflow:auto; flex:1; min-height:0; }\n #chat-list li { display:flex; align-items:center; gap:4px; border-radius:8px; }\n #chat-list li.on, #chat-list li:hover { background:var(--bg); }\n #chat-list button.open { flex:1; min-width:0; background:transparent; color:var(--text); font-weight:400; text-align:start; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:7px 8px; }\n #chat-list button.del { background:transparent; color:var(--muted); padding:2px 8px; visibility:hidden; } #chat-list li:hover button.del, #chat-list li.on button.del, #chat-list button.del:focus { visibility:visible; }\n #chat-main { display:flex; flex-direction:column; min-width:0; min-height:0; }\n #chat-top { display:none; padding:8px 12px; border-bottom:1px solid var(--line); }\n #chat-scroll { flex:1; overflow:auto; min-height:0; padding:16px; }\n #chat-log { max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:18px; }\n #chat-hello { max-width:760px; margin:18vh auto 0; text-align:center; } #chat-hello h2 { font-size:28px; font-weight:600; margin:0 0 6px; } #chat-hello p { color:var(--muted); margin:0; }\n .m-user { align-self:flex-end; max-width:85%; background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:8px 14px; white-space:pre-wrap; overflow-wrap:anywhere; }\n .m-ai { overflow-wrap:anywhere; line-height:1.6; } .m-ai.error { color:var(--bad); }\n .m-ai h3,.m-ai h4 { margin:12px 0 4px; } .m-ai p { margin:8px 0; } .m-ai ul,.m-ai ol { margin:8px 0; padding-inline-start:22px; }\n .m-ai img { max-width:100%; border-radius:10px; display:block; margin-top:8px; } .m-ai audio { width:100%; margin-top:8px; } .m-user img { max-width:220px; border-radius:8px; display:block; }\n .m-ai pre { max-height:none; position:relative; direction:ltr; } .m-ai details { color:var(--muted); font-size:13px; margin:6px 0; }\n .m-foot { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:6px; font-size:12.5px; color:var(--muted); } .m-foot button { background:transparent; color:var(--muted); border:1px solid var(--line); padding:2px 9px; font-size:12.5px; font-weight:500; }\n #chat-box { max-width:760px; width:100%; margin:0 auto 14px; padding:0 16px; position:relative; }\n #chat-composer { border:1px solid var(--line); border-radius:18px; background:var(--surface); padding:10px 12px; }\n #chat-input { border:0; background:transparent; min-height:44px; max-height:220px; resize:none; padding:4px; outline:none; display:block; }\n #chat-tools { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } #chat-tools .grow { flex:1; }\n #chat-tools button.icon { width:34px; height:34px; padding:0; border-radius:10px; font-size:18px; line-height:1; }\n select#chat-model { width:auto; max-width:230px; border:0; background:transparent; }\n #chat-menu { position:absolute; bottom:calc(100% + 6px); inset-inline-start:16px; width:min(340px,calc(100% - 32px)); background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:10px 12px; box-shadow:0 8px 28px rgba(0,0,0,.25); z-index:5; }\n #chat-menu label { font-size:14px; } #chat-menu textarea { min-height:64px; font-size:13px; } #chat-menu input[type=range] { width:120px; }\n #chat-menu button.line { width:100%; text-align:start; background:transparent; color:var(--text); font-weight:500; padding:6px 0; }\n #chat-attached { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }\n .att { display:inline-flex; align-items:center; gap:6px; max-width:260px; font-size:12.5px; border:1px solid var(--line); border-radius:8px; padding:3px 6px; color:var(--muted); }\n .att img { width:32px; height:32px; object-fit:cover; border-radius:5px; } .att-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .att button { padding:0 7px; font-size:13px; }\n #chat-view.dropping #chat-composer { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }\n #chat-where { text-align:center; color:var(--muted); font-size:12.5px; min-height:1.3em; }\n #chat-hint { text-align:center; color:var(--muted); font-size:12.5px; min-height:1.3em; margin-top:6px; }\n @media (max-width:720px) { #chat-view { grid-template-columns:minmax(0,1fr); } #chat-side { display:none; } #chat-view.side #chat-side { display:flex; } #chat-view.side #chat-main { display:none; } #chat-top { display:block; } }\n [hidden] { display:none !important; }\n #tip { position:fixed; z-index:50; max-width:320px; padding:8px 10px; border-radius:8px; background:var(--text); color:var(--bg); font-size:13px; line-height:1.45; box-shadow:0 4px 16px rgba(0,0,0,.18); pointer-events:none; }\n</style>\n</head>\n<body>\n<div id="locked" hidden>\n <h1>BitPull</h1>\n <p data-i18n="locked.text"></p>\n</div>\n<div id="loggedout" hidden>\n <h1>BitPull</h1>\n <p data-i18n="logout.done"></p>\n <p><code>bitpull login --device</code></p>\n</div>\n<div id="app" hidden>\n<header>\n <h1 data-i18n="app.title"></h1>\n <nav class="views"><button id="view-chat" data-i18n="view.chat" data-tip="tip.view.chat"></button><button id="view-projects" data-i18n="view.projects" data-tip="tip.view.projects"></button><button id="view-panel" data-i18n="view.panel" data-tip="tip.view.panel"></button></nav>\n <div class="head-right">\n <div class="pills" id="pills"></div>\n <select id="lang" aria-label="Language" data-tip="tip.lang"></select>\n </div>\n</header>\n<div class="live" id="live" hidden></div>\n<div id="chat-view" hidden>\n <aside id="chat-side">\n <button class="new" id="chat-new" data-i18n="chat.new" data-tip="tip.chat.new"></button>\n <input type="text" id="chat-search" data-i18n-placeholder="chat.search">\n <ul id="chat-list"></ul>\n </aside>\n <div id="chat-main">\n <div id="chat-top"><button class="secondary" id="chat-side-toggle" data-i18n="chat.history" data-tip="tip.chat.history"></button></div>\n <div id="chat-scroll">\n <div id="chat-hello"><h2 data-i18n="chat.hello"></h2><p id="chat-hello-hint"></p></div>\n <div id="chat-log"></div>\n </div>\n <div id="chat-box">\n <div id="chat-menu" hidden>\n <button class="line" id="chat-attach" data-i18n="chat.attach" data-tip="tip.chat.attach"></button>\n <input type="file" id="chat-file" multiple hidden>\n <label data-tip="tip.ask.mine"><span data-i18n="ask.mine"></span> <input type="checkbox" id="chat-mine"></label>\n <label data-tip="tip.ai.control"><span data-i18n="ai.control"></span> <input type="checkbox" id="chat-control" checked></label>\n <label data-tip="tip.ai.auto"><span data-i18n="ai.auto"></span> <input type="checkbox" id="chat-auto"></label>\n <label data-tip="tip.chat.temp"><span data-i18n="chat.temp"></span> <input type="range" id="chat-temp" min="0" max="1.5" step="0.1" value="0.7"></label>\n <label for="chat-system" data-tip="tip.chat.system"><span data-i18n="chat.system"></span></label>\n <textarea id="chat-system" data-i18n-placeholder="chat.system.placeholder"></textarea>\n </div>\n <div id="chat-composer">\n <div id="chat-attached" hidden></div>\n <textarea id="chat-input" rows="1" data-i18n-placeholder="chat.placeholder"></textarea>\n <div id="chat-tools">\n <button class="secondary icon" id="chat-more" aria-label="+" data-tip="tip.chat.more">+</button>\n <span class="grow"></span>\n <select id="chat-model" data-tip="tip.chat.model"></select>\n <button id="chat-send" data-i18n="chat.send" data-tip="tip.chat.send"></button>\n <button class="secondary" id="chat-stop" data-i18n="chat.stop" data-tip="tip.chat.stop" hidden></button>\n </div>\n </div>\n <div id="chat-hint"></div>\n <div class="row" style="justify-content:center;margin-top:4px"><button class="secondary" id="chat-use" data-tip="tip.chat.use" hidden></button></div>\n <div id="chat-where"></div>\n </div>\n </div>\n</div>\n<main>\n <section>\n <h2 data-i18n="credits.title"></h2>\n <div class="stat" id="balance">\u2013</div>\n <p class="hint" id="plan"></p>\n <div class="plans" id="plan-cards"></div>\n <div class="summary" id="buy-summary" hidden></div>\n <div class="msg" id="buy-msg"></div>\n <div class="account-row">\n <span id="account-line"></span>\n <button class="secondary" id="logout" data-i18n="logout.button" data-tip="tip.logout"></button>\n </div>\n <div class="summary" id="logout-confirm" hidden>\n <p id="logout-text"></p>\n <div class="row">\n <button class="danger" id="logout-yes" data-i18n="logout.yes" data-tip="tip.logout.yes"></button>\n <button class="secondary" id="logout-no" data-i18n="plans.cancel" data-tip="tip.logout.no"></button>\n </div>\n </div>\n </section>\n\n <section>\n <h2 data-i18n="lend.title"></h2>\n <p class="hint" data-i18n="lend.hint"></p>\n <div class="stat" id="worker-state">\u2013</div>\n <p class="hint" id="worker-detail"></p>\n <p class="status" id="worker-visible"></p>\n <div class="row"><button id="pause" class="danger"></button></div>\n <div class="msg" id="pause-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="mynet.title"></h2>\n <p class="hint" data-i18n="mynet.hint"></p>\n <ul class="list" id="mynet"></ul>\n <div class="msg" id="mynet-msg"></div>\n <h3 data-i18n="mygroup.title"></h3>\n <p class="hint" data-i18n="mygroup.hint"></p>\n <p class="hint" id="group-fit"></p>\n <ul class="list" id="group-plan"></ul>\n <div class="row" id="group-start"><select id="group-model" style="width:auto;max-width:100%" data-tip="tip.mygroup.model"></select><button id="group-go" data-i18n="mygroup.start" data-tip="tip.mygroup.start"></button></div>\n <details id="group-custom"><summary data-i18n="mygroup.custom"></summary>\n <p class="hint" data-i18n="mygroup.custom.hint"></p>\n <div class="row"><input class="wide" type="text" id="hub-query" data-i18n-placeholder="mygroup.custom.search" style="flex:1"><button class="secondary" id="hub-find" data-i18n="mygroup.custom.find" data-tip="tip.mygroup.custom.find"></button></div>\n <div class="row" id="hub-pick" hidden><select id="hub-repo" style="width:auto;max-width:100%" data-tip="tip.mygroup.custom.repo"></select><select id="hub-file" style="width:auto;max-width:100%" data-tip="tip.mygroup.custom.file"></select><button id="hub-use" data-i18n="mygroup.start" data-tip="tip.mygroup.custom.use"></button></div>\n </details>\n <ul class="list" id="group-status"></ul>\n <p class="hint" id="group-traffic" hidden></p>\n <p class="hint" id="group-speed" hidden></p>\n <h3 data-i18n="mygroup.folders"></h3>\n <p class="hint" data-i18n="mygroup.folders.hint"></p>\n <div class="row"><input class="wide" type="text" id="model-folder" data-i18n-placeholder="folders.placeholder" style="flex:1"><button class="secondary" id="model-folder-add" data-i18n="mygroup.folders.add" data-tip="tip.mygroup.folders.add"></button></div>\n <ul class="list" id="model-folders"></ul>\n <div id="group-ask" hidden>\n <div class="row"><button id="group-send" data-i18n="chat.open" data-tip="tip.chat.group"></button><button class="secondary" id="group-stop" data-i18n="mygroup.stop" data-tip="tip.mygroup.stop"></button></div>\n <label data-tip="tip.mygroup.share"><span data-i18n="mygroup.share"></span> <input type="checkbox" id="group-share"></label>\n <p class="hint" data-i18n="mygroup.share.hint"></p>\n <div class="summary" id="group-share-confirm" hidden>\n <p data-i18n="mygroup.share.confirm"></p>\n <div class="row"><button id="group-share-yes" data-i18n="mygroup.share.yes" data-tip="tip.mygroup.share.yes"></button><button class="secondary" id="group-share-no" data-i18n="plans.cancel" data-tip="tip.mygroup.share.no"></button></div>\n </div>\n </div>\n <div class="msg" id="group-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="setup.title"></h2>\n <p class="hint" data-i18n="setup.hint"></p>\n <div id="setup"><p class="hint" data-i18n="setup.checking"></p></div>\n <div class="msg" id="setup-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="models.title"></h2>\n <p class="hint" data-i18n="models.hint"></p>\n <div id="ai-models"><p class="hint" data-i18n="setup.checking"></p></div>\n <h3 data-i18n="limits.title"></h3>\n <p class="hint" data-i18n="limits.hint"></p>\n <div id="ai-limits"></div>\n <div class="row"><button id="save-limits" data-i18n="limits.save" data-tip="tip.limits.save"></button></div>\n <div class="msg" id="models-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="rules.title"></h2>\n <p class="help" id="rules-machine"></p>\n <p class="help"><strong data-i18n="sec.del.title"></strong> <span data-i18n="sec.del.lender"></span></p>\n <form id="rules">\n <div class="field"><label><span data-i18n="rules.enabled"></span> <input type="checkbox" name="enabled"></label><p class="help" data-i18n="rules.enabled.help"></p></div>\n <div class="field"><label><span data-i18n="rules.cpus"></span> <input type="number" name="maxCpus" min="0.25" step="0.25"></label><p class="help" data-i18n="rules.cpus.help"></p><p class="help" id="rules-cpus-max"></p></div>\n <div class="field"><label><span data-i18n="rules.memory"></span> <input type="number" name="maxMemoryMb" min="256" step="256"></label><p class="help" data-i18n="rules.memory.help"></p><p class="help" id="rules-memory-max"></p></div>\n <div class="field"><label><span data-i18n="rules.disk"></span> <input type="number" name="maxDiskGb" min="1" step="1"></label><p class="help" data-i18n="rules.disk.help"></p><p class="help" id="rules-disk-max"></p></div>\n <div class="field"><label><span data-i18n="rules.gpu"></span> <select name="gpu"><option value="none" data-i18n="rules.gpu.none"></option><option value="consumer" data-i18n="rules.gpu.consumer"></option><option value="high" data-i18n="rules.gpu.high"></option><option value="datacenter" data-i18n="rules.gpu.datacenter"></option></select></label><p class="help" data-i18n="rules.gpu.help"></p><p class="help" id="rules-gpu-found"></p></div>\n <div class="field"><label><span data-i18n="rules.containers"></span> <input type="checkbox" name="containers"></label><p class="help" data-i18n="rules.containers.help"></p></div>\n <div class="field"><label><span data-i18n="rules.models"></span> <input type="checkbox" name="serveModels"></label><p class="help" data-i18n="rules.models.help"></p></div>\n <div class="field"><label><span data-i18n="rules.idle"></span> <input type="number" name="idleMinutes" min="0"></label><p class="help" data-i18n="rules.idle.help"></p></div>\n <div class="field"><label><span data-i18n="rules.ac"></span> <input type="checkbox" name="onlyOnAc"></label><p class="help" data-i18n="rules.ac.help"></p></div>\n <div class="field"><label><span data-i18n="rules.awake"></span> <input type="checkbox" name="keepAwake"></label><p class="help" data-i18n="rules.awake.help"></p></div>\n <div class="field"><label><span data-i18n="rules.hours"></span> <span><input type="number" name="hourFrom" min="0" max="23" style="width:56px"> <input type="number" name="hourTo" min="0" max="24" style="width:56px"></span></label><p class="help" data-i18n="rules.hours.help"></p></div>\n <div class="field"><label><span data-i18n="rules.cache"></span> <input type="number" name="cacheMax" min="0"></label><p class="help" data-i18n="rules.cache.help"></p></div>\n <div class="row"><button type="submit" data-i18n="rules.save" data-tip="tip.rules.save"></button></div>\n <div class="msg" id="rules-msg"></div>\n </form>\n </section>\n\n <section>\n <h2 data-i18n="folders.title"></h2>\n <p class="hint" data-i18n="folders.hint"></p>\n <div class="row"><input class="wide" type="text" id="folder-path" data-i18n-placeholder="folders.placeholder"><button id="folder-add" data-i18n="folders.add" data-tip="tip.folder.add"></button></div>\n <ul class="list" id="folders"></ul>\n <div class="msg" id="folder-msg"></div>\n </section>\n\n <section id="remote-section">\n <h2 data-i18n="remote.title"></h2>\n <p class="hint" data-i18n="remote.help"></p>\n <label data-tip="tip.remote.enabled"><span data-i18n="remote.enabled"></span> <input type="checkbox" id="remote-enabled"></label>\n <div class="row" style="margin-top:8px"><button id="remote-add" data-i18n="remote.add" data-tip="tip.remote.add"></button></div>\n <div id="remote-code" hidden><div id="remote-qr" style="max-width:220px;background:#fff;padding:6px;border-radius:8px;margin:8px 0"></div><p class="hint" id="remote-scan"></p><code id="remote-link" style="word-break:break-all"></code></div>\n <ul class="list" id="remote-phones"></ul>\n <div class="msg" id="remote-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="repos.title"></h2>\n <p class="hint" data-i18n="repos.hint"></p>\n <ul class="list" id="repos"></ul>\n <div class="msg" id="repos-msg"></div>\n </section>\n\n <section>\n <h2 data-i18n="ask.title"></h2>\n <p class="hint" id="models-hint"></p>\n <div class="row">\n <button id="ask" data-i18n="chat.open" data-tip="tip.chat.go"></button>\n <label data-tip="tip.wait"><span data-i18n="ask.wait"></span> <input type="number" id="wait-minutes" min="1" max="120" step="1" style="width:70px"></label>\n <button class="secondary" id="save-wait" data-i18n="ask.wait.save" data-tip="tip.wait"></button>\n </div>\n <div class="msg" id="wait-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="tools.title"></h2>\n <p class="hint" data-i18n="tools.hint"></p>\n <div id="tools"></div>\n <div class="msg" id="tools-msg"></div>\n <p class="hint" style="margin-top:12px"><span data-i18n="tools.openai"></span> <code id="oai-url"></code> \xB7 <span data-i18n="tools.key"></span> <code id="oai-key">\u2022\u2022\u2022\u2022\u2022\u2022</code> <button class="secondary" id="show-key" data-i18n="tools.show" data-tip="tip.show_key"></button></p>\n </section>\n\n <section class="full">\n <h2 data-i18n="update.title"></h2>\n <p class="hint" id="update-info"></p>\n <div class="row"><button id="update-btn" data-i18n="update.button" data-tip="tip.update"></button></div>\n <div class="msg" id="update-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="ai.log.title"></h2>\n <p class="hint" data-i18n="ai.log.hint"></p>\n <ul class="list" id="ai-log"></ul>\n <div class="msg" id="ai-log-msg"></div>\n </section>\n\n <section class="full">\n <h2 data-i18n="jobs.title"></h2>\n <p class="help"><strong data-i18n="sec.del.title"></strong> <span data-i18n="sec.del.text"></span></p>\n <p class="help" id="file-limits" hidden></p>\n <ul class="list" id="jobs"></ul>\n </section>\n</main>\n</div>\n<script>\nconst LANGS = { en: "English", zh: "\u4E2D\u6587", hi: "\u0939\u093F\u0928\u094D\u0926\u0940", es: "Espa\xF1ol", ar: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", fr: "Fran\xE7ais", bn: "\u09AC\u09BE\u0982\u09B2\u09BE", pt: "Portugu\xEAs", ru: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439", id: "Bahasa Indonesia", de: "Deutsch", it: "Italiano" };\nconst $ = (id) => document.getElementById(id);\nconst esc = (s) => String(s ?? "").replace(/[&<>"\']/g, (c) => ({ "&":"&","<":"<",">":">",\'"\':""","\'":"'" })[c]);\n\nconst params = new URLSearchParams(location.hash.slice(1));\nlet token = params.get("token");\nconst langFromApp = params.get("lang");\n// The app\'s own login page, to go back to after logging out (only the app\'s own addresses).\nlet backTo = params.get("back");\ntry { if (token) sessionStorage.setItem("bp_token", token); else token = sessionStorage.getItem("bp_token"); } catch {}\ntry { if (backTo) sessionStorage.setItem("bp_back", backTo); else backTo = sessionStorage.getItem("bp_back"); } catch {}\nif (!/^(tauri:\\/\\/localhost|https?:\\/\\/tauri\\.localhost)$/.test(backTo || "")) backTo = null;\nif (location.hash) history.replaceState(null, "", location.pathname);\n\n// ---- languages ------------------------------------------------------------------------\nlet lang = "en", dict = {}, fallback = {};\nconst t = (key, vars = {}) => String(dict[key] ?? fallback[key] ?? key).replace(/\\{(\\w+)\\}/g, (_, k) => (vars[k] ?? `{${k}}`));\nconst fmt = (n) => Number(n).toLocaleString(lang, { maximumFractionDigits: 2 });\n\nasync function loadLang(l, save) {\n lang = LANGS[l] ? l : "en";\n if (!Object.keys(fallback).length) fallback = await (await fetch("/i18n/en.json")).json();\n dict = lang === "en" ? fallback : await fetch(`/i18n/${lang}.json`).then((r) => (r.ok ? r.json() : fallback)).catch(() => fallback);\n document.documentElement.lang = lang;\n document.documentElement.dir = lang === "ar" ? "rtl" : "ltr";\n document.querySelectorAll("[data-i18n]").forEach((e) => (e.textContent = t(e.dataset.i18n)));\n document.querySelectorAll("[data-i18n-placeholder]").forEach((e) => (e.placeholder = t(e.dataset.i18nPlaceholder)));\n $("lang").value = lang;\n if (save) api("/api/language", { language: lang }).catch(() => {});\n if (state) render();\n refreshSetup();\n if (update) refreshUpdate();\n if (tools.length) refreshTools();\n}\n$("lang").innerHTML = Object.entries(LANGS).map(([k, v]) => `<option value="${k}">${v}</option>`).join("");\n$("lang").addEventListener("change", (e) => loadLang(e.target.value, true));\n\nasync function api(path, body) {\n const res = await fetch(path, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-bitpull-token": token || "" }, body: body ? JSON.stringify(body) : undefined });\n const data = await res.json().catch(() => ({}));\n if (!res.ok) throw new Error(data.error || res.status);\n return data;\n}\n\n// ---- status -----------------------------------------------------------------------------\nlet state = null;\n/** The user changed the lending rules and has not saved them yet. */\nlet rulesDirty = false;\nconst fmtBytes = (b) => (b >= 1e9 ? `${fmt(Math.round((b / 1e9) * 10) / 10)} GB` : `${fmt(Math.max(1, Math.round(b / 1e6)))} MB`);\n\n/** What this computer really has, next to the rules: nobody lends more than exists. */\nfunction renderMachine(m) {\n if (!m) return;\n const gb = (mb) => fmt(Math.round((mb / 1024) * 10) / 10);\n const gpuText = !m.gpuKnown ? t("rules.machine.gpu_unknown") : m.gpus.length ? m.gpus.map((g) => g.name + (g.memoryMb ? ` ${gb(g.memoryMb)} GB` : "") + (g.cores ? ` \xB7 ${t("rules.machine.cores", { n: g.cores })}` : "")).join(", ") : t("rules.machine.gpu_none");\n const disk = m.freeDiskBytes == null ? "?" : fmt(Math.floor(m.freeDiskBytes / 1024 ** 3));\n $("rules-machine").textContent = t("rules.machine", { cpus: m.cpus, cpu: m.cpuModel || "CPU", ram: gb(m.memoryMb), gpu: gpuText, disk });\n const f = $("rules").elements;\n f.maxCpus.max = m.cpus;\n f.maxMemoryMb.max = m.lendableMemoryMb;\n $("rules-cpus-max").textContent = t("rules.cpus.max", { n: m.cpus });\n $("rules-memory-max").textContent = t("rules.memory.max", { n: fmt(m.lendableMemoryMb) });\n const room = m.freeDiskBytes == null ? null : Math.max(0, Math.floor(m.freeDiskBytes / 1024 ** 3) - 5);\n if (room !== null) f.maxDiskGb.max = Math.max(1, room);\n $("rules-disk-max").textContent = room === null ? "" : t("rules.disk.max", { n: fmt(room) });\n $("rules-gpu-found").textContent = m.gpuKnown ? t("rules.gpu.found", { cls: t(`rules.gpu.${m.gpuClass}`) }) : "";\n}\n\n// ---- the person\'s phones as screen and keyboard of this computer ----------------------------------\n// Shown on a phone too, but changed only here: the computer refuses these settings from a phone.\nconst onPhone = token === "phone";\nif (onPhone) $("remote-section").hidden = true;\nfunction renderRemote(r) {\n $("remote-enabled").checked = !!r.enabled;\n $("remote-add").disabled = !r.enabled;\n $("remote-phones").innerHTML = r.phones.length\n ? r.phones.map((p) => `<li><span>${esc(p.name)} <span class="hint">\xB7 ${esc(t("remote.added", { date: new Date(p.added).toLocaleDateString(lang) }))}${r.connected === p.id ? ` \xB7 ${esc(t("remote.connected"))}` : ""}</span></span><button class="secondary" data-unphone="${esc(p.id)}" data-tip="tip.remote.remove">${esc(t("remote.remove"))}</button></li>`).join("")\n : `<li class="hint">${esc(t("remote.none"))}</li>`;\n}\nasync function refreshRemote() {\n if (onPhone) return;\n try { renderRemote(await api("/api/remote")); } catch {}\n}\n$("remote-enabled").addEventListener("change", async (e) => {\n try { renderRemote(await api("/api/remote/settings", { enabled: e.target.checked })); if (!e.target.checked) $("remote-code").hidden = true; }\n catch (err) { e.target.checked = !e.target.checked; $("remote-msg").className = "msg error"; $("remote-msg").textContent = err.message; }\n});\n$("remote-add").addEventListener("click", async () => {\n try {\n const r = await api("/api/remote/pair", { name: "" });\n $("remote-qr").innerHTML = r.svg;\n $("remote-scan").textContent = t("remote.scan", { minutes: r.minutes });\n $("remote-link").textContent = r.link;\n $("remote-code").hidden = false;\n } catch (err) { $("remote-msg").className = "msg error"; $("remote-msg").textContent = err.message; }\n});\n$("remote-phones").addEventListener("click", async (e) => {\n const id = e.target.dataset && e.target.dataset.unphone;\n if (!id) return;\n try { renderRemote(await api("/api/remote/remove", { id })); } catch {}\n});\n\nasync function refresh() {\n try { state = await api("/api/status"); } catch (e) { if (String(e.message) === "unauthorized") { $("app").hidden = true; $("locked").hidden = false; } return; }\n $("app").hidden = false; $("locked").hidden = true;\n render();\n}\n\nfunction render() {\n const s = state, n = s.node;\n const pills = [\n `<span class="pill ${n.online ? "good" : "bad"}">${esc(t(n.online ? "pill.online" : "pill.offline"))}</span>`,\n `<span class="pill ${n.relay === "disconnected" ? "warn" : "good"}">${esc(t(n.relay === "direct" ? "pill.direct" : n.relay === "connected" ? "pill.relay_on" : "pill.relay_off"))}</span>`,\n `<span class="pill">${esc(t("pill.cached", { n: n.cached }))}</span>`,\n ];\n if (s.me) pills.unshift(`<span class="pill">${esc(s.me.email)}</span>`);\n $("pills").innerHTML = pills.join("");\n const net = s.network;\n if (net) {\n $("live").hidden = false;\n $("live").innerHTML = `<span class="dot"></span><strong>${esc(t("net.title"))}</strong>` +\n [["net.online", net.computersOnline], ["net.lending", net.computersLending], ["net.cpu", net.cpus], ["net.ram", net.memoryGb], ["net.gpu", net.gpus.total]]\n .map(([k, v]) => `<span>${esc(t(k, { n: fmt(v) }))}</span>`).join("") +\n `<span title="${esc(net.models.join(", "))}">${esc(t("net.models", { n: fmt(net.models.length) }))}</span><span>${esc(t("net.jobs", { n: fmt(net.jobsRunning) }))}</span>`;\n }\n if (s.me) {\n $("balance").textContent = fmt(s.me.balance);\n $("plan").textContent = t("credits.plan.name", { plan: t(`plan.${s.me.plan}`) }) + (s.me.heldCredits > 0 ? ` \xB7 ${t("credits.held", { n: fmt(s.me.heldCredits) })}` : "");\n const via = (s.me.logins || []).map((m) => (m === "password" ? t("account.via.email") : m === "github" ? "GitHub" : "Google")).join(", ");\n $("account-line").textContent = t("account.line", { email: s.me.email }) + (via ? ` \xB7 ${via}` : "");\n renderPlans(s.me.plan);\n }\n const w = n.worker;\n $("worker-state").textContent = t(`lend.state.${w.state}`);\n $("worker-detail").textContent = w.currentJob\n ? t("lend.running", { kind: t(w.currentJob.kind === "inference" ? "jobs.model" : "jobs.container"), time: new Date(w.currentJob.since).toLocaleTimeString(lang) })\n : t("lend.detail", { reason: w.reason && !w.reason.startsWith("error") ? t(`lend.reason.${w.reason}`) : (w.reason || ""), n: w.completed, cpus: s.capacity.cpus, ram: fmt(s.capacity.memoryMb / 1024) }) +\n (s.capacity.containers ? t("lend.detail.sandbox", { name: w.sandbox || "" }) : "") + (s.capacity.models.length ? t("lend.detail.models", { n: s.capacity.models.length }) : "");\n // What the network actually sees of this computer, and why nothing can run when that is the case.\n const seen = s.me && (s.me.nodes || []).find((x) => x.id === s.nodeId);\n const usable = s.capacity.containers || s.capacity.models.length > 0;\n const tried = (s.sandbox && s.sandbox.tried || []).join(" \xB7 ");\n // Known problems in the person\'s language; anything else as reported by the system.\n const sbProblem = /Docker Hub login is no longer valid/.test(tried) ? t("lend.docker_login") : tried;\n $("worker-visible").className = "status " + (seen && seen.online && seen.capacity && usable ? "ok" : "missing");\n // Lending switched off: say so plainly, with what to do; Pause/Resume does not turn it on.\n $("pause").hidden = !s.lending.enabled;\n if (!s.lending.enabled) {\n $("worker-visible").className = "status missing";\n $("worker-visible").textContent = t("lend.off_hint", { enabled: t("rules.enabled"), rules: t("rules.title"), save: t("rules.save") });\n } else if (s.lendingNow.ok && s.capacity.memoryMb < 1024 && usable) {\n $("worker-visible").className = "status missing";\n $("worker-visible").textContent = t("lend.low_memory", { mb: s.capacity.memoryMb, rules: t("rules.title"), memory: t("rules.memory"), save: t("rules.save") });\n } else $("worker-visible").textContent = s.lending.paused || !s.lendingNow.ok ? ""\n : !usable ? t("lend.unusable", { reason: s.lending.containers ? (sbProblem || t("lend.unusable.docker")) : t("lend.unusable.off") })\n : seen && seen.online && seen.capacity ? t("lend.visible", { cpus: seen.capacity.cpus, ram: fmt(seen.capacity.memoryMb / 1024) })\n : t("lend.invisible");\n $("pause").textContent = t(s.lending.paused ? "lend.resume" : "lend.pause");\n $("pause").dataset.tip = s.lending.paused ? "tip.resume" : "tip.pause";\n $("pause").className = s.lending.paused ? "" : "danger";\n\n // Never overwrite changes the user has not saved yet (on macOS a clicked checkbox does not take focus).\n if (!rulesDirty && (!document.activeElement || !$("rules").contains(document.activeElement))) {\n const f = $("rules").elements, l = s.lending;\n for (const k of ["enabled", "containers", "serveModels", "onlyOnAc"]) f[k].checked = !!l[k];\n f.keepAwake.checked = l.keepAwake !== false;\n for (const k of ["maxCpus", "maxMemoryMb", "maxDiskGb", "idleMinutes"]) f[k].value = l[k] ?? "";\n f.gpu.value = l.gpu; f.cacheMax.value = s.cacheMax;\n f.hourFrom.value = l.hours ? l.hours[0] : ""; f.hourTo.value = l.hours ? l.hours[1] : "";\n }\n renderMachine(s.machine);\n renderMyNetwork(s);\n renderGroups(s).catch(() => {});\n $("folders").innerHTML = s.folders.map((p) => `<li><span class="path">${esc(p)}</span><span><button class="secondary" data-draft="${esc(p)}" data-tip="tip.folder.draft">${esc(t("folders.draft"))}</button> <button data-publish="${esc(p)}" data-tip="tip.folder.publish">${esc(t("folders.publish"))}</button> <button class="secondary" data-unfolder="${esc(p)}" data-tip="tip.folder.remove">${esc(t("folders.remove"))}</button></span></li>`).join("") || `<li class="hint">${esc(t("folders.none"))}</li>`;\n $("repos").innerHTML = s.repos.map((r) => `<li><span><strong>${esc(r.name)}</strong><br><span class="path hint">${esc(r.path)}</span></span><button class="secondary" data-unpublish="${esc(r.id)}" data-tip="tip.repo.withdraw">${esc(t("repos.withdraw"))}</button></li>`).join("") || `<li class="hint">${esc(t("repos.none"))}</li>`;\n $("jobs").innerHTML = s.jobs.map((j) => `<li><span>${esc(t(j.kind === "inference" ? "jobs.model" : "jobs.container"))} \xB7 ${new Date(j.createdAt).toLocaleString(lang)}</span><span>${esc(t(`jobs.status.${j.status}`))}${j.cost != null ? ` \xB7 ${esc(t("jobs.cost", { n: fmt(j.cost) }))}` : ""}</span></li>`).join("") || `<li class="hint">${esc(t("jobs.none"))}</li>`;\n $("oai-url").textContent = s.openaiBaseUrl;\n const fl = s.fileLimits;\n $("file-limits").hidden = !fl || !fl.enabled;\n if (fl && fl.enabled) $("file-limits").textContent = t("files.limit", { size: fmtBytes(fl.maxBytes) });\n if (document.activeElement !== $("wait-minutes") && !$("wait-minutes").dataset.dirty) $("wait-minutes").value = s.answerWaitMinutes ?? 10;\n $("models-hint").textContent = t("ask.offers", { models: s.capacity.models.join(", ") || t("ask.offers_none") });\n}\n\n// ---- every button answers at once, and says clearly what went wrong ----------------------\nfunction explain(err) {\n const m = String((err && err.message) || err);\n if (/Failed to fetch|Load failed|NetworkError/.test(m)) return t("err.agent_down");\n if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|coordinator unreachable|timeout/i.test(m)) return t("err.server");\n if (/unauthorized/.test(m)) return t("err.session");\n if (/billing_not_configured/.test(m)) return t("err.billing");\n if (/no_network_computer/.test(m)) return t("mynet.none_ready");\n if (/no_shared_group/.test(m)) return t("chat.err.noshared");\n const notReady = /group_not_ready:\\s*(\\w+)\\s*(.*)/.exec(m);\n if (notReady) return t("chat.err.notready", { state: [t(`mygroup.state.${notReady[1]}`), notReady[2]].filter(Boolean).join(" ") });\n if (/context_too_long/.test(m)) return t("chat.err.context");\n if (/not_enough_memory/.test(m)) return t("mygroup.err.memory");\n if (/group_needs_two/.test(m)) return t("mygroup.err.two");\n if (/group_computer_too_small/.test(m)) return t("mygroup.err.small");\n if (/group_needs_update: /.test(m)) return t("mygroup.err.update.who", { names: m.replace(/^.*group_needs_update:\\s*/, "") });\n if (/group_needs_update/.test(m)) return t("mygroup.err.update");\n if (/custom_model_not_found/.test(m)) return t("mygroup.err.custom");\n if (/group_custom_private/.test(m)) return t("mygroup.err.private");\n if (/group_needs_plan/.test(m)) return t("mygroup.err.plan");\n if (/needs_paid_credits/.test(m)) return t("chat.err.paid");\n if (/attachments_too_large/.test(m)) return t("chat.err.toomany");\n if (/group_needs_this_computer/.test(m)) return t("mygroup.err.here");\n if (/not found on disk|ENOENT/.test(m)) return t("err.no_folder");\n if (/not a folder/.test(m)) return t("err.not_folder");\n if (/insufficient_credits/.test(m)) return `${t("err.credits")} ${m.replace(/^insufficient_credits:\\s*/, "")}`;\n if (/device_required/.test(m)) return t("err.device");\n if (/No open model is online/.test(m)) return t("err.no_model");\n if (/invalid_request|invalid JSON/.test(m)) return t("err.invalid");\n return m.replace(/^Error:\\s*/, "");\n}\n\n/** Runs a button\'s action: disables it at once, shows "Working\u2026", then the result next to it. */\n/** The person\'s computers: those in the network run their jobs first, for free. */\nlet myNetHtml = "";\nfunction renderMyNetwork(s) {\n const nodes = (s.me && s.me.nodes) || [];\n const html = nodes.length ? nodes.map((n) => {\n const name = n.name || n.id.slice(0, 8);\n const usable = n.capacity && (n.capacity.containers || (n.capacity.models || []).length > 0);\n const facts = [n.id === s.nodeId ? t("mynet.this") : "", t(n.online ? "mynet.online" : "mynet.offline"), n.inNetwork ? t("mynet.member") : "", usable ? "" : t("mynet.cannot")].filter(Boolean).join(" \xB7 ");\n const tip = n.inNetwork ? "tip.mynet.remove" : "tip.mynet.add";\n return `<li><span><strong>${esc(name)}</strong><br><span class="hint">${esc(facts)}</span></span><button class="secondary" data-net="${esc(n.id)}" data-net-on="${n.inNetwork ? "" : "1"}" data-tip="${tip}">${esc(t(n.inNetwork ? "mynet.remove" : "mynet.add"))}</button></li>`;\n }).join("") : `<li><span class="hint">${esc(t("mynet.none"))}</span></li>`;\n // Rewritten only when something changed: the refresh must not swallow a click.\n if (html !== myNetHtml) { myNetHtml = html; $("mynet").innerHTML = html; }\n}\n/** A model run by several of the person\'s computers together. */\nlet myGroup = null, groupModels = [];\nlet groupHtml = "";\nasync function renderGroups(s) {\n const { models, groups, speed } = await api("/api/groups");\n $("group-speed").hidden = !speed;\n if (speed) $("group-speed").textContent = t("mygroup.speed", { n: fmt(speed) });\n groupModels = models;\n if (!$("group-model").options.length) {\n const families = [...new Set(models.map((m) => m.family))];\n $("group-model").innerHTML = families.map((f) => `<optgroup label="${esc(f)}">${models.filter((m) => m.family === f).map((m) => `<option value="${esc(m.id)}">${esc(m.name)} \xB7 ${fmt(m.gb)} GB${m.frontier ? ` \xB7 ${esc(t("mygroup.frontier"))}` : ""}</option>`).join("")}</optgroup>`).join("");\n }\n myGroup = groups[0] || null;\n $("group-share").checked = !!(myGroup && myGroup.shared);\n $("group-start").hidden = !!myGroup;\n $("group-ask").hidden = !myGroup;\n const nodes = (s.me && s.me.nodes) || [];\n const html = myGroup ? [myGroup.hostNodeId, ...myGroup.workers].map((id) => {\n const n = nodes.find((x) => x.id === id);\n const st = myGroup.states[id] || { state: "waiting" };\n const detail = st.detail === "not_enough_disk" ? t("mygroup.err.disk") : st.detail === "download_stalled" ? t("mygroup.err.stalled") : st.detail || "";\n return `<li><span><strong>${esc((n && n.name) || id.slice(0, 8))}</strong><br><span class="hint">${esc(t(`mygroup.state.${st.state}`))}${detail ? ` \xB7 ${esc(detail)}` : ""}${st.link ? ` \xB7 ${esc(t(`mygroup.link.${st.link === "direct" ? "direct" : "server"}`))}` : ""}</span></span></li>`;\n }).join("") : "";\n if (html !== groupHtml) { groupHtml = html; $("group-status").innerHTML = html; }\n const ready = myGroup && (myGroup.states[myGroup.hostNodeId] || {}).state === "ready";\n $("group-send").disabled = !ready;\n const folderHtml = (s.modelFolders || []).map((p) => `<li><span class="path">${esc(p)}</span><button class="secondary" data-model-folder="${esc(p)}" data-tip="tip.mygroup.folders.remove">${esc(t("mygroup.folders.remove"))}</button></li>`).join("");\n if ($("model-folders").innerHTML !== folderHtml) $("model-folders").innerHTML = folderHtml;\n const traffic = s.me && s.me.traffic;\n $("group-traffic").hidden = !myGroup || !traffic;\n if (traffic) {\n $("group-traffic").className = traffic.blocked ? "msg error" : "hint";\n $("group-traffic").textContent = traffic.blocked ? t("mygroup.err.traffic") : t("mygroup.traffic", { used: fmt(traffic.usedGb), included: fmt(traffic.includedGb) });\n }\n $("group-share").disabled = !ready || !!(myGroup && myGroup.model.startsWith("group:custom:"));\n $("group-custom").hidden = !!myGroup;\n renderGroupFit();\n}\n/** Every model can be chosen: this says if the computers online today are enough for it. */\nlet groupPlanFor = "";\nasync function renderGroupFit() {\n const m = groupModels.find((x) => x.id === $("group-model").value);\n $("group-fit").hidden = !m || !!myGroup;\n $("group-plan").hidden = !m || !!myGroup;\n if (!m || !state || !state.me) return;\n const key = JSON.stringify([m.id, state.me.nodes.map((n) => [n.id, n.online, n.inNetwork, n.capacity && n.capacity.memoryMb])]);\n if (key !== groupPlanFor && !myGroup) {\n groupPlanFor = key;\n const p = await api("/api/groups/preview", { model: m.id }).catch(() => null);\n if (p && key === groupPlanFor) {\n const rows = p.shares.map((s) => `<li><span><strong>${esc(s.name)}</strong></span><span class="${s.fits ? "hint" : "missing"}">${esc(t(s.tooSmall ? "mygroup.plan.small" : "mygroup.plan.row", { percent: s.percent, gb: fmt(s.gb), have: fmt(s.memoryGb) }))}</span></li>`);\n if (p.disk.freeGb !== null) rows.push(`<li><span class="${p.disk.ok ? "hint" : "missing"}">${esc(t(p.disk.ok ? "mygroup.plan.disk" : "mygroup.plan.disk.no", { need: fmt(p.disk.needGb), free: fmt(p.disk.freeGb) }))}</span></li>`);\n $("group-plan").innerHTML = rows.join("");\n }\n }\n const have = state.me.nodes.filter((n) => n.inNetwork && n.online).reduce((sum, n) => sum + ((n.capacity && n.capacity.memoryMb) || 0), 0) / 1024;\n $("group-fit").textContent = t(have >= m.memoryGb ? "mygroup.fit.ok" : "mygroup.fit.no", { have: fmt(have), need: fmt(m.memoryGb), license: m.license });\n}\n$("group-go").addEventListener("click", () => act($("group-go"), $("group-msg"), async () => { await api("/api/groups", { model: $("group-model").value }); await refresh(); }));\n$("group-stop").addEventListener("click", () => act($("group-stop"), $("group-msg"), async () => { await api("/api/groups/stop", { id: myGroup.id }); await refresh(); }));\n$("group-send").addEventListener("click", () => { $("chat-model").dataset.want = `own:${myGroup.model}`; showView("chat"); });\nconst shareGroup = (btn, shared) => act(btn, $("group-msg"), async () => { await api("/api/groups/share", { id: myGroup.id, shared }); $("group-share-confirm").hidden = true; await refresh(); });\n$("group-share").addEventListener("change", (e) => {\n if (e.target.checked) { e.target.checked = false; $("group-share-confirm").hidden = false; }\n else shareGroup(e.target, false);\n});\n$("group-share-yes").addEventListener("click", () => shareGroup($("group-share-yes"), true));\n$("group-share-no").addEventListener("click", () => { $("group-share-confirm").hidden = true; });\n$("group-model").addEventListener("change", () => renderGroupFit());\nconst loadHubFiles = async () => {\n const { files } = await api("/api/hub/files", { repo: $("hub-repo").value });\n $("hub-file").innerHTML = files.map((f) => `<option value="${esc(f.file)}">${esc(f.file)} \xB7 ${fmt(f.gb)} GB</option>`).join("");\n};\n$("hub-find").addEventListener("click", () => act($("hub-find"), $("group-msg"), async () => {\n const { results } = await api("/api/hub/search", { query: $("hub-query").value.trim() });\n $("hub-repo").innerHTML = results.map((r) => `<option value="${esc(r.repo)}">${esc(r.repo)}</option>`).join("");\n $("hub-pick").hidden = !results.length;\n if (results.length) await loadHubFiles();\n}));\n$("hub-repo").addEventListener("change", () => act($("hub-find"), $("group-msg"), loadHubFiles));\n$("hub-use").addEventListener("click", () => act($("hub-use"), $("group-msg"), async () => { await api("/api/groups", { model: "custom", custom: { repo: $("hub-repo").value, file: $("hub-file").value } }); await refresh(); }));\n$("model-folder-add").addEventListener("click", () => act($("model-folder-add"), $("group-msg"), async () => { await api("/api/model-folders", { path: $("model-folder").value.trim() }); $("model-folder").value = ""; await refresh(); }));\n$("model-folders").addEventListener("click", (e) => {\n const b = e.target.closest("[data-model-folder]");\n if (b) act(b, $("group-msg"), async () => { await api("/api/model-folders/remove", { path: b.dataset.modelFolder }); await refresh(); });\n});\n$("mynet").addEventListener("click", (e) => {\n const b = e.target.closest("[data-net]");\n if (!b) return;\n act(b, $("mynet-msg"), async () => { await api("/api/network", { id: b.dataset.net, inNetwork: b.dataset.netOn === "1" }); await refresh(); });\n});\n\nasync function act(btn, msgEl, fn, okText) {\n if (btn.disabled) return;\n const label = btn.textContent;\n btn.disabled = true;\n btn.textContent = t("working");\n if (msgEl) { msgEl.className = "msg"; msgEl.textContent = ""; }\n try {\n const r = await fn();\n const text = typeof okText === "function" ? okText(r) : okText;\n if (msgEl && text) { msgEl.className = "msg ok"; msgEl.textContent = text; }\n return r;\n } catch (err) {\n if (msgEl) { msgEl.className = "msg error"; msgEl.textContent = explain(err); }\n } finally {\n btn.disabled = false;\n btn.textContent = label;\n }\n}\n\nfor (const ev of ["input", "change"]) $("rules").addEventListener(ev, () => { rulesDirty = true; });\n\n$("rules").addEventListener("submit", (e) => {\n e.preventDefault();\n const f = e.target.elements;\n if ((f.hourFrom.value === "") !== (f.hourTo.value === "")) { $("rules-msg").className = "msg error"; $("rules-msg").textContent = t("rules.hours_both"); return; }\n const hours = f.hourFrom.value !== "" ? [Number(f.hourFrom.value), Number(f.hourTo.value)] : null;\n act(e.submitter || e.target.querySelector("button"), $("rules-msg"), async () => {\n await api("/api/settings", { cacheMax: Number(f.cacheMax.value), lending: { enabled: f.enabled.checked, maxCpus: Number(f.maxCpus.value), maxMemoryMb: Number(f.maxMemoryMb.value), maxDiskGb: Number(f.maxDiskGb.value),\n gpu: f.gpu.value, containers: f.containers.checked, serveModels: f.serveModels.checked, idleMinutes: Number(f.idleMinutes.value), onlyOnAc: f.onlyOnAc.checked, keepAwake: f.keepAwake.checked, hours } });\n rulesDirty = false;\n refresh();\n }, t("rules.saved"));\n});\n\n$("pause").addEventListener("click", () => {\n const pausing = !state.lending.paused;\n act($("pause"), $("pause-msg"), async () => { await api("/api/pause", { paused: pausing }); await refresh(); }, t(pausing ? "lend.paused_ok" : "lend.resumed_ok"));\n});\n\n$("folder-add").addEventListener("click", () => {\n const path = $("folder-path").value.trim();\n if (!path) { $("folder-msg").className = "msg error"; $("folder-msg").textContent = t("folders.need_path"); return; }\n act($("folder-add"), $("folder-msg"), async () => { await api("/api/folders", { path }); $("folder-path").value = ""; await refresh(); }, t("folders.added"));\n});\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || b.closest("#setup")) return;\n const msg = $("folder-msg");\n if (b.dataset.unfolder) act(b, msg, async () => { await api("/api/folders/remove", { path: b.dataset.unfolder }); await refresh(); }, t("folders.removed"));\n if (b.dataset.draft) act(b, msg, () => api("/api/draft", { folder: b.dataset.draft }), (r) => t("folders.drafted", { n: r.draft.modules.length }));\n if (b.dataset.publish) act(b, msg, async () => {\n const r = await api("/api/publish", { folder: b.dataset.publish });\n if (!r.ok) throw new Error(r.errors.join(" ") + (r.secrets ? " " + t("folders.secrets_in", { where: r.secrets.map((x) => t("folders.line", { file: x.file, line: x.line })).join(", ") }) : ""));\n await refresh();\n return r;\n }, (r) => t("folders.published", { name: r.repo.name, n: r.files }));\n if (b.dataset.unpublish) act(b, $("repos-msg"), async () => { await api("/api/repos/remove", { id: b.dataset.unpublish }); await refresh(); }, t("repos.withdrawn"));\n if (b.dataset.buy) showSummary(b.dataset.buy);\n if (b.dataset.pay) act(b, $("buy-msg"), async () => { const { url } = await api("/api/checkout", { product: b.dataset.pay, lang, ...(b.dataset.pay === "recharge" ? { amountEur: rechargeEur() } : {}) }); await api("/api/open", { url }); $("buy-summary").hidden = true; }, t("buy.ok"));\n if (b.id === "buy-cancel") $("buy-summary").hidden = true;\n});\n\n// ---- plans: name, price and what each includes; a summary before the payment page ------------\nlet plansInfo = null, currentPlan = null;\nconst euro = (n) => Number(n).toLocaleString(lang, { style: "currency", currency: "EUR" });\nconst rechargeEur = () => Math.round(Number(($("recharge-eur") || {}).value)) || 0;\nconst rechargeOk = (n) => n >= plansInfo.recharge.minEur && n <= plansInfo.recharge.maxEur;\nconst planLines = (id) => (id === "recharge" ? [t("plans.recharge.1", { n: fmt(rechargeEur() * plansInfo.recharge.creditsPerEur) }), t("plans.recharge.2"), t("plans.recharge.3")] : [1, 2, 3].map((i) => t(`plan.${id}.${i}`)));\nconst planName = (id) => (id === "recharge" ? t("plans.recharge.title") : t(`plan.${id}`));\nasync function renderPlans(current) {\n currentPlan = current;\n if (!plansInfo) plansInfo = await api("/api/plans").catch(() => null);\n if (!plansInfo) return;\n const items = [...plansInfo.plans.map((p) => ({ id: p.id, price: euro(p.priceEurMonth), per: p.id === "free" ? "" : t("plan.month") })), { id: "recharge", price: t("plans.recharge.from", { price: euro(plansInfo.recharge.minEur) }), per: "" }];\n const rechargeBox = `<label>${esc(t("plans.recharge.amount"))} <input type="number" id="recharge-eur" min="${plansInfo.recharge.minEur}" max="${plansInfo.recharge.maxEur}" step="1" value="20" style="width:80px"></label><ul><li>${esc(t("plans.recharge.rate", { n: plansInfo.recharge.creditsPerEur }))}</li><li>${esc(t("plans.recharge.2"))}</li><li>${esc(t("plans.recharge.3"))}</li></ul>`;\n const keep = rechargeEur();\n const html = items.map((p) => `<div class="plan${p.id === current ? " current" : ""}"><h3>${esc(planName(p.id))}${p.id === current ? `<span class="badge">${esc(t("plans.current"))}</span>` : ""}</h3>` +\n `<div class="price">${esc(p.price)}<span class="hint">${esc(p.per)}</span></div>${p.id === "recharge" ? rechargeBox : `<ul>${planLines(p.id).map((l) => `<li>${esc(l)}</li>`).join("")}</ul>`}` +\n (p.id === "free" || p.id === current ? "" : `<button class="secondary" data-buy="${p.id}" data-tip="tip.plans.choose">${esc(t("plans.choose", { plan: planName(p.id) }))}</button>`) + `</div>`).join("");\n if ($("plan-cards").dataset.html !== html) {\n $("plan-cards").dataset.html = html;\n $("plan-cards").innerHTML = html;\n if (keep) $("recharge-eur").value = keep;\n }\n if (!plansInfo.payments && !$("buy-msg").textContent) $("buy-msg").textContent = t("plans.off");\n}\nfunction showSummary(id) {\n if (!plansInfo) return;\n const p = plansInfo.plans.find((x) => x.id === id);\n if (id === "recharge" && !rechargeOk(rechargeEur())) { $("buy-msg").className = "msg error"; $("buy-msg").textContent = t("plans.recharge.range", { min: plansInfo.recharge.minEur, max: plansInfo.recharge.maxEur }); return; }\n const price = id === "recharge" ? euro(rechargeEur()) : `${euro(p.priceEurMonth)}${t("plan.month")}`;\n const lines = id === "recharge" ? planLines(id) : [...planLines(id), t("plans.renew")];\n $("buy-summary").innerHTML = `<div class="hint">${esc(t("plans.summary"))}</div><h3>${esc(id === "recharge" ? planName(id) : `BitPull ${planName(id)}`)} \xB7 ${esc(price)}</h3>` +\n `<ul>${lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul><div class="row"><button data-pay="${id}" data-tip="tip.plans.pay"${plansInfo.payments ? "" : " disabled"}>${esc(t("plans.pay"))}</button>` +\n `<button class="secondary" id="buy-cancel" data-tip="tip.plans.cancel">${esc(t("plans.cancel"))}</button></div>`;\n $("buy-summary").hidden = false;\n}\n\n// ---- log out (e.g. to log in with another account) ----------------------------------------\n$("logout").addEventListener("click", () => {\n $("logout-text").textContent = t("logout.confirm", { email: state && state.me ? state.me.email : "" });\n $("logout-confirm").hidden = false;\n});\n$("logout-no").addEventListener("click", () => ($("logout-confirm").hidden = true));\n$("logout-yes").addEventListener("click", () => act($("logout-yes"), $("buy-msg"), async () => {\n await api("/api/logout", {});\n try { sessionStorage.removeItem("bp_token"); } catch {}\n // In the app: back to its login page. Elsewhere: how to log in again.\n if (backTo) { location.replace(`${backTo}/index.html?loggedout=1`); return; }\n $("app").hidden = true; $("loggedout").hidden = false;\n}));\n\n$("ask").addEventListener("click", () => showView("chat"));\n$("show-key").addEventListener("click", () => { $("oai-key").textContent = token; });\n$("wait-minutes").addEventListener("input", () => { $("wait-minutes").dataset.dirty = "1"; });\n$("save-wait").addEventListener("click", () => {\n const n = Math.round(Number($("wait-minutes").value));\n if (!(n >= 1 && n <= 120)) { $("wait-msg").className = "msg error"; $("wait-msg").textContent = t("ask.wait.range"); return; }\n act($("save-wait"), $("wait-msg"), async () => { await api("/api/settings", { answerWaitMinutes: n }); delete $("wait-minutes").dataset.dirty; }, t("ask.wait.saved", { n }));\n});\n\n// ---- "Set up this computer" -------------------------------------------------------------\nasync function refreshSetup() {\n if (!token) return;\n let st;\n try { st = await api("/api/setup"); } catch { return; }\n const task = (id) => st.tasks.find((x) => x.id === id);\n const progress = (x) => x ? (x.state === "running" ? `<progress max="100" value="${x.progress || 0}"></progress><div class="status">${esc(x.label)} ${x.progress ? x.progress + "%" : ""}</div>`\n : x.state === "failed" ? `<div class="status missing">${esc(x.detail || "")}</div>`\n : `<div class="status ok">${esc(x.detail && /[\\\\/]/.test(x.detail) ? t("setup.opened", { file: x.detail.split(/[\\\\/]/).pop() }) : t("setup.done"))}</div>`) : "";\n const NAMES = { docker: "Docker", ollama: "Ollama", "apple-container": "Apple Container", nvidia: "NVIDIA" };\n const install = (id, label) => {\n const inst = st.installers[id];\n if (!inst) return "";\n const vars = esc(JSON.stringify({ name: NAMES[id] || id }));\n if (inst.kind === "download") return `<button data-download="${id}" data-tip="tip.setup.download" data-tip-vars="${vars}">${esc(label)}</button>`;\n if (inst.kind === "page") return `<button class="secondary" data-open="${esc(inst.url)}" data-tip="tip.setup.open" data-tip-vars="${vars}">${esc(label)}</button>`;\n return `<div class="status">${esc(t("setup.terminal"))} <code>${esc(inst.command)}</code></div>`;\n };\n const row = (title, ok, okText, missingText, action, help) =>\n `<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(title)}</strong><div class="status">${esc(help)}</div></div><div style="text-align:end;min-width:220px">${ok ? `<span class="ok">\u2713 ${esc(okText)}</span>` : `<span class="missing">${esc(missingText)}</span><div>${action}</div>`}</div></div>`;\n const rows = [];\n rows.push(row("Docker", st.docker.running, t("setup.status.running"), t(st.docker.installed ? "setup.status.stopped" : "setup.status.missing"),\n st.docker.installed ? `<button data-start="docker" data-tip="tip.setup.start" data-tip-vars="${esc(JSON.stringify({ name: "Docker" }))}">${esc(t("setup.docker.start"))}</button>` : install("docker", t("setup.docker.download")) + progress(task("download-docker")),\n t("setup.docker.help") + (st.docker.problem ? ` \u2014 ${/Docker Hub login is no longer valid/.test(st.docker.problem) ? t("lend.docker_login") : st.docker.problem}` : "")));\n if (st.os === "darwin" && st.arch === "arm64")\n rows.push(row(t("setup.apple.title"), st.appleContainer.installed, t("setup.status.installed"), t("setup.status.missing"), install("apple-container", t("setup.apple.get")), t("setup.apple.help")));\n rows.push(row("Ollama", st.ollama.running, t("setup.status.running"), t(st.ollama.installed ? "setup.status.stopped" : "setup.status.missing"),\n st.ollama.installed ? `<button data-start="ollama" data-tip="tip.setup.start" data-tip-vars="${esc(JSON.stringify({ name: "Ollama" }))}">${esc(t("setup.ollama.start"))}</button>` : install("ollama", t("setup.ollama.download")) + progress(task("download-ollama")),\n t("setup.ollama.help")));\n const rec = st.recommendedModel;\n rows.push(row(t("setup.model.title"), st.ollama.models.length > 0, st.ollama.models.join(", "), t("setup.model.none"),\n st.ollama.running ? `<button data-pull="${esc(rec.name)}" data-tip="tip.setup.pull" data-tip-vars="${esc(JSON.stringify({ model: rec.name, size: rec.sizeGb }))}">${esc(t("setup.model.download", { model: rec.name, size: rec.sizeGb }))}</button>${progress(task("model-" + rec.name))}` : `<div class="status">${esc(t("setup.model.need_ollama"))}</div>`,\n t("setup.model.help", { ram: st.ramGb, model: rec.name })));\n if (st.nvidia !== null)\n rows.push(row(t("setup.nvidia.title"), st.nvidia, t("setup.status.found"), t("setup.status.not_found"), install("nvidia", t("setup.nvidia.get")), t("setup.nvidia.help")));\n $("setup").innerHTML = rows.join("") + `<div class="row"><button class="secondary" id="recheck" data-tip="tip.setup.recheck">${esc(t("setup.recheck"))}</button></div>`;\n renderModels(st, task, progress);\n if (st.tasks.some((x) => x.state === "running")) setTimeout(refreshSetup, 1500);\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#setup")) return;\n const m = $("setup-msg");\n if (b.dataset.download) act(b, m, () => api("/api/setup/download", { id: b.dataset.download }), t("setup.ok.download"));\n if (b.dataset.start) act(b, m, () => api("/api/setup/start", { id: b.dataset.start }), t("setup.ok.start"));\n if (b.dataset.pull) act(b, m, () => api("/api/setup/pull", { model: b.dataset.pull }), t("setup.ok.pull"));\n if (b.dataset.open) act(b, m, () => api("/api/open", { url: b.dataset.open }), t("setup.ok.page"));\n if (b.id === "recheck") act(b, m, () => api("/api/setup/recheck", {}), t("setup.ok.recheck"));\n setTimeout(refreshSetup, 1000);\n});\n\n// ---- open AI models to share, and the lender\'s limits ------------------------------------\nconst TASK_ORDER = ["chat", "vision", "ocr", "translate", "embed", "rerank", "image", "upscale", "stt", "tts", "music"];\nfunction renderModels(st, task, progress) {\n const lem = st.lemonade;\n const engine = !lem.supported\n ? `<span class="missing">${esc(t("models.engine.unsupported"))}</span>`\n : lem.running ? `<span class="ok">\u2713 ${esc(t("setup.status.running"))}</span>`\n : `<button data-lemonade="1" data-tip="tip.models.engine">${esc(t(lem.installed ? "models.engine.repair" : "models.engine.install"))}</button>${progress(task("download-lemonade"))}${lem.error ? `<div class="status missing">${esc(lem.error)}</div>` : ""}`;\n const installed = (m) => m.runtime === "ollama"\n ? st.ollama.models.some((x) => x === m.id || x === m.id + ":latest")\n : lem.models.includes(m.id);\n const ready = (m) => m.runtime === "ollama" ? st.ollama.running : lem.running;\n const html = [`<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(t("models.engine.title"))}</strong><div class="status">${esc(t("models.engine.help"))}</div></div><div style="text-align:end;min-width:220px">${engine}</div></div>`];\n for (const tk of TASK_ORDER) {\n const list = st.openModels.filter((m) => m.tasks[0] === tk);\n if (!list.length) continue;\n html.push(`<h3 style="margin:14px 0 4px">${esc(t("task." + tk))}</h3>`);\n for (const m of list) {\n const tip = esc(JSON.stringify({ model: m.name, size: m.sizeGb }));\n let action;\n if (installed(m)) action = `<span class="ok">\u2713 ${esc(t("models.shared"))}</span>`;\n else if (!m.available) action = `<span class="status">${esc(t("models.not_here"))}</span>`;\n else if (!ready(m)) action = `<span class="status">${esc(t(m.runtime === "ollama" ? "setup.model.need_ollama" : "models.need_engine"))}</span>`;\n else action = `<button class="${m.fits ? "" : "secondary"}" data-pull-model="${esc(m.id)}" data-tip="tip.models.download" data-tip-vars="${tip}">${esc(t("models.download", { size: m.sizeGb }))}</button>${m.fits ? "" : `<div class="status missing">${esc(t("models.needs_ram", { ram: m.minRamGb }))}</div>`}${progress(task("model-" + m.id))}`;\n html.push(`<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(m.name)}</strong>${m.recommended ? ` <span class="pill">${esc(t("models.recommended"))}</span>` : ""}<div class="status">${esc(m.sizeGb)} GB \xB7 ${esc(t("models.license"))}: ${esc(m.license)}</div></div><div style="text-align:end;min-width:220px">${action}</div></div>`);\n }\n }\n $("ai-models").innerHTML = html.join("");\n // Limits: keep what the user is typing; render only once per load.\n if (!$("ai-limits").dataset.ready) {\n const lim = st.aiLimits, u = st.aiUsage;\n const cell = (p, k) => `<input type="number" min="0" step="1" data-limit="${p}.${k}" value="${lim[p][k] ?? ""}" placeholder="${esc(t("limits.none"))}" style="width:120px">`;\n $("ai-limits").innerHTML = `<table class="limits"><tr><th></th><th>${esc(t("limits.requests"))}</th><th>${esc(t("limits.tokens"))}</th><th>${esc(t("limits.used"))}</th></tr>` +\n ["day", "week", "month"].map((p) => `<tr><td>${esc(t("limits." + p))}</td><td>${cell(p, "requests")}</td><td>${cell(p, "tokens")}</td><td class="status">${esc(t("limits.usage", { requests: u[p].requests, tokens: fmt(u[p].tokens) }))}</td></tr>`).join("") + `</table>`;\n $("ai-limits").dataset.ready = "1";\n }\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#ai-models")) return;\n const m = $("models-msg");\n if (b.dataset.lemonade) act(b, m, () => api("/api/setup/download", { id: "lemonade" }), t("models.ok.engine"));\n if (b.dataset.pullModel) act(b, m, () => api("/api/setup/pull", { model: b.dataset.pullModel }), t("setup.ok.pull"));\n setTimeout(refreshSetup, 1000);\n});\n\n$("save-limits").addEventListener("click", () => {\n const aiLimits = { day: {}, week: {}, month: {} };\n for (const input of document.querySelectorAll("[data-limit]")) {\n const [p, k] = input.dataset.limit.split(".");\n aiLimits[p][k] = input.value === "" ? null : Math.max(0, Math.floor(Number(input.value)));\n }\n act($("save-limits"), $("models-msg"), () => api("/api/settings", { lending: { aiLimits } }), t("limits.saved"));\n});\n\n// ---- connect AI tools -------------------------------------------------------------------\nlet tools = [];\nasync function refreshTools() {\n try { tools = (await api("/api/integrations")).tools; } catch { return; }\n $("tools").innerHTML = tools.map((x, i) => {\n const vars = esc(JSON.stringify({ name: x.name }));\n const status = x.connected ? `<span class="ok">\u2713 ${esc(t("tools.connected"))}</span>` : x.installed ? `<span class="status">${esc(t("tools.installed"))}</span>` : `<span class="status">${esc(t("tools.not_found"))}</span>`;\n const connect = x.installed && !x.connected ? `<button data-connect="${esc(x.id)}" data-tip="tip.tools.connect" data-tip-vars="${vars}">${esc(t("tools.connect"))}</button>` : "";\n const copy = `<button class="secondary" data-copy="${i}" data-tip="tip.tools.copy" data-tip-vars="${vars}">${esc(t("tools.copy"))}</button>`;\n const problem = x.problem === "config_invalid" ? `<div class="status missing">${esc(t("tools.config_invalid", { name: x.name }))}</div>` : "";\n return `<div class="check-row"><div style="flex:1 1 280px"><strong>${esc(x.name)}</strong> ${status}${problem}</div><div class="row" style="margin:0">${connect}${copy}</div></div>`;\n }).join("");\n}\n\nasync function copyText(text) {\n try { await navigator.clipboard.writeText(text); return true; } catch {}\n const ta = document.createElement("textarea");\n ta.value = text; document.body.appendChild(ta); ta.select();\n let ok = false;\n try { ok = document.execCommand("copy"); } catch {}\n ta.remove();\n return ok;\n}\n\ndocument.addEventListener("click", (e) => {\n const b = e.target.closest("button");\n if (!b || !b.closest("#tools")) return;\n const m = $("tools-msg");\n if (b.dataset.connect) {\n const tool = tools.find((x) => x.id === b.dataset.connect);\n act(b, m, async () => {\n try { await api("/api/integrations/connect", { id: b.dataset.connect }); }\n catch (err) { if (/config_invalid/.test(err.message)) throw new Error(t("tools.config_invalid", { name: tool.name })); throw err; }\n await refreshTools();\n }, t("tools.done", { name: tool.name }));\n }\n if (b.dataset.copy !== undefined) {\n const tool = tools[Number(b.dataset.copy)];\n act(b, m, async () => {\n if (!(await copyText(tool.manual))) throw new Error(`${t("tools.manual")} ${tool.manual}`);\n }, `${t("tools.copied")} ${tool.where ? `${t("tools.manual")} ${tool.where}` : ""}`);\n }\n});\n\n// ---- app updates ------------------------------------------------------------------------\nlet update = null;\nasync function refreshUpdate() {\n try { update = await api("/api/update"); } catch { return; }\n const u = update;\n $("update-info").textContent = `${t("update.current", { v: u.current })}${u.latest ? " \xB7 " + t("update.latest", { v: u.latest }) : ""}`;\n const btn = $("update-btn");\n if (!btn.disabled) btn.textContent = u.available ? t("update.install", { v: u.latest }) : t("update.button");\n const st = u.status;\n const fresh = st && Date.now() / 1000 - Number(st.at || 0) < 600;\n const m = $("update-msg");\n if (!u.inApp) { m.className = "msg"; m.textContent = t("update.not_in_app"); return; }\n if (fresh && st.state) {\n m.className = st.state === "error" ? "msg error" : st.state === "up_to_date" ? "msg ok" : "msg";\n m.textContent = t(`update.state.${st.state}`, { v: st.version || u.latest || "", msg: st.message || "" });\n if (["requested", "checking", "installing", "restarting"].includes(st.state)) setTimeout(refreshUpdate, 1500);\n } else if (u.available) { m.className = "msg"; m.textContent = t("update.available", { v: u.latest }); }\n}\n$("update-btn").addEventListener("click", () => {\n act($("update-btn"), null, async () => {\n try { await api("/api/update", {}); }\n catch (err) { $("update-msg").className = "msg error"; $("update-msg").textContent = /updates_only_in_app/.test(err.message) ? t("update.not_in_app") : explain(err); return; }\n setTimeout(refreshUpdate, 500);\n });\n});\n\n// ---- chat: one page to talk to every model ------------------------------------------------\n// renderMd:start\n/** Answers come from models and other people\'s computers: everything is escaped before formatting. */\nfunction renderMd(text) {\n const safe = (s) => String(s ?? "").replace(/[&<>"\']/g, (c) => ({ "&":"&","<":"<",">":">",\'"\':""","\'":"'" }[c]));\n const inline = (s) => safe(s)\n .replace(/`([^`\\n]+)`/g, "<code>$1</code>")\n .replace(/\\*\\*([^*\\n]+)\\*\\*/g, "<strong>$1</strong>")\n .replace(/(^|[\\s(])\\*([^*\\n]+)\\*/g, "$1<em>$2</em>")\n .replace(/\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, \'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>\');\n const out = [];\n const parts = String(text ?? "").split(/```/);\n parts.forEach((part, i) => {\n if (i % 2) { out.push(`<pre><code>${safe(part.replace(/^[^\\n]*\\n/, ""))}</code></pre>`); return; }\n let list = null;\n const close = () => { if (list) { out.push(`</${list}>`); list = null; } };\n for (const line of part.split("\\n")) {\n const h = /^(#{1,4})\\s+(.*)/.exec(line), u = /^\\s*[-*]\\s+(.*)/.exec(line), o = /^\\s*\\d+[.)]\\s+(.*)/.exec(line);\n if (u || o) {\n const want = u ? "ul" : "ol";\n if (list !== want) { close(); out.push(`<${want}>`); list = want; }\n out.push(`<li>${inline((u || o)[1])}</li>`);\n continue;\n }\n close();\n if (h) out.push(`<h${h[1].length > 2 ? 4 : 3}>${inline(h[2])}</h${h[1].length > 2 ? 4 : 3}>`);\n else if (line.trim()) out.push(`<p>${inline(line)}</p>`);\n }\n close();\n });\n return out.join("");\n}\n/** Only pictures and sounds that came from this app are shown. */\nconst safeMedia = (url) => /^data:(image\\/(png|jpeg)|audio\\/(mpeg|wav));base64,[A-Za-z0-9+/=]+$/.test(String(url));\n// renderMd:end\nconst CHAT_KEEP_CHARS = 12000;\n// chatlogic:start\n/** What the model does with a message that has no attachment: most chat, others make or read something. */\nfunction modelTask(m) {\n if (!m) return "chat";\n if (m.paid) return m.tasks.includes("image") ? "image" : "chat";\n if (m.tasks.includes("chat") || m.tasks.includes("vision")) return "chat";\n return m.tasks.find((k) => ["image", "music", "tts", "stt", "ocr", "upscale", "translate"].includes(k)) || "chat";\n}\nconst IMAGE_TASKS = ["vision", "ocr", "upscale"];\nconst readsImages = (m) => !!m && !m.paid && m.tasks.some((k) => IMAGE_TASKS.includes(k));\nconst hearsAudio = (m) => !!m && !m.paid && m.tasks.includes("stt");\n/** How this model can take these attachments, or what is missing: nothing is sent that it would ignore. */\nfunction attachmentPlan(m, files) {\n const images = files.flatMap((f) => (f.kind === "image" ? [f.base64] : f.kind === "video" ? f.frames : []));\n const audios = files.flatMap((f) => (f.kind === "audio" ? [f.base64] : []));\n const videoAudio = files.map((f) => f.kind === "video" && f.audio).find(Boolean);\n const plain = { task: modelTask(m), files: [], need: null };\n if (!images.length && !audios.length) return plain;\n if (m && m.paid) return { ...plain, need: "paid" };\n if ((images.length && audios.length) || audios.length > 1) return { ...plain, need: "mix" };\n if (images.length) {\n if (readsImages(m)) {\n const task = IMAGE_TASKS.find((k) => m.tasks.includes(k));\n return { task, files: task === "upscale" ? images.slice(0, 1) : images, need: null };\n }\n if (videoAudio && hearsAudio(m)) return { task: "stt", files: [videoAudio], need: null };\n return { ...plain, need: "vision" };\n }\n return hearsAudio(m) ? { task: "stt", files: audios, need: null } : { ...plain, need: "stt" };\n}\n/** The first usable model that can take what is missing. */\nconst modelFor = (need, models) => models.find((x) => x.available && (need === "vision" ? readsImages(x) : need === "stt" ? hearsAudio(x) : false)) || null;\n/** Why there is nothing to talk to, in this person\'s real situation. */\nfunction noModelReason(s) {\n if (s && s.groupState && s.groupState !== "ready") return "chat.none.group";\n if (s && s.computersOnline === 0) return "chat.none.offline";\n if (s && s.hasCommunity && !(s.balance > 0)) return "chat.none.credits";\n if (s && s.hasPaid) return "chat.none.paid";\n return "chat.none.empty";\n}\nconst OFFICE_FILE = /\\.(pdf|docx?|xlsx?|pptx?|odt|ods|odp|pages|numbers|key|rtf|zip|rar|7z|gz|tar|exe|dmg|iso|bin)$/i;\n/** What kind of attachment a file is, from its type and then its name. */\nfunction fileKind(name, type) {\n if (OFFICE_FILE.test(name)) return "unreadable";\n if (/^image\\//.test(type) || /\\.(png|jpe?g|webp|gif|bmp|heic|heif|avif|tiff?)$/i.test(name)) return "image";\n if (/^audio\\//.test(type) || /\\.(mp3|m4a|wav|ogg|oga|opus|flac|aac|aiff?)$/i.test(name)) return "audio";\n if (/^video\\//.test(type) || /\\.(mp4|m4v|mov|webm|mkv|avi)$/i.test(name)) return "video";\n return "text";\n}\n/** A file with zero bytes in it is not text, whatever its name says. */\nconst looksLikeText = (bytes) => !bytes.subarray(0, 8192).includes(0);\n/** 16-bit mono WAV, what the speech models read. */\nfunction wavBytes(samples, rate) {\n const out = new Uint8Array(44 + samples.length * 2), v = new DataView(out.buffer);\n const text = (at, s) => { for (let i = 0; i < s.length; i++) out[at + i] = s.charCodeAt(i); };\n text(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); text(8, "WAVE"); text(12, "fmt ");\n v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true);\n v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); text(36, "data"); v.setUint32(40, samples.length * 2, true);\n for (let i = 0; i < samples.length; i++) v.setInt16(44 + i * 2, Math.max(-1, Math.min(1, samples[i])) * 32767, true);\n return out;\n}\n// chatlogic:end\n/** Limits of what a job can carry: it travels encoded twice and the server takes 16 MB. */\nconst JOB_FILES_MAX_BYTES = 8_500_000;\nconst AUDIO_MAX_SECONDS = 240;\nconst SOURCE_MAX_MB = { image: 30, audio: 200, video: 200 };\nconst TEXT_MAX_KB = 300;\nconst IMAGE_MAX_SIDE = 1600;\nconst VIDEO_FRAME_SIDE = 1024;\nconst VIDEO_MAX_FRAMES = 6;\nconst MAX_ATTACHMENTS = 8;\nconst MODELS_REFRESH_MS = 15000;\nlet chat = null, chatList = [], chatModelsList = [], chatBusy = null, attachments = [], chatStatus = null, modelsHtml = "", lastSent = null, attachNote = "";\n\nconst newChat = () => ({ id: crypto.randomUUID(), title: "", updatedAt: Date.now(), messages: [], model: $("chat-model").value || undefined });\nconst selectedModel = () => chatModelsList.find((m) => m.key === $("chat-model").value && m.available) || null;\nconst chatPlan = () => attachmentPlan(selectedModel(), attachments.filter((a) => !a.busy));\nfunction renderChatHint() {\n const m = selectedModel(), plan = chatPlan();\n const usable = chatModelsList.some((x) => x.available);\n const fix = plan.need && modelFor(plan.need, chatModelsList);\n const text = attachNote || (!m ? t(usable ? "chat.pick" : noModelReason(chatStatus), { state: chatStatus && chatStatus.groupState ? t(`mygroup.state.${chatStatus.groupState}`) : "" })\n : plan.need ? t(`chat.need.${plan.need}`, { model: m.name }) : t(`chat.hint.${plan.task}`));\n $("chat-hint").textContent = text;\n $("chat-hello-hint").textContent = text;\n $("chat-use").hidden = !fix;\n if (fix) { $("chat-use").textContent = t("chat.use", { model: fix.name }); $("chat-use").dataset.key = fix.key; }\n $("chat-send").disabled = !m || !!plan.need || attachments.some((a) => a.busy);\n renderChatWhere();\n}\n/** Before sending, says who will read the question and the most it can cost. */\nfunction renderChatWhere() {\n const m = selectedModel();\n if (!m) { $("chat-where").textContent = ""; return; }\n const task = chatPlan().task;\n const chars = $("chat-input").value.length + (chat ? chat.messages.reduce((n, x) => n + Math.min((x.content || "").length, CHAT_KEEP_CHARS), 0) : 0);\n const input = (Math.min(chars, CHAT_KEEP_CHARS) / 4 + 50) / 1e6, output = 1024 / 1e6;\n const credits = m.paid ? input * (m.inRate || 0) + output * (m.outRate || 0) : (input + output) * (m.rate || 0);\n const cost = task !== "chat" ? t("chat.where.cost.task") : credits ? t("chat.where.cost", { n: fmt(Math.max(credits, 0.001).toFixed(3)) }) : "";\n const where = m.own || ($("chat-mine").checked && !m.paid) ? (m.own ? "own" : "mine") : m.paid ? "paid" : m.id.startsWith("group:") ? "shared" : "net";\n $("chat-where").textContent = where === "own" || where === "mine" ? t(`chat.where.${where}`) : `${t(`chat.where.${where}`)} ${cost}`;\n}\nasync function loadChatModels() {\n const { own, network, paid, status } = await api("/api/chat/models");\n const entry = (source, x) => ({ key: `${source}:${x.model || x.id}`, id: x.model || x.id, name: x.name || x.model || x.id, tasks: x.tasks || [], paid: source === "paid", own: source === "own", group: !!x.group, available: x.available !== false, rate: x.creditsPerMillion });\n const community = network.filter((x) => x.tasks.some((k) => !["embed", "rerank"].includes(k))).map((x) => entry("net", x));\n const groups = [\n ["chat.group.own", own.map((x) => entry("own", x)), ""],\n ["chat.group.network", community, "chat.off.credits"],\n ["chat.group.paid", paid.map((x) => ({ ...entry("paid", x), tasks: [x.modality === "image" ? "image" : "chat"], inRate: x.creditsPerMillionInputTokens, outRate: x.creditsPerMillionOutputTokens })), "chat.off.paid"],\n ];\n chatModelsList = groups.flatMap((g) => g[1]);\n chatStatus = { ...status, hasCommunity: community.length > 0, hasPaid: paid.length > 0 };\n const html = groups.filter((g) => g[1].length).map(([label, list, off]) => `<optgroup label="${esc(t(label))}">${list.map((m) => `<option value="${esc(m.key)}"${m.available ? "" : " disabled"}>${esc(m.name)}${m.available ? "" : ` \xB7 ${esc(t(off))}`}</option>`).join("")}</optgroup>`).join("");\n const keep = $("chat-model").value;\n if (html !== modelsHtml) { modelsHtml = html; $("chat-model").innerHTML = html; }\n $("chat-model").hidden = !chatModelsList.length;\n let saved = null;\n try { saved = localStorage.getItem("bitpull.chatModel"); } catch {}\n const want = [$("chat-model").dataset.want, chat && chat.model, keep, saved].find((k) => chatModelsList.some((m) => m.key === k && m.available));\n delete $("chat-model").dataset.want;\n const first = chatModelsList.find((m) => m.available);\n $("chat-model").value = want || (first ? first.key : "");\n renderChatHint();\n}\nfunction renderChatList() {\n const q = $("chat-search").value.trim().toLowerCase();\n $("chat-list").innerHTML = chatList.filter((c) => !q || c.title.toLowerCase().includes(q)).map((c) =>\n `<li class="${chat && c.id === chat.id ? "on" : ""}"><button class="open" data-open="${esc(c.id)}" data-tip="tip.chat.open">${esc(c.title || t("chat.new"))}</button><button class="del" data-del="${esc(c.id)}" aria-label="${esc(t("chat.delete"))}" data-tip="tip.chat.delete">\xD7</button></li>`).join("");\n}\n/** What an AI asks to change: shown as it is, and only a click here makes it happen. */\nfunction proposalHtml(msg, i) {\n const p = msg.proposal;\n if (!p) return "";\n const show = (v) => (v === undefined ? "\u2013" : esc(typeof v === "string" ? v : JSON.stringify(v)));\n const head = `<strong>${esc(t(`act.${p.action}`))}</strong>`;\n if (msg.proposalState) return `<div class="summary">${head}<div class="hint">${esc(t(`ai.state.${msg.proposalState}`))}</div></div>`;\n const rows = p.fields.map((f) => `<tr><td><code>${esc(f.field)}</code></td><td>${show(f.current)}</td><td>${show(f.next)}</td></tr>`).join("");\n return `<div class="summary"><div class="hint">${esc(t("ai.wants"))}</div>${head}` +\n (rows ? `<table class="limits"><tr><th></th><th>${esc(t("ai.now"))}</th><th>${esc(t("ai.then"))}</th></tr>${rows}</table>` : "") +\n (p.source === "community" ? `<p class="hint">${esc(t("ai.community"))}</p>` : p.always ? `<p class="hint">${esc(t("ai.always"))}</p>` : "") +\n `<div class="row"><button data-ai-yes="${i}" data-tip="tip.ai.yes">${esc(t("ai.yes"))}</button><button class="secondary" data-ai-no="${i}" data-tip="tip.ai.no">${esc(t("plans.cancel"))}</button></div></div>`;\n}\nfunction messageHtml(msg, i) {\n if (msg.role === "user") return `<div class="m-user">${(msg.media || []).filter(safeMedia).map((u) => `<img src="${u}" alt="">`).join("")}${esc(msg.content)}</div>`;\n const think = /^\\s*<think>([\\s\\S]*?)<\\/think>/.exec(msg.content || "");\n const body = think ? msg.content.slice(think[0].length) : msg.content;\n const media = (msg.media || []).filter(safeMedia).map((u) => (u.startsWith("data:image") ? `<img src="${u}" alt="">` : `<audio controls src="${u}"></audio>`)).join("");\n const facts = [msg.modelName, msg.cost ? t("ask.credits", { n: fmt(msg.cost) }) : msg.free ? t("chat.free") : "", msg.seconds ? `${msg.seconds} s` : "", msg.tokensPerSecond ? t("chat.speed", { n: fmt(msg.tokensPerSecond) }) : ""].filter(Boolean).map(esc).join(" \xB7 ");\n const last = i === chat.messages.length - 1;\n const did = (msg.did || []).map((a) => `<div class="hint">${esc(t("ai.auto.did", { action: t(`act.${a}`) }))}</div>`).join("");\n return `<div>${proposalHtml(msg, i)}${did}<div class="m-ai${msg.error ? " error" : ""}">${think ? `<details><summary>${esc(t("chat.reasoning"))}</summary>${renderMd(think[1])}</details>` : ""}${msg.error ? esc(msg.content) : renderMd(body)}${media}</div>\n <div class="m-foot"><span>${facts}</span>${msg.error ? "" : `<button data-copy="${i}" data-tip="tip.chat.copy">${esc(t("chat.copy"))}</button>`}${last ? `<button data-retry="1" data-tip="tip.chat.retry">${esc(t("chat.retry"))}</button>` : ""}</div></div>`;\n}\nfunction renderChat() {\n const msgs = chat ? chat.messages : [];\n $("chat-hello").hidden = msgs.length > 0 || !!chatBusy;\n $("chat-log").innerHTML = msgs.map(messageHtml).join("") + (chatBusy ? `<div class="m-ai hint" id="chat-wait"></div>` : "");\n tickWait();\n $("chat-scroll").scrollTop = $("chat-scroll").scrollHeight;\n $("chat-send").hidden = !!chatBusy;\n $("chat-stop").hidden = !chatBusy;\n renderAttachments();\n renderChatList();\n}\nfunction tickWait() {\n const el = $("chat-wait");\n if (el && chatBusy && !chatBusy.text) el.textContent = t("chat.thinking", { s: Math.round((Date.now() - chatBusy.since) / 1000) });\n}\nsetInterval(tickWait, 1000);\nasync function saveCurrentChat() {\n chat.updatedAt = Date.now();\n await api("/api/chats/save", chat).catch(() => {});\n chatList = (await api("/api/chats")).chats;\n renderChatList();\n}\n/** Keeps the end of the conversation, which is what fits in what a model can read at once. */\nfunction historyForModel() {\n const out = [];\n let chars = 0;\n for (const m of [...chat.messages].reverse()) {\n if (m.error || !m.content) continue;\n chars += m.content.length;\n if (out.length && chars > CHAT_KEEP_CHARS) break;\n out.unshift({ role: m.role, content: m.content });\n }\n return out;\n}\n/** Shows an answer as it arrives, one line of the stream at a time. */\nasync function readLive(res, busy) {\n const reader = res.body.getReader(), decoder = new TextDecoder();\n const out = { content: "" };\n let rest = "";\n for (;;) {\n const { value, done } = await reader.read();\n if (done) return out;\n rest += decoder.decode(value, { stream: true });\n const lines = rest.split("\\n");\n rest = lines.pop();\n for (const line of lines.filter(Boolean)) {\n const d = JSON.parse(line);\n if (d.error) throw new Error(d.error);\n if (d.text) out.content += d.text;\n if (d.done) out.tokensPerSecond = d.tokensPerSecond;\n }\n busy.text = out.content;\n const el = $("chat-wait");\n if (el && chatBusy === busy) { el.className = "m-ai"; el.innerHTML = renderMd(out.content); $("chat-scroll").scrollTop = $("chat-scroll").scrollHeight; }\n }\n}\nasync function askChat(plan) {\n const m = selectedModel();\n if (!m) return;\n const { task, files } = plan;\n lastSent = { chatId: chat.id, plan };\n const started = Date.now();\n const busy = { since: started, ctrl: new AbortController() };\n chatBusy = busy;\n renderChat();\n let reply;\n const headers = { "content-type": "application/json", "x-bitpull-token": token || "" };\n // An answer that may use the app arrives whole: it has to be read before anything is shown.\n const useControl = $("chat-control").checked && task === "chat" && !files.length;\n const body = JSON.stringify({\n model: m.id, paid: m.paid, task, system: $("chat-system").value.trim() || undefined, messages: historyForModel(),\n placement: m.own || $("chat-mine").checked ? "mine" : "auto", temperature: Number($("chat-temp").value), files: files.length ? files : undefined,\n appControl: useControl,\n });\n const done = (data) => ({ role: "assistant", content: data.content || "", proposal: data.proposal, did: (data.done || []).map((d) => d.action), media: data.media, cost: data.cost, free: !data.cost, modelName: m.name, seconds: Math.round((Date.now() - started) / 1000), tokensPerSecond: data.tokensPerSecond });\n try {\n // The person\'s own group answers word by word when this computer runs its engine.\n const live = m.group && task === "chat" && !useControl ? await fetch("/api/chat/stream", { method: "POST", signal: busy.ctrl.signal, headers, body }) : null;\n if (live && live.ok) {\n reply = done(await readLive(live, busy));\n } else {\n const res = await fetch("/api/chat", { method: "POST", signal: busy.ctrl.signal, headers, body });\n const data = await res.json().catch(() => ({}));\n if (!res.ok) throw new Error(data.error || res.status);\n reply = done(data);\n }\n } catch (err) {\n reply = { role: "assistant", error: true, content: err.name === "AbortError" ? t("chat.stopped") : explain(err) };\n }\n if (chatBusy !== busy) return;\n chatBusy = null;\n chat.messages.push(reply);\n renderChat();\n await saveCurrentChat();\n}\nasync function sendChat() {\n const text = $("chat-input").value.trim();\n const m = selectedModel();\n if (chatBusy || !m || attachments.some((a) => a.busy)) return;\n const files = attachments;\n if (!text && !files.length) return;\n const plan = attachmentPlan(m, files);\n if (plan.need) return renderChatHint();\n if (["ocr", "upscale", "stt"].includes(plan.task) && !plan.files.length) { $("chat-hint").textContent = t(`chat.hint.${plan.task}`); return; }\n if (plan.files.reduce((n, b) => n + b.length * 0.75, 0) > JOB_FILES_MAX_BYTES) { $("chat-hint").textContent = t("chat.err.toomany"); return; }\n if (!chat) chat = newChat();\n const pasted = files.filter((f) => f.kind === "text").map((f) => `${f.name}:\\n\\`\\`\\`\\n${f.text}\\n\\`\\`\\``);\n const notes = files.filter((f) => f.kind === "video").map((f) => t(plan.task === "stt" ? "chat.video.heard" : "chat.video.frames", { n: f.frames.length, name: f.name }));\n const asked = text || (plan.task === "vision" ? t("chat.ask.image") : files.map((f) => f.name).join(", "));\n const content = [asked, ...notes, ...pasted].join("\\n\\n");\n const media = files.flatMap((f) => (f.kind === "image" ? [f.url] : f.kind === "video" ? [f.url] : []));\n chat.messages.push({ role: "user", content, ...(media.length ? { media } : {}) });\n chat.title = chat.title || (text || files[0].name).slice(0, 60);\n chat.model = m.key;\n $("chat-input").value = "";\n $("chat-input").style.height = "";\n attachments = [];\n attachNote = "";\n renderAttachments();\n await saveCurrentChat();\n await askChat(plan);\n}\nconst bytesToBase64 = (bytes) => {\n let s = "";\n for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 32768));\n return btoa(s);\n};\nconst dataOf = (url) => url.slice(url.indexOf(",") + 1);\n/** Draws a picture or a video frame at a size a model can take, always as a JPEG. */\nfunction drawnJpeg(source, width, height, side) {\n const scale = Math.min(1, side / Math.max(width, height));\n const c = document.createElement("canvas");\n c.width = Math.max(1, Math.round(width * scale));\n c.height = Math.max(1, Math.round(height * scale));\n const g = c.getContext("2d");\n g.fillStyle = "#fff";\n g.fillRect(0, 0, c.width, c.height);\n g.drawImage(source, 0, 0, c.width, c.height);\n return c.toDataURL("image/jpeg", 0.85);\n}\nasync function readImage(file) {\n const src = URL.createObjectURL(file);\n try {\n const img = new Image();\n img.src = src;\n await img.decode();\n const url = drawnJpeg(img, img.naturalWidth, img.naturalHeight, IMAGE_MAX_SIDE);\n return { kind: "image", url, base64: dataOf(url) };\n } finally {\n URL.revokeObjectURL(src);\n }\n}\n/** Any sound the system can decode, as 16 kHz mono: what the speech models read. */\nasync function readAudio(file) {\n const RATE = 16000;\n const decoded = await new OfflineAudioContext(1, RATE, RATE).decodeAudioData(await file.arrayBuffer());\n const seconds = Math.min(decoded.duration, AUDIO_MAX_SECONDS);\n const mix = new OfflineAudioContext(1, Math.max(1, Math.ceil(seconds * RATE)), RATE);\n const source = mix.createBufferSource();\n source.buffer = decoded;\n source.connect(mix.destination);\n source.start();\n const rendered = await mix.startRendering();\n return { base64: bytesToBase64(wavBytes(rendered.getChannelData(0), RATE)), cut: decoded.duration > AUDIO_MAX_SECONDS };\n}\nconst waitFor = (el, event, ms = 15000) => new Promise((ok, no) => {\n const timer = setTimeout(() => no(new Error("timeout")), ms);\n el.addEventListener(event, () => { clearTimeout(timer); ok(); }, { once: true });\n el.addEventListener("error", () => { clearTimeout(timer); no(new Error("video")); }, { once: true });\n});\n/** A few frames spread along the video, and its sound when there is any. No model watches a whole video. */\nasync function readVideo(file) {\n const src = URL.createObjectURL(file);\n try {\n const v = document.createElement("video");\n v.muted = true;\n v.preload = "auto";\n v.src = src;\n await waitFor(v, "loadeddata");\n // Recordings often do not say how long they are: seeking far ahead makes the browser find out.\n if (!Number.isFinite(v.duration)) { v.currentTime = 1e7; await waitFor(v, "seeked").catch(() => {}); }\n const length = Number.isFinite(v.duration) ? v.duration : 0;\n const count = length ? Math.min(VIDEO_MAX_FRAMES, Math.max(3, Math.ceil(length / 10))) : 1;\n const urls = [];\n for (let i = 0; i < count; i++) {\n if (length) { v.currentTime = (length * (i + 0.5)) / count; await waitFor(v, "seeked"); }\n urls.push(drawnJpeg(v, v.videoWidth, v.videoHeight, VIDEO_FRAME_SIDE));\n }\n const sound = await readAudio(file).catch(() => null);\n return { kind: "video", url: urls[0], frames: urls.map(dataOf), audio: sound ? sound.base64 : null, cut: !!(sound && sound.cut) };\n } finally {\n URL.revokeObjectURL(src);\n }\n}\nasync function readAttachment(file) {\n const kind = fileKind(file.name, file.type);\n if (kind === "unreadable") throw new Error(t("chat.err.type", { name: file.name }));\n if (kind === "text") {\n if (file.size > TEXT_MAX_KB * 1024) throw new Error(t("chat.err.size", { name: file.name, limit: `${TEXT_MAX_KB} KB` }));\n const bytes = new Uint8Array(await file.arrayBuffer());\n if (!looksLikeText(bytes)) throw new Error(t("chat.err.type", { name: file.name }));\n return { kind, text: new TextDecoder().decode(bytes) };\n }\n if (file.size > SOURCE_MAX_MB[kind] * 1024 * 1024) throw new Error(t("chat.err.size", { name: file.name, limit: `${SOURCE_MAX_MB[kind]} MB` }));\n try {\n if (kind === "image") return await readImage(file);\n if (kind === "video") return await readVideo(file);\n const sound = await readAudio(file);\n return { kind, ...sound };\n } catch {\n throw new Error(t("chat.err.media", { name: file.name }));\n }\n}\n/** Every way in leads here: the button, dragging and pasting. What cannot be used is said, never dropped. */\nasync function addAttachments(list) {\n $("chat-menu").hidden = true;\n attachNote = "";\n for (const file of list) {\n if (attachments.length >= MAX_ATTACHMENTS) { attachNote = t("chat.err.toomany"); break; }\n const a = { id: crypto.randomUUID(), name: file.name || "file", busy: true };\n attachments.push(a);\n renderAttachments();\n try {\n Object.assign(a, await readAttachment(file), { busy: false });\n if (a.cut) attachNote = t("chat.att.cut", { n: AUDIO_MAX_SECONDS / 60 });\n } catch (err) {\n attachments = attachments.filter((x) => x !== a);\n attachNote = err.message;\n }\n renderAttachments();\n }\n}\nfunction renderAttachments() {\n $("chat-attached").hidden = !attachments.length;\n $("chat-attached").innerHTML = attachments.map((a) => `<span class="att">${a.url && safeMedia(a.url) ? `<img src="${a.url}" alt="">` : ""}<span class="att-name">${esc(a.busy ? t("chat.att.working", { name: a.name }) : a.name)}</span>${a.kind === "video" ? `<span class="hint">${esc(t("chat.att.frames", { n: a.frames.length }))}</span>` : ""}<button class="secondary" data-att="${esc(a.id)}" aria-label="${esc(t("chat.att.remove"))}" data-tip="tip.chat.att.remove">\xD7</button></span>`).join("");\n renderChatHint();\n}\n/** The header wraps on narrow windows: the chat takes exactly what is left. */\nconst fitChat = () => document.documentElement.style.setProperty("--head", `${document.querySelector("header").offsetHeight}px`);\nwindow.addEventListener("resize", fitChat);\nfunction showView(name) {\n // Projects open in their own page, the editor: the dashboard stays as it was.\n if (name === "projects") { location.assign(`/ide#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`); return; }\n const isChat = name === "chat", isProjects = false, isPanel = !isChat;\n $("chat-view").hidden = !isChat;\n document.querySelector("main").hidden = !isPanel;\n $("live").style.display = isPanel ? "" : "none";\n $("view-chat").classList.toggle("on", isChat);\n $("view-projects").classList.toggle("on", isProjects);\n $("view-panel").classList.toggle("on", isPanel);\n try { localStorage.setItem("bitpull.view", name); } catch {}\n fitChat();\n if (isChat) {\n loadChatModels().catch((err) => { $("chat-hint").textContent = explain(err); });\n api("/api/chats").then((r) => { chatList = r.chats; renderChat(); }).catch(() => {});\n }\n}\n$("view-chat").addEventListener("click", () => showView("chat"));\n$("view-projects").addEventListener("click", () => showView("projects"));\n$("view-panel").addEventListener("click", () => showView("panel"));\n$("chat-new").addEventListener("click", () => { chat = null; $("chat-view").classList.remove("side"); renderChat(); $("chat-input").focus(); });\n$("chat-side-toggle").addEventListener("click", () => $("chat-view").classList.toggle("side"));\n$("chat-search").addEventListener("input", renderChatList);\n$("chat-more").addEventListener("click", () => { $("chat-menu").hidden = !$("chat-menu").hidden; });\n$("chat-attach").addEventListener("click", () => $("chat-file").click());\n$("chat-attached").addEventListener("click", (e) => {\n const b = e.target.closest("[data-att]");\n if (!b) return;\n attachments = attachments.filter((a) => a.id !== b.dataset.att);\n attachNote = "";\n renderAttachments();\n});\n$("chat-use").addEventListener("click", (e) => { $("chat-model").value = e.target.dataset.key; $("chat-model").dispatchEvent(new Event("change")); });\n$("chat-model").addEventListener("change", () => {\n const key = $("chat-model").value;\n try { localStorage.setItem("bitpull.chatModel", key); } catch {}\n if (chat) { chat.model = key; saveCurrentChat(); }\n attachNote = "";\n renderChatHint();\n});\n// Dropping a file anywhere in the chat attaches it, and never opens it in place of the app.\nconst chatView = $("chat-view");\nfor (const type of ["dragenter", "dragover"]) chatView.addEventListener(type, (e) => { e.preventDefault(); chatView.classList.add("dropping"); });\nchatView.addEventListener("dragleave", (e) => { if (e.target === chatView || !chatView.contains(e.relatedTarget)) chatView.classList.remove("dropping"); });\nchatView.addEventListener("drop", (e) => {\n e.preventDefault();\n chatView.classList.remove("dropping");\n if (e.dataTransfer.files.length) addAttachments([...e.dataTransfer.files]);\n});\n$("chat-input").addEventListener("paste", (e) => {\n const list = [...e.clipboardData.files];\n if (list.length) { e.preventDefault(); addAttachments(list); }\n});\nsetInterval(() => {\n if ($("chat-view").hidden || chatBusy || document.activeElement === $("chat-model")) return;\n loadChatModels().catch(() => {});\n}, MODELS_REFRESH_MS);\n$("chat-send").addEventListener("click", sendChat);\n$("chat-stop").addEventListener("click", () => {\n const busy = chatBusy;\n if (!busy) return;\n chatBusy = null;\n busy.ctrl.abort();\n // What already arrived stays: stopping the answer does not lose it.\n chat.messages.push(busy.text ? { role: "assistant", content: busy.text, modelName: selectedModel()?.name, free: true } : { role: "assistant", error: true, content: t("chat.stopped") });\n renderChat();\n saveCurrentChat();\n});\n$("chat-input").addEventListener("input", (e) => { e.target.style.height = ""; e.target.style.height = `${e.target.scrollHeight}px`; renderChatWhere(); });\n$("chat-mine").addEventListener("change", renderChatWhere);\nconst saveAiSettings = () => api("/api/assistant/settings", { enabled: $("chat-control").checked, auto: $("chat-auto").checked }).catch((err) => { $("chat-hint").textContent = explain(err); });\n$("chat-control").addEventListener("change", saveAiSettings);\n$("chat-auto").addEventListener("change", saveAiSettings);\nlet aiLogHtml = "";\nasync function refreshAiLog() {\n const a = await api("/api/assistant").catch(() => null);\n if (!a) return;\n if (document.activeElement !== $("chat-control")) $("chat-control").checked = a.enabled;\n if (document.activeElement !== $("chat-auto")) $("chat-auto").checked = a.auto;\n const html = a.entries.slice(0, 20).map((e) => `<li><span>${esc(t(`act.${e.action}`))}<br><span class="hint">${esc(new Date(e.at).toLocaleString(lang))} \xB7 ${esc(t(`ai.how.${e.how}`))} \xB7 ${esc(e.model)}${e.ok ? "" : ` \xB7 ${esc(e.error || "")}`}</span></span>${e.undoable ? `<button class="secondary" data-ai-undo="${esc(e.id)}" data-tip="tip.ai.undo">${esc(t("ai.undo"))}</button>` : ""}</li>`).join("");\n if (html !== aiLogHtml) { aiLogHtml = html; $("ai-log").innerHTML = html || `<li class="hint">${esc(t("ai.log.none"))}</li>`; }\n}\n$("ai-log").addEventListener("click", (e) => {\n const b = e.target.closest("[data-ai-undo]");\n if (b) act(b, $("ai-log-msg"), async () => { await api("/api/assistant/undo", { id: b.dataset.aiUndo }); await refreshAiLog(); await refresh(); }, t("ai.undone"));\n});\n$("chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendChat(); } });\n$("chat-file").addEventListener("change", (e) => {\n const list = [...e.target.files];\n e.target.value = "";\n addAttachments(list);\n});\n$("chat-list").addEventListener("click", async (e) => {\n const open = e.target.closest("[data-open]"), del = e.target.closest("[data-del]");\n if (open && !chatBusy) {\n chat = await api("/api/chats/get", { id: open.dataset.open });\n if (chat.system !== undefined) $("chat-system").value = chat.system;\n const known = chatModelsList.some((m) => m.key === chat.model && m.available);\n if (known) $("chat-model").value = chat.model;\n attachNote = chat.model && !known ? t("chat.model.gone") : "";\n $("chat-view").classList.remove("side");\n renderChatHint();\n renderChat();\n }\n if (del) {\n await api("/api/chats/delete", { id: del.dataset.del });\n if (chat && chat.id === del.dataset.del) chat = null;\n chatList = (await api("/api/chats")).chats;\n renderChat();\n }\n});\nasync function answerProposal(i, route, state) {\n const msg = chat.messages[i];\n msg.proposalState = "working";\n renderChat();\n try {\n await api(route, { id: msg.proposal.id });\n msg.proposalState = state;\n } catch (err) {\n msg.proposalState = /proposal_gone/.test(String(err.message)) ? "expired" : "failed";\n }\n renderChat();\n await saveCurrentChat();\n refreshAiLog();\n refresh();\n}\n$("chat-log").addEventListener("click", (e) => {\n const yes = e.target.closest("[data-ai-yes]"), no = e.target.closest("[data-ai-no]");\n if (yes) answerProposal(Number(yes.dataset.aiYes), "/api/assistant/confirm", "done");\n if (no) answerProposal(Number(no.dataset.aiNo), "/api/assistant/reject", "refused");\n const copy = e.target.closest("[data-copy]"), retry = e.target.closest("[data-retry]");\n if (copy) copyText(chat.messages[Number(copy.dataset.copy)].content);\n if (retry && !chatBusy) {\n chat.messages.pop();\n askChat(lastSent && lastSent.chatId === chat.id ? lastSent.plan : attachmentPlan(selectedModel(), []));\n }\n});\n\n// ---- explanations on hover: after a short pause over a button, say what it does ----------\nconst tipBox = document.createElement("div");\ntipBox.id = "tip";\ntipBox.setAttribute("role", "tooltip");\ntipBox.hidden = true;\ndocument.body.appendChild(tipBox);\nlet tipTimer = null;\nconst TIP_DELAY_MS = 800;\nfunction showTip(el) {\n let vars = {};\n try { vars = JSON.parse(el.dataset.tipVars || "{}"); } catch {}\n tipBox.textContent = t(el.dataset.tip, vars);\n tipBox.hidden = false;\n const r = el.getBoundingClientRect(), w = tipBox.offsetWidth, h = tipBox.offsetHeight;\n const left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), window.innerWidth - w - 8);\n const top = r.bottom + 8 + h > window.innerHeight ? r.top - h - 8 : r.bottom + 8;\n tipBox.style.left = `${left}px`;\n tipBox.style.top = `${Math.max(8, top)}px`;\n}\nfunction hideTip() { clearTimeout(tipTimer); tipBox.hidden = true; }\nconst scheduleTip = (e) => {\n const el = e.target.closest && e.target.closest("[data-tip]");\n if (!el) return;\n clearTimeout(tipTimer);\n tipTimer = setTimeout(() => showTip(el), TIP_DELAY_MS);\n};\ndocument.addEventListener("mouseover", scheduleTip);\ndocument.addEventListener("focusin", scheduleTip);\ndocument.addEventListener("mouseout", (e) => { if (e.target.closest && e.target.closest("[data-tip]")) hideTip(); });\ndocument.addEventListener("focusout", hideTip);\ndocument.addEventListener("mousedown", hideTip);\ndocument.addEventListener("keydown", (e) => { if (e.key === "Escape") hideTip(); });\nwindow.addEventListener("scroll", hideTip, { passive: true });\n\n// ---- start --------------------------------------------------------------------------------\n(async () => {\n if (!token) { await loadLang(langFromApp || "en"); $("locked").hidden = false; return; }\n let saved = null;\n try { saved = (await api("/api/language")).language; } catch {}\n const browser = (navigator.languages || [navigator.language]).map((l) => l.slice(0, 2)).find((l) => LANGS[l]);\n await loadLang(langFromApp || saved || browser || "en", !!langFromApp && langFromApp !== saved);\n refresh();\n refreshAiLog();\n setInterval(refreshAiLog, 15000);\n refreshUpdate();\n refreshTools();\n refreshRemote();\n setInterval(() => { if (!document.hidden && !$("remote-code").hidden) refreshRemote(); }, 4000);\n let view = "panel";\n try { view = localStorage.getItem("bitpull.view") || "panel"; } catch {}\n if (view === "projects") view = "panel";\n showView(view);\n // A hidden window asks nothing: it updates as soon as it is seen again.\n setInterval(() => { if (!document.hidden) refresh(); }, 5000);\n document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });\n setInterval(refreshSetup, 20000);\n setInterval(refreshUpdate, 60000);\n})();\n</script>\n</body>\n</html>\n';
|
|
26112
26120
|
return (0, import_promises22.readFile)((0, import_node_path20.join)((0, import_node_path20.dirname)((0, import_node_url2.fileURLToPath)(__bitpull_import_meta_url)), "..", "ui", "index.html"), "utf8");
|
|
26113
26121
|
}
|
|
26114
26122
|
var AiLimit = external_exports.object({ requests: external_exports.number().int().min(0).max(1e7).nullable(), tokens: external_exports.number().int().min(0).max(1e11).nullable() });
|
|
@@ -38526,7 +38534,7 @@ function openBrowser(url) {
|
|
|
38526
38534
|
const args = (0, import_node_os16.platform)() === "win32" ? ["/c", "start", "", url] : [url];
|
|
38527
38535
|
(0, import_node_child_process14.spawn)(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
38528
38536
|
}
|
|
38529
|
-
var agentVersion = () => true ? "0.1.
|
|
38537
|
+
var agentVersion = () => true ? "0.1.27" : "dev";
|
|
38530
38538
|
var dashboardUrl = (cfg) => `http://127.0.0.1:${cfg.controlPort}/#token=${cfg.controlToken}`;
|
|
38531
38539
|
var AppNotRunning = class extends Error {
|
|
38532
38540
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bitpull",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "BitPull (bitpull.cloud): share signed, AI-readable code, computing power and open AI models with AI tools through MCP. CLI and MCP server.",
|
|
5
5
|
"mcpName": "io.github.SamuealPK/bitpull",
|
|
6
6
|
"bin": {
|