openclacky 1.1.5 → 1.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +113 -63
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +521 -17
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +30 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +41 -6
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +51 -40
- data/lib/clacky/web/i18n.js +248 -24
- data/lib/clacky/web/index.html +362 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +299 -18
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +15 -2
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -25,6 +25,8 @@ const Sessions = (() => {
|
|
|
25
25
|
// Search state
|
|
26
26
|
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
|
|
27
27
|
let _searchOpen = false; // is the search panel visible?
|
|
28
|
+
let _cronView = false; // are we in the cron sub-view?
|
|
29
|
+
let _cronCount = 0; // total cron sessions from server
|
|
28
30
|
let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
|
|
29
31
|
let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
|
|
30
32
|
// Buffer for tool_stdout lines that arrive before history has finished rendering.
|
|
@@ -284,6 +286,7 @@ const Sessions = (() => {
|
|
|
284
286
|
const tip1 = I18n.t("chat.empty.tip1");
|
|
285
287
|
const tip2 = I18n.t("chat.empty.tip2");
|
|
286
288
|
const tip3 = I18n.t("chat.empty.tip3");
|
|
289
|
+
const tip4 = I18n.t("chat.empty.tip4");
|
|
287
290
|
return `
|
|
288
291
|
<div class="chat-empty-icon" aria-hidden="true">
|
|
289
292
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -296,6 +299,7 @@ const Sessions = (() => {
|
|
|
296
299
|
<li>${escapeHtml(tip1)}</li>
|
|
297
300
|
<li>${escapeHtml(tip2)}</li>
|
|
298
301
|
<li>${escapeHtml(tip3)}</li>
|
|
302
|
+
<li>${escapeHtml(tip4)}</li>
|
|
299
303
|
</ul>
|
|
300
304
|
`;
|
|
301
305
|
}
|
|
@@ -482,6 +486,7 @@ const Sessions = (() => {
|
|
|
482
486
|
|
|
483
487
|
const _pendingImages = [];
|
|
484
488
|
const _pendingFiles = [];
|
|
489
|
+
let _imageSeq = 0;
|
|
485
490
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB — hard reject before compression
|
|
486
491
|
const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
|
|
487
492
|
const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
|
|
@@ -608,9 +613,13 @@ const Sessions = (() => {
|
|
|
608
613
|
alert(`Image too large: ${file.name} (max 5 MB)`);
|
|
609
614
|
return;
|
|
610
615
|
}
|
|
616
|
+
const seq = ++_imageSeq;
|
|
617
|
+
const ext = (file.name.split('.').pop() || 'png').toLowerCase();
|
|
618
|
+
const displayName = `IMG_${String(seq).padStart(3, '0')}.${ext}`;
|
|
619
|
+
|
|
611
620
|
_compressImage(file)
|
|
612
621
|
.then(dataUrl => {
|
|
613
|
-
_pendingImages.push({ dataUrl, name:
|
|
622
|
+
_pendingImages.push({ dataUrl, name: displayName, mimeType: "image/jpeg", seq });
|
|
614
623
|
_renderAttachmentPreviews();
|
|
615
624
|
})
|
|
616
625
|
.catch(err => alert(`Image processing failed: ${err.message}`));
|
|
@@ -778,6 +787,8 @@ const Sessions = (() => {
|
|
|
778
787
|
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
779
788
|
|
|
780
789
|
// Merge images and files into unified files array for WS payload.
|
|
790
|
+
_pendingImages.sort((a, b) => a.seq - b.seq);
|
|
791
|
+
|
|
781
792
|
const files = [
|
|
782
793
|
..._pendingImages.map(img => ({
|
|
783
794
|
name: img.name,
|
|
@@ -792,9 +803,19 @@ const Sessions = (() => {
|
|
|
792
803
|
];
|
|
793
804
|
_pendingImages.length = 0;
|
|
794
805
|
_pendingFiles.length = 0;
|
|
806
|
+
_imageSeq = 0;
|
|
795
807
|
_renderAttachmentPreviews();
|
|
796
808
|
|
|
797
809
|
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
810
|
+
|
|
811
|
+
// Disable any pending feedback cards — user has replied (either by clicking
|
|
812
|
+
// an option button or by typing directly). The backend has already consumed
|
|
813
|
+
// the feedback; make the frontend reflect that immediately.
|
|
814
|
+
document.querySelectorAll(".feedback-card:not(.feedback-card--submitted)").forEach(card => {
|
|
815
|
+
card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
|
|
816
|
+
card.classList.add("feedback-card--submitted");
|
|
817
|
+
});
|
|
818
|
+
|
|
798
819
|
input.value = "";
|
|
799
820
|
input.style.height = "auto";
|
|
800
821
|
setTimeout(() => { _sending = false; }, 300);
|
|
@@ -1227,6 +1248,63 @@ const Sessions = (() => {
|
|
|
1227
1248
|
break;
|
|
1228
1249
|
}
|
|
1229
1250
|
|
|
1251
|
+
case "request_feedback": {
|
|
1252
|
+
// Collapse any open tool group
|
|
1253
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1254
|
+
|
|
1255
|
+
const rfQuestion = ev.question || "";
|
|
1256
|
+
const rfContext = ev.context || "";
|
|
1257
|
+
const rfOptions = ev.options;
|
|
1258
|
+
const rfHasOptions = Array.isArray(rfOptions) && rfOptions.length > 0;
|
|
1259
|
+
|
|
1260
|
+
if (!rfHasOptions) {
|
|
1261
|
+
// No options — render as plain assistant bubble
|
|
1262
|
+
const normalizeBullets = (t) => t ? t.replace(/^[•·‣▸▪\-–]\s*/gm, "- ") : t;
|
|
1263
|
+
const parts = [rfContext && rfContext.trim(), rfQuestion].filter(Boolean);
|
|
1264
|
+
const rfText = parts.map(normalizeBullets).join("\n\n");
|
|
1265
|
+
const rfEl = document.createElement("div");
|
|
1266
|
+
rfEl.className = "msg msg-assistant";
|
|
1267
|
+
rfEl.dataset.raw = rfText;
|
|
1268
|
+
rfEl.innerHTML = _renderMarkdown(rfText);
|
|
1269
|
+
_appendCopyButton(rfEl);
|
|
1270
|
+
container.appendChild(rfEl);
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Has options — answered → disabled card; pending → active card (same as live)
|
|
1275
|
+
const rfAnswered = ev._answered === true;
|
|
1276
|
+
const rfCard = document.createElement("div");
|
|
1277
|
+
rfCard.className = rfAnswered ? "feedback-card feedback-card--submitted" : "feedback-card";
|
|
1278
|
+
let rfHtml = "";
|
|
1279
|
+
if (rfContext && rfContext.trim()) {
|
|
1280
|
+
rfHtml += `<div class="feedback-context msg-assistant">${_renderMarkdown(rfContext)}</div>`;
|
|
1281
|
+
}
|
|
1282
|
+
rfHtml += `<div class="feedback-question msg-assistant">${_renderMarkdown(rfQuestion)}</div>`;
|
|
1283
|
+
rfHtml += `<div class="feedback-options">`;
|
|
1284
|
+
rfOptions.forEach((opt, idx) => {
|
|
1285
|
+
rfHtml += `<button class="feedback-option-btn" data-option-index="${idx}"${rfAnswered ? " disabled" : ""}>${escapeHtml(opt)}</button>`;
|
|
1286
|
+
});
|
|
1287
|
+
rfHtml += `</div>`;
|
|
1288
|
+
rfHtml += `<div class="feedback-hint">${I18n.t("chat.feedback_hint")}</div>`;
|
|
1289
|
+
rfCard.innerHTML = rfHtml;
|
|
1290
|
+
|
|
1291
|
+
// If still pending, wire up click handlers (same as showFeedbackRequest)
|
|
1292
|
+
if (!rfAnswered) {
|
|
1293
|
+
rfCard.querySelectorAll(".feedback-option-btn").forEach(btn => {
|
|
1294
|
+
btn.onclick = () => {
|
|
1295
|
+
rfCard.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
|
|
1296
|
+
rfCard.classList.add("feedback-card--submitted");
|
|
1297
|
+
const input = $("user-input");
|
|
1298
|
+
if (input) input.value = btn.textContent.trim();
|
|
1299
|
+
_sendMessage();
|
|
1300
|
+
};
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
container.appendChild(rfCard);
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1230
1308
|
default:
|
|
1231
1309
|
return; // skip unknown types
|
|
1232
1310
|
}
|
|
@@ -1296,6 +1374,20 @@ const Sessions = (() => {
|
|
|
1296
1374
|
}
|
|
1297
1375
|
});
|
|
1298
1376
|
|
|
1377
|
+
// Pre-scan: mark each request_feedback as answered.
|
|
1378
|
+
// A feedback card is disabled as soon as any subsequent history_user_message appears
|
|
1379
|
+
// (user either clicked an option or typed a reply).
|
|
1380
|
+
{
|
|
1381
|
+
let lastFeedbackIdx = -1;
|
|
1382
|
+
events.forEach((ev, i) => {
|
|
1383
|
+
if (ev.type === "request_feedback") { ev._answered = false; lastFeedbackIdx = i; }
|
|
1384
|
+
if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
|
|
1385
|
+
events[lastFeedbackIdx]._answered = true;
|
|
1386
|
+
lastFeedbackIdx = -1;
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1299
1391
|
// Dedup by created_at: skip rounds already rendered (e.g. arrived via live WS)
|
|
1300
1392
|
const dedup = _renderedCreatedAt[id] || (_renderedCreatedAt[id] = new Set());
|
|
1301
1393
|
const frag = document.createDocumentFragment();
|
|
@@ -1637,6 +1729,49 @@ const Sessions = (() => {
|
|
|
1637
1729
|
container.appendChild(el);
|
|
1638
1730
|
}
|
|
1639
1731
|
|
|
1732
|
+
// ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
|
|
1733
|
+
function _renderCronGroupItem(container, count) {
|
|
1734
|
+
const el = document.createElement("div");
|
|
1735
|
+
el.className = "session-item cron-group-item";
|
|
1736
|
+
el.innerHTML = `
|
|
1737
|
+
<div class="session-body">
|
|
1738
|
+
<div class="session-name">
|
|
1739
|
+
<span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
|
|
1740
|
+
<span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
|
|
1741
|
+
</div>
|
|
1742
|
+
<div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
|
|
1743
|
+
</div>
|
|
1744
|
+
`;
|
|
1745
|
+
el.onclick = () => {
|
|
1746
|
+
_cronView = true;
|
|
1747
|
+
Sessions.renderList();
|
|
1748
|
+
};
|
|
1749
|
+
container.appendChild(el);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// ── Chat-section header visibility ────────────────────────────────────
|
|
1753
|
+
function _updateChatHeader(isCronView) {
|
|
1754
|
+
const chatSection = document.getElementById("chat-section");
|
|
1755
|
+
if (!chatSection) return;
|
|
1756
|
+
|
|
1757
|
+
const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
|
|
1758
|
+
const cronHeader = document.getElementById("cron-view-header");
|
|
1759
|
+
const searchBar = document.getElementById("session-search-bar");
|
|
1760
|
+
const newSessionBtn = document.getElementById("btn-session-search-toggle");
|
|
1761
|
+
|
|
1762
|
+
if (isCronView) {
|
|
1763
|
+
if (normalHeader) normalHeader.style.display = "none";
|
|
1764
|
+
if (cronHeader) cronHeader.style.display = "";
|
|
1765
|
+
if (searchBar) searchBar.hidden = true;
|
|
1766
|
+
if (newSessionBtn) newSessionBtn.style.display = "none";
|
|
1767
|
+
} else {
|
|
1768
|
+
if (normalHeader) normalHeader.style.display = "";
|
|
1769
|
+
if (cronHeader) cronHeader.style.display = "none";
|
|
1770
|
+
if (searchBar) searchBar.hidden = !_searchOpen;
|
|
1771
|
+
// newSessionBtn display managed by renderList's magnifier logic
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1640
1775
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
1641
1776
|
return {
|
|
1642
1777
|
get all() { return _sessions; },
|
|
@@ -1658,6 +1793,14 @@ const Sessions = (() => {
|
|
|
1658
1793
|
_initMessageHistory();
|
|
1659
1794
|
// Re-render session list (badges/labels) when the user switches language
|
|
1660
1795
|
document.addEventListener("langchange", () => Sessions.renderList());
|
|
1796
|
+
|
|
1797
|
+
// Cron view back button
|
|
1798
|
+
document.getElementById("btn-cron-back")
|
|
1799
|
+
.addEventListener("click", () => {
|
|
1800
|
+
_cronView = false;
|
|
1801
|
+
Sessions.renderList();
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1661
1804
|
// Browsers block file:// navigation from http:// pages. Intercept clicks on
|
|
1662
1805
|
// file:// links and delegate to the backend API.
|
|
1663
1806
|
// Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
|
|
@@ -1702,16 +1845,18 @@ const Sessions = (() => {
|
|
|
1702
1845
|
// ── List management ───────────────────────────────────────────────────
|
|
1703
1846
|
|
|
1704
1847
|
/** Populate list from initial session_list WS event (connect only). */
|
|
1705
|
-
setAll(list, hasMore = false) {
|
|
1848
|
+
setAll(list, hasMore = false, cronCount = 0) {
|
|
1706
1849
|
_sessions.length = 0;
|
|
1707
1850
|
_sessions.push(...list);
|
|
1708
|
-
_hasMore
|
|
1851
|
+
_hasMore = !!hasMore;
|
|
1852
|
+
_cronCount = cronCount;
|
|
1709
1853
|
},
|
|
1710
1854
|
|
|
1711
1855
|
/** Insert a newly created session into the local list. */
|
|
1712
1856
|
add(session) {
|
|
1713
1857
|
if (!_sessions.find(s => s.id === session.id)) {
|
|
1714
1858
|
_sessions.push(session);
|
|
1859
|
+
if (session.source === "cron") _cronCount++;
|
|
1715
1860
|
}
|
|
1716
1861
|
},
|
|
1717
1862
|
|
|
@@ -1730,7 +1875,10 @@ const Sessions = (() => {
|
|
|
1730
1875
|
/** Remove a session from the list (from session_deleted event). */
|
|
1731
1876
|
remove(id) {
|
|
1732
1877
|
const idx = _sessions.findIndex(s => s.id === id);
|
|
1733
|
-
if (idx !== -1)
|
|
1878
|
+
if (idx !== -1) {
|
|
1879
|
+
if (_sessions[idx].source === "cron") _cronCount = Math.max(0, _cronCount - 1);
|
|
1880
|
+
_sessions.splice(idx, 1);
|
|
1881
|
+
}
|
|
1734
1882
|
// Clean up per-session progress state (timer + DOM + logical state)
|
|
1735
1883
|
Sessions._deleteProgressState(id);
|
|
1736
1884
|
},
|
|
@@ -1772,7 +1920,8 @@ const Sessions = (() => {
|
|
|
1772
1920
|
(data.sessions || []).forEach(s => {
|
|
1773
1921
|
if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
|
|
1774
1922
|
});
|
|
1775
|
-
_hasMore
|
|
1923
|
+
_hasMore = !!data.has_more;
|
|
1924
|
+
_cronCount = data.cron_count || 0;
|
|
1776
1925
|
} catch (e) {
|
|
1777
1926
|
console.error("loadMore error:", e);
|
|
1778
1927
|
} finally {
|
|
@@ -1809,7 +1958,8 @@ const Sessions = (() => {
|
|
|
1809
1958
|
if (!res.ok) return;
|
|
1810
1959
|
const data = await res.json();
|
|
1811
1960
|
_sessions.push(...(data.sessions || []));
|
|
1812
|
-
_hasMore
|
|
1961
|
+
_hasMore = !!data.has_more;
|
|
1962
|
+
_cronCount = data.cron_count || 0;
|
|
1813
1963
|
} catch (e) {
|
|
1814
1964
|
console.error("commitSearch error:", e);
|
|
1815
1965
|
} finally {
|
|
@@ -1994,14 +2144,53 @@ const Sessions = (() => {
|
|
|
1994
2144
|
const qEl = document.getElementById("session-search-q");
|
|
1995
2145
|
if (qClearBtn) qClearBtn.hidden = !(qEl && qEl.value);
|
|
1996
2146
|
|
|
2147
|
+
// ── Split cron vs non-cron for folding ───────────────────────────
|
|
2148
|
+
const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
|
|
2149
|
+
const isCronView = _cronView && !hasActiveFilter;
|
|
2150
|
+
const cronSessions = visible.filter(s => s.source === "cron");
|
|
2151
|
+
const nonCronSessions = visible.filter(s => s.source !== "cron");
|
|
2152
|
+
|
|
2153
|
+
// Update chat-section header based on view mode
|
|
2154
|
+
_updateChatHeader(isCronView);
|
|
2155
|
+
|
|
1997
2156
|
const list = $("session-list");
|
|
1998
2157
|
list.innerHTML = "";
|
|
1999
|
-
|
|
2000
|
-
|
|
2158
|
+
|
|
2159
|
+
if (hasActiveFilter) {
|
|
2160
|
+
// Filter active: show all matching results flat, no group entry
|
|
2161
|
+
visible.forEach(s => _renderSessionItem(list, s));
|
|
2162
|
+
} else if (isCronView) {
|
|
2163
|
+
// Cron sub-view: show only cron sessions.
|
|
2164
|
+
// If none are loaded yet, auto-load more pages until we find them.
|
|
2165
|
+
if (cronSessions.length === 0) {
|
|
2166
|
+
if (_hasMore && !_loadingMore) {
|
|
2167
|
+
list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
|
|
2168
|
+
Sessions.loadMore(); // async — will call renderList() again when done
|
|
2169
|
+
return; // skip empty-state / load-more button for now
|
|
2170
|
+
}
|
|
2171
|
+
if (_loadingMore) {
|
|
2172
|
+
// A loadMore() call is already in flight (its own renderList call
|
|
2173
|
+
// reached us). Keep the loading indicator so the user never sees
|
|
2174
|
+
// the "no sessions" empty state during the gap.
|
|
2175
|
+
list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
cronSessions.forEach(s => _renderSessionItem(list, s));
|
|
2180
|
+
} else if (_cronCount > 0) {
|
|
2181
|
+
// Normal list view: group entry (uses total count, not just loaded) + non-cron sessions
|
|
2182
|
+
_renderCronGroupItem(list, _cronCount);
|
|
2183
|
+
nonCronSessions.forEach(s => _renderSessionItem(list, s));
|
|
2001
2184
|
} else {
|
|
2185
|
+
// Normal list view, no cron sessions
|
|
2002
2186
|
visible.forEach(s => _renderSessionItem(list, s));
|
|
2003
2187
|
}
|
|
2004
2188
|
|
|
2189
|
+
// Empty state fallback
|
|
2190
|
+
if (list.children.length === 0) {
|
|
2191
|
+
list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2005
2194
|
if (_hasMore) list.appendChild(_makeLoadMoreBtn());
|
|
2006
2195
|
|
|
2007
2196
|
// Scroll active session into view so the sidebar always shows the current session.
|
|
@@ -2089,6 +2278,8 @@ const Sessions = (() => {
|
|
|
2089
2278
|
if (action === "pin") {
|
|
2090
2279
|
await Sessions.togglePin(session.id);
|
|
2091
2280
|
} else if (action === "rename") {
|
|
2281
|
+
// Close sidebar on mobile so the rename dialog isn't obscured
|
|
2282
|
+
window.mobileCloseSidebar?.();
|
|
2092
2283
|
// Find the session item by data-session-id attribute
|
|
2093
2284
|
const sessionItem = document.querySelector(`.session-item[data-session-id="${session.id}"]`);
|
|
2094
2285
|
if (sessionItem) {
|
|
@@ -2096,6 +2287,8 @@ const Sessions = (() => {
|
|
|
2096
2287
|
Sessions._startRename(session.id, nameDiv, session.name);
|
|
2097
2288
|
}
|
|
2098
2289
|
} else if (action === "delete") {
|
|
2290
|
+
// Close sidebar on mobile so the delete dialog isn't obscured
|
|
2291
|
+
window.mobileCloseSidebar?.();
|
|
2099
2292
|
await Sessions.deleteSession(session.id);
|
|
2100
2293
|
}
|
|
2101
2294
|
});
|
|
@@ -2178,7 +2371,10 @@ const Sessions = (() => {
|
|
|
2178
2371
|
// info while the agent is working.
|
|
2179
2372
|
const inp = $("user-input");
|
|
2180
2373
|
if (inp) {
|
|
2181
|
-
const
|
|
2374
|
+
const mobile = window.innerWidth <= 768;
|
|
2375
|
+
const key = status === "running"
|
|
2376
|
+
? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
|
|
2377
|
+
: (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
|
|
2182
2378
|
inp.setAttribute("data-i18n-placeholder", key);
|
|
2183
2379
|
inp.setAttribute("placeholder", I18n.t(key));
|
|
2184
2380
|
}
|
|
@@ -2258,6 +2454,17 @@ const Sessions = (() => {
|
|
|
2258
2454
|
sibModel.textContent = s.model || "";
|
|
2259
2455
|
// Store current session ID on the model element for later use
|
|
2260
2456
|
sibModel.dataset.sessionId = s.id;
|
|
2457
|
+
if (s.model_id) {
|
|
2458
|
+
sibModel.dataset.modelId = s.model_id;
|
|
2459
|
+
} else {
|
|
2460
|
+
delete sibModel.dataset.modelId;
|
|
2461
|
+
}
|
|
2462
|
+
// Disable model switching while the agent is responding
|
|
2463
|
+
const busy = s.status === "running";
|
|
2464
|
+
sibModel.classList.toggle("sib-model-disabled", busy);
|
|
2465
|
+
sibModel.title = busy
|
|
2466
|
+
? I18n.t("sib.model.tooltip.busy")
|
|
2467
|
+
: I18n.t("sib.model.tooltip");
|
|
2261
2468
|
}
|
|
2262
2469
|
if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
|
|
2263
2470
|
|
|
@@ -2288,7 +2495,9 @@ const Sessions = (() => {
|
|
|
2288
2495
|
const sibCost = $("sib-cost");
|
|
2289
2496
|
if (sibCost) {
|
|
2290
2497
|
if (s.cost_source && s.cost_source !== "estimated") {
|
|
2291
|
-
|
|
2498
|
+
const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
|
|
2499
|
+
const cost = typeof Billing !== "undefined" ? Billing.convertCost(s.total_cost || 0) : (s.total_cost || 0);
|
|
2500
|
+
sibCost.textContent = `${symbol}${cost.toFixed(2)}`;
|
|
2292
2501
|
} else {
|
|
2293
2502
|
sibCost.textContent = "N/A";
|
|
2294
2503
|
}
|
|
@@ -2367,6 +2576,46 @@ const Sessions = (() => {
|
|
|
2367
2576
|
|
|
2368
2577
|
wrap.style.display = "";
|
|
2369
2578
|
if (sep) sep.style.display = "";
|
|
2579
|
+
|
|
2580
|
+
// Mobile: bind tap-to-show popup once (flag prevents re-binding on every update)
|
|
2581
|
+
if (!el._signalTapBound) {
|
|
2582
|
+
el._signalTapBound = true;
|
|
2583
|
+
el.addEventListener("click", (e) => {
|
|
2584
|
+
if (window.innerWidth > 768) return; // desktop: native title tooltip is fine
|
|
2585
|
+
e.stopPropagation();
|
|
2586
|
+
// Remove any existing popup
|
|
2587
|
+
const existing = document.querySelector(".sib-signal-popup");
|
|
2588
|
+
if (existing) { existing.remove(); return; }
|
|
2589
|
+
|
|
2590
|
+
const popup = document.createElement("div");
|
|
2591
|
+
popup.className = "sib-signal-popup";
|
|
2592
|
+
// Format tooltip text: replace " · " with newlines for readability
|
|
2593
|
+
popup.textContent = el.title.replace(/ · /g, "\n");
|
|
2594
|
+
document.body.appendChild(popup);
|
|
2595
|
+
|
|
2596
|
+
// Position: above the signal element, aligned to its left edge
|
|
2597
|
+
const rect = el.getBoundingClientRect();
|
|
2598
|
+
let left = rect.left;
|
|
2599
|
+
// Prevent overflow off right edge
|
|
2600
|
+
const popupWidth = 220;
|
|
2601
|
+
if (left + popupWidth > window.innerWidth - 8) {
|
|
2602
|
+
left = window.innerWidth - popupWidth - 8;
|
|
2603
|
+
}
|
|
2604
|
+
popup.style.left = left + "px";
|
|
2605
|
+
popup.style.visibility = "hidden";
|
|
2606
|
+
// Use rAF to get actual rendered height before positioning
|
|
2607
|
+
requestAnimationFrame(() => {
|
|
2608
|
+
const popupHeight = popup.getBoundingClientRect().height;
|
|
2609
|
+
popup.style.top = (rect.top - popupHeight - 6) + "px";
|
|
2610
|
+
popup.style.visibility = "";
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
// Close on next tap anywhere
|
|
2614
|
+
setTimeout(() => {
|
|
2615
|
+
document.addEventListener("click", () => popup.remove(), { once: true });
|
|
2616
|
+
}, 0);
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2370
2619
|
},
|
|
2371
2620
|
|
|
2372
2621
|
// ── Message helpers ────────────────────────────────────────────────────
|
|
@@ -2456,14 +2705,16 @@ const Sessions = (() => {
|
|
|
2456
2705
|
// :api => "$0.00123" (exact, from API response)
|
|
2457
2706
|
// :price => "~$0.00123" (estimated from pricing table)
|
|
2458
2707
|
// :estimated => "N/A" (model unknown in pricing table)
|
|
2459
|
-
const
|
|
2708
|
+
const rawCost = ev.cost || 0;
|
|
2709
|
+
const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
|
|
2710
|
+
const cost = typeof Billing !== "undefined" ? Billing.convertCost(rawCost) : rawCost;
|
|
2460
2711
|
let costStr;
|
|
2461
2712
|
if (!ev.cost_source || ev.cost_source === "estimated") {
|
|
2462
2713
|
costStr = "N/A";
|
|
2463
2714
|
} else if (ev.cost_source === "price") {
|
|
2464
|
-
costStr =
|
|
2715
|
+
costStr = `~${symbol}${cost.toFixed(5)}`;
|
|
2465
2716
|
} else {
|
|
2466
|
-
costStr =
|
|
2717
|
+
costStr = `${symbol}${cost.toFixed(5)}`;
|
|
2467
2718
|
}
|
|
2468
2719
|
|
|
2469
2720
|
// Always-visible: label, delta, cache indicator, cost
|
|
@@ -3143,6 +3394,7 @@ const Sessions = (() => {
|
|
|
3143
3394
|
const modelEl = e.target.closest("#sib-model");
|
|
3144
3395
|
if (modelEl) {
|
|
3145
3396
|
e.stopPropagation();
|
|
3397
|
+
if (modelEl.classList.contains("sib-model-disabled")) return;
|
|
3146
3398
|
const dropdown = $("sib-model-dropdown");
|
|
3147
3399
|
if (!dropdown) return;
|
|
3148
3400
|
|
|
@@ -3150,7 +3402,7 @@ const Sessions = (() => {
|
|
|
3150
3402
|
dropdown.style.display = "none";
|
|
3151
3403
|
_isOpen = false;
|
|
3152
3404
|
} else {
|
|
3153
|
-
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.
|
|
3405
|
+
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null);
|
|
3154
3406
|
|
|
3155
3407
|
// Calculate position relative to the model element (fixed positioning)
|
|
3156
3408
|
const rect = modelEl.getBoundingClientRect();
|
|
@@ -3173,7 +3425,7 @@ const Sessions = (() => {
|
|
|
3173
3425
|
});
|
|
3174
3426
|
|
|
3175
3427
|
// Populate dropdown with available models
|
|
3176
|
-
async function _populateModelDropdown(sessionId,
|
|
3428
|
+
async function _populateModelDropdown(sessionId, currentModelId) {
|
|
3177
3429
|
const dropdown = $("sib-model-dropdown");
|
|
3178
3430
|
if (!dropdown) return;
|
|
3179
3431
|
|
|
@@ -3217,16 +3469,41 @@ const Sessions = (() => {
|
|
|
3217
3469
|
});
|
|
3218
3470
|
|
|
3219
3471
|
// ── Model rows ─────────────────────────────────────────────────────
|
|
3472
|
+
const _nameCounts = models.reduce((acc, m) => {
|
|
3473
|
+
acc[m.model] = (acc[m.model] || 0) + 1;
|
|
3474
|
+
return acc;
|
|
3475
|
+
}, {});
|
|
3476
|
+
|
|
3220
3477
|
models.forEach(m => {
|
|
3221
|
-
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:",
|
|
3478
|
+
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
|
|
3222
3479
|
const opt = document.createElement("div");
|
|
3223
3480
|
opt.className = "sib-model-option";
|
|
3224
3481
|
opt.dataset.modelId = m.id;
|
|
3225
|
-
if (m.
|
|
3482
|
+
if (m.id === currentModelId) opt.classList.add("current");
|
|
3226
3483
|
|
|
3227
3484
|
const left = document.createElement("span");
|
|
3228
3485
|
left.className = "sib-model-name";
|
|
3229
|
-
|
|
3486
|
+
|
|
3487
|
+
const nameLine = document.createElement("span");
|
|
3488
|
+
nameLine.className = "sib-model-name-main";
|
|
3489
|
+
nameLine.textContent = m.model;
|
|
3490
|
+
left.appendChild(nameLine);
|
|
3491
|
+
|
|
3492
|
+
if (_nameCounts[m.model] > 1) {
|
|
3493
|
+
left.classList.add("has-sub");
|
|
3494
|
+
const host = (() => {
|
|
3495
|
+
try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
|
|
3496
|
+
})();
|
|
3497
|
+
const subBits = [host, m.api_key_masked].filter(Boolean);
|
|
3498
|
+
if (subBits.length) {
|
|
3499
|
+
const subLine = document.createElement("span");
|
|
3500
|
+
subLine.className = "sib-model-name-sub";
|
|
3501
|
+
subLine.textContent = subBits.join(" · ");
|
|
3502
|
+
left.appendChild(subLine);
|
|
3503
|
+
opt.title = `${m.model} · ${subBits.join(" · ")}`;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3230
3507
|
opt.appendChild(left);
|
|
3231
3508
|
|
|
3232
3509
|
const right = document.createElement("span");
|
|
@@ -3677,3 +3954,7 @@ const Sessions = (() => {
|
|
|
3677
3954
|
document.addEventListener("langchange", () => {
|
|
3678
3955
|
if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
|
|
3679
3956
|
});
|
|
3957
|
+
|
|
3958
|
+
document.addEventListener("currencychange", () => {
|
|
3959
|
+
if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
|
|
3960
|
+
});
|