openclacky 1.2.12 → 1.2.14
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 +5 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +39 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/session_serializer.rb +1 -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 +132 -15
- data/lib/clacky/agent_config.rb +183 -22
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/client.rb +11 -1
- data/lib/clacky/default_parsers/pdf_parser.rb +70 -86
- data/lib/clacky/default_parsers/pdf_parser_vlm.py +136 -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 +77 -12
- data/lib/clacky/server/http_server.rb +296 -7
- 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 +18 -8
- 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/vision/resolver.rb +157 -0
- data/lib/clacky/web/app.css +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +22 -6
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +241 -60
- 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
- data/lib/clacky.rb +1 -0
- metadata +6 -2
data/lib/clacky/web/settings.js
CHANGED
|
@@ -1528,7 +1528,7 @@ const Settings = (() => {
|
|
|
1528
1528
|
// The state object per kind:
|
|
1529
1529
|
// { source, configured, model, base_url, api_key_masked, provider, available }
|
|
1530
1530
|
|
|
1531
|
-
const MEDIA_KINDS = ["image", "video", "audio"];
|
|
1531
|
+
const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
|
|
1532
1532
|
let _mediaState = null;
|
|
1533
1533
|
let _mediaDefaults = null;
|
|
1534
1534
|
const _mediaCustomDraft = {};
|
|
@@ -1538,10 +1538,16 @@ const Settings = (() => {
|
|
|
1538
1538
|
if (!container) return;
|
|
1539
1539
|
container.innerHTML = `<div class="settings-loading">${I18n.t("settings.media.loading")}</div>`;
|
|
1540
1540
|
try {
|
|
1541
|
-
const
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1541
|
+
const [mediaRes, ocrRes] = await Promise.all([
|
|
1542
|
+
fetch("/api/config/media"),
|
|
1543
|
+
fetch("/api/config/ocr")
|
|
1544
|
+
]);
|
|
1545
|
+
const mediaData = await mediaRes.json();
|
|
1546
|
+
const ocrData = await ocrRes.json();
|
|
1547
|
+
_mediaState = mediaData.media || {};
|
|
1548
|
+
_mediaDefaults = mediaData.default_provider || {};
|
|
1549
|
+
_mediaState["ocr"] = ocrData.ocr || { source: "off", available: [] };
|
|
1550
|
+
_mediaDefaults["ocr"] = ocrData.default_provider || { available: [] };
|
|
1545
1551
|
_renderMediaRows();
|
|
1546
1552
|
} catch (e) {
|
|
1547
1553
|
container.innerHTML = `<div class="settings-error">${I18n.t("settings.media.error", { msg: e.message })}</div>`;
|
|
@@ -1557,6 +1563,14 @@ const Settings = (() => {
|
|
|
1557
1563
|
});
|
|
1558
1564
|
}
|
|
1559
1565
|
|
|
1566
|
+
function _refreshKindRows(_kind) {
|
|
1567
|
+
_renderMediaRows();
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async function _reloadKind(_kind) {
|
|
1571
|
+
await _loadMedia();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1560
1574
|
function _renderMediaRow(kind) {
|
|
1561
1575
|
const state = (_mediaState && _mediaState[kind]) || { source: "off", available: [] };
|
|
1562
1576
|
const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
|
|
@@ -1616,9 +1630,6 @@ const Settings = (() => {
|
|
|
1616
1630
|
if (state.configured && state.model) return state.model;
|
|
1617
1631
|
return "";
|
|
1618
1632
|
}
|
|
1619
|
-
if (state.source === "custom" && state.configured && !_mediaCustomDraft[kind]) {
|
|
1620
|
-
return state.model || "";
|
|
1621
|
-
}
|
|
1622
1633
|
return "";
|
|
1623
1634
|
}
|
|
1624
1635
|
|
|
@@ -1629,15 +1640,74 @@ const Settings = (() => {
|
|
|
1629
1640
|
if (state.configured && state.model) {
|
|
1630
1641
|
const wrap = document.createElement("div");
|
|
1631
1642
|
wrap.className = "media-row-detail";
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1643
|
+
|
|
1644
|
+
const providerLine = document.createElement("div");
|
|
1645
|
+
providerLine.className = "media-provider-line";
|
|
1646
|
+
const pLabel = document.createElement("span");
|
|
1647
|
+
pLabel.className = "media-provider-label";
|
|
1648
|
+
pLabel.textContent = I18n.t("settings.media.field.provider");
|
|
1649
|
+
const pVal = document.createElement("span");
|
|
1650
|
+
pVal.className = "media-provider-value";
|
|
1651
|
+
pVal.textContent = state.provider || "—";
|
|
1652
|
+
providerLine.appendChild(pLabel);
|
|
1653
|
+
providerLine.appendChild(pVal);
|
|
1654
|
+
wrap.appendChild(providerLine);
|
|
1655
|
+
|
|
1656
|
+
const modelField = _buildField(I18n.t("settings.media.field.model"));
|
|
1657
|
+
const opts = Array.isArray(state.available) ? state.available : [];
|
|
1658
|
+
const aliases = (state.aliases && typeof state.aliases === "object") ? state.aliases : {};
|
|
1659
|
+
const labelOf = (m) => aliases[m] ? `${m} (${aliases[m]})` : m;
|
|
1660
|
+
if (opts.length > 1) {
|
|
1661
|
+
const sel = document.createElement("select");
|
|
1662
|
+
sel.className = "field-select";
|
|
1663
|
+
opts.forEach(m => {
|
|
1664
|
+
const o = document.createElement("option");
|
|
1665
|
+
o.value = m;
|
|
1666
|
+
o.textContent = labelOf(m);
|
|
1667
|
+
if (m === state.model) o.selected = true;
|
|
1668
|
+
sel.appendChild(o);
|
|
1669
|
+
});
|
|
1670
|
+
sel.addEventListener("change", async () => {
|
|
1671
|
+
const picked = sel.value;
|
|
1672
|
+
const payload = picked === (def && def.model)
|
|
1673
|
+
? { source: "auto" }
|
|
1674
|
+
: { source: "auto", model: picked };
|
|
1675
|
+
sel.disabled = true;
|
|
1676
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
|
|
1677
|
+
try {
|
|
1678
|
+
await _saveMediaConfig(kind, payload);
|
|
1679
|
+
await _reloadKind(kind);
|
|
1680
|
+
} catch (e) {
|
|
1681
|
+
sel.disabled = false;
|
|
1682
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
modelField.appendChild(sel);
|
|
1686
|
+
} else {
|
|
1687
|
+
const ro = document.createElement("div");
|
|
1688
|
+
ro.className = "media-auto-readonly";
|
|
1689
|
+
ro.textContent = labelOf(state.model);
|
|
1690
|
+
modelField.appendChild(ro);
|
|
1691
|
+
}
|
|
1692
|
+
wrap.appendChild(modelField);
|
|
1693
|
+
|
|
1694
|
+
if (state.stale && state.requested_model) {
|
|
1695
|
+
const warn = document.createElement("div");
|
|
1696
|
+
warn.className = "media-row-hint is-warning";
|
|
1697
|
+
warn.textContent = I18n.t("settings.media.auto.stale", {
|
|
1698
|
+
requested: labelOf(state.requested_model),
|
|
1699
|
+
current: labelOf(state.model)
|
|
1700
|
+
});
|
|
1701
|
+
wrap.appendChild(warn);
|
|
1702
|
+
} else {
|
|
1703
|
+
const hint = document.createElement("div");
|
|
1704
|
+
hint.className = "media-row-hint";
|
|
1705
|
+
hint.textContent = I18n.t("settings.media.auto.followsDefault");
|
|
1706
|
+
wrap.appendChild(hint);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
wrap.appendChild(_buildMediaResult(kind));
|
|
1710
|
+
|
|
1641
1711
|
return wrap;
|
|
1642
1712
|
}
|
|
1643
1713
|
const wrap = document.createElement("div");
|
|
@@ -1651,21 +1721,34 @@ const Settings = (() => {
|
|
|
1651
1721
|
if (state.configured && !_mediaCustomDraft[kind]) {
|
|
1652
1722
|
const wrap = document.createElement("div");
|
|
1653
1723
|
wrap.className = "media-row-detail";
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1724
|
+
|
|
1725
|
+
const list = document.createElement("div");
|
|
1726
|
+
list.className = "media-custom-readonly-list";
|
|
1727
|
+
[
|
|
1728
|
+
["settings.media.field.model", state.model],
|
|
1729
|
+
["settings.media.field.baseUrl", state.base_url],
|
|
1730
|
+
["settings.media.field.apiKey", state.api_key_masked]
|
|
1731
|
+
].forEach(([lk, v]) => {
|
|
1732
|
+
const row = document.createElement("div");
|
|
1733
|
+
row.className = "media-custom-readonly-row";
|
|
1734
|
+
const lbl = document.createElement("span");
|
|
1735
|
+
lbl.className = "media-custom-readonly-label";
|
|
1736
|
+
lbl.textContent = I18n.t(lk);
|
|
1737
|
+
const val = document.createElement("span");
|
|
1738
|
+
val.className = "media-custom-readonly-value";
|
|
1739
|
+
val.textContent = v || "—";
|
|
1740
|
+
row.appendChild(lbl);
|
|
1741
|
+
row.appendChild(val);
|
|
1742
|
+
list.appendChild(row);
|
|
1743
|
+
});
|
|
1744
|
+
wrap.appendChild(list);
|
|
1745
|
+
|
|
1664
1746
|
const actions = document.createElement("div");
|
|
1665
1747
|
actions.className = "media-row-actions";
|
|
1748
|
+
|
|
1666
1749
|
const editBtn = document.createElement("button");
|
|
1667
1750
|
editBtn.type = "button";
|
|
1668
|
-
editBtn.className = "media-row-btn";
|
|
1751
|
+
editBtn.className = "btn-secondary media-row-btn-sm";
|
|
1669
1752
|
editBtn.textContent = I18n.t("settings.media.action.edit");
|
|
1670
1753
|
editBtn.addEventListener("click", () => {
|
|
1671
1754
|
_mediaCustomDraft[kind] = {
|
|
@@ -1673,10 +1756,35 @@ const Settings = (() => {
|
|
|
1673
1756
|
base_url: state.base_url || "",
|
|
1674
1757
|
api_key: ""
|
|
1675
1758
|
};
|
|
1676
|
-
|
|
1759
|
+
_refreshKindRows(kind);
|
|
1677
1760
|
});
|
|
1761
|
+
|
|
1762
|
+
const testBtn = document.createElement("button");
|
|
1763
|
+
testBtn.type = "button";
|
|
1764
|
+
testBtn.className = "btn-secondary media-row-btn-sm";
|
|
1765
|
+
testBtn.textContent = I18n.t("settings.media.action.test");
|
|
1766
|
+
testBtn.addEventListener("click", async () => {
|
|
1767
|
+
testBtn.disabled = true;
|
|
1768
|
+
editBtn.disabled = true;
|
|
1769
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.testing"));
|
|
1770
|
+
try {
|
|
1771
|
+
const r = await _testMediaConfig(kind, {
|
|
1772
|
+
model: state.model,
|
|
1773
|
+
base_url: state.base_url,
|
|
1774
|
+
api_key: state.api_key_masked
|
|
1775
|
+
});
|
|
1776
|
+
_setMediaResult(kind, r.ok ? "ok" : "fail", r.message || "");
|
|
1777
|
+
} finally {
|
|
1778
|
+
testBtn.disabled = false;
|
|
1779
|
+
editBtn.disabled = false;
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
actions.appendChild(testBtn);
|
|
1678
1784
|
actions.appendChild(editBtn);
|
|
1679
1785
|
wrap.appendChild(actions);
|
|
1786
|
+
|
|
1787
|
+
wrap.appendChild(_buildMediaResult(kind));
|
|
1680
1788
|
return wrap;
|
|
1681
1789
|
}
|
|
1682
1790
|
|
|
@@ -1684,37 +1792,58 @@ const Settings = (() => {
|
|
|
1684
1792
|
const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
|
|
1685
1793
|
const wrap = document.createElement("div");
|
|
1686
1794
|
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 => {
|
|
1795
|
+
|
|
1796
|
+
const fields = document.createElement("div");
|
|
1797
|
+
fields.className = "media-custom-fields";
|
|
1798
|
+
|
|
1799
|
+
const mkInput = (labelKey, fieldName, type, placeholder, initial) => {
|
|
1800
|
+
const f = _buildField(I18n.t(labelKey));
|
|
1801
|
+
const inp = document.createElement("input");
|
|
1802
|
+
inp.type = type;
|
|
1803
|
+
inp.className = "field-input";
|
|
1804
|
+
inp.value = initial || "";
|
|
1805
|
+
inp.placeholder = placeholder;
|
|
1806
|
+
inp.dataset.field = fieldName;
|
|
1702
1807
|
inp.addEventListener("input", () => {
|
|
1703
1808
|
_mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
|
|
1704
|
-
_mediaCustomDraft[kind][
|
|
1809
|
+
_mediaCustomDraft[kind][fieldName] = inp.value;
|
|
1705
1810
|
});
|
|
1706
|
-
|
|
1707
|
-
|
|
1811
|
+
f.appendChild(inp);
|
|
1812
|
+
return f;
|
|
1813
|
+
};
|
|
1814
|
+
fields.appendChild(mkInput("settings.media.field.model", "model", "text", "gpt-image-1", draft.model));
|
|
1815
|
+
fields.appendChild(mkInput("settings.media.field.baseUrl", "base_url", "text", "https://api.openai.com/v1", draft.base_url));
|
|
1816
|
+
fields.appendChild(mkInput("settings.media.field.apiKey", "api_key", "password", I18n.t("settings.media.apiKey.placeholder"), draft.api_key));
|
|
1817
|
+
wrap.appendChild(fields);
|
|
1818
|
+
|
|
1819
|
+
const result = _buildMediaResult(kind);
|
|
1820
|
+
wrap.appendChild(result);
|
|
1821
|
+
|
|
1822
|
+
const actions = document.createElement("div");
|
|
1823
|
+
actions.className = "media-row-actions";
|
|
1824
|
+
|
|
1825
|
+
const cancelBtn = document.createElement("button");
|
|
1826
|
+
cancelBtn.type = "button";
|
|
1827
|
+
cancelBtn.className = "btn-secondary media-row-btn-sm";
|
|
1828
|
+
cancelBtn.textContent = I18n.t("settings.media.action.cancel");
|
|
1829
|
+
cancelBtn.addEventListener("click", () => {
|
|
1708
1830
|
delete _mediaCustomDraft[kind];
|
|
1709
|
-
// If there's no saved custom, fall back to whatever the saved source is (or off)
|
|
1710
1831
|
if (!state.configured) {
|
|
1711
1832
|
const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
|
|
1712
1833
|
_mediaState[kind] = { ..._mediaState[kind], source: fallback };
|
|
1713
1834
|
}
|
|
1714
|
-
|
|
1835
|
+
_refreshKindRows(kind);
|
|
1715
1836
|
});
|
|
1716
|
-
|
|
1837
|
+
|
|
1838
|
+
const saveBtn = document.createElement("button");
|
|
1839
|
+
saveBtn.type = "button";
|
|
1840
|
+
saveBtn.className = "btn-primary media-row-btn-sm";
|
|
1841
|
+
saveBtn.textContent = I18n.t("settings.media.action.save");
|
|
1842
|
+
saveBtn.addEventListener("click", async () => {
|
|
1717
1843
|
const d = _mediaCustomDraft[kind] || {};
|
|
1844
|
+
saveBtn.disabled = true;
|
|
1845
|
+
cancelBtn.disabled = true;
|
|
1846
|
+
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
|
|
1718
1847
|
try {
|
|
1719
1848
|
await _saveMediaConfig(kind, {
|
|
1720
1849
|
source: "custom",
|
|
@@ -1723,26 +1852,57 @@ const Settings = (() => {
|
|
|
1723
1852
|
api_key: d.api_key || ""
|
|
1724
1853
|
});
|
|
1725
1854
|
delete _mediaCustomDraft[kind];
|
|
1726
|
-
await
|
|
1855
|
+
await _reloadKind(kind);
|
|
1727
1856
|
} catch (e) {
|
|
1728
|
-
|
|
1857
|
+
saveBtn.disabled = false;
|
|
1858
|
+
cancelBtn.disabled = false;
|
|
1859
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1729
1860
|
}
|
|
1730
1861
|
});
|
|
1731
|
-
|
|
1862
|
+
|
|
1863
|
+
actions.appendChild(cancelBtn);
|
|
1864
|
+
actions.appendChild(saveBtn);
|
|
1865
|
+
wrap.appendChild(actions);
|
|
1866
|
+
|
|
1732
1867
|
return wrap;
|
|
1733
1868
|
}
|
|
1734
1869
|
|
|
1870
|
+
function _buildField(labelText) {
|
|
1871
|
+
const f = document.createElement("div");
|
|
1872
|
+
f.className = "model-field";
|
|
1873
|
+
const label = document.createElement("span");
|
|
1874
|
+
label.className = "field-label";
|
|
1875
|
+
label.textContent = labelText;
|
|
1876
|
+
f.appendChild(label);
|
|
1877
|
+
return f;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function _buildMediaResult(kind) {
|
|
1881
|
+
const el = document.createElement("div");
|
|
1882
|
+
el.className = "model-test-result";
|
|
1883
|
+
el.dataset.mediaKind = kind;
|
|
1884
|
+
return el;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function _setMediaResult(kind, status, message) {
|
|
1888
|
+
const el = document.querySelector(`.model-test-result[data-media-kind="${kind}"]`);
|
|
1889
|
+
if (!el) return;
|
|
1890
|
+
el.className = `model-test-result result-${status}`;
|
|
1891
|
+
if (!message) {
|
|
1892
|
+
el.textContent = "";
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const prefix = status === "ok" ? "✓ " : status === "fail" ? "✗ " : "";
|
|
1896
|
+
el.textContent = prefix + message;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1735
1899
|
async function _onMediaSourceClick(kind, source) {
|
|
1736
1900
|
const cur = (_mediaState && _mediaState[kind]) || {};
|
|
1737
1901
|
if (cur.source === source && source !== "custom") return;
|
|
1738
1902
|
|
|
1739
1903
|
if (source === "custom") {
|
|
1740
1904
|
if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
|
|
1741
|
-
_mediaCustomDraft[kind] = {
|
|
1742
|
-
model: cur.model || "",
|
|
1743
|
-
base_url: cur.base_url || "",
|
|
1744
|
-
api_key: ""
|
|
1745
|
-
};
|
|
1905
|
+
_mediaCustomDraft[kind] = { model: "", base_url: "", api_key: "" };
|
|
1746
1906
|
}
|
|
1747
1907
|
_mediaState[kind] = { ...cur, source: "custom" };
|
|
1748
1908
|
_renderMediaRows();
|
|
@@ -1754,12 +1914,14 @@ const Settings = (() => {
|
|
|
1754
1914
|
delete _mediaCustomDraft[kind];
|
|
1755
1915
|
await _loadMedia();
|
|
1756
1916
|
} catch (e) {
|
|
1757
|
-
|
|
1917
|
+
_renderMediaRows();
|
|
1918
|
+
_setMediaResult(kind, "fail", e.message);
|
|
1758
1919
|
}
|
|
1759
1920
|
}
|
|
1760
1921
|
|
|
1761
1922
|
async function _saveMediaConfig(kind, body) {
|
|
1762
|
-
const
|
|
1923
|
+
const url = kind === "ocr" ? `/api/config/ocr` : `/api/config/media/${kind}`;
|
|
1924
|
+
const res = await fetch(url, {
|
|
1763
1925
|
method: "PATCH",
|
|
1764
1926
|
headers: { "Content-Type": "application/json" },
|
|
1765
1927
|
body: JSON.stringify(body)
|
|
@@ -1771,6 +1933,25 @@ const Settings = (() => {
|
|
|
1771
1933
|
return data;
|
|
1772
1934
|
}
|
|
1773
1935
|
|
|
1936
|
+
async function _testMediaConfig(kind, { model, base_url, api_key }) {
|
|
1937
|
+
try {
|
|
1938
|
+
const url = kind === "ocr" ? `/api/config/ocr/test` : `/api/config/media/test`;
|
|
1939
|
+
const payload = kind === "ocr"
|
|
1940
|
+
? { model, base_url, api_key }
|
|
1941
|
+
: { kind, model, base_url, api_key };
|
|
1942
|
+
const res = await fetch(url, {
|
|
1943
|
+
method: "POST",
|
|
1944
|
+
headers: { "Content-Type": "application/json" },
|
|
1945
|
+
body: JSON.stringify(payload)
|
|
1946
|
+
});
|
|
1947
|
+
const data = await res.json().catch(() => ({}));
|
|
1948
|
+
if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
|
|
1949
|
+
return data;
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
return { ok: false, message: e.message };
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1774
1955
|
|
|
1775
1956
|
function init() {
|
|
1776
1957
|
_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
|
|