openclacky 1.2.10 → 1.2.12

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/lib/clacky/agent/session_serializer.rb +1 -1
  4. data/lib/clacky/agent/tool_registry.rb +10 -0
  5. data/lib/clacky/agent.rb +59 -22
  6. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  7. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  8. data/lib/clacky/client.rb +25 -3
  9. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  10. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  11. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  12. data/lib/clacky/message_history.rb +57 -0
  13. data/lib/clacky/openai_stream_aggregator.rb +26 -2
  14. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  15. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  16. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  17. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  18. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  19. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  20. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  21. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  22. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  23. data/lib/clacky/server/http_server.rb +73 -7
  24. data/lib/clacky/server/session_registry.rb +4 -6
  25. data/lib/clacky/tools/trash_manager.rb +1 -1
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +21 -3
  28. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  29. data/lib/clacky/web/brand.js +22 -2
  30. data/lib/clacky/web/favicon.ico +0 -0
  31. data/lib/clacky/web/i18n.js +4 -0
  32. data/lib/clacky/web/index.html +4 -3
  33. data/lib/clacky/web/logo_nav_dark.png +0 -0
  34. data/lib/clacky/web/model-tester.js +8 -1
  35. data/lib/clacky/web/sessions.js +169 -41
  36. data/lib/clacky/web/theme.js +1 -0
  37. data/scripts/build/lib/gem.sh +9 -2
  38. data/scripts/build/src/install_full.sh.cc +2 -0
  39. data/scripts/build/src/uninstall.sh.cc +1 -1
  40. data/scripts/install.ps1 +19 -5
  41. data/scripts/install.sh +9 -2
  42. data/scripts/install_full.sh +11 -2
  43. data/scripts/install_rails_deps.sh +9 -2
  44. data/scripts/uninstall.sh +10 -3
  45. metadata +7 -2
@@ -99,16 +99,15 @@ module Clacky
99
99
  @sessions.key?(session_id)
100
100
  end
101
101
 
102
- # Restore all sessions from disk (up to n per source type) into the registry.
103
- # Used at startup. Already-registered sessions are skipped.
104
- def restore_from_disk(n: 5)
102
+ # Restore at most n sessions per source as a hot cache at startup.
103
+ # Everything else is loaded on demand via ensure(id).
104
+ def restore_from_disk(n: 2)
105
105
  return unless @session_manager && @session_restorer
106
106
 
107
107
  all = @session_manager.all_sessions
108
108
  .sort_by { |s| s[:created_at] || "" }
109
109
  .reverse
110
110
 
111
- # Take up to n per source type
112
111
  counts = Hash.new(0)
113
112
  all.each do |session_data|
114
113
  src = (session_data[:source] || "manual").to_s
@@ -291,6 +290,7 @@ module Clacky
291
290
  latest_latency: ls&.dig(:latest_latency),
292
291
  reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
293
292
  pinned: s[:pinned] || false,
293
+ channel_info: s[:channel_info],
294
294
  }
295
295
  end
296
296
 
@@ -375,8 +375,6 @@ module Clacky
375
375
  count_by_status(:running) >= max_running_agents
376
376
  end
377
377
 
378
- # Evict oldest idle agents beyond MAX_IDLE_AGENTS.
379
- # Persists session data to disk before releasing the agent from memory.
380
378
  def evict_excess_idle!
381
379
  to_evict = []
382
380
 
@@ -514,7 +514,7 @@ module Clacky
514
514
  end
515
515
 
516
516
  session.merge(file_size: total)
517
- end.sort_by { |s| s[:created_at] || "" }.reverse
517
+ end.sort_by { |s| s[:deleted_at] || s[:created_at] || "" }.reverse
518
518
  end
519
519
  private_class_method :_trash_sessions
520
520
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.10"
4
+ VERSION = "1.2.12"
5
5
  end
@@ -256,8 +256,7 @@ body {
256
256
  letter-spacing: 0;
257
257
  }
