heartcode 0.1.14 → 0.1.24
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/CHANGELOG.md +197 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +408 -142
- package/src/settings.js +44 -7
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/src/settings.html
CHANGED
|
@@ -4,55 +4,95 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>HeartCode Settings</title>
|
|
7
|
+
<script>
|
|
8
|
+
// light or dark: chosen on the page (kept in this browser), else as the system is set
|
|
9
|
+
try { const t = localStorage.getItem('heart-theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t } catch {}
|
|
10
|
+
</script>
|
|
7
11
|
<style>
|
|
12
|
+
/* One accent (green), one cool-gray family; red only for what failed. */
|
|
8
13
|
:root {
|
|
9
|
-
--bg: #
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
+
--bg: #f6f6f8; --side: #fbfbfc; --panel: #ffffff; --panel2: #fafafb; --text: #17181c; --muted: #5d616c; --faint: #8d919b;
|
|
15
|
+
--line: #e7e8ec; --line-strong: #d6d8de; --hover: #f2f3f5; --input: #ffffff;
|
|
16
|
+
--accent: #16824c; --accent-soft: #e8f5ee; --on-accent: #ffffff;
|
|
17
|
+
--brand: var(--accent); --brand-soft: var(--accent-soft); --on-brand: var(--on-accent);
|
|
18
|
+
--ok: #1d8a4b; --ok-soft: #e7f5ec; --err: #d1242f; --err-soft: #fdecee;
|
|
19
|
+
--ring: 0 0 0 3px rgba(22, 130, 76, .18);
|
|
20
|
+
--shadow: 0 1px 2px rgba(20, 22, 30, .04), 0 8px 24px rgba(20, 22, 30, .06);
|
|
21
|
+
--shadow-pop: 0 24px 64px rgba(20, 22, 30, .18), 0 2px 6px rgba(20, 22, 30, .06);
|
|
22
|
+
--sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
|
23
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
24
|
+
--radius: 14px; --radius-s: 8px;
|
|
25
|
+
color-scheme: light;
|
|
14
26
|
}
|
|
27
|
+
/* dark: as the system is set (unless Light is chosen on the page), or Dark chosen */
|
|
15
28
|
@media (prefers-color-scheme: dark) {
|
|
16
|
-
:root {
|
|
17
|
-
--bg: #
|
|
18
|
-
--
|
|
19
|
-
--
|
|
29
|
+
:root:not([data-theme=light]) {
|
|
30
|
+
--bg: #0c0d10; --side: #0f1014; --panel: #14151a; --panel2: #17191e; --text: #ececf0; --muted: #a2a6b0; --faint: #6d717c;
|
|
31
|
+
--line: #23252c; --line-strong: #2f323a; --hover: #1c1e24; --input: #101115;
|
|
32
|
+
--accent: #5ccf8a; --accent-soft: rgba(92, 207, 138, .13); --on-accent: #07140d;
|
|
33
|
+
--ok: #62cf8b; --ok-soft: rgba(98, 207, 139, .12); --err: #ff7a7a; --err-soft: rgba(255, 122, 122, .12);
|
|
34
|
+
--ring: 0 0 0 3px rgba(92, 207, 138, .25);
|
|
35
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, .3);
|
|
36
|
+
--shadow-pop: 0 28px 72px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .03);
|
|
37
|
+
color-scheme: dark;
|
|
20
38
|
}
|
|
21
39
|
}
|
|
40
|
+
:root[data-theme=dark] {
|
|
41
|
+
--bg: #0c0d10; --side: #0f1014; --panel: #14151a; --panel2: #17191e; --text: #ececf0; --muted: #a2a6b0; --faint: #6d717c;
|
|
42
|
+
--line: #23252c; --line-strong: #2f323a; --hover: #1c1e24; --input: #101115;
|
|
43
|
+
--accent: #5ccf8a; --accent-soft: rgba(92, 207, 138, .13); --on-accent: #07140d;
|
|
44
|
+
--ok: #62cf8b; --ok-soft: rgba(98, 207, 139, .12); --err: #ff7a7a; --err-soft: rgba(255, 122, 122, .12);
|
|
45
|
+
--ring: 0 0 0 3px rgba(92, 207, 138, .25);
|
|
46
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, .3);
|
|
47
|
+
--shadow-pop: 0 28px 72px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .03);
|
|
48
|
+
color-scheme: dark;
|
|
49
|
+
}
|
|
22
50
|
* { box-sizing: border-box; }
|
|
23
|
-
|
|
24
|
-
|
|
51
|
+
html { scroll-behavior: smooth; }
|
|
52
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
|
53
|
+
a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: 2px; }
|
|
54
|
+
a:hover { text-decoration-color: currentColor; }
|
|
55
|
+
::selection { background: var(--accent-soft); }
|
|
25
56
|
button, input, select, textarea { font: inherit; color: inherit; }
|
|
57
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
26
58
|
[hidden] { display: none !important; }
|
|
27
59
|
|
|
28
60
|
/* the app: pages on the left, one page at a time on the right, using the whole width */
|
|
29
61
|
.app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
|
30
|
-
aside { border-right: 1px solid var(--line); padding:
|
|
31
|
-
.brand { display: flex; align-items:
|
|
32
|
-
.brand span { color: var(--
|
|
33
|
-
|
|
62
|
+
aside { border-right: 1px solid var(--line); padding: 22px 14px 16px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--side); display: flex; flex-direction: column; }
|
|
63
|
+
.brand { display: flex; align-items: baseline; gap: 8px; font-weight: 650; font-size: 15px; letter-spacing: -.01em; padding: 0 10px 22px; }
|
|
64
|
+
.brand span { color: var(--accent); font-size: 17px; }
|
|
65
|
+
.brand small { color: var(--faint); font-weight: 500; font-size: 13px; }
|
|
66
|
+
nav a { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius-s); color: var(--muted); text-decoration: none; margin-bottom: 1px; font-weight: 500; transition: background .15s, color .15s; }
|
|
34
67
|
nav a:hover { background: var(--hover); color: var(--text); }
|
|
35
|
-
nav a.on { background: var(--
|
|
36
|
-
nav a
|
|
37
|
-
nav a .
|
|
68
|
+
nav a.on { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow), inset 0 0 0 1px var(--line); }
|
|
69
|
+
nav a.on::before { content: ''; position: absolute; left: -14px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
|
70
|
+
nav a .ic { width: 18px; text-align: center; opacity: .7; font-size: 13px; }
|
|
71
|
+
nav a.on .ic { color: var(--accent); opacity: 1; }
|
|
72
|
+
nav a .count { margin-left: auto; font-size: 11.5px; color: var(--faint); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
38
73
|
.using:empty { visibility: hidden; } /* until your settings are in */
|
|
39
|
-
.using { margin: auto
|
|
74
|
+
.using { margin: auto 0 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; font-size: 12px; background: var(--panel); }
|
|
40
75
|
.using .k { color: var(--faint); display: block; }
|
|
41
|
-
.using b { display: block; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin:
|
|
42
|
-
|
|
76
|
+
.using b { display: block; font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; margin: 3px 0 2px; color: var(--text); }
|
|
77
|
+
.using a { font-weight: 600; }
|
|
78
|
+
.theme-pick { display: flex; margin-top: 10px; height: 34px; }
|
|
79
|
+
.theme-pick label { flex: 1; justify-content: center; font-size: 12.5px; }
|
|
80
|
+
.using:empty + .theme-pick { margin-top: auto; }
|
|
81
|
+
main { padding: 36px 44px 72px; width: 100%; max-width: 1640px; min-width: 0; }
|
|
43
82
|
.page { display: none; }
|
|
44
|
-
.page.on { display: block; }
|
|
45
|
-
|
|
83
|
+
.page.on { display: block; animation: page-in .22s ease-out; }
|
|
84
|
+
@keyframes page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
85
|
+
.head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px 24px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
46
86
|
.disk { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
|
47
|
-
.disk b { font-size: 22px; }
|
|
87
|
+
.disk b { font-size: 22px; font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
|
48
88
|
.disk .k { color: var(--muted); font-size: 12.5px; }
|
|
49
|
-
h1 { font-size: 24px; margin: 0 0
|
|
50
|
-
.lead { color: var(--muted); margin: 0; max-width:
|
|
51
|
-
h2 { font-size: 15px; margin: 0 0 4px; }
|
|
52
|
-
h3 { font-size:
|
|
53
|
-
.sub { color: var(--muted); margin: 0 0
|
|
89
|
+
h1 { font-size: 24px; font-weight: 680; letter-spacing: -.025em; line-height: 1.2; margin: 0 0 6px; }
|
|
90
|
+
.lead { color: var(--muted); margin: 0; max-width: 68ch; font-size: 13.5px; text-wrap: pretty; }
|
|
91
|
+
h2 { font-size: 15px; font-weight: 620; letter-spacing: -.01em; margin: 0 0 4px; }
|
|
92
|
+
h3 { font-size: 12.5px; font-weight: 600; margin: 0 0 8px; color: var(--muted); }
|
|
93
|
+
.sub { color: var(--muted); margin: 0 0 16px; font-size: 13px; text-wrap: pretty; }
|
|
54
94
|
|
|
55
|
-
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding:
|
|
95
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px 24px 24px; }
|
|
56
96
|
.stack { display: flex; flex-direction: column; gap: 18px; }
|
|
57
97
|
/* two columns: the work on the left, a panel on the right */
|
|
58
98
|
.split { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 24px; align-items: start; }
|
|
@@ -65,25 +105,26 @@
|
|
|
65
105
|
.cols > .stack { display: flex; flex-direction: column; }
|
|
66
106
|
.cols > .stack > .card:last-child { flex: 1; } /* both columns end at the same line */
|
|
67
107
|
/* two or three choices in one control (Command | URL) */
|
|
68
|
-
.seg { display: inline-flex;
|
|
69
|
-
.seg label { display: flex; align-items: center; padding: 0
|
|
70
|
-
.seg label
|
|
71
|
-
.seg label.on { background: var(--
|
|
108
|
+
.seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: 10px; background: var(--hover); height: 36px; }
|
|
109
|
+
.seg label { display: flex; align-items: center; padding: 0 12px; border-radius: 7px; cursor: pointer; color: var(--muted); font-weight: 500; font-size: 13px; transition: background .15s, color .15s; }
|
|
110
|
+
.seg label:hover { color: var(--text); }
|
|
111
|
+
.seg label.on { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow), inset 0 0 0 1px var(--line); }
|
|
72
112
|
.seg input { position: absolute; opacity: 0; width: 0; }
|
|
73
113
|
/* a list on the left, what's chosen on the right */
|
|
74
114
|
.md { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 20px; align-items: start; }
|
|
75
115
|
.md .list { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; position: sticky; top: 24px; max-height: calc(100vh - 170px); display: flex; flex-direction: column; }
|
|
76
116
|
.md .list .top { padding: 12px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; }
|
|
77
117
|
.md .list .items { overflow-y: auto; }
|
|
78
|
-
.md .group { padding:
|
|
79
|
-
.md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px
|
|
118
|
+
.md .group { padding: 14px 16px 6px; font-size: 12px; font-weight: 600; color: var(--faint); }
|
|
119
|
+
.md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px 16px; box-shadow: inset 3px 0 0 transparent; cursor: pointer; transition: background .12s; }
|
|
80
120
|
.md .it:hover { background: var(--hover); }
|
|
81
|
-
.md .it.on { background: var(--accent-soft);
|
|
121
|
+
.md .it.on { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
|
|
82
122
|
.md .it .t { display: flex; gap: 6px; align-items: center; font-weight: 600; font-size: 13px; word-break: break-all; }
|
|
83
123
|
.md .it .d { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
84
124
|
.md .detail { min-width: 0; }
|
|
85
125
|
.md.solo { grid-template-columns: minmax(0, 1fr); } /* nothing to list: the panel gets the width */
|
|
86
126
|
.md.solo .list { display: none; }
|
|
127
|
+
.md.solo .detail > .card { height: auto !important; }
|
|
87
128
|
/* a long list (the prompts): list and editor fill the screen's height, like an editor app */
|
|
88
129
|
/* a list (or tabs) and its editor fill the screen below the page's title, like an editor app;
|
|
89
130
|
the height is set from where it starts (fillHeight) */
|
|
@@ -98,51 +139,60 @@
|
|
|
98
139
|
.md.tabs .list { position: static; max-height: none; background: none; border: 0; overflow: visible; }
|
|
99
140
|
.md.tabs .items { display: flex; flex-wrap: wrap; gap: 8px; overflow: visible; }
|
|
100
141
|
.md.tabs .group { display: none; }
|
|
101
|
-
.md.tabs .it { width: auto; border: 1px solid var(--line); border-radius:
|
|
102
|
-
.md.tabs .it
|
|
142
|
+
.md.tabs .it { width: auto; border: 1px solid var(--line); border-radius: 10px; padding: 7px 14px; background: var(--panel); box-shadow: none; }
|
|
143
|
+
.md.tabs .it:hover { border-color: var(--line-strong); }
|
|
144
|
+
.md.tabs .it.on { border-color: var(--accent); background: var(--accent-soft); box-shadow: none; }
|
|
103
145
|
.md.tabs .it .d { display: none; }
|
|
104
146
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); flex: none; }
|
|
105
147
|
|
|
106
|
-
label.f { display: block; font-size: 12.5px; font-weight: 600; margin:
|
|
107
|
-
.hint { color: var(--faint); font-size: 12.5px; margin-top:
|
|
148
|
+
label.f { display: block; font-size: 12.5px; font-weight: 600; margin: 16px 0 6px; }
|
|
149
|
+
.hint { color: var(--faint); font-size: 12.5px; margin-top: 7px; text-wrap: pretty; }
|
|
108
150
|
input[type=text], input[type=password], input[type=number], input[type=search], select, textarea {
|
|
109
|
-
width: 100%; padding: 8px
|
|
151
|
+
width: 100%; padding: 8px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius-s); background: var(--input);
|
|
152
|
+
transition: border-color .15s, box-shadow .15s;
|
|
110
153
|
}
|
|
111
|
-
input[type=text], input[type=password], input[type=number], input[type=search], select { height:
|
|
112
|
-
textarea {
|
|
154
|
+
input[type=text], input[type=password], input[type=number], input[type=search], select { height: 36px; }
|
|
155
|
+
input::placeholder, textarea::placeholder { color: var(--faint); }
|
|
156
|
+
input[type=text]:hover, input[type=password]:hover, input[type=number]:hover, input[type=search]:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--line-strong) 60%, var(--muted)); }
|
|
157
|
+
select { appearance: none; padding-right: 34px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%238d919b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5l3 3 3-3'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
|
|
158
|
+
textarea { font-family: var(--mono); font-size: 12.5px; resize: vertical; tab-size: 2; line-height: 1.6; display: block; }
|
|
113
159
|
textarea.editor { min-height: 96px; overflow-y: auto; } /* grows with its text (grow()), up to most of the screen */
|
|
114
|
-
input:focus, select:focus, textarea:focus { outline:
|
|
115
|
-
|
|
116
|
-
button:
|
|
160
|
+
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
|
161
|
+
input[type=checkbox] { accent-color: var(--accent); }
|
|
162
|
+
button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 14px; line-height: 18px; border-radius: var(--radius-s); border: 1px solid var(--line-strong); background: var(--panel); cursor: pointer; white-space: nowrap; font-weight: 500; transition: background .15s, border-color .15s, color .15s, transform .08s, filter .15s; }
|
|
163
|
+
button:hover:not(:disabled) { background: var(--hover); }
|
|
164
|
+
button:active:not(:disabled) { transform: translateY(1px); }
|
|
117
165
|
button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
118
|
-
button.primary:hover:not(:disabled) { filter: brightness(1.
|
|
166
|
+
button.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.07); }
|
|
119
167
|
button.danger { color: var(--err); }
|
|
120
|
-
button.
|
|
168
|
+
button.danger:hover:not(:disabled) { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
|
|
169
|
+
button.small { padding: 4px 10px; font-size: 12.5px; }
|
|
121
170
|
button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
|
122
|
-
button:disabled {
|
|
171
|
+
button.ghost:hover:not(:disabled) { color: var(--text); background: var(--hover); }
|
|
172
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
123
173
|
.row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
|
|
124
174
|
.row > .grow { flex: 1; min-width: 180px; }
|
|
125
175
|
.actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
|
|
126
176
|
.actions .spacer { flex: 1; }
|
|
127
177
|
.mono { font-family: var(--mono); font-size: 12.5px; }
|
|
128
178
|
.muted { color: var(--muted); }
|
|
129
|
-
.tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; padding:
|
|
130
|
-
.tag.brand { background: var(--
|
|
179
|
+
.tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; line-height: 18px; padding: 0 7px; border-radius: 6px; background: var(--hover); color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
180
|
+
.tag.brand { background: var(--accent-soft); color: var(--accent); }
|
|
131
181
|
.tag.ok { background: var(--ok-soft); color: var(--ok); }
|
|
132
182
|
.tag.err { background: var(--err-soft); color: var(--err); }
|
|
133
|
-
.empty { text-align: center; padding:
|
|
183
|
+
.empty { text-align: center; padding: 36px 16px; color: var(--muted); }
|
|
134
184
|
.empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 4px; }
|
|
135
185
|
|
|
136
186
|
/* first start: the steps, then adding a provider */
|
|
137
187
|
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; }
|
|
138
|
-
.step { display: flex; gap: 12px; align-items: center; padding:
|
|
139
|
-
.step .num { width: 28px; height: 28px; border-radius:
|
|
188
|
+
.step { display: flex; gap: 12px; align-items: center; padding: 15px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--muted); font-size: 13px; }
|
|
189
|
+
.step .num { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; font-weight: 700; background: var(--hover); color: var(--muted); flex: none; }
|
|
140
190
|
.step b { display: block; color: var(--text); }
|
|
141
191
|
.step.done .num { background: var(--ok-soft); color: var(--ok); }
|
|
142
192
|
.step.now { border-color: var(--brand); }
|
|
143
193
|
.step.now .num { background: var(--brand); color: var(--on-brand); }
|
|
144
194
|
.presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 10px; }
|
|
145
|
-
.preset { text-align: left; padding: 12px 14px; border-radius:
|
|
195
|
+
.preset { display: block; text-align: left; padding: 12px 14px; border-radius: 12px; }
|
|
146
196
|
.preset b { display: block; }
|
|
147
197
|
.preset span { color: var(--muted); font-size: 12px; }
|
|
148
198
|
.preset.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
@@ -151,14 +201,15 @@
|
|
|
151
201
|
.head-actions { display: flex; gap: 8px; align-items: center; }
|
|
152
202
|
.work { display: flex; flex-direction: column; }
|
|
153
203
|
.work > .card { flex: 1; padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
154
|
-
.cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
204
|
+
.cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
155
205
|
.cat-top input[type=search] { flex: 1; min-width: 140px; }
|
|
156
206
|
.cat-top select { flex: none; width: auto; padding-right: 30px; }
|
|
157
207
|
.cat-top button { flex: none; }
|
|
208
|
+
.cat-top .free-only { display: flex; align-items: center; gap: 7px; flex: none; font-size: 13px; color: var(--muted); cursor: pointer; white-space: nowrap; }
|
|
158
209
|
.cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
|
|
159
|
-
.cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
210
|
+
.cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick, .free-only input { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
160
211
|
/* what to do with the ticked models: at the bottom of the list, only while some are ticked */
|
|
161
|
-
.picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--accent-soft); font-size: 13px; }
|
|
212
|
+
.picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); background: var(--accent-soft); font-size: 13px; animation: page-in .18s ease-out; }
|
|
162
213
|
.picks .n { font-weight: 600; margin-right: auto; }
|
|
163
214
|
.prov-h { cursor: pointer; }
|
|
164
215
|
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; }
|
|
@@ -166,25 +217,52 @@
|
|
|
166
217
|
section.collapsed .prov-h .fold { transform: rotate(-90deg); }
|
|
167
218
|
section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
|
|
168
219
|
.cat-list { flex: 1; overflow-y: auto; }
|
|
169
|
-
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
|
220
|
+
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; min-height: 52px; background: var(--panel2); border-bottom: 1px solid var(--line); transition: background .12s; }
|
|
221
|
+
.prov-h:hover { background: color-mix(in srgb, var(--panel2) 70%, var(--hover)); }
|
|
170
222
|
.prov-h .who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
|
171
|
-
.prov-h .name { font-weight:
|
|
172
|
-
.prov-h .meta { font-size:
|
|
223
|
+
.prov-h .name { font-weight: 650; font-size: 14.5px; letter-spacing: -.01em; }
|
|
224
|
+
.prov-h .meta { font-family: var(--mono); font-size: 11.5px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-left: 4px; }
|
|
173
225
|
.prov-h .meta .mono { font-size: 12px; }
|
|
174
226
|
.rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
|
|
175
|
-
.mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; }
|
|
176
|
-
.mrow:hover { background:
|
|
227
|
+
.mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; font-weight: 400; transform: none !important; }
|
|
228
|
+
.mrow:hover:not(:disabled) { background: transparent; }
|
|
177
229
|
.mrow.flash { animation: flash 1.6s ease-out; }
|
|
178
230
|
@keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
|
|
179
|
-
.mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
231
|
+
.mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
180
232
|
.rows > .mrow, .rows > .mwrap { height: 56px; } /* one height: a long list only draws what's in view */
|
|
181
233
|
.mwrap .mrow { height: 100%; }
|
|
182
234
|
.mrow .can { display: block; margin-top: 2px; color: var(--muted); font-family: system-ui, sans-serif; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; }
|
|
183
|
-
.mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
|
235
|
+
.mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; opacity: .55; transition: opacity .12s; }
|
|
236
|
+
.mwrap:hover .mrow .add, .mrow:focus-visible .add { opacity: 1; }
|
|
184
237
|
.mrow.taken .id { color: var(--muted); }
|
|
185
|
-
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
|
|
238
|
+
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); transition: background .12s; }
|
|
239
|
+
.mwrap:hover { background: var(--hover); }
|
|
186
240
|
.mwrap .mrow { border-bottom: 0; }
|
|
187
241
|
.mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
|
|
242
|
+
/* a row's buttons: quiet until the row is pointed at */
|
|
243
|
+
.mwrap .try { flex: none; margin-right: 12px; border-color: transparent; background: transparent; color: var(--muted); }
|
|
244
|
+
.mwrap:hover .try { border-color: var(--line-strong); background: var(--panel); color: var(--text); }
|
|
245
|
+
.prov-h .by-name { display: flex; gap: 6px; flex: none; }
|
|
246
|
+
.prov-h .sw { margin-top: 0; }
|
|
247
|
+
section.off .rows, section.off .prov-h .who { opacity: .45; }
|
|
248
|
+
.mtable li.off > :not(.grip, input, button) { opacity: .45; }
|
|
249
|
+
.prov-h .by-name input { width: 190px; height: 28px; padding: 0 9px; font-size: 12px; font-family: var(--mono); background: transparent; }
|
|
250
|
+
.prov-h .by-name button, .prov-h > button.small { height: 28px; }
|
|
251
|
+
.hello-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
|
252
|
+
.hello-cols pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; line-height: 1.55; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; max-height: 55vh; overflow: auto; }
|
|
253
|
+
#hello-status.ok { color: var(--ok); }
|
|
254
|
+
#hello-status.bad { color: var(--err); }
|
|
255
|
+
dialog#gone { width: min(560px, 92vw); }
|
|
256
|
+
dialog#gone h2 { color: var(--err); font-family: inherit; font-size: 18px; }
|
|
257
|
+
dialog#gone::backdrop { background: rgba(0, 0, 0, .75); }
|
|
258
|
+
@media (max-width: 860px) { .hello-cols { grid-template-columns: 1fr; } }
|
|
259
|
+
#hellos-list { max-height: 60vh; overflow: auto; border: 1px solid var(--line); border-radius: 12px; }
|
|
260
|
+
#hellos-list button { display: flex; justify-content: flex-start; gap: 12px; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; padding: 10px 14px; font-size: 12.5px; font-weight: 400; }
|
|
261
|
+
#hellos-list button:last-child { border-bottom: 0; }
|
|
262
|
+
#hellos-list .id { flex: none; width: 40%; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
263
|
+
#hellos-list .said { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
264
|
+
#hellos-list .ok .said { color: var(--ok); }
|
|
265
|
+
#hellos-list .bad .said { color: var(--err); }
|
|
188
266
|
.mwrap { padding-left: 16px; align-items: center; }
|
|
189
267
|
.mwrap .pick + .mrow { padding-left: 12px; }
|
|
190
268
|
.mwrap .forget:hover { color: var(--err); }
|
|
@@ -199,10 +277,10 @@
|
|
|
199
277
|
.reqs { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: 420px; }
|
|
200
278
|
.req-list { max-height: 70vh; overflow-y: auto; border-right: 1px solid var(--line); }
|
|
201
279
|
.req-detail { max-height: 70vh; overflow-y: auto; padding: 16px 18px; min-width: 0; }
|
|
202
|
-
.rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line);
|
|
203
|
-
.rq:hover { background: var(--hover); }
|
|
204
|
-
.rq.on { background: var(--accent-soft);
|
|
205
|
-
.rq.failed {
|
|
280
|
+
.rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); box-shadow: inset 3px 0 0 transparent; background: none; padding: 11px 16px; cursor: pointer; font-weight: 400; transform: none !important; }
|
|
281
|
+
.rq:hover:not(:disabled) { background: var(--hover); }
|
|
282
|
+
.rq.on, .rq.on:hover:not(:disabled) { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
|
|
283
|
+
.rq.failed { box-shadow: inset 3px 0 0 var(--err); }
|
|
206
284
|
.rq.failed.on { background: var(--err-soft); }
|
|
207
285
|
.rq .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
208
286
|
.rq .m { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -211,7 +289,7 @@
|
|
|
211
289
|
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 8px 0 14px; }
|
|
212
290
|
.kv dt { color: var(--muted); }
|
|
213
291
|
.kv dd { margin: 0; overflow-wrap: anywhere; }
|
|
214
|
-
.req-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius:
|
|
292
|
+
.req-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; line-height: 1.55; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 6px 0 14px; max-height: 360px; overflow: auto; }
|
|
215
293
|
.req-detail details { margin: 4px 0; }
|
|
216
294
|
.req-detail summary { cursor: pointer; font-size: 13px; color: var(--muted); }
|
|
217
295
|
.req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
|
|
@@ -224,15 +302,18 @@
|
|
|
224
302
|
.olist .test button { flex: none; }
|
|
225
303
|
.olist .test.ok { color: var(--ok); }
|
|
226
304
|
.olist .test.warn { color: var(--accent); }
|
|
227
|
-
dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius:
|
|
228
|
-
dialog
|
|
229
|
-
|
|
305
|
+
dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); color: var(--text); padding: 26px 28px 24px; box-shadow: var(--shadow-pop); }
|
|
306
|
+
dialog[open] { animation: pop-in .18s cubic-bezier(.2, .8, .3, 1); }
|
|
307
|
+
@keyframes pop-in { from { opacity: 0; transform: translateY(6px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
308
|
+
dialog::backdrop { background: rgba(8, 9, 12, .55); backdrop-filter: blur(3px); }
|
|
309
|
+
dialog h2 { margin: 0 0 6px; font-size: 18px; font-weight: 650; letter-spacing: -.015em; overflow-wrap: anywhere; }
|
|
230
310
|
dialog#ms .opt:last-of-type { border-bottom: 0; }
|
|
231
311
|
#pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
|
|
232
|
-
#pr-list li { counter-increment: rank; padding:
|
|
312
|
+
#pr-list li { counter-increment: rank; padding: 10px 14px; margin-bottom: 6px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel2); cursor: grab; user-select: none; font-weight: 500; transition: border-color .12s; }
|
|
313
|
+
#pr-list li:hover { border-color: var(--accent); }
|
|
233
314
|
#pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
|
|
234
315
|
#pr-list li.dragging { opacity: .4; }
|
|
235
|
-
dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top:
|
|
316
|
+
dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
|
|
236
317
|
.olist .test.failed { color: var(--err); }
|
|
237
318
|
.olist .test.testing, .olist .test.waiting { color: var(--accent); }
|
|
238
319
|
.olist .test button { padding: 0 7px; font-size: 11px; }
|
|
@@ -257,9 +338,9 @@
|
|
|
257
338
|
body.sorting, body.sorting * { cursor: grabbing !important; }
|
|
258
339
|
/* Your models: a table, one row a model */
|
|
259
340
|
.mt-card { padding: 0; }
|
|
260
|
-
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height:
|
|
341
|
+
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 60px; }
|
|
261
342
|
.mt-top .sorts { flex: 1 1 320px; min-width: 0; color: var(--muted); font-size: 13px; } /* too narrow beside the buttons: a line of its own */
|
|
262
|
-
.mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--
|
|
343
|
+
.mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); border-radius: 8px; padding: 1px 3px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
|
|
263
344
|
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
264
345
|
.mt-scroll { overflow-x: auto; }
|
|
265
346
|
.mt { min-width: 1116px; }
|
|
@@ -273,7 +354,7 @@
|
|
|
273
354
|
.mt-outcomes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
|
|
274
355
|
.mt-outcomes > span:first-child { margin-right: 4px; }
|
|
275
356
|
/* a test result: what it was and how many (a click selects them), and ↻ to test them again */
|
|
276
|
-
.pill { display: inline-flex; align-items: stretch; border: 1px solid var(--line); border-radius:
|
|
357
|
+
.pill { display: inline-flex; align-items: stretch; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); overflow: hidden; font-size: 12px; line-height: 1; }
|
|
277
358
|
.pill button { border: 0; border-radius: 0; background: none; padding: 5px 8px; display: inline-flex; align-items: center; gap: 5px; }
|
|
278
359
|
.pill button:hover:not(:disabled) { background: var(--hover); }
|
|
279
360
|
.pill .what { font-weight: 600; }
|
|
@@ -282,30 +363,36 @@
|
|
|
282
363
|
.pill .n { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
283
364
|
.pill .again { border-left: 1px solid var(--line); padding: 5px 6px; color: var(--muted); }
|
|
284
365
|
.pill .again:hover:not(:disabled) { color: var(--text); }
|
|
285
|
-
.pill.on { border-color: var(--
|
|
286
|
-
.pill.on .again { border-left-color: var(--
|
|
366
|
+
.pill.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
367
|
+
.pill.on .again { border-left-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
|
287
368
|
.mt-h, .mtable li { display: grid; grid-template-columns: 16px 20px 44px minmax(150px, 3fr) minmax(70px, 1.1fr) 66px 66px 42px 60px 50px 54px 60px minmax(166px, 1.5fr) minmax(120px, 1.3fr) 28px; align-items: center; column-gap: 6px; padding: 8px 16px; }
|
|
288
|
-
.mt-h
|
|
289
|
-
.mt-h
|
|
369
|
+
.mtable .src, .mt-h > :nth-child(13) { padding-left: 12px; } /* room after the right-aligned prices */
|
|
370
|
+
.mt-h { border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 500; color: var(--faint); padding-top: 10px; padding-bottom: 10px; background: var(--panel2); }
|
|
371
|
+
.mt-h button { display: inline; border: 0; background: none !important; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; transform: none !important; }
|
|
372
|
+
.mt-h button.num { text-align: right; }
|
|
290
373
|
.mt-h button:hover { color: var(--text); }
|
|
291
|
-
.mt-h button.on { color: var(--
|
|
374
|
+
.mt-h button.on { color: var(--accent); font-weight: 650; }
|
|
292
375
|
.mt-h .num, .mtable .num { text-align: right; }
|
|
293
376
|
.mtable { list-style: none; margin: 0; padding: 0; }
|
|
294
|
-
.mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; }
|
|
377
|
+
.mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; font-variant-numeric: tabular-nums; transition: background .12s; }
|
|
378
|
+
.mtable li.in:hover { background: var(--hover); }
|
|
295
379
|
.mtable li:last-child { border-bottom: 0; }
|
|
296
380
|
.mtable li.in { user-select: none; transition: transform .16s ease, box-shadow .16s ease; }
|
|
297
381
|
.mtable.drag li.in { cursor: grab; }
|
|
298
|
-
.mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow:
|
|
382
|
+
.mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: var(--shadow-pop), 0 0 0 2px var(--accent); }
|
|
299
383
|
.mtable .grip { color: var(--faint); touch-action: none; }
|
|
300
384
|
.mtable:not(.drag) .grip { visibility: hidden; }
|
|
301
|
-
.mtable .n { font-weight:
|
|
385
|
+
.mtable .n { font-weight: 650; color: var(--faint); }
|
|
386
|
+
.mtable li:first-child .n { color: var(--accent); }
|
|
302
387
|
.mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
303
388
|
.mtable .p { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
304
389
|
.mtable .yes { color: var(--ok); }
|
|
305
390
|
.mtable .no { color: var(--faint); }
|
|
306
391
|
.mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
307
392
|
.mtable .src.warn { color: var(--accent); }
|
|
308
|
-
.mtable button { padding:
|
|
393
|
+
.mtable button { padding: 2px 8px; font-size: 11.5px; border-color: transparent; background: transparent; color: var(--muted); }
|
|
394
|
+
.mtable li:hover button, .mtable li.picked button { border-color: var(--line-strong); background: var(--panel); color: var(--text); }
|
|
395
|
+
.mtable li:hover button.danger { color: var(--err); }
|
|
309
396
|
.mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
310
397
|
.mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
311
398
|
.mtable .try.ok { color: var(--ok); }
|
|
@@ -330,28 +417,28 @@
|
|
|
330
417
|
.opt:first-child { padding-top: 0; }
|
|
331
418
|
.opt .t { flex: 1; min-width: 0; }
|
|
332
419
|
.opt b { display: block; }
|
|
333
|
-
.opt span { color: var(--muted); font-size: 13px; }
|
|
420
|
+
.opt span { color: var(--muted); font-size: 13px; line-height: 1.45; }
|
|
334
421
|
.sw { position: relative; width: 38px; height: 22px; flex: none; margin-top: 2px; }
|
|
335
422
|
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
336
|
-
.sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line); transition: background .
|
|
337
|
-
.sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: transform .
|
|
338
|
-
.sw input:checked + i { background: var(--
|
|
423
|
+
.sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line-strong); transition: background .18s; cursor: pointer; }
|
|
424
|
+
.sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .18s cubic-bezier(.3, .7, .4, 1); }
|
|
425
|
+
.sw input:checked + i { background: var(--accent); }
|
|
339
426
|
.sw input:checked + i::after { transform: translateX(16px); }
|
|
340
427
|
.sw input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
341
428
|
|
|
342
429
|
/* choices as cards (permissions, colors) */
|
|
343
430
|
.choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
|
344
|
-
.choice { position: relative; border: 1px solid var(--line); border-radius:
|
|
345
|
-
.choice:hover { border-color: var(--accent); }
|
|
346
|
-
.choice.on { border-color: var(--
|
|
431
|
+
.choice { position: relative; border: 1px solid var(--line-strong); border-radius: 12px; padding: 13px 15px; cursor: pointer; background: var(--panel); transition: border-color .15s, box-shadow .15s, background .15s; }
|
|
432
|
+
.choice:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--line-strong)); }
|
|
433
|
+
.choice.on { border-color: var(--accent); background: var(--accent-soft); box-shadow: var(--ring); }
|
|
347
434
|
.choice b { display: block; }
|
|
348
|
-
.choice span { color: var(--muted); font-size: 12.5px; }
|
|
435
|
+
.choice span { display: block; margin-top: 2px; color: var(--muted); font-size: 12.5px; line-height: 1.45; }
|
|
349
436
|
.choice input { position: absolute; opacity: 0; }
|
|
350
437
|
.swatch { display: flex; gap: 3px; margin-bottom: 8px; }
|
|
351
438
|
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid var(--line); }
|
|
352
439
|
|
|
353
440
|
/* a model's tooltip: shown at once on hover (or keyboard focus), next to the row */
|
|
354
|
-
#tip { position: fixed; z-index: 30; max-width: 380px; padding:
|
|
441
|
+
#tip { position: fixed; z-index: 30; max-width: 380px; padding: 13px 15px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow-pop); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .08s; }
|
|
355
442
|
#tip.show { opacity: 1; }
|
|
356
443
|
#tip .tid { font-family: var(--mono); font-weight: 700; font-size: 13px; word-break: break-all; }
|
|
357
444
|
#tip .tname { color: var(--muted); margin-top: 1px; }
|
|
@@ -360,14 +447,15 @@
|
|
|
360
447
|
#tip dd { margin: 0; }
|
|
361
448
|
#tip .tdesc { color: var(--muted); margin-top: 10px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
362
449
|
#tip .thint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); color: var(--accent); font-weight: 600; }
|
|
363
|
-
#toast { position: fixed; bottom:
|
|
364
|
-
#toast.show { opacity: 1; }
|
|
450
|
+
#toast { position: fixed; bottom: 24px; left: 50%; transform: translate(-50%, 8px); padding: 10px 18px; border-radius: 12px; background: var(--text); color: var(--bg); font-weight: 500; box-shadow: var(--shadow-pop); opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
|
|
451
|
+
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
365
452
|
#toast.err { background: var(--err); color: #fff; }
|
|
366
453
|
/* what's running (tests, loading): a strip at the top of the panel it's about, updated in place */
|
|
367
454
|
.job-slot:empty { display: none; }
|
|
368
455
|
.job-slot.solo { margin: -6px 0 18px; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } /* on its own, under a page's title */
|
|
369
456
|
.job-slot.solo .job:last-child { border-bottom: 0; }
|
|
370
457
|
.job { padding: 10px 16px 0; border-bottom: 1px solid var(--line); background: var(--panel2); font-size: 12.5px; }
|
|
458
|
+
.job .bar i, .job .spin { color: var(--accent); }
|
|
371
459
|
.job .top { display: flex; align-items: center; gap: 10px; min-height: 24px; }
|
|
372
460
|
.job .spin { flex: none; color: var(--brand); }
|
|
373
461
|
.job .what { font-weight: 600; white-space: nowrap; }
|
|
@@ -395,7 +483,9 @@
|
|
|
395
483
|
.brand { padding: 0 0 8px; }
|
|
396
484
|
nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }
|
|
397
485
|
nav a { flex: none; margin: 0; }
|
|
486
|
+
nav a.on::before { display: none; } /* a strip of tabs: the highlighted one is enough */
|
|
398
487
|
nav a .count, .using { display: none; }
|
|
488
|
+
.theme-pick { margin: 6px 0 4px; max-width: 280px; }
|
|
399
489
|
main { padding: 20px 16px 60px; }
|
|
400
490
|
.steps { grid-template-columns: 1fr; }
|
|
401
491
|
.choices { grid-template-columns: 1fr; }
|
|
@@ -412,7 +502,7 @@
|
|
|
412
502
|
<body>
|
|
413
503
|
<div class="app">
|
|
414
504
|
<aside>
|
|
415
|
-
<div class="brand"><span>♥</span> HeartCode settings</div>
|
|
505
|
+
<div class="brand"><span>♥</span> HeartCode <small>settings</small></div>
|
|
416
506
|
<nav id="nav">
|
|
417
507
|
<a href="#providers" data-page="providers"><span class="ic">◉</span> Providers & models <span class="count" id="n-providers"></span></a>
|
|
418
508
|
<a href="#models" data-page="models"><span class="ic">☰</span> Your models <span class="count" id="n-mine"></span></a>
|
|
@@ -424,6 +514,9 @@
|
|
|
424
514
|
<a href="#files" data-page="files"><span class="ic">≡</span> Advanced files</a>
|
|
425
515
|
</nav>
|
|
426
516
|
<div class="using" id="using"></div>
|
|
517
|
+
<div class="seg theme-pick" id="theme-pick" role="radiogroup" aria-label="Light or dark">
|
|
518
|
+
<label><input type="radio" name="theme-pick" value="system">System</label><label><input type="radio" name="theme-pick" value="light">Light</label><label><input type="radio" name="theme-pick" value="dark">Dark</label>
|
|
519
|
+
</div>
|
|
427
520
|
</aside>
|
|
428
521
|
|
|
429
522
|
<main>
|
|
@@ -431,7 +524,7 @@
|
|
|
431
524
|
<!-- Providers & models -->
|
|
432
525
|
<section class="page" id="page-providers">
|
|
433
526
|
<div class="head">
|
|
434
|
-
<div><h1>Providers & models</h1><p class="lead">Click a model to add it to your list, or tick several
|
|
527
|
+
<div><h1>Providers & models</h1><p class="lead">Click a model to add it to your list, or tick several. HeartCode tries your list in order: #1 first, the next when one fails.</p></div>
|
|
435
528
|
<div class="head-actions"><button id="p-mine" type="button"></button><button class="primary" id="p-open">+ Add a provider</button></div>
|
|
436
529
|
</div>
|
|
437
530
|
<div class="steps" id="steps"></div>
|
|
@@ -440,7 +533,8 @@
|
|
|
440
533
|
<div class="cat-top">
|
|
441
534
|
<input type="checkbox" id="pick-shown" aria-label="select every model shown">
|
|
442
535
|
<input type="search" id="m-search" placeholder="Search all models…">
|
|
443
|
-
<
|
|
536
|
+
<label class="free-only" title="only the models their provider lists at $0 (a provider whose list has no prices can't say)"><input type="checkbox" id="free-only">Free only</label>
|
|
537
|
+
<select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price (free first)</option></select>
|
|
444
538
|
<button class="small ghost" id="fold-all" type="button">Collapse all</button>
|
|
445
539
|
<button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button>
|
|
446
540
|
</div>
|
|
@@ -453,7 +547,7 @@
|
|
|
453
547
|
|
|
454
548
|
<!-- Your models: all at once, sortable -->
|
|
455
549
|
<section class="page" id="page-models">
|
|
456
|
-
<div class="head"><div><h1>Your models</h1><p class="lead">Every model
|
|
550
|
+
<div class="head"><div><h1>Your models</h1><p class="lead">Every model HeartCode can use, and what each can do. Drag a row to reorder, or sort by columns (click more to break ties) and save the order you see.</p></div></div>
|
|
457
551
|
<div class="card mt-card" id="mt"></div>
|
|
458
552
|
</section>
|
|
459
553
|
|
|
@@ -535,7 +629,7 @@
|
|
|
535
629
|
</div>
|
|
536
630
|
<div class="card">
|
|
537
631
|
<h2>Voice input</h2>
|
|
538
|
-
<p class="sub"
|
|
632
|
+
<p class="sub">/voice in HeartCode. Empty: whisper-cli on this machine. Or a provider's transcription API: <span class="mono">{ "provider": "groq", "model": "whisper-large-v3" }</span>; add <span class="mono">"language": "auto"</span> for other languages.</p>
|
|
539
633
|
<textarea id="v-text" spellcheck="false" rows="3" placeholder="(whisper-cli on this machine)"></textarea>
|
|
540
634
|
<div class="actions"><button class="primary" id="v-save">Save voice</button></div>
|
|
541
635
|
</div>
|
|
@@ -606,7 +700,7 @@
|
|
|
606
700
|
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
607
701
|
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
608
702
|
<div id="p-more" hidden>
|
|
609
|
-
<label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (
|
|
703
|
+
<label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (the box next to its name).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
|
|
610
704
|
<div class="actions" id="p-added"></div>
|
|
611
705
|
</div>
|
|
612
706
|
<div class="actions"><button class="danger" id="p-delete" hidden>Delete provider</button><span class="spacer"></span><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
@@ -624,6 +718,27 @@
|
|
|
624
718
|
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
625
719
|
</dialog>
|
|
626
720
|
<dialog id="k-form" aria-labelledby="k-title"></dialog>
|
|
721
|
+
<dialog id="gone" aria-labelledby="gone-title">
|
|
722
|
+
<h2 id="gone-title">Disconnected</h2>
|
|
723
|
+
<p class="sub">HeartCode's settings page was turned off, so nothing here can be changed or saved now.</p>
|
|
724
|
+
<p class="sub">To open it again, type <b>/settings</b> in HeartCode, or run <b>heart settings</b> in a terminal: it opens a new page (this one can't reconnect, its key is gone).</p>
|
|
725
|
+
<div class="actions"><span class="spacer"></span><button id="gone-again" type="button">Try again</button></div>
|
|
726
|
+
</dialog>
|
|
727
|
+
<dialog id="hello" aria-labelledby="hello-title">
|
|
728
|
+
<h2 id="hello-title"></h2>
|
|
729
|
+
<p class="sub" id="hello-said"></p>
|
|
730
|
+
<div class="hello-cols">
|
|
731
|
+
<div><h3>Sent</h3><pre id="hello-sent"></pre></div>
|
|
732
|
+
<div><h3>Received <span id="hello-status"></span></h3><pre id="hello-got"></pre></div>
|
|
733
|
+
</div>
|
|
734
|
+
<div class="actions"><button id="hello-again" type="button">Send again</button><span class="spacer"></span><button class="primary" id="hello-close" type="button">Close</button></div>
|
|
735
|
+
</dialog>
|
|
736
|
+
<dialog id="hellos" aria-labelledby="hellos-title">
|
|
737
|
+
<h2 id="hellos-title"></h2>
|
|
738
|
+
<p class="sub" id="hellos-said"></p>
|
|
739
|
+
<div id="hellos-list"></div>
|
|
740
|
+
<div class="actions"><button id="hellos-ok" type="button">Tick the ones that answered</button><span class="spacer"></span><button class="primary" id="hellos-close" type="button">Close</button></div>
|
|
741
|
+
</dialog>
|
|
627
742
|
<dialog id="pr" aria-labelledby="pr-title">
|
|
628
743
|
<form>
|
|
629
744
|
<h2 id="pr-title">Provider ranking</h2>
|
|
@@ -636,6 +751,20 @@
|
|
|
636
751
|
<div id="tip" role="tooltip"></div>
|
|
637
752
|
<script>
|
|
638
753
|
const token = new URLSearchParams(location.search).get('t')
|
|
754
|
+
// Light, dark, or as the system is set: kept in this browser (the one thing the page keeps itself)
|
|
755
|
+
{
|
|
756
|
+
const pick = document.getElementById('theme-pick')
|
|
757
|
+
const now = () => document.documentElement.dataset.theme || 'system'
|
|
758
|
+
const show = () => pick.querySelectorAll('label').forEach((l) => { const i = l.querySelector('input'); i.checked = i.value === now(); l.classList.toggle('on', i.checked) })
|
|
759
|
+
pick.addEventListener('change', (e) => {
|
|
760
|
+
const v = e.target.value
|
|
761
|
+
if (v === 'system') delete document.documentElement.dataset.theme
|
|
762
|
+
else document.documentElement.dataset.theme = v
|
|
763
|
+
try { v === 'system' ? localStorage.removeItem('heart-theme') : localStorage.setItem('heart-theme', v) } catch {}
|
|
764
|
+
show()
|
|
765
|
+
})
|
|
766
|
+
show()
|
|
767
|
+
}
|
|
639
768
|
const $ = (id) => document.getElementById(id)
|
|
640
769
|
const h = (tag, props = {}, ...kids) => {
|
|
641
770
|
const el = document.createElement(tag)
|
|
@@ -691,8 +820,22 @@ function fillSlot(slot) {
|
|
|
691
820
|
}
|
|
692
821
|
return slot
|
|
693
822
|
}
|
|
823
|
+
// The settings server turned off (or this page's key no longer works there): the page says so and
|
|
824
|
+
// is blocked, nothing can be changed. Still there (a moment's network trouble): it goes on.
|
|
825
|
+
const reachable = () => fetch(`/?t=${token}`, { cache: 'no-store' }).then((r) => r.ok, () => false)
|
|
826
|
+
async function checkGone() {
|
|
827
|
+
if (await reachable()) return $('gone').open && $('gone').close()
|
|
828
|
+
if ($('gone').open) return
|
|
829
|
+
for (const d of document.querySelectorAll('dialog[open]')) d.close()
|
|
830
|
+
$('gone').showModal()
|
|
831
|
+
}
|
|
832
|
+
$('gone').addEventListener('cancel', (e) => e.preventDefault()) // Esc doesn't close it
|
|
833
|
+
$('gone-again').onclick = checkGone
|
|
694
834
|
async function api(path, body) {
|
|
695
|
-
const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) })
|
|
835
|
+
const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }).catch((e) => {
|
|
836
|
+
checkGone()
|
|
837
|
+
throw new Error("the settings page is off: HeartCode isn't listening")
|
|
838
|
+
})
|
|
696
839
|
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
697
840
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
698
841
|
return data
|
|
@@ -702,6 +845,7 @@ const DOING = {
|
|
|
702
845
|
'/api/provider': 'Checking the address and saving the provider',
|
|
703
846
|
'/api/provider/delete': 'Deleting the provider',
|
|
704
847
|
'/api/provider/list': 'Saving the model list setting',
|
|
848
|
+
'/api/provider/on': 'Switching the provider',
|
|
705
849
|
'/api/order': 'Saving your models',
|
|
706
850
|
'/api/models-dev': null, // its own strip
|
|
707
851
|
}
|
|
@@ -951,6 +1095,14 @@ let modelQuery = ''
|
|
|
951
1095
|
// storage isn't: the page's address has a new port each time)
|
|
952
1096
|
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
953
1097
|
let catSort = 'name'
|
|
1098
|
+
// only the models their provider lists at $0 (what its own list says: the price shown under each)
|
|
1099
|
+
let freeOnly = false
|
|
1100
|
+
const isFree = (d) => Boolean(d?.price) && !d.price.input && !d.price.output
|
|
1101
|
+
$('free-only').onchange = (e) => {
|
|
1102
|
+
freeOnly = e.target.checked
|
|
1103
|
+
savePref('freeOnly', freeOnly)
|
|
1104
|
+
if (S) drawProviders(S)
|
|
1105
|
+
}
|
|
954
1106
|
$('m-sort').onchange = (e) => {
|
|
955
1107
|
catSort = e.target.value
|
|
956
1108
|
savePref('catSort', catSort)
|
|
@@ -1187,19 +1339,22 @@ function drawProviders(s) {
|
|
|
1187
1339
|
$('steps').hidden = order.length > 0 // the first start only
|
|
1188
1340
|
$('p-work').hidden = none
|
|
1189
1341
|
// your list, at the top: how many, and the way to its page (order, tests, what to do when one fails)
|
|
1190
|
-
|
|
1191
|
-
$('p-mine').
|
|
1192
|
-
$('p-mine').
|
|
1342
|
+
const shown = shownOrder()
|
|
1343
|
+
$('p-mine').hidden = !shown.length
|
|
1344
|
+
$('p-mine').textContent = `Your models (${shown.length}) →`
|
|
1345
|
+
$('p-mine').title = shown.length ? `HeartCode uses ${shown[0].provider} · ${shown[0].model}. Open the Your models page: the order, tests, and what happens when one fails` : ''
|
|
1193
1346
|
fillHeight()
|
|
1194
1347
|
|
|
1195
1348
|
// every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1196
1349
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1197
|
-
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
|
|
1198
|
-
|
|
1350
|
+
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
|
|
1351
|
+
// (but one of yours a provider doesn't list gets a row of its own).
|
|
1352
|
+
const unlisted = order.filter((t) => !(MODELS[t.provider]?.list ?? []).includes(t.model)).map(keyOf).sort()
|
|
1353
|
+
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort, freeOnly, unlisted])
|
|
1199
1354
|
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
1200
1355
|
catalogShape = shape
|
|
1201
1356
|
markCatalog()
|
|
1202
|
-
$('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models
|
|
1357
|
+
$('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models…`
|
|
1203
1358
|
$('n-providers').textContent = s.providers.length || ''
|
|
1204
1359
|
}
|
|
1205
1360
|
// Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
|
|
@@ -1317,9 +1472,22 @@ const addModel = (provider, id) => {
|
|
|
1317
1472
|
const order = S.order
|
|
1318
1473
|
return order.length ? saveOrder([...order, { provider, model: id }], `${id} added as #${order.length + 1}`) : useModel(provider, id)
|
|
1319
1474
|
}
|
|
1475
|
+
// a provider switched off: its models stay in your list, but HeartCode skips them; the one in use is
|
|
1476
|
+
// the first of a provider that's on
|
|
1477
|
+
const isOff = (name) => Boolean(S?.providers.find((p) => p.name === name)?.off)
|
|
1478
|
+
const inUse = () => S.order.find((t) => !isOff(t.provider))
|
|
1479
|
+
// Your models as shown: none of a provider that's switched off (they come back when it's on again)
|
|
1480
|
+
const shownOrder = () => S.order.filter((t) => !isOff(t.provider))
|
|
1481
|
+
// An order saved from what's shown: the models of a provider that's off keep their places in yours
|
|
1482
|
+
const withOff = (shown) => {
|
|
1483
|
+
const rest = shown[Symbol.iterator]()
|
|
1484
|
+
return [...S.order.map((t) => (isOff(t.provider) ? t : rest.next().value)).filter(Boolean), ...rest]
|
|
1485
|
+
}
|
|
1320
1486
|
function rowTail(provider, id) {
|
|
1321
|
-
|
|
1322
|
-
|
|
1487
|
+
if (placeOf(provider, id) >= 0 && isOff(provider)) return h('span', { className: 'tag', textContent: 'in your list · off', title: `${provider} is switched off: HeartCode skips its models (and Your models doesn't show them) until it's on` })
|
|
1488
|
+
const at = shownOrder().findIndex((t) => t.provider === provider && t.model === id) // its place among the ones HeartCode uses
|
|
1489
|
+
const now = inUse()
|
|
1490
|
+
return now?.provider === provider && now.model === id ? h('span', { className: 'tag brand', textContent: `#${at + 1} in use` }) : at >= 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
|
|
1323
1491
|
}
|
|
1324
1492
|
// what a listed model can do: as tested or set up, else what models.dev says (one of yours: its
|
|
1325
1493
|
// profile, also when the provider's list is off)
|
|
@@ -1333,6 +1501,80 @@ function canCell(d, mine) {
|
|
|
1333
1501
|
const text = [can, priceText(price)].filter(Boolean).join(' · ')
|
|
1334
1502
|
return text ? h('span', { className: 'can', textContent: text }) : null
|
|
1335
1503
|
}
|
|
1504
|
+
// Test on a model's row: "Hello" sent to it (by the server), and the request and the answer shown.
|
|
1505
|
+
// r: an answer already had (from Test on the ticked ones): shown, not sent again.
|
|
1506
|
+
let helloOf = null
|
|
1507
|
+
async function hello(provider, model, had) {
|
|
1508
|
+
helloOf = { provider, model }
|
|
1509
|
+
const show = (sent, got, status, cls) => {
|
|
1510
|
+
$('hello-sent').textContent = sent
|
|
1511
|
+
$('hello-got').textContent = got
|
|
1512
|
+
$('hello-status').textContent = status
|
|
1513
|
+
$('hello-status').className = cls
|
|
1514
|
+
}
|
|
1515
|
+
$('hello-title').textContent = `${provider} · ${model}`
|
|
1516
|
+
$('hello-said').textContent = 'Sending "Hello"…'
|
|
1517
|
+
show('', '', '', '')
|
|
1518
|
+
$('hello-again').disabled = true
|
|
1519
|
+
if (!$('hello').open) $('hello').showModal()
|
|
1520
|
+
const asked = helloOf
|
|
1521
|
+
const r = had ?? (await sendHello(provider, model))
|
|
1522
|
+
if (asked !== helloOf) return // another one asked meanwhile
|
|
1523
|
+
$('hello-again').disabled = false
|
|
1524
|
+
if (r.failed) return ($('hello-said').textContent = `Not sent: ${r.failed}`)
|
|
1525
|
+
const json = (x) => (typeof x === 'string' ? x : JSON.stringify(x, null, 2))
|
|
1526
|
+
const s = r.sent
|
|
1527
|
+
const g = r.received
|
|
1528
|
+
show(`${s.method} ${s.url}\n${Object.entries(s.headers).map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${json(s.body)}`,
|
|
1529
|
+
g.error ? g.error : `${g.type ? `content-type: ${g.type}\n\n` : ''}${json(g.body) || '(empty)'}`,
|
|
1530
|
+
g.error ? '· no answer' : `· ${g.status} · ${g.ms} ms`, g.ok ? 'ok' : 'bad')
|
|
1531
|
+
$('hello-said').textContent = helloSaid(r, 200)
|
|
1532
|
+
}
|
|
1533
|
+
const sendHello = (provider, model) => api('/api/hello', { provider, model }).catch((e) => ({ failed: e.message }))
|
|
1534
|
+
const helloSaid = (r, n) => {
|
|
1535
|
+
const g = r.received
|
|
1536
|
+
return r.failed ? `Not sent: ${r.failed}` : g.error ? `It didn't answer: ${g.error}` : !g.ok ? `The provider refused it (${g.status}).` : r.reply ? `It answered: ${r.reply.length > n ? r.reply.slice(0, n) + '…' : r.reply}` : 'It answered, but with no text in choices[0].message.content.'
|
|
1537
|
+
}
|
|
1538
|
+
const answered = (r) => Boolean(r?.received?.ok && r.reply)
|
|
1539
|
+
// Test on the ticked models: "Hello" to each, a provider's one after another (its rate limit), the
|
|
1540
|
+
// providers at once. Each row: what came back; clicked, what was sent and received. Closing the
|
|
1541
|
+
// dialog stops the ones not sent yet.
|
|
1542
|
+
let hellos = null
|
|
1543
|
+
async function helloAll(list) {
|
|
1544
|
+
const run = (hellos = { list: list.map((t) => ({ ...t, r: null })) })
|
|
1545
|
+
$('hellos-title').textContent = `Hello to ${list.length} model${list.length === 1 ? '' : 's'}`
|
|
1546
|
+
const draw = () => {
|
|
1547
|
+
if (run !== hellos) return
|
|
1548
|
+
const done = run.list.filter((t) => t.r)
|
|
1549
|
+
const ok = done.filter((t) => answered(t.r)).length
|
|
1550
|
+
$('hellos-said').textContent = `${done.length} of ${run.list.length} done${done.length ? ` · ${ok} answered, ${done.length - ok} didn't` : ''}${done.length < run.list.length ? ' · sending…' : ''}`
|
|
1551
|
+
$('hellos-ok').disabled = !ok
|
|
1552
|
+
put($('hellos-list'), run.list.map((t) => h('button', { type: 'button', className: !t.r ? '' : answered(t.r) ? 'ok' : 'bad', title: t.r ? 'see what was sent and what came back' : 'not answered yet', disabled: !t.r, onclick: () => hello(t.provider, t.model, t.r) },
|
|
1553
|
+
h('span', { className: 'id', textContent: keyOf(t) }),
|
|
1554
|
+
h('span', { className: 'said', textContent: t.r ? helloSaid(t.r, 120) : 'waiting…' }))))
|
|
1555
|
+
}
|
|
1556
|
+
draw()
|
|
1557
|
+
if (!$('hellos').open) $('hellos').showModal()
|
|
1558
|
+
const byProvider = Map.groupBy(run.list, (t) => t.provider)
|
|
1559
|
+
await Promise.all([...byProvider.values()].map(async (ts) => {
|
|
1560
|
+
for (const t of ts) {
|
|
1561
|
+
if (run !== hellos) return
|
|
1562
|
+
t.r = await sendHello(t.provider, t.model)
|
|
1563
|
+
draw()
|
|
1564
|
+
}
|
|
1565
|
+
}))
|
|
1566
|
+
}
|
|
1567
|
+
$('hellos-close').onclick = () => $('hellos').close()
|
|
1568
|
+
$('hellos').addEventListener('close', () => (hellos = null))
|
|
1569
|
+
// the ticks: the ones that answered, only
|
|
1570
|
+
$('hellos-ok').onclick = () => {
|
|
1571
|
+
PICKED_CAT.clear()
|
|
1572
|
+
for (const t of hellos?.list ?? []) if (answered(t.r)) pickedOf(t.provider).add(t.model)
|
|
1573
|
+
$('hellos').close()
|
|
1574
|
+
drawPicks()
|
|
1575
|
+
}
|
|
1576
|
+
$('hello-again').onclick = () => helloOf && hello(helloOf.provider, helloOf.model)
|
|
1577
|
+
$('hello-close').onclick = () => $('hello').close()
|
|
1336
1578
|
// every model added to a provider by name forgotten; the ones in your list leave it too
|
|
1337
1579
|
async function clearAdded(p) {
|
|
1338
1580
|
const inList = S.order.filter((t) => t.provider === p.name && p.added.includes(t.model))
|
|
@@ -1382,14 +1624,13 @@ function drawPicks() {
|
|
|
1382
1624
|
put($('picks'),
|
|
1383
1625
|
h('span', { className: 'n', textContent: `${all.length} selected` }),
|
|
1384
1626
|
h('button', { className: 'small ghost', textContent: 'Clear', onclick: () => (PICKED_CAT.clear(), drawPicks()) }),
|
|
1627
|
+
h('button', { className: 'small', textContent: `Test ${all.length}`, title: 'send each ticked model "Hello" and see which answer', onclick: () => helloAll(all) }),
|
|
1385
1628
|
toTake.length ? h('button', { className: 'small danger', textContent: `Remove ${toTake.length} from your list`, onclick: take }) : null,
|
|
1386
1629
|
toAdd.length ? h('button', { className: 'small primary', textContent: `Add ${toAdd.length} to your list`, onclick: () => (PICKED_CAT.clear(), saveOrder([...S.order, ...toAdd], `${toAdd.length} added: your list has ${S.order.length + toAdd.length} models`)) }) : null)
|
|
1387
1630
|
if (was !== $('picks').hidden) fillHeight() // the list gives it room
|
|
1388
1631
|
}
|
|
1389
1632
|
function buildCatalog(s) {
|
|
1390
1633
|
const q = modelQuery.trim()
|
|
1391
|
-
// a name no provider lists (anywhere): offered under each provider, to use it anyway
|
|
1392
|
-
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
1393
1634
|
SHOWN.clear()
|
|
1394
1635
|
put($('p-list'), s.providers.map((p) => {
|
|
1395
1636
|
if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
|
|
@@ -1398,36 +1639,56 @@ function buildCatalog(s) {
|
|
|
1398
1639
|
// and the models added by name (and any of yours it doesn't list), marked as yours
|
|
1399
1640
|
const listed = new Set(m.list ?? [])
|
|
1400
1641
|
const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
|
|
1401
|
-
const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
|
|
1642
|
+
const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()) && (!freeOnly || isFree(m.details?.[id])))
|
|
1402
1643
|
const row = (id, own) =>
|
|
1403
1644
|
h('button', { className: 'mrow', 'data-provider': p.name, 'data-model': id, onclick: () => (placeOf(p.name, id) === undefined ? addModel(p.name, id) : removeMine(p.name, id)) },
|
|
1404
1645
|
h('span', { className: 'id', title: id }, id, canCell(m.details?.[id], S.order[placeOf(p.name, id)])),
|
|
1646
|
+
isFree(m.details?.[id]) ? h('span', { className: 'tag ok', textContent: 'free', title: `${p.name}'s model list says $0` }) : null,
|
|
1405
1647
|
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1406
1648
|
rowTail(p.name, id))
|
|
1407
1649
|
// its tick box (to add or take off several at once), and one you added: ✕ forgets it (it leaves your list too)
|
|
1408
1650
|
const tick = (id) => h('input', { type: 'checkbox', className: 'pick', checked: pickedOf(p.name).has(id), 'data-model': id, 'aria-label': `select ${id}`, onchange: (e) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id), drawPicks()) })
|
|
1409
|
-
const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id),
|
|
1651
|
+
const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id),
|
|
1652
|
+
h('button', { className: 'small try', textContent: 'Test', title: `send ${id} "Hello" and see what was sent and what came back`, onclick: () => hello(p.name, id) }),
|
|
1653
|
+
!p.added.includes(id) ? null :
|
|
1410
1654
|
h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
|
|
1411
1655
|
if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
|
|
1412
1656
|
if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
|
|
1413
1657
|
act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
|
|
1414
1658
|
} }))
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1659
|
+
// a model by its name, into your list; one it has already: said, and shown
|
|
1660
|
+
const addByName = (e) => {
|
|
1661
|
+
e.preventDefault()
|
|
1662
|
+
const box = e.target.elements.name
|
|
1663
|
+
const id = box.value.trim()
|
|
1664
|
+
if (!id) return
|
|
1665
|
+
const at = placeOf(p.name, id)
|
|
1666
|
+
if (at !== undefined) return showRow(p.name, id, `${id} is already in your list (#${at + 1})`)
|
|
1667
|
+
if (listed.has(id) || yours.includes(id)) return showRow(p.name, id, `${p.name} already has ${id}: click it to add it`)
|
|
1668
|
+
box.value = ''
|
|
1669
|
+
addModel(p.name, id)
|
|
1670
|
+
}
|
|
1671
|
+
if (q && !ids.length) return null // searching: only the providers with a match
|
|
1672
|
+
if (freeOnly && !ids.length) return null // free only: the providers with a free one
|
|
1673
|
+
return h('section', { 'data-provider': p.name, className: p.off ? 'off' : '' },
|
|
1419
1674
|
// the header folds it (a click on it, or its arrow; not on its buttons and switch)
|
|
1420
1675
|
h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
|
|
1421
1676
|
h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
|
|
1422
1677
|
ids.length ? h('input', { type: 'checkbox', className: 'pick-all', 'data-provider': p.name, 'aria-label': `select ${p.name}'s models`, title: `select all of ${p.name}'s models shown (or none)`, onchange: (e) => (ids.forEach((id) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id))), drawPicks()) }) : null,
|
|
1678
|
+
h('label', { className: 'sw', title: p.off ? `switched off: HeartCode skips ${p.name}'s models. Switch it on` : `switch ${p.name} off: HeartCode skips its models (nothing is deleted)` },
|
|
1679
|
+
h('input', { type: 'checkbox', checked: !p.off, 'aria-label': `${p.name} on`, onchange: (e) => act('/api/provider/on', { name: p.name, on: e.target.checked }, e.target.checked ? `${p.name} is on` : `${p.name} is off: HeartCode skips its models`) }),
|
|
1680
|
+
h('i')),
|
|
1423
1681
|
h('div', { className: 'who' },
|
|
1424
1682
|
h('span', { className: 'name', textContent: p.name }),
|
|
1425
|
-
|
|
1683
|
+
p.off ? h('span', { className: 'tag', textContent: 'off' }) : null,
|
|
1684
|
+
m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: add a model by its name, in the box on the right" }) : m.loading ? h('span', { className: 'tag', textContent: 'loading…' }) : m.error ? h('span', { className: 'tag err', textContent: "can't list its models", title: m.error }) : h('span', { className: 'tag', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
|
|
1426
1685
|
h('span', { className: 'tag inlist' }),
|
|
1427
1686
|
h('span', { className: 'meta mono', textContent: p.baseUrl, title: `${p.baseUrl} · key ${p.key || 'none'}` })),
|
|
1687
|
+
h('form', { className: 'by-name', onsubmit: addByName },
|
|
1688
|
+
h('input', { type: 'text', name: 'name', placeholder: 'model name', 'aria-label': `add a model to ${p.name} by its name`, title: `a model's exact name: added to your list, also one ${p.name} doesn't list` }),
|
|
1689
|
+
h('button', { className: 'small', textContent: 'Add' })),
|
|
1428
1690
|
h('button', { className: 'small', textContent: 'Edit', title: 'its address and key, its model list, delete it', onclick: () => openForm(p) })),
|
|
1429
1691
|
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1430
|
-
typed.length ? h('div', { className: 'rows' }, typed) : null,
|
|
1431
1692
|
(() => {
|
|
1432
1693
|
if (!ids.length) return null
|
|
1433
1694
|
SHOWN.set(p.name, ids)
|
|
@@ -1436,10 +1697,11 @@ function buildCatalog(s) {
|
|
|
1436
1697
|
box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
|
|
1437
1698
|
return box
|
|
1438
1699
|
})(),
|
|
1439
|
-
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or
|
|
1440
|
-
m.off && !ids.length
|
|
1700
|
+
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or add a model by its name, in the box above." })) : null,
|
|
1701
|
+
m.off && !ids.length ? h('div', { className: 'cat-note', textContent: "Its list is off: add a model by its name, in the box above." }) : null,
|
|
1441
1702
|
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1442
1703
|
}))
|
|
1704
|
+
if (freeOnly && !$('p-list').querySelector('section')) put($('p-list'), h('div', { className: 'cat-note', textContent: "None of your providers lists a model at $0. (A provider whose model list has no prices can't say what's free: Free only leaves those out.)" }))
|
|
1443
1705
|
}
|
|
1444
1706
|
// --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
|
|
1445
1707
|
// for the ones before it), the sort kept in this browser ---
|
|
@@ -1461,7 +1723,7 @@ let SORT = []
|
|
|
1461
1723
|
// your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
|
|
1462
1724
|
let RANK = []
|
|
1463
1725
|
const ranked = () => {
|
|
1464
|
-
const names = [...new Set(
|
|
1726
|
+
const names = [...new Set(shownOrder().map((t) => t.provider))]
|
|
1465
1727
|
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
1466
1728
|
}
|
|
1467
1729
|
// the ranking dialog: drag a provider to its place, then Save
|
|
@@ -1521,7 +1783,7 @@ const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your rankin
|
|
|
1521
1783
|
const PICKED = new Set()
|
|
1522
1784
|
let lastPicked = null
|
|
1523
1785
|
function drawMine(s) {
|
|
1524
|
-
const order =
|
|
1786
|
+
const order = shownOrder() // a provider switched off: its models aren't shown (nor tested, sorted, ranked)
|
|
1525
1787
|
$('n-mine').textContent = order.length || ''
|
|
1526
1788
|
const view = sorted(order)
|
|
1527
1789
|
for (const k of PICKED) if (placeOf(...k.split(/\/(.*)/s)) === undefined) PICKED.delete(k) // gone from the list
|
|
@@ -1530,7 +1792,7 @@ function drawMine(s) {
|
|
|
1530
1792
|
const n = order.length - rest.length
|
|
1531
1793
|
if (!confirm(`Take ${n} model${n === 1 ? '' : 's'} off your list?${!rest.length ? ' None is left then: HeartCode has no model to use until you choose one.' : rest[0] !== order[0] ? ` HeartCode then uses ${rest[0].provider} · ${rest[0].model}.` : ''} (They stay under their providers.)`)) return
|
|
1532
1794
|
PICKED.clear()
|
|
1533
|
-
saveOrder(rest, `${n} removed${rest.length ? '' : ': no model chosen now'}`)
|
|
1795
|
+
saveOrder(withOff(rest), `${n} removed${rest.length ? '' : ': no model chosen now'}`)
|
|
1534
1796
|
}
|
|
1535
1797
|
const all = view.length > 0 && view.every((t) => PICKED.has(keyOf(t)))
|
|
1536
1798
|
// the tests' results, by outcome: a click selects those models (again: unselects them)
|
|
@@ -1548,7 +1810,7 @@ function drawMine(s) {
|
|
|
1548
1810
|
const keep = (list, moved) => {
|
|
1549
1811
|
SORT = []
|
|
1550
1812
|
savePref('sort', SORT)
|
|
1551
|
-
return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1813
|
+
return saveOrder(withOff(list), list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1552
1814
|
}
|
|
1553
1815
|
// a row dragged (in the order shown, sorted or not)
|
|
1554
1816
|
const move = (i, j) => {
|
|
@@ -1573,13 +1835,13 @@ function drawMine(s) {
|
|
|
1573
1835
|
lastPicked = i
|
|
1574
1836
|
drawMine(S)
|
|
1575
1837
|
}
|
|
1576
|
-
const tableV = table && virtual(table, { count: view.length, height: TABLE_ROW, row: (i) => tableRow(view[i],
|
|
1838
|
+
const tableV = table && virtual(table, { count: view.length, height: TABLE_ROW, row: (i) => tableRow(view[i], order.indexOf(view[i]), (e) => pick(i, e)), scroller: window })
|
|
1577
1839
|
if (tableV) tableV.items = view.length
|
|
1578
1840
|
put($('mt'),
|
|
1579
1841
|
h('div', { className: 'mt-top' },
|
|
1580
1842
|
h('div', { className: 'sorts' }, SORT.length
|
|
1581
1843
|
? ['Sorted by ', SORT.map(({ key, dir }, k) => h('span', { className: 'chip' }, `${k + 1}. ${COLS[key].label}: ${dirText(key, dir)}`, h('button', { type: 'button', textContent: '✕', 'aria-label': `stop sorting by ${COLS[key].label}`, onclick: () => setSort(SORT.filter((x) => x.key !== key)) })))]
|
|
1582
|
-
: order.length > 1 ? '
|
|
1844
|
+
: order.length > 1 ? 'Your order: #1 first, the next when one fails.' : ''),
|
|
1583
1845
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1584
1846
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1585
1847
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
@@ -1588,17 +1850,17 @@ function drawMine(s) {
|
|
|
1588
1850
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1589
1851
|
order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
|
|
1590
1852
|
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
1591
|
-
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1853
|
+
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll(order) }) : null),
|
|
1592
1854
|
// when the one in use fails, and what a new message starts with
|
|
1593
1855
|
order.length > 1 ? h('div', { className: 'mt-rules' },
|
|
1594
1856
|
h('span', { className: 'rule' },
|
|
1595
1857
|
h('label', { for: 'r-on', textContent: 'When a model fails, go on to the next' }),
|
|
1596
|
-
h('select', { id: 'r-on', onchange: (e) => saveOrder(order, 'saved', { on: e.target.value }) },
|
|
1858
|
+
h('select', { id: 'r-on', onchange: (e) => saveOrder(S.order, 'saved', { on: e.target.value }) },
|
|
1597
1859
|
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1598
|
-
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1860
|
+
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(S.order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1599
1861
|
h('span', { className: 'rule' },
|
|
1600
1862
|
h('label', { for: 'r-next', textContent: 'A new message starts with' }),
|
|
1601
|
-
h('select', { id: 'r-next', onchange: (e) => saveOrder(order, 'saved', { next: e.target.value }) },
|
|
1863
|
+
h('select', { id: 'r-next', onchange: (e) => saveOrder(S.order, 'saved', { next: e.target.value }) },
|
|
1602
1864
|
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))),
|
|
1603
1865
|
h('span', { className: 'hint', textContent: s.rotation.on === 'retries' ? 'A rate limit or outage: that model is tried again that many times first; any other error moves on at once.' : "Any error moves on at once, to another provider's model first." })) : null,
|
|
1604
1866
|
order.length
|
|
@@ -1626,12 +1888,12 @@ function tableRow(t, at, pick) {
|
|
|
1626
1888
|
const c = can(t)
|
|
1627
1889
|
const ctx = COLS.context.get(t)
|
|
1628
1890
|
const picked = PICKED.has(keyOf(t))
|
|
1629
|
-
return h('li', { className: 'in' + (picked ? ' picked' : ''), 'data-key': `${t.provider}/${t.model}` },
|
|
1891
|
+
return h('li', { className: 'in' + (picked ? ' picked' : '') + (isOff(t.provider) ? ' off' : ''), 'data-key': `${t.provider}/${t.model}`, title: isOff(t.provider) ? `${t.provider} is switched off: HeartCode skips this model until it's on` : '' },
|
|
1630
1892
|
h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
|
|
1631
1893
|
h('input', { type: 'checkbox', checked: picked, 'aria-label': `select ${t.model}`, title: 'select (Shift: and every row up to the last one selected)', onclick: (e) => (e.preventDefault(), pick(e)) }),
|
|
1632
1894
|
h('span', { className: 'n', textContent: `#${at + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
|
|
1633
1895
|
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1634
|
-
h('span', { className: 'p', textContent: t.provider, title: t.provider }),
|
|
1896
|
+
h('span', { className: 'p', textContent: isOff(t.provider) ? `${t.provider} · off` : t.provider, title: t.provider }),
|
|
1635
1897
|
h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
|
|
1636
1898
|
h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
|
|
1637
1899
|
yes(c.tools), yes(c.thinking), yes(c.images),
|
|
@@ -1653,15 +1915,15 @@ function markCatalog() {
|
|
|
1653
1915
|
const n = S.order.filter((t) => t.provider === sec.dataset.provider).length
|
|
1654
1916
|
const tag = sec.querySelector('.inlist')
|
|
1655
1917
|
tag.hidden = !n
|
|
1656
|
-
tag.textContent = `${n} in your list${
|
|
1918
|
+
tag.textContent = `${n} in your list${inUse()?.provider === sec.dataset.provider ? ' · in use' : ''}`
|
|
1657
1919
|
}
|
|
1658
1920
|
drawPicks()
|
|
1659
1921
|
}
|
|
1660
1922
|
|
|
1661
1923
|
function drawUsing(s) {
|
|
1662
|
-
const o = s.order
|
|
1924
|
+
const o = s.order.filter((t) => !isOff(t.provider))
|
|
1663
1925
|
$('using').hidden = page() === 'providers' // there it's on the page itself
|
|
1664
|
-
put($('using'), o.length ? [h('span', { className: 'k', textContent: 'HeartCode uses' }), h('b', { textContent: o[0].model }), h('span', { className: 'k', textContent: `${o[0].provider}${o.length > 1 ? ` · ${o.length - 1} fallback${o.length > 2 ? 's' : ''}` : ''}` })] : [h('span', { className: 'k', textContent: 'No model chosen yet' }), h('a', { href: '#providers', textContent: 'Choose one' })])
|
|
1926
|
+
put($('using'), o.length ? [h('span', { className: 'k', textContent: 'HeartCode uses' }), h('b', { textContent: o[0].model }), h('span', { className: 'k', textContent: `${o[0].provider}${o.length > 1 ? ` · ${o.length - 1} fallback${o.length > 2 ? 's' : ''}` : ''}` })] : s.order.length ? [h('span', { className: 'k', textContent: 'The providers of all your models are switched off' }), h('a', { href: '#providers', textContent: 'Switch one on' })] : [h('span', { className: 'k', textContent: 'No model chosen yet' }), h('a', { href: '#providers', textContent: 'Choose one' })])
|
|
1665
1927
|
}
|
|
1666
1928
|
|
|
1667
1929
|
// --- General ---
|
|
@@ -1958,6 +2220,8 @@ function render(s) {
|
|
|
1958
2220
|
const p = s.prefs
|
|
1959
2221
|
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1960
2222
|
$('m-sort').value = catSort
|
|
2223
|
+
freeOnly = p.freeOnly === true
|
|
2224
|
+
$('free-only').checked = freeOnly
|
|
1961
2225
|
FOLDED.clear()
|
|
1962
2226
|
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1963
2227
|
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
|
@@ -1993,6 +2257,8 @@ else {
|
|
|
1993
2257
|
// A model's test, as it goes: the server streams each change; only its line in your list changes.
|
|
1994
2258
|
if (token) {
|
|
1995
2259
|
const events = new EventSource(`/api/events?t=${token}`)
|
|
2260
|
+
events.addEventListener('error', checkGone) // the server went away (turned off), or the connection did for a moment
|
|
2261
|
+
events.addEventListener('open', () => $('gone').open && checkGone())
|
|
1996
2262
|
// a request logged: at the top of the list, nothing else redrawn
|
|
1997
2263
|
// the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
|
|
1998
2264
|
// when it closes (what's typed in it stays)
|