chocomint 1.1.0 → 1.1.2
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/README.md +40 -1
- data/bin/chocomint-server +2 -1
- data/config/config.yml +39 -37
- data/lib/chocomint/config.rb +137 -5
- data/lib/chocomint/edit_handlers.rb +112 -66
- data/lib/chocomint/factory.rb +9 -6
- data/lib/chocomint/llm/base_client.rb +41 -3
- data/lib/chocomint/llm/chat_client.rb +51 -83
- data/lib/chocomint/llm/primary_client.rb +7 -4
- data/lib/chocomint/planner.rb +8 -6
- data/lib/chocomint/server.rb +49 -3
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +33 -1
- data/public/edit/edit.js +232 -18
- data/public/edit/index.html +51 -0
- metadata +1 -1
data/public/edit/edit.js
CHANGED
|
@@ -1460,7 +1460,23 @@ function addPendingMsg() {
|
|
|
1460
1460
|
sp.textContent = SPINNER_FRAMES[0];
|
|
1461
1461
|
head.appendChild(sp);
|
|
1462
1462
|
head.appendChild(document.createTextNode(' AI 考え中...'));
|
|
1463
|
+
// 経過時間をリアルタイム表示 (例: 30s)。1 秒ごとに更新する。
|
|
1464
|
+
const elapsed = document.createElement('span');
|
|
1465
|
+
elapsed.className = 'pending-elapsed';
|
|
1466
|
+
head.appendChild(elapsed);
|
|
1463
1467
|
b.appendChild(head);
|
|
1468
|
+
const startedAt = Date.now();
|
|
1469
|
+
const updateElapsed = () => {
|
|
1470
|
+
elapsed.textContent = '(' + Math.floor((Date.now() - startedAt) / 1000) + 's)';
|
|
1471
|
+
};
|
|
1472
|
+
updateElapsed();
|
|
1473
|
+
const elapsedTimer = setInterval(updateElapsed, 1000);
|
|
1474
|
+
div._startedAt = startedAt;
|
|
1475
|
+
// 経過秒 (実行時間) を確定して返す。タイマーも止める (二重停止は無害)。
|
|
1476
|
+
div._finishElapsed = () => {
|
|
1477
|
+
clearInterval(elapsedTimer);
|
|
1478
|
+
return Math.round((Date.now() - startedAt) / 1000);
|
|
1479
|
+
};
|
|
1464
1480
|
// ツール実行の進捗行を並べるコンテナ (SSE の tool_start / tool_done で更新)。
|
|
1465
1481
|
const tools = document.createElement('div');
|
|
1466
1482
|
tools.className = 'pending-tools';
|
|
@@ -1472,6 +1488,7 @@ function addPendingMsg() {
|
|
|
1472
1488
|
// remove 時に回転対象からも外す (ツール行のスピナーも含めて)。
|
|
1473
1489
|
const origRemove = div.remove.bind(div);
|
|
1474
1490
|
div.remove = () => {
|
|
1491
|
+
clearInterval(elapsedTimer);
|
|
1475
1492
|
spinnerTargets.delete(sp);
|
|
1476
1493
|
tools.querySelectorAll('.pending-spinner').forEach(s => spinnerTargets.delete(s));
|
|
1477
1494
|
origRemove();
|
|
@@ -1539,6 +1556,24 @@ function pendingToolDone(pending, ev) {
|
|
|
1539
1556
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
1540
1557
|
}
|
|
1541
1558
|
|
|
1559
|
+
// trace_id に対応する監査ログ (実行の詳細) の URL。エディタと同一オリジンで開ける。
|
|
1560
|
+
function traceLogHref(traceId) {
|
|
1561
|
+
return '/logs?trace_id=' + encodeURIComponent(traceId);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// 完了マーク (✓/✕) とツール名を、その実行の監査ログへのリンクにする。
|
|
1565
|
+
// trace_id が判明した時点 (result 受信後) に呼ぶ。既にリンク済みなら href だけ更新する。
|
|
1566
|
+
function linkToolRow(row, traceId) {
|
|
1567
|
+
if (!traceId) return;
|
|
1568
|
+
const href = traceLogHref(traceId);
|
|
1569
|
+
const title = 'この実行の監査ログを開く';
|
|
1570
|
+
row.querySelectorAll('.ptool-mark, .ptool-label, .step-mark, .step-label').forEach(el => {
|
|
1571
|
+
el.classList.add('log-link');
|
|
1572
|
+
el.onclick = () => window.open(href, '_blank', 'noopener');
|
|
1573
|
+
el.title = title;
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1542
1577
|
// 軽量 Markdown → HTML 変換 (外部ライブラリ非依存 / オフライン用)。
|
|
1543
1578
|
// XSS 防止のため、まず全体を HTML エスケープしてから Markdown 記法だけを HTML 化する。
|
|
1544
1579
|
function escapeHtml(s) {
|
|
@@ -1551,14 +1586,24 @@ function renderMarkdown(src) {
|
|
|
1551
1586
|
codeBlocks.push(code.replace(/^\n/, ''));
|
|
1552
1587
|
return ' CB' + (codeBlocks.length - 1) + ' ';
|
|
1553
1588
|
});
|
|
1589
|
+
// ローカル LLM は番号付きリストを改行せず「1. … 2. … 3. …」と 1 行で返すことがある。
|
|
1590
|
+
// 行頭以外に現れる番号マーカー ( "2." など。前が空白/全角空白) の直前で改行して、
|
|
1591
|
+
// 各項目を独立した行に分割する (箇条書きとして描画できるようにするため)。
|
|
1592
|
+
text = text.replace(/[ ]+(?=\d+\.[ ])/g, '\n');
|
|
1593
|
+
|
|
1554
1594
|
const lines = text.split('\n');
|
|
1555
1595
|
let html = '';
|
|
1556
|
-
let
|
|
1557
|
-
const closeList = () => { if (
|
|
1596
|
+
let listTag = null; // 開いているリストの種類 ('ul' / 'ol' / null)
|
|
1597
|
+
const closeList = () => { if (listTag) { html += '</' + listTag + '>'; listTag = null; } };
|
|
1598
|
+
const openList = (tag) => {
|
|
1599
|
+
if (listTag !== tag) { closeList(); html += '<' + tag + '>'; listTag = tag; }
|
|
1600
|
+
};
|
|
1558
1601
|
for (let i = 0; i < lines.length; i++) {
|
|
1559
1602
|
const line = lines[i];
|
|
1560
1603
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1561
1604
|
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
1605
|
+
// 番号付きリスト: 「1. 内容」「2) 内容」など。
|
|
1606
|
+
const oli = line.match(/^\s*\d+[.)][ ]+(.*)$/);
|
|
1562
1607
|
// GFM テーブル: 「| 見出し |」+ 次行が区切り (|---|---|) なら表として処理。
|
|
1563
1608
|
if (isTableRow(line) && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
|
|
1564
1609
|
closeList();
|
|
@@ -1572,8 +1617,11 @@ function renderMarkdown(src) {
|
|
|
1572
1617
|
const lvl = h[1].length;
|
|
1573
1618
|
html += '<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>';
|
|
1574
1619
|
} else if (li) {
|
|
1575
|
-
|
|
1620
|
+
openList('ul');
|
|
1576
1621
|
html += '<li>' + inline(li[1]) + '</li>';
|
|
1622
|
+
} else if (oli) {
|
|
1623
|
+
openList('ol');
|
|
1624
|
+
html += '<li>' + inline(oli[1]) + '</li>';
|
|
1577
1625
|
} else if (line.trim() === '') {
|
|
1578
1626
|
closeList();
|
|
1579
1627
|
} else if (/^ CB\d+ $/.test(line)) {
|
|
@@ -1629,12 +1677,19 @@ function addUserMsg(text) {
|
|
|
1629
1677
|
}
|
|
1630
1678
|
|
|
1631
1679
|
// AI 発言: 枠なしの地の文 (ラベルなし)。回答は Markdown として描画する。
|
|
1632
|
-
|
|
1680
|
+
// elapsedSec: 実行時間 (秒)。渡されれば発言の下に控えめに「実行時間 Ns」を添える。
|
|
1681
|
+
function addAiMsg(text, elapsedSec) {
|
|
1633
1682
|
const div = document.createElement('div');
|
|
1634
1683
|
div.className = 'msg ai';
|
|
1635
1684
|
const b = document.createElement('div');
|
|
1636
1685
|
b.className = 'bubble markdown';
|
|
1637
1686
|
b.innerHTML = renderMarkdown(text);
|
|
1687
|
+
if (elapsedSec != null) {
|
|
1688
|
+
const meta = document.createElement('div');
|
|
1689
|
+
meta.className = 'msg-elapsed';
|
|
1690
|
+
meta.textContent = '実行時間 ' + elapsedSec + 's';
|
|
1691
|
+
b.appendChild(meta);
|
|
1692
|
+
}
|
|
1638
1693
|
div.appendChild(b);
|
|
1639
1694
|
chatLog.appendChild(div);
|
|
1640
1695
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
@@ -1643,7 +1698,7 @@ function addAiMsg(text) {
|
|
|
1643
1698
|
|
|
1644
1699
|
// 実行ステップ 1 件を「番号・ツールラベル・対象・成否・出力」の構造で描画する。
|
|
1645
1700
|
// 生 JSON をそのまま出さず、人間が追える形にするのが目的。
|
|
1646
|
-
function renderStep(s, no) {
|
|
1701
|
+
function renderStep(s, no, traceId) {
|
|
1647
1702
|
const st = document.createElement('div');
|
|
1648
1703
|
st.className = 'step' + (s.status === 'PASS' ? '' : ' failed');
|
|
1649
1704
|
|
|
@@ -1677,6 +1732,9 @@ function renderStep(s, no) {
|
|
|
1677
1732
|
|
|
1678
1733
|
st.appendChild(head);
|
|
1679
1734
|
|
|
1735
|
+
// trace_id があれば、完了マークとツール名をこの実行の監査ログへのリンクにする。
|
|
1736
|
+
if (traceId) linkToolRow(st, traceId);
|
|
1737
|
+
|
|
1680
1738
|
// 出力は改行を保った等幅ブロックで表示。空なら控えめに「(出力なし)」。
|
|
1681
1739
|
const out = document.createElement('code');
|
|
1682
1740
|
out.className = 'out';
|
|
@@ -1691,9 +1749,10 @@ function renderStep(s, no) {
|
|
|
1691
1749
|
}
|
|
1692
1750
|
|
|
1693
1751
|
// AI の実行結果 (ステータスバッジ + 返信 + 各ツールの出力) を描画する。
|
|
1694
|
-
|
|
1752
|
+
// elapsedSec: 実行時間 (秒)。渡されればバッジに「実行時間 Ns」を添える。
|
|
1753
|
+
function renderAiResult(data, elapsedSec) {
|
|
1695
1754
|
// 会話応答 (ツール実行なし) は自然文だけを地の文として表示する。
|
|
1696
|
-
if (data.reply) return addAiMsg(data.reply);
|
|
1755
|
+
if (data.reply) return addAiMsg(data.reply, elapsedSec);
|
|
1697
1756
|
|
|
1698
1757
|
const status = data.status || 'FAIL';
|
|
1699
1758
|
const div = document.createElement('div');
|
|
@@ -1705,7 +1764,9 @@ function renderAiResult(data) {
|
|
|
1705
1764
|
badge.className = 'badge';
|
|
1706
1765
|
const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
|
|
1707
1766
|
// attempts は「LLM への試行回数」。1 回なら冗長なので添えない。
|
|
1708
|
-
badge.textContent = statusText +
|
|
1767
|
+
badge.textContent = statusText +
|
|
1768
|
+
(data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
|
|
1769
|
+
(elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
|
|
1709
1770
|
b.appendChild(badge);
|
|
1710
1771
|
|
|
1711
1772
|
// AI による実行結果の要約 (Markdown)。生のツール出力より上に目立たせて表示。
|
|
@@ -1734,7 +1795,7 @@ function renderAiResult(data) {
|
|
|
1734
1795
|
if (steps.length) {
|
|
1735
1796
|
const wrap = document.createElement('div');
|
|
1736
1797
|
wrap.className = 'steps';
|
|
1737
|
-
steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1)));
|
|
1798
|
+
steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1, data.trace_id)));
|
|
1738
1799
|
// 要約があるときは実行の詳細を折りたたむ (まとめを主役にする)。
|
|
1739
1800
|
if (data.summary) {
|
|
1740
1801
|
const det = document.createElement('details');
|
|
@@ -1770,7 +1831,8 @@ function clearChat() {
|
|
|
1770
1831
|
}
|
|
1771
1832
|
|
|
1772
1833
|
// 現在のチャット表示テキストを AI に要約させ、ログを短い要約 1 件に畳み込む。
|
|
1773
|
-
//
|
|
1834
|
+
// 履歴は表示ログから毎回組み立てて送るため、これでログを畳めば次回送信の
|
|
1835
|
+
// history も要約後の短い文脈に置き換わる (肥大化を防ぐ)。
|
|
1774
1836
|
async function compactChat() {
|
|
1775
1837
|
const transcript = collectTranscript();
|
|
1776
1838
|
if (!transcript.trim()) { clearChat(); return; }
|
|
@@ -1800,18 +1862,41 @@ async function compactChat() {
|
|
|
1800
1862
|
}
|
|
1801
1863
|
}
|
|
1802
1864
|
|
|
1865
|
+
// メッセージ要素から「発言そのもの」のテキストだけを取り出す。
|
|
1866
|
+
// 実行時間 (.msg-elapsed) やステータスバッジ (.badge) などの UI 注記は
|
|
1867
|
+
// AI への履歴・要約に混ぜたくないので、複製から取り除いてから textContent を取る。
|
|
1868
|
+
function messageText(m) {
|
|
1869
|
+
const clone = m.cloneNode(true);
|
|
1870
|
+
clone.querySelectorAll('.msg-elapsed, .badge').forEach(el => el.remove());
|
|
1871
|
+
return (clone.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1803
1874
|
// チャットログを AI へ渡す 1 本のプレーンテキストに書き出す (要約入力用)。
|
|
1804
1875
|
// ユーザー発言 / AI 発言 / 実行要約 を role 付きの行にして連結する。
|
|
1805
1876
|
function collectTranscript() {
|
|
1806
1877
|
const lines = [];
|
|
1807
1878
|
chatLog.querySelectorAll('.msg').forEach(m => {
|
|
1808
1879
|
const role = m.classList.contains('user') ? 'ユーザー' : 'アシスタント';
|
|
1809
|
-
const text = (m
|
|
1880
|
+
const text = messageText(m);
|
|
1810
1881
|
if (text) lines.push(role + ': ' + text);
|
|
1811
1882
|
});
|
|
1812
1883
|
return lines.join('\n');
|
|
1813
1884
|
}
|
|
1814
1885
|
|
|
1886
|
+
// これまでのチャットログを /edit/chat に渡す履歴配列に書き出す。
|
|
1887
|
+
// サーバーは会話履歴を保持しないため、送信のたびに表示済みログから組み立てて送る。
|
|
1888
|
+
function collectChatHistory() {
|
|
1889
|
+
const history = [];
|
|
1890
|
+
chatLog.querySelectorAll('.msg').forEach(m => {
|
|
1891
|
+
// pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
|
|
1892
|
+
if (m.querySelector('.pending-head')) return;
|
|
1893
|
+
const role = m.classList.contains('user') ? 'user' : 'assistant';
|
|
1894
|
+
const text = messageText(m);
|
|
1895
|
+
if (text) history.push({ role, content: text });
|
|
1896
|
+
});
|
|
1897
|
+
return history;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1815
1900
|
// /compact の結果 (これまでの会話のまとめ) を専用の見出し付きで表示する。
|
|
1816
1901
|
function addCompactSummary(text) {
|
|
1817
1902
|
const div = document.createElement('div');
|
|
@@ -1855,11 +1940,19 @@ function finalizePending(pending, data) {
|
|
|
1855
1940
|
const badge = document.createElement('span');
|
|
1856
1941
|
badge.className = 'badge';
|
|
1857
1942
|
const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
|
|
1858
|
-
|
|
1943
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1944
|
+
badge.textContent = statusText +
|
|
1945
|
+
(data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
|
|
1946
|
+
(elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
|
|
1859
1947
|
if (head) head.replaceWith(badge); else b.insertBefore(badge, b.firstChild);
|
|
1860
1948
|
|
|
1861
|
-
//
|
|
1949
|
+
// trace_id が判明したので、各ツール行 (完了マーク + ツール名) を監査ログへのリンクにする。
|
|
1862
1950
|
const tools = pending._toolsEl;
|
|
1951
|
+
if (data.trace_id && tools) {
|
|
1952
|
+
tools.querySelectorAll('.ptool').forEach(row => linkToolRow(row, data.trace_id));
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// 要約 (Markdown) はツール一覧の上に目立たせて置く。
|
|
1863
1956
|
if (data.summary) {
|
|
1864
1957
|
const sum = document.createElement('div');
|
|
1865
1958
|
sum.className = 'summary markdown';
|
|
@@ -1895,8 +1988,10 @@ function handleChatEvent(pending, evName, dataStr) {
|
|
|
1895
1988
|
// 会話応答 (reply) はツール実行が無いので、pending を消して地の文で表示。
|
|
1896
1989
|
// ツール実行があった場合は、進捗表示を消さずにその場で仕上げる。
|
|
1897
1990
|
if (data.reply || !pending._hadTool) {
|
|
1991
|
+
// pending 破棄前に実行時間を確定して結果表示へ渡す。
|
|
1992
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1898
1993
|
pending.remove();
|
|
1899
|
-
renderAiResult(data);
|
|
1994
|
+
renderAiResult(data, elapsedSec);
|
|
1900
1995
|
} else {
|
|
1901
1996
|
finalizePending(pending, data);
|
|
1902
1997
|
}
|
|
@@ -1906,8 +2001,9 @@ function handleChatEvent(pending, evName, dataStr) {
|
|
|
1906
2001
|
if (pending._hadTool) {
|
|
1907
2002
|
finalizePending(pending, { status: 'FAIL', error: data.error || 'エラー' });
|
|
1908
2003
|
} else {
|
|
2004
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1909
2005
|
pending.remove();
|
|
1910
|
-
renderAiResult({ status: 'FAIL', error: data.error || 'エラー' });
|
|
2006
|
+
renderAiResult({ status: 'FAIL', error: data.error || 'エラー' }, elapsedSec);
|
|
1911
2007
|
}
|
|
1912
2008
|
}
|
|
1913
2009
|
}
|
|
@@ -1917,6 +2013,8 @@ async function sendChat() {
|
|
|
1917
2013
|
if (!instruction) return;
|
|
1918
2014
|
// /clear /compact はローカルコマンド (AI へは送らない)。
|
|
1919
2015
|
if (handleSlashCommand(instruction)) return;
|
|
2016
|
+
// 今回の発言を DOM に足す前に、これまでのログを履歴として集めておく。
|
|
2017
|
+
const history = collectChatHistory();
|
|
1920
2018
|
chatInput.value = '';
|
|
1921
2019
|
addUserMsg(instruction);
|
|
1922
2020
|
chatForm.classList.add('busy');
|
|
@@ -1926,13 +2024,14 @@ async function sendChat() {
|
|
|
1926
2024
|
try {
|
|
1927
2025
|
const r = await fetch('/edit/chat', {
|
|
1928
2026
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1929
|
-
body: JSON.stringify({ path: currentPath || '', instruction, stream: true })
|
|
2027
|
+
body: JSON.stringify({ path: currentPath || '', instruction, history, stream: true })
|
|
1930
2028
|
});
|
|
1931
2029
|
if (!r.ok || !r.body) {
|
|
1932
2030
|
// SSE を開始できなかった場合は本文を JSON として読んでフォールバック。
|
|
1933
2031
|
const data = await r.json().catch(() => ({ status: 'FAIL', error: r.status + ' error' }));
|
|
2032
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1934
2033
|
pending.remove();
|
|
1935
|
-
renderAiResult(data);
|
|
2034
|
+
renderAiResult(data, elapsedSec);
|
|
1936
2035
|
applyResultSideEffects(data);
|
|
1937
2036
|
return;
|
|
1938
2037
|
}
|
|
@@ -1957,8 +2056,9 @@ async function sendChat() {
|
|
|
1957
2056
|
}
|
|
1958
2057
|
}
|
|
1959
2058
|
} catch (err) {
|
|
2059
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1960
2060
|
pending.remove();
|
|
1961
|
-
renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
|
|
2061
|
+
renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message }, elapsedSec);
|
|
1962
2062
|
} finally {
|
|
1963
2063
|
stopSpinner();
|
|
1964
2064
|
chatForm.classList.remove('busy');
|
|
@@ -2518,6 +2618,119 @@ remoteUrlInput.addEventListener('keydown', (e) => {
|
|
|
2518
2618
|
});
|
|
2519
2619
|
initDraggableWindow(remoteDialog);
|
|
2520
2620
|
|
|
2621
|
+
// ---- モデル選択 (ステータスバー) ----
|
|
2622
|
+
const modelDialog = document.getElementById('model-dialog');
|
|
2623
|
+
const modelNameEl = document.getElementById('statusbar-model-name');
|
|
2624
|
+
const modelOllamaSelect = document.getElementById('model-ollama-select');
|
|
2625
|
+
const modelOllamaEmpty = document.getElementById('model-ollama-empty');
|
|
2626
|
+
const modelOpenrouterInput = document.getElementById('model-openrouter-input');
|
|
2627
|
+
const modelOpenrouterReady = document.getElementById('model-openrouter-ready');
|
|
2628
|
+
const modelOpenrouterNokey = document.getElementById('model-openrouter-nokey');
|
|
2629
|
+
const modelError = document.getElementById('model-error');
|
|
2630
|
+
// ダイアログ内で選択中の provider ("ollama" | "openrouter")。
|
|
2631
|
+
let modelSelectedProvider = 'ollama';
|
|
2632
|
+
// /edit/models の直近の応答 (現在値と選択肢) を保持する。
|
|
2633
|
+
let modelInfo = null;
|
|
2634
|
+
|
|
2635
|
+
// ステータスバーのモデル名を更新する (起動時と切り替え成功時に呼ぶ)。
|
|
2636
|
+
function updateModelStatus(provider, model) {
|
|
2637
|
+
if (!modelNameEl) return;
|
|
2638
|
+
modelNameEl.textContent = model || 'モデル未設定';
|
|
2639
|
+
const btn = document.getElementById('statusbar-model');
|
|
2640
|
+
if (btn) btn.title = `AI モデルを選択 (現在: ${provider} / ${model || '未設定'})`;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// 起動時に現在のモデルを取得してステータスバーに表示する。
|
|
2644
|
+
async function initModelStatus() {
|
|
2645
|
+
try {
|
|
2646
|
+
modelInfo = await gitFetch('/edit/models');
|
|
2647
|
+
updateModelStatus(modelInfo.provider, modelInfo.model);
|
|
2648
|
+
} catch {
|
|
2649
|
+
// 取得に失敗しても致命的ではない。"—" 表示のままにする。
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// provider タブを切り替えて対応ペインを表示する。
|
|
2654
|
+
function selectModelProvider(provider) {
|
|
2655
|
+
modelSelectedProvider = provider;
|
|
2656
|
+
modelError.textContent = '';
|
|
2657
|
+
document.getElementById('model-tab-ollama').classList.toggle('active', provider === 'ollama');
|
|
2658
|
+
document.getElementById('model-tab-openrouter').classList.toggle('active', provider === 'openrouter');
|
|
2659
|
+
document.getElementById('model-pane-ollama').hidden = provider !== 'ollama';
|
|
2660
|
+
document.getElementById('model-pane-openrouter').hidden = provider !== 'openrouter';
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
async function openModelDialog() {
|
|
2664
|
+
modelError.textContent = '';
|
|
2665
|
+
// 開くたびに最新の情報 (インストール済みモデル・OpenRouter 可否) を取り直す。
|
|
2666
|
+
try {
|
|
2667
|
+
modelInfo = await gitFetch('/edit/models');
|
|
2668
|
+
} catch (err) {
|
|
2669
|
+
modelInfo = modelInfo || { provider: 'ollama', model: '', ollama_models: [], openrouter_available: false };
|
|
2670
|
+
}
|
|
2671
|
+
updateModelStatus(modelInfo.provider, modelInfo.model);
|
|
2672
|
+
|
|
2673
|
+
// Ollama: インストール済みモデルを select に流し込み、現在値を選択する。
|
|
2674
|
+
modelOllamaSelect.innerHTML = '';
|
|
2675
|
+
const models = modelInfo.ollama_models || [];
|
|
2676
|
+
models.forEach(name => {
|
|
2677
|
+
const opt = document.createElement('option');
|
|
2678
|
+
opt.value = name; opt.textContent = name;
|
|
2679
|
+
modelOllamaSelect.appendChild(opt);
|
|
2680
|
+
});
|
|
2681
|
+
modelOllamaEmpty.hidden = models.length > 0;
|
|
2682
|
+
modelOllamaSelect.hidden = models.length === 0;
|
|
2683
|
+
if (modelInfo.provider === 'ollama' && modelInfo.model && models.includes(modelInfo.model)) {
|
|
2684
|
+
modelOllamaSelect.value = modelInfo.model;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// OpenRouter: キーがあれば入力欄、無ければ案内を表示する。
|
|
2688
|
+
const orAvailable = !!modelInfo.openrouter_available;
|
|
2689
|
+
modelOpenrouterReady.hidden = !orAvailable;
|
|
2690
|
+
modelOpenrouterNokey.hidden = orAvailable;
|
|
2691
|
+
modelOpenrouterInput.value = modelInfo.provider === 'openrouter' ? (modelInfo.model || '') : '';
|
|
2692
|
+
|
|
2693
|
+
selectModelProvider(modelInfo.provider || 'ollama');
|
|
2694
|
+
centerWindow(modelDialog);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function closeModelDialog() { modelDialog.hidden = true; }
|
|
2698
|
+
|
|
2699
|
+
async function applyModel() {
|
|
2700
|
+
modelError.textContent = '';
|
|
2701
|
+
let model = '';
|
|
2702
|
+
if (modelSelectedProvider === 'ollama') {
|
|
2703
|
+
model = modelOllamaSelect.value;
|
|
2704
|
+
if (!model) { modelError.textContent = 'モデルを選択してください'; return; }
|
|
2705
|
+
} else {
|
|
2706
|
+
model = modelOpenrouterInput.value.trim();
|
|
2707
|
+
if (!model) { modelError.textContent = 'モデル ID を入力してください'; return; }
|
|
2708
|
+
}
|
|
2709
|
+
const okBtn = document.getElementById('model-ok');
|
|
2710
|
+
okBtn.disabled = true;
|
|
2711
|
+
try {
|
|
2712
|
+
const data = await gitPost('/edit/model', { provider: modelSelectedProvider, model });
|
|
2713
|
+
updateModelStatus(data.provider, data.model);
|
|
2714
|
+
closeModelDialog();
|
|
2715
|
+
} catch (err) {
|
|
2716
|
+
modelError.textContent = err.message;
|
|
2717
|
+
} finally {
|
|
2718
|
+
okBtn.disabled = false;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
document.getElementById('statusbar-model').onclick = () => openModelDialog();
|
|
2723
|
+
document.getElementById('model-tab-ollama').onclick = () => selectModelProvider('ollama');
|
|
2724
|
+
document.getElementById('model-tab-openrouter').onclick = () => selectModelProvider('openrouter');
|
|
2725
|
+
document.getElementById('model-ok').onclick = () => applyModel();
|
|
2726
|
+
document.getElementById('model-cancel').onclick = closeModelDialog;
|
|
2727
|
+
document.getElementById('model-close').onclick = closeModelDialog;
|
|
2728
|
+
modelOpenrouterInput.addEventListener('keydown', (e) => {
|
|
2729
|
+
if (e.key === 'Enter') { e.preventDefault(); applyModel(); }
|
|
2730
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeModelDialog(); }
|
|
2731
|
+
});
|
|
2732
|
+
initDraggableWindow(modelDialog);
|
|
2733
|
+
|
|
2521
2734
|
// ---- 起動 ----
|
|
2522
2735
|
refreshTree();
|
|
2523
2736
|
initConsole();
|
|
@@ -2527,6 +2740,7 @@ initPanelView();
|
|
|
2527
2740
|
initFilesTab();
|
|
2528
2741
|
setAutoSave(autoSaveEnabled); // メニューのチェック表示を保存済み設定に合わせる
|
|
2529
2742
|
refreshGitPanel();
|
|
2743
|
+
initModelStatus();
|
|
2530
2744
|
|
|
2531
2745
|
// 自動保存 (debounce) が確定する前にタブを閉じようとした場合に警告する。
|
|
2532
2746
|
window.addEventListener('beforeunload', (e) => {
|
data/public/edit/index.html
CHANGED
|
@@ -146,6 +146,10 @@
|
|
|
146
146
|
<button id="statusbar-eol" type="button" title="改行コードを切り替え" hidden>
|
|
147
147
|
<span id="statusbar-eol-name">LF</span>
|
|
148
148
|
</button>
|
|
149
|
+
<!-- モデル選択。現在の LLM モデル名を表示し、クリックでモデル設定ダイアログを開く。 -->
|
|
150
|
+
<button id="statusbar-model" type="button" title="AI モデルを選択">
|
|
151
|
+
<span class="statusbar-model-icon">◇</span><span id="statusbar-model-name">—</span>
|
|
152
|
+
</button>
|
|
149
153
|
</div>
|
|
150
154
|
</div>
|
|
151
155
|
<div id="ctx-menu"></div>
|
|
@@ -250,6 +254,53 @@
|
|
|
250
254
|
</div>
|
|
251
255
|
</div>
|
|
252
256
|
|
|
257
|
+
<!-- モデル設定ダイアログ。ステータスバーのモデル名クリックで開く。
|
|
258
|
+
Ollama はインストール済みモデルから選択、OpenRouter はモデル ID を手入力する。-->
|
|
259
|
+
<div id="model-dialog" class="vwin" hidden>
|
|
260
|
+
<div class="vwin-titlebar">
|
|
261
|
+
<span class="vwin-title">AI モデル設定</span>
|
|
262
|
+
<button type="button" class="vwin-close" id="model-close" title="キャンセル" aria-label="キャンセル">×</button>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="vwin-body">
|
|
265
|
+
<div class="model-tabs" role="tablist">
|
|
266
|
+
<button type="button" class="model-tab" id="model-tab-ollama" data-provider="ollama" role="tab">Ollama</button>
|
|
267
|
+
<button type="button" class="model-tab" id="model-tab-openrouter" data-provider="openrouter" role="tab">OpenRouter</button>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- Ollama: インストール済みモデルから選ぶ。-->
|
|
271
|
+
<div class="model-pane" id="model-pane-ollama">
|
|
272
|
+
<label for="model-ollama-select">インストール済みモデル</label>
|
|
273
|
+
<select id="model-ollama-select"></select>
|
|
274
|
+
<p class="model-hint" id="model-ollama-empty" hidden>
|
|
275
|
+
Ollama に接続できないか、モデルが 1 つもインストールされていません。
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- OpenRouter: API キーがあればモデル ID を手入力。無ければ案内のみ表示。-->
|
|
280
|
+
<div class="model-pane" id="model-pane-openrouter" hidden>
|
|
281
|
+
<div id="model-openrouter-ready" hidden>
|
|
282
|
+
<label for="model-openrouter-input">モデル ID</label>
|
|
283
|
+
<input type="text" id="model-openrouter-input" autocomplete="off"
|
|
284
|
+
placeholder="例: anthropic/claude-3.5-sonnet">
|
|
285
|
+
<p class="model-hint">
|
|
286
|
+
OpenRouter のモデル ID を入力してください
|
|
287
|
+
(<a href="https://openrouter.ai/models" target="_blank" rel="noopener">モデル一覧</a>)。
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
<p class="model-hint model-warn" id="model-openrouter-nokey" hidden>
|
|
291
|
+
環境変数 <code>OPENROUTER_API_KEY</code> が設定されていません。
|
|
292
|
+
設定してからサーバーを再起動すると OpenRouter が使えるようになります。
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="vwin-error" id="model-error"></div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="vwin-actions">
|
|
299
|
+
<button type="button" class="vwin-btn" id="model-cancel">キャンセル</button>
|
|
300
|
+
<button type="button" class="vwin-btn primary" id="model-ok">このモデルを使う</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
253
304
|
<script src="/edit/assets/xterm/xterm.js"></script>
|
|
254
305
|
<script src="/edit/assets/xterm/addon-fit.js"></script>
|
|
255
306
|
<script>
|