heartcode 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>HeartCode Settings</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f5f6f8; --panel: #ffffff; --panel2: #f9fafb; --text: #1f2328; --muted: #57606a; --faint: #8c959f; --line: #dde2e8;
|
|
10
|
+
--brand: #1a7f37; --brand-soft: #e6f4ea; --on-brand: #ffffff; --accent: #0969da; --accent-soft: #e8f1fd; --ok: #1a7f37; --ok-soft: #e6f4ea; --err: #cf222e; --err-soft: #fdecee;
|
|
11
|
+
--input: #ffffff; --hover: #eef1f5; --on-accent: #ffffff;
|
|
12
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
13
|
+
--radius: 12px;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--bg: #0d0f13; --panel: #15181e; --panel2: #1a1d24; --text: #e6e8eb; --muted: #9aa0aa; --faint: #6b7280; --line: #272c36;
|
|
18
|
+
--brand: #9ece6a; --brand-soft: #1e2a18; --on-brand: #0d0f13; --accent: #7aa2f7; --accent-soft: #1b2438; --ok: #9ece6a; --ok-soft: #1e2a18; --err: #f7768e; --err-soft: #3a1c22;
|
|
19
|
+
--input: #0f1116; --hover: #1d212a; --on-accent: #0d0f13;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
24
|
+
a { color: var(--accent); }
|
|
25
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
26
|
+
[hidden] { display: none !important; }
|
|
27
|
+
|
|
28
|
+
/* the app: pages on the left, one page at a time on the right, using the whole width */
|
|
29
|
+
.app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
|
30
|
+
aside { border-right: 1px solid var(--line); padding: 20px 12px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--panel); display: flex; flex-direction: column; }
|
|
31
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; padding: 0 10px 20px; }
|
|
32
|
+
.brand span { color: var(--brand); font-size: 18px; }
|
|
33
|
+
nav a { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; margin-bottom: 2px; }
|
|
34
|
+
nav a:hover { background: var(--hover); color: var(--text); }
|
|
35
|
+
nav a.on { background: var(--brand-soft); color: var(--text); font-weight: 600; }
|
|
36
|
+
nav a .ic { width: 18px; text-align: center; opacity: .8; }
|
|
37
|
+
nav a .count { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: normal; }
|
|
38
|
+
.using { margin: auto 4px 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; font-size: 12.5px; background: var(--panel2); }
|
|
39
|
+
.using .k { color: var(--faint); display: block; }
|
|
40
|
+
.using b { display: block; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 2px 0; }
|
|
41
|
+
main { padding: 32px 40px 64px; width: 100%; max-width: 1640px; min-width: 0; }
|
|
42
|
+
.page { display: none; }
|
|
43
|
+
.page.on { display: block; }
|
|
44
|
+
.head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
|
|
45
|
+
h1 { font-size: 24px; margin: 0 0 4px; }
|
|
46
|
+
.lead { color: var(--muted); margin: 0; max-width: 760px; }
|
|
47
|
+
h2 { font-size: 15px; margin: 0 0 4px; }
|
|
48
|
+
h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
49
|
+
.sub { color: var(--muted); margin: 0 0 14px; font-size: 13px; }
|
|
50
|
+
|
|
51
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; }
|
|
52
|
+
.stack { display: flex; flex-direction: column; gap: 18px; }
|
|
53
|
+
/* two columns: the work on the left, a panel on the right */
|
|
54
|
+
.split { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 24px; align-items: start; }
|
|
55
|
+
.split > .side { position: sticky; top: 24px; }
|
|
56
|
+
/* cards that fill the width, side by side */
|
|
57
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); gap: 18px; align-items: start; }
|
|
58
|
+
.grid .wide { grid-column: 1 / -1; }
|
|
59
|
+
/* two columns of cards, each a stack (no gaps where one card is shorter) */
|
|
60
|
+
.cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: stretch; }
|
|
61
|
+
.cols > .stack { display: flex; flex-direction: column; }
|
|
62
|
+
.cols > .stack > .card:last-child { flex: 1; } /* both columns end at the same line */
|
|
63
|
+
/* two or three choices in one control (Command | URL) */
|
|
64
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; height: 38px; }
|
|
65
|
+
.seg label { display: flex; align-items: center; padding: 0 14px; cursor: pointer; color: var(--muted); }
|
|
66
|
+
.seg label + label { border-left: 1px solid var(--line); }
|
|
67
|
+
.seg label.on { background: var(--accent-soft); color: var(--text); font-weight: 600; }
|
|
68
|
+
.seg input { position: absolute; opacity: 0; width: 0; }
|
|
69
|
+
/* a list on the left, what's chosen on the right */
|
|
70
|
+
.md { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 20px; align-items: start; }
|
|
71
|
+
.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; }
|
|
72
|
+
.md .list .top { padding: 12px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; }
|
|
73
|
+
.md .list .items { overflow-y: auto; }
|
|
74
|
+
.md .group { padding: 10px 14px 4px; font-size: 11px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .05em; }
|
|
75
|
+
.md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px 14px; border-left: 3px solid transparent; cursor: pointer; }
|
|
76
|
+
.md .it:hover { background: var(--hover); }
|
|
77
|
+
.md .it.on { background: var(--accent-soft); border-left-color: var(--accent); }
|
|
78
|
+
.md .it .t { display: flex; gap: 6px; align-items: center; font-weight: 600; font-size: 13px; word-break: break-all; }
|
|
79
|
+
.md .it .d { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
80
|
+
.md .detail { min-width: 0; }
|
|
81
|
+
.md.solo { grid-template-columns: minmax(0, 1fr); } /* nothing to list: the panel gets the width */
|
|
82
|
+
.md.solo .list { display: none; }
|
|
83
|
+
/* a long list (the prompts): list and editor fill the screen's height, like an editor app */
|
|
84
|
+
/* a list (or tabs) and its editor fill the screen below the page's title, like an editor app;
|
|
85
|
+
the height is set from where it starts (fillHeight) */
|
|
86
|
+
.md.fill { min-height: 460px; align-items: stretch; }
|
|
87
|
+
.md.fill:not(.tabs) .list { max-height: none; height: 100%; position: static; }
|
|
88
|
+
.md.fill.tabs { grid-template-rows: auto minmax(0, 1fr); }
|
|
89
|
+
.md.fill .detail { min-height: 0; }
|
|
90
|
+
.md.fill .detail > .card { height: 100%; display: flex; flex-direction: column; overflow: auto; }
|
|
91
|
+
.md.fill .detail > .card > textarea.editor { flex: 1; height: auto !important; min-height: 140px; }
|
|
92
|
+
/* a few items: tabs above the panel instead of a mostly empty column */
|
|
93
|
+
.md.tabs { grid-template-columns: minmax(0, 1fr); gap: 14px; }
|
|
94
|
+
.md.tabs .list { position: static; max-height: none; background: none; border: 0; overflow: visible; }
|
|
95
|
+
.md.tabs .items { display: flex; flex-wrap: wrap; gap: 8px; overflow: visible; }
|
|
96
|
+
.md.tabs .group { display: none; }
|
|
97
|
+
.md.tabs .it { width: auto; border: 1px solid var(--line); border-radius: 999px; padding: 7px 16px; background: var(--panel); }
|
|
98
|
+
.md.tabs .it.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
99
|
+
.md.tabs .it .d { display: none; }
|
|
100
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); flex: none; }
|
|
101
|
+
|
|
102
|
+
label.f { display: block; font-size: 12.5px; font-weight: 600; margin: 14px 0 6px; }
|
|
103
|
+
.hint { color: var(--faint); font-size: 12.5px; margin-top: 6px; }
|
|
104
|
+
input[type=text], input[type=password], input[type=number], input[type=search], select, textarea {
|
|
105
|
+
width: 100%; padding: 8px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--input);
|
|
106
|
+
}
|
|
107
|
+
input[type=text], input[type=password], input[type=number], input[type=search], select { height: 38px; }
|
|
108
|
+
textarea { font-family: var(--mono); font-size: 12.5px; resize: vertical; tab-size: 2; line-height: 1.55; display: block; }
|
|
109
|
+
textarea.editor { min-height: 96px; overflow-y: auto; } /* grows with its text (grow()), up to most of the screen */
|
|
110
|
+
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
111
|
+
button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; white-space: nowrap; }
|
|
112
|
+
button:hover:not(:disabled) { border-color: var(--accent); }
|
|
113
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
114
|
+
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
|
|
115
|
+
button.danger { color: var(--err); }
|
|
116
|
+
button.small { padding: 3px 10px; font-size: 12.5px; }
|
|
117
|
+
button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
|
118
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
119
|
+
.row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
|
|
120
|
+
.row > .grow { flex: 1; min-width: 180px; }
|
|
121
|
+
.actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
|
|
122
|
+
.actions .spacer { flex: 1; }
|
|
123
|
+
.mono { font-family: var(--mono); font-size: 12.5px; }
|
|
124
|
+
.muted { color: var(--muted); }
|
|
125
|
+
.tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 10px; background: var(--hover); color: var(--muted); }
|
|
126
|
+
.tag.brand { background: var(--brand); color: var(--on-brand); }
|
|
127
|
+
.tag.ok { background: var(--ok-soft); color: var(--ok); }
|
|
128
|
+
.tag.err { background: var(--err-soft); color: var(--err); }
|
|
129
|
+
.empty { text-align: center; padding: 26px 16px; color: var(--muted); }
|
|
130
|
+
.empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 4px; }
|
|
131
|
+
|
|
132
|
+
/* first start: the steps, then adding a provider */
|
|
133
|
+
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; }
|
|
134
|
+
.step { display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--muted); font-size: 13px; }
|
|
135
|
+
.step .num { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; background: var(--hover); color: var(--muted); flex: none; }
|
|
136
|
+
.step b { display: block; color: var(--text); }
|
|
137
|
+
.step.done .num { background: var(--ok-soft); color: var(--ok); }
|
|
138
|
+
.step.now { border-color: var(--brand); }
|
|
139
|
+
.step.now .num { background: var(--brand); color: var(--on-brand); }
|
|
140
|
+
.presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 10px; }
|
|
141
|
+
.preset { text-align: left; padding: 12px 14px; border-radius: 10px; }
|
|
142
|
+
.preset b { display: block; }
|
|
143
|
+
.preset span { color: var(--muted); font-size: 12px; }
|
|
144
|
+
.preset.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
145
|
+
|
|
146
|
+
/* Providers & models: every provider's models on the left (pick one), yours in order on the right;
|
|
147
|
+
both fill the screen's height and scroll inside */
|
|
148
|
+
.work { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 20px; }
|
|
149
|
+
.work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
150
|
+
.cat-top { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; align-items: center; }
|
|
151
|
+
.cat-top input { flex: 1; min-width: 0; }
|
|
152
|
+
.cat-top button { flex: none; }
|
|
153
|
+
.prov-h { cursor: pointer; }
|
|
154
|
+
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; align-self: flex-start; margin-top: 1px; }
|
|
155
|
+
.prov-h .fold:hover { color: var(--text); }
|
|
156
|
+
section.collapsed .prov-h .fold { transform: rotate(-90deg); }
|
|
157
|
+
section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
|
|
158
|
+
.cat-list { flex: 1; overflow-y: auto; }
|
|
159
|
+
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
|
160
|
+
.prov-h .who { flex: 1; min-width: 200px; }
|
|
161
|
+
.prov-h .name { font-weight: 700; font-size: 15px; }
|
|
162
|
+
.prov-h .meta { font-size: 12px; color: var(--muted); }
|
|
163
|
+
.prov-h .meta .mono { font-size: 12px; }
|
|
164
|
+
.rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
|
|
165
|
+
.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; }
|
|
166
|
+
.mrow:hover { background: var(--hover); }
|
|
167
|
+
.mrow.flash { animation: flash 1.6s ease-out; }
|
|
168
|
+
@keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
|
|
169
|
+
.mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
170
|
+
.mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
|
171
|
+
.mrow.taken .id { color: var(--muted); }
|
|
172
|
+
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
|
|
173
|
+
.mwrap .mrow { border-bottom: 0; }
|
|
174
|
+
.mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
|
|
175
|
+
.mwrap .forget:hover { color: var(--err); }
|
|
176
|
+
.prov-h .listsw { margin-top: 8px; width: fit-content; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
|
177
|
+
.prov-h .listsw .sw { margin-top: 0; }
|
|
178
|
+
.log-opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; }
|
|
179
|
+
.log-opts .opt { border-bottom: 0; padding-bottom: 0; }
|
|
180
|
+
@media (max-width: 860px) { .log-opts { grid-template-columns: 1fr; } }
|
|
181
|
+
.log-days { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
|
|
182
|
+
.log-days input { width: 90px; }
|
|
183
|
+
.log-days .muted { font-size: 12.5px; flex: 1; min-width: 220px; }
|
|
184
|
+
.reqs-card { margin-top: 18px; padding: 0; }
|
|
185
|
+
.reqs-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 16px 18px; border-bottom: 1px solid var(--line); }
|
|
186
|
+
.reqs { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: 420px; }
|
|
187
|
+
.req-list { max-height: 70vh; overflow-y: auto; border-right: 1px solid var(--line); }
|
|
188
|
+
.req-detail { max-height: 70vh; overflow-y: auto; padding: 16px 18px; min-width: 0; }
|
|
189
|
+
.rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; background: none; padding: 10px 14px; cursor: pointer; }
|
|
190
|
+
.rq:hover { background: var(--hover); }
|
|
191
|
+
.rq.on { background: var(--accent-soft); border-left-color: var(--accent); }
|
|
192
|
+
.rq.failed { border-left-color: var(--err); }
|
|
193
|
+
.rq.failed.on { background: var(--err-soft); }
|
|
194
|
+
.rq .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
195
|
+
.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; }
|
|
196
|
+
.rq .l2 { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-top: 3px; }
|
|
197
|
+
.rq .why { color: var(--err); font-size: 12px; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
198
|
+
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 8px 0 14px; }
|
|
199
|
+
.kv dt { color: var(--muted); }
|
|
200
|
+
.kv dd { margin: 0; overflow-wrap: anywhere; }
|
|
201
|
+
.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: 8px; padding: 10px 12px; margin: 6px 0 12px; max-height: 360px; overflow: auto; }
|
|
202
|
+
.req-detail details { margin: 4px 0; }
|
|
203
|
+
.req-detail summary { cursor: pointer; font-size: 13px; color: var(--muted); }
|
|
204
|
+
.req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
|
|
205
|
+
@media (max-width: 860px) { .reqs { grid-template-columns: 1fr; } .req-list { border-right: 0; max-height: 50vh; } }
|
|
206
|
+
.cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
|
|
207
|
+
.byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
|
|
208
|
+
.byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
|
|
209
|
+
/* two lines' room, never more: a test's progress doesn't move the cards below */
|
|
210
|
+
.olist .test { font-size: 11.5px; line-height: 1.45; margin-top: 3px; color: var(--faint); display: flex; align-items: flex-start; gap: 6px; height: calc(2 * 1.45em + 2px); }
|
|
211
|
+
.olist .test .msg { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
|
212
|
+
.olist .test .spin { margin-top: 3px; flex: none; }
|
|
213
|
+
.olist .test button { flex: none; }
|
|
214
|
+
.olist .test.ok { color: var(--ok); }
|
|
215
|
+
.olist .test.warn { color: var(--accent); }
|
|
216
|
+
dialog#ms { width: min(480px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
|
|
217
|
+
dialog#ms::backdrop { background: rgba(0, 0, 0, .55); }
|
|
218
|
+
dialog#ms h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
|
|
219
|
+
dialog#ms .opt:last-of-type { border-bottom: 0; }
|
|
220
|
+
dialog#ms .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
221
|
+
.olist .test.failed { color: var(--err); }
|
|
222
|
+
.olist .test.testing, .olist .test.waiting { color: var(--accent); }
|
|
223
|
+
.olist .test button { padding: 0 7px; font-size: 11px; }
|
|
224
|
+
.spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
|
|
225
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
226
|
+
.mine-top { padding: 16px 18px 6px; }
|
|
227
|
+
.mine-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
228
|
+
.mine-title h2 { margin: 0 0 6px; flex: 1; white-space: nowrap; }
|
|
229
|
+
.mine-title button { white-space: nowrap; }
|
|
230
|
+
.olist { list-style: none; margin: 0; padding: 6px 12px 12px; flex: 1; overflow-y: auto; }
|
|
231
|
+
.olist li { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; margin-bottom: 8px; background: var(--panel); }
|
|
232
|
+
.olist li.in { border-color: var(--brand); background: var(--brand-soft); }
|
|
233
|
+
.olist li.next { border-style: dashed; background: none; color: var(--faint); font-size: 12.5px; justify-content: center; padding: 14px 10px; }
|
|
234
|
+
.olist li.in { cursor: grab; user-select: none; transition: transform .16s ease, box-shadow .16s ease; position: relative; }
|
|
235
|
+
.olist.sorting li.in { transition: transform .16s ease; }
|
|
236
|
+
.olist li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
|
|
237
|
+
.olist .grip { touch-action: none; }
|
|
238
|
+
body.sorting, body.sorting * { cursor: grabbing !important; }
|
|
239
|
+
/* Your models: a table, one row a model */
|
|
240
|
+
.mt-card { padding: 0; }
|
|
241
|
+
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
|
|
242
|
+
.mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
|
|
243
|
+
.mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
|
|
244
|
+
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
245
|
+
.mt-scroll { overflow-x: auto; }
|
|
246
|
+
.mt { min-width: 1080px; }
|
|
247
|
+
.mt-h, .mtable li { display: grid; grid-template-columns: 16px 30px minmax(170px, 3fr) minmax(80px, 1.2fr) 76px 82px 56px 62px 54px 60px 64px minmax(200px, 2fr) 30px; align-items: center; column-gap: 8px; padding: 8px 18px; }
|
|
248
|
+
.mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
|
|
249
|
+
.mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
|
|
250
|
+
.mt-h button:hover { color: var(--text); }
|
|
251
|
+
.mt-h button.on { color: var(--brand); font-weight: 700; }
|
|
252
|
+
.mt-h .num, .mtable .num { text-align: right; }
|
|
253
|
+
.mtable { list-style: none; margin: 0; padding: 0; }
|
|
254
|
+
.mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; }
|
|
255
|
+
.mtable li:last-child { border-bottom: 0; }
|
|
256
|
+
.mtable li.in { user-select: none; transition: transform .16s ease, box-shadow .16s ease; }
|
|
257
|
+
.mtable.drag li.in { cursor: grab; }
|
|
258
|
+
.mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
|
|
259
|
+
.mtable .grip { color: var(--faint); touch-action: none; }
|
|
260
|
+
.mtable:not(.drag) .grip { visibility: hidden; }
|
|
261
|
+
.mtable .n { font-weight: 800; color: var(--brand); }
|
|
262
|
+
.mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }
|
|
263
|
+
.mtable .p { color: var(--muted); overflow-wrap: anywhere; }
|
|
264
|
+
.mtable .yes { color: var(--ok); }
|
|
265
|
+
.mtable .no { color: var(--faint); }
|
|
266
|
+
.mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
267
|
+
.mtable .src.warn { color: var(--accent); }
|
|
268
|
+
.mtable button { padding: 1px 7px; font-size: 11.5px; }
|
|
269
|
+
.mt-empty { padding: 28px 18px; color: var(--muted); }
|
|
270
|
+
.olist .grip { cursor: grab; color: var(--faint); padding: 0 2px; user-select: none; }
|
|
271
|
+
.olist .n { font-weight: 800; color: var(--brand); min-width: 24px; }
|
|
272
|
+
.olist .what { flex: 1; min-width: 0; }
|
|
273
|
+
.olist .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }
|
|
274
|
+
.olist .p { color: var(--muted); font-size: 12px; }
|
|
275
|
+
.olist button { padding: 2px 8px; font-size: 12px; }
|
|
276
|
+
.mine-rules { border-top: 1px solid var(--line); padding: 4px 18px 16px; }
|
|
277
|
+
.mine-rules .hint { min-height: 2.9em; } /* the hints differ in length: the next setting stays put */
|
|
278
|
+
.mine-empty { padding: 30px 22px; text-align: center; color: var(--muted); }
|
|
279
|
+
.mine-empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 6px; }
|
|
280
|
+
|
|
281
|
+
/* switches */
|
|
282
|
+
.opt { display: flex; gap: 14px; align-items: flex-start; padding: 14px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
|
283
|
+
.opt:last-child { border-bottom: 0; padding-bottom: 0; }
|
|
284
|
+
.opt:first-child { padding-top: 0; }
|
|
285
|
+
.opt .t { flex: 1; min-width: 0; }
|
|
286
|
+
.opt b { display: block; }
|
|
287
|
+
.opt span { color: var(--muted); font-size: 13px; }
|
|
288
|
+
.sw { position: relative; width: 38px; height: 22px; flex: none; margin-top: 2px; }
|
|
289
|
+
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
290
|
+
.sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line); transition: background .15s; }
|
|
291
|
+
.sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
|
292
|
+
.sw input:checked + i { background: var(--ok); }
|
|
293
|
+
.sw input:checked + i::after { transform: translateX(16px); }
|
|
294
|
+
.sw input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
295
|
+
|
|
296
|
+
/* choices as cards (permissions, colors) */
|
|
297
|
+
.choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
|
298
|
+
.choice { position: relative; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; background: var(--panel); }
|
|
299
|
+
.choice:hover { border-color: var(--accent); }
|
|
300
|
+
.choice.on { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
|
|
301
|
+
.choice b { display: block; }
|
|
302
|
+
.choice span { color: var(--muted); font-size: 12.5px; }
|
|
303
|
+
.choice input { position: absolute; opacity: 0; }
|
|
304
|
+
.swatch { display: flex; gap: 3px; margin-bottom: 8px; }
|
|
305
|
+
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid var(--line); }
|
|
306
|
+
|
|
307
|
+
/* a model's tooltip: shown at once on hover (or keyboard focus), next to the row */
|
|
308
|
+
#tip { position: fixed; z-index: 30; max-width: 380px; padding: 12px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 10px 30px rgba(0, 0, 0, .28); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .08s; }
|
|
309
|
+
#tip.show { opacity: 1; }
|
|
310
|
+
#tip .tid { font-family: var(--mono); font-weight: 700; font-size: 13px; word-break: break-all; }
|
|
311
|
+
#tip .tname { color: var(--muted); margin-top: 1px; }
|
|
312
|
+
#tip dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 10px 0 0; }
|
|
313
|
+
#tip dt { color: var(--faint); }
|
|
314
|
+
#tip dd { margin: 0; }
|
|
315
|
+
#tip .tdesc { color: var(--muted); margin-top: 10px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
316
|
+
#tip .thint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); color: var(--accent); font-weight: 600; }
|
|
317
|
+
#toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); padding: 10px 18px; border-radius: 10px; background: var(--text); color: var(--bg); opacity: 0; transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
|
|
318
|
+
#toast.show { opacity: 1; }
|
|
319
|
+
#toast.err { background: var(--err); color: #fff; }
|
|
320
|
+
|
|
321
|
+
@media (max-width: 1180px) {
|
|
322
|
+
.split { grid-template-columns: 1fr; }
|
|
323
|
+
.work { grid-template-columns: 1fr; height: auto !important; }
|
|
324
|
+
.work .mine { order: -1; }
|
|
325
|
+
.cat-list { max-height: 520px; }
|
|
326
|
+
.split > .side { position: static; order: -1; }
|
|
327
|
+
.md { grid-template-columns: 280px minmax(0, 1fr); }
|
|
328
|
+
}
|
|
329
|
+
@media (max-width: 860px) {
|
|
330
|
+
.app { grid-template-columns: minmax(0, 1fr); }
|
|
331
|
+
aside { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 12px 16px 8px; }
|
|
332
|
+
.brand { padding: 0 0 8px; }
|
|
333
|
+
nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }
|
|
334
|
+
nav a { flex: none; margin: 0; }
|
|
335
|
+
nav a .count, .using { display: none; }
|
|
336
|
+
main { padding: 20px 16px 60px; }
|
|
337
|
+
.steps { grid-template-columns: 1fr; }
|
|
338
|
+
.choices { grid-template-columns: 1fr; }
|
|
339
|
+
.grid, .cols { grid-template-columns: 1fr; }
|
|
340
|
+
.md { grid-template-columns: 1fr; }
|
|
341
|
+
.md .list { position: static; max-height: 300px; }
|
|
342
|
+
.md.fill .list { height: auto !important; max-height: 300px; }
|
|
343
|
+
.md.fill .detail > .card { height: auto; }
|
|
344
|
+
.md.fill .detail > .card > textarea.editor { min-height: 260px; }
|
|
345
|
+
.prov-head, .prov-body { padding-left: 14px; padding-right: 14px; }
|
|
346
|
+
}
|
|
347
|
+
</style>
|
|
348
|
+
</head>
|
|
349
|
+
<body>
|
|
350
|
+
<div class="app">
|
|
351
|
+
<aside>
|
|
352
|
+
<div class="brand"><span>♥</span> HeartCode settings</div>
|
|
353
|
+
<nav id="nav">
|
|
354
|
+
<a href="#providers" data-page="providers"><span class="ic">◉</span> Providers & models <span class="count" id="n-providers"></span></a>
|
|
355
|
+
<a href="#models" data-page="models"><span class="ic">☰</span> Your models <span class="count" id="n-mine"></span></a>
|
|
356
|
+
<a href="#general" data-page="general"><span class="ic">⚙</span> General</a>
|
|
357
|
+
<a href="#logging" data-page="logging"><span class="ic">▤</span> Logging <span class="count" id="n-failed"></span></a>
|
|
358
|
+
<a href="#prompts" data-page="prompts"><span class="ic">✎</span> System prompt</a>
|
|
359
|
+
<a href="#mcp" data-page="mcp"><span class="ic">⧉</span> MCP servers <span class="count" id="n-mcp"></span></a>
|
|
360
|
+
<a href="#skills" data-page="skills"><span class="ic">✦</span> Skills <span class="count" id="n-skills"></span></a>
|
|
361
|
+
<a href="#files" data-page="files"><span class="ic">≡</span> Advanced files</a>
|
|
362
|
+
</nav>
|
|
363
|
+
<div class="using" id="using"></div>
|
|
364
|
+
</aside>
|
|
365
|
+
|
|
366
|
+
<main>
|
|
367
|
+
<!-- Providers & models -->
|
|
368
|
+
<section class="page" id="page-providers">
|
|
369
|
+
<div class="head">
|
|
370
|
+
<div><h1>Providers & models</h1><p class="lead">A provider is a service with models (OpenRouter, Groq, Ollama on this machine…). Add one, then choose which of its models HeartCode uses.</p></div>
|
|
371
|
+
<button class="primary" id="p-open">+ Add a provider</button>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="steps" id="steps"></div>
|
|
374
|
+
<div class="stack">
|
|
375
|
+
<div class="card" id="p-form" hidden>
|
|
376
|
+
<h2 id="p-title">Add a provider</h2>
|
|
377
|
+
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
378
|
+
<div class="presets" id="presets"></div>
|
|
379
|
+
<div class="row" style="margin-top:6px">
|
|
380
|
+
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
381
|
+
<div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
|
|
382
|
+
</div>
|
|
383
|
+
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
384
|
+
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
385
|
+
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
386
|
+
<div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="work" id="p-work">
|
|
389
|
+
<div class="card catalog">
|
|
390
|
+
<div class="cat-top"><input type="search" id="m-search" placeholder="Search all models…"><button class="small" id="fold-all" type="button">Collapse all</button><button class="small" id="unfold-all" type="button">Expand all</button></div>
|
|
391
|
+
<div class="cat-list" id="p-list"></div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="card mine" id="inuse"></div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</section>
|
|
397
|
+
|
|
398
|
+
<!-- Your models: all at once, sortable -->
|
|
399
|
+
<section class="page" id="page-models">
|
|
400
|
+
<div class="head"><div><h1>Your models</h1><p class="lead">Every model in your list, with what it can do. Click a column to sort by it, then more columns to break ties; click again to flip, a third time to drop it. Save the order you see, or drag a row: that saves the order you see with your change, and you go on from there.</p></div></div>
|
|
401
|
+
<div class="card mt-card" id="mt"></div>
|
|
402
|
+
</section>
|
|
403
|
+
|
|
404
|
+
<!-- Logging -->
|
|
405
|
+
<section class="page" id="page-logging">
|
|
406
|
+
<div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div></div>
|
|
407
|
+
<div class="card">
|
|
408
|
+
<div class="log-opts">
|
|
409
|
+
<div>
|
|
410
|
+
<h2>The run's log</h2>
|
|
411
|
+
<p class="sub">Everything each run does, as text: requests, replies, retries, tools, commands, what you typed.</p>
|
|
412
|
+
<div id="l-run"></div>
|
|
413
|
+
</div>
|
|
414
|
+
<div>
|
|
415
|
+
<h2>Requests</h2>
|
|
416
|
+
<p class="sub">Each request to a model and its reply or error: which failed, which worked, and which fallback answered.</p>
|
|
417
|
+
<div id="l-req"></div>
|
|
418
|
+
<div class="row" style="align-items:center;gap:10px;margin-top:12px">
|
|
419
|
+
<label class="f" for="l-detail" style="margin:0">Keep</label>
|
|
420
|
+
<select id="l-detail" style="flex:1">
|
|
421
|
+
<option value="full">Everything: what was sent and what came back</option>
|
|
422
|
+
<option value="summary">Only a summary: model, result, error, timings, tokens</option>
|
|
423
|
+
</select>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="hint">Everything can make big files on long conversations. API keys are never written.</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div class="log-days">
|
|
429
|
+
<label for="l-days"><b>Keep logs for</b></label>
|
|
430
|
+
<input type="number" id="l-days" min="1" max="3650" step="1" value="30">
|
|
431
|
+
<span>days</span>
|
|
432
|
+
<span class="muted">Older ones (both kinds) are deleted when HeartCode starts, and right away when you change this. 30 days unless you change it.</span>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="card reqs-card">
|
|
436
|
+
<div class="reqs-top">
|
|
437
|
+
<h2 style="margin:0">Requests logged</h2>
|
|
438
|
+
<div class="seg" id="l-filter">
|
|
439
|
+
<label class="on"><input type="radio" name="l-filter" value="all" checked>All</label><label><input type="radio" name="l-filter" value="failed">Failed</label><label><input type="radio" name="l-filter" value="fallback">Fallbacks</label>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="reqs">
|
|
443
|
+
<div class="req-list" id="l-list"></div>
|
|
444
|
+
<div class="req-detail" id="l-detail-pane"><div class="empty">Click a request to see it in full.</div></div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
</section>
|
|
448
|
+
|
|
449
|
+
<!-- General -->
|
|
450
|
+
<section class="page" id="page-general">
|
|
451
|
+
<div class="head"><div><h1>General</h1><p class="lead">How HeartCode works for you. Saved for next time; a running HeartCode takes changes right away.</p></div></div>
|
|
452
|
+
<div class="cols">
|
|
453
|
+
<div class="stack">
|
|
454
|
+
<div class="card">
|
|
455
|
+
<h2>What can HeartCode do without asking?</h2>
|
|
456
|
+
<p class="sub">Shift+Tab in HeartCode switches between these too (and to plan mode).</p>
|
|
457
|
+
<div class="choices" id="g-perm"></div>
|
|
458
|
+
</div>
|
|
459
|
+
<div class="card">
|
|
460
|
+
<h2>Colors</h2>
|
|
461
|
+
<p class="sub">Your own colors on top of a theme: theme.json in <a href="#files">Advanced files</a>.</p>
|
|
462
|
+
<div class="choices" id="g-theme"></div>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="card">
|
|
465
|
+
<h2>Thinking and context</h2>
|
|
466
|
+
<div class="row">
|
|
467
|
+
<div class="grow"><label class="f" for="g-effort">How hard a thinking model thinks</label><select id="g-effort"></select></div>
|
|
468
|
+
<div class="grow"><label class="f" for="g-context">Context window (tokens)</label><input type="number" id="g-context" min="1" placeholder="as the model says"></div>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="hint">Leave the context window empty unless HeartCode gets it wrong for your model.</div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="stack">
|
|
474
|
+
<div class="card">
|
|
475
|
+
<h2>Features</h2>
|
|
476
|
+
<p class="sub">Switch each one on or off.</p>
|
|
477
|
+
<div id="g-toggles"></div>
|
|
478
|
+
</div>
|
|
479
|
+
<div class="card">
|
|
480
|
+
<h2>Voice input</h2>
|
|
481
|
+
<p class="sub">Ctrl+R 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>
|
|
482
|
+
<textarea id="v-text" spellcheck="false" rows="3" placeholder="(whisper-cli on this machine)"></textarea>
|
|
483
|
+
<div class="actions"><button class="primary" id="v-save">Save voice</button></div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</section>
|
|
488
|
+
|
|
489
|
+
<!-- System prompt and the other prompts -->
|
|
490
|
+
<section class="page" id="page-prompts">
|
|
491
|
+
<div class="head"><div><h1>System prompt</h1><p class="lead">What HeartCode tells the model. Change a prompt and save: a running HeartCode uses yours from the next message. <span class="mono">{{names}}</span> are filled in by HeartCode; Reset puts HeartCode's back.</p></div></div>
|
|
492
|
+
<div class="md">
|
|
493
|
+
<div class="list"><div class="top"><input type="search" id="r-search" placeholder="Search prompts…"></div><div class="items" id="r-items"></div></div>
|
|
494
|
+
<div class="detail" id="r-detail"></div>
|
|
495
|
+
</div>
|
|
496
|
+
</section>
|
|
497
|
+
|
|
498
|
+
<!-- MCP servers -->
|
|
499
|
+
<section class="page" id="page-mcp">
|
|
500
|
+
<div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div></div>
|
|
501
|
+
<div class="stack">
|
|
502
|
+
<div class="card">
|
|
503
|
+
<h2>Add a server</h2>
|
|
504
|
+
<p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
|
|
505
|
+
<div class="row">
|
|
506
|
+
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
507
|
+
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
508
|
+
<label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
|
|
509
|
+
</div></div>
|
|
510
|
+
<div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
|
|
511
|
+
<button class="primary" id="c-add">Add server</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="card" id="c-list"></div>
|
|
515
|
+
<details class="card" id="c-raw"><summary style="cursor:pointer;font-weight:600">Edit mcp.json directly</summary><p class="sub" style="margin-top:10px">For what the form doesn't have: environment variables (<span class="mono">"env"</span>), headers, and so on.</p><div id="c-file"></div></details>
|
|
516
|
+
</div>
|
|
517
|
+
</section>
|
|
518
|
+
|
|
519
|
+
<!-- Skills -->
|
|
520
|
+
<section class="page" id="page-skills">
|
|
521
|
+
<div class="head"><div><h1>Skills</h1><p class="lead">Ready-made prompts. You type one as <span class="mono">/name</span>, or the model uses it when it fits.</p></div><button class="primary" id="k-new">+ New skill</button></div>
|
|
522
|
+
<div class="md">
|
|
523
|
+
<div class="list"><div class="items" id="k-items"></div></div>
|
|
524
|
+
<div class="detail" id="k-detail"></div>
|
|
525
|
+
</div>
|
|
526
|
+
</section>
|
|
527
|
+
|
|
528
|
+
<!-- Advanced files -->
|
|
529
|
+
<section class="page" id="page-files">
|
|
530
|
+
<div class="head"><div><h1>Advanced files</h1><p class="lead">The rest of HeartCode's settings, as the files they're kept in (<span class="mono" id="f-home"></span>). A JSON file that doesn't parse isn't saved; save one empty to delete it.</p></div></div>
|
|
531
|
+
<div class="md">
|
|
532
|
+
<div class="list"><div class="items" id="f-items"></div></div>
|
|
533
|
+
<div class="detail" id="f-detail"></div>
|
|
534
|
+
</div>
|
|
535
|
+
</section>
|
|
536
|
+
</main>
|
|
537
|
+
</div>
|
|
538
|
+
<dialog id="ms" aria-labelledby="ms-title">
|
|
539
|
+
<form>
|
|
540
|
+
<h2 id="ms-title"></h2>
|
|
541
|
+
<p class="sub" id="ms-from"></p>
|
|
542
|
+
<label class="f" for="ms-context">Context window (tokens)</label>
|
|
543
|
+
<div class="row" style="gap:8px;align-items:center"><input type="number" id="ms-context" min="512" step="1" style="flex:1"><button class="small" id="ms-measure" type="button">Measure</button></div>
|
|
544
|
+
<div class="hint">Empty: as found (models.dev, the server, or what HeartCode learns). Measure asks the server.</div>
|
|
545
|
+
<label class="opt"><div class="t"><b>Tool calls</b><span>Reads, edits and runs things itself. Off: HeartCode guides it step by step.</span></div><span class="sw"><input type="checkbox" id="ms-tools"><i></i></span></label>
|
|
546
|
+
<label class="opt"><div class="t"><b>Thinking</b><span>Thinks before it answers (the effort setting applies).</span></div><span class="sw"><input type="checkbox" id="ms-thinking"><i></i></span></label>
|
|
547
|
+
<label class="opt"><div class="t"><b>Images</b><span>Reads screenshots and pictures you send.</span></div><span class="sw"><input type="checkbox" id="ms-images"><i></i></span></label>
|
|
548
|
+
<label class="opt"><div class="t"><b>Streaming</b><span>Its reply shows as it's written. Off: all at once at the end.</span></div><span class="sw"><input type="checkbox" id="ms-stream"><i></i></span></label>
|
|
549
|
+
<div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
|
|
550
|
+
</form>
|
|
551
|
+
</dialog>
|
|
552
|
+
<div id="toast" role="status"></div>
|
|
553
|
+
<div id="tip" role="tooltip"></div>
|
|
554
|
+
<script>
|
|
555
|
+
const token = new URLSearchParams(location.search).get('t')
|
|
556
|
+
const $ = (id) => document.getElementById(id)
|
|
557
|
+
const h = (tag, props = {}, ...kids) => {
|
|
558
|
+
const el = document.createElement(tag)
|
|
559
|
+
for (const [k, v] of Object.entries(props)) k.startsWith('on') ? el.addEventListener(k.slice(2), v) : k in el ? (el[k] = v) : el.setAttribute(k, v)
|
|
560
|
+
el.append(...kids.flat(Infinity).filter((x) => x != null && x !== false))
|
|
561
|
+
return el
|
|
562
|
+
}
|
|
563
|
+
// an element's content, without the null/false parts (left out on purpose)
|
|
564
|
+
const put = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter((x) => x != null && x !== false))
|
|
565
|
+
let S = null
|
|
566
|
+
let toastTimer
|
|
567
|
+
function toast(text, err = false) {
|
|
568
|
+
const t = $('toast')
|
|
569
|
+
t.textContent = text
|
|
570
|
+
t.className = 'show' + (err ? ' err' : '')
|
|
571
|
+
clearTimeout(toastTimer)
|
|
572
|
+
toastTimer = setTimeout(() => (t.className = ''), err ? 6000 : 2400)
|
|
573
|
+
}
|
|
574
|
+
async function api(path, body) {
|
|
575
|
+
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) })
|
|
576
|
+
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
577
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
578
|
+
return data
|
|
579
|
+
}
|
|
580
|
+
async function act(path, body, done) {
|
|
581
|
+
try {
|
|
582
|
+
const r = await api(path, body)
|
|
583
|
+
if (r.providers) render(r)
|
|
584
|
+
if (done) toast(done)
|
|
585
|
+
return r
|
|
586
|
+
} catch (e) {
|
|
587
|
+
toast(e.message, true)
|
|
588
|
+
return null
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const sw = (props) => h('label', { className: 'sw' }, h('input', { type: 'checkbox', ...props }), h('i'))
|
|
592
|
+
|
|
593
|
+
// pages: one at a time, from the address (#providers…)
|
|
594
|
+
const PAGES = ['providers', 'models', 'general', 'logging', 'prompts', 'mcp', 'skills', 'files']
|
|
595
|
+
const page = () => (PAGES.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'providers')
|
|
596
|
+
function showPage() {
|
|
597
|
+
for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
|
|
598
|
+
for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
|
|
599
|
+
if (S) drawUsing(S)
|
|
600
|
+
scrollTo(0, 0)
|
|
601
|
+
fillHeight()
|
|
602
|
+
document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
|
|
603
|
+
}
|
|
604
|
+
addEventListener('hashchange', showPage)
|
|
605
|
+
|
|
606
|
+
// A list on the left, the chosen one on the right (prompts, skills, files). Unsaved text in the
|
|
607
|
+
// editor is kept per item while you look at others, marked with a dot in the list.
|
|
608
|
+
const DRAFTS = { prompts: {}, skills: {}, files: {} }
|
|
609
|
+
const SEL = { prompts: 'agent/system', skills: null, files: null }
|
|
610
|
+
function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
611
|
+
const all = groups.flatMap((g) => g.items)
|
|
612
|
+
if (!all.some((x) => x.id === SEL[kind]) && SEL[kind] !== '+new') SEL[kind] = all[0]?.id ?? null
|
|
613
|
+
// the list: built when its items change; otherwise each item is updated in place (the chosen
|
|
614
|
+
// one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
|
|
615
|
+
const inside = (x) => [h('div', { className: 't' }, DRAFTS[kind][x.id] !== undefined ? h('span', { className: 'dot', title: 'not saved' }) : null, x.title, x.badge ?? null), x.sub ? h('div', { className: 'd', textContent: x.sub }) : null]
|
|
616
|
+
const shape = JSON.stringify(groups.map((g) => [g.title, g.items.map((x) => x.id)]))
|
|
617
|
+
if (itemsEl.dataset.shape !== shape) {
|
|
618
|
+
itemsEl.dataset.shape = shape
|
|
619
|
+
put(itemsEl, groups.map((g) => [
|
|
620
|
+
g.title && g.items.length ? h('div', { className: 'group', textContent: g.title }) : null,
|
|
621
|
+
g.items.map((x) => h('button', { className: 'it', 'data-id': x.id })),
|
|
622
|
+
]))
|
|
623
|
+
}
|
|
624
|
+
const byId = new Map(all.map((x) => [x.id, x]))
|
|
625
|
+
for (const b of itemsEl.querySelectorAll('.it')) {
|
|
626
|
+
const x = byId.get(b.dataset.id)
|
|
627
|
+
b.className = 'it' + (x.id === SEL[kind] ? ' on' : '')
|
|
628
|
+
put(b, inside(x))
|
|
629
|
+
b.onclick = () => ((SEL[kind] = x.id), masterDetail(kind, itemsEl, detailEl, groups, draw))
|
|
630
|
+
}
|
|
631
|
+
itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
|
|
632
|
+
itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
|
|
633
|
+
put(detailEl, draw(SEL[kind] === '+new' ? '+new' : all.find((x) => x.id === SEL[kind])))
|
|
634
|
+
itemsEl.closest('.md').classList.add('fill')
|
|
635
|
+
fillHeight()
|
|
636
|
+
}
|
|
637
|
+
// an editor for one item: its text (or your unsaved changes to it)
|
|
638
|
+
// A text box as tall as its text (at least a few lines, at most most of the screen): no empty box.
|
|
639
|
+
function grow(ta, min = 96) {
|
|
640
|
+
const fit = () => {
|
|
641
|
+
ta.style.height = 'auto'
|
|
642
|
+
ta.style.height = `${Math.min(Math.max(ta.scrollHeight + 2, min), Math.max(min, innerHeight * 0.72))}px`
|
|
643
|
+
}
|
|
644
|
+
ta.addEventListener('input', fit)
|
|
645
|
+
requestAnimationFrame(fit) // once it's on the page
|
|
646
|
+
return ta
|
|
647
|
+
}
|
|
648
|
+
// the list-and-editor pages: down to the bottom of the screen (from wherever their title ends)
|
|
649
|
+
function fillHeight() {
|
|
650
|
+
requestAnimationFrame(() => {
|
|
651
|
+
const room = (el) => Math.max(460, innerHeight - el.getBoundingClientRect().top - window.scrollY - 28)
|
|
652
|
+
for (const md of document.querySelectorAll('.page.on .md.fill')) md.style.height = innerWidth > 860 ? `${room(md)}px` : ''
|
|
653
|
+
// the models: as tall as what's in them, at most to the bottom of the screen (then they scroll)
|
|
654
|
+
// (measured from the content's own height: the layout isn't touched, so nothing scrolls back)
|
|
655
|
+
for (const w of document.querySelectorAll('.page.on .work')) {
|
|
656
|
+
const natural = Math.max(...[...w.children].map((card) => [...card.children].reduce((n, c) => n + c.scrollHeight, 2)))
|
|
657
|
+
w.style.height = innerWidth > 860 ? `${Math.min(natural, room(w))}px` : ''
|
|
658
|
+
}
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
addEventListener('resize', () => (document.querySelectorAll('textarea').forEach((t) => t.dispatchEvent(new Event('input'))), fillHeight()))
|
|
662
|
+
function editor(kind, id, saved, props = {}) {
|
|
663
|
+
const ta = grow(h('textarea', { className: 'editor', spellcheck: false, ...props, value: DRAFTS[kind][id] ?? saved }))
|
|
664
|
+
ta.addEventListener('input', () => {
|
|
665
|
+
if (ta.value === saved) delete DRAFTS[kind][id]
|
|
666
|
+
else DRAFTS[kind][id] = ta.value
|
|
667
|
+
})
|
|
668
|
+
return ta
|
|
669
|
+
}
|
|
670
|
+
const saved = (kind, id) => delete DRAFTS[kind][id]
|
|
671
|
+
// saves one item: its draft goes first (so the page drawn after shows it saved), and comes back if saving fails
|
|
672
|
+
async function saveItem(kind, id, path, body, done) {
|
|
673
|
+
const d = DRAFTS[kind][id]
|
|
674
|
+
delete DRAFTS[kind][id]
|
|
675
|
+
const r = await act(path, body, done)
|
|
676
|
+
if (!r && d !== undefined) DRAFTS[kind][id] = d
|
|
677
|
+
if (!r) render(S)
|
|
678
|
+
return r
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --- Providers & models ---
|
|
682
|
+
const PRESETS = [
|
|
683
|
+
['openrouter', 'OpenRouter', 'https://openrouter.ai/api/v1', 'hundreds of models'],
|
|
684
|
+
['groq', 'Groq', 'https://api.groq.com/openai/v1', 'very fast'],
|
|
685
|
+
['openai', 'OpenAI', 'https://api.openai.com/v1', 'GPT models'],
|
|
686
|
+
['gemini', 'Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai', 'Google'],
|
|
687
|
+
['mistral', 'Mistral', 'https://api.mistral.ai/v1', 'Mistral, Codestral'],
|
|
688
|
+
['deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', 'DeepSeek models'],
|
|
689
|
+
['together', 'Together', 'https://api.together.xyz/v1', 'open models'],
|
|
690
|
+
['ollama', 'Ollama', 'http://localhost:11434/v1', 'on this machine'],
|
|
691
|
+
['lmstudio', 'LM Studio', 'http://localhost:1234/v1', 'on this machine'],
|
|
692
|
+
]
|
|
693
|
+
function drawPresets() {
|
|
694
|
+
put($('presets'), PRESETS.map(([name, label, url, what]) => h('button', { type: 'button', className: 'preset' + ($('p-url').value === url ? ' on' : ''), onclick: () => {
|
|
695
|
+
if (!editing && (!$('p-name').value || PRESETS.some(([n]) => n === $('p-name').value))) $('p-name').value = name
|
|
696
|
+
$('p-url').value = url
|
|
697
|
+
drawPresets()
|
|
698
|
+
$('p-key').focus()
|
|
699
|
+
} }, h('b', { textContent: label }), h('span', { textContent: what }))))
|
|
700
|
+
}
|
|
701
|
+
$('p-url').oninput = drawPresets
|
|
702
|
+
|
|
703
|
+
const MODELS = {} // provider → { list } | { error } | { loading }
|
|
704
|
+
const filters = {}
|
|
705
|
+
function loadModels(name, again = false) {
|
|
706
|
+
if (MODELS[name] && !again) return
|
|
707
|
+
MODELS[name] = { loading: true }
|
|
708
|
+
api('/api/models', { name }).then(({ models, details }) => (MODELS[name] = { list: models, details: details ?? {} }), (e) => (MODELS[name] = { error: e.message })).then(() => S && drawProviders(S))
|
|
709
|
+
}
|
|
710
|
+
const useModel = (provider, model) => act('/api/model', { provider, model }, `HeartCode now uses ${provider} · ${model}`)
|
|
711
|
+
const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
|
|
712
|
+
|
|
713
|
+
let editing = null
|
|
714
|
+
let formOpen = false
|
|
715
|
+
function openForm(p = null) {
|
|
716
|
+
editing = p
|
|
717
|
+
formOpen = true
|
|
718
|
+
$('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
|
|
719
|
+
$('p-name').value = p?.name ?? ''
|
|
720
|
+
$('p-url').value = p?.baseUrl ?? ''
|
|
721
|
+
$('p-key').value = ''
|
|
722
|
+
$('p-key').placeholder = p?.key ? 'empty: keep the key it has' : 'paste it here (none for a server on this machine, like Ollama)'
|
|
723
|
+
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
724
|
+
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
725
|
+
drawPresets()
|
|
726
|
+
drawProviders(S)
|
|
727
|
+
$('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
728
|
+
;(p ? $('p-url') : $('p-name')).focus()
|
|
729
|
+
}
|
|
730
|
+
function closeForm() {
|
|
731
|
+
editing = null
|
|
732
|
+
formOpen = false
|
|
733
|
+
drawProviders(S)
|
|
734
|
+
}
|
|
735
|
+
$('p-open').onclick = () => openForm()
|
|
736
|
+
$('p-cancel').onclick = closeForm
|
|
737
|
+
$('p-save').onclick = async () => {
|
|
738
|
+
const name = $('p-name').value.trim()
|
|
739
|
+
const k = $('p-key').value.trim()
|
|
740
|
+
const was = editing
|
|
741
|
+
const r = await act('/api/provider', { name, baseUrl: $('p-url').value, apiKey: k, keepKey: Boolean(was?.key && !k), was: was?.name })
|
|
742
|
+
if (!r) return
|
|
743
|
+
const added = r.providers.find((p) => p.name === name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''))
|
|
744
|
+
if (!added) return
|
|
745
|
+
if (was) delete MODELS[was.name]
|
|
746
|
+
loadModels(added.name, true) // a new one, or a new URL or key: its models (again)
|
|
747
|
+
toast(was ? `saved ${added.name}` : `added ${added.name}: its models are loading`)
|
|
748
|
+
closeForm()
|
|
749
|
+
document.querySelector(`[data-provider="${added.name}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
750
|
+
}
|
|
751
|
+
for (const id of ['c-name', 'c-what']) $(id).addEventListener('keydown', (e) => e.key === 'Enter' && $('c-add').click())
|
|
752
|
+
for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown', (e) => e.key === 'Enter' && $('p-save').click())
|
|
753
|
+
|
|
754
|
+
// model names in a natural order: "glm-5.2" before "glm-5.10", case doesn't matter
|
|
755
|
+
const byName = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
|
756
|
+
let modelQuery = ''
|
|
757
|
+
// Providers folded on the page (the browser remembers them). Typing a search opens them all, until
|
|
758
|
+
// you fold or unfold one (or all) again.
|
|
759
|
+
let searchOpen = false
|
|
760
|
+
const FOLDED = new Set((() => {
|
|
761
|
+
try {
|
|
762
|
+
return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
|
|
763
|
+
} catch {
|
|
764
|
+
return []
|
|
765
|
+
}
|
|
766
|
+
})())
|
|
767
|
+
function applyFolds() {
|
|
768
|
+
try {
|
|
769
|
+
localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
|
|
770
|
+
} catch {}
|
|
771
|
+
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
772
|
+
const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
|
|
773
|
+
sec.classList.toggle('collapsed', folded)
|
|
774
|
+
sec.querySelector('.fold')?.setAttribute('aria-expanded', String(!folded))
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// A fold by hand during a search starts from what's shown (the ones it opened stay open).
|
|
778
|
+
function settle() {
|
|
779
|
+
if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
|
|
780
|
+
searchOpen = false
|
|
781
|
+
}
|
|
782
|
+
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), applyFolds())
|
|
783
|
+
$('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), applyFolds())
|
|
784
|
+
$('unfold-all').onclick = () => (settle(), FOLDED.clear(), applyFolds())
|
|
785
|
+
$('m-search').oninput = (e) => ((modelQuery = e.target.value), (searchOpen = true), S && drawProviders(S), applyFolds())
|
|
786
|
+
// A model's tooltip: its full name and what's known about it (from the provider, HeartCode's price
|
|
787
|
+
// list and its own test), and what a click does. Shown on hover and on keyboard focus.
|
|
788
|
+
const kTok = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
|
|
789
|
+
const usd = (x) => (x === 0 ? 'free' : `$${x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3).replace(/0$/, '') : x.toFixed(2)}`)
|
|
790
|
+
// What a model is, under it in your list, in the two lines its card keeps for it (at most two
|
|
791
|
+
// lines of text, its button on the right): where that comes from, and Edit (or Set it up, for one
|
|
792
|
+
// models.dev doesn't know). Replaced in place when it changes (streamed).
|
|
793
|
+
const FROM = { 'models.dev': 'from models.dev', you: 'set by you', tested: 'tested' }
|
|
794
|
+
function testLine(t) {
|
|
795
|
+
const x = t.tested
|
|
796
|
+
const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
|
|
797
|
+
const [cls, text, action] =
|
|
798
|
+
!x ? ['none', 'looking it up in models.dev…', null]
|
|
799
|
+
: x.source === 'assumed' ? ['warn', 'not in models.dev: set up what it can do', [retry(t), edit('Set it up')]]
|
|
800
|
+
: ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
|
|
801
|
+
return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text }), action)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// models.dev looked up again: one model (its Retry), or all of yours (Retry all)
|
|
805
|
+
const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
|
|
806
|
+
async function lookUp(t) {
|
|
807
|
+
toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
|
|
808
|
+
const r = await act('/api/models-dev', t ? { provider: t.provider, model: t.model } : {})
|
|
809
|
+
if (r) toast(t ? (r.found ? `${t.model}: found in models.dev` : `${t.model}: still not in models.dev`) : `models.dev: ${r.found} of ${r.of} found`)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The model's setup: a window over the page (nothing on it moves). Its context window (empty: as
|
|
813
|
+
// found; Measure asks the server) and what it can do; saved above what models.dev says.
|
|
814
|
+
let setupFor = null
|
|
815
|
+
function openSetup(t) {
|
|
816
|
+
setupFor = t
|
|
817
|
+
const x = t.tested ?? {}
|
|
818
|
+
$('ms-title').textContent = t.model
|
|
819
|
+
$('ms-from').textContent = {
|
|
820
|
+
assumed: "models.dev doesn't know this model. Tell HeartCode what it can do; until then it assumes tool calls, streaming and a ~128k window.",
|
|
821
|
+
'models.dev': 'From models.dev. Change anything that isn\'t right for how you reach it.',
|
|
822
|
+
you: 'Set by you.',
|
|
823
|
+
tested: 'From an earlier test. Change anything that isn\'t right.',
|
|
824
|
+
}[x.source] ?? ''
|
|
825
|
+
$('ms-context').value = x.set?.context ?? ''
|
|
826
|
+
$('ms-context').placeholder = x.context ? `${x.context.toLocaleString()}${x.sure === false ? ' (assumed)' : ''}` : 'tokens'
|
|
827
|
+
$('ms-tools').checked = x.mode ? x.mode === 'tools' : true
|
|
828
|
+
$('ms-thinking').checked = Boolean(x.thinking)
|
|
829
|
+
$('ms-images').checked = Boolean(x.vision)
|
|
830
|
+
$('ms-stream').checked = x.stream !== false
|
|
831
|
+
$('ms').showModal()
|
|
832
|
+
}
|
|
833
|
+
$('ms-save').onclick = async (e) => {
|
|
834
|
+
e.preventDefault()
|
|
835
|
+
const t = setupFor
|
|
836
|
+
const raw = $('ms-context').value.trim()
|
|
837
|
+
const r = await act('/api/model-setup', { provider: t.provider, model: t.model, context: raw ? Number(raw) : null, tools: $('ms-tools').checked, thinking: $('ms-thinking').checked, images: $('ms-images').checked, stream: $('ms-stream').checked }, `${t.model}: saved`)
|
|
838
|
+
if (r) $('ms').close()
|
|
839
|
+
}
|
|
840
|
+
$('ms-cancel').onclick = (e) => (e.preventDefault(), $('ms').close())
|
|
841
|
+
$('ms').querySelector('form').onsubmit = (e) => (e.preventDefault(), $('ms-save').click()) // Enter: save
|
|
842
|
+
// Measure: one request far too big for any model; the server's refusal states its window
|
|
843
|
+
$('ms-measure').onclick = async (e) => {
|
|
844
|
+
e.preventDefault()
|
|
845
|
+
const t = setupFor
|
|
846
|
+
if (!confirm(`Measure ${t.model}'s context window?\n\nHeartCode sends it one request of about 2.2 million tokens (4 MB). Servers refuse it with their limit in the error, and a refused request isn't charged. If a server does take that much, it's stopped at once, but that request may be charged.`)) return
|
|
847
|
+
const b = $('ms-measure')
|
|
848
|
+
Object.assign(b, { disabled: true, textContent: 'measuring…' })
|
|
849
|
+
try {
|
|
850
|
+
const r = await api('/api/measure', { provider: t.provider, model: t.model })
|
|
851
|
+
render(r)
|
|
852
|
+
$('ms-context').value = r.measured.limit ?? r.measured.over
|
|
853
|
+
toast(r.measured.limit ? `${t.model}: ${r.measured.limit.toLocaleString()} tokens, said by the server` : `${t.model} took over ${r.measured.over.toLocaleString()} tokens`)
|
|
854
|
+
} catch (x) {
|
|
855
|
+
toast(x.message, true)
|
|
856
|
+
} finally {
|
|
857
|
+
Object.assign(b, { disabled: false, textContent: 'Measure' })
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// short enough for the two lines a card keeps for it (guided: no tool calls, HeartCode guides it)
|
|
861
|
+
const testedText = (t) => `${t.mode === 'tools' ? 'tool calls' : 'guided'}${t.context ? ` · ${t.sure === false ? '~' : ''}${kTok(t.context)} context` : ''}${t.thinking ? ' · thinks' : ''}${t.vision ? ' · images' : ''}`
|
|
862
|
+
// an error without the request's address in it ("500 from http://…/chat/completions: …" → "500: …")
|
|
863
|
+
const shortError = (e) => {
|
|
864
|
+
const x = String(e ?? '').replace(/ from https?:\/\/\S+?:(?=\s|$)/, ':').replace(/\{\s*"error"\s*:\s*\{[^{}]*?"message"\s*:\s*"((?:[^"\\]|\\.)*)"[\s\S]*$/, '$1').trim() // a JSON error: its message
|
|
865
|
+
return x.length > 120 ? x.slice(0, 119) + '…' : x
|
|
866
|
+
}
|
|
867
|
+
function withTip(el, content) {
|
|
868
|
+
const show = () => {
|
|
869
|
+
if (document.body.classList.contains('sorting')) return // not while a card is being moved
|
|
870
|
+
const tip = $('tip')
|
|
871
|
+
put(tip, content())
|
|
872
|
+
tip.classList.add('show')
|
|
873
|
+
const r = el.getBoundingClientRect()
|
|
874
|
+
const t = tip.getBoundingClientRect()
|
|
875
|
+
const below = r.bottom + 8 + t.height < innerHeight
|
|
876
|
+
tip.style.top = `${below ? r.bottom + 6 : Math.max(8, r.top - t.height - 6)}px`
|
|
877
|
+
tip.style.left = `${Math.min(Math.max(8, r.left), innerWidth - t.width - 8)}px`
|
|
878
|
+
}
|
|
879
|
+
const hide = () => $('tip').classList.remove('show')
|
|
880
|
+
el.addEventListener('mouseenter', show)
|
|
881
|
+
el.addEventListener('focus', show)
|
|
882
|
+
el.addEventListener('mouseleave', hide)
|
|
883
|
+
el.addEventListener('blur', hide)
|
|
884
|
+
el.addEventListener('click', hide)
|
|
885
|
+
return el
|
|
886
|
+
}
|
|
887
|
+
addEventListener('scroll', () => $('tip').classList.remove('show'), true)
|
|
888
|
+
|
|
889
|
+
function drawProviders(s) {
|
|
890
|
+
const order = s.order
|
|
891
|
+
const none = !s.providers.length
|
|
892
|
+
// the steps: always on the page (nothing jumps when one is done), ticked off as you go; with
|
|
893
|
+
// no provider yet, the form is open (the only way in: no other button)
|
|
894
|
+
put($('steps'), [
|
|
895
|
+
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
896
|
+
['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
897
|
+
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
898
|
+
].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
|
|
899
|
+
const showForm = formOpen || none
|
|
900
|
+
$('p-form').hidden = !showForm
|
|
901
|
+
$('p-cancel').hidden = none // nothing to go back to
|
|
902
|
+
$('p-open').hidden = showForm
|
|
903
|
+
$('p-work').hidden = none
|
|
904
|
+
fillHeight()
|
|
905
|
+
|
|
906
|
+
// right: your models, in order (#1 is the one in use); drag or ↑ ↓ to reorder
|
|
907
|
+
const side = innerWidth > 860 ? 'on the left' : 'below' // where the models are (a phone: under this)
|
|
908
|
+
const save = (list, done) => saveOrder(list, done)
|
|
909
|
+
const move = (i, j) => {
|
|
910
|
+
if (i === j) return
|
|
911
|
+
const list = [...order]
|
|
912
|
+
const [t] = list.splice(i, 1)
|
|
913
|
+
list.splice(j, 0, t)
|
|
914
|
+
save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
915
|
+
}
|
|
916
|
+
put($('inuse'),
|
|
917
|
+
h('div', { className: 'mine-top' },
|
|
918
|
+
h('div', { className: 'mine-title' },
|
|
919
|
+
h('h2', { textContent: 'Your models' }),
|
|
920
|
+
h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
|
|
921
|
+
h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
|
|
922
|
+
h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
|
|
923
|
+
h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
|
|
924
|
+
order.length ? h('ol', { className: 'olist' }, order.map((t, i) => h('li', {
|
|
925
|
+
className: 'in', 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
|
|
926
|
+
onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
|
|
927
|
+
},
|
|
928
|
+
order.length > 1 ? h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true' }) : null,
|
|
929
|
+
h('span', { className: 'n', textContent: `#${i + 1}` }),
|
|
930
|
+
h('div', { className: 'what' },
|
|
931
|
+
h('div', { className: 'm', textContent: t.model }),
|
|
932
|
+
h('div', { className: 'p', textContent: `${t.provider} · ${i === 0 ? 'in use' : 'fallback'}` }),
|
|
933
|
+
testLine(t)),
|
|
934
|
+
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) })),
|
|
935
|
+
), h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` }))
|
|
936
|
+
: h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
|
|
937
|
+
order.length > 1 ? h('div', { className: 'mine-rules' },
|
|
938
|
+
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
939
|
+
h('div', { className: 'row', style: 'gap:8px' },
|
|
940
|
+
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
941
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model'], ['any', 'at any error']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
942
|
+
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
943
|
+
h('div', { className: 'hint', textContent: { failure: 'A rate limit, server error, timeout or lost connection moves on at once. Errors like "model not found" or a wrong key stop.', retries: 'The same model is tried again (with a short wait) that many times first.', any: 'Also errors like "model not found" or a bad request move on.' }[s.rotation.on] }),
|
|
944
|
+
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
945
|
+
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
946
|
+
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
947
|
+
|
|
948
|
+
sortable($('inuse').querySelector('.olist'), move)
|
|
949
|
+
|
|
950
|
+
// left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
951
|
+
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
952
|
+
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
|
|
953
|
+
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + (MODELS[p.name]?.loading ? 'loading' : '')), modelQuery.trim()])
|
|
954
|
+
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
955
|
+
catalogShape = shape
|
|
956
|
+
markCatalog()
|
|
957
|
+
$('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models, or type a model name…`
|
|
958
|
+
$('n-providers').textContent = s.providers.length || ''
|
|
959
|
+
}
|
|
960
|
+
// Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
|
|
961
|
+
// others move aside as it goes; let go and it's saved, already in its place (no jump when the
|
|
962
|
+
// page redraws from what's saved).
|
|
963
|
+
function sortable(ol, onMove) {
|
|
964
|
+
if (!ol) return
|
|
965
|
+
ol.addEventListener('pointerdown', (e) => {
|
|
966
|
+
const li = e.target.closest('li.in')
|
|
967
|
+
if (!li || e.button !== 0 || e.target.closest('button, a, input, select, textarea')) return
|
|
968
|
+
if (e.pointerType !== 'mouse' && !e.target.closest('.grip')) return // a finger scrolls the list, except on the grip
|
|
969
|
+
const items = [...ol.querySelectorAll('li.in')]
|
|
970
|
+
if (items.length < 2) return
|
|
971
|
+
const from = items.indexOf(li)
|
|
972
|
+
const rects = items.map((x) => x.getBoundingClientRect())
|
|
973
|
+
const step = rects[1].top - rects[0].top // a card and the space after it
|
|
974
|
+
const centers = rects.map((r) => r.top + r.height / 2)
|
|
975
|
+
const startY = e.clientY
|
|
976
|
+
let to = from
|
|
977
|
+
let moving = false
|
|
978
|
+
const shift = () => items.forEach((x, k) => {
|
|
979
|
+
if (x === li) return
|
|
980
|
+
const by = from < to && k > from && k <= to ? -step : from > to && k >= to && k < from ? step : 0
|
|
981
|
+
x.style.transform = by ? `translateY(${by}px)` : ''
|
|
982
|
+
})
|
|
983
|
+
const onMovePointer = (ev) => {
|
|
984
|
+
const dy = ev.clientY - startY
|
|
985
|
+
if (!moving) {
|
|
986
|
+
if (Math.abs(dy) < 4) return
|
|
987
|
+
moving = true
|
|
988
|
+
li.setPointerCapture(e.pointerId)
|
|
989
|
+
li.classList.add('lifted')
|
|
990
|
+
ol.classList.add('sorting')
|
|
991
|
+
document.body.classList.add('sorting')
|
|
992
|
+
$('tip').classList.remove('show')
|
|
993
|
+
getSelection()?.removeAllRanges()
|
|
994
|
+
}
|
|
995
|
+
const y = Math.max(rects[0].top - rects[from].top, Math.min(rects.at(-1).bottom - rects[from].bottom, dy)) // within the list
|
|
996
|
+
li.style.transform = `translateY(${y}px)`
|
|
997
|
+
const mid = centers[from] + y
|
|
998
|
+
to = items.filter((x, k) => k !== from && centers[k] < mid).length
|
|
999
|
+
shift()
|
|
1000
|
+
}
|
|
1001
|
+
const done = () => {
|
|
1002
|
+
removeEventListener('pointermove', onMovePointer)
|
|
1003
|
+
removeEventListener('pointerup', done)
|
|
1004
|
+
removeEventListener('pointercancel', done)
|
|
1005
|
+
if (!moving) return
|
|
1006
|
+
document.body.classList.remove('sorting')
|
|
1007
|
+
ol.classList.remove('sorting')
|
|
1008
|
+
li.classList.remove('lifted')
|
|
1009
|
+
for (const x of items) {
|
|
1010
|
+
x.style.transition = 'none' // into place at once: the cards are where they look
|
|
1011
|
+
x.style.transform = ''
|
|
1012
|
+
}
|
|
1013
|
+
if (to !== from) items[to].insertAdjacentElement(to > from ? 'afterend' : 'beforebegin', li)
|
|
1014
|
+
ol.querySelectorAll('li.in').forEach((x, k) => (x.querySelector('.n').textContent = `#${k + 1}`))
|
|
1015
|
+
requestAnimationFrame(() => items.forEach((x) => (x.style.transition = '')))
|
|
1016
|
+
onMove(from, to)
|
|
1017
|
+
}
|
|
1018
|
+
addEventListener('pointermove', onMovePointer)
|
|
1019
|
+
addEventListener('pointerup', done)
|
|
1020
|
+
addEventListener('pointercancel', done)
|
|
1021
|
+
})
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// --- the models on the left: rows that read your list when clicked or hovered (S.order, now) ---
|
|
1025
|
+
let catalogShape = null
|
|
1026
|
+
const placeOf = (provider, id) => {
|
|
1027
|
+
const i = S.order.findIndex((t) => t.provider === provider && t.model === id)
|
|
1028
|
+
return i < 0 ? undefined : i
|
|
1029
|
+
}
|
|
1030
|
+
// A model's row on the left: scrolled to and lit up for a moment (one just added by name).
|
|
1031
|
+
function showRow(provider, id, said) {
|
|
1032
|
+
toast(said)
|
|
1033
|
+
const row = $('p-list').querySelector(`.mrow[data-provider="${CSS.escape(provider)}"][data-model="${CSS.escape(id)}"]`)
|
|
1034
|
+
if (!row) return
|
|
1035
|
+
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
1036
|
+
row.classList.remove('flash')
|
|
1037
|
+
void row.offsetWidth // restart the animation
|
|
1038
|
+
row.classList.add('flash')
|
|
1039
|
+
}
|
|
1040
|
+
// Off your list, the one in use too: the next one becomes #1, or none is left (asked first, then).
|
|
1041
|
+
const removeMine = (provider, id) => {
|
|
1042
|
+
const rest = S.order.filter((t) => t.provider !== provider || t.model !== id)
|
|
1043
|
+
if (!rest.length && !confirm(`Take off ${id}? It's your only model: HeartCode then has none to use until you choose one.`)) return
|
|
1044
|
+
return saveOrder(rest, !rest.length ? `${id} removed: no model chosen now` : placeOf(provider, id) === 0 ? `${id} removed: HeartCode now uses ${rest[0].provider} · ${rest[0].model}` : `${id} removed`)
|
|
1045
|
+
}
|
|
1046
|
+
const addModel = (provider, id) => {
|
|
1047
|
+
const order = S.order
|
|
1048
|
+
return order.length ? saveOrder([...order, { provider, model: id }], `${id} added as #${order.length + 1}`) : useModel(provider, id)
|
|
1049
|
+
}
|
|
1050
|
+
function rowTail(provider, id) {
|
|
1051
|
+
const at = placeOf(provider, id)
|
|
1052
|
+
return at === 0 ? h('span', { className: 'tag brand', textContent: '#1 in use' }) : at > 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
|
|
1053
|
+
}
|
|
1054
|
+
function buildCatalog(s) {
|
|
1055
|
+
const q = modelQuery.trim()
|
|
1056
|
+
// a name no provider lists (anywhere): offered under each provider, to use it anyway
|
|
1057
|
+
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
1058
|
+
put($('p-list'), s.providers.map((p) => {
|
|
1059
|
+
if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
|
|
1060
|
+
else loadModels(p.name, MODELS[p.name]?.off)
|
|
1061
|
+
const m = MODELS[p.name] ?? { loading: true }
|
|
1062
|
+
// and the models added by name (and any of yours it doesn't list), marked as yours
|
|
1063
|
+
const listed = new Set(m.list ?? [])
|
|
1064
|
+
const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
|
|
1065
|
+
const ids = [...listed, ...yours].sort(byName).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
|
|
1066
|
+
const row = (id, own) =>
|
|
1067
|
+
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)) },
|
|
1068
|
+
h('span', { className: 'id', textContent: id }),
|
|
1069
|
+
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1070
|
+
rowTail(p.name, id))
|
|
1071
|
+
// one you added: ✕ forgets it (it leaves your list too)
|
|
1072
|
+
const item = (id) => !p.added.includes(id) ? row(id) : h('div', { className: 'mwrap' }, row(id),
|
|
1073
|
+
h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
|
|
1074
|
+
if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
|
|
1075
|
+
if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
|
|
1076
|
+
act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
|
|
1077
|
+
} }))
|
|
1078
|
+
const typed = !anyMatch ? [row(q, true)] : []
|
|
1079
|
+
if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
|
|
1080
|
+
// a model its list leaves out (some servers don't list them all): by its exact name
|
|
1081
|
+
const name = h('input', { type: 'text', placeholder: m.off ? 'List off: a model\'s exact name' : 'Custom model: its exact name', 'aria-label': `add a model to ${p.name} by name` })
|
|
1082
|
+
const go = () => {
|
|
1083
|
+
const id = name.value.trim()
|
|
1084
|
+
if (!id) return name.focus()
|
|
1085
|
+
name.value = ''
|
|
1086
|
+
// only under this provider, with its others: you choose whether to use it (click it, like any)
|
|
1087
|
+
if ([...(MODELS[p.name]?.list ?? []), ...p.added].includes(id)) return showRow(p.name, id, `${id} is in ${p.name}'s models already`)
|
|
1088
|
+
act('/api/provider/added', { name: p.name, model: id }).then((r) => r && showRow(p.name, id, `${id} added to ${p.name}'s models: click it to use it`))
|
|
1089
|
+
}
|
|
1090
|
+
name.addEventListener('keydown', (e) => e.key === 'Enter' && go())
|
|
1091
|
+
return h('section', { 'data-provider': p.name },
|
|
1092
|
+
// the header folds it (a click on it, or its arrow; not on its buttons and switch)
|
|
1093
|
+
h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
|
|
1094
|
+
h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
|
|
1095
|
+
h('div', { className: 'who' },
|
|
1096
|
+
h('span', { className: 'name', textContent: p.name }), ' ',
|
|
1097
|
+
m.off ? h('span', { className: 'tag', textContent: 'list off' }) : 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 ok', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
|
|
1098
|
+
' ', h('span', { className: 'tag inlist' }),
|
|
1099
|
+
h('div', { className: 'meta' }, h('span', { className: 'mono', textContent: p.baseUrl }), ' · key ', h('span', { className: 'mono', textContent: p.key || 'none' })),
|
|
1100
|
+
withTip(h('label', { className: 'listsw' }, sw({ checked: p.list, 'aria-label': `load ${p.name}'s model list`, onchange: (e) => act('/api/provider/list', { name: p.name, on: e.target.checked }, `${p.name}: model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load its model list'),
|
|
1101
|
+
() => 'On: its models are loaded from the provider. Off: never asked for; only the models you add by name show.')),
|
|
1102
|
+
h('button', { className: 'small', textContent: 'Edit', onclick: () => openForm(p) }),
|
|
1103
|
+
h('button', { className: 'small danger', textContent: 'Delete', onclick: () => confirm(`Delete the provider "${p.name}"? Its models leave your list too.`) && act('/api/provider/delete', { name: p.name }, `deleted ${p.name}`) })),
|
|
1104
|
+
h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go })),
|
|
1105
|
+
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1106
|
+
typed.length || ids.length ? h('div', { className: 'rows' }, typed, ids.map(item)) : null,
|
|
1107
|
+
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 exact name, above.' })) : null,
|
|
1108
|
+
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1109
|
+
}))
|
|
1110
|
+
}
|
|
1111
|
+
// --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
|
|
1112
|
+
// for the ones before it), the sort kept in this browser ---
|
|
1113
|
+
const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.tools !== false, thinking: Boolean(t.tested ? t.tested.thinking : t.known?.thinking), images: Boolean(t.tested ? t.tested.vision : t.known?.images) })
|
|
1114
|
+
// how: text (A to Z first), cheap (lowest first), else most first
|
|
1115
|
+
const COLS = {
|
|
1116
|
+
model: { label: 'Model', how: 'text', get: (t) => t.model },
|
|
1117
|
+
provider: { label: 'Provider', how: 'text', get: (t) => t.provider },
|
|
1118
|
+
context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
|
|
1119
|
+
abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
|
|
1120
|
+
tools: { label: 'Tools', get: (t) => can(t).tools },
|
|
1121
|
+
thinking: { label: 'Thinking', get: (t) => can(t).thinking },
|
|
1122
|
+
images: { label: 'Images', get: (t) => can(t).images },
|
|
1123
|
+
input: { label: 'In $/M', how: 'cheap', get: (t) => t.price?.input ?? null },
|
|
1124
|
+
output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
|
|
1125
|
+
}
|
|
1126
|
+
let SORT = (() => {
|
|
1127
|
+
try {
|
|
1128
|
+
return JSON.parse(localStorage.getItem('heart.sort') ?? '[]').filter((x) => COLS[x?.key])
|
|
1129
|
+
} catch {
|
|
1130
|
+
return []
|
|
1131
|
+
}
|
|
1132
|
+
})()
|
|
1133
|
+
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1134
|
+
function setSort(next) {
|
|
1135
|
+
SORT = next
|
|
1136
|
+
try {
|
|
1137
|
+
localStorage.setItem('heart.sort', JSON.stringify(SORT))
|
|
1138
|
+
} catch {}
|
|
1139
|
+
drawMine(S)
|
|
1140
|
+
}
|
|
1141
|
+
// a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
|
|
1142
|
+
function clickSort(key) {
|
|
1143
|
+
const at = SORT.find((x) => x.key === key)
|
|
1144
|
+
if (!at) return setSort([...SORT, { key, dir: firstDir(key) }])
|
|
1145
|
+
if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
|
|
1146
|
+
setSort(SORT.filter((x) => x !== at))
|
|
1147
|
+
}
|
|
1148
|
+
function sorted(order) {
|
|
1149
|
+
const cmp = (a, b, { key, dir }) => {
|
|
1150
|
+
let x = COLS[key].get(a)
|
|
1151
|
+
let y = COLS[key].get(b)
|
|
1152
|
+
if (x === y) return 0
|
|
1153
|
+
if (x == null) return 1 // unknown (no price): last, either way
|
|
1154
|
+
if (y == null) return -1
|
|
1155
|
+
const r = typeof x === 'string' ? byName(x, y) : Number(x) - Number(y)
|
|
1156
|
+
return dir === 'up' ? r : -r
|
|
1157
|
+
}
|
|
1158
|
+
return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
|
|
1159
|
+
}
|
|
1160
|
+
const dirText = (key, dir) => (COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
|
|
1161
|
+
function drawMine(s) {
|
|
1162
|
+
const order = s.order
|
|
1163
|
+
$('n-mine').textContent = order.length || ''
|
|
1164
|
+
const view = sorted(order)
|
|
1165
|
+
const same = view.every((t, i) => t === order[i])
|
|
1166
|
+
// the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
|
|
1167
|
+
const keep = (list, moved) => {
|
|
1168
|
+
SORT = []
|
|
1169
|
+
try {
|
|
1170
|
+
localStorage.setItem('heart.sort', '[]')
|
|
1171
|
+
} catch {}
|
|
1172
|
+
saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1173
|
+
}
|
|
1174
|
+
// a row dragged (in the order shown, sorted or not)
|
|
1175
|
+
const move = (i, j) => {
|
|
1176
|
+
if (i === j) return
|
|
1177
|
+
const list = [...view]
|
|
1178
|
+
const [t] = list.splice(i, 1)
|
|
1179
|
+
list.splice(j, 0, t)
|
|
1180
|
+
keep(list, true)
|
|
1181
|
+
}
|
|
1182
|
+
const yes = (v) => h('span', { className: v ? 'yes' : 'no', textContent: v ? '✓' : '–' })
|
|
1183
|
+
const src = (t) => {
|
|
1184
|
+
const x = t.tested
|
|
1185
|
+
if (!x) return h('div', { className: 'src', textContent: 'looking it up…' })
|
|
1186
|
+
if (x.source === 'assumed') return h('div', { className: 'src warn', title: "models.dev doesn't know it: Retry, or set up what it can do" }, 'not found', retry(t), h('button', { className: 'small', textContent: 'Set up', onclick: () => openSetup(t) }))
|
|
1187
|
+
return h('div', { className: 'src' }, Object.keys(x.set ?? {}).length ? 'set by you' : x.source === 'models.dev' ? 'models.dev' : FROM[x.source], h('button', { className: 'small', textContent: 'Edit', onclick: () => openSetup(t) }))
|
|
1188
|
+
}
|
|
1189
|
+
const head = (key, num) => {
|
|
1190
|
+
const at = SORT.findIndex((x) => x.key === key)
|
|
1191
|
+
return h('button', { type: 'button', className: (at >= 0 ? 'on ' : '') + (num ? 'num' : ''), title: `sort by ${COLS[key].label.toLowerCase()}`, onclick: () => clickSort(key) },
|
|
1192
|
+
COLS[key].label, at >= 0 ? ` ${SORT[at].dir === 'up' ? '↑' : '↓'}${SORT.length > 1 ? at + 1 : ''}` : '')
|
|
1193
|
+
}
|
|
1194
|
+
put($('mt'),
|
|
1195
|
+
h('div', { className: 'mt-top' },
|
|
1196
|
+
h('div', { className: 'sorts' }, SORT.length
|
|
1197
|
+
? ['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)) })))]
|
|
1198
|
+
: order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
|
|
1199
|
+
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1200
|
+
SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
|
|
1201
|
+
order.length ? h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', onclick: () => lookUp() }) : null),
|
|
1202
|
+
order.length
|
|
1203
|
+
? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
|
|
1204
|
+
h('div', { className: 'mt-h' }, h('span'), h('span', { textContent: '#' }), head('model'), head('provider'), head('context', true), head('abilities', true), head('tools'), head('thinking'), head('images'), head('input', true), head('output', true), h('span', { textContent: 'From' }), h('span')),
|
|
1205
|
+
h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }, view.map((t) => {
|
|
1206
|
+
const c = can(t)
|
|
1207
|
+
const ctx = COLS.context.get(t)
|
|
1208
|
+
return h('li', { className: 'in', 'data-key': `${t.provider}/${t.model}` },
|
|
1209
|
+
h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
|
|
1210
|
+
h('span', { className: 'n', textContent: `#${order.indexOf(t) + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
|
|
1211
|
+
h('span', { className: 'm', textContent: t.model }),
|
|
1212
|
+
h('span', { className: 'p', textContent: t.provider }),
|
|
1213
|
+
h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
|
|
1214
|
+
h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
|
|
1215
|
+
yes(c.tools), yes(c.thinking), yes(c.images),
|
|
1216
|
+
h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
|
|
1217
|
+
h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
|
|
1218
|
+
src(t),
|
|
1219
|
+
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1220
|
+
}))))
|
|
1221
|
+
: h('div', { className: 'mt-empty' }, 'No models in your list yet. ', h('a', { href: '#providers', textContent: 'Choose some under Providers & models' }), '.'))
|
|
1222
|
+
sortable($('mt').querySelector('.mtable'), move)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// your list changed: the rows and counts it touches, in place
|
|
1226
|
+
function markCatalog() {
|
|
1227
|
+
for (const r of $('p-list').querySelectorAll('.mrow')) {
|
|
1228
|
+
const at = placeOf(r.dataset.provider, r.dataset.model)
|
|
1229
|
+
r.classList.toggle('taken', at !== undefined)
|
|
1230
|
+
r.lastElementChild.replaceWith(rowTail(r.dataset.provider, r.dataset.model))
|
|
1231
|
+
}
|
|
1232
|
+
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
1233
|
+
const n = S.order.filter((t) => t.provider === sec.dataset.provider).length
|
|
1234
|
+
const tag = sec.querySelector('.inlist')
|
|
1235
|
+
tag.hidden = !n
|
|
1236
|
+
tag.textContent = `${n} in your list${S.order[0]?.provider === sec.dataset.provider ? ' · #1' : ''}`
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function drawUsing(s) {
|
|
1241
|
+
const o = s.order
|
|
1242
|
+
$('using').hidden = page() === 'providers' // there it's on the page itself
|
|
1243
|
+
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' })])
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// --- General ---
|
|
1247
|
+
const PERM_INFO = { all: ['Everything', 'Edits files and runs commands without asking'], commands: ['Edits only', 'Edits files; asks before running commands'], ask: ['Ask first', 'Asks before every edit and command'] }
|
|
1248
|
+
const THEME_INFO = { dark: ["Your terminal's background", ['#1f2328', '#d4d4d8', '#ff6b9d', '#7aa2f7']], light: ['White background', ['#fafbfc', '#24292f', '#d6336c', '#0969da']], midnight: ['Deep blue background', ['#1a1b26', '#c0caf5', '#ff7ab2', '#7aa2f7']], mono: ['No colors: bold and dim', ['#000000', '#bbbbbb', '#ffffff', '#777777']] }
|
|
1249
|
+
const TOGGLE_INFO = { autoCompact: 'Compact long conversations', director: 'Let the model set things up', format: 'Format files after edits', lsp: 'Language server checks' }
|
|
1250
|
+
function choices(id, list, cur, name) {
|
|
1251
|
+
put($(id), list.map(([v, title, desc, colors]) => h('label', { className: 'choice' + (v === cur ? ' on' : '') },
|
|
1252
|
+
h('input', { type: 'radio', name: id, checked: v === cur, onchange: () => act('/api/setting', { name, value: v }, `${title}: saved`) }),
|
|
1253
|
+
colors ? h('div', { className: 'swatch' }, colors.map((c) => h('i', { style: `background:${c}` }))) : null,
|
|
1254
|
+
h('b', { textContent: title }), h('span', { textContent: desc }))))
|
|
1255
|
+
}
|
|
1256
|
+
function drawGeneral(s) {
|
|
1257
|
+
choices('g-perm', ['all', 'commands', 'ask'].map((p) => [p, ...PERM_INFO[p]]), s.settings.permissions, 'permissions')
|
|
1258
|
+
choices('g-theme', s.choices.theme.map((t) => [t, t[0].toUpperCase() + t.slice(1), ...(THEME_INFO[t] ?? ['', null])]), s.settings.theme, 'theme')
|
|
1259
|
+
put($('g-effort'), s.choices.effort.map((e) => h('option', { value: e, textContent: { default: "The model's own setting", low: 'Low: quick, less thinking', medium: 'Medium', high: 'High: thinks the longest' }[e] ?? e })))
|
|
1260
|
+
$('g-effort').value = s.settings.effort
|
|
1261
|
+
if (document.activeElement !== $('g-context')) $('g-context').value = s.settings.context ?? ''
|
|
1262
|
+
put($('g-toggles'), Object.entries(s.choices.toggles).map(([k, d]) => h('label', { className: 'opt' },
|
|
1263
|
+
h('div', { className: 't' }, h('b', { textContent: TOGGLE_INFO[k] ?? k }), h('span', { textContent: d[0].toUpperCase() + d.slice(1) })),
|
|
1264
|
+
sw({ checked: s.settings[k], onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${TOGGLE_INFO[k] ?? k}: ${e.target.checked ? 'on' : 'off'}`) }))))
|
|
1265
|
+
if (document.activeElement !== $('v-text')) $('v-text').value = s.voice
|
|
1266
|
+
}
|
|
1267
|
+
$('g-effort').onchange = (e) => act('/api/setting', { name: 'effort', value: e.target.value }, 'effort saved')
|
|
1268
|
+
$('g-context').onchange = (e) => act('/api/setting', { name: 'context', value: e.target.value.trim() ? Number(e.target.value) : null }, 'context window saved')
|
|
1269
|
+
$('v-save').onclick = () => act('/api/voice', { text: $('v-text').value }, 'voice saved')
|
|
1270
|
+
|
|
1271
|
+
// --- Logging: the two switches, how much of each request; the requests logged (live) ---
|
|
1272
|
+
const logSwitch = (s, k, title, about) => h('label', { className: 'opt' },
|
|
1273
|
+
h('div', { className: 't' }, h('b', { textContent: title }), h('span', { textContent: about })),
|
|
1274
|
+
sw({ checked: s.settings[k], 'aria-label': title, onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${title}: ${e.target.checked ? 'on' : 'off'}`) }))
|
|
1275
|
+
function drawLogging(s) {
|
|
1276
|
+
put($('l-run'), logSwitch(s, 'log', 'Keep a log of every run', `From now on: a folder per run in ${s.logs.runs}, a file per tab (tab-1.log, tab-2.log…).`))
|
|
1277
|
+
put($('l-req'), logSwitch(s, 'logRequests', 'Log every request and reply', `Right away: a file per request in ${s.logs.requests}, a folder per run.`))
|
|
1278
|
+
$('l-detail').value = s.settings.logDetail
|
|
1279
|
+
if (document.activeElement !== $('l-days')) $('l-days').value = s.settings.logDays
|
|
1280
|
+
$('l-detail').disabled = !s.settings.logRequests
|
|
1281
|
+
}
|
|
1282
|
+
$('l-days').onchange = (e) => {
|
|
1283
|
+
const days = Number(e.target.value)
|
|
1284
|
+
if (!Number.isInteger(days) || days < 1) return toast('a whole number of days, 1 or more', true), (e.target.value = S.settings.logDays)
|
|
1285
|
+
act('/api/setting', { name: 'logDays', value: days }, `logs kept for ${days} day${days === 1 ? '' : 's'}; older ones deleted`)
|
|
1286
|
+
}
|
|
1287
|
+
$('l-detail').onchange = (e) => act('/api/setting', { name: 'logDetail', value: e.target.value }, 'saved: from the next request')
|
|
1288
|
+
|
|
1289
|
+
let REQS = null // the requests logged, newest first
|
|
1290
|
+
let reqOpen = null // the one shown in full: "run/file"
|
|
1291
|
+
const reqFilter = () => document.querySelector('input[name="l-filter"]:checked').value
|
|
1292
|
+
const reqShown = (r) => ({ all: true, failed: r.result === 'failed', fallback: r.place > 1 })[reqFilter()]
|
|
1293
|
+
for (const r of document.querySelectorAll('input[name="l-filter"]')) r.onchange = () => {
|
|
1294
|
+
for (const c of document.querySelectorAll('#l-filter label')) c.classList.toggle('on', c.querySelector('input').checked)
|
|
1295
|
+
drawReqs()
|
|
1296
|
+
}
|
|
1297
|
+
const when = (iso) => {
|
|
1298
|
+
const d = new Date(iso)
|
|
1299
|
+
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
1300
|
+
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}`
|
|
1301
|
+
}
|
|
1302
|
+
const secs = (ms) => (ms == null ? '' : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
|
|
1303
|
+
const tokensOf = (u) => (u ? `${u.prompt_tokens ?? '?'} in · ${u.completion_tokens ?? '?'} out` : '')
|
|
1304
|
+
// what went wrong, in the server's words when it said some (an error's JSON body), else the message
|
|
1305
|
+
const whyOf = (r) => {
|
|
1306
|
+
try {
|
|
1307
|
+
const e = JSON.parse(r.errorBody).error
|
|
1308
|
+
if (e) return `${r.status ? `${r.status}: ` : ''}${typeof e === 'string' ? e : e.message ?? JSON.stringify(e)}`
|
|
1309
|
+
} catch {}
|
|
1310
|
+
return r.error
|
|
1311
|
+
}
|
|
1312
|
+
const resultTag = (r) => h('span', { className: `tag ${r.result === 'ok' ? 'ok' : r.result === 'failed' ? 'err' : ''}`, textContent: r.result === 'ok' ? 'ok' : r.result === 'failed' ? `failed${r.status ? ` ${r.status}` : ''}` : 'stopped' })
|
|
1313
|
+
const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : r.of > 1 ? h('span', { className: 'tag', textContent: `#1 of ${r.of}` }) : null)
|
|
1314
|
+
function reqRow(r) {
|
|
1315
|
+
const key = `${r.run}/${r.file}`
|
|
1316
|
+
return h('button', { className: `rq ${r.result}${reqOpen === key ? ' on' : ''}`, 'data-key': key, onclick: () => openReq(r) },
|
|
1317
|
+
h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model }), placeTag(r)),
|
|
1318
|
+
h('div', { className: 'l2' },
|
|
1319
|
+
h('span', { textContent: when(r.at) }), h('span', { textContent: r.provider ?? '' }), h('span', { textContent: secs(r.ms) }),
|
|
1320
|
+
r.usage ? h('span', { textContent: tokensOf(r.usage) }) : null,
|
|
1321
|
+
r.purpose && r.purpose !== 'chat' ? h('span', { textContent: r.purpose === 'test' ? 'model test' : r.purpose }) : null,
|
|
1322
|
+
r.tab ? h('span', { textContent: `tab ${r.tab}` }) : null),
|
|
1323
|
+
r.result === 'failed' && r.error ? h('div', { className: 'why', textContent: whyOf(r), title: r.error }) : null)
|
|
1324
|
+
}
|
|
1325
|
+
function drawReqs() {
|
|
1326
|
+
if (!REQS) return put($('l-list'), h('div', { className: 'empty', textContent: 'Loading…' }))
|
|
1327
|
+
const shown = REQS.filter(reqShown)
|
|
1328
|
+
const failed = REQS.filter((r) => r.result === 'failed').length
|
|
1329
|
+
$('n-failed').textContent = failed ? `${failed} failed` : ''
|
|
1330
|
+
put($('l-list'), shown.length ? shown.map(reqRow) : h('div', { className: 'empty', textContent: REQS.length ? 'None of these.' : S?.settings.logRequests ? 'Nothing yet: requests show here as HeartCode sends them.' : 'Nothing logged. Switch on "Log every request and reply" above.' }))
|
|
1331
|
+
}
|
|
1332
|
+
async function openReq(r) {
|
|
1333
|
+
reqOpen = `${r.run}/${r.file}`
|
|
1334
|
+
for (const b of $('l-list').querySelectorAll('.rq')) b.classList.toggle('on', b.dataset.key === reqOpen)
|
|
1335
|
+
let d
|
|
1336
|
+
try {
|
|
1337
|
+
d = await api('/api/request', { run: r.run, file: r.file })
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
return put($('l-detail-pane'), h('div', { className: 'empty', textContent: `Can't read it: ${e.message}` }))
|
|
1340
|
+
}
|
|
1341
|
+
const row = (k, v) => (v === undefined || v === null || v === '' ? null : [h('dt', { textContent: k }), h('dd', { textContent: String(v) })])
|
|
1342
|
+
const pre = (text) => h('pre', { textContent: typeof text === 'string' ? text : JSON.stringify(text, null, 2) })
|
|
1343
|
+
const msgs = d.request?.messages ?? []
|
|
1344
|
+
put($('l-detail-pane'),
|
|
1345
|
+
h('div', { className: 'l1', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' }, resultTag(d), h('b', { className: 'mono', textContent: d.model }), placeTag(d)),
|
|
1346
|
+
h('dl', { className: 'kv' },
|
|
1347
|
+
row('When', new Date(d.at).toLocaleString()), row('Provider', d.provider), row('Address', d.url),
|
|
1348
|
+
row('Where in your list', d.of > 1 ? `#${d.place} of ${d.of}${d.place > 1 ? ' (a fallback: the ones before it failed)' : ''}` : null),
|
|
1349
|
+
row('For', d.purpose === 'test' ? 'testing the model' : d.purpose === 'btw' ? 'a /btw question' : 'the conversation'), row('Tab', d.tab),
|
|
1350
|
+
row('Took', secs(d.ms)), row('First token after', secs(d.firstTokenMs)), row('Tokens', tokensOf(d.usage)), row('Finished because', d.finishReason),
|
|
1351
|
+
row('HTTP status', d.status), row('File', `${S?.logs.requests ?? ''}/${r.run}/${r.file}`)),
|
|
1352
|
+
d.error ? [h('h3', { textContent: 'Error' }), pre(whyOf(d))] : null,
|
|
1353
|
+
d.errorBody && d.errorBody !== d.error ? h('details', {}, h('summary', { textContent: 'The whole error' }), pre(`${d.error}\n\n${d.errorBody}`)) : null,
|
|
1354
|
+
d.request === undefined && d.requestChars !== undefined ? h('p', { className: 'hint', textContent: `Saved as a summary: ${d.messages} messages, ${d.requestChars} characters sent; ${d.replyChars} characters and ${d.toolCalls} tool calls back. Choose "Everything" above to keep what was sent and what came back.` }) : null,
|
|
1355
|
+
d.reply ? [h('h3', { textContent: 'Reply' }),
|
|
1356
|
+
d.reply.toolCalls?.length ? h('div', {}, h('div', { className: 'muted', textContent: `${d.reply.toolCalls.length} tool call${d.reply.toolCalls.length > 1 ? 's' : ''}` }), pre(d.reply.toolCalls.map((t) => `${t.function?.name}(${t.function?.arguments ?? ''})`).join('\n'))) : null,
|
|
1357
|
+
d.reply.content ? pre(d.reply.content) : null,
|
|
1358
|
+
d.reply.reasoning ? h('details', {}, h('summary', { textContent: 'Thinking' }), pre(d.reply.reasoning)) : null] : null,
|
|
1359
|
+
d.request ? [h('h3', { textContent: `Sent: ${msgs.length} message${msgs.length === 1 ? '' : 's'}${d.request.tools?.length ? `, ${d.request.tools.length} tools` : ''}` }),
|
|
1360
|
+
msgs.map((m, i) => h('details', { open: i === msgs.length - 1 }, h('summary', { textContent: `${m.role}${m.tool_calls ? ' (tool calls)' : ''}${m.name ? ` · ${m.name}` : ''}` }), pre(m.content ?? m.tool_calls ?? ''))),
|
|
1361
|
+
h('details', {}, h('summary', { textContent: 'The whole request (JSON)' }), pre(d.request))] : null)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// --- prompts: a list, and the chosen one to edit ---
|
|
1365
|
+
let promptQuery = ''
|
|
1366
|
+
$('r-search').oninput = (e) => ((promptQuery = e.target.value.toLowerCase()), S && drawPrompts(S))
|
|
1367
|
+
function drawPrompts(s) {
|
|
1368
|
+
const match = (p) => !promptQuery || `${p.id} ${p.title ?? ''} ${p.note}`.toLowerCase().includes(promptQuery)
|
|
1369
|
+
const item = (p) => ({ id: p.id, title: p.title ? p.title.replace(/^System prompt /, 'System ') : p.id.split('/')[1], sub: p.title ? p.id : p.note.split('\n')[0], badge: p.changed ? h('span', { className: 'tag brand', textContent: 'yours' }) : null, p })
|
|
1370
|
+
const groups = [{ title: 'System prompts', items: s.prompts.filter((p) => p.title && match(p)).map(item) }]
|
|
1371
|
+
for (const file of ['agent', 'guided', 'compact', 'probe']) groups.push({ title: { agent: 'Tools, notes and replies', guided: 'Guided mode (models without tool calls)', compact: 'Summaries', probe: 'Model tests' }[file], items: s.prompts.filter((p) => !p.title && p.id.startsWith(file + '/') && match(p)).map(item) })
|
|
1372
|
+
masterDetail('prompts', $('r-items'), $('r-detail'), groups, (x) => {
|
|
1373
|
+
if (!x) return h('div', { className: 'card empty', textContent: 'No prompt matches.' })
|
|
1374
|
+
const p = x.p
|
|
1375
|
+
const ta = editor('prompts', p.id, p.text)
|
|
1376
|
+
return h('div', { className: 'card' },
|
|
1377
|
+
h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', textContent: p.title ?? p.id }), p.changed ? h('span', { className: 'tag brand', textContent: 'yours' }) : h('span', { className: 'tag', textContent: "HeartCode's" })),
|
|
1378
|
+
h('div', { className: 'mono muted', style: 'margin:2px 0 10px', textContent: p.id }),
|
|
1379
|
+
p.note ? h('p', { className: 'sub', textContent: p.note, style: 'white-space:pre-wrap' }) : null,
|
|
1380
|
+
ta,
|
|
1381
|
+
h('div', { className: 'actions' },
|
|
1382
|
+
h('button', { className: 'primary', textContent: 'Save', onclick: () => saveItem('prompts', p.id, '/api/prompt', { id: p.id, text: ta.value }, 'saved: used from the next message') }),
|
|
1383
|
+
DRAFTS.prompts[p.id] !== undefined ? h('button', { className: 'ghost', textContent: 'Undo my changes', onclick: () => (saved('prompts', p.id), drawPrompts(S)) }) : null,
|
|
1384
|
+
h('span', { className: 'spacer' }),
|
|
1385
|
+
p.changed ? h('button', { textContent: "Reset to HeartCode's", onclick: () => confirm("Put HeartCode's prompt back? Yours is deleted.") && saveItem('prompts', p.id, '/api/prompt', { id: p.id, text: null }, "HeartCode's prompt again") }) : null))
|
|
1386
|
+
})
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// --- MCP ---
|
|
1390
|
+
const mcpKind = () => document.querySelector('input[name="c-kind"]:checked').value
|
|
1391
|
+
for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange = () => {
|
|
1392
|
+
for (const c of document.querySelectorAll('#c-kind label')) c.classList.toggle('on', c.querySelector('input').checked)
|
|
1393
|
+
$('c-what-label').textContent = mcpKind() === 'url' ? 'URL' : 'Command'
|
|
1394
|
+
$('c-what').placeholder = mcpKind() === 'url' ? 'https://mcp.example.com/mcp' : 'npx -y @modelcontextprotocol/server-github'
|
|
1395
|
+
}
|
|
1396
|
+
$('c-add').onclick = async () => {
|
|
1397
|
+
const r = await act('/api/mcp/add', { name: $('c-name').value, [mcpKind()]: $('c-what').value }, `added ${$('c-name').value.trim()}: it starts from the next message`)
|
|
1398
|
+
if (r) $('c-name').value = $('c-what').value = ''
|
|
1399
|
+
}
|
|
1400
|
+
function drawMcp(s) {
|
|
1401
|
+
put($('c-list'), h('h2', { textContent: 'Servers' }), h('p', { className: 'sub', textContent: 'Built in, and yours. Switch one off to keep it without using it.' }),
|
|
1402
|
+
s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
|
|
1403
|
+
h('div', { className: 't' }, h('b', {}, m.name, ' ', h('span', { className: 'tag', textContent: m.from })), m.description ? h('span', { textContent: m.description }) : null, h('div', { className: 'mono muted', style: 'word-break:break-all;margin-top:3px', textContent: m.what })),
|
|
1404
|
+
m.from === 'yours' ? h('button', { className: 'small danger', textContent: 'Remove', onclick: () => confirm(`Remove the MCP server ${m.name}?`) && act('/api/mcp/remove', { name: m.name }, `removed ${m.name}`) }) : null,
|
|
1405
|
+
sw({ checked: m.on, 'aria-label': `${m.name} on`, onchange: (e) => act('/api/mcp/toggle', { name: m.name, on: e.target.checked }, `${m.name}: ${e.target.checked ? 'on' : 'off'}`) }))) : h('div', { className: 'empty', textContent: 'No MCP servers.' }))
|
|
1406
|
+
const f = s.files.find((x) => x.name === 'mcp.json')
|
|
1407
|
+
const ta = editor('files', 'mcp.json', f.text, { placeholder: f.example })
|
|
1408
|
+
put($('c-file'), ta, h('div', { className: 'actions' }, h('button', { className: 'primary', textContent: 'Save mcp.json', onclick: () => saveItem('files', 'mcp.json', '/api/file', { name: 'mcp.json', text: ta.value }, 'saved mcp.json: the servers start from the next message') })))
|
|
1409
|
+
$('n-mcp').textContent = s.mcp.filter((m) => m.on).length || ''
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// --- skills: a form (name, who uses it, what it's for, the prompt); SKILL.md is made from it ---
|
|
1413
|
+
const USES = [['both', 'You and the model', 'type /name, or the model uses it when it fits'], ['me', 'Only you', 'only when you type /name'], ['model', 'Only the model', "when it fits; you don't type it"]]
|
|
1414
|
+
function parseSkill(text) {
|
|
1415
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
1416
|
+
const out = { description: '', use: 'both', extra: [], body: (m ? m[2] : text).replace(/^\s*\n/, '') }
|
|
1417
|
+
for (const line of (m?.[1] ?? '').split('\n')) {
|
|
1418
|
+
const kv = /^([\w-]+):\s*(.*)$/.exec(line.trim())
|
|
1419
|
+
if (kv && (kv[1] === 'description' || kv[1] === 'use')) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '')
|
|
1420
|
+
else if (line.trim()) out.extra.push(line) // the rest (name, argument-hint…) stays as it was
|
|
1421
|
+
}
|
|
1422
|
+
return out
|
|
1423
|
+
}
|
|
1424
|
+
const skillText = (k) => `---\n${k.description.trim() ? `description: ${k.description.trim().replace(/\n/g, ' ')}\n` : ''}use: ${k.use}\n${k.extra.map((l) => l + '\n').join('')}---\n\n${k.body.trim()}\n`
|
|
1425
|
+
const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
|
|
1426
|
+
$('k-new').onclick = () => ((SEL.skills = '+new'), S && drawSkills(S))
|
|
1427
|
+
function skillForm(id, start, { isNew, readOnly }) {
|
|
1428
|
+
const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
|
|
1429
|
+
const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
|
|
1430
|
+
const field = (key, props) => h('input', { type: 'text', value: d[key], readOnly, oninput: (e) => ((d[key] = e.target.value), touch()), ...props })
|
|
1431
|
+
const uses = h('div', { className: 'choices' }, USES.map(([v, t, sub]) => h('label', { className: 'choice' + (d.use === v ? ' on' : '') },
|
|
1432
|
+
h('input', { type: 'radio', name: `use-${id}`, checked: d.use === v, disabled: readOnly, onchange: () => ((d.use = v), touch(), uses.querySelectorAll('.choice').forEach((c) => c.classList.toggle('on', c.querySelector('input').checked))) }),
|
|
1433
|
+
h('b', { textContent: t }), h('span', { textContent: sub }))))
|
|
1434
|
+
const body = grow(h('textarea', { className: 'editor', spellcheck: false, readOnly, value: d.body, placeholder: 'What the model should do. $ARGUMENTS is what you type after /name: "/review the login page" gives "the login page".', oninput: (e) => ((d.body = e.target.value), touch()) }), 220)
|
|
1435
|
+
return { d, el: [
|
|
1436
|
+
h('div', { className: 'row' },
|
|
1437
|
+
isNew ? h('div', { className: 'grow' }, h('label', { className: 'f', textContent: "Name: you'll type /name" }), field('name', { placeholder: 'review' })) : null,
|
|
1438
|
+
h('div', { className: 'grow', style: 'flex:3' }, h('label', { className: 'f', textContent: 'What it does (the model reads this to know when to use it)' }), field('description', { placeholder: 'Reviews the changes for bugs and unclear code' }))),
|
|
1439
|
+
h('label', { className: 'f', textContent: 'Who uses it' }), uses,
|
|
1440
|
+
h('label', { className: 'f', textContent: 'The prompt' }), body,
|
|
1441
|
+
] }
|
|
1442
|
+
}
|
|
1443
|
+
function drawSkills(s) {
|
|
1444
|
+
if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
|
|
1445
|
+
$('k-new').hidden = SEL.skills === '+new'
|
|
1446
|
+
const mine = s.skills.filter((k) => k.mine)
|
|
1447
|
+
const theirs = s.skills.filter((k) => !k.mine)
|
|
1448
|
+
const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
|
|
1449
|
+
masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
|
|
1450
|
+
if (x === '+new') {
|
|
1451
|
+
const f = skillForm('+new', NEW, { isNew: true })
|
|
1452
|
+
const first = !s.skills.length
|
|
1453
|
+
return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1454
|
+
first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
|
|
1455
|
+
f.el,
|
|
1456
|
+
h('div', { className: 'actions' },
|
|
1457
|
+
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1458
|
+
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1459
|
+
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1460
|
+
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1461
|
+
if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
|
|
1462
|
+
} }),
|
|
1463
|
+
first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
|
|
1464
|
+
}
|
|
1465
|
+
const k = x.k
|
|
1466
|
+
const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
|
|
1467
|
+
return h('div', { className: 'card' },
|
|
1468
|
+
h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', className: 'mono', textContent: '/' + k.name }), k.mine ? null : h('span', { className: 'tag', textContent: 'from a project: read only' })),
|
|
1469
|
+
f.el,
|
|
1470
|
+
k.mine ? h('div', { className: 'actions' },
|
|
1471
|
+
h('button', { className: 'primary', textContent: 'Save', onclick: () => saveItem('skills', k.name, '/api/skill', { name: k.name, was: k.name, text: skillText(f.d) }, `saved /${k.name}`) }),
|
|
1472
|
+
h('span', { className: 'spacer' }),
|
|
1473
|
+
h('button', { className: 'danger', textContent: 'Delete', onclick: () => confirm(`Delete the skill /${k.name}?`) && saveItem('skills', k.name, '/api/skill/delete', { name: k.name }, `deleted /${k.name}`) })) : null)
|
|
1474
|
+
})
|
|
1475
|
+
$('n-skills').textContent = s.skills.length || ''
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// --- advanced files ---
|
|
1479
|
+
function drawFiles(s) {
|
|
1480
|
+
$('f-home').textContent = s.home
|
|
1481
|
+
const files = s.files.filter((f) => f.name !== 'mcp.json')
|
|
1482
|
+
masterDetail('files', $('f-items'), $('f-detail'), [{ items: files.map((f) => ({ id: f.name, title: f.name, sub: f.about, badge: f.text.trim() ? null : h('span', { className: 'tag', textContent: 'none yet' }), f })) }], (x) => {
|
|
1483
|
+
if (!x) return null
|
|
1484
|
+
const f = x.f
|
|
1485
|
+
const ta = editor('files', f.name, f.text, { placeholder: f.example })
|
|
1486
|
+
return h('div', { className: 'card' },
|
|
1487
|
+
h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', className: 'mono', textContent: f.name }), f.text.trim() ? null : h('span', { className: 'tag', textContent: 'none yet' })),
|
|
1488
|
+
h('p', { className: 'sub', style: 'margin:6px 0 12px', textContent: f.about }),
|
|
1489
|
+
!f.text.trim() && DRAFTS.files[f.name] === undefined ? h('div', { className: 'hint', style: 'margin:-4px 0 10px' }, 'Empty: the example is shown in grey. ', h('button', { className: 'small', textContent: 'Start from the example', onclick: () => ((DRAFTS.files[f.name] = f.example + '\n'), drawFiles(S)) })) : null,
|
|
1490
|
+
ta,
|
|
1491
|
+
h('div', { className: 'actions' }, h('button', { className: 'primary', textContent: `Save ${f.name}`, onclick: () => saveItem('files', f.name, '/api/file', { name: f.name, text: ta.value }, ta.value.trim() ? `saved ${f.name}` : `${f.name} deleted`) })))
|
|
1492
|
+
})
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function render(s) {
|
|
1496
|
+
S = s
|
|
1497
|
+
drawProviders(s)
|
|
1498
|
+
drawMine(s)
|
|
1499
|
+
drawUsing(s)
|
|
1500
|
+
drawGeneral(s)
|
|
1501
|
+
drawLogging(s)
|
|
1502
|
+
if (REQS) drawReqs()
|
|
1503
|
+
drawPrompts(s)
|
|
1504
|
+
drawMcp(s)
|
|
1505
|
+
drawSkills(s)
|
|
1506
|
+
drawFiles(s)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
showPage()
|
|
1510
|
+
drawPresets()
|
|
1511
|
+
if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link HeartCode printed' }), h('p', { className: 'lead', textContent: 'It has the key to this page: /settings in HeartCode, or heart settings.' }))
|
|
1512
|
+
else {
|
|
1513
|
+
api('/api/state').then(render, (e) => toast(e.message, true))
|
|
1514
|
+
api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => ((REQS = []), drawReqs()))
|
|
1515
|
+
}
|
|
1516
|
+
// A model's test, as it goes: the server streams each change; only its line in your list changes.
|
|
1517
|
+
if (token) {
|
|
1518
|
+
const events = new EventSource(`/api/events?t=${token}`)
|
|
1519
|
+
// a request logged: at the top of the list, nothing else redrawn
|
|
1520
|
+
events.addEventListener('request', (e) => {
|
|
1521
|
+
const r = JSON.parse(e.data)
|
|
1522
|
+
if (!REQS) return
|
|
1523
|
+
REQS.unshift(r)
|
|
1524
|
+
if (REQS.length === 1 || !reqShown(r)) return drawReqs()
|
|
1525
|
+
$('l-list').prepend(reqRow(r))
|
|
1526
|
+
const failed = REQS.filter((x) => x.result === 'failed').length
|
|
1527
|
+
$('n-failed').textContent = failed ? `${failed} failed` : ''
|
|
1528
|
+
})
|
|
1529
|
+
events.addEventListener('test', (e) => {
|
|
1530
|
+
const d = JSON.parse(e.data)
|
|
1531
|
+
const t = S?.order.find((x) => `${x.provider}/${x.model}` === d.key)
|
|
1532
|
+
if (!t) return
|
|
1533
|
+
Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
|
|
1534
|
+
document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
|
|
1535
|
+
drawMine(S)
|
|
1536
|
+
})
|
|
1537
|
+
}
|
|
1538
|
+
</script>
|
|
1539
|
+
</body>
|
|
1540
|
+
</html>
|