@kevin5251984/guild 0.2.20 → 0.2.22

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.
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
9
9
  <link rel="icon" href="/favicon-16.png" type="image/png" sizes="16x16" />
10
10
  <title>Guild — 大廳</title>
11
- <link rel="stylesheet" href="/chat.css?v=quest-nest" />
11
+ <link rel="stylesheet" href="/chat.css?v=nav-fold" />
12
12
  <script src="/i18n.js"></script>
13
13
  <script src="/md.js"></script>
14
14
  </head>
@@ -18,17 +18,38 @@
18
18
  <div class="side-top">
19
19
  <input class="search" id="filter" placeholder="搜尋" data-i18n-placeholder="search" />
20
20
  </div>
21
- <div class="section-h">
22
- <span data-i18n="channels">委託</span>
23
- <button type="button" class="add-ch" id="toggle-create" title="新委託" data-i18n-title="newChannel">+</button>
21
+ <div class="nav-sec" data-nav-sec="channels">
22
+ <div class="section-h">
23
+ <button type="button" class="sec-fold" data-fold="channels" aria-expanded="true" aria-controls="channels">
24
+ <span class="sec-chev" aria-hidden="true">▾</span>
25
+ <span data-i18n="channels">委託</span>
26
+ </button>
27
+ <button type="button" class="add-ch" id="toggle-create" title="新委託" data-i18n-title="newChannel">+</button>
28
+ </div>
29
+ <form class="create-row" id="create-row">
30
+ <input id="new-channel" placeholder="委託名稱" data-i18n-placeholder="newChannelName" />
31
+ <button type="submit" data-i18n="create">建立</button>
32
+ </form>
33
+ <ul class="navlist" id="channels"></ul>
34
+ </div>
35
+ <div class="nav-sec" data-nav-sec="dms">
36
+ <div class="section-h">
37
+ <button type="button" class="sec-fold" data-fold="dms" aria-expanded="true" aria-controls="dms">
38
+ <span class="sec-chev" aria-hidden="true">▾</span>
39
+ <span data-i18n="dms">密談</span>
40
+ </button>
41
+ </div>
42
+ <ul class="navlist" id="dms"></ul>
43
+ </div>
44
+ <div class="nav-sec" data-nav-sec="cron">
45
+ <div class="section-h">
46
+ <button type="button" class="sec-fold" data-fold="cron" aria-expanded="true" aria-controls="cron-nav">
47
+ <span class="sec-chev" aria-hidden="true">▾</span>
48
+ <span data-i18n="cron.title">排程</span>
49
+ </button>
50
+ </div>
51
+ <ul class="navlist" id="cron-nav"></ul>
24
52
  </div>
25
- <form class="create-row" id="create-row">
26
- <input id="new-channel" placeholder="委託名稱" data-i18n-placeholder="newChannelName" />
27
- <button type="submit" data-i18n="create">建立</button>
28
- </form>
29
- <ul class="navlist" id="channels"></ul>
30
- <div class="section-h" data-i18n="dms">密談</div>
31
- <ul class="navlist" id="dms"></ul>
32
53
  <div class="side-foot">
33
54
  <a href="/studio" data-i18n="nav.studio">編制</a>
34
55
  <a href="/library" data-i18n="nav.library">工坊</a>
@@ -116,6 +137,7 @@
116
137
  <button type="button" class="attach-item" data-attach="rules"><span class="attach-ico">☰</span><span data-i18n="attach.rules">規則</span></button>
117
138
  <button type="button" class="attach-item" data-attach="term"><span class="attach-ico">&gt;_</span><span data-i18n="attach.term">終端</span></button>
118
139
  <button type="button" class="attach-item" data-attach="tree"><span class="attach-ico">🗺</span><span data-i18n="attach.tree">工作區</span></button>
140
+ <button type="button" class="attach-item" data-attach="cron"><span class="attach-ico">⏱</span><span data-i18n="attach.cron">排程</span></button>
119
141
  <button type="button" class="attach-item" data-attach="slash"><span class="attach-ico">/</span><span data-i18n="attach.slash">指令</span></button>
120
142
  </div>
121
143
  </div>
@@ -230,6 +252,19 @@
230
252
  <button type="button" id="channel-md-save" data-i18n="save">儲存</button>
231
253
  </div>
232
254
  </dialog>
