dsh-ide-git 0.20.0 → 0.21.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.
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +355 -6
- package/src/index.js +93 -3
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-ide-git",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ide-git",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/client.js
CHANGED
|
@@ -302,6 +302,12 @@ window.__ModuleLoader__.load({
|
|
|
302
302
|
'composer.changesHint': '未提交变更数',
|
|
303
303
|
'composer.fileHint': '新增 {a} · 删除 {d} 个文件',
|
|
304
304
|
'composer.linesHint': '+{ins} −{del} 行',
|
|
305
|
+
'modelRoute.pick': '选择模型',
|
|
306
|
+
'modelRoute.trigger': 'AI 提交信息模型',
|
|
307
|
+
'modelRoute.modelRow': '模型',
|
|
308
|
+
'modelRoute.effortRow': '思考',
|
|
309
|
+
'modelRoute.effortDefault': '跟随默认',
|
|
310
|
+
'modelRoute.empty': '暂无可用模型',
|
|
305
311
|
'composer.behindHint': '远程新提交(可拉取)',
|
|
306
312
|
'composer.aheadHint': '本地待推送提交',
|
|
307
313
|
'composer.inWorktree': '已被其他工作树签出',
|
|
@@ -638,6 +644,12 @@ window.__ModuleLoader__.load({
|
|
|
638
644
|
'composer.changesHint': 'Uncommitted changes',
|
|
639
645
|
'composer.fileHint': '{a} added · {d} deleted files',
|
|
640
646
|
'composer.linesHint': '+{ins} −{del} lines',
|
|
647
|
+
'modelRoute.pick': 'Pick a model',
|
|
648
|
+
'modelRoute.trigger': 'AI commit message model',
|
|
649
|
+
'modelRoute.modelRow': 'Model',
|
|
650
|
+
'modelRoute.effortRow': 'Reasoning',
|
|
651
|
+
'modelRoute.effortDefault': 'Follow default',
|
|
652
|
+
'modelRoute.empty': 'No models available',
|
|
641
653
|
'composer.behindHint': 'New upstream commits (pullable)',
|
|
642
654
|
'composer.aheadHint': 'Local commits waiting to push',
|
|
643
655
|
'composer.inWorktree': 'Checked out in another worktree',
|
|
@@ -982,6 +994,12 @@ window.__ModuleLoader__.load({
|
|
|
982
994
|
'composer.changesHint': '未提交變更數',
|
|
983
995
|
'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
|
|
984
996
|
'composer.linesHint': '+{ins} −{del} 行',
|
|
997
|
+
'modelRoute.pick': '選擇模型',
|
|
998
|
+
'modelRoute.trigger': 'AI 提交訊息模型',
|
|
999
|
+
'modelRoute.modelRow': '模型',
|
|
1000
|
+
'modelRoute.effortRow': '思考',
|
|
1001
|
+
'modelRoute.effortDefault': '跟隨預設',
|
|
1002
|
+
'modelRoute.empty': '暫無可用模型',
|
|
985
1003
|
'composer.behindHint': '遠端新提交(可以拉取)',
|
|
986
1004
|
'composer.aheadHint': '本地等待推送提交',
|
|
987
1005
|
'composer.inWorktree': '已經喺其他工作樹簽出',
|
|
@@ -1318,6 +1336,12 @@ window.__ModuleLoader__.load({
|
|
|
1318
1336
|
'composer.changesHint': '未提交變更數',
|
|
1319
1337
|
'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
|
|
1320
1338
|
'composer.linesHint': '+{ins} −{del} 行',
|
|
1339
|
+
'modelRoute.pick': '選擇模型',
|
|
1340
|
+
'modelRoute.trigger': 'AI 提交訊息模型',
|
|
1341
|
+
'modelRoute.modelRow': '模型',
|
|
1342
|
+
'modelRoute.effortRow': '思考',
|
|
1343
|
+
'modelRoute.effortDefault': '跟隨預設',
|
|
1344
|
+
'modelRoute.empty': '暫無可用模型',
|
|
1321
1345
|
'composer.behindHint': '遠端新提交(可拉取)',
|
|
1322
1346
|
'composer.aheadHint': '本地待推送提交',
|
|
1323
1347
|
'composer.inWorktree': '已被其他工作樹簽出',
|
|
@@ -1654,6 +1678,12 @@ window.__ModuleLoader__.load({
|
|
|
1654
1678
|
'composer.changesHint': '未提交變更數',
|
|
1655
1679
|
'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
|
|
1656
1680
|
'composer.linesHint': '+{ins} −{del} 行',
|
|
1681
|
+
'modelRoute.pick': '選擇模型',
|
|
1682
|
+
'modelRoute.trigger': 'AI 提交訊息模型',
|
|
1683
|
+
'modelRoute.modelRow': '模型',
|
|
1684
|
+
'modelRoute.effortRow': '思考',
|
|
1685
|
+
'modelRoute.effortDefault': '跟隨預設',
|
|
1686
|
+
'modelRoute.empty': '暫無可用模型',
|
|
1657
1687
|
'composer.behindHint': '遠端新提交(可以拉取)',
|
|
1658
1688
|
'composer.aheadHint': '本地等待推送提交',
|
|
1659
1689
|
'composer.inWorktree': '已經喺其他工作樹簽出',
|
|
@@ -1990,6 +2020,12 @@ window.__ModuleLoader__.load({
|
|
|
1990
2020
|
'composer.changesHint': '未コミットの変更数',
|
|
1991
2021
|
'composer.fileHint': '追加 {a} · 削除 {d} ファイル',
|
|
1992
2022
|
'composer.linesHint': '+{ins} −{del} 行',
|
|
2023
|
+
'modelRoute.pick': 'モデルを選択',
|
|
2024
|
+
'modelRoute.trigger': 'AI コミットメッセージのモデル',
|
|
2025
|
+
'modelRoute.modelRow': 'モデル',
|
|
2026
|
+
'modelRoute.effortRow': '思考',
|
|
2027
|
+
'modelRoute.effortDefault': 'デフォルトに従う',
|
|
2028
|
+
'modelRoute.empty': '利用可能なモデルがありません',
|
|
1993
2029
|
'composer.behindHint': 'リモートの新規コミット(取得可能)',
|
|
1994
2030
|
'composer.aheadHint': 'プッシュ待ちのローカルコミット',
|
|
1995
2031
|
'composer.inWorktree': '別のワークツリーでチェックアウト中',
|
|
@@ -2326,6 +2362,12 @@ window.__ModuleLoader__.load({
|
|
|
2326
2362
|
'composer.changesHint': '커밋되지 않은 변경 수',
|
|
2327
2363
|
'composer.fileHint': '추가 {a} · 삭제 {d}개 파일',
|
|
2328
2364
|
'composer.linesHint': '+{ins} −{del}줄',
|
|
2365
|
+
'modelRoute.pick': '모델 선택',
|
|
2366
|
+
'modelRoute.trigger': 'AI 커밋 메시지 모델',
|
|
2367
|
+
'modelRoute.modelRow': '모델',
|
|
2368
|
+
'modelRoute.effortRow': '추론',
|
|
2369
|
+
'modelRoute.effortDefault': '기본값 따르기',
|
|
2370
|
+
'modelRoute.empty': '사용 가능한 모델이 없습니다',
|
|
2329
2371
|
'composer.behindHint': '원격 새 커밋(가져오기 가능)',
|
|
2330
2372
|
'composer.aheadHint': '푸시 대기 중인 로컬 커밋',
|
|
2331
2373
|
'composer.inWorktree': '다른 작업 트리에서 체크아웃됨',
|
|
@@ -2662,6 +2704,12 @@ window.__ModuleLoader__.load({
|
|
|
2662
2704
|
'composer.changesHint': 'Uncommittete Änderungen',
|
|
2663
2705
|
'composer.fileHint': '{a} hinzugefügt · {d} gelöscht',
|
|
2664
2706
|
'composer.linesHint': '+{ins} −{del} Zeilen',
|
|
2707
|
+
'modelRoute.pick': 'Modell wählen',
|
|
2708
|
+
'modelRoute.trigger': 'Modell für KI-Commits',
|
|
2709
|
+
'modelRoute.modelRow': 'Modell',
|
|
2710
|
+
'modelRoute.effortRow': 'Denken',
|
|
2711
|
+
'modelRoute.effortDefault': 'Standard folgen',
|
|
2712
|
+
'modelRoute.empty': 'Keine Modelle verfügbar',
|
|
2665
2713
|
'composer.behindHint': 'Neue Upstream-Commits (abrufbar)',
|
|
2666
2714
|
'composer.aheadHint': 'Lokale Commits zum Pushen',
|
|
2667
2715
|
'composer.inWorktree': 'In anderem Worktree ausgecheckt',
|
|
@@ -2998,6 +3046,12 @@ window.__ModuleLoader__.load({
|
|
|
2998
3046
|
'composer.changesHint': 'Modifications non commitées',
|
|
2999
3047
|
'composer.fileHint': '{a} ajoutés · {d} supprimés',
|
|
3000
3048
|
'composer.linesHint': '+{ins} −{del} lignes',
|
|
3049
|
+
'modelRoute.pick': 'Choisir un modèle',
|
|
3050
|
+
'modelRoute.trigger': 'Modèle des messages de commit IA',
|
|
3051
|
+
'modelRoute.modelRow': 'Modèle',
|
|
3052
|
+
'modelRoute.effortRow': 'Raisonnement',
|
|
3053
|
+
'modelRoute.effortDefault': 'Suivre la valeur par défaut',
|
|
3054
|
+
'modelRoute.empty': 'Aucun modèle disponible',
|
|
3001
3055
|
'composer.behindHint': 'Nouveaux commits amont (récupérables)',
|
|
3002
3056
|
'composer.aheadHint': 'Commits locaux en attente d’envoi',
|
|
3003
3057
|
'composer.inWorktree': 'Extrait dans un autre arbre de travail',
|
|
@@ -3334,6 +3388,12 @@ window.__ModuleLoader__.load({
|
|
|
3334
3388
|
'composer.changesHint': 'Незакоммиченные изменения',
|
|
3335
3389
|
'composer.fileHint': 'добавлено: {a} · удалено: {d}',
|
|
3336
3390
|
'composer.linesHint': '+{ins} −{del} строк',
|
|
3391
|
+
'modelRoute.pick': 'Выбрать модель',
|
|
3392
|
+
'modelRoute.trigger': 'Модель сообщений коммитов ИИ',
|
|
3393
|
+
'modelRoute.modelRow': 'Модель',
|
|
3394
|
+
'modelRoute.effortRow': 'Рассуждение',
|
|
3395
|
+
'modelRoute.effortDefault': 'По умолчанию',
|
|
3396
|
+
'modelRoute.empty': 'Нет доступных моделей',
|
|
3337
3397
|
'composer.behindHint': 'Новые коммиты upstream (можно забрать)',
|
|
3338
3398
|
'composer.aheadHint': 'Локальные коммиты, ожидающие отправки',
|
|
3339
3399
|
'composer.inWorktree': 'Выверен в другом worktree',
|
|
@@ -3670,6 +3730,12 @@ window.__ModuleLoader__.load({
|
|
|
3670
3730
|
'composer.changesHint': 'Alterações não commitadas',
|
|
3671
3731
|
'composer.fileHint': '{a} adicionados · {d} removidos',
|
|
3672
3732
|
'composer.linesHint': '+{ins} −{del} linhas',
|
|
3733
|
+
'modelRoute.pick': 'Escolher modelo',
|
|
3734
|
+
'modelRoute.trigger': 'Modelo da mensagem de commit por IA',
|
|
3735
|
+
'modelRoute.modelRow': 'Modelo',
|
|
3736
|
+
'modelRoute.effortRow': 'Raciocínio',
|
|
3737
|
+
'modelRoute.effortDefault': 'Seguir o padrão',
|
|
3738
|
+
'modelRoute.empty': 'Nenhum modelo disponível',
|
|
3673
3739
|
'composer.behindHint': 'Novos commits remotos (para pull)',
|
|
3674
3740
|
'composer.aheadHint': 'Commits locais aguardando push',
|
|
3675
3741
|
'composer.inWorktree': 'Em checkout em outra worktree',
|
|
@@ -4006,6 +4072,12 @@ window.__ModuleLoader__.load({
|
|
|
4006
4072
|
'composer.changesHint': 'Modifiche non ancora committate',
|
|
4007
4073
|
'composer.fileHint': '{a} aggiunti · {d} eliminati',
|
|
4008
4074
|
'composer.linesHint': '+{ins} −{del} righe',
|
|
4075
|
+
'modelRoute.pick': 'Scegli modello',
|
|
4076
|
+
'modelRoute.trigger': 'Modello dei messaggi di commit IA',
|
|
4077
|
+
'modelRoute.modelRow': 'Modello',
|
|
4078
|
+
'modelRoute.effortRow': 'Ragionamento',
|
|
4079
|
+
'modelRoute.effortDefault': 'Segui il predefinito',
|
|
4080
|
+
'modelRoute.empty': 'Nessun modello disponibile',
|
|
4009
4081
|
'composer.behindHint': 'Nuovi commit upstream (scaricabili)',
|
|
4010
4082
|
'composer.aheadHint': 'Commit locali in attesa di push',
|
|
4011
4083
|
'composer.inWorktree': 'Estratto in un altro worktree',
|
|
@@ -4342,6 +4414,12 @@ window.__ModuleLoader__.load({
|
|
|
4342
4414
|
'composer.changesHint': 'Ongecommitte wijzigingen',
|
|
4343
4415
|
'composer.fileHint': '{a} toegevoegd · {d} verwijderd',
|
|
4344
4416
|
'composer.linesHint': '+{ins} −{del} regels',
|
|
4417
|
+
'modelRoute.pick': 'Kies een model',
|
|
4418
|
+
'modelRoute.trigger': 'Model voor AI-commitberichten',
|
|
4419
|
+
'modelRoute.modelRow': 'Model',
|
|
4420
|
+
'modelRoute.effortRow': 'Redeneren',
|
|
4421
|
+
'modelRoute.effortDefault': 'Standaard volgen',
|
|
4422
|
+
'modelRoute.empty': 'Geen modellen beschikbaar',
|
|
4345
4423
|
'composer.behindHint': 'Nieuwe upstream-commits (op te halen)',
|
|
4346
4424
|
'composer.aheadHint': 'Lokale commits die wachten op push',
|
|
4347
4425
|
'composer.inWorktree': 'Uitgecheckt in een andere worktree',
|
|
@@ -4678,6 +4756,12 @@ window.__ModuleLoader__.load({
|
|
|
4678
4756
|
'composer.changesHint': 'Niezatwierdzone zmiany',
|
|
4679
4757
|
'composer.fileHint': 'dodane: {a} · usunięte: {d}',
|
|
4680
4758
|
'composer.linesHint': '+{ins} −{del} linii',
|
|
4759
|
+
'modelRoute.pick': 'Wybierz model',
|
|
4760
|
+
'modelRoute.trigger': 'Model wiadomości commitów AI',
|
|
4761
|
+
'modelRoute.modelRow': 'Model',
|
|
4762
|
+
'modelRoute.effortRow': 'Rozumowanie',
|
|
4763
|
+
'modelRoute.effortDefault': 'Domyślne',
|
|
4764
|
+
'modelRoute.empty': 'Brak dostępnych modeli',
|
|
4681
4765
|
'composer.behindHint': 'Nowe commity upstream (do pobrania)',
|
|
4682
4766
|
'composer.aheadHint': 'Lokalne commity oczekujące na wysłanie',
|
|
4683
4767
|
'composer.inWorktree': 'Wybrane w innym drzewie roboczym',
|
|
@@ -5014,6 +5098,12 @@ window.__ModuleLoader__.load({
|
|
|
5014
5098
|
'composer.changesHint': 'Ocommittade ändringar',
|
|
5015
5099
|
'composer.fileHint': '{a} tillagda · {d} borttagna',
|
|
5016
5100
|
'composer.linesHint': '+{ins} −{del} rader',
|
|
5101
|
+
'modelRoute.pick': 'Välj modell',
|
|
5102
|
+
'modelRoute.trigger': 'Modell för AI-commit-meddelanden',
|
|
5103
|
+
'modelRoute.modelRow': 'Modell',
|
|
5104
|
+
'modelRoute.effortRow': 'Resonemang',
|
|
5105
|
+
'modelRoute.effortDefault': 'Följ standard',
|
|
5106
|
+
'modelRoute.empty': 'Inga modeller tillgängliga',
|
|
5017
5107
|
'composer.behindHint': 'Nya upstream-commits (hämtbara)',
|
|
5018
5108
|
'composer.aheadHint': 'Lokala commits som väntar på push',
|
|
5019
5109
|
'composer.inWorktree': 'Utcheckad i ett annat arbetsträd',
|
|
@@ -5350,6 +5440,12 @@ window.__ModuleLoader__.load({
|
|
|
5350
5440
|
'composer.changesHint': 'Commitlenmemiş değişiklikler',
|
|
5351
5441
|
'composer.fileHint': '{a} eklendi · {d} silindi',
|
|
5352
5442
|
'composer.linesHint': '+{ins} −{del} satır',
|
|
5443
|
+
'modelRoute.pick': 'Model seç',
|
|
5444
|
+
'modelRoute.trigger': 'Yapay zekâ commit mesajı modeli',
|
|
5445
|
+
'modelRoute.modelRow': 'Model',
|
|
5446
|
+
'modelRoute.effortRow': 'Akıl yürütme',
|
|
5447
|
+
'modelRoute.effortDefault': 'Varsayılanı izle',
|
|
5448
|
+
'modelRoute.empty': 'Kullanılabilir model yok',
|
|
5353
5449
|
'composer.behindHint': 'Yeni upstream commitleri (çekilebilir)',
|
|
5354
5450
|
'composer.aheadHint': 'Push bekleyen yerel commitler',
|
|
5355
5451
|
'composer.inWorktree': 'Başka bir worktree’da checkout yapılmış',
|
|
@@ -5686,6 +5782,12 @@ window.__ModuleLoader__.load({
|
|
|
5686
5782
|
'composer.changesHint': 'Perubahan yang belum di-commit',
|
|
5687
5783
|
'composer.fileHint': '{a} ditambahkan · {d} dihapus',
|
|
5688
5784
|
'composer.linesHint': '+{ins} −{del} baris',
|
|
5785
|
+
'modelRoute.pick': 'Pilih model',
|
|
5786
|
+
'modelRoute.trigger': 'Model pesan commit AI',
|
|
5787
|
+
'modelRoute.modelRow': 'Model',
|
|
5788
|
+
'modelRoute.effortRow': 'Penalaran',
|
|
5789
|
+
'modelRoute.effortDefault': 'Ikuti bawaan',
|
|
5790
|
+
'modelRoute.empty': 'Tidak ada model yang tersedia',
|
|
5689
5791
|
'composer.behindHint': 'Commit upstream baru (bisa ditarik)',
|
|
5690
5792
|
'composer.aheadHint': 'Commit lokal menunggu push',
|
|
5691
5793
|
'composer.inWorktree': 'Sudah di-checkout di worktree lain',
|
|
@@ -6022,6 +6124,12 @@ window.__ModuleLoader__.load({
|
|
|
6022
6124
|
'composer.changesHint': 'Thay đổi chưa commit',
|
|
6023
6125
|
'composer.fileHint': 'thêm {a} · xóa {d} tệp',
|
|
6024
6126
|
'composer.linesHint': '+{ins} −{del} dòng',
|
|
6127
|
+
'modelRoute.pick': 'Chọn mô hình',
|
|
6128
|
+
'modelRoute.trigger': 'Mô hình tin nhắn commit AI',
|
|
6129
|
+
'modelRoute.modelRow': 'Mô hình',
|
|
6130
|
+
'modelRoute.effortRow': 'Suy luận',
|
|
6131
|
+
'modelRoute.effortDefault': 'Theo mặc định',
|
|
6132
|
+
'modelRoute.empty': 'Không có mô hình nào',
|
|
6025
6133
|
'composer.behindHint': 'Commit mới từ xa (có thể kéo về)',
|
|
6026
6134
|
'composer.aheadHint': 'Commit cục bộ chờ đẩy lên',
|
|
6027
6135
|
'composer.inWorktree': 'Đã được checkout ở worktree khác',
|
|
@@ -6358,6 +6466,12 @@ window.__ModuleLoader__.load({
|
|
|
6358
6466
|
'composer.changesHint': 'تغييرات غير ملتزَم بها',
|
|
6359
6467
|
'composer.fileHint': 'مضاف {a} · محذوف {d}',
|
|
6360
6468
|
'composer.linesHint': '+{ins} −{del} أسطر',
|
|
6469
|
+
'modelRoute.pick': 'اختر نموذجًا',
|
|
6470
|
+
'modelRoute.trigger': 'نموذج رسائل الالتزام بالذكاء الاصطناعي',
|
|
6471
|
+
'modelRoute.modelRow': 'النموذج',
|
|
6472
|
+
'modelRoute.effortRow': 'الاستدلال',
|
|
6473
|
+
'modelRoute.effortDefault': 'اتبع الافتراضي',
|
|
6474
|
+
'modelRoute.empty': 'لا توجد نماذج متاحة',
|
|
6361
6475
|
'composer.behindHint': 'التزامات جديدة في المستودع البعيد (قابلة للسحب)',
|
|
6362
6476
|
'composer.aheadHint': 'التزامات محلية بانتظار الدفع',
|
|
6363
6477
|
'composer.inWorktree': 'مسحوب في شجرة عمل أخرى',
|
|
@@ -6694,6 +6808,12 @@ window.__ModuleLoader__.load({
|
|
|
6694
6808
|
'composer.changesHint': 'बिना कमिट बदलाव',
|
|
6695
6809
|
'composer.fileHint': '{a} जोड़ी गईं · {d} हटाई गईं',
|
|
6696
6810
|
'composer.linesHint': '+{ins} −{del} पंक्तियाँ',
|
|
6811
|
+
'modelRoute.pick': 'मॉडल चुनें',
|
|
6812
|
+
'modelRoute.trigger': 'AI कमिट संदेश मॉडल',
|
|
6813
|
+
'modelRoute.modelRow': 'मॉडल',
|
|
6814
|
+
'modelRoute.effortRow': 'तर्क',
|
|
6815
|
+
'modelRoute.effortDefault': 'डिफ़ॉल्ट का पालन करें',
|
|
6816
|
+
'modelRoute.empty': 'कोई मॉडल उपलब्ध नहीं',
|
|
6697
6817
|
'composer.behindHint': 'अपस्ट्रीम के नए कमिट (खींचने योग्य)',
|
|
6698
6818
|
'composer.aheadHint': 'पुश के लिए प्रतीक्षारत स्थानीय कमिट',
|
|
6699
6819
|
'composer.inWorktree': 'दूसरे वर्कट्री में चेकआउट',
|
|
@@ -7030,6 +7150,12 @@ window.__ModuleLoader__.load({
|
|
|
7030
7150
|
'composer.changesHint': 'การเปลี่ยนแปลงที่ยังไม่คอมมิต',
|
|
7031
7151
|
'composer.fileHint': 'เพิ่ม {a} · ลบ {d} ไฟล์',
|
|
7032
7152
|
'composer.linesHint': '+{ins} −{del} บรรทัด',
|
|
7153
|
+
'modelRoute.pick': 'เลือกโมเดล',
|
|
7154
|
+
'modelRoute.trigger': 'โมเดลข้อความคอมมิต AI',
|
|
7155
|
+
'modelRoute.modelRow': 'โมเดล',
|
|
7156
|
+
'modelRoute.effortRow': 'การใช้เหตุผล',
|
|
7157
|
+
'modelRoute.effortDefault': 'ตามค่าเริ่มต้น',
|
|
7158
|
+
'modelRoute.empty': 'ไม่มีโมเดลที่ใช้ได้',
|
|
7033
7159
|
'composer.behindHint': 'คอมมิตใหม่จากรีโมต (ดึงได้)',
|
|
7034
7160
|
'composer.aheadHint': 'คอมมิตในเครื่องรอพุช',
|
|
7035
7161
|
'composer.inWorktree': 'ถูกเช็คเอาต์ในเวิร์กทรีอื่น',
|
|
@@ -7299,6 +7425,19 @@ window.__ModuleLoader__.load({
|
|
|
7299
7425
|
function isAuthRejection(status) {
|
|
7300
7426
|
return status === 401 || status === 403
|
|
7301
7427
|
}
|
|
7428
|
+
|
|
7429
|
+
/* Whether a proxy answer looks like OUR route (a JSON object). With no
|
|
7430
|
+
remote-web-ui installed the SPA static fallback serves /remote/<path> as
|
|
7431
|
+
'405 Method Not Allowed' with an EMPTY body (issue #20: on a plain-HTTP
|
|
7432
|
+
LAN page the panel surfaced a bare 'HTTP 405' instead of the real
|
|
7433
|
+
403 refusal). A non-JSON retry therefore proves there is no proxy, and
|
|
7434
|
+
the original answer must be what the caller sees. Cloning leaves the
|
|
7435
|
+
caller's own body untouched. */
|
|
7436
|
+
async function answersJson(response) {
|
|
7437
|
+
let body = null
|
|
7438
|
+
try { body = await response.clone().json() } catch (error) { return false }
|
|
7439
|
+
return body !== null && typeof body === 'object'
|
|
7440
|
+
}
|
|
7302
7441
|
/* ---- end remote channel fallback core ---- */
|
|
7303
7442
|
|
|
7304
7443
|
/* Sticky remote-channel mode: once the proxy has answered, later calls go
|
|
@@ -7320,13 +7459,15 @@ window.__ModuleLoader__.load({
|
|
|
7320
7459
|
} else {
|
|
7321
7460
|
response = await fetch(direct, init)
|
|
7322
7461
|
if (isAuthRejection(response.status) === true) {
|
|
7323
|
-
/* Off the origin's trust path: try the pairing proxy once.
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7462
|
+
/* Off the origin's trust path: try the pairing proxy once. It has to
|
|
7463
|
+
answer like our route (a JSON body) to take over: with no
|
|
7464
|
+
remote-web-ui installed the SPA fallback serves the retry as 405
|
|
7465
|
+
with an empty body, and that non-answer must not outrank the
|
|
7466
|
+
ORIGINAL rejection (issue #20). A proxy answer that is itself an
|
|
7467
|
+
auth rejection is discarded the same way, so a genuine refusal can
|
|
7468
|
+
never be masked. */
|
|
7328
7469
|
const retried = await fetch(remoteFallbackUrl(direct), init)
|
|
7329
|
-
if (isAuthRejection(retried.status) !== true) {
|
|
7470
|
+
if (isAuthRejection(retried.status) !== true && (await answersJson(retried)) === true) {
|
|
7330
7471
|
viaRemoteChannel = true
|
|
7331
7472
|
response = retried
|
|
7332
7473
|
}
|
|
@@ -11060,6 +11201,9 @@ window.__ModuleLoader__.load({
|
|
|
11060
11201
|
function ChangesPanel(props) {
|
|
11061
11202
|
const t = props.t
|
|
11062
11203
|
const summary = props.summary
|
|
11204
|
+
/* The panel-side model picker (issue #19) rides the official model
|
|
11205
|
+
directories, soft-injected into a module ref at apply time — see
|
|
11206
|
+
ModelRoutePicker for why a bare ctx property read cannot work. */
|
|
11063
11207
|
const [message, setMessage] = useState('')
|
|
11064
11208
|
const [amend, setAmend] = useState(false)
|
|
11065
11209
|
const [collapsed, setCollapsed] = useState(() => {
|
|
@@ -11358,6 +11502,7 @@ window.__ModuleLoader__.load({
|
|
|
11358
11502
|
title: t('changes.aiQuota'),
|
|
11359
11503
|
onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
|
|
11360
11504
|
}, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
|
|
11505
|
+
E(ModelRoutePicker, { sessionId: props.sessionId, t: t }),
|
|
11361
11506
|
E('button', {
|
|
11362
11507
|
type: 'button', className: 'dig-btn dig-btn-primary dig-btn-small',
|
|
11363
11508
|
disabled: message.trim() === '' || props.busy === true,
|
|
@@ -11379,6 +11524,7 @@ window.__ModuleLoader__.load({
|
|
|
11379
11524
|
disabled: aiBusy === true || props.busy === true,
|
|
11380
11525
|
onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
|
|
11381
11526
|
}, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
|
|
11527
|
+
E(ModelRoutePicker, { sessionId: props.sessionId, t: t }),
|
|
11382
11528
|
E('span', { className: 'dig-commit-ai-hint' }, t('changes.aiQuota')),
|
|
11383
11529
|
aiUndo === null ? null : E('button', {
|
|
11384
11530
|
type: 'button', className: 'dig-link',
|
|
@@ -15200,6 +15346,22 @@ window.__ModuleLoader__.load({
|
|
|
15200
15346
|
'.dig-cs-item-note{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}',
|
|
15201
15347
|
'.dig-cs-item-counts{display:inline-flex;gap:5px;margin-left:auto;flex:none;font-variant-numeric:tabular-nums;font-size:11px}',
|
|
15202
15348
|
'.dig-cs-static{color:var(--dsw-alias-label-tertiary);cursor:default}',
|
|
15349
|
+
/* ---- ModelRoutePicker (issue #19) ---- */
|
|
15350
|
+
'.dig-mrp-root{position:relative;display:inline-flex;flex:none;min-width:0}',
|
|
15351
|
+
'.dig-mrp-trigger{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:220px;font-size:11px;line-height:16px}',
|
|
15352
|
+
'.dig-mrp-trigger[data-open=true]{color:var(--dsw-alias-brand-primary)}',
|
|
15353
|
+
'.dig-mrp-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
15354
|
+
'.dig-mrp-effort{flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:10px}',
|
|
15355
|
+
'.dig-mrp-menu{max-width:300px}',
|
|
15356
|
+
'.dig-mrp-row .dig-cs-item-name{display:none}',
|
|
15357
|
+
'.dig-mrp-row-label{flex:none}',
|
|
15358
|
+
'.dig-mrp-row-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:auto;color:var(--dsw-alias-label-tertiary)}',
|
|
15359
|
+
'.dig-mrp-row-arrow{flex:none;display:inline-flex;color:var(--dsw-alias-label-tertiary)}',
|
|
15360
|
+
'.dig-mrp-back{color:var(--dsw-alias-label-tertiary)}',
|
|
15361
|
+
'.dig-mrp-group{padding:6px 10px 2px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}',
|
|
15362
|
+
'.dig-mrp-empty{padding:10px;color:var(--dsw-alias-label-tertiary);font-size:12px}',
|
|
15363
|
+
'.dig-mrp-error{padding:6px 10px;margin-bottom:2px;border-radius:var(--dsw-radius-sm,8px);background:rgba(220,38,38,.12);color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;word-break:break-word}',
|
|
15364
|
+
'body[data-ds-dark-theme] .dig-mrp-error{background:rgba(248,113,113,.16)}',
|
|
15203
15365
|
].concat(LANE_TRACE_RULES).join('\n')
|
|
15204
15366
|
|
|
15205
15367
|
/* ============================== plugin ============================== */
|
|
@@ -15429,6 +15591,179 @@ window.__ModuleLoader__.load({
|
|
|
15429
15591
|
E('span', { className: 'dig-cs-chunk', 'aria-hidden': 'true' }, text)))
|
|
15430
15592
|
}
|
|
15431
15593
|
|
|
15594
|
+
/* ---- ModelRoutePicker (issue #19) ------------------------------------
|
|
15595
|
+
The panel-side model + reasoning picker, bound to the SAME session model
|
|
15596
|
+
route the composer's own selector writes: the host's ui-model-selection
|
|
15597
|
+
plugin exposes the modelDirectories resolver, and directoryFor(sessionId)
|
|
15598
|
+
hands out the session-scoped directory whose select() IS
|
|
15599
|
+
sessions.selectModel — so this picker, the composer pill and the /model
|
|
15600
|
+
command can never disagree. The VIEW is hand-drawn (ModelSelect is not
|
|
15601
|
+
exported from the official package); the DATA and the selection semantics
|
|
15602
|
+
are official end to end. The resolver arrives through a soft inject into
|
|
15603
|
+
a module-level ref (a bare `ctx.modelDirectories` read throws "without
|
|
15604
|
+
inject" on a real host — cordis gates EVERY property read behind an
|
|
15605
|
+
inject declaration); until it lands, and on any older host, the picker
|
|
15606
|
+
hides itself instead of throwing, and the AI-write button still works
|
|
15607
|
+
through the host's served-models fallback. */
|
|
15608
|
+
let modelDirectoriesRef = null
|
|
15609
|
+
const modelDirectorySubs = new Set()
|
|
15610
|
+
function ModelRoutePicker(props) {
|
|
15611
|
+
const t = props.t
|
|
15612
|
+
const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : 'default'
|
|
15613
|
+
const [injectVersion, setInjectVersion] = useState(0)
|
|
15614
|
+
const [directory, setDirectory] = useState(null)
|
|
15615
|
+
const [state, setState] = useState(null)
|
|
15616
|
+
const [open, setOpen] = useState(false)
|
|
15617
|
+
const [pane, setPane] = useState('root')
|
|
15618
|
+
const rootRef = useRef(null)
|
|
15619
|
+
/* The soft inject may land AFTER first paint (apply runs once, this slot
|
|
15620
|
+
mounts per session view): re-render when the module ref refills. */
|
|
15621
|
+
useEffect(() => {
|
|
15622
|
+
const bump = () => setInjectVersion((v) => v + 1)
|
|
15623
|
+
modelDirectorySubs.add(bump)
|
|
15624
|
+
return () => modelDirectorySubs.delete(bump)
|
|
15625
|
+
}, [])
|
|
15626
|
+
const resolver = modelDirectoriesRef
|
|
15627
|
+
const ok = resolver !== undefined && resolver !== null && typeof resolver.directoryFor === 'function'
|
|
15628
|
+
/* Resolve the session-scoped directory once per session; subscribe AFTER
|
|
15629
|
+
taking the snapshot so a change that lands between the two is not lost. */
|
|
15630
|
+
useEffect(() => {
|
|
15631
|
+
if (ok !== true) return undefined
|
|
15632
|
+
let dir = null
|
|
15633
|
+
try { dir = resolver.directoryFor(sessionId) } catch (error) { void error }
|
|
15634
|
+
if (dir === null || dir === undefined) return undefined
|
|
15635
|
+
setState(dir.store.getSnapshot())
|
|
15636
|
+
setDirectory(dir)
|
|
15637
|
+
/* Snapshot and subscription live on dir.store (uSES-safe); load()/
|
|
15638
|
+
select() hang directly off the directory. */
|
|
15639
|
+
return dir.store.subscribe(() => setState(dir.store.getSnapshot()))
|
|
15640
|
+
}, [ok, resolver, sessionId, injectVersion])
|
|
15641
|
+
/* The catalog loads on mount; failures surface through the store and the
|
|
15642
|
+
menu shows them. A rejected load must not unmount or disable anything. */
|
|
15643
|
+
useEffect(() => {
|
|
15644
|
+
if (directory === null) return undefined
|
|
15645
|
+
directory.load().catch(() => {})
|
|
15646
|
+
return undefined
|
|
15647
|
+
}, [directory])
|
|
15648
|
+
/* Outside close. pointerdown + capture: the change list captures
|
|
15649
|
+
pointerdown for the rubber band, so a plain mousedown listener can
|
|
15650
|
+
miss the very click that opened another menu (v0.19.2 lesson). */
|
|
15651
|
+
useEffect(() => {
|
|
15652
|
+
if (open !== true) return undefined
|
|
15653
|
+
const close = (event) => {
|
|
15654
|
+
const root = rootRef.current
|
|
15655
|
+
if (root !== null && root.contains(event.target) === true) return
|
|
15656
|
+
setOpen(false)
|
|
15657
|
+
setPane('root')
|
|
15658
|
+
}
|
|
15659
|
+
document.addEventListener('pointerdown', close, true)
|
|
15660
|
+
return () => document.removeEventListener('pointerdown', close, true)
|
|
15661
|
+
}, [open])
|
|
15662
|
+
if (ok !== true || directory === null) return null
|
|
15663
|
+
const s = state === null ? directory.getSnapshot() : state
|
|
15664
|
+
const current = s === null || s.current === undefined ? null : s.current
|
|
15665
|
+
const busy = s !== null && s.pending !== null && s.pending !== undefined
|
|
15666
|
+
const groups = s === null || s.groups === undefined ? [] : s.groups
|
|
15667
|
+
/* The current route's reasoning table, looked up through the SAME
|
|
15668
|
+
directory groups the menu renders (official effectiveEffort rule). */
|
|
15669
|
+
let routeGroup = null
|
|
15670
|
+
let routeModel = null
|
|
15671
|
+
if (current !== null) {
|
|
15672
|
+
routeGroup = groups.filter((row) => row.id === current.provider)[0] || null
|
|
15673
|
+
routeModel = routeGroup === null ? null : routeGroup.models.filter((row) => row.id === current.model)[0] || null
|
|
15674
|
+
}
|
|
15675
|
+
const reasoning = routeModel === null || routeModel.reasoning === undefined ? undefined : routeModel.reasoning
|
|
15676
|
+
const effectiveEffort = current === null ? undefined : current.reasoningEffort !== undefined ? current.reasoningEffort : reasoning === undefined ? undefined : reasoning.defaultEffort
|
|
15677
|
+
const effortName = (effort) => {
|
|
15678
|
+
if (reasoning !== undefined) {
|
|
15679
|
+
const hit = reasoning.efforts.filter((row) => row.id === effort)[0]
|
|
15680
|
+
if (hit !== undefined) return hit.name
|
|
15681
|
+
}
|
|
15682
|
+
return effort === undefined ? t('modelRoute.effortDefault') : effort
|
|
15683
|
+
}
|
|
15684
|
+
const effortLabel = current === null ? '' : reasoning === undefined
|
|
15685
|
+
? (current.reasoningEffort === undefined ? '' : current.reasoningEffort)
|
|
15686
|
+
: (effectiveEffort === undefined ? t('modelRoute.effortDefault') : effortName(effectiveEffort))
|
|
15687
|
+
const modelName = current === null ? '' : routeModel === null ? current.model : routeModel.name
|
|
15688
|
+
const effortChoices = reasoning === undefined ? [] : ([
|
|
15689
|
+
...(reasoning.defaultEffort === undefined ? [{ key: 'provider-default', effort: undefined, label: t('modelRoute.effortDefault') }] : []),
|
|
15690
|
+
...reasoning.efforts.map((row) => ({ key: row.id, effort: row.id, label: row.name })),
|
|
15691
|
+
])
|
|
15692
|
+
const closeMenu = () => { setOpen(false); setPane('root') }
|
|
15693
|
+
const choose = (selection) => {
|
|
15694
|
+
void directory.select(selection).then((result) => {
|
|
15695
|
+
if (result !== undefined && result !== null && result.ok !== true) return
|
|
15696
|
+
closeMenu()
|
|
15697
|
+
}).catch(() => {})
|
|
15698
|
+
}
|
|
15699
|
+
const triggerTitle = t('modelRoute.trigger') + (current === null ? '' : ' · ' + current.provider + '/' + current.model + (effortLabel === '' ? '' : ' · ' + effortLabel))
|
|
15700
|
+
return E('div', { className: 'dig-mrp-root', ref: rootRef },
|
|
15701
|
+
E('button', {
|
|
15702
|
+
type: 'button',
|
|
15703
|
+
className: 'dig-btn dig-btn-small dig-mrp-trigger',
|
|
15704
|
+
'data-open': open === true ? 'true' : undefined,
|
|
15705
|
+
title: triggerTitle,
|
|
15706
|
+
onClick: () => { setOpen(!open); setPane('root') },
|
|
15707
|
+
},
|
|
15708
|
+
E(Icon, { name: 'settings', size: 11 }),
|
|
15709
|
+
E('span', { className: 'dig-mrp-label' }, current === null ? t('modelRoute.pick') : modelName),
|
|
15710
|
+
effortLabel === '' ? null : E('span', { className: 'dig-mrp-effort' }, effortLabel),
|
|
15711
|
+
E(Icon, { name: 'chevron', size: 9 })),
|
|
15712
|
+
open !== true ? null : E('div', { className: 'dig-cs-menu dig-mrp-menu', role: 'menu' },
|
|
15713
|
+
s !== null && typeof s.error === 'string' && s.error !== ''
|
|
15714
|
+
? E('div', { className: 'dig-mrp-error', role: 'status' }, s.error)
|
|
15715
|
+
: null,
|
|
15716
|
+
pane === 'root'
|
|
15717
|
+
? [
|
|
15718
|
+
E('button', {
|
|
15719
|
+
type: 'button', key: 'model', className: 'dig-cs-item dig-mrp-row', disabled: busy === true,
|
|
15720
|
+
onClick: () => setPane('model'),
|
|
15721
|
+
},
|
|
15722
|
+
E('span', { className: 'dig-mrp-row-label' }, t('modelRoute.modelRow')),
|
|
15723
|
+
E('span', { className: 'dig-mrp-row-value' }, current === null ? t('modelRoute.pick') : modelName),
|
|
15724
|
+
E('span', { className: 'dig-mrp-row-arrow' }, E(Icon, { name: 'chevron', size: 9 }))),
|
|
15725
|
+
E('button', {
|
|
15726
|
+
type: 'button', key: 'effort', className: 'dig-cs-item dig-mrp-row', disabled: busy === true || current === null || effortChoices.length === 0,
|
|
15727
|
+
onClick: () => setPane('effort'),
|
|
15728
|
+
},
|
|
15729
|
+
E('span', { className: 'dig-mrp-row-label' }, t('modelRoute.effortRow')),
|
|
15730
|
+
E('span', { className: 'dig-mrp-row-value' }, effortChoices.length === 0 ? '' : (effortLabel === '' ? t('modelRoute.effortDefault') : effortLabel)),
|
|
15731
|
+
E('span', { className: 'dig-mrp-row-arrow' }, E(Icon, { name: 'chevron', size: 9 }))),
|
|
15732
|
+
]
|
|
15733
|
+
: [
|
|
15734
|
+
E('button', {
|
|
15735
|
+
type: 'button', key: 'back', className: 'dig-cs-item dig-mrp-back', disabled: busy === true,
|
|
15736
|
+
onClick: () => setPane('root'),
|
|
15737
|
+
}, '‹ ' + (pane === 'model' ? t('modelRoute.modelRow') : t('modelRoute.effortRow'))),
|
|
15738
|
+
],
|
|
15739
|
+
pane === 'model' && groups.length === 0
|
|
15740
|
+
? E('div', { className: 'dig-mrp-empty' }, t('modelRoute.empty'))
|
|
15741
|
+
: null,
|
|
15742
|
+
pane === 'model' && groups.map((group) => E(Fragment, { key: group.id },
|
|
15743
|
+
E('div', { className: 'dig-mrp-group' }, group.name === undefined || group.name === null || group.name === '' ? group.id : group.name),
|
|
15744
|
+
group.models.map((model) => {
|
|
15745
|
+
const selected = current !== null && current.provider === group.id && current.model === model.id
|
|
15746
|
+
return E('button', {
|
|
15747
|
+
type: 'button', key: model.id, className: 'dig-cs-item', 'data-current': selected === true ? 'true' : undefined,
|
|
15748
|
+
disabled: busy === true,
|
|
15749
|
+
title: group.id + '/' + model.id,
|
|
15750
|
+
onClick: () => choose({ provider: group.id, model: model.id }),
|
|
15751
|
+
},
|
|
15752
|
+
E('span', { className: 'dig-cs-item-name' }, model.name === undefined || model.name === null ? model.id : model.name),
|
|
15753
|
+
E('span', { className: 'dig-cs-item-note' }, selected === true ? E(Icon, { name: 'check', size: 11 }) : null))
|
|
15754
|
+
}))),
|
|
15755
|
+
pane === 'effort' && current !== null && effortChoices.map((level) => {
|
|
15756
|
+
const selected = effectiveEffort === level.effort
|
|
15757
|
+
return E('button', {
|
|
15758
|
+
type: 'button', key: level.key, className: 'dig-cs-item', 'data-current': selected === true ? 'true' : undefined,
|
|
15759
|
+
disabled: busy === true,
|
|
15760
|
+
onClick: () => choose({ provider: current.provider, model: current.model, reasoningEffort: level.effort }),
|
|
15761
|
+
},
|
|
15762
|
+
E('span', { className: 'dig-cs-item-name' }, level.label),
|
|
15763
|
+
E('span', { className: 'dig-cs-item-note' }, selected === true ? E(Icon, { name: 'check', size: 11 }) : null))
|
|
15764
|
+
})))
|
|
15765
|
+
}
|
|
15766
|
+
|
|
15432
15767
|
function ComposerGitStatus(props) {
|
|
15433
15768
|
const ctx = props.ctx
|
|
15434
15769
|
const t = props.t
|
|
@@ -16330,6 +16665,20 @@ window.__ModuleLoader__.load({
|
|
|
16330
16665
|
}, 'dsh-ide-git: session-switch prewarm')
|
|
16331
16666
|
})
|
|
16332
16667
|
|
|
16668
|
+
/* ---- panel model picker: the official directories resolver ---- */
|
|
16669
|
+
/* ui-model-selection exposes the resolver as a CONTEXT PROPERTY, and
|
|
16670
|
+
cordis refuses bare property reads ("cannot get property … without
|
|
16671
|
+
inject") — the only legal consumer path is a declared inject. Soft:
|
|
16672
|
+
an older host without the service never fires this callback, the ref
|
|
16673
|
+
stays null and the picker hides itself (the AI-write button keeps its
|
|
16674
|
+
served-models fallback). Subscribers re-render the mounted pickers. */
|
|
16675
|
+
ctx.inject(['modelDirectories'], (mdCtx) => {
|
|
16676
|
+
const md = mdCtx.get('modelDirectories')
|
|
16677
|
+
if (md === undefined || md === null || typeof md.directoryFor !== 'function') return
|
|
16678
|
+
modelDirectoriesRef = md
|
|
16679
|
+
for (const notify of modelDirectorySubs) { try { notify() } catch (error) { void error } }
|
|
16680
|
+
})
|
|
16681
|
+
|
|
16333
16682
|
/* ---- settings: the row Config is the rail's source of truth ---- */
|
|
16334
16683
|
/* dsh >= 0.1.7 serves a plugin's row Config to the browser through
|
|
16335
16684
|
ctx.configForms; from then on the settings card and the rail read that one
|
package/src/index.js
CHANGED
|
@@ -1788,6 +1788,28 @@ function failureTextOf(failure) {
|
|
|
1788
1788
|
return String(failure.code === undefined ? failure : failure.code)
|
|
1789
1789
|
}
|
|
1790
1790
|
|
|
1791
|
+
/** The no-route fallback: the first provider this host serves that lists at
|
|
1792
|
+
least one model, and that provider's first model. Issue #19 — a fresh
|
|
1793
|
+
session (no model picked yet) used to fail the panel button with 'no-route'
|
|
1794
|
+
before the user ever saw a message; a commit message is too small a task to
|
|
1795
|
+
lecture anyone about routing. Errors per provider stay silent on purpose:
|
|
1796
|
+
a provider that fails its catalog simply is not a fallback candidate. */
|
|
1797
|
+
async function fallbackRouteOf() {
|
|
1798
|
+
const llm = commitServices.llm
|
|
1799
|
+
for (const row of llm.listProviders()) {
|
|
1800
|
+
try {
|
|
1801
|
+
const models = await llm.listModels(row.id)
|
|
1802
|
+
const model = models[0]
|
|
1803
|
+
if (model !== undefined && model !== null && typeof model.id === 'string' && model.id !== '') {
|
|
1804
|
+
return { provider: row.id, model: model.id }
|
|
1805
|
+
}
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
void error
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return null
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1791
1813
|
/** Route the call exactly like the session does when it talks to the model. */
|
|
1792
1814
|
async function commitRouteOf(payload) {
|
|
1793
1815
|
const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
|
|
@@ -1801,7 +1823,13 @@ async function commitRouteOf(payload) {
|
|
|
1801
1823
|
try { header = session.requestHeader() } catch (error) { header = null; void error }
|
|
1802
1824
|
const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
|
|
1803
1825
|
if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
|
|
1804
|
-
|
|
1826
|
+
/* Issue #19: 'no-route' is the failure every blank session hit. The
|
|
1827
|
+
served fallback below keeps the button useful on a brand-new session;
|
|
1828
|
+
the panel's model picker (this release) is the user's visible way to
|
|
1829
|
+
steer it, and it drives the SAME session route once a model is picked. */
|
|
1830
|
+
const fallback = await fallbackRouteOf()
|
|
1831
|
+
if (fallback !== null) return { provider: fallback.provider, model: fallback.model, source: 'fallback' }
|
|
1832
|
+
throw new PanelError('no-models', 'no model route and no served models to fall back to; pick a model in the session first', 502)
|
|
1805
1833
|
}
|
|
1806
1834
|
return { provider: config.provider, model: config.model, source: 'session' }
|
|
1807
1835
|
}
|
|
@@ -2058,6 +2086,61 @@ function headerOf(req, name) {
|
|
|
2058
2086
|
return value
|
|
2059
2087
|
}
|
|
2060
2088
|
|
|
2089
|
+
/* Normalize an authority ("host[:port]" or an absolute URL) to
|
|
2090
|
+
"hostname:port", filling the scheme default port when it is implicit.
|
|
2091
|
+
Returns '' for anything unparseable (including the "null" opaque origin). */
|
|
2092
|
+
function authorityKey(value, protocol) {
|
|
2093
|
+
const text = String(value === undefined || value === null ? '' : value).trim()
|
|
2094
|
+
if (text === '') return ''
|
|
2095
|
+
let hostname = text
|
|
2096
|
+
let port = ''
|
|
2097
|
+
let scheme = protocol
|
|
2098
|
+
if (text.includes('://') === true) {
|
|
2099
|
+
let parsed = null
|
|
2100
|
+
try { parsed = new URL(text) } catch (error) { parsed = null }
|
|
2101
|
+
if (parsed === null) return ''
|
|
2102
|
+
hostname = parsed.hostname
|
|
2103
|
+
port = parsed.port
|
|
2104
|
+
scheme = parsed.protocol
|
|
2105
|
+
} else {
|
|
2106
|
+
const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
|
|
2107
|
+
if (bracketed !== null) {
|
|
2108
|
+
hostname = bracketed[1]
|
|
2109
|
+
port = bracketed[2] === undefined ? '' : bracketed[2]
|
|
2110
|
+
} else {
|
|
2111
|
+
const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
|
|
2112
|
+
if (plain === null) return ''
|
|
2113
|
+
hostname = plain[1]
|
|
2114
|
+
port = plain[2] === undefined ? '' : plain[2]
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
if (hostname === '') return ''
|
|
2118
|
+
if (port === '') port = scheme === 'https:' ? '443' : '80'
|
|
2119
|
+
return hostname.toLowerCase() + ':' + port
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
|
|
2123
|
+
served over plain HTTP from a non-loopback address is not a secure context,
|
|
2124
|
+
and the fetch-metadata headers are omitted there (issue #20: the git panel
|
|
2125
|
+
answered 403 on a LAN address). Origin and Referer are forbidden headers a
|
|
2126
|
+
page cannot forge — a cross-site request always carries the caller's own
|
|
2127
|
+
origin — so comparing either against the Host header restores the
|
|
2128
|
+
same-origin fence without trusting anything the request body claims. */
|
|
2129
|
+
function sameOriginOf(req, host) {
|
|
2130
|
+
const hostKey = authorityKey(host, 'http:')
|
|
2131
|
+
if (hostKey === '') return false
|
|
2132
|
+
/* A Host header without a port says nothing about the scheme, so either
|
|
2133
|
+
scheme default may be the one the browser used in Origin. */
|
|
2134
|
+
const candidates = new Set([hostKey])
|
|
2135
|
+
if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
|
|
2136
|
+
const matches = (value) => {
|
|
2137
|
+
const key = authorityKey(value, 'http:')
|
|
2138
|
+
return key !== '' && candidates.has(key)
|
|
2139
|
+
}
|
|
2140
|
+
if (matches(headerOf(req, 'origin')) === true) return true
|
|
2141
|
+
return matches(headerOf(req, 'referer'))
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2061
2144
|
/** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
|
|
2062
2145
|
function isTrusted(req) {
|
|
2063
2146
|
const host = headerOf(req, 'host')
|
|
@@ -2066,7 +2149,8 @@ function isTrusted(req) {
|
|
|
2066
2149
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
|
|
2067
2150
|
if (/^127\./.test(hostname)) return true
|
|
2068
2151
|
const site = headerOf(req, 'sec-fetch-site')
|
|
2069
|
-
|
|
2152
|
+
if (site === 'same-origin' || site === 'same-site') return true
|
|
2153
|
+
return sameOriginOf(req, host)
|
|
2070
2154
|
}
|
|
2071
2155
|
|
|
2072
2156
|
function sendJson(res, status, body) {
|
|
@@ -2108,7 +2192,13 @@ function readJsonBody(req, limit = 1 << 20) {
|
|
|
2108
2192
|
|
|
2109
2193
|
async function handle(req, res) {
|
|
2110
2194
|
if (!isTrusted(req)) {
|
|
2111
|
-
sendJson(res, 403, {
|
|
2195
|
+
sendJson(res, 403, {
|
|
2196
|
+
ok: false,
|
|
2197
|
+
error: {
|
|
2198
|
+
code: 'forbidden',
|
|
2199
|
+
message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
|
|
2200
|
+
},
|
|
2201
|
+
})
|
|
2112
2202
|
return
|
|
2113
2203
|
}
|
|
2114
2204
|
if (req.method !== 'POST') {
|