Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
1869 lines (1811 loc) · 97.1 KB
/
Copy pathindex.html
File metadata and controls
1869 lines (1811 loc) · 97.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>omp 设置中心</title>
<style>
:root { color-scheme: dark; }
body { font-family: "Segoe UI", system-ui, sans-serif; background: #16181d;
color: #d7dae0; margin: 0 auto; padding: 20px; max-width: 860px; }
h1 { font-size: 18px; margin: 0 0 4px; }
.sub { color: #8b93a1; font-size: 12px; margin-bottom: 14px; word-break: break-all; }
.hdr { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.lang-switch { display: flex; flex: none; }
.lang-switch button { background: #232732; border: 1px solid #3a4050; color: #8b93a1;
border-radius: 0; padding: 4px 12px; font-size: 12px; }
.lang-switch button:first-child { border-radius: 6px 0 0 6px; }
.lang-switch button:last-child { border-radius: 0 6px 6px 0; border-left: 0; }
.lang-switch button.active { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.tabs button { background: #232732; border: 1px solid #3a4050; color: #8b93a1;
border-radius: 8px; padding: 7px 18px; cursor: pointer; font-size: 14px; }
.tabs button.active { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
.role { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
border: 1px solid #2a2e37; border-radius: 8px; margin-bottom: 8px; background: #1c1f26; }
.role .name { width: 110px; font-weight: 600; }
.role .name small { display: block; font-weight: 400; color: #8b93a1; font-size: 11px; }
.role .value { flex: none; font-family: Consolas, monospace; font-size: 13px; color: #9fd0ff; }
select { background: #232732; color: #d7dae0; border: 1px solid #3a4050;
border-radius: 6px; padding: 6px 8px; font-size: 13px; display: none;
max-width: 360px; }
select.group { min-width: 180px; max-width: 220px; }
.role.editing { flex-wrap: wrap; }
.role.editing select { display: inline-block; }
button { background: #2d6cdf; color: #fff; border: 0; border-radius: 6px;
padding: 6px 14px; cursor: pointer; font-size: 13px; }
button.ghost { background: transparent; border: 1px solid #3a4050; color: #8b93a1; }
button:hover { filter: brightness(1.15); }
#toast { position: fixed; bottom: 20px; right: 20px; background: #234; padding: 10px 16px;
border-radius: 8px; display: none; font-size: 13px; border: 1px solid #3a4050; }
#toast.bad { background: #4a1d1d; border-color: #a33; }
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.toolbar input[type=text] { flex: 1; min-width: 240px; background: #232732; color: #d7dae0;
border: 1px solid #3a4050; border-radius: 6px; padding: 7px 10px; font-size: 13px; }
.toolbar label { font-size: 12px; color: #8b93a1; display: flex; gap: 5px; align-items: center; cursor: pointer; }
.grp { margin: 16px 0 6px; font-size: 14px; font-weight: 700; color: #9fc3ff;
border-bottom: 1px solid #2a2e37; padding-bottom: 4px; }
.grp small { color: #6b7280; font-weight: 400; margin-left: 8px; }
.row { border: 1px solid #242833; border-radius: 8px; background: #1c1f26;
padding: 8px 12px; margin-bottom: 6px; }
.row .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.row .zhname { font-size: 13px; font-weight: 600; min-width: 150px; }
.row .key { font-family: Consolas, monospace; font-size: 11px; color: #6b7280; }
.row .badge { font-size: 10px; border-radius: 4px; padding: 1px 6px; }
.badge.custom { background: #274a2d; color: #8ee08e; }
.badge.default { background: #2b2f3a; color: #6b7280; }
.badge.ro { background: #4a3a1d; color: #e0c98e; }
.row input[type=text] { background: #232732; color: #d7dae0; border: 1px solid #3a4050;
border-radius: 6px; padding: 5px 8px; font-size: 12px; width: 220px;
font-family: Consolas, monospace; }
.row select { display: inline-block; width: auto; min-width: 90px; }
.row .desc { font-size: 11px; color: #8b93a1; margin-top: 4px; line-height: 1.5; }
.row .save { padding: 4px 12px; font-size: 12px; }
.row input:disabled, .row select:disabled { opacity: .45; }
.count { font-size: 12px; color: #6b7280; }
.settings-layout { display: flex; gap: 14px; align-items: flex-start; }
#group-nav { flex: 0 0 200px; width: 200px; position: sticky; top: 12px;
max-height: calc(100vh - 60px); overflow-y: auto;
background: #1c1f26; border: 1px solid #2a2e37; border-radius: 8px; padding: 6px; }
.nav-item { display: flex; justify-content: space-between; align-items: center; gap: 6px;
padding: 5px 8px; border-radius: 6px; cursor: pointer; font-size: 12px; color: #9aa2b1; }
.nav-item:hover { background: #232732; color: #d7dae0; }
.nav-item.active { background: #2d6cdf; color: #fff; }
.nav-item.dim { opacity: .35; }
.nav-item .gname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-item .cnt { flex: none; font-size: 10px; color: #6b7280; background: #232732;
border-radius: 8px; padding: 0 6px; min-width: 14px; text-align: center; }
.nav-item.active .cnt { background: rgba(255,255,255,.18); color: #fff; }
#settings { flex: 1; min-width: 0; }
.empty-hint { color: #6b7280; font-size: 13px; padding: 24px 0; text-align: center; }
@media (max-width: 700px) {
.settings-layout { flex-direction: column; }
#group-nav { position: static; width: auto; flex: none; max-height: none;
align-self: stretch; max-width: 100%;
display: flex; overflow-x: auto; overflow-y: hidden; gap: 4px; padding: 4px; }
.nav-item { flex: 0 0 auto; }
.nav-item .gname { white-space: nowrap; }
}
#view-models .vm-prov { border: 1px solid #2a2e37; border-radius: 8px; background: #1c1f26;
margin-bottom: 8px; }
.vm-head { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; }
.vm-head .pname { font-weight: 700; color: #9fc3ff; flex: 1; }
.vm-head .pcnt { font-size: 12px; color: #8b93a1; }
.vm-head .caret { color: #6b7280; width: 12px; }
.vm-head button { padding: 3px 10px; font-size: 12px; }
.vm-list { padding: 2px 12px 8px 34px; display: none; }
.vm-prov.open .vm-list { display: block; }
.vm-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px; cursor: pointer; }
.vm-item .sel { font-family: Consolas, monospace; color: #6b7280; font-size: 11px; }
.vm-item.def .mname::after { content: attr(data-tag); margin-left: 6px; font-size: 10px;
background: #274a2d; color: #8ee08e; border-radius: 4px; padding: 1px 5px; }
.vm-note { font-size: 12px; color: #8b93a1; margin-bottom: 10px; line-height: 1.6; }
.vm-bar { position: sticky; bottom: 0; background: #16181d; padding: 10px 0;
display: flex; gap: 10px; align-items: center; border-top: 1px solid #2a2e37; }
.vm-bar .dirty { font-size: 12px; color: #e0c98e; }
/* ---------- 用量 / Usage ---------- */
.u-card { border: 1px solid #2a2e37; border-radius: 8px; background: #1c1f26;
padding: 10px 12px; margin-bottom: 10px; }
.u-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.u-head .pname { font-weight: 700; color: #9fc3ff; font-size: 14px; }
.u-head .meta { font-size: 12px; color: #8b93a1; }
.u-head .ftime { margin-left: auto; font-size: 11px; color: #6b7280; }
.u-row { padding: 6px 0; border-top: 1px solid #242833; }
.u-row .ltop { display: flex; justify-content: space-between; align-items: baseline;
gap: 10px; font-size: 13px; }
.u-row .lname { font-weight: 600; }
.u-row .lscope { font-size: 11px; color: #6b7280; margin-left: 8px; font-weight: 400; }
.u-row .amt { font-family: Consolas, monospace; font-size: 12px; color: #d7dae0; }
.u-bar { height: 8px; background: #232732; border-radius: 4px; margin-top: 6px; overflow: hidden; }
.u-fill { height: 100%; border-radius: 4px; background: #3fb27f;
transition: width .3s ease; }
.u-fill.warning { background: #e0b34a; }
.u-fill.exhausted, .u-fill.error { background: #e05a4a; }
.u-fill.unknown { background: #4a5062; }
.u-sub { display: flex; justify-content: space-between; gap: 10px;
font-size: 11px; color: #8b93a1; margin-top: 5px; flex-wrap: wrap; }
.u-sub .reset { color: #6b7280; }
.u-note { font-size: 11px; color: #8b93a1; padding: 4px 0 2px; border-top: 1px solid #242833;
line-height: 1.5; }
.u-extra { font-size: 11px; color: #8b93a1; margin-bottom: 8px; line-height: 1.6; }
.u-mini { padding: 2px 8px; font-size: 11px; }
.u-redeem { background: #274a2d; color: #8ee08e; margin-top: 6px; }
.u-redeem:disabled { opacity: .45; }
.a-acct { border-top: 1px solid #2a2e37; padding-top: 8px; margin-top: 8px; }
.u-head + .a-acct { border-top: 0; margin-top: 0; padding-top: 0; }
.a-id { font-size: 13px; color: #d7dae0; display: flex; gap: 8px; flex-wrap: wrap; align-items: baseline; margin-bottom: 4px; }
.a-id .meta { font-size: 11px; color: #8b93a1; }
.a-pol { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 6px; font-size: 12px; color: #8b93a1; }
.a-pol input { width: 70px; padding: 2px 6px; }
.a-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; background: #2b2616; color: #e3b341; }
.role .rdesc { flex: 1; min-width: 0; font-size: 11px; color: #8b93a1;
line-height: 1.55; white-space: normal; }
.role.editing .rdesc { flex-basis: 100%; order: 10; }
.roles-intro { font-size: 12px; color: #8b93a1; line-height: 1.7; margin-bottom: 12px;
padding: 10px 14px; border: 1px solid #2a2e37; border-radius: 8px;
background: #1c1f26; }
/* ---------- 故障兜底(retry.fallbackChains) ---------- */
.fb-sec .grp { margin-top: 22px; }
.fb-sec select { display: inline-block; width: auto; min-width: 90px; }
.fb-ed { margin-top: 8px; padding-top: 8px; border-top: 1px dashed #2a2e37; }
.fb-entry { display: flex; align-items: center; gap: 6px; margin: 5px 0; flex-wrap: wrap; }
.fb-entry select { min-width: 70px; max-width: 300px; }
.fb-entry .ord { color: #6b7280; font-size: 11px; width: 18px; text-align: right;
font-family: Consolas, monospace; }
.fb-entry button { padding: 2px 8px; font-size: 12px; }
.fb-mode { margin-right: 8px; }
.fb-ops { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
.fb-view { font-family: Consolas, monospace; font-size: 12px; color: #9fd0ff;
word-break: break-all; }
/* ---------- Ctrl+P 轮换顺序(cycleOrder) ---------- */
.cy-sec .grp { margin-top: 22px; }
.cy-name { font-weight: 600; font-size: 13px; width: 90px; overflow-wrap: anywhere; }
.cy-warn { color: #e0c98e; font-size: 12px; }
.cy-sec .fb-entry > .fb-view, .cy-sec .fb-entry > .cy-warn { flex: 1; min-width: 0; }
.cy-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
.cy-bind { display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
flex-basis: 100%; margin: 4px 0 2px 24px; }
.cy-dirty { font-size: 12px; color: #e0c98e; }
@media (max-width: 700px) {
body { padding: 16px; }
.tabs { flex-wrap: wrap; }
.tabs button { padding: 7px 12px; }
.role { display: grid; grid-template-columns: minmax(0, 1fr) auto;
align-items: start; gap: 8px 12px; }
.role .name { width: auto; grid-column: 1; order: 1; }
.role > button { grid-column: 2; order: 5; justify-self: end; }
.role > button:first-of-type { order: 1; }
.role .value { grid-column: 1 / -1; order: 2; min-width: 0;
overflow-wrap: anywhere; }
.role .rdesc { grid-column: 1 / -1; order: 3; }
.role.editing select { grid-column: 1 / -1; order: 4;
width: 100%; min-width: 0; max-width: 100%; }
.roles-intro, .row .key { overflow-wrap: anywhere; }
.fb-sec select { min-width: 0; max-width: 100%; }
.fb-entry select { max-width: 100%; }
.cy-sec select, .cy-sec input[type=text] { min-width: 0; max-width: 100%; }
.cy-bind { margin-left: 0; }
}
</style>
</head>
<body>
<div class="hdr">
<h1 id="h1">omp 设置中心</h1>
<div class="lang-switch" title="切换界面语言 / Switch UI language">
<button id="lang-zh" class="active">中文</button><button id="lang-en">EN</button>
</div>
</div>
<div class="sub" id="cfg"></div>
<div class="tabs">
<button id="tab-roles" class="active">模型角色</button>
<button id="tab-settings">全部设置</button>
<button id="tab-models">模型显示</button>
<button id="tab-usage">用量</button>
<button id="tab-accounts">账号</button>
</div>
<div id="view-roles"></div>
<div id="view-settings" style="display:none">
<div class="toolbar">
<input type="text" id="q" placeholder="搜索:键名 / 中文名 / 描述…">
<label><input type="checkbox" id="onlyCustom"><span id="only-custom-text">只看已自定义</span></label>
<span class="count" id="count"></span>
</div>
<div class="settings-layout">
<nav id="group-nav"></nav>
<div id="settings"></div>
</div>
</div>
<div id="view-models" style="display:none">
<div class="vm-note" id="vm-note"></div>
<div class="toolbar">
<input type="text" id="vm-q" placeholder="">
<span class="count" id="vm-count"></span>
</div>
<div id="vm-box"></div>
<div class="vm-bar">
<button id="vm-save"></button>
<button id="vm-reset" class="ghost"></button>
<span class="dirty" id="vm-dirty"></span>
</div>
</div>
<div id="view-usage" style="display:none">
<div class="toolbar">
<button id="u-refresh"></button>
<button id="u-force" class="ghost"></button>
<label><input type="checkbox" id="u-auto"><span id="u-auto-text"></span></label>
<span class="count" id="u-updated"></span>
</div>
<div id="u-box"></div>
</div>
<div id="view-accounts" style="display:none">
<div class="toolbar">
<button id="a-refresh"></button>
<span class="count" id="a-updated"></span>
</div>
<div id="a-box"></div>
</div>
<div id="toast"></div>
<script>
let models = [], settingsCache = [], visibleSet = new Set();
/* ---------- i18n:中英文切换 ---------- */
const MACRO_GROUP_EN = {"AST 工具":"AST Tools","Git 与提交":"Git & Commits","会话与交互":"Session & Interaction",
"供应商与认证":"Providers & Auth","协作与分享":"Collaboration & Sharing","安全与遥测":"Security & Telemetry",
"开发与调试":"Development & Debugging","技能与自动学习":"Skills & Auto Learning","检索与读取":"Retrieval & Reading",
"界面与输入":"UI & Input","系统与更新":"System & Update","记忆系统":"Memory Systems","语音":"Voice"};
const T = {
zh: {
title: 'omp 设置中心',
tabRoles: '模型角色', tabSettings: '全部设置',
searchPh: '搜索:键名 / 中文名 / 描述…',
onlyCustom: '只看已自定义',
modelsAvail: n => `共 ${n} 个可用模型`,
roleComment: {"default":"主会话模型","smol":"轻量快速任务","slow":"深度推理",
"plan":"规划模式","vision":"图像理解","task":"子代理","commit":"提交信息生成",
"tiny":"极小分类/标题","web":"网络搜索","advisor":"顾问评审"},
rolesIntro: '「模型角色」是 omp 的模型分工表:不同功能各自绑定一个角色,每个角色再指向具体模型(写法是 供应商/模型名,可带 :high 等思考档位)。角色的值里可以写 @角色名 来引用另一个角色(如 plan: "@slow"),写 * 等价于 @default;也可写逗号分隔的多个候选,取第一个能用的。没有内置说明的名字(如 e)是自定义角色:不影响任何内置功能,只有当你的配置、提示词或扩展用 @名字 引用它时才生效;把名字加进 cycleOrder 可以让 Ctrl+P 轮换到它。',
roleDesc: {
"default": '主对话模型:你在会话里敲的每轮对话、主 agent 的所有工具调用都走它,也是大多数角色找不到配置时的最终兜底。务必配一个可靠的主力模型。不配时用启动时选中的模型。',
"smol": '轻量快速任务模型:自动生成会话标题、给子代理起名、内置机械任务代理 sonic、编辑失败后的自动修复、生成技能描述、/prewalk 一次性交接(先用主模型规划、真正动手写文件时切到它)、/cleanse 清理、--plan-yolo-into 等都用它。适合快而便宜的模型(flash/mini 级);配太强浪费钱,配太弱会拉低标题和子代理质量。不配时:若 default 已配置则直接用 default,否则按内置快模型优先级表挑。',
"slow": '深度思考模型:Advisor(顾问评审)没单独配模型时会用到这里;也是 Ctrl+P 模型轮换的一档(默认轮换顺序 smol → default → slow);启动时可用 --slow 或环境变量 PI_SLOW_MODEL 临时覆盖。建议配旗舰推理模型——它决定顾问评审的质量。不配时:若 default 已配置则直接用 default,否则按内置旗舰模型优先级表挑。',
"plan": '规划模式的专用模型:输入 /plan 或按 Alt+Shift+P 进入规划模式时切到它,退出时切回(还有「每个新会话自动进入规划模式」的设置项)。适合架构规划能力强的模型。不配时规划模式直接沿用当前会话模型。也可用 --plan / PI_PLAN_MODEL 临时覆盖。',
"vision": '看图模型:当你拖进图片或对图片提问(如 read screenshot.png 带问题)时,omp 依次尝试 vision → default → 当前会话模型 → 任意能看图的可用模型,取第一个支持图片输入的。要配能看图的模型;配了不支持图片输入的会被跳过,全部都不行时会报错提示你给 vision 配一个。不配时按上述链自动找能看图的模型。',
"task": 'task 子代理工具的默认模型:主 agent 用 task 工具把工作分给子代理(如内置的通用代理 task),没写死模型的子代理就走它。子代理要独立完成真实工作(读写文件、跑命令),适合稳定的中强模型;配太弱委派的活容易失败。不配时子代理回落到当前会话模型。',
"commit": '生成 git 提交信息的模型:执行 omp commit(/commit)起草 commit message 时用它。小活,快而便宜的模型最合适。不配时自动回退到 smol,再没有就走 default。',
"tiny": '极轻量后台模型:自动生成会话标题、语音播报前的文本润色等打杂任务用它,选最小最快的模型即可(本地小模型也行)。不配时回退到 smol(smol 再不配则沿用 default / 内置快模型表)。',
"web": '联网搜索后端:web_search 等搜索工具用哪个服务/模型由它定。可以绑专用搜索服务(如 web/exa、web/duckduckgo、web/hosted),也可以绑自带联网搜索能力的聊天模型(如 google-gemini-cli/gemini-2.5-flash,会真的用它做搜索)。绑了不支持联网的普通聊天模型会报错并自动尝试内置搜索服务。不配时按内置搜索服务优先级表走。',
"advisor": '顾问评审模型:开启 advisor.enabled 或在会话里 /advisor on 后,一个后台助手持续审查主 agent 的每轮输出并插话提建议;WATCHDOG.yml 里没写 model 的顾问条目也用它。要配强模型——它得能发现主模型的错。不配时:先用 slow 的配置;slow 也没配则按内置旗舰模型表挑;不会直接用当前会话模型。',
"memory": '记忆系统模型:记忆后端(如 Mnemopi)写入/召回记忆时用它,小模型够用。不配时回退到 tiny,tiny 再不配则继续沿 smol 链。',
"image": '图片生成模型:generate_image 工具画图时用它,必须选能生成图片的模型(如 gpt-image、FLUX 这类生图模型),和 vision(看图)是两回事。不配时按内置生图模型表挑。',
"speech": '语音合成(TTS)模型:tts 工具把文字转语音时用它,选语音合成类模型(如本地 local/kokoro、xai/grok-tts)。',
"dictation": '语音听写(STT)模型:语音输入转文字时用它(如本地 local/parakeet)。',
"judge": '评判模型:评测/判定类调用用它,优先选专用评判模型(typesafe、openrouter-decisions 类),也可用普通聊天模型。不配时按 tiny → smol → default 顺序兜底。',
},
roleDescFallback: '自定义角色:omp 的内置功能不会主动用它,只有当你的配置、提示词或扩展里用 @%s 引用时才生效(比如在自定义子代理的头部写 model: "@%s",或把它加进 cycleOrder 让 Ctrl+P 轮换到)。任何自定义角色名都可以绑模型。',
switchBtn: '切换', saveBtn: '保存', cancelBtn: '取消',
currentOpt: v => `(当前) ${v}`,
currentSuffix: v => `${v}(当前)`,
boolLabel: v => v === 'true' ? 'true(开)' : 'false(关)',
roleSaved: (role, v) => `${role} → ${v}(已备份 config.yml,即时生效)`,
keySaved: (k, v) => `${k} → ${v}(已备份,写后 omp 即时生效)`,
failed: e => `失败: ${e}`,
badgeCustom: '已自定义', badgeDefault: '默认值', badgeRO: '只读(数组/嵌套)',
noneVal: '(无)',
navTitle: (g, n) => `${g}(${n} 项)`,
grpAll: n => `${n} 项`,
grpHit: (a, b) => `${a} / ${b} 项命中`,
emptyHint: '该分类下无匹配设置项',
count: (a, b) => `${a} / ${b} 项`,
typePrefix: '类型',
typeName: ty => ({boolean:'布尔', number:'数字', integer:'数字', string:'字符串',
enum:'枚举', array:'数组', record:'键值表'}[ty] ?? ty),
tabModels: '模型显示',
vmNote: '勾选的模型会出现在 omp 的 /model 选择器、Ctrl+P 轮换和本页「模型角色」下拉中;未勾选的只是隐藏,不会被禁用——已分配给角色的模型照常工作。保存写入 config.yml 的 enabledModels(自动备份,omp 即时生效)。',
vmSearch: '搜索:供应商 / 模型名 / selector…',
vmCount: (a, b) => `显示 ${a} / ${b} 个模型`,
vmSave: '保存', vmReset: '撤销更改',
vmDirty: n => `${n} 处未保存更改`,
vmAll: '全选', vmNone: '全不选',
vmDefTag: '默认角色·始终显示',
vmSaved: n => `已保存:显示 ${n} 个模型(已备份 config.yml,omp 即时生效)`,
vmUnmanaged: 'config.yml 的 enabledModels 含 path 作用域等复杂结构,本页只读,请手工编辑。',
tabUsage: '用量',
uRefresh: '刷新', uAuto: '每 60 秒自动刷新',
uUpdated: ts => `更新于 ${ts}`,
uFetched: ts => `数据获取于 ${ts}`,
uLoading: '正在拉取用量数据…',
uEmpty: '暂无用量数据。请先在 omp 中 /login 登录供应商账号。',
uNoLimits: '无可用限额数据',
uCredits: n => `重置额度券 ×${n}`,
uExpire: ds => `过期 ${ds}`,
uRemain: v => `剩余 ${v}`, uUsed: v => `已用 ${v}`,
tabAccounts: '账号', aRefresh: '刷新',
aIntro: '列出 omp 可按账号设置选号策略的 OAuth 账号与剩余额度(无身份标识的登录、API Key 凭据不显示),可为每个账号设置选号策略(写入 config.yml 的 auth.accountPolicies,自动备份,omp 即时生效)。优先级越高越先用,但只在健康、额度可测的账号之间比较,不会越过限流与保留额度保护;只影响新会话,运行中的会话保持当前账号。保留额度%:剩余额度低于该值时视为接近上限、让位给其他账号。新增账号请在 omp 中执行 /login。',
aReserve: '全局默认保留额度%(retry.usageReservePct)', aReserveUnset: '未设置,omp 默认',
aPriority: '优先级', aReservePct: '保留额度%', aSave: '保存', aClear: '清除策略',
aNoPolicy: '未设策略(按 omp 默认选号)', aHasPolicy: '已设策略',
aStale: '指向未登录账号的策略', aStaleHint: '这些策略匹配不到任何当前已登录账号,不会生效;账号重新登录后会自动对上。',
aDelete: '删除', aSaved: '已保存(已备份 config.yml)', aDeleted: '已删除',
aDefaultPh: n => `默认 ${n}`,
uReset: '重置于 ', uResetNow: '即将重置',
uNoUsageAcct: n => `无用量端点的账号 ×${n}`,
uDisabledCred: n => `已停用凭据 ×${n}`,
uForce: '强刷',
uForceTitle: '失效缓存并向供应商重新拉取(omp usage invalidate)',
uForceAll: '全部强刷',
uForceDone: '已重新抓取最新用量',
uResetHint: '也可在 omp 会话中用 /usage reset',
uRedeem: '用券重置',
uRedeemConfirm: p => `将消耗 ${p} 的 1 张额度券,重置其用量限额(不可逆)。确认继续?`,
uRedeemDone: '已消耗 1 张额度券并重置用量',
uRedeeming: '正在消耗额度券…',
fbTitle: '故障兜底',
fbIntro: 'retry.fallbackChains:模型请求失败时按顺序切换的备选模型链。键可以是模型角色(如 default)、具体模型 provider/model,或供应商通配 provider/*。模型类键(provider/model、provider/*)优先于角色键,只要当前主模型匹配就生效、与所在角色无关;没有配置模型键时按当前角色查链,角色没配链则继承 default 的链。注意:image / web / speech / dictation / judge / memory 等模型种类角色用自己的链,不走 default。条目可带思考档后缀(如 :high),不带则继承失败轮的思考档;条目也可以是 provider/*(保留失败模型的 id、只换供应商)或 openrouter/google/* 这类带前缀通配。空链表示显式禁止回退。',
fbMF: '模型回退 retry.modelFallback',
fbMFHint: '总开关:允许出错重试时切到 fallbackChains 里的模型;关闭则只在原模型上重试。',
fbOn: '触发条件 retry.fallbackOn',
fbOnAny: '任意错误', fbOnLimit: '仅额度耗尽', fbOnExcept: '额度耗尽除外',
fbRev: '返回策略 retry.fallbackRevertPolicy',
fbRevCooldown: '冷却结束即返回 cooldown-expiry', fbRevNever: '留在回退模型 never',
fbStUnset: '未配置', fbStEmpty: '已禁用', fbStCustom: '自定义',
fbModeUnset: '未配置(跟随角色/default 链)',
fbModeEmpty: '显式禁用回退',
fbModeCustom: '自定义链',
fbKeyRole: '角色', fbKeyModel: '模型', fbKeyWild: '通配',
fbEffUnset: '按查找规则生效', fbEffEmpty: '已禁用',
fbAdd: '+ 添加条目',
fbInherit: '继承思考档',
fbWild: p => `${p}/*(整个供应商)`,
fbSaved: k => `${k} 的回退链已保存(已备份 config.yml)`,
fbLoadFailed: '回退链配置读取失败',
cyTitle: 'Ctrl+P 轮换顺序',
cyIntro: d => `cycleOrder:在会话中按 Ctrl+P 时,按这里的顺序在各角色绑定的模型之间循环切换。可加入已有角色,也可输入自定义角色名(如 e、fast);自定义角色需要先绑定模型,Ctrl+P 才有对应的模型可切换。未自定义时 omp 使用默认顺序 ${d}。`,
cyStCustom: '已自定义', cyStDefault: '默认顺序',
cyCurrent: '当前轮换',
cyUnbound: '未绑定模型', cyBind: '绑定模型',
cyAddRole: '+ 加入角色', cyNoRoles: '已有角色都在列表中',
cyCustomPh: '自定义角色名', cyAddCustom: '+ 加入自定义角色',
cyEmpty: '列表为空:至少保留一个角色,或点「恢复默认」。',
cyBadName: '角色名只能包含字母、数字、下划线、点和连字符,不能带 @ 或空格',
cyDup: n => `${n} 已在轮换列表中`,
cySaved: '轮换顺序已保存(已备份 config.yml)',
cyReset: '恢复默认',
cyResetConfirm: d => `删除 cycleOrder 配置,恢复 omp 默认顺序 ${d}?`,
cyResetDone: '已恢复默认轮换顺序',
cyDirty: '有未保存的修改',
cyLoadFailed: '轮换顺序读取失败',
cyUp: '上移', cyDown: '下移', cyRemove: '移出轮换',
cyProvider: '供应商', cyModel: '模型',
},
en: {
title: 'omp Settings Web',
tabRoles: 'Model Roles', tabSettings: 'All Settings',
searchPh: 'Search: key / name / description…',
onlyCustom: 'Only customized',
modelsAvail: n => `${n} models available`,
roleComment: {"default":"Main session model","smol":"Fast lightweight tasks","slow":"Deep reasoning",
"plan":"Plan mode","vision":"Image understanding","task":"Subagent","commit":"Commit message",
"tiny":"Tiny classification/titles","web":"Web search","advisor":"Advisor review"},
rolesIntro: 'Model roles are omp\'s model assignment table: each feature binds to a role, and each role points at a concrete model (written provider/model, optionally with a :high thinking-level suffix). A role value may reference another role via @name (e.g. plan: "@slow"), * means @default, and comma-separated entries pick the first one that works. Names without a built-in description (like e) are custom roles: no built-in feature uses them — they only take effect when your config, prompts, or extensions reference @name; adding the name to cycleOrder lets Ctrl+P cycle to it.',
roleDesc: {
"default": 'The main conversation model: every turn you type and every tool call of the primary agent goes through it, and it is the final fallback when most other roles have no configuration. Always give it a reliable flagship model. Unset: the model picked at startup is used.',
"smol": 'Fast lightweight-task model: auto session titles, naming subagents, the bundled low-effort agent sonic, auto-repair after failed edits, skill-description generation, /prewalk one-shot handoff (the main model plans, smol does the edits), /cleanse, --plan-yolo-into, and more all use it. Best with a fast, cheap model (flash/mini class); too strong wastes money, too weak degrades titles and subagents. Unset: inherits default if configured, otherwise the built-in fast-model priority list picks one.',
"slow": 'Deep-reasoning model: the advisor falls back here when it has no own model; also a stop in the Ctrl+P model cycle (default order smol → default → slow); can be overridden per launch via --slow or the PI_SLOW_MODEL env var. Give it a flagship reasoning model — it sets the quality of advisor reviews. Unset: inherits default if configured, otherwise the built-in flagship priority list picks one.',
"plan": 'The model for plan mode: typing /plan or pressing Alt+Shift+P switches to this role while planning, and back on exit (there is also a setting to auto-enter plan mode in every new session). Suit a model strong at architecture and planning. Unset: plan mode simply keeps the current session model. Can be overridden via --plan / PI_PLAN_MODEL.',
"vision": 'Image-understanding model: when you attach an image or ask about one, omp tries vision → default → the active model → any available image-capable model, taking the first that supports image input. Give it a model that can actually see images; a text-only pick is skipped, and if no candidate can read images you get an error telling you to configure vision. Unset: the chain above finds any image-capable model automatically.',
"task": 'Default model for task-tool subagents: when the primary agent delegates work via the task tool (e.g. the bundled general-purpose task agent), subagents without a hardcoded model use this. Subagents do real work on their own (editing files, running commands), so pick a dependable mid/strong model; too weak and delegated tasks fail. Unset: subagents fall back to the current session model.',
"commit": 'Commit-message model: omp commit (/commit) drafts the commit message with it. A small job — a fast, cheap model is ideal. Unset: falls back to smol, then to default.',
"tiny": 'Ultra-light background model: auto session titles, text polishing before speech output, and similar chores. Pick the smallest, fastest model you have (a local one is fine). Unset: falls back to smol (and if smol is unset too, keeps going through default / the built-in fast-model list).',
"web": 'Web-search backend: decides which service or model the web_search tools use. You can bind a dedicated search service (web/exa, web/duckduckgo, web/hosted, …) or a chat model with built-in web grounding (e.g. google-gemini-cli/gemini-2.5-flash — it will actually be used for searches). Binding an ordinary chat model without web support reports an error and then tries the built-in search services. Unset: the built-in search-service priority list applies.',
"advisor": 'Advisor review model: with advisor.enabled or /advisor on, a background assistant reviews every primary-agent turn and interjects advice; WATCHDOG.yml entries without their own model use this too. Give it a strong model — it must catch the primary model\'s mistakes. Unset: uses the configured slow role; if slow is also unset, the built-in flagship list — it never simply inherits the active session model.',
"memory": 'Memory-system model: memory backends (e.g. Mnemopi) use it when writing and recalling memories. A small model suffices. Unset: falls back to tiny, then continues down smol\'s chain.',
"image": 'Image-generation model for the generate_image tool. Must be a model that generates images (e.g. gpt-image, FLUX) — not to be confused with vision, which reads images. Unset: the built-in image-model list applies.',
"speech": 'Text-to-speech model: the tts tool uses it to turn text into audio. Pick a speech-synthesis model (local/kokoro, xai/grok-tts, …).',
"dictation": 'Speech-to-text model: used for voice input transcription (e.g. local/parakeet).',
"judge": 'Judgment model for eval/judgment calls. Prefer a dedicated judgment model (typesafe, openrouter-decisions); an ordinary chat model also works. Unset: falls back through tiny → smol → default.',
},
roleDescFallback: 'Custom role: no built-in omp feature uses it — it only takes effect when your config, prompts, or extensions reference @%s (e.g. writing model: "@%s" in a custom subagent\'s header), or when added to cycleOrder so Ctrl+P cycles to it. Any custom role name can hold a model binding.',
switchBtn: 'Switch', saveBtn: 'Save', cancelBtn: 'Cancel',
currentOpt: v => `(current) ${v}`,
currentSuffix: v => `${v} (current)`,
boolLabel: v => v === 'true' ? 'true (on)' : 'false (off)',
roleSaved: (role, v) => `${role} → ${v} (config.yml backed up, applies immediately)`,
keySaved: (k, v) => `${k} → ${v} (backed up; omp picks it up immediately)`,
failed: e => `Failed: ${e}`,
badgeCustom: 'Customized', badgeDefault: 'Default', badgeRO: 'Read-only (array/nested)',
noneVal: '(none)',
navTitle: (g, n) => `${g} (${n} items)`,
grpAll: n => `${n} items`,
grpHit: (a, b) => `${a} / ${b} matched`,
emptyHint: 'No matching settings in this category',
count: (a, b) => `${a} / ${b} items`,
typePrefix: 'Type',
typeName: ty => ty,
tabModels: 'Model Visibility',
vmNote: 'Checked models appear in omp\'s /model picker, Ctrl+P cycling, and the Model Roles dropdowns on this page. Unchecked models are only hidden, not disabled — models already assigned to roles keep working. Saving writes enabledModels in config.yml (backed up; omp picks it up immediately).',
vmSearch: 'Search: provider / model name / selector…',
vmCount: (a, b) => `Showing ${a} / ${b} models`,
vmSave: 'Save', vmReset: 'Discard changes',
vmDirty: n => `${n} unsaved change(s)`,
vmAll: 'All', vmNone: 'None',
vmDefTag: 'default role · always shown',
vmSaved: n => `Saved: ${n} models shown (config.yml backed up; applies immediately)`,
vmUnmanaged: 'enabledModels in config.yml uses path-scoped or nested entries; this page is read-only — edit it by hand.',
tabUsage: 'Usage',
uRefresh: 'Refresh', uAuto: 'Auto-refresh every 60s',
uUpdated: ts => `Updated ${ts}`,
uFetched: ts => `Data fetched ${ts}`,
uLoading: 'Fetching usage data…',
uEmpty: 'No usage data yet. Run /login in omp to add provider accounts.',
uNoLimits: 'No limit data available',
uCredits: n => `Reset credits ×${n}`,
uExpire: ds => `expiring ${ds}`,
uRemain: v => `${v} left`, uUsed: v => `${v} used`,
tabAccounts: 'Accounts', aRefresh: 'Refresh',
aIntro: 'OAuth accounts omp can route per account, with remaining quota (logins without an identity and API-key credentials are not shown). Set a routing policy per account (written to auth.accountPolicies in config.yml; backed up, picked up by omp immediately). Higher priority is preferred, but only among healthy, measured accounts — it never overrides rate-limit blocks or quota reserve, and it only affects new sessions; running sessions keep their account. Reserve %: below this remaining share an account counts as near its limit and yields to others. Add accounts with /login in omp.',
aReserve: 'Global default reserve % (retry.usageReservePct)', aReserveUnset: 'unset, omp default',
aPriority: 'Priority', aReservePct: 'Reserve %', aSave: 'Save', aClear: 'Clear policy',
aNoPolicy: 'No policy (omp default ranking)', aHasPolicy: 'Policy set',
aStale: 'Policies for accounts not signed in', aStaleHint: 'These match no signed-in account and have no effect; they apply again once the account signs back in.',
aDelete: 'Delete', aSaved: 'Saved (config.yml backed up)', aDeleted: 'Deleted',
aDefaultPh: n => `default ${n}`,
uReset: 'resets ', uResetNow: 'resetting soon',
uNoUsageAcct: n => `Accounts without usage endpoints ×${n}`,
uDisabledCred: n => `Disabled credentials ×${n}`,
uForce: 'Force refresh',
uForceTitle: 'Invalidate cache and re-fetch from the provider (omp usage invalidate)',
uForceAll: 'Force refresh all',
uForceDone: 'Refreshed with latest usage',
uResetHint: 'also redeemable in an omp session via /usage reset',
uRedeem: 'Reset with credit',
uRedeemConfirm: p => `This spends 1 saved reset credit for ${p} and resets its usage limit (irreversible). Continue?`,
uRedeemDone: 'Spent 1 reset credit — usage reset',
uRedeeming: 'Spending reset credit…',
fbTitle: 'Failure Fallback',
fbIntro: 'retry.fallbackChains: ordered backup models a failed request switches to. Keys may be model roles (e.g. default), concrete models provider/model, or provider wildcards provider/*. Model-oriented keys beat role keys — they fire whenever the active primary matches, regardless of the calling role; with no matching model key the chain is looked up by role, and a role without a chain inherits default\'s. Note: model-kind roles such as image / web / speech / dictation / judge / memory consult their own chains, never default\'s. Entries may carry a thinking suffix (e.g. :high); without one they inherit the failing turn\'s effort. An entry may also be provider/* (keeps the failing model id, swaps only the provider) or a prefixed wildcard like openrouter/google/*. An empty chain means fallback explicitly disabled.',
fbMF: 'Model fallback retry.modelFallback',
fbMFHint: 'Master switch: lets error recovery switch to models in fallbackChains; off means retrying only on the original model.',
fbOn: 'Trigger retry.fallbackOn',
fbOnAny: 'any error', fbOnLimit: 'usage limit only', fbOnExcept: 'all but usage limits',
fbRev: 'Revert policy retry.fallbackRevertPolicy',
fbRevCooldown: 'revert on cooldown-expiry', fbRevNever: 'stay on fallback (never)',
fbStUnset: 'unset', fbStEmpty: 'disabled', fbStCustom: 'custom',
fbModeUnset: 'Unset (inherit role/default chain)',
fbModeEmpty: 'Explicitly disable fallback',
fbModeCustom: 'Custom chain',
fbKeyRole: 'role', fbKeyModel: 'model', fbKeyWild: 'wildcard',
fbEffUnset: 'applies via lookup rules', fbEffEmpty: 'disabled',
fbAdd: '+ Add entry',
fbInherit: 'inherit thinking',
fbWild: p => `${p}/* (whole provider)`,
fbSaved: k => `Fallback chain for ${k} saved (config.yml backed up)`,
fbLoadFailed: 'Failed to load fallback chains',
cyTitle: 'Ctrl+P Cycle Order',
cyIntro: d => `cycleOrder: pressing Ctrl+P in a session cycles through the models bound to these roles, in this order. Add existing roles or type a custom role name (e.g. e, fast); a custom role needs a model binding before Ctrl+P has a model to switch to. When not customized, omp uses the default order ${d}.`,
cyStCustom: 'customized', cyStDefault: 'default order',
cyCurrent: 'Current cycle',
cyUnbound: 'no model bound', cyBind: 'Bind model',
cyAddRole: '+ Add role', cyNoRoles: 'All existing roles are listed',
cyCustomPh: 'Custom role name', cyAddCustom: '+ Add custom role',
cyEmpty: 'The list is empty: keep at least one role, or use Restore default.',
cyBadName: 'Role names may contain only letters, digits, underscore, dot and hyphen — no @ or spaces',
cyDup: n => `${n} is already in the cycle`,
cySaved: 'Cycle order saved (config.yml backed up)',
cyReset: 'Restore default',
cyResetConfirm: d => `Remove cycleOrder and restore omp's default order ${d}?`,
cyResetDone: 'Default cycle order restored',
cyDirty: 'Unsaved changes',
cyLoadFailed: 'Failed to load cycle order',
cyUp: 'Move up', cyDown: 'Move down', cyRemove: 'Remove from cycle',
cyProvider: 'Provider', cyModel: 'Model',
},
};
let LANG = localStorage.getItem('omp-ui-lang') === 'en' ? 'en' : 'zh';
let zhGroupEn = {}; // 中文大类名 → 英文名(由 __groups × __groups_en 配对生成)
const t = () => T[LANG];
function groupName(g) {
return LANG === 'en' ? (MACRO_GROUP_EN[g] || zhGroupEn[g] || g) : g;
}
function enName(key) { // 键名末段 camelCase 拆成可读英文
const last = key.split('.').pop().replace(/_/g, ' ');
const spaced = last.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function buildGroupEn(groupsZh, groupsEn) {
zhGroupEn = {};
for (const id in groupsZh) if (groupsEn[id]) zhGroupEn[groupsZh[id]] = groupsEn[id];
}
function applyStaticLang() {
document.documentElement.lang = LANG === 'zh' ? 'zh' : 'en';
document.title = t().title;
document.getElementById('h1').textContent = t().title;
document.getElementById('tab-roles').textContent = t().tabRoles;
document.getElementById('tab-settings').textContent = t().tabSettings;
document.getElementById('tab-models').textContent = t().tabModels;
document.getElementById('tab-usage').textContent = t().tabUsage;
document.getElementById('tab-accounts').textContent = t().tabAccounts;
document.getElementById('a-refresh').textContent = t().aRefresh;
document.getElementById('u-refresh').textContent = t().uRefresh;
document.getElementById('u-auto-text').textContent = t().uAuto;
document.getElementById('u-force').textContent = t().uForceAll;
document.getElementById('u-force').title = t().uForceTitle;
document.getElementById('vm-q').placeholder = t().vmSearch;
document.getElementById('vm-save').textContent = t().vmSave;
document.getElementById('vm-reset').textContent = t().vmReset;
document.getElementById('q').placeholder = t().searchPh;
document.getElementById('only-custom-text').textContent = t().onlyCustom;
document.getElementById('lang-zh').classList.toggle('active', LANG === 'zh');
document.getElementById('lang-en').classList.toggle('active', LANG === 'en');
}
function rerenderDynamic() { // 不整页重载:重渲染两个页签的动态内容
load();
if (settingsCache.length) { renderNav(hitsByGroup()); renderGroup(); }
if (vm.models.length) renderModels();
if (us.data) renderUsage();
if (ac.data) renderAccounts();
}
function setLang(lang) {
if (LANG === lang) return;
LANG = lang;
localStorage.setItem('omp-ui-lang', lang);
applyStaticLang();
rerenderDynamic();
}
document.getElementById('lang-zh').onclick = () => setLang('zh');
document.getElementById('lang-en').onclick = () => setLang('en');
/* ---------- 通用 ---------- */
function toast(msg, bad) {
const t = document.getElementById('toast');
t.textContent = msg; t.style.display = 'block';
t.className = bad ? 'bad' : '';
setTimeout(() => t.style.display = 'none', 4000);
}
document.getElementById('tab-roles').onclick = () => switchTab('roles');
document.getElementById('tab-settings').onclick = () => switchTab('settings');
document.getElementById('tab-models').onclick = () => switchTab('models');
document.getElementById('tab-usage').onclick = () => switchTab('usage');
document.getElementById('tab-accounts').onclick = () => switchTab('accounts');
function switchTab(which) {
for (const v of ['roles', 'settings', 'models', 'usage', 'accounts']) {
document.getElementById('tab-' + v).classList.toggle('active', which === v);
document.getElementById('view-' + v).style.display = which === v ? '' : 'none';
}
if (which === 'accounts') loadAccounts();
if (which === 'settings' && !document.getElementById('settings').childElementCount) loadSettings();
if (which === 'models') loadModels();
if (which === 'usage') { loadUsage(); startUsageTimer(); } else stopUsageTimer();
}
/* ---------- 模型角色 ---------- */
async function load() {
const r = await fetch('/api/state').then(x => x.json());
models = r.models;
visibleSet = new Set(r.visible || r.models.map(m => m.selector));
document.getElementById('cfg').textContent = r.config + ' · ' + t().modelsAvail(r.models.length);
const box = document.getElementById('view-roles');
box.innerHTML = '';
const intro = document.createElement('div');
intro.className = 'roles-intro';
intro.textContent = t().rolesIntro;
box.appendChild(intro);
for (const [role, value] of Object.entries(r.roles)) {
box.appendChild(roleRow(role, value));
}
fb.roles = Object.keys(r.roles);
try {
const fr = await fetch('/api/fallback').then(x => x.json());
if (fr && fr.error) { fb.err = fr.error; fb.data = null; toast(t().failed(fr.error), true); }
else { fb.err = null; fb.data = fr; }
} catch (e) {
fb.err = String(e.message || e); fb.data = null;
toast(t().failed(fb.err), true);
}
await loadCycle(r.roles);
renderCycle(box);
renderFallback(box);
}
function roleRow(role, value) {
const div = document.createElement('div');
div.className = 'role';
const groups = {};
for (const m of models) // 只列「模型显示」中可见的模型;当前值即使被隐藏也保留
if (visibleSet.has(m.selector) || m.selector === value) (groups[m.provider] ??= []).push(m);
const gsel = document.createElement('select');
gsel.className = 'group';
for (const [p, list] of Object.entries(groups).sort((a,b)=>a[0].localeCompare(b[0]))) {
const o = document.createElement('option');
o.value = p;
o.textContent = `${p} (${list.length})`;
gsel.appendChild(o);
}
const inCatalog = models.some(m => m.selector === value);
if (!inCatalog) {
const o = document.createElement('option');
o.value = '__current__';
o.textContent = t().currentOpt(value);
gsel.prepend(o);
}
const msel = document.createElement('select');
function fillModels(group) {
msel.innerHTML = '';
const list = group === '__current__' ? [] : groups[group] || [];
for (const m of list) {
const o = document.createElement('option');
o.value = m.selector;
o.textContent = m.name + ' (' + m.selector + ')';
msel.appendChild(o);
}
if (group === '__current__') {
const o = document.createElement('option');
o.value = value; o.textContent = value; o.selected = true;
msel.appendChild(o);
}
}
gsel.onchange = () => fillModels(gsel.value);
gsel.value = inCatalog ? (models.find(m => m.selector === value)?.provider ?? Object.keys(groups)[0])
: '__current__';
fillModels(gsel.value);
if (inCatalog) msel.value = value;
div.innerHTML = `<div class="name"><span class="rname"></span><small></small></div>
<div class="rdesc"></div>
<div class="value"></div>`;
div.querySelector('.rname').textContent = role;
div.querySelector('.name small').textContent = t().roleComment[role] || '';
const desc = t().roleDesc[role] || t().roleDescFallback.replace(/%s/g, role);
div.querySelector('.rdesc').textContent = desc;
div.querySelector('.value').textContent = value;
const edit = document.createElement('button');
edit.textContent = t().switchBtn;
edit.onclick = () => {
div.classList.add('editing');
div.append(gsel, msel);
edit.style.display = 'none';
const ok = document.createElement('button'); ok.textContent = t().saveBtn;
const no = document.createElement('button'); no.textContent = t().cancelBtn; no.className = 'ghost';
ok.onclick = async () => {
const r = await fetch('/api/set', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({role, value: msel.value})}).then(x => x.json());
toast(r.ok ? t().roleSaved(role, msel.value) : t().failed(r.error), !r.ok);
load();
};
no.onclick = () => load();
div.append(ok, no);
};
div.appendChild(edit);
return div;
}
/* ---------- 故障兜底(retry.fallbackChains) ---------- */
const fb = { data: null, err: null, roles: [] };
const FB_THINK = ['', 'low', 'medium', 'high', 'xhigh', 'max', 'off'];
const FB_KIND = { // 模型种类角色:用自己的链,不走 default
image: 1, web: 1, speech: 1, dictation: 1, judge: 1, memory: 1 };
function fbSplit(e) { // 条目 = 基础选择器 + 可选 :思考档
const i = e.lastIndexOf(':');
if (i > 0 && FB_THINK.includes(e.slice(i + 1)))
return { base: e.slice(0, i), th: e.slice(i + 1) };
return { base: e, th: '' };
}
function fbProviders() { // 新选项只来自当前可见模型;既有失效条目由各行单独保留
const g = {};
for (const m of models) if (visibleSet.has(m.selector)) (g[m.provider] ??= []).push(m);
return g;
}
function fbOpt(sel, value, label) {
const o = document.createElement('option');
o.value = value; o.textContent = label; sel.appendChild(o); return o;
}
function fbState(chain) {
return chain === undefined ? 'unset' : (chain.length === 0 ? 'empty' : 'custom');
}
function fbBadge(chain) {
const st = fbState(chain);
const b = document.createElement('span');
b.className = 'badge ' + (st === 'unset' ? 'default' : st === 'empty' ? 'ro' : 'custom');
b.textContent = st === 'unset' ? t().fbStUnset : st === 'empty' ? t().fbStEmpty : t().fbStCustom;
return b;
}
function fbScalarRow(label, hint, key, value, options) { // 标量项走 /api/set-key
const div = document.createElement('div');
div.className = 'row';
div.dataset.setting = key;
const head = document.createElement('div');
head.className = 'head';
head.innerHTML = `<span class="zhname"></span><span class="key"></span>`;
head.querySelector('.zhname').textContent = label;
head.querySelector('.key').textContent = key;
const sel = document.createElement('select');
sel.setAttribute('aria-label', label);
for (const [v, lab] of options) fbOpt(sel, v, lab).selected = (v === value);
head.appendChild(sel);
const save = document.createElement('button');
save.className = 'save'; save.textContent = t().saveBtn;
save.onclick = async () => {
save.disabled = true;
try {
const r = await fetch('/api/set-key', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, value: sel.value})}).then(x => x.json());
if (r.ok) { toast(t().keySaved(key, sel.value)); await load(); }
else toast(t().failed(r.error), true);
} catch (e) {
toast(t().failed(e.message || e), true);
} finally { save.disabled = false; }
};
head.appendChild(save);
div.appendChild(head);
const d = document.createElement('div');
d.className = 'desc'; d.textContent = hint;
div.appendChild(d);
return div;
}
function fbEntryEditor(wchain, i, refresh) { // 单条链条目编辑行
const cur = fbSplit(wchain[i]);
const groups = fbProviders();
const row = document.createElement('div');
row.className = 'fb-entry';
const ord = document.createElement('span');
ord.className = 'ord'; ord.textContent = String(i + 1);
const psel = document.createElement('select');
const msel = document.createElement('select');
const tsel = document.createElement('select');
const curProv = cur.base.split('/')[0];
psel.setAttribute('aria-label', LANG === 'zh' ? '供应商' : 'Provider');
msel.setAttribute('aria-label', LANG === 'zh' ? '备用模型' : 'Fallback model');
tsel.setAttribute('aria-label', LANG === 'zh' ? '思考强度' : 'Thinking level');
const knownProv = curProv in groups;
for (const p of Object.keys(groups).sort()) fbOpt(psel, p, p);
if (!knownProv) fbOpt(psel, '__raw__', t().currentOpt(curProv)).selected = true;
else psel.value = curProv;
function fillM() {
msel.innerHTML = '';
if (psel.value === '__raw__') { fbOpt(msel, cur.base, cur.base); return; }
const p = psel.value;
if (cur.base === p + '/*') fbOpt(msel, cur.base, t().currentOpt(cur.base));
for (const m of groups[p] || []) fbOpt(msel, m.selector, m.name + ' (' + m.selector + ')');
const want = cur.base.startsWith(p + '/') ? cur.base : (groups[p] || [])[0]?.selector;
if (want && ![...msel.options].some(o => o.value === want))
fbOpt(msel, want, t().currentOpt(want)); // 隐藏/不在目录的既有条目保留
msel.value = want;
}
psel.onchange = () => { fillM(); upd(); };
for (const th of FB_THINK) fbOpt(tsel, th, th === '' ? t().fbInherit : ':' + th);
tsel.value = cur.th;
if (![...tsel.options].some(o => o.value === cur.th)) { // 未知后缀原样保留
fbOpt(tsel, cur.th, t().currentOpt(cur.th)).selected = true;
}
function upd() {
const base = psel.value === '__raw__' ? cur.base : msel.value;
wchain[i] = base + (tsel.value ? ':' + tsel.value : '');
}
msel.onchange = upd; tsel.onchange = upd;
fillM();
const up = document.createElement('button'); up.className = 'ghost'; up.textContent = '↑';
const dn = document.createElement('button'); dn.className = 'ghost'; dn.textContent = '↓';
const del = document.createElement('button'); del.className = 'ghost'; del.textContent = '✕';
up.setAttribute('aria-label', LANG === 'zh' ? '上移' : 'Move up');
dn.setAttribute('aria-label', LANG === 'zh' ? '下移' : 'Move down');
del.setAttribute('aria-label', LANG === 'zh' ? '删除' : 'Remove');
up.disabled = i === 0; dn.disabled = i === wchain.length - 1;
up.onclick = () => { [wchain[i-1], wchain[i]] = [wchain[i], wchain[i-1]]; refresh(); };
dn.onclick = () => { [wchain[i+1], wchain[i]] = [wchain[i], wchain[i+1]]; refresh(); };
del.onclick = () => { wchain.splice(i, 1); refresh(); };
row.append(ord, psel, msel, tsel, up, dn, del);
return row;
}
function fbChainRow(key) {
const chains = fb.data ? fb.data.chains || {} : {};
const chain = chains[key];
const isRole = fb.roles.includes(key);
const kind = isRole ? 'role' : (key.includes('*') ? 'wild' : 'model');
const div = document.createElement('div');
div.className = 'row';
div.dataset.chainKey = key;
const head = document.createElement('div');
head.className = 'head';
head.innerHTML = `<span class="zhname"></span><span class="key"></span>`;
head.querySelector('.zhname').textContent = isRole
? key + (t().roleComment[key] ? ' · ' + t().roleComment[key] : '')
: key;
head.querySelector('.key').textContent = 'retry.fallbackChains.' + key;
const kt = document.createElement('span');
kt.className = 'badge default';
kt.textContent = kind === 'role' ? t().fbKeyRole : kind === 'wild' ? t().fbKeyWild : t().fbKeyModel;
head.append(kt, fbBadge(chain));
const edit = document.createElement('button');
edit.className = 'save'; edit.textContent = t().switchBtn;
head.appendChild(edit);
div.appendChild(head);
const view = document.createElement('div');
view.className = 'desc';
const eff = document.createElement('span');
eff.style.marginRight = '8px';
const st = fbState(chain);
eff.textContent = st === 'unset'
? (key === 'default' || FB_KIND[key] ? t().fbStUnset : t().fbEffUnset)
: st === 'empty' ? t().fbEffEmpty : '';
const vw = document.createElement('span');
vw.className = 'fb-view';
vw.textContent = chain ? (chain.length ? chain.join(' → ') : '[]') : '';
view.append(eff, vw);
div.appendChild(view);
const ed = document.createElement('div');
ed.className = 'fb-ed'; ed.style.display = 'none';
div.appendChild(ed);
let wmode = st;
let wchain = chain ? chain.slice() : [];
function renderEd() {
ed.innerHTML = '';
const msel = document.createElement('select');
msel.className = 'fb-mode';
msel.setAttribute('aria-label', LANG === 'zh' ? '兜底模式' : 'Fallback mode');
for (const [v, lab] of [['unset', t().fbModeUnset],
['empty', t().fbModeEmpty],
['custom', t().fbModeCustom]]) fbOpt(msel, v, lab);
msel.value = wmode;
msel.onchange = () => { wmode = msel.value; renderEd(); };
ed.appendChild(msel);
if (wmode === 'custom') {
wchain.forEach((_, i) => ed.appendChild(fbEntryEditor(wchain, i, renderEd)));
const add = document.createElement('button');
add.className = 'ghost u-mini'; add.textContent = t().fbAdd;
add.disabled = !Object.values(fbProviders()).some(list => list.length);
add.onclick = () => {
const gs = fbProviders();
const p = Object.keys(gs).sort().find(p => gs[p].length);
const first = p && gs[p][0];
if (!first) return;
wchain.push(first.selector);
renderEd();
};
ed.appendChild(add);
}
const ops = document.createElement('div');
ops.className = 'fb-ops';
const ok = document.createElement('button'); ok.textContent = t().saveBtn;
const no = document.createElement('button'); no.textContent = t().cancelBtn; no.className = 'ghost';
ok.onclick = async () => {
const payload = wmode === 'unset' ? null : wmode === 'empty' ? [] : wchain.slice();
if (wmode === 'custom' && (!wchain.length || wchain.some(s => !s || !s.includes('/')))) {
toast(LANG === 'zh' ? '自定义链至少需要一个明确的备用模型;禁用兜底请选择显式禁用。'
: 'Choose at least one fallback model, or explicitly disable fallback.', true);
return;
}
ok.disabled = true;
try {
const r = await fetch('/api/fallback', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, chain: payload})}).then(x => x.json());
if (r.ok) {
fb.data.chains = fb.data.chains || {};
if (payload === null) delete fb.data.chains[key]; else fb.data.chains[key] = payload;
toast(t().fbSaved(key));
renderFallback(document.getElementById('view-roles'));
} else toast(t().failed(r.error), true);
} catch (e) {
toast(t().failed(e.message || e), true);
} finally { ok.disabled = false; }
};
no.onclick = () => { // 取消:回到已保存状态
wmode = fbState((fb.data.chains || {})[key]);
wchain = ((fb.data.chains || {})[key] || []).slice();
ed.style.display = 'none'; ed.innerHTML = '';
};
ops.append(ok, no);
ed.appendChild(ops);
}
edit.onclick = () => {
if (ed.style.display === 'none') {
wmode = fbState(chain); wchain = chain ? chain.slice() : [];
ed.style.display = ''; renderEd();
} else { ed.style.display = 'none'; ed.innerHTML = ''; }
};
return div;
}
function renderFallback(box) {
const old = box.querySelector('.fb-sec');
if (old) old.remove();
const sec = document.createElement('div');
sec.className = 'fb-sec';
const h = document.createElement('div');
h.className = 'grp';
h.innerHTML = `<span class="grp-name"></span><small>retry.fallbackChains</small>`;
h.querySelector('.grp-name').textContent = t().fbTitle;
sec.appendChild(h);
if (!fb.data) { // 读取失败:显示错误而不是空链
const e = document.createElement('div');
e.className = 'empty-hint';
e.textContent = t().fbLoadFailed + (fb.err ? ': ' + fb.err : '');
sec.appendChild(e);
box.appendChild(sec);
return;
}
const intro = document.createElement('div');
intro.className = 'roles-intro';
intro.textContent = t().fbIntro;
sec.appendChild(intro);
const d = fb.data;
sec.appendChild(fbScalarRow(t().fbMF, t().fbMFHint, 'retry.modelFallback',
String(d.modelFallback !== false), [['true', t().boolLabel('true')], ['false', t().boolLabel('false')]]));
sec.appendChild(fbScalarRow(t().fbOn, '', 'retry.fallbackOn',
d.fallbackOn || 'any', [['any', t().fbOnAny], ['usage-limit', t().fbOnLimit],
['except-usage-limit', t().fbOnExcept]]));
sec.appendChild(fbScalarRow(t().fbRev, '', 'retry.fallbackRevertPolicy',
d.fallbackRevertPolicy || 'cooldown-expiry',
[['cooldown-expiry', t().fbRevCooldown], ['never', t().fbRevNever]]));
const chains = d.chains || {};
const keys = fb.roles.slice();
for (const k of Object.keys(chains)) if (!keys.includes(k)) keys.push(k);
for (const k of keys) sec.appendChild(fbChainRow(k));
box.appendChild(sec);
}
/* ---------- Ctrl+P 轮换顺序(cycleOrder) ---------- */
const cy = { saved: null, draft: null, custom: false, def: [], err: null, roles: {} };
const CY_ROLE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
const cySame = (a, b) => JSON.stringify(a) === JSON.stringify(b);
async function loadCycle(roles) {
cy.roles = roles;
try {
const r = await fetch('/api/cycle-order').then(x => x.json());
if (r.error) throw new Error(r.error);
const keepDraft = cy.saved && cy.draft && !cySame(cy.draft, cy.saved); // 绑定模型后重载时保留未保存草稿
cy.saved = r.order; cy.custom = r.custom; cy.def = r.default; cy.err = null;
if (!keepDraft) cy.draft = r.order.slice();
} catch (e) {
cy.err = String(e.message || e); cy.saved = null;
toast(t().failed(cy.err), true);
}
}
async function cyPost(order) {
try {
const r = await fetch('/api/cycle-order', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order})}).then(x => x.json());
if (!r.ok) { toast(t().failed(r.error), true); return false; }
cy.saved = r.order; cy.custom = r.custom; cy.draft = r.order.slice();
return true;
} catch (e) {
toast(t().failed(e.message || e), true);
return false;
}
}
function cyBindEditor(role, onCancel) { // 为未绑定的(自定义)角色选择模型,写 modelRoles
const wrap = document.createElement('div');
wrap.className = 'cy-bind';
const groups = fbProviders();
const psel = document.createElement('select');
const msel = document.createElement('select');
psel.setAttribute('aria-label', t().cyProvider);
msel.setAttribute('aria-label', t().cyModel);
for (const p of Object.keys(groups).sort()) if (groups[p].length) fbOpt(psel, p, `${p} (${groups[p].length})`);
const fill = () => {
msel.innerHTML = '';
for (const m of groups[psel.value] || []) fbOpt(msel, m.selector, m.name + ' (' + m.selector + ')');
};
psel.onchange = fill; fill();
const ok = document.createElement('button'); ok.textContent = t().saveBtn;