openclacky 1.2.12 → 1.2.13
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/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent.rb +9 -1
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/openai_stream_aggregator.rb +4 -1
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/http_server.rb +117 -3
- data/lib/clacky/server/session_registry.rb +30 -8
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +18 -4
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- metadata +4 -2
data/lib/clacky/web/settings.js
CHANGED
|
@@ -1616,9 +1616,6 @@ const Settings = (() => {
|
|
|
1616
1616
|
if (state.configured && state.model) return state.model;
|
|
1617
1617
|
return "";
|
|
1618
1618
|
}
|
|
1619
|
-
if (state.source === "custom" && state.configured && !_mediaCustomDraft[kind]) {
|
|
1620
|
-
return state.model || "";
|
|
1621
|
-
}
|
|
1622
1619
|
return "";
|
|
1623
1620
|
}
|
|
1624
1621
|
|
|
@@ -1629,15 +1626,74 @@ const Settings = (() => {
|
|
|
1629
1626
|
if (state.configured && state.model) {
|
|
1630
1627
|
const wrap = document.createElement("div");
|
|
1631
1628
|
wrap.className = "media-row-detail";
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1629
|
+
|
|
1630
|
+
const providerLine = document.createElement("div");
|
|
1631
|
+
providerLine.className = "media-provider-line";
|
|
1632
|
+
const pLabel = document.createElement("span");
|
|
1633
|
+
pLabel.className = "media-provider-label";
|
|
1634
|
+
pLabel.textContent = I18n.t("settings.media.field.provider");
|
|
1635
|
+
const pVal = document.createElement("span");
|
|
1636
|
+
pVal.className = "media-provider-value";
|
|
1637
|
+
pVal.textContent = state.provider || "—";
|
|
1638
|
+
providerLine.appendChild(pLabel);
|
|
1639
|
+
providerLine.appendChild(pVal);
|
|
1640
|
+
wrap.appendChild(providerLine);
|
|
1641
|
+
|
|
1642
|
+
const modelField = _buildField(I18n.t("settings.media.field.model"));
|
|
1643
|
+
const opts = Array.isArray(state.available) ? state.available : [];
|
|
1644
|
+
const aliases = (state.aliases && typeof state.aliases === "object") ? state.aliases : {};
|
|
1645
|
+
const labelOf = (m) => aliases[m] ? `${m} (${aliases[m]})` : m;
|
|
1646
|
+
if (opts.length > 1) {
|
|
1647
|
+
const sel = document.createElement("select");
|
|
1648
|
+
sel.className = "field-select";
|
|
1649
|
+
opts.forEach(m => {
|
|
1650
|
+
const o = document.createElement("option");
|
|
1651
|
+
o.value = m;
|
|
1652
|
+
o.textContent = labelOf(m);
|
|
1653
|
+
if (m === state.model) o.selected = true;
|
|
1654
|
+
sel.appendChild(o);
|
|
1655
|
+
});
|
|
1656
|
+
sel.addEventListener("change", async () => {
|
|
1657
|
+
const picked = sel.value;
|
|
1658
|
+
const payload = picked === (def && def.model)
|
|
1659
|
+
? { source: "auto" }
|
|
1660
|
+
: { source: "auto", model: picked };
|
|
1661
|
+
sel.disabled = true;
|
|
1662
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
|
|
1663
|
+
try {
|
|
1664
|
+
await _saveMediaConfig(kind, payload);
|
|
1665
|
+
await _loadMedia();
|
|
1666
|
+
} catch (e) {
|
|
1667
|
+
sel.disabled = false;
|
|
1668
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
modelField.appendChild(sel);
|
|
1672
|
+
} else {
|
|
1673
|
+
const ro = document.createElement("div");
|
|
1674
|
+
ro.className = "media-auto-readonly";
|
|
1675
|
+
ro.textContent = labelOf(state.model);
|
|
1676
|
+
modelField.appendChild(ro);
|
|
1677
|
+
}
|
|
1678
|
+
wrap.appendChild(modelField);
|
|
1679
|
+
|
|
1680
|
+
if (state.stale && state.requested_model) {
|
|
1681
|
+
const warn = document.createElement("div");
|
|
1682
|
+
warn.className = "media-row-hint is-warning";
|
|
1683
|
+
warn.textContent = I18n.t("settings.media.auto.stale", {
|
|
1684
|
+
requested: labelOf(state.requested_model),
|
|
1685
|
+
current: labelOf(state.model)
|
|
1686
|
+
});
|
|
1687
|
+
wrap.appendChild(warn);
|
|
1688
|
+
} else {
|
|
1689
|
+
const hint = document.createElement("div");
|
|
1690
|
+
hint.className = "media-row-hint";
|
|
1691
|
+
hint.textContent = I18n.t("settings.media.auto.followsDefault");
|
|
1692
|
+
wrap.appendChild(hint);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
wrap.appendChild(_buildMediaResult(kind));
|
|
1696
|
+
|
|
1641
1697
|
return wrap;
|
|
1642
1698
|
}
|
|
1643
1699
|
const wrap = document.createElement("div");
|
|
@@ -1651,21 +1707,34 @@ const Settings = (() => {
|
|
|
1651
1707
|
if (state.configured && !_mediaCustomDraft[kind]) {
|
|
1652
1708
|
const wrap = document.createElement("div");
|
|
1653
1709
|
wrap.className = "media-row-detail";
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1710
|
+
|
|
1711
|
+
const list = document.createElement("div");
|
|
1712
|
+
list.className = "media-custom-readonly-list";
|
|
1713
|
+
[
|
|
1714
|
+
["settings.media.field.model", state.model],
|
|
1715
|
+
["settings.media.field.baseUrl", state.base_url],
|
|
1716
|
+
["settings.media.field.apiKey", state.api_key_masked]
|
|
1717
|
+
].forEach(([lk, v]) => {
|
|
1718
|
+
const row = document.createElement("div");
|
|
1719
|
+
row.className = "media-custom-readonly-row";
|
|
1720
|
+
const lbl = document.createElement("span");
|
|
1721
|
+
lbl.className = "media-custom-readonly-label";
|
|
1722
|
+
lbl.textContent = I18n.t(lk);
|
|
1723
|
+
const val = document.createElement("span");
|
|
1724
|
+
val.className = "media-custom-readonly-value";
|
|
1725
|
+
val.textContent = v || "—";
|
|
1726
|
+
row.appendChild(lbl);
|
|
1727
|
+
row.appendChild(val);
|
|
1728
|
+
list.appendChild(row);
|
|
1729
|
+
});
|
|
1730
|
+
wrap.appendChild(list);
|
|
1731
|
+
|
|
1664
1732
|
const actions = document.createElement("div");
|
|
1665
1733
|
actions.className = "media-row-actions";
|
|
1734
|
+
|
|
1666
1735
|
const editBtn = document.createElement("button");
|
|
1667
1736
|
editBtn.type = "button";
|
|
1668
|
-
editBtn.className = "media-row-btn";
|
|
1737
|
+
editBtn.className = "btn-secondary media-row-btn-sm";
|
|
1669
1738
|
editBtn.textContent = I18n.t("settings.media.action.edit");
|
|
1670
1739
|
editBtn.addEventListener("click", () => {
|
|
1671
1740
|
_mediaCustomDraft[kind] = {
|
|
@@ -1675,8 +1744,33 @@ const Settings = (() => {
|
|
|
1675
1744
|
};
|
|
1676
1745
|
_renderMediaRows();
|
|
1677
1746
|
});
|
|
1747
|
+
|
|
1748
|
+
const testBtn = document.createElement("button");
|
|
1749
|
+
testBtn.type = "button";
|
|
1750
|
+
testBtn.className = "btn-secondary media-row-btn-sm";
|
|
1751
|
+
testBtn.textContent = I18n.t("settings.media.action.test");
|
|
1752
|
+
testBtn.addEventListener("click", async () => {
|
|
1753
|
+
testBtn.disabled = true;
|
|
1754
|
+
editBtn.disabled = true;
|
|
1755
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.testing"));
|
|
1756
|
+
try {
|
|
1757
|
+
const r = await _testMediaConfig(kind, {
|
|
1758
|
+
model: state.model,
|
|
1759
|
+
base_url: state.base_url,
|
|
1760
|
+
api_key: state.api_key_masked
|
|
1761
|
+
});
|
|
1762
|
+
_setMediaResult(kind, r.ok ? "ok" : "fail", r.message || "");
|
|
1763
|
+
} finally {
|
|
1764
|
+
testBtn.disabled = false;
|
|
1765
|
+
editBtn.disabled = false;
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
actions.appendChild(testBtn);
|
|
1678
1770
|
actions.appendChild(editBtn);
|
|
1679
1771
|
wrap.appendChild(actions);
|
|
1772
|
+
|
|
1773
|
+
wrap.appendChild(_buildMediaResult(kind));
|
|
1680
1774
|
return wrap;
|
|
1681
1775
|
}
|
|
1682
1776
|
|
|
@@ -1684,37 +1778,58 @@ const Settings = (() => {
|
|
|
1684
1778
|
const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
|
|
1685
1779
|
const wrap = document.createElement("div");
|
|
1686
1780
|
wrap.className = "media-row-detail";
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
</div>
|
|
1700
|
-
`;
|
|
1701
|
-
form.querySelectorAll("input").forEach(inp => {
|
|
1781
|
+
|
|
1782
|
+
const fields = document.createElement("div");
|
|
1783
|
+
fields.className = "media-custom-fields";
|
|
1784
|
+
|
|
1785
|
+
const mkInput = (labelKey, fieldName, type, placeholder, initial) => {
|
|
1786
|
+
const f = _buildField(I18n.t(labelKey));
|
|
1787
|
+
const inp = document.createElement("input");
|
|
1788
|
+
inp.type = type;
|
|
1789
|
+
inp.className = "field-input";
|
|
1790
|
+
inp.value = initial || "";
|
|
1791
|
+
inp.placeholder = placeholder;
|
|
1792
|
+
inp.dataset.field = fieldName;
|
|
1702
1793
|
inp.addEventListener("input", () => {
|
|
1703
1794
|
_mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
|
|
1704
|
-
_mediaCustomDraft[kind][
|
|
1795
|
+
_mediaCustomDraft[kind][fieldName] = inp.value;
|
|
1705
1796
|
});
|
|
1706
|
-
|
|
1707
|
-
|
|
1797
|
+
f.appendChild(inp);
|
|
1798
|
+
return f;
|
|
1799
|
+
};
|
|
1800
|
+
fields.appendChild(mkInput("settings.media.field.model", "model", "text", "gpt-image-1", draft.model));
|
|
1801
|
+
fields.appendChild(mkInput("settings.media.field.baseUrl", "base_url", "text", "https://api.openai.com/v1", draft.base_url));
|
|
1802
|
+
fields.appendChild(mkInput("settings.media.field.apiKey", "api_key", "password", I18n.t("settings.media.apiKey.placeholder"), draft.api_key));
|
|
1803
|
+
wrap.appendChild(fields);
|
|
1804
|
+
|
|
1805
|
+
const result = _buildMediaResult(kind);
|
|
1806
|
+
wrap.appendChild(result);
|
|
1807
|
+
|
|
1808
|
+
const actions = document.createElement("div");
|
|
1809
|
+
actions.className = "media-row-actions";
|
|
1810
|
+
|
|
1811
|
+
const cancelBtn = document.createElement("button");
|
|
1812
|
+
cancelBtn.type = "button";
|
|
1813
|
+
cancelBtn.className = "btn-secondary media-row-btn-sm";
|
|
1814
|
+
cancelBtn.textContent = I18n.t("settings.media.action.cancel");
|
|
1815
|
+
cancelBtn.addEventListener("click", () => {
|
|
1708
1816
|
delete _mediaCustomDraft[kind];
|
|
1709
|
-
// If there's no saved custom, fall back to whatever the saved source is (or off)
|
|
1710
1817
|
if (!state.configured) {
|
|
1711
1818
|
const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
|
|
1712
1819
|
_mediaState[kind] = { ..._mediaState[kind], source: fallback };
|
|
1713
1820
|
}
|
|
1714
1821
|
_renderMediaRows();
|
|
1715
1822
|
});
|
|
1716
|
-
|
|
1823
|
+
|
|
1824
|
+
const saveBtn = document.createElement("button");
|
|
1825
|
+
saveBtn.type = "button";
|
|
1826
|
+
saveBtn.className = "btn-primary media-row-btn-sm";
|
|
1827
|
+
saveBtn.textContent = I18n.t("settings.media.action.save");
|
|
1828
|
+
saveBtn.addEventListener("click", async () => {
|
|
1717
1829
|
const d = _mediaCustomDraft[kind] || {};
|
|
1830
|
+
saveBtn.disabled = true;
|
|
1831
|
+
cancelBtn.disabled = true;
|
|
1832
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
|
|
1718
1833
|
try {
|
|
1719
1834
|
await _saveMediaConfig(kind, {
|
|
1720
1835
|
source: "custom",
|
|
@@ -1725,24 +1840,55 @@ const Settings = (() => {
|
|
|
1725
1840
|
delete _mediaCustomDraft[kind];
|
|
1726
1841
|
await _loadMedia();
|
|
1727
1842
|
} catch (e) {
|
|
1728
|
-
|
|
1843
|
+
saveBtn.disabled = false;
|
|
1844
|
+
cancelBtn.disabled = false;
|
|
1845
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1729
1846
|
}
|
|
1730
1847
|
});
|
|
1731
|
-
|
|
1848
|
+
|
|
1849
|
+
actions.appendChild(cancelBtn);
|
|
1850
|
+
actions.appendChild(saveBtn);
|
|
1851
|
+
wrap.appendChild(actions);
|
|
1852
|
+
|
|
1732
1853
|
return wrap;
|
|
1733
1854
|
}
|
|
1734
1855
|
|
|
1856
|
+
function _buildField(labelText) {
|
|
1857
|
+
const f = document.createElement("div");
|
|
1858
|
+
f.className = "model-field";
|
|
1859
|
+
const label = document.createElement("span");
|
|
1860
|
+
label.className = "field-label";
|
|
1861
|
+
label.textContent = labelText;
|
|
1862
|
+
f.appendChild(label);
|
|
1863
|
+
return f;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function _buildMediaResult(kind) {
|
|
1867
|
+
const el = document.createElement("div");
|
|
1868
|
+
el.className = "model-test-result";
|
|
1869
|
+
el.dataset.mediaKind = kind;
|
|
1870
|
+
return el;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function _setMediaResult(kind, status, message) {
|
|
1874
|
+
const el = document.querySelector(`.model-test-result[data-media-kind="${kind}"]`);
|
|
1875
|
+
if (!el) return;
|
|
1876
|
+
el.className = `model-test-result result-${status}`;
|
|
1877
|
+
if (!message) {
|
|
1878
|
+
el.textContent = "";
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const prefix = status === "ok" ? "✓ " : status === "fail" ? "✗ " : "";
|
|
1882
|
+
el.textContent = prefix + message;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1735
1885
|
async function _onMediaSourceClick(kind, source) {
|
|
1736
1886
|
const cur = (_mediaState && _mediaState[kind]) || {};
|
|
1737
1887
|
if (cur.source === source && source !== "custom") return;
|
|
1738
1888
|
|
|
1739
1889
|
if (source === "custom") {
|
|
1740
1890
|
if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
|
|
1741
|
-
_mediaCustomDraft[kind] = {
|
|
1742
|
-
model: cur.model || "",
|
|
1743
|
-
base_url: cur.base_url || "",
|
|
1744
|
-
api_key: ""
|
|
1745
|
-
};
|
|
1891
|
+
_mediaCustomDraft[kind] = { model: "", base_url: "", api_key: "" };
|
|
1746
1892
|
}
|
|
1747
1893
|
_mediaState[kind] = { ...cur, source: "custom" };
|
|
1748
1894
|
_renderMediaRows();
|
|
@@ -1754,7 +1900,8 @@ const Settings = (() => {
|
|
|
1754
1900
|
delete _mediaCustomDraft[kind];
|
|
1755
1901
|
await _loadMedia();
|
|
1756
1902
|
} catch (e) {
|
|
1757
|
-
|
|
1903
|
+
_renderMediaRows();
|
|
1904
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1758
1905
|
}
|
|
1759
1906
|
}
|
|
1760
1907
|
|
|
@@ -1771,6 +1918,21 @@ const Settings = (() => {
|
|
|
1771
1918
|
return data;
|
|
1772
1919
|
}
|
|
1773
1920
|
|
|
1921
|
+
async function _testMediaConfig(kind, { model, base_url, api_key }) {
|
|
1922
|
+
try {
|
|
1923
|
+
const res = await fetch(`/api/config/media/test`, {
|
|
1924
|
+
method: "POST",
|
|
1925
|
+
headers: { "Content-Type": "application/json" },
|
|
1926
|
+
body: JSON.stringify({ kind, model, base_url, api_key })
|
|
1927
|
+
});
|
|
1928
|
+
const data = await res.json().catch(() => ({}));
|
|
1929
|
+
if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
|
|
1930
|
+
return data;
|
|
1931
|
+
} catch (e) {
|
|
1932
|
+
return { ok: false, message: e.message };
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1774
1936
|
|
|
1775
1937
|
function init() {
|
|
1776
1938
|
_initTabs();
|
data/lib/clacky/web/skills.js
CHANGED
|
@@ -813,13 +813,7 @@ const SkillAC = (() => {
|
|
|
813
813
|
// Load from localStorage, default to false (hide system skills)
|
|
814
814
|
let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
|
|
815
815
|
|
|
816
|
-
//
|
|
817
|
-
// Safari fires compositionend BEFORE keydown (violating W3C spec), and the
|
|
818
|
-
// gap between compositionend and keydown is ~5ms on Safari. We record the
|
|
819
|
-
// timestamp of compositionend and treat any Enter keydown within 20ms as
|
|
820
|
-
// still-composing. Chrome is unaffected because e.isComposing is still true.
|
|
821
|
-
// Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
|
|
822
|
-
let _lastCompositionEndTime = -Infinity;
|
|
816
|
+
let _ime = null; // IME tracker for #user-input, set up in _initDOMBindings
|
|
823
817
|
|
|
824
818
|
/** Called whenever the active session changes — just store the id, no prefetch. */
|
|
825
819
|
function _loadForSession(sessionId) {
|
|
@@ -1110,18 +1104,15 @@ const SkillAC = (() => {
|
|
|
1110
1104
|
input.focus();
|
|
1111
1105
|
});
|
|
1112
1106
|
|
|
1113
|
-
// IME composition
|
|
1114
|
-
|
|
1115
|
-
$("user-input").addEventListener("compositionend", () => {
|
|
1116
|
-
_lastCompositionEndTime = Date.now();
|
|
1117
|
-
});
|
|
1107
|
+
// IME composition tracker: shared by main keydown + AC _handleKey.
|
|
1108
|
+
_ime = IME.track($("user-input"));
|
|
1118
1109
|
|
|
1119
1110
|
// Main composer keydown: SkillAC consumes nav keys first, then Enter → send.
|
|
1120
1111
|
$("user-input").addEventListener("keydown", e => {
|
|
1121
1112
|
// Let skill autocomplete consume arrow/enter/escape first
|
|
1122
1113
|
if (_handleKey(e)) return;
|
|
1123
1114
|
|
|
1124
|
-
if (e.key === "Enter" && !e.shiftKey && !
|
|
1115
|
+
if (e.key === "Enter" && !e.shiftKey && !_ime.isComposing(e)) {
|
|
1125
1116
|
e.preventDefault();
|
|
1126
1117
|
Sessions.sendMessage();
|
|
1127
1118
|
}
|
|
@@ -1173,7 +1164,7 @@ const SkillAC = (() => {
|
|
|
1173
1164
|
_select(targetIdx);
|
|
1174
1165
|
return true;
|
|
1175
1166
|
}
|
|
1176
|
-
if (e.key === "Enter" && !
|
|
1167
|
+
if (e.key === "Enter" && !_ime.isComposing(e)) {
|
|
1177
1168
|
if (_activeIndex >= 0) {
|
|
1178
1169
|
e.preventDefault();
|
|
1179
1170
|
_select(_activeIndex);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Cross-browser IME composition guard for Enter-to-submit inputs.
|
|
2
|
+
//
|
|
3
|
+
// Problem: pressing Enter to confirm an IME composition (e.g. selecting a
|
|
4
|
+
// Chinese candidate) must NOT trigger submit. Different browsers signal this
|
|
5
|
+
// differently:
|
|
6
|
+
// - Chrome / Firefox / Edge: e.isComposing === true on the confirming Enter
|
|
7
|
+
// - Older browsers: e.keyCode === 229
|
|
8
|
+
// - Safari: fires compositionend ~5ms BEFORE keydown, so isComposing is
|
|
9
|
+
// already false. We need a recent compositionend timestamp to suppress.
|
|
10
|
+
//
|
|
11
|
+
// Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// IME.bindEnter(inputEl, () => submit());
|
|
15
|
+
//
|
|
16
|
+
// Or low-level for handlers that already own a keydown listener:
|
|
17
|
+
// const ime = IME.track(inputEl);
|
|
18
|
+
// inputEl.addEventListener("keydown", e => {
|
|
19
|
+
// if (e.key === "Enter" && !ime.isComposing(e)) submit();
|
|
20
|
+
// });
|
|
21
|
+
const IME = (() => {
|
|
22
|
+
const SAFARI_GUARD_MS = 20;
|
|
23
|
+
|
|
24
|
+
function track(inputEl) {
|
|
25
|
+
let lastCompositionEnd = -Infinity;
|
|
26
|
+
const onEnd = () => { lastCompositionEnd = Date.now(); };
|
|
27
|
+
inputEl.addEventListener("compositionend", onEnd);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isComposing(e) {
|
|
31
|
+
if (e.isComposing || e.keyCode === 229) return true;
|
|
32
|
+
return Date.now() - lastCompositionEnd <= SAFARI_GUARD_MS;
|
|
33
|
+
},
|
|
34
|
+
dispose() {
|
|
35
|
+
inputEl.removeEventListener("compositionend", onEnd);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function bindEnter(inputEl, handler, options = {}) {
|
|
41
|
+
const ime = track(inputEl);
|
|
42
|
+
const onKey = (e) => {
|
|
43
|
+
if (e.key !== "Enter") return;
|
|
44
|
+
if (options.allowShift !== true && e.shiftKey) return;
|
|
45
|
+
if (ime.isComposing(e)) return;
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
handler(e);
|
|
48
|
+
};
|
|
49
|
+
inputEl.addEventListener("keydown", onKey);
|
|
50
|
+
return () => {
|
|
51
|
+
inputEl.removeEventListener("keydown", onKey);
|
|
52
|
+
ime.dispose();
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { track, bindEnter };
|
|
57
|
+
})();
|
|
@@ -16,10 +16,143 @@
|
|
|
16
16
|
// Guard: restore hash routing only once after initial session_list arrives.
|
|
17
17
|
let _initialRestoreDone = false;
|
|
18
18
|
|
|
19
|
+
// ── Phase grouping (folds subagent runs like skill evolution) ───────────
|
|
20
|
+
//
|
|
21
|
+
// Strategy: when a phase_start arrives, we append a foldable card to the
|
|
22
|
+
// outer message stream and push its body onto RenderTarget. Sessions.append*
|
|
23
|
+
// resolves its destination via RenderTarget.current(), so subagent events
|
|
24
|
+
// land inside the card. Infrastructure paths (history fetch, empty-hint,
|
|
25
|
+
// scroll, container clear) read RenderTarget.outer() and stay anchored to
|
|
26
|
+
// the real #messages node — phase activity never pollutes them.
|
|
27
|
+
//
|
|
28
|
+
// The DOM id "messages" is never swapped: external code, CSS, devtools and
|
|
29
|
+
// closures all see a stable identity.
|
|
30
|
+
const RenderTarget = (() => {
|
|
31
|
+
const stack = [];
|
|
32
|
+
return {
|
|
33
|
+
push(el) { stack.push(el); },
|
|
34
|
+
pop() { return stack.pop(); },
|
|
35
|
+
current(){ return stack[stack.length - 1] || document.getElementById("messages"); },
|
|
36
|
+
outer() { return document.getElementById("messages"); },
|
|
37
|
+
depth() { return stack.length; },
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
window.RenderTarget = RenderTarget;
|
|
41
|
+
|
|
42
|
+
const _phaseStack = []; // [{ id, kind, card, body, summary }]
|
|
43
|
+
|
|
44
|
+
function _activePhase() {
|
|
45
|
+
return _phaseStack[_phaseStack.length - 1] || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _phaseLabel(kind) {
|
|
49
|
+
const map = {
|
|
50
|
+
skill_evolution: I18n.t ? (I18n.t("phase.skill_evolution") || "Skill evolution") : "Skill evolution",
|
|
51
|
+
};
|
|
52
|
+
return map[kind] || kind;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _beginPhase(ev) {
|
|
56
|
+
if (_phaseStack.length > 0) return; // single-level for now
|
|
57
|
+
|
|
58
|
+
const outer = RenderTarget.outer();
|
|
59
|
+
if (!outer) return;
|
|
60
|
+
|
|
61
|
+
const card = document.createElement("details");
|
|
62
|
+
card.className = "msg-phase";
|
|
63
|
+
card.dataset.phaseId = ev.phase_id;
|
|
64
|
+
card.dataset.phaseKind = ev.kind || "phase";
|
|
65
|
+
|
|
66
|
+
const summary = document.createElement("summary");
|
|
67
|
+
summary.className = "msg-phase-summary";
|
|
68
|
+
const labelText = ev.label || _phaseLabel(ev.kind);
|
|
69
|
+
summary.innerHTML = `<span class="msg-phase-icon">🧬</span><span class="msg-phase-label">${escapeHtml(labelText)}</span><span class="msg-phase-status">…</span>`;
|
|
70
|
+
card.appendChild(summary);
|
|
71
|
+
|
|
72
|
+
const body = document.createElement("div");
|
|
73
|
+
body.className = "msg-phase-body";
|
|
74
|
+
card.appendChild(body);
|
|
75
|
+
|
|
76
|
+
outer.appendChild(card);
|
|
77
|
+
RenderTarget.push(body);
|
|
78
|
+
|
|
79
|
+
_phaseStack.push({
|
|
80
|
+
id: ev.phase_id,
|
|
81
|
+
kind: ev.kind,
|
|
82
|
+
card,
|
|
83
|
+
body,
|
|
84
|
+
summary,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
outer.scrollTop = outer.scrollHeight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _endPhase(phaseId, summary) {
|
|
91
|
+
const idx = _phaseStack.findIndex(p => p.id === phaseId);
|
|
92
|
+
if (idx === -1) return;
|
|
93
|
+
while (_phaseStack.length > idx) {
|
|
94
|
+
const phase = _phaseStack.pop();
|
|
95
|
+
RenderTarget.pop();
|
|
96
|
+
_finalizePhase(phase, { summary: phase.id === phaseId ? summary : null });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _finalizePhase(phase, { summary, incomplete } = {}) {
|
|
101
|
+
const body = phase.body;
|
|
102
|
+
const isEmpty = !incomplete && body && body.children.length === 0;
|
|
103
|
+
if (isEmpty) phase.card.classList.add("msg-phase-empty");
|
|
104
|
+
|
|
105
|
+
const statusEl = phase.summary && phase.summary.querySelector(".msg-phase-status");
|
|
106
|
+
if (statusEl) {
|
|
107
|
+
if (incomplete) {
|
|
108
|
+
statusEl.textContent = " (interrupted)";
|
|
109
|
+
statusEl.classList.add("msg-phase-status-incomplete");
|
|
110
|
+
} else if (isEmpty) {
|
|
111
|
+
const noChange = I18n.t ? (I18n.t("phase.no_changes") || "no changes needed") : "no changes needed";
|
|
112
|
+
statusEl.textContent = ` ✓ ${noChange}`;
|
|
113
|
+
} else if (summary) {
|
|
114
|
+
statusEl.textContent = ` ✓ ${summary}`;
|
|
115
|
+
} else {
|
|
116
|
+
statusEl.textContent = " ✓";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _closeAllPhases(reason) {
|
|
122
|
+
while (_phaseStack.length > 0) {
|
|
123
|
+
const phase = _phaseStack.pop();
|
|
124
|
+
RenderTarget.pop();
|
|
125
|
+
_finalizePhase(phase, { incomplete: reason === "incomplete" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
window._closeAllPhases = _closeAllPhases;
|
|
130
|
+
|
|
19
131
|
|
|
20
132
|
WS.onEvent(ev => {
|
|
133
|
+
// Safety nets:
|
|
134
|
+
// - User just sent a message → any open phase is stale, close it.
|
|
135
|
+
// - Session changed → phase belongs to the previous session, close it.
|
|
136
|
+
if (ev.type === "history_user_message" || ev.type === "subscribed") {
|
|
137
|
+
_closeAllPhases("incomplete");
|
|
138
|
+
}
|
|
139
|
+
|
|
21
140
|
switch (ev.type) {
|
|
22
141
|
|
|
142
|
+
// ── Phase grouping ─────────────────────────────────────────────────
|
|
143
|
+
case "phase_start": {
|
|
144
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
145
|
+
_beginPhase(ev);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "phase_end": {
|
|
150
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
151
|
+
_endPhase(ev.phase_id, ev.summary);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
23
156
|
// ── Internal WS lifecycle ──────────────────────────────────────────
|
|
24
157
|
case "_ws_connected": {
|
|
25
158
|
const banner = document.getElementById("offline-banner");
|
|
@@ -41,6 +174,7 @@ WS.onEvent(ev => {
|
|
|
41
174
|
// caused stuck UI after reconnect when the snapshot logic wasn't
|
|
42
175
|
// re-asserting status on every reconnect.
|
|
43
176
|
Sessions.clearAllProgress();
|
|
177
|
+
_closeAllPhases("incomplete");
|
|
44
178
|
break;
|
|
45
179
|
}
|
|
46
180
|
|
|
@@ -207,6 +341,7 @@ WS.onEvent(ev => {
|
|
|
207
341
|
if (ev.session_id !== Sessions.activeId) break;
|
|
208
342
|
Sessions.clearProgress();
|
|
209
343
|
Sessions.collapseToolGroup();
|
|
344
|
+
_closeAllPhases("incomplete"); // safety net: missed phase_end
|
|
210
345
|
{
|
|
211
346
|
const costSource = ev.cost_source;
|
|
212
347
|
const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
|
|
@@ -251,6 +386,7 @@ WS.onEvent(ev => {
|
|
|
251
386
|
if (ev.session_id !== Sessions.activeId) break;
|
|
252
387
|
Sessions.clearProgress();
|
|
253
388
|
Sessions.collapseToolGroup();
|
|
389
|
+
_closeAllPhases("incomplete");
|
|
254
390
|
Sessions.appendInfo(I18n.t("chat.interrupted"));
|
|
255
391
|
break;
|
|
256
392
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openclacky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -387,6 +387,7 @@ files:
|
|
|
387
387
|
- lib/clacky/default_skills/personal-website/publish.rb
|
|
388
388
|
- lib/clacky/default_skills/product-help/SKILL.md
|
|
389
389
|
- lib/clacky/default_skills/recall-memory/SKILL.md
|
|
390
|
+
- lib/clacky/default_skills/search-skills/SKILL.md
|
|
390
391
|
- lib/clacky/default_skills/skill-add/SKILL.md
|
|
391
392
|
- lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb
|
|
392
393
|
- lib/clacky/default_skills/skill-creator/SKILL.md
|
|
@@ -558,6 +559,7 @@ files:
|
|
|
558
559
|
- lib/clacky/web/tasks.js
|
|
559
560
|
- lib/clacky/web/theme.js
|
|
560
561
|
- lib/clacky/web/trash.js
|
|
562
|
+
- lib/clacky/web/utils.js
|
|
561
563
|
- lib/clacky/web/vendor/hljs/highlight.min.js
|
|
562
564
|
- lib/clacky/web/vendor/hljs/hljs-theme.css
|
|
563
565
|
- lib/clacky/web/vendor/katex/auto-render.min.js
|