heartcode 0.1.9 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/SKILL.md +47 -35
- package/bin/heart.js +30 -8
- package/bin/heartcode.js +75 -103
- package/package.json +1 -1
- package/src/acp.js +7 -1
- package/src/agent.js +23 -54
- package/src/client.js +11 -43
- package/src/config.js +65 -6
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +149 -111
- package/src/settings.js +36 -1
package/src/config.js
CHANGED
|
@@ -72,9 +72,22 @@ function fromFile() {
|
|
|
72
72
|
// cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
|
|
73
73
|
// flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
|
|
74
74
|
// only (cfg.adhoc), unless saved.
|
|
75
|
-
// The model name that means your list: #1, then the
|
|
76
|
-
// Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
-
|
|
75
|
+
// The model name that means your list: #1, then the next when one fails (also: no model given;
|
|
76
|
+
// "default" is its old name). Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
+
// is that model alone: fixed (--fallback-model adds others after it).
|
|
78
|
+
export const AUTO_MODEL = 'auto'
|
|
79
|
+
export const isAuto = (m) => m === AUTO_MODEL || m === 'default'
|
|
80
|
+
|
|
81
|
+
// A model as given (-m, --fallback-model, /model): "provider/model" (as opencode writes it; split at
|
|
82
|
+
// the first "/", provider names have none), the older "provider model", or a model of `current`.
|
|
83
|
+
export function parseModel(v, providers, current) {
|
|
84
|
+
v = String(v).trim()
|
|
85
|
+
const spaced = /^(\S+)\s+(.+)$/.exec(v)
|
|
86
|
+
if (spaced && providers[spaced[1]]) return { provider: spaced[1], model: spaced[2] }
|
|
87
|
+
const i = v.indexOf('/')
|
|
88
|
+
if (i > 0 && providers[v.slice(0, i)]) return { provider: v.slice(0, i), model: v.slice(i + 1) }
|
|
89
|
+
return { provider: current, model: v }
|
|
90
|
+
}
|
|
78
91
|
|
|
79
92
|
// providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
|
|
80
93
|
export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
|
|
@@ -98,15 +111,61 @@ export function loadConfig(flags = {}) {
|
|
|
98
111
|
cfg.providers[cfg.provider].apiKey = apiKey
|
|
99
112
|
}
|
|
100
113
|
const model = flags.model || process.env.HEART_MODEL
|
|
101
|
-
if (model && model
|
|
114
|
+
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
|
|
115
|
+
synced(cfg, readJson(CONFIG_FILE, {}))
|
|
102
116
|
return cfg
|
|
103
117
|
}
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
// What a cfg stands for in config.json (the run-only provider and model left out).
|
|
120
|
+
function saved(cfg) {
|
|
106
121
|
const providers = byName(cfg.providers)
|
|
107
122
|
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
108
123
|
const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
|
|
109
124
|
const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
|
|
110
125
|
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
111
|
-
|
|
126
|
+
return JSON.parse(JSON.stringify(out))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Several HeartCodes (and the settings page in each) keep a copy of the settings. A save writes only
|
|
130
|
+
// what this one changed since it last read or wrote the file, onto the file as it is now, and
|
|
131
|
+
// takeIn brings in what the others changed: no copy undoes another's change with an old value.
|
|
132
|
+
// file: config.json as last read or written here; mine: what this run's cfg stood for then (a -m
|
|
133
|
+
// model for this run is no change of its own, so a save doesn't write it as #1).
|
|
134
|
+
let file = null
|
|
135
|
+
let mine = null
|
|
136
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
137
|
+
const keysOf = (...objs) => [...new Set(objs.flatMap((o) => Object.keys(o ?? {})))]
|
|
138
|
+
const synced = (cfg, disk) => ((file = disk), (mine = saved(cfg)))
|
|
139
|
+
|
|
140
|
+
export function saveConfig(cfg) {
|
|
141
|
+
const out = saved(cfg)
|
|
142
|
+
const was = mine ?? {}
|
|
143
|
+
const disk = readJson(CONFIG_FILE, {})
|
|
144
|
+
for (const k of keysOf(out, was)) {
|
|
145
|
+
if (k === 'providers') {
|
|
146
|
+
// provider by provider: a key changed here and a provider added elsewhere both stay
|
|
147
|
+
const ps = { ...disk.providers }
|
|
148
|
+
for (const n of keysOf(out.providers, was.providers)) if (!same(out.providers?.[n], was.providers?.[n])) out.providers?.[n] === undefined ? delete ps[n] : (ps[n] = out.providers[n])
|
|
149
|
+
disk.providers = byName(ps)
|
|
150
|
+
} else if (!same(out[k], was[k])) out[k] === undefined ? delete disk[k] : (disk[k] = out[k])
|
|
151
|
+
}
|
|
152
|
+
writeJson(CONFIG_FILE, disk)
|
|
153
|
+
synced(cfg, disk)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// What others changed in config.json since this run last read or wrote it, into cfg (the run-only
|
|
157
|
+
// provider stays). The keys that changed: a running HeartCode applies them (the model list…).
|
|
158
|
+
// what a changed key of config.json means to a running HeartCode (applySettings)
|
|
159
|
+
export const whatOf = (k) => (['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
160
|
+
export function takeIn(cfg) {
|
|
161
|
+
const disk = readJson(CONFIG_FILE, null)
|
|
162
|
+
if (!disk || same(disk, file)) return []
|
|
163
|
+
const keys = keysOf(disk, file).filter((k) => (k === 'providers' || k === 'provider' || k === 'model' || SETTINGS.includes(k)) && !same(disk[k], file?.[k]))
|
|
164
|
+
for (const k of keys) {
|
|
165
|
+
const value = k === 'providers' ? { ...byName(disk.providers), ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : disk[k]
|
|
166
|
+
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
167
|
+
}
|
|
168
|
+
file = disk
|
|
169
|
+
mine = saved(cfg)
|
|
170
|
+
return keys
|
|
112
171
|
}
|
package/src/guided.js
CHANGED
|
@@ -225,14 +225,8 @@ class Guided {
|
|
|
225
225
|
|
|
226
226
|
// `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
|
|
227
227
|
// room: tokens of context to keep free for the reply (only for fitting the context; never sent).
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return await this.ask1(question, opts, tries)
|
|
231
|
-
} catch (e) {
|
|
232
|
-
// too large for the model: strat.context has dropped to its limit, so the context shrinks
|
|
233
|
-
if (!e.inputLimit || tries >= 3) throw e
|
|
234
|
-
return this.query(question, opts, tries + 1)
|
|
235
|
-
}
|
|
228
|
+
query(question, opts = {}) {
|
|
229
|
+
return this.ask1(question, opts)
|
|
236
230
|
}
|
|
237
231
|
|
|
238
232
|
async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
|
package/src/headless.js
CHANGED
|
@@ -150,6 +150,7 @@ export async function runHeadless(app, opts) {
|
|
|
150
150
|
said = false
|
|
151
151
|
current = { ctl: new AbortController() }
|
|
152
152
|
app.signal = current.ctl.signal
|
|
153
|
+
app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
|
|
153
154
|
let answer = ''
|
|
154
155
|
let error = null
|
|
155
156
|
try {
|
|
@@ -162,6 +163,8 @@ export async function runHeadless(app, opts) {
|
|
|
162
163
|
log('error', error, e.stack)
|
|
163
164
|
} finally {
|
|
164
165
|
app.save()
|
|
166
|
+
app.working = false
|
|
167
|
+
app.afterLine?.()
|
|
165
168
|
}
|
|
166
169
|
const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
|
|
167
170
|
current = null
|
package/src/settings.html
CHANGED
|
@@ -228,11 +228,15 @@
|
|
|
228
228
|
.olist .test button { flex: none; }
|
|
229
229
|
.olist .test.ok { color: var(--ok); }
|
|
230
230
|
.olist .test.warn { color: var(--accent); }
|
|
231
|
-
dialog
|
|
232
|
-
dialog
|
|
233
|
-
dialog
|
|
231
|
+
dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
|
|
232
|
+
dialog::backdrop { background: rgba(0, 0, 0, .55); }
|
|
233
|
+
dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
|
|
234
234
|
dialog#ms .opt:last-of-type { border-bottom: 0; }
|
|
235
|
-
|
|
235
|
+
#pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
|
|
236
|
+
#pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
|
|
237
|
+
#pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
|
|
238
|
+
#pr-list li.dragging { opacity: .4; }
|
|
239
|
+
dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
236
240
|
.olist .test.failed { color: var(--err); }
|
|
237
241
|
.olist .test.testing, .olist .test.waiting { color: var(--accent); }
|
|
238
242
|
.olist .test button { padding: 0 7px; font-size: 11px; }
|
|
@@ -258,7 +262,7 @@
|
|
|
258
262
|
/* Your models: a table, one row a model */
|
|
259
263
|
.mt-card { padding: 0; }
|
|
260
264
|
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
|
|
261
|
-
.mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
|
|
265
|
+
.mt-top .sorts { flex: 1 1 320px; min-width: 0; color: var(--muted); font-size: 13px; } /* too narrow beside the buttons: a line of its own */
|
|
262
266
|
.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; }
|
|
263
267
|
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
264
268
|
.mt-scroll { overflow-x: auto; }
|
|
@@ -431,19 +435,6 @@
|
|
|
431
435
|
</div>
|
|
432
436
|
<div class="steps" id="steps"></div>
|
|
433
437
|
<div class="stack">
|
|
434
|
-
<div class="card" id="p-form" hidden>
|
|
435
|
-
<h2 id="p-title">Add a provider</h2>
|
|
436
|
-
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
437
|
-
<div class="presets" id="presets"></div>
|
|
438
|
-
<div class="row" style="margin-top:6px">
|
|
439
|
-
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
440
|
-
<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>
|
|
441
|
-
</div>
|
|
442
|
-
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
443
|
-
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
444
|
-
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
445
|
-
<div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
|
|
446
|
-
</div>
|
|
447
438
|
<div class="work" id="p-work" hidden>
|
|
448
439
|
<div class="card catalog">
|
|
449
440
|
<div class="cat-top">
|
|
@@ -565,20 +556,8 @@
|
|
|
565
556
|
|
|
566
557
|
<!-- MCP servers -->
|
|
567
558
|
<section class="page" id="page-mcp">
|
|
568
|
-
<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>
|
|
559
|
+
<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><button class="primary" id="c-open">+ Add a server</button></div>
|
|
569
560
|
<div class="stack">
|
|
570
|
-
<div class="card">
|
|
571
|
-
<h2>Add a server</h2>
|
|
572
|
-
<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>
|
|
573
|
-
<div class="row">
|
|
574
|
-
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
575
|
-
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
576
|
-
<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>
|
|
577
|
-
</div></div>
|
|
578
|
-
<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>
|
|
579
|
-
<button class="primary" id="c-add">Add server</button>
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
561
|
<div class="card" id="c-list"></div>
|
|
583
562
|
<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>
|
|
584
563
|
</div>
|
|
@@ -617,6 +596,40 @@
|
|
|
617
596
|
<div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
|
|
618
597
|
</form>
|
|
619
598
|
</dialog>
|
|
599
|
+
<dialog id="p-form" aria-labelledby="p-title">
|
|
600
|
+
<h2 id="p-title">Add a provider</h2>
|
|
601
|
+
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
602
|
+
<div class="presets" id="presets"></div>
|
|
603
|
+
<div class="row" style="margin-top:6px">
|
|
604
|
+
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
605
|
+
<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>
|
|
606
|
+
</div>
|
|
607
|
+
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
608
|
+
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
609
|
+
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
610
|
+
<div class="actions"><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
611
|
+
</dialog>
|
|
612
|
+
<dialog id="c-form" aria-labelledby="c-title">
|
|
613
|
+
<h2 id="c-title">Add a server</h2>
|
|
614
|
+
<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>
|
|
615
|
+
<div class="row">
|
|
616
|
+
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
617
|
+
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
618
|
+
<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>
|
|
619
|
+
</div></div>
|
|
620
|
+
<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>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
623
|
+
</dialog>
|
|
624
|
+
<dialog id="k-form" aria-labelledby="k-title"></dialog>
|
|
625
|
+
<dialog id="pr" aria-labelledby="pr-title">
|
|
626
|
+
<form>
|
|
627
|
+
<h2 id="pr-title">Provider ranking</h2>
|
|
628
|
+
<p class="sub">Drag the providers into order, best first. Your models are then sorted by it.</p>
|
|
629
|
+
<ol id="pr-list"></ol>
|
|
630
|
+
<div class="actions"><button id="pr-cancel" type="button">Cancel</button><button class="primary" id="pr-save" type="button">Save</button></div>
|
|
631
|
+
</form>
|
|
632
|
+
</dialog>
|
|
620
633
|
<div id="toast" role="status"></div>
|
|
621
634
|
<div id="tip" role="tooltip"></div>
|
|
622
635
|
<script>
|
|
@@ -766,7 +779,7 @@ const DRAFTS = { prompts: {}, skills: {}, files: {} }
|
|
|
766
779
|
const SEL = { prompts: 'agent/system', skills: null, files: null }
|
|
767
780
|
function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
768
781
|
const all = groups.flatMap((g) => g.items)
|
|
769
|
-
if (!all.some((x) => x.id === SEL[kind])
|
|
782
|
+
if (!all.some((x) => x.id === SEL[kind])) SEL[kind] = all[0]?.id ?? null
|
|
770
783
|
// the list: built when its items change; otherwise each item is updated in place (the chosen
|
|
771
784
|
// one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
|
|
772
785
|
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]
|
|
@@ -787,7 +800,7 @@ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
|
787
800
|
}
|
|
788
801
|
itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
|
|
789
802
|
itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
|
|
790
|
-
put(detailEl, draw(
|
|
803
|
+
put(detailEl, draw(all.find((x) => x.id === SEL[kind])))
|
|
791
804
|
itemsEl.closest('.md').classList.add('fill')
|
|
792
805
|
fillHeight()
|
|
793
806
|
}
|
|
@@ -879,10 +892,8 @@ const useModel = (provider, model) => act('/api/model', { provider, model }, `He
|
|
|
879
892
|
const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
|
|
880
893
|
|
|
881
894
|
let editing = null
|
|
882
|
-
let formOpen = false
|
|
883
895
|
function openForm(p = null) {
|
|
884
896
|
editing = p
|
|
885
|
-
formOpen = true
|
|
886
897
|
$('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
|
|
887
898
|
$('p-name').value = p?.name ?? ''
|
|
888
899
|
$('p-url').value = p?.baseUrl ?? ''
|
|
@@ -891,15 +902,11 @@ function openForm(p = null) {
|
|
|
891
902
|
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
892
903
|
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
893
904
|
drawPresets()
|
|
894
|
-
|
|
895
|
-
$('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
905
|
+
$('p-form').showModal()
|
|
896
906
|
;(p ? $('p-url') : $('p-name')).focus()
|
|
897
907
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
formOpen = false
|
|
901
|
-
drawProviders(S)
|
|
902
|
-
}
|
|
908
|
+
const closeForm = () => $('p-form').close()
|
|
909
|
+
$('p-form').onclose = () => (editing = null)
|
|
903
910
|
$('p-open').onclick = () => openForm()
|
|
904
911
|
$('p-cancel').onclick = closeForm
|
|
905
912
|
$('p-save').onclick = async () => {
|
|
@@ -923,36 +930,21 @@ for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown',
|
|
|
923
930
|
const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare // one collator: thousands of names sort fast
|
|
924
931
|
let modelQuery = ''
|
|
925
932
|
// the models under each provider: by name, or by what its list says they cost (cheapest first; no price: last)
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
return 'name'
|
|
931
|
-
}
|
|
932
|
-
})()
|
|
933
|
-
$('m-sort').value = catSort
|
|
933
|
+
// The page's own choices (sorts, folds, the provider ranking), kept in ~/.heart (the browser's
|
|
934
|
+
// storage isn't: the page's address has a new port each time)
|
|
935
|
+
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
936
|
+
let catSort = 'name'
|
|
934
937
|
$('m-sort').onchange = (e) => {
|
|
935
938
|
catSort = e.target.value
|
|
936
|
-
|
|
937
|
-
localStorage.setItem('heart.catSort', catSort)
|
|
938
|
-
} catch {}
|
|
939
|
+
savePref('catSort', catSort)
|
|
939
940
|
if (S) drawProviders(S)
|
|
940
941
|
}
|
|
941
942
|
const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) : Infinity)
|
|
942
|
-
// Providers folded on the page (
|
|
943
|
-
//
|
|
943
|
+
// Providers folded on the page (kept: savePref). Typing a search opens them all, until you fold or
|
|
944
|
+
// unfold one (or all) again.
|
|
944
945
|
let searchOpen = false
|
|
945
|
-
const FOLDED = new Set(
|
|
946
|
-
try {
|
|
947
|
-
return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
|
|
948
|
-
} catch {
|
|
949
|
-
return []
|
|
950
|
-
}
|
|
951
|
-
})())
|
|
946
|
+
const FOLDED = new Set()
|
|
952
947
|
function applyFolds() {
|
|
953
|
-
try {
|
|
954
|
-
localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
|
|
955
|
-
} catch {}
|
|
956
948
|
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
957
949
|
const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
|
|
958
950
|
sec.classList.toggle('collapsed', folded)
|
|
@@ -965,9 +957,10 @@ function settle() {
|
|
|
965
957
|
if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
|
|
966
958
|
searchOpen = false
|
|
967
959
|
}
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
$('
|
|
960
|
+
const folds = () => (applyFolds(), savePref('folded', [...FOLDED])) // a fold changed: shown and kept
|
|
961
|
+
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), folds())
|
|
962
|
+
$('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), folds())
|
|
963
|
+
$('unfold-all').onclick = () => (settle(), FOLDED.clear(), folds())
|
|
971
964
|
// every model shown on the left (the search's matches, when searching) added to the end of your list
|
|
972
965
|
$('add-all').onclick = () => {
|
|
973
966
|
if (!S) return
|
|
@@ -1183,17 +1176,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
|
|
|
1183
1176
|
function drawProviders(s) {
|
|
1184
1177
|
const order = s.order
|
|
1185
1178
|
const none = !s.providers.length
|
|
1186
|
-
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1187
|
-
// no provider yet, the form is open (the only way in: no other button)
|
|
1179
|
+
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1188
1180
|
put($('steps'), [
|
|
1189
1181
|
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
1190
1182
|
['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
1191
1183
|
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
1192
1184
|
].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))))
|
|
1193
|
-
const showForm = formOpen || none
|
|
1194
|
-
$('p-form').hidden = !showForm
|
|
1195
|
-
$('p-cancel').hidden = none // nothing to go back to
|
|
1196
|
-
$('p-open').hidden = showForm
|
|
1197
1185
|
$('p-work').hidden = none
|
|
1198
1186
|
fillHeight()
|
|
1199
1187
|
|
|
@@ -1241,9 +1229,9 @@ function drawProviders(s) {
|
|
|
1241
1229
|
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
1242
1230
|
h('div', { className: 'row', style: 'gap:8px' },
|
|
1243
1231
|
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
1244
|
-
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model
|
|
1232
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1245
1233
|
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),
|
|
1246
|
-
h('div', { className: 'hint', textContent: { failure: '
|
|
1234
|
+
h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
|
|
1247
1235
|
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
1248
1236
|
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1249
1237
|
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
@@ -1479,7 +1467,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
|
|
|
1479
1467
|
// how: text (A to Z first), cheap (lowest first), else most first
|
|
1480
1468
|
const COLS = {
|
|
1481
1469
|
model: { label: 'Model', how: 'text', get: (t) => t.model },
|
|
1482
|
-
provider: { label: 'Provider', how: '
|
|
1470
|
+
provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
|
|
1483
1471
|
context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
|
|
1484
1472
|
abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
|
|
1485
1473
|
tools: { label: 'Tools', get: (t) => can(t).tools },
|
|
@@ -1489,19 +1477,41 @@ const COLS = {
|
|
|
1489
1477
|
output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
|
|
1490
1478
|
works: { label: 'Works?', get: (t) => { const r = TRIES[keyOf(t)]; return !r || r.going ? 1 : r.ok ? 2 : 0 } }, // tested and working first; not tested; not working
|
|
1491
1479
|
}
|
|
1492
|
-
let SORT =
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1480
|
+
let SORT = []
|
|
1481
|
+
// your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
|
|
1482
|
+
let RANK = []
|
|
1483
|
+
const ranked = () => {
|
|
1484
|
+
const names = [...new Set(S.order.map((t) => t.provider))]
|
|
1485
|
+
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
1486
|
+
}
|
|
1487
|
+
// the ranking dialog: drag a provider to its place, then Save
|
|
1488
|
+
function openRanking() {
|
|
1489
|
+
const list = $('pr-list')
|
|
1490
|
+
let held = null
|
|
1491
|
+
put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
|
|
1492
|
+
list.ondragstart = (e) => (held = e.target).classList.add('dragging')
|
|
1493
|
+
list.ondragend = () => held?.classList.remove('dragging')
|
|
1494
|
+
list.ondragover = (e) => {
|
|
1495
|
+
e.preventDefault()
|
|
1496
|
+
const over = e.target.closest('li')
|
|
1497
|
+
if (!held || !over || over === held) return
|
|
1498
|
+
const r = over.getBoundingClientRect()
|
|
1499
|
+
list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
|
|
1497
1500
|
}
|
|
1498
|
-
|
|
1501
|
+
$('pr').showModal()
|
|
1502
|
+
}
|
|
1503
|
+
$('pr-cancel').onclick = () => $('pr').close()
|
|
1504
|
+
$('pr-save').onclick = () => {
|
|
1505
|
+
const list = [...$('pr-list').children].map((li) => li.textContent)
|
|
1506
|
+
RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
|
|
1507
|
+
savePref('rank', RANK)
|
|
1508
|
+
$('pr').close()
|
|
1509
|
+
setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
|
|
1510
|
+
}
|
|
1499
1511
|
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1500
1512
|
function setSort(next) {
|
|
1501
1513
|
SORT = next
|
|
1502
|
-
|
|
1503
|
-
localStorage.setItem('heart.sort', JSON.stringify(SORT))
|
|
1504
|
-
} catch {}
|
|
1514
|
+
savePref('sort', SORT)
|
|
1505
1515
|
drawMine(S)
|
|
1506
1516
|
}
|
|
1507
1517
|
// a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
|
|
@@ -1511,7 +1521,9 @@ function clickSort(key) {
|
|
|
1511
1521
|
if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
|
|
1512
1522
|
setSort(SORT.filter((x) => x !== at))
|
|
1513
1523
|
}
|
|
1524
|
+
let RANKED = new Map() // provider → its place in the ranking, for one sort
|
|
1514
1525
|
function sorted(order) {
|
|
1526
|
+
RANKED = new Map(ranked().map((n, i) => [n, i]))
|
|
1515
1527
|
const cmp = (a, b, { key, dir }) => {
|
|
1516
1528
|
let x = COLS[key].get(a)
|
|
1517
1529
|
let y = COLS[key].get(b)
|
|
@@ -1523,7 +1535,7 @@ function sorted(order) {
|
|
|
1523
1535
|
}
|
|
1524
1536
|
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)
|
|
1525
1537
|
}
|
|
1526
|
-
const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : 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')
|
|
1538
|
+
const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your ranking' : 'your ranking, reversed') : key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : 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')
|
|
1527
1539
|
// The models ticked on the Your models page ("provider/model"), to remove several at once; the last
|
|
1528
1540
|
// one ticked, so a Shift+click ticks the ones between
|
|
1529
1541
|
const PICKED = new Set()
|
|
@@ -1552,13 +1564,10 @@ function drawMine(s) {
|
|
|
1552
1564
|
what === 'works' ? null : h('button', { type: 'button', className: 'again', textContent: '↻', title: `Test these ${n} again`, 'aria-label': `test the ${what} ones again`, disabled: testingAll, onclick: () => tryAll(models) }))
|
|
1553
1565
|
})
|
|
1554
1566
|
|
|
1555
|
-
const same = view.every((t, i) => t === order[i])
|
|
1556
1567
|
// the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
|
|
1557
1568
|
const keep = (list, moved) => {
|
|
1558
1569
|
SORT = []
|
|
1559
|
-
|
|
1560
|
-
localStorage.setItem('heart.sort', '[]')
|
|
1561
|
-
} catch {}
|
|
1570
|
+
savePref('sort', SORT)
|
|
1562
1571
|
return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1563
1572
|
}
|
|
1564
1573
|
// a row dragged (in the order shown, sorted or not)
|
|
@@ -1594,8 +1603,10 @@ function drawMine(s) {
|
|
|
1594
1603
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1595
1604
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1596
1605
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1606
|
+
PICKED.size ? h('button', { className: 'small', textContent: 'Invert selection', title: 'tick the ones not ticked, untick the ones ticked', onclick: () => (view.forEach((t) => (PICKED.has(keyOf(t)) ? PICKED.delete(keyOf(t)) : PICKED.add(keyOf(t)))), (lastPicked = null), drawMine(S)) }) : null,
|
|
1607
|
+
order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
|
|
1597
1608
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1598
|
-
|
|
1609
|
+
order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
|
|
1599
1610
|
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
1600
1611
|
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1601
1612
|
order.length
|
|
@@ -1845,8 +1856,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
|
|
|
1845
1856
|
}
|
|
1846
1857
|
$('c-add').onclick = async () => {
|
|
1847
1858
|
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`)
|
|
1848
|
-
if (r) $('c-name').value = $('c-what').value = ''
|
|
1859
|
+
if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
|
|
1849
1860
|
}
|
|
1861
|
+
$('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
|
|
1862
|
+
$('c-cancel').onclick = () => $('c-form').close()
|
|
1850
1863
|
function drawMcp(s) {
|
|
1851
1864
|
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.' }),
|
|
1852
1865
|
s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
|
|
@@ -1873,7 +1886,27 @@ function parseSkill(text) {
|
|
|
1873
1886
|
}
|
|
1874
1887
|
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`
|
|
1875
1888
|
const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
|
|
1876
|
-
|
|
1889
|
+
// a new skill: the form in a dialog (what you typed is kept if you close it)
|
|
1890
|
+
function newSkill() {
|
|
1891
|
+
const f = skillForm('+new', NEW, { isNew: true })
|
|
1892
|
+
const first = !S.skills.length
|
|
1893
|
+
put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1894
|
+
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,
|
|
1895
|
+
f.el,
|
|
1896
|
+
h('div', { className: 'actions' },
|
|
1897
|
+
h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
|
|
1898
|
+
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1899
|
+
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1900
|
+
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1901
|
+
const was = SEL.skills
|
|
1902
|
+
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1903
|
+
if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
|
|
1904
|
+
else SEL.skills = was
|
|
1905
|
+
} })))
|
|
1906
|
+
$('k-form').showModal()
|
|
1907
|
+
$('k-form').querySelector('input')?.focus()
|
|
1908
|
+
}
|
|
1909
|
+
$('k-new').onclick = newSkill
|
|
1877
1910
|
function skillForm(id, start, { isNew, readOnly }) {
|
|
1878
1911
|
const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
|
|
1879
1912
|
const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
|
|
@@ -1891,27 +1924,11 @@ function skillForm(id, start, { isNew, readOnly }) {
|
|
|
1891
1924
|
] }
|
|
1892
1925
|
}
|
|
1893
1926
|
function drawSkills(s) {
|
|
1894
|
-
if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
|
|
1895
|
-
$('k-new').hidden = SEL.skills === '+new'
|
|
1896
1927
|
const mine = s.skills.filter((k) => k.mine)
|
|
1897
1928
|
const theirs = s.skills.filter((k) => !k.mine)
|
|
1898
1929
|
const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
|
|
1899
1930
|
masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
|
|
1900
|
-
if (x
|
|
1901
|
-
const f = skillForm('+new', NEW, { isNew: true })
|
|
1902
|
-
const first = !s.skills.length
|
|
1903
|
-
return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1904
|
-
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,
|
|
1905
|
-
f.el,
|
|
1906
|
-
h('div', { className: 'actions' },
|
|
1907
|
-
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1908
|
-
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1909
|
-
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1910
|
-
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1911
|
-
if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
|
|
1912
|
-
} }),
|
|
1913
|
-
first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
|
|
1914
|
-
}
|
|
1931
|
+
if (!x) return h('div', { className: 'card' }, h('h2', { textContent: 'No skills yet' }), h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Make one with + New skill, or bring them from Claude Code and others with /import in HeartCode.' }))
|
|
1915
1932
|
const k = x.k
|
|
1916
1933
|
const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
|
|
1917
1934
|
return h('div', { className: 'card' },
|
|
@@ -1943,9 +1960,20 @@ function drawFiles(s) {
|
|
|
1943
1960
|
}
|
|
1944
1961
|
|
|
1945
1962
|
function render(s) {
|
|
1963
|
+
if (s.prefs) {
|
|
1964
|
+
// the page's choices, as kept (also changed on another page: shown here too)
|
|
1965
|
+
const p = s.prefs
|
|
1966
|
+
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1967
|
+
$('m-sort').value = catSort
|
|
1968
|
+
FOLDED.clear()
|
|
1969
|
+
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1970
|
+
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
|
1971
|
+
RANK = Array.isArray(p.rank) ? p.rank.filter((x) => typeof x === 'string') : []
|
|
1972
|
+
}
|
|
1946
1973
|
S = s
|
|
1947
1974
|
for (const [key, r] of Object.entries(TRIES)) if (!r.going && placeOf(...key.split(/\/(.*)/s)) === undefined) delete TRIES[key] // off your list: its result too
|
|
1948
1975
|
drawProviders(s)
|
|
1976
|
+
applyFolds()
|
|
1949
1977
|
drawMine(s)
|
|
1950
1978
|
drawUsing(s)
|
|
1951
1979
|
drawGeneral(s)
|
|
@@ -1963,6 +1991,7 @@ if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link Heart
|
|
|
1963
1991
|
else {
|
|
1964
1992
|
job('state', { where: ['page'], what: 'Loading your settings', detail: 'providers, models, logging, prompts, MCP servers, skills' })
|
|
1965
1993
|
api('/api/state').then(render, (e) => toast(e.message, true)).finally(() => job('state'))
|
|
1994
|
+
|
|
1966
1995
|
.then(() => api('/api/tests', {}))
|
|
1967
1996
|
.then(({ results, run }) => (Object.assign(TRIES, results), showRun(run)), (e) => toast(`the tests so far: ${e.message}`, true)) // and one going
|
|
1968
1997
|
job('requests', { where: ['requests'], what: 'Loading the request log', detail: 'every request HeartCode logged' })
|
|
@@ -1972,6 +2001,15 @@ else {
|
|
|
1972
2001
|
if (token) {
|
|
1973
2002
|
const events = new EventSource(`/api/events?t=${token}`)
|
|
1974
2003
|
// a request logged: at the top of the list, nothing else redrawn
|
|
2004
|
+
// the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
|
|
2005
|
+
// when it closes (what's typed in it stays)
|
|
2006
|
+
let later = null
|
|
2007
|
+
events.addEventListener('state', (e) => {
|
|
2008
|
+
const s = JSON.parse(e.data)
|
|
2009
|
+
if (!document.querySelector('dialog[open]')) return render(s)
|
|
2010
|
+
later = s
|
|
2011
|
+
})
|
|
2012
|
+
for (const d of document.querySelectorAll('dialog')) d.addEventListener('close', () => later && !document.querySelector('dialog[open]') && (render(later), (later = null)))
|
|
1975
2013
|
events.addEventListener('request', (e) => {
|
|
1976
2014
|
const r = JSON.parse(e.data)
|
|
1977
2015
|
if (page() === 'logging') drawDisk() // the logs grew
|