258
258
  .header-logo-img {
259
- height: 1.375rem;
260
- max-width: 6.875rem;
259
+ height: 2.5rem;
261
260
  object-fit: contain;
262
261
  display: block;
263
262
  flex-shrink: 0;
@@ -2172,7 +2171,26 @@ body {
2172
2171
  gap: 0.375rem;
2173
2172
  }
2174
2173
  .tool-item-name { color: var(--color-warning); font-weight: 600; }
2175
- .tool-item-arg { color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 25rem; }
2174
+ .tool-item-arg { color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1 1 auto; }
2175
+ .tool-item-header.tool-item-expandable { cursor: pointer; user-select: none; }
2176
+ .tool-item-header.tool-item-expandable:hover .tool-item-name,
2177
+ .tool-item-header.tool-item-expandable:hover .tool-item-arg { color: var(--color-text-primary); }
2178
+ .tool-item.expanded .tool-item-arg { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
2179
+ .tool-item-details {
2180
+ margin: 0.25rem 0 2px 0;
2181
+ padding: 0.5rem;
2182
+ background: var(--color-bg-secondary);
2183
+ border: 1px solid var(--color-border-secondary);
2184
+ border-radius: 4px;
2185
+ font-size: 0.6875rem;
2186
+ font-family: monospace;
2187
+ color: var(--color-text-primary);
2188
+ white-space: pre-wrap;
2189
+ word-break: break-all;
2190
+ line-height: 1.5;
2191
+ max-height: 18rem;
2192
+ overflow-y: auto;
2193
+ }
2176
2194
  .tool-item-status { margin-left: auto; font-size: 0.6875rem; flex-shrink: 0; }
2177
2195
  .tool-item-status.ok { color: var(--color-success); }
2178
2196
  .tool-item-status.err { color: var(--color-error); }
@@ -329,13 +329,16 @@ const Brand = (() => {
329
329
  };
330
330
  img.src = info.logo_url;
331
331
  }
332
- } else {
333
- // No logo configured — hide logo image and remove has-logo class
332
+ } else if (info.product_name) {
333
+ // Brand configured but no logo — hide the image, brand name text is enough
334
334
  if (logoImg) {
335
335
  logoImg.style.display = "none";
336
336
  logoImg.src = "";
337
337
  }
338
338
  if (brandWrap) brandWrap.classList.remove("has-logo");
339
+ } else {
340
+ // No brand at all — show default OpenClacky logo
341
+ _applyDefaultLogo();
339
342
  }
340
343
 
341
344
  // Always show brand name text; hide it only when no brand name is set
@@ -355,6 +358,18 @@ const Brand = (() => {
355
358
  });
356
359
  }
357
360
 
361
+ // Apply the default OpenClacky logo based on current theme.
362
+ function _applyDefaultLogo() {
363
+ const logoImg = document.getElementById("header-logo-img");
364
+ const brandWrap = document.getElementById("header-brand");
365
+ if (!logoImg) return;
366
+
367
+ logoImg.src = "/logo_nav_dark.png";
368
+ logoImg.alt = "OpenClacky";
369
+ logoImg.style.display = "";
370
+ if (brandWrap) brandWrap.classList.add("has-logo");
371
+ }
372
+
358
373
  // Replace the browser tab favicon with the given URL.
359
374
  // Works for both image URLs and SVG data URIs.
360
375
  function _applyFavicon(url) {
@@ -510,5 +525,10 @@ const Brand = (() => {
510
525
  }
511
526
  }
512
527
 
528
+ // Both themes use the same transparent A版 logo via CSS, no swap needed.
529
+ window.addEventListener("clacky-theme-change", e => {
530
+ // No-op
531
+ });
532
+
513
533
  return { check, refresh, applyBrandName: _applyBrandName, applyHeaderLogo: _applyHeaderLogo, applyOwnerBadge: _applyOwnerBadge, applyGetSerialLink: _applyGetSerialLink, clearBrandCache: _clearBrandCache, goToLicenseInput: _goToLicenseInput, get userLicensed() { return _userLicensed; }, get branded() { return _branded; } };
514
534
  })();