255
+ <dialog id="cron-dialog">
256
+ <h2 data-i18n="cron.title">排程</h2>
257
+ <p data-i18n="cron.help">新增會請這席呼叫 cronjob。時間用自然語言寫即可,例如「每10分鐘跟我說一次 Hi」。guildd 每 60 秒看一次;到點後這席開一輪新的 @handle,prompt 必須自包含。</p>
258
+ <div id="cron-list" class="cron-list"></div>
259
+ <label for="cron-bot" data-i18n="cron.seat">席</label>
260
+ <select id="cron-bot"></select>
261
+ <label for="cron-ask" data-i18n="cron.ask">要排什麼</label>
262
+ <textarea id="cron-ask" rows="4" placeholder="每10分鐘跟我說一次 Hi" data-i18n-placeholder="cron.askPh"></textarea>
263
+ <div class="dialog-actions">
264
+ <button type="button" class="ghost" id="cron-cancel" data-i18n="cancel">取消</button>
265
+ <button type="button" id="cron-add" data-i18n="cron.add">請他排</button>
266
+ </div>
267
+ </dialog>
233
268
  <dialog id="bot-memory">
234
269
  <h2 data-i18n="memory.bot">MEMORY.md</h2>
235
270
  <p data-i18n="memory.botHelp">這名冒險者學會的站立筆記。有用的回合後會自動更新。</p>
@@ -320,6 +355,19 @@
320
355
  return new Set();
321
356
  }
322
357
  })(),
358
+ cronJobs: [],
359
+ navFold: (function () {
360
+ try {
361
+ const raw = JSON.parse(localStorage.getItem("guild-nav-fold") || "{}");
362
+ return {
363
+ channels: Boolean(raw.channels),
364
+ dms: Boolean(raw.dms),
365
+ cron: Boolean(raw.cron),
366
+ };
367
+ } catch {
368
+ return { channels: false, dms: false, cron: false };
369
+ }
370
+ })(),
323
371
  };
324
372
 
325
373
  function hue(key) {
@@ -1292,8 +1340,8 @@
1292
1340
  }
1293
1341
  function botModelMeta(bot) {
1294
1342
  const model = botModelLabel(bot) || "—";
1343
+ const ref = botModelRef(bot);
1295
1344
  const spec = (() => {
1296
- const ref = botModelRef(bot);
1297
1345
  if (!ref) return null;
1298
1346
  const hit = allModels().find(
1299
1347
  (row) => row.provider === ref.provider && row.model === ref.model,
@@ -1301,7 +1349,7 @@
1301
1349
  return (hit && hit.reasoning) || null;
1302
1350
  })();
1303
1351
  const reasonVal = clampEffort(
1304
- modelCfg && modelCfg.reasoning,
1352
+ ref && ref.reasoning,
1305
1353
  spec,
1306
1354
  Boolean(modelCfg && modelCfg.fast),
1307
1355
  );
@@ -1422,6 +1470,65 @@
1422
1470
  const nb = Number.isFinite(tb) ? tb : 0;
1423
1471
  return nb - na;
1424
1472
  }
1473
+ function navSecFolded(sec) {
1474
+ const q = (document.getElementById("filter").value || "").trim();
1475
+ if (q) return false;
1476
+ return Boolean(state.navFold[sec]);
1477
+ }
1478
+ function applyNavFold() {
1479
+ ["channels", "dms", "cron"].forEach((sec) => {
1480
+ const el = document.querySelector('[data-nav-sec="' + sec + '"]');
1481
+ if (!el) return;
1482
+ const folded = navSecFolded(sec);
1483
+ el.classList.toggle("folded", folded);
1484
+ const btn = el.querySelector("[data-fold]");
1485
+ if (btn) btn.setAttribute("aria-expanded", folded ? "false" : "true");
1486
+ });
1487
+ }
1488
+ function saveNavFold() {
1489
+ try {
1490
+ localStorage.setItem("guild-nav-fold", JSON.stringify(state.navFold));
1491
+ } catch {
1492
+ /* ignore quota */
1493
+ }
1494
+ }
1495
+ function cronJobHref(job) {
1496
+ const id = String((job && job.roomId) || "");
1497
+ if ((state.channels || []).some((ch) => ch.id === id)) {
1498
+ return "#c/" + encodeURIComponent(id);
1499
+ }
1500
+ if (id.indexOf("dm-") === 0) {
1501
+ return "#d/" + encodeURIComponent(id.slice(3));
1502
+ }
1503
+ return "#c/" + encodeURIComponent(id);
1504
+ }
1505
+ function cronNavPreview(job) {
1506
+ const bot = botById(job.botId);
1507
+ const handle = bot ? bot.handle : "bot";
1508
+ return t("cron.navWhere", {
1509
+ handle: handle,
1510
+ schedule: job.schedule || "",
1511
+ });
1512
+ }
1513
+ async function loadNavCron() {
1514
+ try {
1515
+ const res = await fetch("/cron");
1516
+ const body = await res.json().catch(() => ({}));
1517
+ const jobs = Array.isArray(body.jobs) ? body.jobs : [];
1518
+ const sig = jobs
1519
+ .map((job) =>
1520
+ [job.id, job.nextRunAt, job.paused ? "1" : "0", job.running ? "1" : "0", job.name].join(":"),
1521
+ )
1522
+ .join("|");
1523
+ if (sig === state.cronSig) return;
1524
+ state.cronSig = sig;
1525
+ state.cronJobs = jobs;
1526
+ } catch {
1527
+ if (state.cronSig != null) return;
1528
+ state.cronJobs = [];
1529
+ }
1530
+ renderNav();
1531
+ }
1425
1532
  function renderNav() {
1426
1533
  const q = document.getElementById("filter").value.toLowerCase();
1427
1534
  const all = state.channels || [];
@@ -1544,6 +1651,53 @@
1544
1651
  });
