chocomint 1.0.3 → 1.1.1
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 +35 -2
- data/THIRD_PARTY_LICENSES.md +1 -0
- data/bin/chocomint-server +2 -1
- data/config/config.yml +39 -37
- data/lib/chocomint/config.rb +137 -5
- data/lib/chocomint/edit_archive_handlers.rb +323 -0
- data/lib/chocomint/edit_handlers.rb +249 -32
- data/lib/chocomint/factory.rb +3 -3
- data/lib/chocomint/llm/chat_client.rb +5 -2
- data/lib/chocomint/llm/primary_client.rb +7 -4
- data/lib/chocomint/planner.rb +8 -6
- data/lib/chocomint/server.rb +53 -3
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +100 -2
- data/public/edit/edit.js +512 -23
- data/public/edit/index.html +60 -0
- metadata +16 -1
data/public/edit/edit.js
CHANGED
|
@@ -165,12 +165,35 @@ const FILE_ICONS = {
|
|
|
165
165
|
jpg: '🖼️',
|
|
166
166
|
jpeg: '🖼️',
|
|
167
167
|
gif: '🖼️',
|
|
168
|
+
webp: '🖼️',
|
|
169
|
+
bmp: '🖼️',
|
|
168
170
|
svg: '🖼️',
|
|
169
171
|
ico: '🖼️',
|
|
172
|
+
avif: '🖼️',
|
|
173
|
+
tif: '🖼️',
|
|
174
|
+
tiff: '🖼️',
|
|
170
175
|
pdf: '📕',
|
|
176
|
+
mp4: '🎬',
|
|
177
|
+
m4v: '🎬',
|
|
178
|
+
webm: '🎬',
|
|
179
|
+
mkv: '🎬',
|
|
180
|
+
mov: '🎬',
|
|
181
|
+
avi: '🎬',
|
|
182
|
+
ogv: '🎬',
|
|
183
|
+
mp3: '🎵',
|
|
184
|
+
wav: '🎵',
|
|
185
|
+
ogg: '🎵',
|
|
186
|
+
m4a: '🎵',
|
|
187
|
+
aac: '🎵',
|
|
188
|
+
flac: '🎵',
|
|
189
|
+
opus: '🎵',
|
|
171
190
|
zip: '📦',
|
|
172
191
|
gz: '📦',
|
|
173
192
|
tar: '📦',
|
|
193
|
+
tgz: '📦',
|
|
194
|
+
bz2: '📦',
|
|
195
|
+
tbz: '📦',
|
|
196
|
+
tbz2: '📦',
|
|
174
197
|
lock: '🔒',
|
|
175
198
|
gitignore: '🚫',
|
|
176
199
|
dockerfile: '🐳',
|
|
@@ -612,18 +635,22 @@ function removeTab(path) {
|
|
|
612
635
|
const wasActive = currentPath === path;
|
|
613
636
|
if (wasActive && autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
614
637
|
openTabs.splice(idx, 1);
|
|
615
|
-
|
|
638
|
+
// メディアタブは Monaco モデルを持たない (model=null)。テキスト/バイナリのみ破棄する。
|
|
639
|
+
if (tab.model) tab.model.dispose();
|
|
616
640
|
if (wasActive) {
|
|
617
641
|
currentPath = null;
|
|
618
642
|
const next = openTabs[idx] || openTabs[idx - 1];
|
|
619
643
|
if (next) {
|
|
620
644
|
activateEditorTab(next.path);
|
|
621
645
|
} else {
|
|
646
|
+
hideMediaView();
|
|
647
|
+
hideArchiveView();
|
|
622
648
|
editor.setModel(null);
|
|
623
649
|
document.getElementById('app').classList.add('no-tabs');
|
|
624
650
|
setPathLabel('(ファイル未選択)');
|
|
625
651
|
setDirty(false);
|
|
626
652
|
setSaveStatus('');
|
|
653
|
+
updateEolStatus();
|
|
627
654
|
document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
|
|
628
655
|
}
|
|
629
656
|
}
|
|
@@ -677,6 +704,199 @@ function openFileByPath(path) {
|
|
|
677
704
|
return openFile(path, li);
|
|
678
705
|
}
|
|
679
706
|
|
|
707
|
+
// ---- メディアプレビュー (画像 / PDF / 動画 / 音声) ----
|
|
708
|
+
// メディアタブがアクティブなときだけ #media-view を表示し、Monaco を隠す。
|
|
709
|
+
// 生ファイルは /edit/raw?path= から取得する (種別ごとの Content-Type / Range 対応)。
|
|
710
|
+
|
|
711
|
+
// 現在 #media-view に載せている <video>/<audio> を止めるために保持する。
|
|
712
|
+
let currentMediaEl = null;
|
|
713
|
+
|
|
714
|
+
function rawUrl(path) {
|
|
715
|
+
return '/edit/raw?path=' + encodeURIComponent(path);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function showMediaView(tab) {
|
|
719
|
+
const app = document.getElementById('app');
|
|
720
|
+
const view = document.getElementById('media-view');
|
|
721
|
+
stopCurrentMedia();
|
|
722
|
+
view.innerHTML = '';
|
|
723
|
+
app.classList.add('show-media');
|
|
724
|
+
// PDF は縁まで使いたいので専用クラスで padding を外す (CSS 側で制御)。
|
|
725
|
+
app.classList.toggle('media-pdf', tab.media === 'pdf');
|
|
726
|
+
|
|
727
|
+
const url = rawUrl(tab.path);
|
|
728
|
+
const name = tab.path.split('/').pop();
|
|
729
|
+
let el;
|
|
730
|
+
switch (tab.media) {
|
|
731
|
+
case 'image':
|
|
732
|
+
el = document.createElement('img');
|
|
733
|
+
el.className = 'media-image';
|
|
734
|
+
el.alt = name;
|
|
735
|
+
el.src = url;
|
|
736
|
+
el.onerror = () => showMediaError(view, '画像を読み込めませんでした');
|
|
737
|
+
view.appendChild(el);
|
|
738
|
+
break;
|
|
739
|
+
case 'pdf':
|
|
740
|
+
el = document.createElement('iframe');
|
|
741
|
+
el.className = 'media-pdf';
|
|
742
|
+
el.title = name;
|
|
743
|
+
el.src = url;
|
|
744
|
+
view.appendChild(el);
|
|
745
|
+
break;
|
|
746
|
+
case 'video':
|
|
747
|
+
el = document.createElement('video');
|
|
748
|
+
el.className = 'media-video';
|
|
749
|
+
el.src = url;
|
|
750
|
+
el.controls = true;
|
|
751
|
+
el.onerror = () => showMediaError(view,
|
|
752
|
+
'この動画はブラウザで再生できない形式の可能性があります (' + name + ')');
|
|
753
|
+
view.appendChild(el);
|
|
754
|
+
currentMediaEl = el;
|
|
755
|
+
break;
|
|
756
|
+
case 'audio': {
|
|
757
|
+
const card = document.createElement('div');
|
|
758
|
+
card.className = 'media-audio-card';
|
|
759
|
+
const label = document.createElement('div');
|
|
760
|
+
label.className = 'media-audio-name';
|
|
761
|
+
label.textContent = '🎵 ' + name;
|
|
762
|
+
el = document.createElement('audio');
|
|
763
|
+
el.className = 'media-audio';
|
|
764
|
+
el.src = url;
|
|
765
|
+
el.controls = true;
|
|
766
|
+
el.onerror = () => showMediaError(view,
|
|
767
|
+
'この音声はブラウザで再生できない形式の可能性があります (' + name + ')');
|
|
768
|
+
card.appendChild(label);
|
|
769
|
+
card.appendChild(el);
|
|
770
|
+
view.appendChild(card);
|
|
771
|
+
currentMediaEl = el;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
default:
|
|
775
|
+
showMediaError(view, 'このファイルはプレビューできません');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function showMediaError(view, message) {
|
|
780
|
+
const p = document.createElement('div');
|
|
781
|
+
p.className = 'media-error';
|
|
782
|
+
p.textContent = message;
|
|
783
|
+
view.appendChild(p);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// 再生中のメディアを止める (タブ切り替え/クローズ時に音が鳴り続けないように)。
|
|
787
|
+
function stopCurrentMedia() {
|
|
788
|
+
if (currentMediaEl) {
|
|
789
|
+
try { currentMediaEl.pause(); } catch (e) { /* 既に破棄済み等は無視 */ }
|
|
790
|
+
currentMediaEl = null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function hideMediaView() {
|
|
795
|
+
const app = document.getElementById('app');
|
|
796
|
+
if (!app.classList.contains('show-media')) return;
|
|
797
|
+
stopCurrentMedia();
|
|
798
|
+
app.classList.remove('show-media', 'media-pdf');
|
|
799
|
+
document.getElementById('media-view').innerHTML = '';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ---- アーカイブプレビュー (zip / tar / tar.gz / bz2 等) ----
|
|
803
|
+
// 第一階層 (トップレベル) のエントリとメタデータを /edit/archive?path= から取得して表示する。
|
|
804
|
+
|
|
805
|
+
// バイト数を人間可読 (KB/MB 等) に整形する。null/未定義は空文字。
|
|
806
|
+
function formatBytes(n) {
|
|
807
|
+
if (n == null || isNaN(n)) return '';
|
|
808
|
+
if (n < 1024) return n + ' B';
|
|
809
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
810
|
+
let v = n / 1024, i = 0;
|
|
811
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
812
|
+
return (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ' + units[i];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// アーカイブ形式のバッジ表示名。
|
|
816
|
+
const ARCHIVE_KIND_LABELS = {
|
|
817
|
+
tar_or_zip: 'ARCHIVE', gz: 'GZIP', bz2: 'BZIP2'
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
async function showArchiveView(tab) {
|
|
821
|
+
const app = document.getElementById('app');
|
|
822
|
+
const view = document.getElementById('archive-view');
|
|
823
|
+
app.classList.add('show-archive');
|
|
824
|
+
const name = tab.path.split('/').pop();
|
|
825
|
+
view.innerHTML = '<div class="archive-empty">読み込み中…</div>';
|
|
826
|
+
|
|
827
|
+
let data;
|
|
828
|
+
try {
|
|
829
|
+
const r = await fetch('/edit/archive?path=' + encodeURIComponent(tab.path));
|
|
830
|
+
data = await r.json();
|
|
831
|
+
if (!r.ok) throw new Error(data.error || 'アーカイブを読み取れませんでした');
|
|
832
|
+
} catch (err) {
|
|
833
|
+
// 取得中にタブが切り替わっていたら描画しない。
|
|
834
|
+
if (currentPath !== tab.path) return;
|
|
835
|
+
view.innerHTML = '';
|
|
836
|
+
view.appendChild(el('div', 'archive-error', '⚠ ' + err.message));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (currentPath !== tab.path) return; // 取得中に別タブへ切り替わっていたら破棄。
|
|
840
|
+
renderArchive(view, name, data);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 単純な要素生成ヘルパ (class と textContent 付き)。
|
|
844
|
+
function el(tag, cls, text) {
|
|
845
|
+
const e = document.createElement(tag);
|
|
846
|
+
if (cls) e.className = cls;
|
|
847
|
+
if (text != null) e.textContent = text;
|
|
848
|
+
return e;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function renderArchive(view, name, data) {
|
|
852
|
+
view.innerHTML = '';
|
|
853
|
+
|
|
854
|
+
// ヘッダー (ファイル名 + 形式バッジ)。
|
|
855
|
+
const head = el('div', 'archive-head');
|
|
856
|
+
head.appendChild(el('span', 'archive-name', '📦 ' + name));
|
|
857
|
+
head.appendChild(el('span', 'archive-kind', ARCHIVE_KIND_LABELS[data.archive] || 'ARCHIVE'));
|
|
858
|
+
view.appendChild(head);
|
|
859
|
+
|
|
860
|
+
// メタデータ行 (エントリ数 / 合計サイズ / 圧縮サイズ)。
|
|
861
|
+
const meta = el('div', 'archive-meta');
|
|
862
|
+
const entryCount = data.total_entries != null ? data.total_entries : (data.entries || []).length;
|
|
863
|
+
meta.appendChild(el('span', null, entryCount + ' エントリ'));
|
|
864
|
+
if (data.total_size != null) meta.appendChild(el('span', null, '展開後 ' + formatBytes(data.total_size)));
|
|
865
|
+
if (data.compressed_size != null) meta.appendChild(el('span', null, '圧縮 ' + formatBytes(data.compressed_size)));
|
|
866
|
+
if (data.capped) meta.appendChild(el('span', 'archive-capped', '(一部のみ表示)'));
|
|
867
|
+
view.appendChild(meta);
|
|
868
|
+
|
|
869
|
+
// エントリ一覧 (第一階層)。
|
|
870
|
+
const entries = data.entries || [];
|
|
871
|
+
if (entries.length === 0) {
|
|
872
|
+
view.appendChild(el('div', 'archive-empty', '(エントリがありません)'));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const ul = el('ul', 'archive-list');
|
|
876
|
+
for (const ent of entries) {
|
|
877
|
+
const li = el('li');
|
|
878
|
+
li.appendChild(el('span', 'a-icon', ent.dir ? '📁' : fileIcon(ent.name)));
|
|
879
|
+
li.appendChild(el('span', 'a-name', ent.name + (ent.dir ? '/' : '')));
|
|
880
|
+
// ディレクトリは配下件数、ファイルはサイズを右側に添える。
|
|
881
|
+
let sub = '';
|
|
882
|
+
if (ent.dir) {
|
|
883
|
+
if (ent.child_count != null) sub = ent.child_count + ' 項目';
|
|
884
|
+
} else if (ent.size != null) {
|
|
885
|
+
sub = formatBytes(ent.size);
|
|
886
|
+
}
|
|
887
|
+
if (sub) li.appendChild(el('span', 'a-sub', sub));
|
|
888
|
+
ul.appendChild(li);
|
|
889
|
+
}
|
|
890
|
+
view.appendChild(ul);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function hideArchiveView() {
|
|
894
|
+
const app = document.getElementById('app');
|
|
895
|
+
if (!app.classList.contains('show-archive')) return;
|
|
896
|
+
app.classList.remove('show-archive');
|
|
897
|
+
document.getElementById('archive-view').innerHTML = '';
|
|
898
|
+
}
|
|
899
|
+
|
|
680
900
|
async function openFile(path, li) {
|
|
681
901
|
if (!editor) return;
|
|
682
902
|
// 既に開いているファイルなら、そのタブへ切り替えるだけ (再フェッチしない)。
|
|
@@ -686,6 +906,24 @@ async function openFile(path, li) {
|
|
|
686
906
|
const data = await r.json();
|
|
687
907
|
if (!r.ok) { alert(data.error || 'open failed'); return; }
|
|
688
908
|
|
|
909
|
+
// 画像 / PDF / 動画 / 音声はメディアタブとして開く (Monaco モデルは持たない)。
|
|
910
|
+
if (data.media) {
|
|
911
|
+
const tab = { path, model: null, viewState: null, dirty: false,
|
|
912
|
+
binary: false, media: data.media, archive: false };
|
|
913
|
+
openTabs.push(tab);
|
|
914
|
+
activateEditorTab(path, li);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// zip / tar 等のアーカイブは第一階層一覧タブとして開く (Monaco モデルは持たない)。
|
|
919
|
+
if (data.archive) {
|
|
920
|
+
const tab = { path, model: null, viewState: null, dirty: false,
|
|
921
|
+
binary: false, media: null, archive: true };
|
|
922
|
+
openTabs.push(tab);
|
|
923
|
+
activateEditorTab(path, li);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
689
927
|
// 新しいモデルを作ってタブに紐づける (以後このモデルが内容・undo 履歴を保持する)。
|
|
690
928
|
const binary = !!data.binary;
|
|
691
929
|
const content = binary
|
|
@@ -693,11 +931,45 @@ async function openFile(path, li) {
|
|
|
693
931
|
: (data.content || '');
|
|
694
932
|
const lang = binary ? 'plaintext' : langOf(path);
|
|
695
933
|
const model = monaco.editor.createModel(content, lang);
|
|
696
|
-
|
|
934
|
+
// 元ファイルの改行コードをモデルへ反映する (Monaco は既定で LF に正規化してしまうため、
|
|
935
|
+
// CRLF のファイルを開いて保存すると LF に化ける。開いた時点の実際の改行を尊重する)。
|
|
936
|
+
if (!binary) {
|
|
937
|
+
const crlf = /\r\n/.test(content);
|
|
938
|
+
model.setEOL(crlf ? monaco.editor.EndOfLineSequence.CRLF
|
|
939
|
+
: monaco.editor.EndOfLineSequence.LF);
|
|
940
|
+
}
|
|
941
|
+
const tab = { path, model, viewState: null, dirty: false, binary, media: null, archive: false };
|
|
697
942
|
openTabs.push(tab);
|
|
698
943
|
activateEditorTab(path, li);
|
|
699
944
|
}
|
|
700
945
|
|
|
946
|
+
// ステータスバーの改行コード表示を更新する。テキストを編集できるタブ (通常タブ) の
|
|
947
|
+
// ときだけ CRLF/LF を表示し、それ以外 (未選択・メディア・アーカイブ・バイナリ) では隠す。
|
|
948
|
+
function updateEolStatus() {
|
|
949
|
+
const btn = document.getElementById('statusbar-eol');
|
|
950
|
+
const nameEl = document.getElementById('statusbar-eol-name');
|
|
951
|
+
if (!btn || !nameEl) return;
|
|
952
|
+
const tab = currentPath ? findTab(currentPath) : null;
|
|
953
|
+
const editable = tab && tab.model && !tab.media && !tab.archive && !tab.binary;
|
|
954
|
+
if (!editable) { btn.hidden = true; return; }
|
|
955
|
+
btn.hidden = false;
|
|
956
|
+
nameEl.textContent = tab.model.getEOL() === '\r\n' ? 'CRLF' : 'LF';
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ステータスバーの改行コードをクリックしたときに CRLF ⇄ LF を切り替える。
|
|
960
|
+
// 切り替えは内容の編集にあたるので dirty 扱いにし、自動保存の有無はエディタ設定に従う
|
|
961
|
+
// (setDirty → scheduleAutoSave。自動保存が OFF なら手動 Ctrl+S まで保存されない)。
|
|
962
|
+
function toggleEol() {
|
|
963
|
+
const tab = currentPath ? findTab(currentPath) : null;
|
|
964
|
+
if (!tab || !tab.model || tab.media || tab.archive || tab.binary) return;
|
|
965
|
+
const toCrlf = tab.model.getEOL() !== '\r\n';
|
|
966
|
+
tab.model.setEOL(toCrlf ? monaco.editor.EndOfLineSequence.CRLF
|
|
967
|
+
: monaco.editor.EndOfLineSequence.LF);
|
|
968
|
+
updateEolStatus();
|
|
969
|
+
setDirty(true);
|
|
970
|
+
scheduleAutoSave();
|
|
971
|
+
}
|
|
972
|
+
|
|
701
973
|
// 指定パスのエディタタブをアクティブにする。Monaco のモデルを差し替え、ビュー状態を復元する。
|
|
702
974
|
// (ターミナルタブ用の activateTab とは別関数なので名前を分けている)
|
|
703
975
|
function activateEditorTab(path, li) {
|
|
@@ -717,15 +989,34 @@ function activateEditorTab(path, li) {
|
|
|
717
989
|
setPathLabel(path);
|
|
718
990
|
|
|
719
991
|
document.getElementById('app').classList.remove('no-tabs');
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
992
|
+
if (tab.media) {
|
|
993
|
+
// メディアタブ: Monaco はモデルを外して隠し、メディアビューアを表示する。
|
|
994
|
+
hideArchiveView();
|
|
995
|
+
suppressChangeEvent = true;
|
|
996
|
+
editor.setModel(null);
|
|
997
|
+
suppressChangeEvent = false;
|
|
998
|
+
showMediaView(tab);
|
|
999
|
+
} else if (tab.archive) {
|
|
1000
|
+
// アーカイブタブ: Monaco を隠し、第一階層一覧を表示する。
|
|
1001
|
+
hideMediaView();
|
|
1002
|
+
suppressChangeEvent = true;
|
|
1003
|
+
editor.setModel(null);
|
|
1004
|
+
suppressChangeEvent = false;
|
|
1005
|
+
showArchiveView(tab);
|
|
1006
|
+
} else {
|
|
1007
|
+
hideMediaView();
|
|
1008
|
+
hideArchiveView();
|
|
1009
|
+
suppressChangeEvent = true;
|
|
1010
|
+
editor.setModel(tab.model);
|
|
1011
|
+
suppressChangeEvent = false;
|
|
1012
|
+
if (tab.viewState) editor.restoreViewState(tab.viewState);
|
|
1013
|
+
editor.updateOptions({ readOnly: tab.binary });
|
|
1014
|
+
editor.focus();
|
|
1015
|
+
}
|
|
726
1016
|
|
|
727
1017
|
isDirty = tab.dirty;
|
|
728
1018
|
setSaveStatus('');
|
|
1019
|
+
updateEolStatus();
|
|
729
1020
|
renderEditorTabs();
|
|
730
1021
|
|
|
731
1022
|
// ファイル一覧側のハイライトを同期する (li が渡されなければパスから探す)。
|
|
@@ -793,6 +1084,9 @@ async function saveCurrent(opts) {
|
|
|
793
1084
|
// ファイル未選択で手動保存 (Ctrl+S / メニュー) されたときは、保存先を尋ねる
|
|
794
1085
|
// ダイアログを開く (自動保存はそもそも未選択時は動かないので auto では出さない)。
|
|
795
1086
|
if (!currentPath) { if (!auto) openSaveAsDialog(); return; }
|
|
1087
|
+
// メディア/アーカイブタブは編集対象ではないので保存しない。
|
|
1088
|
+
const cur = findTab(currentPath);
|
|
1089
|
+
if (cur && (cur.media || cur.archive)) return;
|
|
796
1090
|
// バイナリ表示中 (readOnly) は保存しない (プレースホルダで上書きしないため)。
|
|
797
1091
|
if (editor.getRawOptions && editor.getRawOptions().readOnly) return;
|
|
798
1092
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
@@ -1166,7 +1460,23 @@ function addPendingMsg() {
|
|
|
1166
1460
|
sp.textContent = SPINNER_FRAMES[0];
|
|
1167
1461
|
head.appendChild(sp);
|
|
1168
1462
|
head.appendChild(document.createTextNode(' AI 考え中...'));
|
|
1463
|
+
// 経過時間をリアルタイム表示 (例: 30s)。1 秒ごとに更新する。
|
|
1464
|
+
const elapsed = document.createElement('span');
|
|
1465
|
+
elapsed.className = 'pending-elapsed';
|
|
1466
|
+
head.appendChild(elapsed);
|
|
1169
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
|
+
};
|
|
1170
1480
|
// ツール実行の進捗行を並べるコンテナ (SSE の tool_start / tool_done で更新)。
|
|
1171
1481
|
const tools = document.createElement('div');
|
|
1172
1482
|
tools.className = 'pending-tools';
|
|
@@ -1178,6 +1488,7 @@ function addPendingMsg() {
|
|
|
1178
1488
|
// remove 時に回転対象からも外す (ツール行のスピナーも含めて)。
|
|
1179
1489
|
const origRemove = div.remove.bind(div);
|
|
1180
1490
|
div.remove = () => {
|
|
1491
|
+
clearInterval(elapsedTimer);
|
|
1181
1492
|
spinnerTargets.delete(sp);
|
|
1182
1493
|
tools.querySelectorAll('.pending-spinner').forEach(s => spinnerTargets.delete(s));
|
|
1183
1494
|
origRemove();
|
|
@@ -1245,6 +1556,24 @@ function pendingToolDone(pending, ev) {
|
|
|
1245
1556
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
1246
1557
|
}
|
|
1247
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
|
+
|
|
1248
1577
|
// 軽量 Markdown → HTML 変換 (外部ライブラリ非依存 / オフライン用)。
|
|
1249
1578
|
// XSS 防止のため、まず全体を HTML エスケープしてから Markdown 記法だけを HTML 化する。
|
|
1250
1579
|
function escapeHtml(s) {
|
|
@@ -1335,12 +1664,19 @@ function addUserMsg(text) {
|
|
|
1335
1664
|
}
|
|
1336
1665
|
|
|
1337
1666
|
// AI 発言: 枠なしの地の文 (ラベルなし)。回答は Markdown として描画する。
|
|
1338
|
-
|
|
1667
|
+
// elapsedSec: 実行時間 (秒)。渡されれば発言の下に控えめに「実行時間 Ns」を添える。
|
|
1668
|
+
function addAiMsg(text, elapsedSec) {
|
|
1339
1669
|
const div = document.createElement('div');
|
|
1340
1670
|
div.className = 'msg ai';
|
|
1341
1671
|
const b = document.createElement('div');
|
|
1342
1672
|
b.className = 'bubble markdown';
|
|
1343
1673
|
b.innerHTML = renderMarkdown(text);
|
|
1674
|
+
if (elapsedSec != null) {
|
|
1675
|
+
const meta = document.createElement('div');
|
|
1676
|
+
meta.className = 'msg-elapsed';
|
|
1677
|
+
meta.textContent = '実行時間 ' + elapsedSec + 's';
|
|
1678
|
+
b.appendChild(meta);
|
|
1679
|
+
}
|
|
1344
1680
|
div.appendChild(b);
|
|
1345
1681
|
chatLog.appendChild(div);
|
|
1346
1682
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
@@ -1349,7 +1685,7 @@ function addAiMsg(text) {
|
|
|
1349
1685
|
|
|
1350
1686
|
// 実行ステップ 1 件を「番号・ツールラベル・対象・成否・出力」の構造で描画する。
|
|
1351
1687
|
// 生 JSON をそのまま出さず、人間が追える形にするのが目的。
|
|
1352
|
-
function renderStep(s, no) {
|
|
1688
|
+
function renderStep(s, no, traceId) {
|
|
1353
1689
|
const st = document.createElement('div');
|
|
1354
1690
|
st.className = 'step' + (s.status === 'PASS' ? '' : ' failed');
|
|
1355
1691
|
|
|
@@ -1383,6 +1719,9 @@ function renderStep(s, no) {
|
|
|
1383
1719
|
|
|
1384
1720
|
st.appendChild(head);
|
|
1385
1721
|
|
|
1722
|
+
// trace_id があれば、完了マークとツール名をこの実行の監査ログへのリンクにする。
|
|
1723
|
+
if (traceId) linkToolRow(st, traceId);
|
|
1724
|
+
|
|
1386
1725
|
// 出力は改行を保った等幅ブロックで表示。空なら控えめに「(出力なし)」。
|
|
1387
1726
|
const out = document.createElement('code');
|
|
1388
1727
|
out.className = 'out';
|
|
@@ -1397,9 +1736,10 @@ function renderStep(s, no) {
|
|
|
1397
1736
|
}
|
|
1398
1737
|
|
|
1399
1738
|
// AI の実行結果 (ステータスバッジ + 返信 + 各ツールの出力) を描画する。
|
|
1400
|
-
|
|
1739
|
+
// elapsedSec: 実行時間 (秒)。渡されればバッジに「実行時間 Ns」を添える。
|
|
1740
|
+
function renderAiResult(data, elapsedSec) {
|
|
1401
1741
|
// 会話応答 (ツール実行なし) は自然文だけを地の文として表示する。
|
|
1402
|
-
if (data.reply) return addAiMsg(data.reply);
|
|
1742
|
+
if (data.reply) return addAiMsg(data.reply, elapsedSec);
|
|
1403
1743
|
|
|
1404
1744
|
const status = data.status || 'FAIL';
|
|
1405
1745
|
const div = document.createElement('div');
|
|
@@ -1411,7 +1751,9 @@ function renderAiResult(data) {
|
|
|
1411
1751
|
badge.className = 'badge';
|
|
1412
1752
|
const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
|
|
1413
1753
|
// attempts は「LLM への試行回数」。1 回なら冗長なので添えない。
|
|
1414
|
-
badge.textContent = statusText +
|
|
1754
|
+
badge.textContent = statusText +
|
|
1755
|
+
(data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
|
|
1756
|
+
(elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
|
|
1415
1757
|
b.appendChild(badge);
|
|
1416
1758
|
|
|
1417
1759
|
// AI による実行結果の要約 (Markdown)。生のツール出力より上に目立たせて表示。
|
|
@@ -1440,7 +1782,7 @@ function renderAiResult(data) {
|
|
|
1440
1782
|
if (steps.length) {
|
|
1441
1783
|
const wrap = document.createElement('div');
|
|
1442
1784
|
wrap.className = 'steps';
|
|
1443
|
-
steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1)));
|
|
1785
|
+
steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1, data.trace_id)));
|
|
1444
1786
|
// 要約があるときは実行の詳細を折りたたむ (まとめを主役にする)。
|
|
1445
1787
|
if (data.summary) {
|
|
1446
1788
|
const det = document.createElement('details');
|
|
@@ -1476,7 +1818,8 @@ function clearChat() {
|
|
|
1476
1818
|
}
|
|
1477
1819
|
|
|
1478
1820
|
// 現在のチャット表示テキストを AI に要約させ、ログを短い要約 1 件に畳み込む。
|
|
1479
|
-
//
|
|
1821
|
+
// 履歴は表示ログから毎回組み立てて送るため、これでログを畳めば次回送信の
|
|
1822
|
+
// history も要約後の短い文脈に置き換わる (肥大化を防ぐ)。
|
|
1480
1823
|
async function compactChat() {
|
|
1481
1824
|
const transcript = collectTranscript();
|
|
1482
1825
|
if (!transcript.trim()) { clearChat(); return; }
|
|
@@ -1518,6 +1861,20 @@ function collectTranscript() {
|
|
|
1518
1861
|
return lines.join('\n');
|
|
1519
1862
|
}
|
|
1520
1863
|
|
|
1864
|
+
// これまでのチャットログを /edit/chat に渡す履歴配列に書き出す。
|
|
1865
|
+
// サーバーは会話履歴を保持しないため、送信のたびに表示済みログから組み立てて送る。
|
|
1866
|
+
function collectChatHistory() {
|
|
1867
|
+
const history = [];
|
|
1868
|
+
chatLog.querySelectorAll('.msg').forEach(m => {
|
|
1869
|
+
// pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
|
|
1870
|
+
if (m.querySelector('.pending-head')) return;
|
|
1871
|
+
const role = m.classList.contains('user') ? 'user' : 'assistant';
|
|
1872
|
+
const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1873
|
+
if (text) history.push({ role, content: text });
|
|
1874
|
+
});
|
|
1875
|
+
return history;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1521
1878
|
// /compact の結果 (これまでの会話のまとめ) を専用の見出し付きで表示する。
|
|
1522
1879
|
function addCompactSummary(text) {
|
|
1523
1880
|
const div = document.createElement('div');
|
|
@@ -1539,7 +1896,9 @@ function addCompactSummary(text) {
|
|
|
1539
1896
|
|
|
1540
1897
|
// 最終結果に伴うエディタ反映とファイル一覧更新 (描画とは分離した副作用処理)。
|
|
1541
1898
|
function applyResultSideEffects(data) {
|
|
1542
|
-
|
|
1899
|
+
const curTab = currentPath ? findTab(currentPath) : null;
|
|
1900
|
+
// メディア/アーカイブタブ表示中は Monaco にモデルが無いので content を流し込まない。
|
|
1901
|
+
if (data.content != null && editor && currentPath && !(curTab && (curTab.media || curTab.archive))) {
|
|
1543
1902
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
1544
1903
|
setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
|
|
1545
1904
|
setDirty(false);
|
|
@@ -1559,11 +1918,19 @@ function finalizePending(pending, data) {
|
|
|
1559
1918
|
const badge = document.createElement('span');
|
|
1560
1919
|
badge.className = 'badge';
|
|
1561
1920
|
const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
|
|
1562
|
-
|
|
1921
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1922
|
+
badge.textContent = statusText +
|
|
1923
|
+
(data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
|
|
1924
|
+
(elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
|
|
1563
1925
|
if (head) head.replaceWith(badge); else b.insertBefore(badge, b.firstChild);
|
|
1564
1926
|
|
|
1565
|
-
//
|
|
1927
|
+
// trace_id が判明したので、各ツール行 (完了マーク + ツール名) を監査ログへのリンクにする。
|
|
1566
1928
|
const tools = pending._toolsEl;
|
|
1929
|
+
if (data.trace_id && tools) {
|
|
1930
|
+
tools.querySelectorAll('.ptool').forEach(row => linkToolRow(row, data.trace_id));
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// 要約 (Markdown) はツール一覧の上に目立たせて置く。
|
|
1567
1934
|
if (data.summary) {
|
|
1568
1935
|
const sum = document.createElement('div');
|
|
1569
1936
|
sum.className = 'summary markdown';
|
|
@@ -1599,8 +1966,10 @@ function handleChatEvent(pending, evName, dataStr) {
|
|
|
1599
1966
|
// 会話応答 (reply) はツール実行が無いので、pending を消して地の文で表示。
|
|
1600
1967
|
// ツール実行があった場合は、進捗表示を消さずにその場で仕上げる。
|
|
1601
1968
|
if (data.reply || !pending._hadTool) {
|
|
1969
|
+
// pending 破棄前に実行時間を確定して結果表示へ渡す。
|
|
1970
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1602
1971
|
pending.remove();
|
|
1603
|
-
renderAiResult(data);
|
|
1972
|
+
renderAiResult(data, elapsedSec);
|
|
1604
1973
|
} else {
|
|
1605
1974
|
finalizePending(pending, data);
|
|
1606
1975
|
}
|
|
@@ -1610,8 +1979,9 @@ function handleChatEvent(pending, evName, dataStr) {
|
|
|
1610
1979
|
if (pending._hadTool) {
|
|
1611
1980
|
finalizePending(pending, { status: 'FAIL', error: data.error || 'エラー' });
|
|
1612
1981
|
} else {
|
|
1982
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1613
1983
|
pending.remove();
|
|
1614
|
-
renderAiResult({ status: 'FAIL', error: data.error || 'エラー' });
|
|
1984
|
+
renderAiResult({ status: 'FAIL', error: data.error || 'エラー' }, elapsedSec);
|
|
1615
1985
|
}
|
|
1616
1986
|
}
|
|
1617
1987
|
}
|
|
@@ -1621,6 +1991,8 @@ async function sendChat() {
|
|
|
1621
1991
|
if (!instruction) return;
|
|
1622
1992
|
// /clear /compact はローカルコマンド (AI へは送らない)。
|
|
1623
1993
|
if (handleSlashCommand(instruction)) return;
|
|
1994
|
+
// 今回の発言を DOM に足す前に、これまでのログを履歴として集めておく。
|
|
1995
|
+
const history = collectChatHistory();
|
|
1624
1996
|
chatInput.value = '';
|
|
1625
1997
|
addUserMsg(instruction);
|
|
1626
1998
|
chatForm.classList.add('busy');
|
|
@@ -1630,13 +2002,14 @@ async function sendChat() {
|
|
|
1630
2002
|
try {
|
|
1631
2003
|
const r = await fetch('/edit/chat', {
|
|
1632
2004
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1633
|
-
body: JSON.stringify({ path: currentPath || '', instruction, stream: true })
|
|
2005
|
+
body: JSON.stringify({ path: currentPath || '', instruction, history, stream: true })
|
|
1634
2006
|
});
|
|
1635
2007
|
if (!r.ok || !r.body) {
|
|
1636
2008
|
// SSE を開始できなかった場合は本文を JSON として読んでフォールバック。
|
|
1637
2009
|
const data = await r.json().catch(() => ({ status: 'FAIL', error: r.status + ' error' }));
|
|
2010
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1638
2011
|
pending.remove();
|
|
1639
|
-
renderAiResult(data);
|
|
2012
|
+
renderAiResult(data, elapsedSec);
|
|
1640
2013
|
applyResultSideEffects(data);
|
|
1641
2014
|
return;
|
|
1642
2015
|
}
|
|
@@ -1661,8 +2034,9 @@ async function sendChat() {
|
|
|
1661
2034
|
}
|
|
1662
2035
|
}
|
|
1663
2036
|
} catch (err) {
|
|
2037
|
+
const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
|
|
1664
2038
|
pending.remove();
|
|
1665
|
-
renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
|
|
2039
|
+
renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message }, elapsedSec);
|
|
1666
2040
|
} finally {
|
|
1667
2041
|
stopSpinner();
|
|
1668
2042
|
chatForm.classList.remove('busy');
|
|
@@ -2172,6 +2546,7 @@ async function gitCheckout(branch, create) {
|
|
|
2172
2546
|
}
|
|
2173
2547
|
|
|
2174
2548
|
document.getElementById('statusbar-branch').onclick = () => openBranchDialog();
|
|
2549
|
+
document.getElementById('statusbar-eol').onclick = () => toggleEol();
|
|
2175
2550
|
document.getElementById('branch-cancel').onclick = closeBranchDialog;
|
|
2176
2551
|
document.getElementById('branch-close').onclick = closeBranchDialog;
|
|
2177
2552
|
document.getElementById('branch-create').onclick = () => {
|
|
@@ -2221,6 +2596,119 @@ remoteUrlInput.addEventListener('keydown', (e) => {
|
|
|
2221
2596
|
});
|
|
2222
2597
|
initDraggableWindow(remoteDialog);
|
|
2223
2598
|
|
|
2599
|
+
// ---- モデル選択 (ステータスバー) ----
|
|
2600
|
+
const modelDialog = document.getElementById('model-dialog');
|
|
2601
|
+
const modelNameEl = document.getElementById('statusbar-model-name');
|
|
2602
|
+
const modelOllamaSelect = document.getElementById('model-ollama-select');
|
|
2603
|
+
const modelOllamaEmpty = document.getElementById('model-ollama-empty');
|
|
2604
|
+
const modelOpenrouterInput = document.getElementById('model-openrouter-input');
|
|
2605
|
+
const modelOpenrouterReady = document.getElementById('model-openrouter-ready');
|
|
2606
|
+
const modelOpenrouterNokey = document.getElementById('model-openrouter-nokey');
|
|
2607
|
+
const modelError = document.getElementById('model-error');
|
|
2608
|
+
// ダイアログ内で選択中の provider ("ollama" | "openrouter")。
|
|
2609
|
+
let modelSelectedProvider = 'ollama';
|
|
2610
|
+
// /edit/models の直近の応答 (現在値と選択肢) を保持する。
|
|
2611
|
+
let modelInfo = null;
|
|
2612
|
+
|
|
2613
|
+
// ステータスバーのモデル名を更新する (起動時と切り替え成功時に呼ぶ)。
|
|
2614
|
+
function updateModelStatus(provider, model) {
|
|
2615
|
+
if (!modelNameEl) return;
|
|
2616
|
+
modelNameEl.textContent = model || 'モデル未設定';
|
|
2617
|
+
const btn = document.getElementById('statusbar-model');
|
|
2618
|
+
if (btn) btn.title = `AI モデルを選択 (現在: ${provider} / ${model || '未設定'})`;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// 起動時に現在のモデルを取得してステータスバーに表示する。
|
|
2622
|
+
async function initModelStatus() {
|
|
2623
|
+
try {
|
|
2624
|
+
modelInfo = await gitFetch('/edit/models');
|
|
2625
|
+
updateModelStatus(modelInfo.provider, modelInfo.model);
|
|
2626
|
+
} catch {
|
|
2627
|
+
// 取得に失敗しても致命的ではない。"—" 表示のままにする。
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// provider タブを切り替えて対応ペインを表示する。
|
|
2632
|
+
function selectModelProvider(provider) {
|
|
2633
|
+
modelSelectedProvider = provider;
|
|
2634
|
+
modelError.textContent = '';
|
|
2635
|
+
document.getElementById('model-tab-ollama').classList.toggle('active', provider === 'ollama');
|
|
2636
|
+
document.getElementById('model-tab-openrouter').classList.toggle('active', provider === 'openrouter');
|
|
2637
|
+
document.getElementById('model-pane-ollama').hidden = provider !== 'ollama';
|
|
2638
|
+
document.getElementById('model-pane-openrouter').hidden = provider !== 'openrouter';
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
async function openModelDialog() {
|
|
2642
|
+
modelError.textContent = '';
|
|
2643
|
+
// 開くたびに最新の情報 (インストール済みモデル・OpenRouter 可否) を取り直す。
|
|
2644
|
+
try {
|
|
2645
|
+
modelInfo = await gitFetch('/edit/models');
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
modelInfo = modelInfo || { provider: 'ollama', model: '', ollama_models: [], openrouter_available: false };
|
|
2648
|
+
}
|
|
2649
|
+
updateModelStatus(modelInfo.provider, modelInfo.model);
|
|
2650
|
+
|
|
2651
|
+
// Ollama: インストール済みモデルを select に流し込み、現在値を選択する。
|
|
2652
|
+
modelOllamaSelect.innerHTML = '';
|
|
2653
|
+
const models = modelInfo.ollama_models || [];
|
|
2654
|
+
models.forEach(name => {
|
|
2655
|
+
const opt = document.createElement('option');
|
|
2656
|
+
opt.value = name; opt.textContent = name;
|
|
2657
|
+
modelOllamaSelect.appendChild(opt);
|
|
2658
|
+
});
|
|
2659
|
+
modelOllamaEmpty.hidden = models.length > 0;
|
|
2660
|
+
modelOllamaSelect.hidden = models.length === 0;
|
|
2661
|
+
if (modelInfo.provider === 'ollama' && modelInfo.model && models.includes(modelInfo.model)) {
|
|
2662
|
+
modelOllamaSelect.value = modelInfo.model;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// OpenRouter: キーがあれば入力欄、無ければ案内を表示する。
|
|
2666
|
+
const orAvailable = !!modelInfo.openrouter_available;
|
|
2667
|
+
modelOpenrouterReady.hidden = !orAvailable;
|
|
2668
|
+
modelOpenrouterNokey.hidden = orAvailable;
|
|
2669
|
+
modelOpenrouterInput.value = modelInfo.provider === 'openrouter' ? (modelInfo.model || '') : '';
|
|
2670
|
+
|
|
2671
|
+
selectModelProvider(modelInfo.provider || 'ollama');
|
|
2672
|
+
centerWindow(modelDialog);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
function closeModelDialog() { modelDialog.hidden = true; }
|
|
2676
|
+
|
|
2677
|
+
async function applyModel() {
|
|
2678
|
+
modelError.textContent = '';
|
|
2679
|
+
let model = '';
|
|
2680
|
+
if (modelSelectedProvider === 'ollama') {
|
|
2681
|
+
model = modelOllamaSelect.value;
|
|
2682
|
+
if (!model) { modelError.textContent = 'モデルを選択してください'; return; }
|
|
2683
|
+
} else {
|
|
2684
|
+
model = modelOpenrouterInput.value.trim();
|
|
2685
|
+
if (!model) { modelError.textContent = 'モデル ID を入力してください'; return; }
|
|
2686
|
+
}
|
|
2687
|
+
const okBtn = document.getElementById('model-ok');
|
|
2688
|
+
okBtn.disabled = true;
|
|
2689
|
+
try {
|
|
2690
|
+
const data = await gitPost('/edit/model', { provider: modelSelectedProvider, model });
|
|
2691
|
+
updateModelStatus(data.provider, data.model);
|
|
2692
|
+
closeModelDialog();
|
|
2693
|
+
} catch (err) {
|
|
2694
|
+
modelError.textContent = err.message;
|
|
2695
|
+
} finally {
|
|
2696
|
+
okBtn.disabled = false;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
document.getElementById('statusbar-model').onclick = () => openModelDialog();
|
|
2701
|
+
document.getElementById('model-tab-ollama').onclick = () => selectModelProvider('ollama');
|
|
2702
|
+
document.getElementById('model-tab-openrouter').onclick = () => selectModelProvider('openrouter');
|
|
2703
|
+
document.getElementById('model-ok').onclick = () => applyModel();
|
|
2704
|
+
document.getElementById('model-cancel').onclick = closeModelDialog;
|
|
2705
|
+
document.getElementById('model-close').onclick = closeModelDialog;
|
|
2706
|
+
modelOpenrouterInput.addEventListener('keydown', (e) => {
|
|
2707
|
+
if (e.key === 'Enter') { e.preventDefault(); applyModel(); }
|
|
2708
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeModelDialog(); }
|
|
2709
|
+
});
|
|
2710
|
+
initDraggableWindow(modelDialog);
|
|
2711
|
+
|
|
2224
2712
|
// ---- 起動 ----
|
|
2225
2713
|
refreshTree();
|
|
2226
2714
|
initConsole();
|
|
@@ -2230,6 +2718,7 @@ initPanelView();
|
|
|
2230
2718
|
initFilesTab();
|
|
2231
2719
|
setAutoSave(autoSaveEnabled); // メニューのチェック表示を保存済み設定に合わせる
|
|
2232
2720
|
refreshGitPanel();
|
|
2721
|
+
initModelStatus();
|
|
2233
2722
|
|
|
2234
2723
|
// 自動保存 (debounce) が確定する前にタブを閉じようとした場合に警告する。
|
|
2235
2724
|
window.addEventListener('beforeunload', (e) => {
|