Binary file
@@ -99,6 +99,8 @@ const I18n = (() => {
99
99
  "workspace.downloadFailed": "Download failed",
100
100
  "sib.model.tooltip": "Click to switch model",
101
101
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
102
+ "sib.variant.header": "Quick switch",
103
+ "sib.variant.default": "default",
102
104
  "sib.signal.tooltip": "Recent LLM latency",
103
105
  "sib.reasoning.tooltip": "Click to change reasoning effort",
104
106
  "sib.reasoning.label": "Reasoning",
@@ -831,6 +833,8 @@ const I18n = (() => {
831
833
  "workspace.downloadFailed": "下载失败",
832
834
  "sib.model.tooltip": "点击切换模型",
833
835
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
836
+ "sib.variant.header": "快速切换",
837
+ "sib.variant.default": "默认",
834
838
  "sib.signal.tooltip": "最近一次 LLM 响应延迟",
835
839
  "sib.reasoning.tooltip": "点击调整思考等级",
836
840
  "sib.reasoning.label": "思考",
@@ -4,7 +4,8 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title id="page-title">{{BRAND_NAME}}</title>
7
- <link rel="icon" type="image/svg+xml" href="/icon.svg">
7
+ <link rel="icon" href="/favicon.ico">
8
+ <link rel="apple-touch-icon" href="/apple-touch-icon-180.png">
8
9
  <link rel="stylesheet" href="/vendor/katex/katex.min.css">
9
10
  <link rel="stylesheet" href="/vendor/hljs/hljs-theme.css">
10
11
  <link rel="stylesheet" href="/app.css">
@@ -30,8 +31,8 @@
30
31
  <path d="M9 3v18"/>
31
32
  </svg>
32
33
  </button>
33
- <div id="header-brand" style="cursor:pointer" onclick="Router.navigate('chat')">
34
- <img class="header-logo-img" id="header-logo-img" src="" alt="" style="display:none">
34
+ <div id="header-brand" class="has-logo" style="cursor:pointer" onclick="Router.navigate('chat')">
35
+ <img class="header-logo-img" id="header-logo-img" src="/logo_nav_dark.png" alt="OpenClacky">
35
36
  <span class="header-logo-divider"></span>
36
37
  <span class="header-logo" id="header-logo">{{BRAND_NAME}}</span>
37
38
  <!-- Owner badge: visible only for creator-tier licenses (user_licensed=true).
Binary file
@@ -23,7 +23,14 @@ window.ModelTester = (function () {
23
23
  return { ok: false, message: e.message };
24
24
  }
25
25
 
26
- if (!data.ok) return { ok: false, message: data.message || "" };
26
+ if (!data.ok) {
27
+ const msg = data.message || "";
28
+ const code = data.error_code || "";
29
+ if (code === "insufficient_credit") {
30
+ return { ok: false, message: I18n.t("error.insufficient_credit"), error_code: code };
31
+ }
32
+ return { ok: false, message: msg, error_code: code };
33
+ }
27
34
 
28
35
  if (data.effective_base_url && data.effective_base_url !== base_url) {
29
36
  return { ok: true, base_url: data.effective_base_url, message: data.message || "", rewrote: true };
@@ -27,6 +27,16 @@ const Sessions = (() => {
27
27
  let _searchOpen = false; // is the search panel visible?
28
28
  let _cronView = false; // are we in the cron sub-view?
29
29
  let _cronCount = 0; // total cron sessions from server
30
+ // ── Cron sub-view independent pagination (commit 2) ──────────────────────
31
+ // The folded cron sub-view paginates *independently* of the outer list so
32
+ // that "Load more" inside it never advances the outer list's cursor, and so
33
+ // all cron sessions can be loaded even when they're sparse across the mixed
34
+ // outer pages. Cron rows fetched here are pushed into the shared `_sessions`
35
+ // array (with dedup), so WS updates / patch / remove keep working unchanged;
36
+ // we only track a separate cursor + hasMore/loading flags for the sub-view.
37
+ let _cronBefore = null; // cursor: oldest cron created_at loaded into the sub-view
38
+ let _cronHasMore = false; // are there older cron sessions to load?
39
+ let _cronLoadingMore = false;
30
40
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
31
41
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
32
42
  // Buffer for tool_stdout lines that arrive before history has finished rendering.
@@ -933,25 +943,57 @@ const Sessions = (() => {
933
943
  const item = document.createElement("div");
934
944
  item.className = "tool-item";
935
945
 
936
- // Use backend-provided summary when available, fall back to client-side summarise
946
+ const argsJson = _formatToolArgs(args);
947
+ if (argsJson) item.dataset.argsJson = argsJson;
948
+ if (name) item.dataset.toolName = String(name);
949
+
937
950
  const argSummary = summary || _summariseArgs(name, args);
938
951
 
939
- // When a structured summary is available, show it as the primary label (no redundant tool name).
940
- // Otherwise show the raw tool name + arg summary as before.
941
952
  const label = summary
942
953
  ? `<span class="tool-item-name">⚙ ${escapeHtml(summary)}</span>`
943
954
  : `<span class="tool-item-name">⚙ ${escapeHtml(name)}</span>` +
944
955
  (argSummary ? `<span class="tool-item-arg">${escapeHtml(argSummary)}</span>` : "");
945
956
 
957
+ const expandable = !!argsJson;
958
+ const headerCls = expandable ? "tool-item-header tool-item-expandable" : "tool-item-header";
959
+
946
960
  item.innerHTML =
947
- `<div class="tool-item-header">` +
961
+ `<div class="${headerCls}">` +
948
962
  label +
949
963
  `<span class="tool-item-status running">…</span>` +
950
964
  `</div>` +
965
+ `<div class="tool-item-details" style="display:none"></div>` +
951
966
  `<pre class="tool-item-stdout" style="display:none"></pre>`;
967
+ _ensureCopyDelegation();
952
968
  return item;
953
969
  }
954
970
 
971
+ function _toggleToolItemDetails(item) {
972
+ if (!item) return;
973
+ const details = item.querySelector(".tool-item-details");
974
+ if (!details) return;
975
+ const isHidden = details.style.display === "none";
976
+ if (isHidden) {
977
+ if (!details.dataset.filled) {
978
+ const json = item.dataset.argsJson || "";
979
+ details.textContent = json;
980
+ details.dataset.filled = "1";
981
+ }
982
+ details.style.display = "";
983
+ item.classList.add("expanded");
984
+ } else {
985
+ details.style.display = "none";
986
+ item.classList.remove("expanded");
987
+ }
988
+ }
989
+
990
+ // Pretty-print tool args as a JSON string, or empty string if unavailable.
991
+ function _formatToolArgs(args) {
992
+ if (args == null) return "";
993
+ if (typeof args === "string") return args;
994
+ try { return JSON.stringify(args, null, 2); } catch (_) { return ""; }
995
+ }
996
+
955
997
  // Convert ANSI escape codes to HTML spans with color classes.
956
998
  // Handles the common SGR codes used by shell scripts (colors + reset).
957
999
  function _ansiToHtml(text) {
@@ -1503,6 +1545,14 @@ const Sessions = (() => {
1503
1545
  const messages = $("messages");
1504
1546
  if (!messages) return;
1505
1547
  messages.addEventListener("click", (e) => {
1548
+ // ── Tool item: click header to expand/collapse args details ──
1549
+ const toolHeader = e.target.closest(".tool-item-header.tool-item-expandable");
1550
+ if (toolHeader) {
1551
+ e.preventDefault();
1552
+ e.stopPropagation();
1553
+ _toggleToolItemDetails(toolHeader.closest(".tool-item"));
1554
+ return;
1555
+ }
1506
1556
  // ── Code block copy button ──
1507
1557
  const codeBtn = e.target.closest(".code-block-copy");
1508
1558
  if (codeBtn) {
@@ -1584,12 +1634,13 @@ const Sessions = (() => {
1584
1634
  }
1585
1635
 
1586
1636
  // Build the unified load-more button.
1587
- function _makeLoadMoreBtn() {
1637
+ function _makeLoadMoreBtn(cron = false) {
1588
1638
  const btn = document.createElement("button");
1589
1639
  btn.className = "btn-load-more-sessions";
1590
- btn.disabled = _loadingMore;
1591
- btn.textContent = _loadingMore ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
1592
- btn.onclick = () => Sessions.loadMore();
1640
+ const loading = cron ? _cronLoadingMore : _loadingMore;
1641
+ btn.disabled = loading;
1642
+ btn.textContent = loading ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
1643
+ btn.onclick = () => cron ? Sessions.loadMoreCron() : Sessions.loadMore();
1593
1644
  return btn;
1594
1645
  }
1595
1646
 
@@ -1679,13 +1730,17 @@ const Sessions = (() => {
1679
1730
  }
1680
1731
 
1681
1732
  // ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
1682
- function _renderCronGroupItem(container, count) {
1733
+ // `hasRunning` mirrors a normal session's status dot: show the green
1734
+ // (running) dot only when any folded cron session is currently running,
1735
+ // and render no dot at all when the group is idle.
1736
+ function _renderCronGroupItem(container, count, hasRunning = false) {
1683
1737
  const el = document.createElement("div");
1684
1738
  el.className = "session-item cron-group-item";
1739
+ const dotHtml = hasRunning ? `<span class="session-dot dot-running"></span>` : "";
1685
1740
  el.innerHTML = `
1686
1741
  <div class="session-body">
1687
1742
  <div class="session-name">
1688
- <span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
1743
+ ${dotHtml}
1689
1744
  <span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
1690
1745
  </div>
1691
1746
  <div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
@@ -1694,6 +1749,10 @@ const Sessions = (() => {
1694
1749
  el.onclick = () => {
1695
1750
  _cronView = true;
1696
1751
  Sessions.renderList();
1752
+ // Method A: always (re-)load the freshest first page of cron sessions
1753
+ // on entering the sub-view. Independent cursor — never touches the
1754
+ // outer list's pagination.
1755
+ Sessions.loadMoreCron({ reset: true });
1697
1756
  };
1698
1757
  container.appendChild(el);
1699
1758
  }
@@ -1881,6 +1940,53 @@ const Sessions = (() => {
1881
1940
  }
1882
1941
  },
1883
1942
 
1943
+ /** Cron sub-view pagination — independent cursor, does NOT touch the outer
1944
+ * list's `_hasMore` / `loadMore` cursor. Fetches the next page of cron
1945
+ * sessions (type=cron) and pushes them into the shared `_sessions` array
1946
+ * (deduped), so WS patch/remove/add keep working unchanged. Only the
1947
+ * sub-view's own cursor + flags advance. Pass `reset:true` to start over
1948
+ * from the newest cron page (used on entering the sub-view). */
1949
+ async loadMoreCron({ reset = false } = {}) {
1950
+ if (_cronLoadingMore) return;
1951
+ if (!reset && !_cronHasMore) return;
1952
+ _cronLoadingMore = true;
1953
+ if (reset) { _cronBefore = null; _cronHasMore = false; }
1954
+
1955
+ const sidebarList = document.getElementById("sidebar-list");
1956
+ const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
1957
+ Sessions.renderList({ skipScrollToActive: true });
1958
+
1959
+ try {
1960
+ const params = new URLSearchParams({ limit: "20", type: "cron" });
1961
+ if (_cronBefore) params.set("before", _cronBefore);
1962
+
1963
+ const res = await fetch(`/api/sessions?${params}`);
1964
+ if (!res.ok) return;
1965
+ const data = await res.json();
1966
+
1967
+ const rows = data.sessions || [];
1968
+ rows.forEach(s => {
1969
+ if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
1970
+ });
1971
+ // Advance cursor to the oldest cron created_at in THIS batch (exclude
1972
+ // pinned — backend returns all pinned on the first page, bypassing
1973
+ // pagination, so their created_at must not drag the cursor back).
1974
+ const oldest = rows.reduce((min, s) => {
1975
+ if (s.pinned || !s.created_at) return min;
1976
+ return (!min || s.created_at < min) ? s.created_at : min;
1977
+ }, null);
1978
+ if (oldest) _cronBefore = oldest;
1979
+ _cronHasMore = !!data.has_more;
1980
+ if (data.cron_count != null) _cronCount = data.cron_count;
1981
+ } catch (e) {
1982
+ console.error("loadMoreCron error:", e);
1983
+ } finally {
1984
+ _cronLoadingMore = false;
1985
+ Sessions.renderList({ skipScrollToActive: true });
1986
+ if (sidebarList) sidebarList.scrollTop = savedScrollTop;
1987
+ }
1988
+ },
1989
+
1884
1990
  /** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
1885
1991
  async commitSearch() {
1886
1992
  // Read live input values into _filter
@@ -2097,7 +2203,6 @@ const Sessions = (() => {
2097
2203
  const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
2098
2204
  const isCronView = _cronView && !hasActiveFilter;
2099
2205
  const cronSessions = visible.filter(s => s.source === "cron");
2100
- const nonCronSessions = visible.filter(s => s.source !== "cron");
2101
2206
 
2102
2207
  // Update chat-section header based on view mode
2103
2208
  _updateChatHeader(isCronView);
@@ -2109,27 +2214,46 @@ const Sessions = (() => {
2109
2214
  // Filter active: show all matching results flat, no group entry
2110
2215
  visible.forEach(s => _renderSessionItem(list, s));
2111
2216
  } else if (isCronView) {
2112
- // Cron sub-view: show only cron sessions.
2113
- // If none are loaded yet, auto-load more pages until we find them.
2114
- if (cronSessions.length === 0) {
2115
- if (_hasMore && !_loadingMore) {
2116
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2117
- Sessions.loadMore(); // async will call renderList() again when done
2118
- return; // skip empty-state / load-more button for now
2119
- }
2120
- if (_loadingMore) {
2121
- // A loadMore() call is already in flight (its own renderList call
2122
- // reached us). Keep the loading indicator so the user never sees
2123
- // the "no sessions" empty state during the gap.
2124
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2125
- return;
2126
- }
2217
+ // Cron sub-view: show only cron sessions, paginated independently via
2218
+ // loadMoreCron() (the first page is fetched on entering the view).
2219
+ // We never call the outer loadMore() here, so the outer list's cursor
2220
+ // is left untouched. While the first page is in flight and nothing is
2221
+ // loaded yet, show a loading placeholder instead of the empty state.
2222
+ if (cronSessions.length === 0 && _cronLoadingMore) {
2223
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2224
+ return;
2127
2225
  }
2128
2226
  cronSessions.forEach(s => _renderSessionItem(list, s));
2129
2227
  } else if (_cronCount > 0) {
2130
- // Normal list view: group entry (uses total count, not just loaded) + non-cron sessions
2131
- _renderCronGroupItem(list, _cronCount);
2132
- nonCronSessions.forEach(s => _renderSessionItem(list, s));
2228
+ // Normal list view: the cron group entry is a *virtual* row that
2229
+ // participates in the time-ordering instead of being pinned to the
2230
+ // top. We walk the already-sorted `visible` list and drop the group
2231
+ // entry at the position of the newest (first-encountered) cron
2232
+ // session — so it sits exactly where the latest folded cron task
2233
+ // would sort by created_at. Pinning is intentionally ignored for the
2234
+ // entry itself: a pinned cron session only takes effect *inside* the
2235
+ // folded sub-view, not on the outer entry.
2236
+ const cronHasRunning = cronSessions.some(s => s.status === "running");
2237
+ let cronEntryRendered = false;
2238
+ visible.forEach(s => {
2239
+ if (s.source === "cron") {
2240
+ // Render the group entry once, at the first (newest) cron slot;
2241
+ // skip every individual cron session in the flat list.
2242
+ if (!cronEntryRendered) {
2243
+ _renderCronGroupItem(list, _cronCount, cronHasRunning);
2244
+ cronEntryRendered = true;
2245
+ }
2246
+ return;
2247
+ }
2248
+ _renderSessionItem(list, s);
2249
+ });
2250
+ // NOTE: we intentionally do NOT append a fallback entry when no cron
2251
+ // session is loaded yet. The group entry is a virtual row that must
2252
+ // ride along with pagination: it only appears at the sort slot of the
2253
+ // first loaded cron session. If the newest cron lives on a later page,
2254
+ // the entry simply shows up once that page is loaded — rather than
2255
+ // being forced onto the bottom of page 1 (which would mislead the
2256
+ // sort position and miss the running state of an unpaged cron).
2133
2257
  } else {
2134
2258
  // Normal list view, no cron sessions
2135
2259
  visible.forEach(s => _renderSessionItem(list, s));
@@ -2140,7 +2264,11 @@ const Sessions = (() => {
2140
2264
  list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2141
2265
  }
2142
2266
 
2143
- if (_hasMore) list.appendChild(_makeLoadMoreBtn());
2267
+ if (isCronView) {
2268
+ if (_cronHasMore) list.appendChild(_makeLoadMoreBtn(true));
2269
+ } else if (_hasMore) {
2270
+ list.appendChild(_makeLoadMoreBtn());
2271
+ }
2144
2272
 
2145
2273
  // Scroll active session into view so the sidebar always shows the current session.
2146
2274
  if (!skipScrollToActive) {
@@ -3452,16 +3580,16 @@ const Sessions = (() => {
3452
3580
 
3453
3581
  const nameLine = document.createElement("span");
3454
3582
  nameLine.className = "sib-model-name-main";
3455
- nameLine.textContent = m.model;
3583
+ // When a non-default quick-switch model is active for the current row,
3584
+ // show only that model's name (avoid the long "main → quick-switch"
3585
+ // string that gets truncated).
3586
+ const hasActiveOverride =
3587
+ m.id === currentModelId &&
3588
+ subInfo.current &&
3589
+ subInfo.current !== subInfo.cardModel;
3590
+ nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
3456
3591
  left.appendChild(nameLine);
3457
3592
 
3458
- if (m.id === currentModelId && subInfo.current && subInfo.current !== subInfo.cardModel) {
3459
- const overrideLine = document.createElement("span");
3460
- overrideLine.className = "sib-model-name-override";
3461
- overrideLine.textContent = `→ ${subInfo.current}`;
3462
- left.appendChild(overrideLine);
3463
- }
3464
-
3465
3593
  if (_nameCounts[m.model] > 1) {
3466
3594
  left.classList.add("has-sub");
3467
3595
  const host = (() => {
@@ -3503,7 +3631,7 @@ const Sessions = (() => {
3503
3631
  const toggleBtn = document.createElement("button");
3504
3632
  toggleBtn.type = "button";
3505
3633
  toggleBtn.className = "sib-submodel-toggle";
3506
- toggleBtn.title = "Switch sub-model";
3634
+ toggleBtn.title = I18n.t("sib.variant.header");
3507
3635
  toggleBtn.setAttribute("aria-expanded", "false");
3508
3636
  toggleBtn.innerHTML =
3509
3637
  '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
@@ -3766,7 +3894,7 @@ const Sessions = (() => {
3766
3894
 
3767
3895
  const header = document.createElement("div");
3768
3896
  header.className = "sib-submodel-panel-header";
3769
- header.textContent = "Sub-model";
3897
+ header.textContent = I18n.t("sib.variant.header");
3770
3898
  panel.appendChild(header);
3771
3899
 
3772
3900
  const cardDefault = subInfo.cardModel;
@@ -3788,7 +3916,7 @@ const Sessions = (() => {
3788
3916
  if (name === cardDefault) {
3789
3917
  const tag = document.createElement("span");
3790
3918
  tag.className = "sib-submodel-default-tag";
3791
- tag.textContent = "default";
3919
+ tag.textContent = I18n.t("sib.variant.default");
3792
3920
  row.appendChild(tag);
3793
3921
  }
3794
3922
 
@@ -21,6 +21,7 @@ const Theme = (() => {
21
21
  function _applyAttr(theme) {
22
22
  document.documentElement.setAttribute(ATTR_NAME, theme);
23
23
  _updateToggleIcon(theme);
24
+ window.dispatchEvent(new CustomEvent("clacky-theme-change", { detail: { theme } }));
24
25
  }
25
26
 
26
27
  function _updateToggleIcon(theme) {
@@ -59,7 +59,12 @@ restore_gemrc() {
59
59
  setup_gem_home() {
60
60
  local gem_dir
61
61
  gem_dir=$(gem environment gemdir 2>/dev/null || true)
62
- [ -w "$gem_dir" ] && return 0
62
+
63
+ # gemdir writable → no workaround needed, clean up old RC entries
64
+ if [ -w "$gem_dir" ]; then
65
+ restore_gem_home
66
+ return 0
67
+ fi
63
68
 
64
69
  local ruby_api
65
70
  ruby_api=$(ruby -e 'puts RbConfig::CONFIG["ruby_version"]' 2>/dev/null)
@@ -68,7 +73,9 @@ setup_gem_home() {
68
73
  export PATH="$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
69
74
  print_info "System Ruby detected — gems will install to ~/.gem/ruby/${ruby_api}"
70
75
 
71
- if [ -n "$SHELL_RC" ] && ! grep -q "GEM_HOME" "$SHELL_RC" 2>/dev/null; then
76
+ # Clean old RC entries (could be from a different Ruby version), then write new ones
77
+ restore_gem_home
78
+ if [ -n "$SHELL_RC" ]; then
72
79
  {
73
80
  echo ""
74
81
  echo "# Ruby user gem dir (added by openclacky installer)"
@@ -153,6 +153,8 @@ install_via_gem() {
153
153
  local ver; ver=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
154
154
  version_ge "$ver" "3.1.0" || { print_error "Ruby $ver too old (>= 3.1.0 required)"; return 1; }
155
155
 
156
+ setup_gem_home
157
+
156
158
  print_info "Installing ${DISPLAY_NAME}..."
157
159
  if gem install openclacky --no-document; then
158
160
  print_success "${DISPLAY_NAME} installed!"
@@ -21,7 +21,7 @@ load_brand() {
21
21
  [ -f "$brand_file" ] || return 0
22
22
  BRAND_NAME=$(awk -F': ' '/^product_name:/{gsub(/^"|"$/, "", $2); gsub(/^ +| +$/, "", $2); print $2}' "$brand_file") || true
23
23
  BRAND_COMMAND=$(awk -F': ' '/^package_name:/{gsub(/^"|"$/, "", $2); gsub(/^ +| +$/, "", $2); print $2}' "$brand_file") || true
24
- [ -n "$BRAND_NAME" ] && DISPLAY_NAME="$BRAND_NAME"
24
+ if [ -n "$BRAND_NAME" ]; then DISPLAY_NAME="$BRAND_NAME"; fi
25
25
  }
26
26
 
27
27
  check_installation() {
data/scripts/install.ps1 CHANGED
@@ -10,6 +10,7 @@
10
10
  # Parameters:
11
11
  # -BrandName Display name shown in prompts (default: OpenClacky)
12
12
  # -CommandName CLI command name after install (default: openclacky)
13
+ # -Region CDN region: china (default) or global (use global for non-China networks)
13
14
  #
14
15
  # WSL1 is preferred (shares Windows network stack — no mirrored networking needed).
15
16
  # If WSL1 import fails, the script falls back to WSL2 with mirrored networking.
@@ -22,7 +23,9 @@
22
23
  param(
23
24
  [switch]$Local,
24
25
  [string]$BrandName = "",
25
- [string]$CommandName = ""
26
+ [string]$CommandName = "",
27
+ [ValidateSet("china", "global")]
28
+ [string]$Region = "china"
26
29
  )
27
30
 
28
31
  Set-StrictMode -Version Latest
@@ -32,11 +35,19 @@ $env:WSL_UTF8 = "1"
32
35
  $global:DisplayName = if ($BrandName) { $BrandName } else { "OpenClacky" }
33
36
  $global:DisplayCmd = if ($CommandName) { $CommandName } else { "openclacky" }
34
37
 
35
- $CLACKY_CDN_BASE_URL = "https://oss.1024code.com"
36
- $CLACKY_CDN_PRIMARY_HOST = "oss.1024code.com"
38
+ Write-Host "==> Region: $Region"
39
+
40
+ $CLACKY_CDN_BASE_URL = if ($Region -eq "global") { "https://sg.oss.1024code.com" } else { "https://oss.1024code.com" }
41
+ $CLACKY_INSTALL_SCRIPT_BASE_URL = if ($Region -eq "global") { "https://raw.githubusercontent.com" } else { "https://oss.1024code.com" }
42
+ $CLACKY_CDN_PRIMARY_HOST = if ($Region -eq "global") { "sg.oss.1024code.com" } else { "oss.1024code.com" }
43
+
44
+ Write-Host "==> CDN_BASE_URL: $CLACKY_CDN_BASE_URL"
45
+ Write-Host "==> INSTALL_SCRIPT_BASE_URL: $CLACKY_INSTALL_SCRIPT_BASE_URL"
46
+ Write-Host "==> CDN_PRIMARY_HOST: $CLACKY_CDN_PRIMARY_HOST"
47
+
37
48
  $CLACKY_CDN_BACKUP_HOST = "clackyai-1258723534.cos.ap-guangzhou.myqcloud.com"
38
- $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
39
- $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.sh"
49
+ $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_INSTALL_SCRIPT_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
50
+ $INSTALL_SCRIPT_URL = "$CLACKY_INSTALL_SCRIPT_BASE_URL/clacky-ai/openclacky/main/scripts/install.sh"
40
51
  $UBUNTU_WSL_AMD64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz"
41
52
  $UBUNTU_WSL_AMD64_SHA256_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz.sha256"
42
53
  $UBUNTU_WSL_ARM64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-arm64-ubuntu22.04lts.rootfs.tar.gz"
@@ -303,6 +314,9 @@ function Run-InstallInWsl {
303
314
  wsl.exe -d Ubuntu -u root -- bash -c "cd ~ && curl -fsSL $INSTALL_SCRIPT_URL | bash -s -- --brand-name=$BrandName --command=$CommandName"
304
315
  }
305
316
 
317
+ if ($LASTEXITCODE -eq 2) {
318
+ exit 2
319
+ }
306
320
  if ($LASTEXITCODE -ne 0) {
307
321
  Write-Fail "Installation failed inside WSL (exit $LASTEXITCODE)."
308
322
  Write-Fail "You can retry manually:"