1545
1652
  })
1546
1653
  .join("");
1654
+ const cronBox = document.getElementById("cron-nav");
1655
+ if (cronBox) {
1656
+ const jobs = (state.cronJobs || []).filter((job) => {
1657
+ if (!q) return true;
1658
+ const bot = botById(job.botId);
1659
+ const hay = [
1660
+ job.name,
1661
+ job.schedule,
1662
+ job.prompt,
1663
+ bot && bot.handle,
1664
+ bot && bot.name,
1665
+ ]
1666
+ .filter(Boolean)
1667
+ .join(" ")
1668
+ .toLowerCase();
1669
+ return hay.includes(q);
1670
+ });
1671
+ cronBox.innerHTML = jobs.length
1672
+ ? jobs
1673
+ .map((job) => {
1674
+ const bot = botById(job.botId);
1675
+ const onRoom =
1676
+ cronJobHref(job) ===
1677
+ (state.kind === "dm"
1678
+ ? "#d/" + encodeURIComponent(state.id)
1679
+ : "#c/" + encodeURIComponent(state.id));
1680
+ return navRowHtml({
1681
+ href: cronJobHref(job),
1682
+ on: onRoom,
1683
+ av: bot
1684
+ ? '<span class="avatar" style="background:' +
1685
+ hue(bot.handle) +
1686
+ '">' +
1687
+ avatarFace(bot) +
1688
+ "</span>"
1689
+ : "",
1690
+ name: job.name || t("cron.title"),
1691
+ updatedAt: job.nextRunAt,
1692
+ preview: cronNavPreview(job),
1693
+ busy: Boolean(job.running),
1694
+ chip: job.paused ? t("cron.pause") : "",
1695
+ });
1696
+ })
1697
+ .join("")
1698
+ : '<li class="nav-empty">' + escapeHtml(t("cron.navEmpty")) + "</li>";
1699
+ }
1700
+ applyNavFold();
1547
1701
  }
1548
1702
 
1549
1703
  function memberRowHtml(bot, action, locked, full) {
@@ -1713,6 +1867,7 @@
1713
1867
  thinking: row.thinking || "",
1714
1868
  steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
1715
1869
  startedAt: row.startedAt || "",
1870
+ paused: Boolean(row.paused),
1716
1871
  }));
1717
1872
  }
1718
1873
  function rebuildBusyBots() {
@@ -1741,6 +1896,7 @@
1741
1896
  thinking: row.thinking || "",
1742
1897
  steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
1743
1898
  startedAt: row.startedAt || "",
1899
+ paused: Boolean(row.paused),
1744
1900
  room: { kind: r.kind, id: r.id },
1745
1901
  }));
1746
1902
  state.lives = others.concat(tagged);
@@ -1791,24 +1947,38 @@
1791
1947
  ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
1792
1948
  : "";
1793
1949
  const botId = live && live.botId ? live.botId : "";
1950
+ const paused = Boolean(live && live.paused);
1794
1951
  const actions = botId
1795
1952
  ? '<div class="turn-actions">' +
1953
+ (paused
1954
+ ? '<button type="button" class="turn-continue" data-live-continue="' +
1955
+ escapeHtml(botId) +
1956
+ '">' +
1957
+ t("live.continue") +
1958
+ "</button>"
1959
+ : '<button type="button" class="turn-pause" data-live-pause="' +
1960
+ escapeHtml(botId) +
1961
+ '">' +
1962
+ t("live.pause") +
1963
+ "</button>") +
1796
1964
  '<button type="button" class="turn-stop" data-live-stop="' +
1797
1965
  escapeHtml(botId) +
1798
1966
  '">' +
1799
1967
  t("live.stop") +
1800
1968
  "</button>" +
1801
- '<button type="button" class="turn-steer" data-live-steer="' +
1802
- escapeHtml(botId) +
1803
- '">' +
1804
- t("live.steer") +
1805
- "</button>" +
1969
+ (paused
1970
+ ? ""
1971
+ : '<button type="button" class="turn-steer" data-live-steer="' +
1972
+ escapeHtml(botId) +
1973
+ '">' +
1974
+ t("live.steer") +
1975
+ "</button>") +
1806
1976
  "</div>"
1807
1977
  : "";
1808
1978
  return (
1809
1979
  '<div class="turn-live">' +
1810
1980
  '<div class="turn-status">' +
1811
- t("deepDiving") +
1981
+ (paused ? t("live.paused") : t("deepDiving")) +
1812
1982
  clock +
1813
1983
  "</div>" +
1814
1984
  liveStepsHtml(live && live.steps) +
@@ -1829,7 +1999,9 @@
1829
1999
  return (
1830
2000
  '<article class="msg bot live" data-bot-id="' +
1831
2001
  escapeHtml(bot.id) +
1832
- '" role="status">' +
2002
+ '"' +
2003
+ (live.paused ? ' data-paused="1"' : "") +
2004
+ ' role="status">' +
1833
2005
  actor.av +
1834
2006
  '<div class="msg-main">' +
1835
2007
  actor.head +
@@ -1866,6 +2038,11 @@
1866
2038
  const block = article.querySelector(".turn-live");
1867
2039
  if (!block) return;
1868
2040
  const status = block.querySelector(".turn-status");
2041
+ const paused = Boolean(live.paused);
2042
+ if ((article.getAttribute("data-paused") === "1") !== paused) {
2043
+ renderThread();
2044
+ return;
2045
+ }
1869
2046
  if (status) {
1870
2047
  const started = live.startedAt ? Date.parse(live.startedAt) : NaN;
1871
2048
  const elapsed = Number.isFinite(started)
@@ -1875,7 +2052,7 @@
1875
2052
  elapsed >= 15000
1876
2053
  ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
1877
2054
  : "";
1878
- status.innerHTML = t("deepDiving") + clock;
2055
+ status.innerHTML = (paused ? t("live.paused") : t("deepDiving")) + clock;
1879
2056
  }
1880
2057
  const html = liveStepsHtml(live.steps);
1881
2058
  const existing = block.querySelector(".live-steps");
@@ -2064,6 +2241,7 @@
2064
2241
  syncThreadChrome();
2065
2242
  return;
2066
2243
  }
2244
+ const keptHtml = holdHtmlFrames(root);
2067
2245
  root.innerHTML =
2068
2246
  rows
2069
2247
  .map((msg, index) => {
@@ -2219,6 +2397,7 @@
2219
2397
  root.scrollTop = Math.max(0, root.scrollHeight - fromBottom);
2220
2398
  }
2221
2399
  syncThreadChrome();
2400
+ putHtmlFrames(root, keptHtml);
2222
2401
  hydrateHtmlPreviews(root);
2223
2402
  }
2224
2403
  function hydrateHtmlPreviews(root) {
@@ -2226,15 +2405,16 @@
2226
2405
  root.querySelectorAll(".md-html-preview").forEach((box) => {
2227
2406
  const src = box.querySelector(".md-html-src");
2228
2407
  const frame = box.querySelector(".md-html-frame");
2229
- if (!src || !frame || frame.dataset.ready) return;
2230
- frame.dataset.ready = "1";
2408
+ if (!src || !frame) return;
2231
2409
  const raw = src.value;
2232
2410
  const lang = (box.querySelector(".md-fence-lang") || {}).textContent || "";
2233
- frame.srcdoc = /svg/i.test(lang)
2234
- ? '<!doctype html><html><body style="margin:0;background:#fff">' +
2235
- raw +
2236
- "</body></html>"
2237
- : raw;
2411
+ const doc =
2412
+ typeof htmlPreviewSrcdoc === "function"
2413
+ ? htmlPreviewSrcdoc(raw, lang)
2414
+ : raw;
2415
+ if (frame.dataset.ready && frame.srcdoc === doc) return;
2416
+ frame.dataset.ready = "1";
2417
+ frame.srcdoc = doc;
2238
2418
  });
2239
2419
  }
2240
2420
  function openHtmlZoom(raw) {
@@ -2262,6 +2442,7 @@
2262
2442
  renderNav();
2263
2443
  renderHead();
2264
2444
  setHash();
2445
+ loadNavCron().catch(() => {});
2265
2446
  }
2266
2447
  async function deleteChannel(id, name) {
2267
2448
  if (!id || id === "channel-general") {
@@ -2328,6 +2509,18 @@
2328
2509
  ? "/dms/" + encodeURIComponent(r.id) + "/abort"
2329
2510
  : "/channels/" + encodeURIComponent(r.id) + "/abort";
2330
2511
  }
2512
+ function pauseUrl(room) {
2513
+ const r = room || state.busyRoom || { kind: state.kind, id: state.id };
2514
+ return r.kind === "dm"
2515
+ ? "/dms/" + encodeURIComponent(r.id) + "/pause"
2516
+ : "/channels/" + encodeURIComponent(r.id) + "/pause";
2517
+ }
2518
+ function continueUrl(room) {
2519
+ const r = room || state.busyRoom || { kind: state.kind, id: state.id };
2520
+ return r.kind === "dm"
2521
+ ? "/dms/" + encodeURIComponent(r.id) + "/continue"
2522
+ : "/channels/" + encodeURIComponent(r.id) + "/continue";
2523
+ }
2331
2524
  function stopTurn(botId) {
2332
2525
  const room = currentRoomRef();
2333
2526
  (state.aborts || []).forEach((item) => {
@@ -2353,6 +2546,30 @@
2353
2546
  }).catch(() => {});
2354
2547
  setBusy(false, botId ? [botId] : [], { room: room });
2355
2548
  }
2549
+ function pauseTurn(botId) {
2550
+ const room = currentRoomRef();
2551
+ fetch(pauseUrl(room), {
2552
+ method: "POST",
2553
+ headers: { "content-type": "application/json" },
2554
+ body: JSON.stringify(botId ? { botId: botId } : {}),
2555
+ }).catch(() => {});
2556
+ (state.lives || []).forEach((row) => {
2557
+ if (!botId || row.botId === botId) row.paused = true;
2558
+ });
2559
+ if (state.live && (!botId || state.live.botId === botId)) {
2560
+ state.live.paused = true;
2561
+ }
2562
+ renderThread();
2563
+ }
2564
+ async function continueTurn(botId) {
2565
+ const room = currentRoomRef();
2566
+ (state.lives || []).forEach((row) => {
2567
+ if (row.botId === botId) row.paused = false;
2568
+ });
2569
+ if (state.live && state.live.botId === botId) state.live.paused = false;
2570
+ renderThread();
2571
+ await postChat(continueUrl(room), { botId: botId }, room, [botId]);
2572
+ }
2356
2573
  async function insertIntoBotTurn(botId) {
2357
2574
  if (!composerHasPayload()) {
2358
2575
  const draft = document.getElementById("draft");
@@ -2534,8 +2751,11 @@
2534
2751
  const pending = (state.messages || []).filter(
2535
2752
  (msg) => String(msg.id).indexOf("pending-") === 0,
2536
2753
  );
2537
- if (!extras.length && !pending.length) return;
2538
2754
  const serverYou = incoming.filter((msg) => msg.author === "you");
2755
+ const dropPending = pending.filter((msg) =>
2756
+ serverYou.some((row) => row.body === msg.body),
2757
+ );
2758
+ if (!extras.length && !dropPending.length) return;
2539
2759
  state.messages = (state.messages || [])
2540
2760
  .filter((msg) => {
2541
2761
  if (String(msg.id).indexOf("pending-") === 0) {
@@ -2817,6 +3037,22 @@
2817
3037
  stopTurn(liveStop.getAttribute("data-live-stop"));
2818
3038
  return;
2819
3039
  }
3040
+ const livePause = event.target.closest("[data-live-pause]");
3041
+ if (livePause) {
3042
+ event.preventDefault();
3043
+ event.stopImmediatePropagation();
3044
+ pauseTurn(livePause.getAttribute("data-live-pause"));
3045
+ return;
3046
+ }
3047
+ const liveContinue = event.target.closest("[data-live-continue]");
3048
+ if (liveContinue) {
3049
+ event.preventDefault();
3050
+ event.stopImmediatePropagation();
3051
+ continueTurn(liveContinue.getAttribute("data-live-continue")).catch(
3052
+ (err) => alert(err.message),
3053
+ );
3054
+ return;
3055
+ }
2820
3056
  const liveSteer = event.target.closest("[data-live-steer]");
2821
3057
  if (liveSteer) {
2822
3058
  event.preventDefault();
@@ -2925,6 +3161,19 @@
2925
3161
  }
2926
3162
  });
2927
3163
  document.getElementById("filter").addEventListener("input", renderNav);
3164
+ document.getElementById("nav-drawer").addEventListener("click", (event) => {
3165
+ const btn = event.target.closest("[data-fold]");
3166
+ if (!btn || event.target.closest(".add-ch")) return;
3167
+ event.preventDefault();
3168
+ const sec = btn.getAttribute("data-fold");
3169
+ if (!sec) return;
3170
+ state.navFold[sec] = !state.navFold[sec];
3171
+ saveNavFold();
3172
+ applyNavFold();
3173
+ if (sec === "cron" && !state.navFold.cron) {
3174
+ loadNavCron().catch(() => {});
3175
+ }
3176
+ });
2928
3177
  document.getElementById("toggle-create").addEventListener("click", () => {
2929
3178
  document.getElementById("create-row").classList.toggle("open");
2930
3179
  document.getElementById("new-channel").focus();
@@ -3084,7 +3333,12 @@
3084
3333
  if (bot) showBotCard(bot, event.currentTarget);
3085
3334
  });
3086
3335
  document.getElementById("thread").addEventListener("click", (event) => {
3087
- if (event.target.closest("[data-live-stop], [data-live-steer]")) return;
3336
+ if (
3337
+ event.target.closest(
3338
+ "[data-live-stop], [data-live-pause], [data-live-continue], [data-live-steer]",
3339
+ )
3340
+ )
3341
+ return;
3088
3342
  const av = event.target.closest(
3089
3343
  ".avatar[data-bot-id], button.name[data-bot-id]",
3090
3344
  );
@@ -3184,6 +3438,64 @@
3184
3438
  channelMdDialog.close();
3185
3439
  await loadWorkspace();
3186
3440
  });
3441
+ document.getElementById("cron-cancel").addEventListener("click", () => {
3442
+ document.getElementById("cron-dialog").close();
3443
+ });
3444
+ document.getElementById("cron-add").addEventListener("click", async () => {
3445
+ const botId = document.getElementById("cron-bot").value;
3446
+ const ask = document.getElementById("cron-ask").value.trim();
3447
+ const bot = botById(botId);
3448
+ if (!bot) {
3449
+ alert(t("cron.needSeat"));
3450
+ return;
3451
+ }
3452
+ if (!ask) {
3453
+ alert(t("cron.needAsk"));
3454
+ return;
3455
+ }
3456
+ const raw = "@" + bot.handle + " " + t("cron.askBody", { ask: ask });
3457
+ document.getElementById("cron-ask").value = "";
3458
+ document.getElementById("cron-dialog").close();
3459
+ const split = splitSendTargets([botId]);
3460
+ if (split.fresh.length) {
3461
+ await flushSend(raw, [], "", [botId], botId);
3462
+ } else {
3463
+ enqueuePending({
3464
+ raw: raw,
3465
+ packed: [],
3466
+ replyTo: "",
3467
+ botIds: [botId],
3468
+ assigneeId: botId,
3469
+ });
3470
+ }
3471
+ });
3472
+ document.getElementById("cron-list").addEventListener("click", async (event) => {
3473
+ const btn = event.target.closest("[data-cron-act]");
3474
+ const row = event.target.closest("[data-cron-id]");
3475
+ if (!btn || !row) return;
3476
+ const id = row.getAttribute("data-cron-id");
3477
+ const act = btn.getAttribute("data-cron-act");
3478
+ if (act === "remove") {
3479
+ const res = await fetch("/cron/" + encodeURIComponent(id), { method: "DELETE" });
3480
+ if (!res.ok) {
3481
+ const body = await res.json().catch(() => ({}));
3482
+ alert(body.error || "cron failed");
3483
+ return;
3484
+ }
3485
+ } else {
3486
+ const res = await fetch(
3487
+ "/cron/" + encodeURIComponent(id) + "/" + act,
3488
+ { method: "POST" },
3489
+ );
3490
+ const body = await res.json().catch(() => ({}));
3491
+ if (!res.ok) {
3492
+ alert(body.error || "cron failed");
3493
+ return;
3494
+ }
3495
+ }
3496
+ await renderCronList();
3497
+ loadNavCron().catch(() => {});
3498
+ });
3187
3499
  document.getElementById("bot-memory-cancel").addEventListener("click", () => {
3188
3500
  botMemoryDialog.close();
3189
3501
  });
@@ -4219,6 +4531,85 @@
4219
4531
  body: chunks.join("\n\n") || t("attach.noPersona"),
4220
4532
  });
4221
4533
  }
4534
+ function cronRoomId() {
4535
+ if (state.kind === "dm") return "dm-" + state.id;
4536
+ return state.id;
4537
+ }
4538
+ async function loadCronJobs() {
4539
+ const id = cronRoomId();
4540
+ if (!id) return [];
4541
+ const res = await fetch("/cron?room=" + encodeURIComponent(id));
4542
+ const body = await res.json().catch(() => ({}));
4543
+ return Array.isArray(body.jobs) ? body.jobs : [];
4544
+ }
4545
+ function cronWhen(iso) {
4546
+ const d = new Date(iso || "");
4547
+ if (!Number.isFinite(d.getTime())) return String(iso || "");
4548
+ return d.toLocaleString();
4549
+ }
4550
+ async function renderCronList() {
4551
+ const box = document.getElementById("cron-list");
4552
+ const jobs = await loadCronJobs();
4553
+ if (!jobs.length) {
4554
+ box.innerHTML = '<div class="cron-empty">' + t("cron.empty") + "</div>";
4555
+ return;
4556
+ }
4557
+ box.innerHTML = jobs
4558
+ .map((job) => {
4559
+ const pauseLabel = job.paused ? t("cron.resume") : t("cron.pause");
4560
+ const pauseAct = job.paused ? "resume" : "pause";
4561
+ const err =
4562
+ job.lastStatus === "failed" && job.lastError
4563
+ ? " · " + escapeHtml(job.lastError)
4564
+ : "";
4565
+ return (
4566
+ '<div class="cron-row" data-cron-id="' +
4567
+ escapeHtml(job.id) +
4568
+ '"><div><b>' +
4569
+ escapeHtml(job.name) +
4570
+ "</b><span>" +
4571
+ escapeHtml(job.schedule) +
4572
+ " · " +
4573
+ t("cron.next", { when: cronWhen(job.nextRunAt) }) +
4574
+ (job.paused ? " · paused" : "") +
4575
+ err +
4576
+ '</span></div><div class="cron-acts">' +
4577
+ '<button type="button" data-cron-act="' +
4578
+ pauseAct +
4579
+ '">' +
4580
+ pauseLabel +
4581
+ '</button><button type="button" data-cron-act="run">' +
4582
+ t("cron.run") +
4583
+ '</button><button type="button" class="ghost danger" data-cron-act="remove">' +
4584
+ t("cron.remove") +
4585
+ "</button></div></div>"
4586
+ );
4587
+ })
4588
+ .join("");
4589
+ }
4590
+ async function openCronDialog() {
4591
+ const dialog = document.getElementById("cron-dialog");
4592
+ const sel = document.getElementById("cron-bot");
4593
+ const ch = currentChannel();
4594
+ const members = ch ? ch.members || [] : [];
4595
+ const dmBot =
4596
+ state.kind === "dm" && state.id ? botById(state.id) : null;
4597
+ const bots = members.length ? members : dmBot ? [dmBot] : [];
4598
+ sel.innerHTML = bots
4599
+ .map(
4600
+ (bot) =>
4601
+ '<option value="' +
4602
+ escapeHtml(bot.id) +
4603
+ '">@' +
4604
+ escapeHtml(bot.handle) +
4605
+ "</option>",
4606
+ )
4607
+ .join("");
4608
+ await renderCronList();
4609
+ dialog.showModal();
4610
+ const ask = document.getElementById("cron-ask");
4611
+ if (ask) ask.focus();
4612
+ }
4222
4613
  function openSlashSheet() {
4223
4614
  const rows = [
4224
4615
  { id: "channel-md", label: t("slash.editMd"), hint: t("slash.editMdHint") },
@@ -4226,6 +4617,7 @@
4226
4617
  { id: "git", label: t("slash.git"), hint: "status + diff" },
4227
4618
  { id: "here", label: t("slash.here"), hint: t("slash.hereHint") },
4228
4619
  { id: "library", label: t("slash.library"), hint: "/library" },
4620
+ { id: "cron", label: t("slash.cron"), hint: t("slash.cronHint") },
4229
4621
  ];
4230
4622
  if (state.kind !== "channel") {
4231
4623
  rows.splice(0, 1);
@@ -4255,6 +4647,11 @@
4255
4647
  }
4256
4648
  if (id === "library") {
4257
4649
  window.location.href = "/library";
4650
+ return;
4651
+ }
4652
+ if (id === "cron") {
4653
+ hideAttach();
4654
+ openCronDialog();
4258
4655
  }
4259
4656
  }
4260
4657
  async function runAttach(kind) {
@@ -4276,6 +4673,10 @@
4276
4673
  renderAttachSheet();
4277
4674
  return;
4278
4675
  }
4676
+ if (kind === "cron") {
4677
+ hideAttach();
4678
+ return openCronDialog();
4679
+ }
4279
4680
  if (kind === "slash") return openSlashSheet();
4280
4681
  }
4281
4682
  function startLiveTimers() {
@@ -4452,6 +4853,9 @@
4452
4853
  }
4453
4854
  setBusy(false, botIds, { room: sent });
4454
4855
  }
4856
+ if (state.kind === sent.kind && state.id === sent.id) {
4857
+ await resumeCurrentLive().catch(() => {});
4858
+ }
4455
4859
  if (ok) await flushQueued(sent);
4456
4860
  }
4457
4861
  async function flushQueued(sent) {
@@ -5084,6 +5488,15 @@
5084
5488
  hint: "/library",
5085
5489
  glyph: "/",
5086
5490
  },
5491
+ {
5492
+ kind: "cmd",
5493
+ section: "cmd",
5494
+ cmdId: "cron",
5495
+ insert: "",
5496
+ name: t("slash.cron"),
5497
+ hint: t("slash.cronHint"),
5498
+ glyph: "/",
5499
+ },
5087
5500
  ];
5088
5501
  if (state.kind !== "channel") {
5089
5502
  return rows.filter(
@@ -5550,8 +5963,9 @@
5550
5963
  document.getElementById("model-fast").checked = Boolean(modelCfg.fast);
5551
5964
  const spec = activeReasoningSpec();
5552
5965
  const choices = effortChoices(spec);
5966
+ const currentRef = activeModelRef();
5553
5967
  const reasonVal = clampEffort(
5554
- modelCfg.reasoning,
5968
+ currentRef && currentRef.reasoning,
5555
5969
  spec,
5556
5970
  Boolean(modelCfg.fast),
5557
5971
  );
@@ -5621,11 +6035,24 @@
5621
6035
  async function applyChatModel(ref) {
5622
6036
  if (state.kind !== "dm") return;
5623
6037
  const bot = botById(state.id);
5624
- if (!bot) return;
6038
+ if (!bot || !ref || !ref.provider || !ref.model) return;
6039
+ const spec = (() => {
6040
+ const hit = allModels().find(
6041
+ (row) => row.provider === ref.provider && row.model === ref.model,
6042
+ );
6043
+ return (hit && hit.reasoning) || null;
6044
+ })();
6045
+ const reason = clampEffort(
6046
+ document.getElementById("model-reasoning").value || ref.reasoning,
6047
+ spec,
6048
+ Boolean(document.getElementById("model-fast").checked),
6049
+ );
6050
+ const next = { provider: ref.provider, model: ref.model };
6051
+ if (reason) next.reasoning = reason;
5625
6052
  const res = await fetch("/bots/" + encodeURIComponent(bot.id), {
5626
6053
  method: "PATCH",
5627
6054
  headers: { "content-type": "application/json" },
5628
- body: JSON.stringify({ model: ref }),
6055
+ body: JSON.stringify({ model: next }),
5629
6056
  });
5630
6057
  if (!res.ok) {
5631
6058
  const body = await res.json().catch(() => ({}));
@@ -5635,16 +6062,16 @@
5635
6062
  const updated = await res.json();
5636
6063
  const i = state.bots.findIndex((item) => item.id === bot.id);
5637
6064
  if (i >= 0) state.bots[i] = { ...state.bots[i], model: updated.model };
5638
- await fetch("/settings/models", {
5639
- method: "PUT",
5640
- headers: { "content-type": "application/json" },
5641
- body: JSON.stringify({
5642
- reasoning: document.getElementById("model-reasoning").value,
5643
- fast: document.getElementById("model-fast").checked,
5644
- }),
5645
- }).then(async (r) => {
5646
- if (r.ok) modelCfg = await r.json();
5647
- });
6065
+ const fast = document.getElementById("model-fast").checked;
6066
+ if (Boolean(modelCfg.fast) !== Boolean(fast)) {
6067
+ await fetch("/settings/models", {
6068
+ method: "PUT",
6069
+ headers: { "content-type": "application/json" },
6070
+ body: JSON.stringify({ fast: fast }),
6071
+ }).then(async (r) => {
6072
+ if (r.ok) modelCfg = await r.json();
6073
+ });
6074
+ }
5648
6075
  renderModelList();
5649
6076
  renderNav();
5650
6077
  }
@@ -5967,6 +6394,11 @@
5967
6394
  setNavOpen(false);
5968
6395
  openFromHash().catch((err) => alert(err.message));
5969
6396
  });
6397
+ applyNavFold();
6398
+ setInterval(() => {
6399
+ if (document.hidden || navSecFolded("cron")) return;
6400
+ loadNavCron().catch(() => {});
6401
+ }, 15000);
5970
6402
  Promise.all([openFromHash(), loadModelPick()])
5971
6403
  .then(() => loadSlashCatalog().catch(() => {}))
5972
6404
  .catch((err) => {