heartcode 0.1.12 → 0.1.14
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 +19 -0
- package/SKILL.md +5 -4
- package/bin/heart.js +30 -8
- package/bin/heartcode.js +23 -28
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/config.js +48 -2
- package/src/settings.html +188 -207
- package/src/settings.js +36 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in each version of HeartCode, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.14 (2026-10-30)
|
|
6
|
+
|
|
7
|
+
- Settings page, Providers & models: one full-width list. Tick models (or a whole provider, or
|
|
8
|
+
everything shown) and add them to your list or take them off it at once; a click on one still
|
|
9
|
+
adds or removes it. Each provider is one line; its model list switch, the models added by name
|
|
10
|
+
and Delete are in its Edit dialog. Your list is a button to the Your models page, which now has
|
|
11
|
+
what to do when a model fails.
|
|
12
|
+
|
|
13
|
+
## 0.1.13 (2026-10-30)
|
|
14
|
+
|
|
15
|
+
- Settings no longer go back by themselves: a save writes only what changed, so another HeartCode
|
|
16
|
+
or settings page with an older copy doesn't undo your change (and keys HeartCode doesn't know stay).
|
|
17
|
+
- A settings change reaches every HeartCode running on the same settings at once, mid-task too: the
|
|
18
|
+
model order, prompts and HEARTCODE.md, hooks, skills, MCP servers. Open settings pages update by themselves.
|
|
19
|
+
- `--temp` runs going on at once share one throwaway folder: a change in one reaches the others,
|
|
20
|
+
never your real settings. Ctrl+C now cleans it up too.
|
|
21
|
+
- Settings page: nothing is kept in the browser (the provider ranking, sorts and folds are in
|
|
22
|
+
~/.heart); Save order is always there; Invert selection on Your models.
|
|
23
|
+
|
|
5
24
|
## 0.1.12 (2026-10-30)
|
|
6
25
|
|
|
7
26
|
- `auto` is the name for your model list (#1, then the next when one fails); `default` still works.
|
package/SKILL.md
CHANGED
|
@@ -77,9 +77,10 @@ image, one that can't call tools gets the tool calls as text.
|
|
|
77
77
|
|
|
78
78
|
`--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
|
|
79
79
|
|
|
80
|
-
A run picks up settings the user changes meanwhile (`heart settings`,
|
|
81
|
-
without being restarted
|
|
82
|
-
|
|
80
|
+
A run picks up settings the user changes meanwhile (`heart settings`, another HeartCode, or
|
|
81
|
+
`~/.heart` by hand) without being restarted, from its next request: the model order, prompts,
|
|
82
|
+
hooks, skills, MCP servers. `--temp` runs share one throwaway folder, so a change in one reaches the
|
|
83
|
+
other `--temp` runs going on, never the real ones.
|
|
83
84
|
|
|
84
85
|
`heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
|
|
85
86
|
Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
|
|
@@ -133,7 +134,7 @@ can't switch the model you chose.
|
|
|
133
134
|
| Everything (edits and commands) | `--permission-mode all` (the default unless the user set another) |
|
|
134
135
|
| Changes kept apart from the user's files | `--worktree=<name>` (in a git repo): it works in `.heartcode/worktrees/<name>` on the branch `heartcode/<name>`; kept if anything changed (merge it from there), removed if nothing did |
|
|
135
136
|
| A cap on how long it works | `--max-turns 30` (model steps), `--max-retries 3` (a failing provider) |
|
|
136
|
-
| Nothing written to the user's `~/.heart` (sessions, logs, settings) | `--temp` |
|
|
137
|
+
| Nothing written to the user's `~/.heart` (sessions, logs, settings) | `--temp` (a throwaway copy, shared by the `--temp` runs going on at once) |
|
|
137
138
|
|
|
138
139
|
Tool names: HeartCode's own (`read_file`, `edit_file`, `write_file`, `run_command`, `search`,
|
|
139
140
|
`list_files`, `web_fetch`, `web_search`…) or Claude Code's (`Read`, `Edit`, `Write`, `Bash`,
|
package/bin/heart.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// heart . this folder
|
|
5
5
|
// heart ~/code/my-app that folder (a folder: a file is an error)
|
|
6
6
|
// heart ~/code/my-app "fix the failing test" -m … then anything heartcode takes (heart --help)
|
|
7
|
-
// heart --temp … a throwaway ~/.heart
|
|
8
|
-
// models' test results and
|
|
9
|
-
// quits; your own is
|
|
7
|
+
// heart --temp … a throwaway ~/.heart, shared by the --temp runs going on at once:
|
|
8
|
+
// your config.json (providers, keys), the models' test results and
|
|
9
|
+
// prices copied in; deleted when the last of them quits; your own is
|
|
10
|
+
// never written to
|
|
10
11
|
// Logs, when they're switched on (the settings page: Logging): each run's log in
|
|
11
12
|
// ~/.heart/logs/runs/<day-month-year-hour-min-sec>/, a file per tab ("log": true in config.json);
|
|
12
13
|
// each request to a model in ~/.heart/logs/requests/<same>/, a file per request ("logRequests").
|
|
@@ -30,15 +31,36 @@ const stamp = (() => {
|
|
|
30
31
|
return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}-${d.getHours()}-${d.getMinutes()}-${d.getSeconds()}`.replace(/\b(\d)\b/g, '0$1')
|
|
31
32
|
})()
|
|
32
33
|
|
|
33
|
-
// --temp: HeartCode's folder is
|
|
34
|
+
// --temp: HeartCode's folder is one in the system's temp folder, shared by every --temp run going
|
|
35
|
+
// on at the same time (a setting changed in one reaches the others, as ~/.heart's do for the real
|
|
36
|
+
// ones). The first one makes it, the last one to quit deletes it. Each run's pid is a file in
|
|
37
|
+
// .runs/ (a run that crashed leaves one: its process is gone, so it doesn't count).
|
|
34
38
|
const temp = args.includes('--temp')
|
|
35
39
|
if (temp) {
|
|
36
40
|
args.splice(args.indexOf('--temp'), 1)
|
|
37
|
-
const dir =
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
const dir = path.join(os.tmpdir(), `heart-temp-${os.userInfo().username}`)
|
|
42
|
+
const runs = path.join(dir, '.runs')
|
|
43
|
+
const alive = (pid) => {
|
|
44
|
+
try {
|
|
45
|
+
return process.kill(Number(pid), 0), true
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return e.code === 'EPERM'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const others = () => (fs.existsSync(runs) ? fs.readdirSync(runs).filter((pid) => Number(pid) !== process.pid && (alive(pid) || (fs.rmSync(path.join(runs, pid), { force: true }), false))) : [])
|
|
51
|
+
if (!others().length) {
|
|
52
|
+
fs.rmSync(dir, { recursive: true, force: true }) // left by a crashed run: from your settings again
|
|
53
|
+
fs.mkdirSync(runs, { recursive: true })
|
|
54
|
+
// your providers and keys; what HeartCode learned about each model and prices (or it tests them again)
|
|
55
|
+
for (const f of ['config.json', 'models', 'prices.json']) if (fs.existsSync(path.join(realHome, f))) fs.cpSync(path.join(realHome, f), path.join(dir, f), { recursive: true })
|
|
56
|
+
}
|
|
57
|
+
fs.writeFileSync(path.join(runs, String(process.pid)), '')
|
|
40
58
|
process.env.HEART_HOME = dir
|
|
41
|
-
process.on('exit', () =>
|
|
59
|
+
process.on('exit', () => {
|
|
60
|
+
fs.rmSync(path.join(runs, String(process.pid)), { force: true })
|
|
61
|
+
if (!others().length) fs.rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
process.once('SIGINT', () => process.exit(130)) // Ctrl+C (heart settings, -p): through 'exit', so the folder goes too
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
// a folder first: HeartCode opens it (subcommands and options go straight through)
|
package/bin/heartcode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { Client } from '../src/client.js'
|
|
5
|
-
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, VERSION } from '../src/config.js'
|
|
5
|
+
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, takeIn, whatOf, VERSION } from '../src/config.js'
|
|
6
6
|
import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
|
|
7
7
|
import { learnFromUsage, LIMIT_MS } from '../src/context.js'
|
|
8
8
|
import { Session, transcript } from '../src/agent.js'
|
|
@@ -26,6 +26,7 @@ import { createWorktree, worktreeState, removeWorktree } from '../src/worktree.j
|
|
|
26
26
|
import { runAcp } from '../src/acp.js'
|
|
27
27
|
import { Tabs } from '../src/tabs.js'
|
|
28
28
|
import { startSettings } from '../src/settings.js'
|
|
29
|
+
import { loadHooks } from '../src/hooks.js'
|
|
29
30
|
import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
|
|
30
31
|
|
|
31
32
|
const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
|
|
@@ -55,8 +56,9 @@ ${c.bold('options')}
|
|
|
55
56
|
--ask ask before every edit and command, this run
|
|
56
57
|
--hide-thinking collapse the model's thinking to one line
|
|
57
58
|
-C, --cwd <dir> project folder (default: current folder)
|
|
58
|
-
--temp a throwaway settings folder
|
|
59
|
-
|
|
59
|
+
--temp a throwaway settings folder (your providers, model tests and prices
|
|
60
|
+
copied in), shared by the --temp runs going on at once and deleted
|
|
61
|
+
after the last; ~/.heart isn't touched
|
|
60
62
|
--context <n> context window size in tokens
|
|
61
63
|
--force re-run the probe
|
|
62
64
|
--save keep the given -u/-m/--api-key/... in ${CONFIG_FILE}
|
|
@@ -179,12 +181,15 @@ class App {
|
|
|
179
181
|
return mcp
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
// After a skill or MCP server was saved or deleted (
|
|
183
|
-
|
|
184
|
+
// After a skill or MCP server was saved or deleted (the settings page, the director, /import):
|
|
185
|
+
// from the next tool call. The old servers are stopped once the message in progress is done
|
|
186
|
+
// (busy), so a call to one of them finishes.
|
|
187
|
+
reload(kind, busy = false) {
|
|
184
188
|
if (kind === 'skill') this.skills = loadSkills(this.root)
|
|
185
189
|
if (kind === 'mcp') {
|
|
186
|
-
this.mcp
|
|
190
|
+
const old = this.mcp
|
|
187
191
|
this.mcp = this.newMcp()
|
|
192
|
+
busy ? this.later.push(() => old.dispose()) : old.dispose()
|
|
188
193
|
}
|
|
189
194
|
if (this.s) Object.assign(this.s, { skills: this.skills, mcp: this.mcp })
|
|
190
195
|
}
|
|
@@ -852,13 +857,13 @@ function openBrowser(url) {
|
|
|
852
857
|
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
853
858
|
}
|
|
854
859
|
|
|
855
|
-
// A change
|
|
856
|
-
// ACP)
|
|
857
|
-
//
|
|
860
|
+
// A change of the settings (the settings page, another HeartCode on the same folder, a hand), brought
|
|
861
|
+
// into every open tab and run (also -p and ACP) right away: the running task's next request has it
|
|
862
|
+
// (the model list's #1, the prompts, hooks, skills, MCP servers…).
|
|
858
863
|
function applySettings(apps, cfg, what) {
|
|
859
864
|
const before = themeCodes()
|
|
860
865
|
if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
|
|
861
|
-
if (what === 'prompts') reloadPrompts()
|
|
866
|
+
if (what === 'prompts') reloadPrompts()
|
|
862
867
|
if (['setting:log', 'setting:logRequests', 'setting:logDetail'].includes(what)) useRequestLog(cfg) // the run's log and the requests', on or off now
|
|
863
868
|
if (what === 'setting:logDays') pruneLogs(logDays(cfg))
|
|
864
869
|
settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
|
|
@@ -888,7 +893,9 @@ function applySettings(apps, cfg, what) {
|
|
|
888
893
|
// the window size, or a model set up or measured on the page: the model in use takes it again
|
|
889
894
|
// from its profile now (it saves its profile as it learns, so it mustn't keep the old one)
|
|
890
895
|
if ((what === 'setting:context' || what === 'profile') && app.s) app.s.client.onUse?.({ provider: app.s.client.provider, model: app.s.client.model })
|
|
891
|
-
if (what === 'mcp' || what === 'skill')
|
|
896
|
+
if (what === 'mcp' || what === 'skill') app.reload(what, busy)
|
|
897
|
+
if (what === 'prompts' && app.s) app.s.refreshSystem = true // the system prompt, again before the next request
|
|
898
|
+
if (what === 'hooks' && app.s && app.s.hooks !== false) app.s.hooks = loadHooks(app.root)
|
|
892
899
|
if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
893
900
|
if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
|
|
894
901
|
else ui.render()
|
|
@@ -897,10 +904,10 @@ function applySettings(apps, cfg, what) {
|
|
|
897
904
|
}
|
|
898
905
|
|
|
899
906
|
// Changes saved by another HeartCode, or \`heart settings\` in another terminal, while this one runs:
|
|
900
|
-
// taken in as the page's own are (applySettings). config.json
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
const WATCHED = { 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', skills: 'skill' }
|
|
907
|
+
// taken in as the page's own are (applySettings). config.json: the keys others changed (takeIn; a
|
|
908
|
+
// model given for this run with -m stays unless the list's #1 changes); the other files by their
|
|
909
|
+
// size and time, and only when this run didn't just apply them itself.
|
|
910
|
+
const WATCHED = { 'mcp.json': 'mcp', 'hooks.json': 'hooks', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', 'HEARTCODE.md': 'prompts', skills: 'skill' }
|
|
904
911
|
const seenStamp = {}
|
|
905
912
|
const stampOf = (p) => {
|
|
906
913
|
try {
|
|
@@ -915,21 +922,9 @@ function settingsSeen(what) {
|
|
|
915
922
|
for (const [name, w] of Object.entries(WATCHED)) if (w === what || (what === 'setting:theme' && w === 'theme')) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
916
923
|
}
|
|
917
924
|
function watchSettings(apps, cfg) {
|
|
918
|
-
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
919
|
-
let last = readJson(CONFIG_FILE, {})
|
|
920
925
|
for (const name of Object.keys(WATCHED)) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
921
926
|
const configChanged = () => {
|
|
922
|
-
const
|
|
923
|
-
if (!now) return
|
|
924
|
-
const keys = [...new Set([...Object.keys(now), ...Object.keys(last)])].filter((k) => !same(now[k], last[k]))
|
|
925
|
-
last = now
|
|
926
|
-
const whats = new Set()
|
|
927
|
-
for (const k of keys) {
|
|
928
|
-
const value = k === 'providers' ? { ...now.providers, ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : now[k]
|
|
929
|
-
if (same(value, cfg[k])) continue
|
|
930
|
-
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
931
|
-
whats.add(['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
932
|
-
}
|
|
927
|
+
const whats = new Set(takeIn(cfg).map(whatOf))
|
|
933
928
|
for (const what of whats) applySettings(apps(), cfg, what)
|
|
934
929
|
if (whats.size) log('settings', `taken in from config.json: ${[...whats].join(', ')}`)
|
|
935
930
|
}
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -669,6 +669,9 @@ async function runNative(s, task, images, signal) {
|
|
|
669
669
|
const stream = strat.toolsStream
|
|
670
670
|
// A long tool call (a whole file) streams in silently: say what's being written.
|
|
671
671
|
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
672
|
+
// the prompts or HEARTCODE.md changed meanwhile (applySettings): the system prompt again, now
|
|
673
|
+
if (s.refreshSystem && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: SYSTEM(s) }
|
|
674
|
+
s.refreshSystem = false
|
|
672
675
|
const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
673
676
|
ui.stopSpin()
|
|
674
677
|
if (!stream) {
|
package/src/config.js
CHANGED
|
@@ -112,14 +112,60 @@ export function loadConfig(flags = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
const model = flags.model || process.env.HEART_MODEL
|
|
114
114
|
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
|
|
115
|
+
synced(cfg, readJson(CONFIG_FILE, {}))
|
|
115
116
|
return cfg
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
// What a cfg stands for in config.json (the run-only provider and model left out).
|
|
120
|
+
function saved(cfg) {
|
|
119
121
|
const providers = byName(cfg.providers)
|
|
120
122
|
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
121
123
|
const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
|
|
122
124
|
const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
|
|
123
125
|
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
124
|
-
|
|
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
|
|
125
171
|
}
|
package/src/settings.html
CHANGED
|
@@ -147,29 +147,29 @@
|
|
|
147
147
|
.preset span { color: var(--muted); font-size: 12px; }
|
|
148
148
|
.preset.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
149
149
|
|
|
150
|
-
/* Providers & models: every provider's models
|
|
151
|
-
|
|
152
|
-
.work { display:
|
|
153
|
-
.work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
154
|
-
.cat-top { padding:
|
|
155
|
-
.cat-
|
|
156
|
-
.cat-
|
|
157
|
-
.cat-
|
|
158
|
-
.cat-
|
|
159
|
-
.cat-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
.
|
|
150
|
+
/* Providers & models: every provider's models in one list, full width; it fills the screen's height and scrolls inside */
|
|
151
|
+
.head-actions { display: flex; gap: 8px; align-items: center; }
|
|
152
|
+
.work { display: flex; flex-direction: column; }
|
|
153
|
+
.work > .card { flex: 1; padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
154
|
+
.cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
155
|
+
.cat-top input[type=search] { flex: 1; min-width: 140px; }
|
|
156
|
+
.cat-top select { flex: none; width: auto; padding-right: 30px; }
|
|
157
|
+
.cat-top button { flex: none; }
|
|
158
|
+
.cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
|
|
159
|
+
.cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
160
|
+
/* what to do with the ticked models: at the bottom of the list, only while some are ticked */
|
|
161
|
+
.picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--accent-soft); font-size: 13px; }
|
|
162
|
+
.picks .n { font-weight: 600; margin-right: auto; }
|
|
163
163
|
.prov-h { cursor: pointer; }
|
|
164
|
-
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s;
|
|
164
|
+
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; }
|
|
165
165
|
.prov-h .fold:hover { color: var(--text); }
|
|
166
166
|
section.collapsed .prov-h .fold { transform: rotate(-90deg); }
|
|
167
167
|
section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
|
|
168
168
|
.cat-list { flex: 1; overflow-y: auto; }
|
|
169
|
-
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px;
|
|
170
|
-
.prov-h .who { flex: 1; min-width:
|
|
169
|
+
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
|
170
|
+
.prov-h .who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
|
171
171
|
.prov-h .name { font-weight: 700; font-size: 15px; }
|
|
172
|
-
.prov-h .meta { font-size: 12px; color: var(--
|
|
172
|
+
.prov-h .meta { font-size: 12px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
173
173
|
.prov-h .meta .mono { font-size: 12px; }
|
|
174
174
|
.rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
|
|
175
175
|
.mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; }
|
|
@@ -185,11 +185,9 @@
|
|
|
185
185
|
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
|
|
186
186
|
.mwrap .mrow { border-bottom: 0; }
|
|
187
187
|
.mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
|
|
188
|
+
.mwrap { padding-left: 16px; align-items: center; }
|
|
189
|
+
.mwrap .pick + .mrow { padding-left: 12px; }
|
|
188
190
|
.mwrap .forget:hover { color: var(--err); }
|
|
189
|
-
.cat-top .listsw { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); white-space: nowrap; margin-right: 6px; }
|
|
190
|
-
.cat-top .listsw .sw { margin-top: 0; }
|
|
191
|
-
.prov-h .listsw { margin-top: 8px; width: fit-content; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
|
192
|
-
.prov-h .listsw .sw { margin-top: 0; }
|
|
193
191
|
.log-opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; }
|
|
194
192
|
.log-opts .opt { border-bottom: 0; padding-bottom: 0; }
|
|
195
193
|
@media (max-width: 860px) { .log-opts { grid-template-columns: 1fr; } }
|
|
@@ -219,8 +217,6 @@
|
|
|
219
217
|
.req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
|
|
220
218
|
@media (max-width: 860px) { .reqs { grid-template-columns: 1fr; } .req-list { border-right: 0; max-height: 50vh; } }
|
|
221
219
|
.cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
|
|
222
|
-
.byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
|
|
223
|
-
.byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
|
|
224
220
|
/* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
|
|
225
221
|
.olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
226
222
|
.olist .test .msg { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }
|
|
@@ -262,13 +258,18 @@
|
|
|
262
258
|
/* Your models: a table, one row a model */
|
|
263
259
|
.mt-card { padding: 0; }
|
|
264
260
|
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
|
|
265
|
-
.mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
|
|
261
|
+
.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 */
|
|
266
262
|
.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; }
|
|
267
263
|
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
268
264
|
.mt-scroll { overflow-x: auto; }
|
|
269
265
|
.mt { min-width: 1116px; }
|
|
270
266
|
.mt-h input[type=checkbox], .mtable input[type=checkbox] { width: 15px; height: 15px; margin: 0; accent-color: var(--brand); cursor: pointer; }
|
|
271
267
|
.mtable li.picked { background: var(--brand-soft); }
|
|
268
|
+
.mt-rules { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 28px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
|
269
|
+
.mt-rules .rule { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
|
270
|
+
.mt-rules select { width: auto; height: 32px; padding: 4px 30px 4px 10px; }
|
|
271
|
+
.mt-rules input { width: 64px; height: 32px; }
|
|
272
|
+
.mt-rules .hint { flex-basis: 100%; margin: 0; }
|
|
272
273
|
.mt-outcomes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
|
|
273
274
|
.mt-outcomes > span:first-child { margin-right: 4px; }
|
|
274
275
|
/* a test result: what it was and how many (a click selects them), and ↻ to test them again */
|
|
@@ -430,26 +431,23 @@
|
|
|
430
431
|
<!-- Providers & models -->
|
|
431
432
|
<section class="page" id="page-providers">
|
|
432
433
|
<div class="head">
|
|
433
|
-
<div><h1>Providers & models</h1><p class="lead">
|
|
434
|
-
<button class="primary" id="p-open">+ Add a provider</button>
|
|
434
|
+
<div><h1>Providers & models</h1><p class="lead">Click a model to add it to your list, or tick several and add them at once. HeartCode uses your list in order: #1, then the next when one fails.</p></div>
|
|
435
|
+
<div class="head-actions"><button id="p-mine" type="button"></button><button class="primary" id="p-open">+ Add a provider</button></div>
|
|
435
436
|
</div>
|
|
436
437
|
<div class="steps" id="steps"></div>
|
|
437
|
-
<div class="
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
<div class="cat-group end"><button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button><button class="small" id="add-all" type="button" title="add every model shown here (only the matches when searching) to your list">Add all</button></div>
|
|
446
|
-
</div>
|
|
447
|
-
</div>
|
|
448
|
-
<div class="job-slot" data-where="catalog"></div>
|
|
449
|
-
<div class="cat-list" id="p-list"></div>
|
|
450
|
-
</div>
|
|
451
|
-
<div class="card mine" id="inuse"></div>
|
|
438
|
+
<div class="work" id="p-work" hidden>
|
|
439
|
+
<div class="card catalog">
|
|
440
|
+
<div class="cat-top">
|
|
441
|
+
<input type="checkbox" id="pick-shown" aria-label="select every model shown">
|
|
442
|
+
<input type="search" id="m-search" placeholder="Search all models…">
|
|
443
|
+
<select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price</option></select>
|
|
444
|
+
<button class="small ghost" id="fold-all" type="button">Collapse all</button>
|
|
445
|
+
<button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button>
|
|
452
446
|
</div>
|
|
447
|
+
<div class="job-slot" data-where="catalog"></div>
|
|
448
|
+
<div class="cat-list" id="p-list"></div>
|
|
449
|
+
<div class="picks" id="picks" hidden></div>
|
|
450
|
+
</div>
|
|
453
451
|
</div>
|
|
454
452
|
</section>
|
|
455
453
|
|
|
@@ -607,7 +605,11 @@
|
|
|
607
605
|
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
608
606
|
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
609
607
|
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
610
|
-
<div
|
|
608
|
+
<div id="p-more" hidden>
|
|
609
|
+
<label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (type one's exact name in the search).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
|
|
610
|
+
<div class="actions" id="p-added"></div>
|
|
611
|
+
</div>
|
|
612
|
+
<div class="actions"><button class="danger" id="p-delete" hidden>Delete provider</button><span class="spacer"></span><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
611
613
|
</dialog>
|
|
612
614
|
<dialog id="c-form" aria-labelledby="c-title">
|
|
613
615
|
<h2 id="c-title">Add a server</h2>
|
|
@@ -654,7 +656,7 @@ function toast(text, err = false) {
|
|
|
654
656
|
}
|
|
655
657
|
// What's running (a test, lists loading, models.dev), shown at the top of the panels it's about
|
|
656
658
|
// while it runs: job(id, { what, where, done, total, detail, stop }) to show or update it (no total:
|
|
657
|
-
// a bar that just moves), job(id) when it's over. where: the panels ('catalog', '
|
|
659
|
+
// a bar that just moves), job(id) when it's over. where: the panels ('catalog', 'table').
|
|
658
660
|
// Updated in place, so nothing jumps while it counts.
|
|
659
661
|
const JOBS = new Map()
|
|
660
662
|
function job(id, state) {
|
|
@@ -901,11 +903,25 @@ function openForm(p = null) {
|
|
|
901
903
|
$('p-key').placeholder = p?.key ? 'empty: keep the key it has' : 'paste it here (none for a server on this machine, like Ollama)'
|
|
902
904
|
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
903
905
|
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
906
|
+
// editing: what used to crowd its header
|
|
907
|
+
$('p-more').hidden = $('p-delete').hidden = !p
|
|
908
|
+
$('presets').hidden = Boolean(p)
|
|
909
|
+
$('p-form').querySelector('.sub').hidden = Boolean(p)
|
|
910
|
+
if (p) {
|
|
911
|
+
$('p-list-on').checked = p.list
|
|
912
|
+
put($('p-added'), p.added.length ? [h('span', { className: 'muted', style: 'font-size:13px', textContent: `${p.added.length} model${p.added.length === 1 ? '' : 's'} you added by name` }), h('button', { className: 'small danger', textContent: 'Forget them', onclick: () => ($('p-form').close(), clearAdded(p)) })] : [])
|
|
913
|
+
}
|
|
904
914
|
drawPresets()
|
|
905
915
|
$('p-form').showModal()
|
|
906
916
|
;(p ? $('p-url') : $('p-name')).focus()
|
|
907
917
|
}
|
|
908
918
|
const closeForm = () => $('p-form').close()
|
|
919
|
+
$('p-delete').onclick = () => {
|
|
920
|
+
const p = editing
|
|
921
|
+
if (!p || !confirm(`Delete the provider "${p.name}"? Its models leave your list too.`)) return
|
|
922
|
+
closeForm()
|
|
923
|
+
act('/api/provider/delete', { name: p.name }, `deleted ${p.name}`)
|
|
924
|
+
}
|
|
909
925
|
$('p-form').onclose = () => (editing = null)
|
|
910
926
|
$('p-open').onclick = () => openForm()
|
|
911
927
|
$('p-cancel').onclick = closeForm
|
|
@@ -917,6 +933,7 @@ $('p-save').onclick = async () => {
|
|
|
917
933
|
if (!r) return
|
|
918
934
|
const added = r.providers.find((p) => p.name === name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''))
|
|
919
935
|
if (!added) return
|
|
936
|
+
if (was && $('p-list-on').checked !== was.list) await act('/api/provider/list', { name: added.name, on: $('p-list-on').checked }) // its list switched on or off
|
|
920
937
|
if (was) delete MODELS[was.name]
|
|
921
938
|
loadModels(added.name, true) // a new one, or a new URL or key: its models (again)
|
|
922
939
|
toast(was ? `saved ${added.name}` : `added ${added.name}: its models are loading`)
|
|
@@ -930,36 +947,22 @@ for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown',
|
|
|
930
947
|
const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare // one collator: thousands of names sort fast
|
|
931
948
|
let modelQuery = ''
|
|
932
949
|
// the models under each provider: by name, or by what its list says they cost (cheapest first; no price: last)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
return 'name'
|
|
938
|
-
}
|
|
939
|
-
})()
|
|
940
|
-
$('m-sort').value = catSort
|
|
950
|
+
// The page's own choices (sorts, folds, the provider ranking), kept in ~/.heart (the browser's
|
|
951
|
+
// storage isn't: the page's address has a new port each time)
|
|
952
|
+
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
953
|
+
let catSort = 'name'
|
|
941
954
|
$('m-sort').onchange = (e) => {
|
|
942
955
|
catSort = e.target.value
|
|
943
|
-
|
|
944
|
-
localStorage.setItem('heart.catSort', catSort)
|
|
945
|
-
} catch {}
|
|
956
|
+
savePref('catSort', catSort)
|
|
946
957
|
if (S) drawProviders(S)
|
|
947
958
|
}
|
|
948
959
|
const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) : Infinity)
|
|
949
|
-
// Providers folded on the page (
|
|
950
|
-
//
|
|
960
|
+
// Providers folded on the page (kept: savePref). Typing a search opens them all, until you fold or
|
|
961
|
+
// unfold one (or all) again.
|
|
951
962
|
let searchOpen = false
|
|
952
|
-
const FOLDED = new Set(
|
|
953
|
-
try {
|
|
954
|
-
return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
|
|
955
|
-
} catch {
|
|
956
|
-
return []
|
|
957
|
-
}
|
|
958
|
-
})())
|
|
963
|
+
const FOLDED = new Set()
|
|
959
964
|
function applyFolds() {
|
|
960
|
-
|
|
961
|
-
localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
|
|
962
|
-
} catch {}
|
|
965
|
+
$('fold-all').textContent = allFolded() ? 'Expand all' : 'Collapse all'
|
|
963
966
|
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
964
967
|
const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
|
|
965
968
|
sec.classList.toggle('collapsed', folded)
|
|
@@ -972,18 +975,16 @@ function settle() {
|
|
|
972
975
|
if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
|
|
973
976
|
searchOpen = false
|
|
974
977
|
}
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
$('
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
if (!add.length) return toast(modelQuery.trim() ? 'every match is in your list already' : 'every model shown is in your list already')
|
|
984
|
-
if (!confirm(`Add ${add.length} model${add.length === 1 ? '' : 's'} to your list${modelQuery.trim() ? ` (the ones matching "${modelQuery.trim()}")` : ''}?`)) return
|
|
985
|
-
saveOrder([...S.order, ...add], `${add.length} added: your list has ${S.order.length + add.length} models`)
|
|
978
|
+
const folds = () => (applyFolds(), savePref('folded', [...FOLDED])) // a fold changed: shown and kept
|
|
979
|
+
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), folds())
|
|
980
|
+
// one button: collapse them all, or (all collapsed) expand them all
|
|
981
|
+
const allFolded = () => Boolean(S?.providers.length) && S.providers.every((p) => FOLDED.has(p.name))
|
|
982
|
+
$('fold-all').onclick = () => (settle(), allFolded() ? FOLDED.clear() : S?.providers.forEach((p) => FOLDED.add(p.name)), folds())
|
|
983
|
+
$('pick-shown').onchange = (e) => {
|
|
984
|
+
for (const [provider, ids] of SHOWN) for (const id of ids) e.target.checked ? pickedOf(provider).add(id) : PICKED_CAT.get(provider)?.delete(id)
|
|
985
|
+
drawPicks()
|
|
986
986
|
}
|
|
987
|
+
$('p-mine').onclick = () => (location.hash = 'models')
|
|
987
988
|
// models.dev fetched again (your models' profiles too), then every listed provider's models with it
|
|
988
989
|
$('dev-all').onclick = async () => {
|
|
989
990
|
if (!(await lookUp())) return
|
|
@@ -994,21 +995,8 @@ $('m-search').oninput = (e) => ((modelQuery = e.target.value), (searchOpen = tru
|
|
|
994
995
|
// list and its own test), and what a click does. Shown on hover and on keyboard focus.
|
|
995
996
|
const kTok = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
|
|
996
997
|
const usd = (x) => (x === 0 ? 'free' : `$${x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3).replace(/0$/, '') : x.toFixed(2)}`)
|
|
997
|
-
//
|
|
998
|
-
// lines of text, its button on the right): where that comes from, and Edit (or Set it up, for one
|
|
999
|
-
// models.dev doesn't know). Replaced in place when it changes (streamed).
|
|
998
|
+
// where what a model can do comes from (the Your models table)
|
|
1000
999
|
const FROM = { 'models.dev': 'from models.dev', you: 'set by you', tested: 'tested' }
|
|
1001
|
-
function testLine(t) {
|
|
1002
|
-
const x = t.tested
|
|
1003
|
-
const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
|
|
1004
|
-
const [cls, text, action] =
|
|
1005
|
-
!x ? ['none', 'looking it up in models.dev…', null]
|
|
1006
|
-
: x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Refresh from models.dev
|
|
1007
|
-
: ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
|
|
1008
|
-
const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
|
|
1009
|
-
return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
1000
|
// Test: one short message to the model, to see that it really answers (the key, access to it, its
|
|
1013
1001
|
// name, the server). The result stays under it until the page is reloaded.
|
|
1014
1002
|
const TRIES = {} // "provider/model" → { going } | { ok, ms, reply, at } | { ok: false, status, error, at } (from the server)
|
|
@@ -1091,7 +1079,7 @@ function showRun(run) {
|
|
|
1091
1079
|
if (run?.over) toast(`${run.stopping ? `stopped after ${run.done} of ${run.total}: ` : ''}${run.good} of ${run.done} work${run.good === run.done ? '' : `; ${run.done - run.good} don't (Works? says why, on hover)`}`, run.good < run.done)
|
|
1092
1080
|
} else
|
|
1093
1081
|
job('test-all', {
|
|
1094
|
-
where: ['table'
|
|
1082
|
+
where: ['table'],
|
|
1095
1083
|
what: run.stopping ? 'Stopping the tests…' : `Testing ${run.some ? '' : 'your '}${run.total} model${run.total === 1 ? '' : 's'}${run.each > 1 ? ` · ${run.each} at a time per provider` : ''}`,
|
|
1096
1084
|
done: run.done, total: run.total, stopping: run.stopping,
|
|
1097
1085
|
detail: [h('span', { className: 'ok', textContent: `✓ ${run.good} work` }), ' · ', h('span', { className: 'bad', textContent: `✗ ${run.done - run.good} don't` }), run.busy.length ? ` · now: ${run.busy.join(', ')}` : '', Object.keys(run.paused ?? {}).length ? ` · paused (busy, 429): ${Object.entries(run.paused).map(([p, secs]) => `${p} ${secs}s`).join(', ')}` : ''],
|
|
@@ -1103,7 +1091,7 @@ function showRun(run) {
|
|
|
1103
1091
|
// models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
|
|
1104
1092
|
const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
|
|
1105
1093
|
async function lookUp(t) {
|
|
1106
|
-
job('models.dev', { where: ['catalog', '
|
|
1094
|
+
job('models.dev', { where: ['catalog', 'table'], what: t ? `Looking up ${t.model} in models.dev` : 'Asking models.dev about your models', detail: 'context, tool calls, thinking, images' })
|
|
1107
1095
|
const r = await act('/api/models-dev', t ? { provider: t.provider, model: t.model } : {})
|
|
1108
1096
|
job('models.dev')
|
|
1109
1097
|
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`)
|
|
@@ -1193,73 +1181,21 @@ function drawProviders(s) {
|
|
|
1193
1181
|
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1194
1182
|
put($('steps'), [
|
|
1195
1183
|
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
1196
|
-
['Choose
|
|
1184
|
+
['Choose models', 'click one, or tick several and add them', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
1197
1185
|
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
1198
1186
|
].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))))
|
|
1187
|
+
$('steps').hidden = order.length > 0 // the first start only
|
|
1199
1188
|
$('p-work').hidden = none
|
|
1189
|
+
// your list, at the top: how many, and the way to its page (order, tests, what to do when one fails)
|
|
1190
|
+
$('p-mine').hidden = !order.length
|
|
1191
|
+
$('p-mine').textContent = `Your models (${order.length}) →`
|
|
1192
|
+
$('p-mine').title = order.length ? `#1: ${order[0].provider} · ${order[0].model}. Open the Your models page: the order, tests, and what happens when one fails` : ''
|
|
1200
1193
|
fillHeight()
|
|
1201
1194
|
|
|
1202
|
-
//
|
|
1203
|
-
const side = innerWidth > 860 ? 'on the left' : 'below' // where the models are (a phone: under this)
|
|
1204
|
-
const save = (list, done) => saveOrder(list, done)
|
|
1205
|
-
const move = (i, j) => {
|
|
1206
|
-
if (i === j) return
|
|
1207
|
-
const list = [...order]
|
|
1208
|
-
const [t] = list.splice(i, 1)
|
|
1209
|
-
list.splice(j, 0, t)
|
|
1210
|
-
return save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1211
|
-
}
|
|
1212
|
-
// a row of your list (the last: where the next one goes)
|
|
1213
|
-
const mineRow = (i) => {
|
|
1214
|
-
if (i === order.length) return h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` })
|
|
1215
|
-
const t = order[i]
|
|
1216
|
-
return h('li', {
|
|
1217
|
-
className: 'in' + (i === 0 ? ' use' : ''), tabIndex: 0, 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
|
|
1218
|
-
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))))),
|
|
1219
|
-
},
|
|
1220
|
-
h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
|
|
1221
|
-
h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
|
|
1222
|
-
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1223
|
-
h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
|
|
1224
|
-
testLine(t),
|
|
1225
|
-
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1226
|
-
}
|
|
1227
|
-
const mine = order.length ? h('ol', { className: 'olist' }) : null
|
|
1228
|
-
const mineV = mine && virtual(mine, { count: order.length + 1, height: MINE_ROW, row: mineRow })
|
|
1229
|
-
if (mineV) mineV.items = order.length
|
|
1230
|
-
put($('inuse'),
|
|
1231
|
-
h('div', { className: 'mine-top' },
|
|
1232
|
-
h('div', { className: 'mine-title' },
|
|
1233
|
-
h('h2', { textContent: 'Your models' }),
|
|
1234
|
-
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') }),
|
|
1235
|
-
h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
|
|
1236
|
-
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') })),
|
|
1237
|
-
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.` })),
|
|
1238
|
-
jobSlot('mine'),
|
|
1239
|
-
order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
|
|
1240
|
-
order.length ? mine
|
|
1241
|
-
: h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
|
|
1242
|
-
order.length > 1 ? h('div', { className: 'mine-rules' },
|
|
1243
|
-
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
1244
|
-
h('div', { className: 'row', style: 'gap:8px' },
|
|
1245
|
-
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
1246
|
-
[['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 }))),
|
|
1247
|
-
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),
|
|
1248
|
-
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.' }),
|
|
1249
|
-
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
1250
|
-
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1251
|
-
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
1252
|
-
|
|
1253
|
-
mineV?.draw()
|
|
1254
|
-
sortable(mineV, move)
|
|
1255
|
-
|
|
1256
|
-
// left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1195
|
+
// every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1257
1196
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1258
1197
|
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
|
|
1259
1198
|
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort])
|
|
1260
|
-
// every provider's list at once: on when all are
|
|
1261
|
-
const allOn = s.providers.length > 0 && s.providers.every((p) => p.list)
|
|
1262
|
-
put($('lists-all'), [sw({ checked: allOn, 'aria-label': "load every provider's model list", onchange: (e) => act('/api/provider/list', { on: e.target.checked }, `every provider's model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load all lists'])
|
|
1263
1199
|
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
1264
1200
|
catalogShape = shape
|
|
1265
1201
|
markCatalog()
|
|
@@ -1272,7 +1208,6 @@ function drawProviders(s) {
|
|
|
1272
1208
|
// Drag a row of a long list (virtual) to move it: it follows the pointer, the others make room, and
|
|
1273
1209
|
// near the list's top or bottom edge it scrolls (faster nearer the edge). onMove(from, to) when
|
|
1274
1210
|
// dropped; the list is drawn again once the order is saved (put back if saving failed).
|
|
1275
|
-
const MINE_ROW = 50 // your models, on the right
|
|
1276
1211
|
const TABLE_ROW = 46 // the Your models page's table
|
|
1277
1212
|
function sortable(v, onMove) {
|
|
1278
1213
|
if (!v || v.items < 2) return
|
|
@@ -1406,12 +1341,56 @@ async function clearAdded(p) {
|
|
|
1406
1341
|
act('/api/provider/added', { name: p.name, all: true }, `${p.name}: the models you added are forgotten`)
|
|
1407
1342
|
}
|
|
1408
1343
|
const CAT_ROW = 56 // a model's row on the left: its name, and what it can do under it
|
|
1409
|
-
|
|
1344
|
+
// The models ticked (provider → Set of ids): kept while you search, so ones ticked for one search
|
|
1345
|
+
// and the next are added (or taken off) together. SHOWN: each provider's models shown now (the
|
|
1346
|
+
// search's matches).
|
|
1347
|
+
const PICKED_CAT = new Map()
|
|
1348
|
+
const SHOWN = new Map()
|
|
1349
|
+
const pickedOf = (name) => PICKED_CAT.get(name) ?? PICKED_CAT.set(name, new Set()).get(name)
|
|
1350
|
+
// The ticked models (every provider's) and what to do with them, on the bar over the list: all the
|
|
1351
|
+
// shown ones ticked (or none), added to your list, taken off it. The tick boxes drawn now (rows and
|
|
1352
|
+
// each provider's) follow.
|
|
1353
|
+
function drawPicks() {
|
|
1354
|
+
if (!S) return
|
|
1355
|
+
const all = [...PICKED_CAT].flatMap(([provider, ids]) => [...ids].map((model) => ({ provider, model })))
|
|
1356
|
+
const shown = [...SHOWN].flatMap(([provider, ids]) => ids.map((model) => ({ provider, model })))
|
|
1357
|
+
const isPicked = (t) => Boolean(PICKED_CAT.get(t.provider)?.has(t.model))
|
|
1358
|
+
const toAdd = all.filter((t) => placeOf(t.provider, t.model) === undefined)
|
|
1359
|
+
const toTake = all.filter((t) => placeOf(t.provider, t.model) !== undefined)
|
|
1360
|
+
for (const box of $('p-list').querySelectorAll('input.pick')) box.checked = Boolean(PICKED_CAT.get(box.closest('section').dataset.provider)?.has(box.dataset.model))
|
|
1361
|
+
for (const box of $('p-list').querySelectorAll('input.pick-all')) {
|
|
1362
|
+
const ids = SHOWN.get(box.dataset.provider) ?? []
|
|
1363
|
+
const n = ids.filter((id) => PICKED_CAT.get(box.dataset.provider)?.has(id)).length
|
|
1364
|
+
box.checked = n > 0 && n === ids.length
|
|
1365
|
+
box.indeterminate = n > 0 && n < ids.length
|
|
1366
|
+
}
|
|
1367
|
+
const top = $('pick-shown')
|
|
1368
|
+
const n = shown.filter(isPicked).length
|
|
1369
|
+
top.checked = n > 0 && n === shown.length
|
|
1370
|
+
top.indeterminate = n > 0 && n < shown.length
|
|
1371
|
+
top.disabled = !shown.length
|
|
1372
|
+
top.title = top.checked ? 'unselect every model shown' : `select every model shown (${shown.length}${modelQuery.trim() ? ': the matches' : ''})`
|
|
1373
|
+
const take = () => {
|
|
1374
|
+
const off = new Set(toTake.map(keyOf))
|
|
1375
|
+
const rest = S.order.filter((t) => !off.has(keyOf(t)))
|
|
1376
|
+
if (!confirm(`Take ${toTake.length} model${toTake.length === 1 ? '' : 's'} off your list?${!rest.length ? ' None is left then: HeartCode has no model to use until you choose one.' : keyOf(rest[0]) !== keyOf(S.order[0]) ? ` HeartCode then uses ${rest[0].provider} · ${rest[0].model}.` : ''}`)) return
|
|
1377
|
+
PICKED_CAT.clear()
|
|
1378
|
+
saveOrder(rest, `${toTake.length} removed`)
|
|
1379
|
+
}
|
|
1380
|
+
const was = $('picks').hidden
|
|
1381
|
+
$('picks').hidden = !all.length
|
|
1382
|
+
put($('picks'),
|
|
1383
|
+
h('span', { className: 'n', textContent: `${all.length} selected` }),
|
|
1384
|
+
h('button', { className: 'small ghost', textContent: 'Clear', onclick: () => (PICKED_CAT.clear(), drawPicks()) }),
|
|
1385
|
+
toTake.length ? h('button', { className: 'small danger', textContent: `Remove ${toTake.length} from your list`, onclick: take }) : null,
|
|
1386
|
+
toAdd.length ? h('button', { className: 'small primary', textContent: `Add ${toAdd.length} to your list`, onclick: () => (PICKED_CAT.clear(), saveOrder([...S.order, ...toAdd], `${toAdd.length} added: your list has ${S.order.length + toAdd.length} models`)) }) : null)
|
|
1387
|
+
if (was !== $('picks').hidden) fillHeight() // the list gives it room
|
|
1388
|
+
}
|
|
1410
1389
|
function buildCatalog(s) {
|
|
1411
|
-
CATALOG = []
|
|
1412
1390
|
const q = modelQuery.trim()
|
|
1413
1391
|
// a name no provider lists (anywhere): offered under each provider, to use it anyway
|
|
1414
1392
|
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
1393
|
+
SHOWN.clear()
|
|
1415
1394
|
put($('p-list'), s.providers.map((p) => {
|
|
1416
1395
|
if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
|
|
1417
1396
|
else loadModels(p.name, MODELS[p.name]?.off)
|
|
@@ -1425,8 +1404,9 @@ function buildCatalog(s) {
|
|
|
1425
1404
|
h('span', { className: 'id', title: id }, id, canCell(m.details?.[id], S.order[placeOf(p.name, id)])),
|
|
1426
1405
|
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1427
1406
|
rowTail(p.name, id))
|
|
1428
|
-
// one you added: ✕ forgets it (it leaves your list too)
|
|
1429
|
-
const
|
|
1407
|
+
// its tick box (to add or take off several at once), and one you added: ✕ forgets it (it leaves your list too)
|
|
1408
|
+
const tick = (id) => h('input', { type: 'checkbox', className: 'pick', checked: pickedOf(p.name).has(id), 'data-model': id, 'aria-label': `select ${id}`, onchange: (e) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id), drawPicks()) })
|
|
1409
|
+
const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id), !p.added.includes(id) ? null :
|
|
1430
1410
|
h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
|
|
1431
1411
|
if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
|
|
1432
1412
|
if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
|
|
@@ -1435,43 +1415,29 @@ function buildCatalog(s) {
|
|
|
1435
1415
|
const typed = !anyMatch ? [row(q, true)] : []
|
|
1436
1416
|
if (typed[0]) typed[0].dataset.typed = '1' // a name no provider lists: not part of Add all
|
|
1437
1417
|
if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
|
|
1438
|
-
// a model its list leaves out (some servers don't list them all): by its exact name
|
|
1439
|
-
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` })
|
|
1440
|
-
const go = () => {
|
|
1441
|
-
const id = name.value.trim()
|
|
1442
|
-
if (!id) return name.focus()
|
|
1443
|
-
name.value = ''
|
|
1444
|
-
// only under this provider, with its others: you choose whether to use it (click it, like any)
|
|
1445
|
-
if ([...(MODELS[p.name]?.list ?? []), ...p.added].includes(id)) return showRow(p.name, id, `${id} is in ${p.name}'s models already`)
|
|
1446
|
-
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`))
|
|
1447
|
-
}
|
|
1448
|
-
name.addEventListener('keydown', (e) => e.key === 'Enter' && go())
|
|
1449
1418
|
return h('section', { 'data-provider': p.name },
|
|
1450
1419
|
// the header folds it (a click on it, or its arrow; not on its buttons and switch)
|
|
1451
1420
|
h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
|
|
1452
1421
|
h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
|
|
1422
|
+
ids.length ? h('input', { type: 'checkbox', className: 'pick-all', 'data-provider': p.name, 'aria-label': `select ${p.name}'s models`, title: `select all of ${p.name}'s models shown (or none)`, onchange: (e) => (ids.forEach((id) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id))), drawPicks()) }) : null,
|
|
1453
1423
|
h('div', { className: 'who' },
|
|
1454
|
-
h('span', { className: 'name', textContent: p.name }),
|
|
1455
|
-
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'}` }),
|
|
1456
|
-
|
|
1457
|
-
h('
|
|
1458
|
-
|
|
1459
|
-
() => 'On: its models are loaded from the provider. Off: never asked for; only the models you add by name show.')),
|
|
1460
|
-
h('button', { className: 'small', textContent: 'Edit', onclick: () => openForm(p) }),
|
|
1461
|
-
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}`) })),
|
|
1462
|
-
h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go }),
|
|
1463
|
-
p.added.length ? h('button', { className: 'small danger', textContent: `Clear added (${p.added.length})`, title: `forget the ${p.added.length} model${p.added.length === 1 ? '' : 's'} you added to ${p.name} by name`, onclick: () => clearAdded(p) }) : null),
|
|
1424
|
+
h('span', { className: 'name', textContent: p.name }),
|
|
1425
|
+
m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: type a model's exact name in the search to add it" }) : 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'}` }),
|
|
1426
|
+
h('span', { className: 'tag inlist' }),
|
|
1427
|
+
h('span', { className: 'meta mono', textContent: p.baseUrl, title: `${p.baseUrl} · key ${p.key || 'none'}` })),
|
|
1428
|
+
h('button', { className: 'small', textContent: 'Edit', title: 'its address and key, its model list, delete it', onclick: () => openForm(p) })),
|
|
1464
1429
|
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1465
1430
|
typed.length ? h('div', { className: 'rows' }, typed) : null,
|
|
1466
1431
|
(() => {
|
|
1467
1432
|
if (!ids.length) return null
|
|
1468
|
-
|
|
1433
|
+
SHOWN.set(p.name, ids)
|
|
1469
1434
|
const box = h('div', { className: 'rows' })
|
|
1470
1435
|
box._ids = ids
|
|
1471
1436
|
box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
|
|
1472
1437
|
return box
|
|
1473
1438
|
})(),
|
|
1474
|
-
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:
|
|
1439
|
+
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or type a model's exact name in the search, and click it." })) : null,
|
|
1440
|
+
m.off && !ids.length && !typed.length ? h('div', { className: 'cat-note', textContent: "Its list is off: type a model's exact name in the search, and click it to add it." }) : null,
|
|
1475
1441
|
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1476
1442
|
}))
|
|
1477
1443
|
}
|
|
@@ -1491,21 +1457,9 @@ const COLS = {
|
|
|
1491
1457
|
output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
|
|
1492
1458
|
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
|
|
1493
1459
|
}
|
|
1494
|
-
let SORT =
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
} catch {
|
|
1498
|
-
return []
|
|
1499
|
-
}
|
|
1500
|
-
})()
|
|
1501
|
-
// your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
|
|
1502
|
-
let RANK = (() => {
|
|
1503
|
-
try {
|
|
1504
|
-
return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
|
|
1505
|
-
} catch {
|
|
1506
|
-
return []
|
|
1507
|
-
}
|
|
1508
|
-
})()
|
|
1460
|
+
let SORT = []
|
|
1461
|
+
// your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
|
|
1462
|
+
let RANK = []
|
|
1509
1463
|
const ranked = () => {
|
|
1510
1464
|
const names = [...new Set(S.order.map((t) => t.provider))]
|
|
1511
1465
|
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
@@ -1530,18 +1484,14 @@ $('pr-cancel').onclick = () => $('pr').close()
|
|
|
1530
1484
|
$('pr-save').onclick = () => {
|
|
1531
1485
|
const list = [...$('pr-list').children].map((li) => li.textContent)
|
|
1532
1486
|
RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
|
|
1533
|
-
|
|
1534
|
-
localStorage.setItem('heart.rank', JSON.stringify(RANK))
|
|
1535
|
-
} catch {}
|
|
1487
|
+
savePref('rank', RANK)
|
|
1536
1488
|
$('pr').close()
|
|
1537
1489
|
setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
|
|
1538
1490
|
}
|
|
1539
1491
|
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1540
1492
|
function setSort(next) {
|
|
1541
1493
|
SORT = next
|
|
1542
|
-
|
|
1543
|
-
localStorage.setItem('heart.sort', JSON.stringify(SORT))
|
|
1544
|
-
} catch {}
|
|
1494
|
+
savePref('sort', SORT)
|
|
1545
1495
|
drawMine(S)
|
|
1546
1496
|
}
|
|
1547
1497
|
// a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
|
|
@@ -1594,13 +1544,10 @@ function drawMine(s) {
|
|
|
1594
1544
|
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) }))
|
|
1595
1545
|
})
|
|
1596
1546
|
|
|
1597
|
-
const same = view.every((t, i) => t === order[i])
|
|
1598
1547
|
// the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
|
|
1599
1548
|
const keep = (list, moved) => {
|
|
1600
1549
|
SORT = []
|
|
1601
|
-
|
|
1602
|
-
localStorage.setItem('heart.sort', '[]')
|
|
1603
|
-
} catch {}
|
|
1550
|
+
savePref('sort', SORT)
|
|
1604
1551
|
return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1605
1552
|
}
|
|
1606
1553
|
// a row dragged (in the order shown, sorted or not)
|
|
@@ -1636,11 +1583,24 @@ function drawMine(s) {
|
|
|
1636
1583
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1637
1584
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1638
1585
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1586
|
+
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,
|
|
1639
1587
|
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,
|
|
1640
1588
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1641
|
-
|
|
1589
|
+
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,
|
|
1642
1590
|
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,
|
|
1643
1591
|
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1592
|
+
// when the one in use fails, and what a new message starts with
|
|
1593
|
+
order.length > 1 ? h('div', { className: 'mt-rules' },
|
|
1594
|
+
h('span', { className: 'rule' },
|
|
1595
|
+
h('label', { for: 'r-on', textContent: 'When a model fails, go on to the next' }),
|
|
1596
|
+
h('select', { id: 'r-on', onchange: (e) => saveOrder(order, 'saved', { on: e.target.value }) },
|
|
1597
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1598
|
+
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1599
|
+
h('span', { className: 'rule' },
|
|
1600
|
+
h('label', { for: 'r-next', textContent: 'A new message starts with' }),
|
|
1601
|
+
h('select', { id: 'r-next', onchange: (e) => saveOrder(order, 'saved', { next: e.target.value }) },
|
|
1602
|
+
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))),
|
|
1603
|
+
h('span', { className: 'hint', textContent: s.rotation.on === 'retries' ? 'A rate limit or outage: that model is tried again that many times first; any other error moves on at once.' : "Any error moves on at once, to another provider's model first." })) : null,
|
|
1644
1604
|
order.length
|
|
1645
1605
|
? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
|
|
1646
1606
|
jobSlot('table'),
|
|
@@ -1695,6 +1655,7 @@ function markCatalog() {
|
|
|
1695
1655
|
tag.hidden = !n
|
|
1696
1656
|
tag.textContent = `${n} in your list${S.order[0]?.provider === sec.dataset.provider ? ' · #1' : ''}`
|
|
1697
1657
|
}
|
|
1658
|
+
drawPicks()
|
|
1698
1659
|
}
|
|
1699
1660
|
|
|
1700
1661
|
function drawUsing(s) {
|
|
@@ -1992,9 +1953,20 @@ function drawFiles(s) {
|
|
|
1992
1953
|
}
|
|
1993
1954
|
|
|
1994
1955
|
function render(s) {
|
|
1956
|
+
if (s.prefs) {
|
|
1957
|
+
// the page's choices, as kept (also changed on another page: shown here too)
|
|
1958
|
+
const p = s.prefs
|
|
1959
|
+
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1960
|
+
$('m-sort').value = catSort
|
|
1961
|
+
FOLDED.clear()
|
|
1962
|
+
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1963
|
+
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
|
1964
|
+
RANK = Array.isArray(p.rank) ? p.rank.filter((x) => typeof x === 'string') : []
|
|
1965
|
+
}
|
|
1995
1966
|
S = s
|
|
1996
1967
|
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
|
|
1997
1968
|
drawProviders(s)
|
|
1969
|
+
applyFolds()
|
|
1998
1970
|
drawMine(s)
|
|
1999
1971
|
drawUsing(s)
|
|
2000
1972
|
drawGeneral(s)
|
|
@@ -2012,6 +1984,7 @@ if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link Heart
|
|
|
2012
1984
|
else {
|
|
2013
1985
|
job('state', { where: ['page'], what: 'Loading your settings', detail: 'providers, models, logging, prompts, MCP servers, skills' })
|
|
2014
1986
|
api('/api/state').then(render, (e) => toast(e.message, true)).finally(() => job('state'))
|
|
1987
|
+
|
|
2015
1988
|
.then(() => api('/api/tests', {}))
|
|
2016
1989
|
.then(({ results, run }) => (Object.assign(TRIES, results), showRun(run)), (e) => toast(`the tests so far: ${e.message}`, true)) // and one going
|
|
2017
1990
|
job('requests', { where: ['requests'], what: 'Loading the request log', detail: 'every request HeartCode logged' })
|
|
@@ -2021,6 +1994,15 @@ else {
|
|
|
2021
1994
|
if (token) {
|
|
2022
1995
|
const events = new EventSource(`/api/events?t=${token}`)
|
|
2023
1996
|
// a request logged: at the top of the list, nothing else redrawn
|
|
1997
|
+
// the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
|
|
1998
|
+
// when it closes (what's typed in it stays)
|
|
1999
|
+
let later = null
|
|
2000
|
+
events.addEventListener('state', (e) => {
|
|
2001
|
+
const s = JSON.parse(e.data)
|
|
2002
|
+
if (!document.querySelector('dialog[open]')) return render(s)
|
|
2003
|
+
later = s
|
|
2004
|
+
})
|
|
2005
|
+
for (const d of document.querySelectorAll('dialog')) d.addEventListener('close', () => later && !document.querySelector('dialog[open]') && (render(later), (later = null)))
|
|
2024
2006
|
events.addEventListener('request', (e) => {
|
|
2025
2007
|
const r = JSON.parse(e.data)
|
|
2026
2008
|
if (page() === 'logging') drawDisk() // the logs grew
|
|
@@ -2049,7 +2031,6 @@ if (token) {
|
|
|
2049
2031
|
const t = S.order[placeOf(...d.key.split(/\/(.*)/s))]
|
|
2050
2032
|
if (!t) continue
|
|
2051
2033
|
Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
|
|
2052
|
-
document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
|
|
2053
2034
|
}
|
|
2054
2035
|
news.clear()
|
|
2055
2036
|
drawMine(S)
|
package/src/settings.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
10
|
import os from 'node:os'
|
|
11
|
-
import { HOME, saveConfig, readJson, writeJson, byName } from './config.js'
|
|
11
|
+
import { HOME, saveConfig, readJson, writeJson, byName, takeIn, whatOf } from './config.js'
|
|
12
12
|
import { DEFAULT_SERVERS } from './mcp.js'
|
|
13
13
|
import { sections, BUILTIN_PROMPTS } from './prompts.js'
|
|
14
14
|
import { Client } from './client.js'
|
|
@@ -102,6 +102,10 @@ const skillsDir = () => path.join(HOME, 'skills')
|
|
|
102
102
|
// what the provider said (names, sizes, prices; not what's known from models.dev or tests, which
|
|
103
103
|
// changes). Asked for again when its list is switched on or off, or the provider is changed.
|
|
104
104
|
const LISTS = path.join(HOME, 'model-lists.json')
|
|
105
|
+
// The page's own choices (sorts, folded providers, the provider ranking): kept here, not in the
|
|
106
|
+
// browser, whose storage is per address (a new port each time)
|
|
107
|
+
const PREFS = path.join(HOME, 'settings-page.json')
|
|
108
|
+
const PREF_KEYS = ['catSort', 'folded', 'sort', 'rank']
|
|
105
109
|
function forgetList(...names) {
|
|
106
110
|
const all = readJson(LISTS, {})
|
|
107
111
|
if (!names.some((n) => n in all)) return
|
|
@@ -342,11 +346,17 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
342
346
|
mcp: mcpServers(),
|
|
343
347
|
prompts: prompts(),
|
|
344
348
|
home: tilde(HOME),
|
|
349
|
+
prefs: readJson(PREFS, {}),
|
|
345
350
|
})
|
|
346
351
|
|
|
347
352
|
const routes = {
|
|
348
353
|
'GET /api/state': () => state(),
|
|
349
354
|
'GET /api/log-size': () => logSize(),
|
|
355
|
+
'POST /api/prefs': ({ key, value }) => {
|
|
356
|
+
if (!PREF_KEYS.includes(key)) throw new Error(`no such choice: ${key}`)
|
|
357
|
+
writeJson(PREFS, { ...readJson(PREFS, {}), [key]: value })
|
|
358
|
+
return {}
|
|
359
|
+
},
|
|
350
360
|
'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
|
|
351
361
|
'POST /api/provider': async ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
352
362
|
name = safeName(name)
|
|
@@ -680,6 +690,29 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
680
690
|
},
|
|
681
691
|
}
|
|
682
692
|
|
|
693
|
+
// Settings changed by anyone else (another HeartCode or page on this folder, a hand): taken in, and
|
|
694
|
+
// every open page shown them at once (an event: the whole state, when it's different)
|
|
695
|
+
const SETTINGS_FILES = ['config.json', 'settings-page.json', 'mcp.json', 'hooks.json', 'keybindings.json', 'theme.json', 'format.json', 'HEARTCODE.md', 'prompts', 'skills']
|
|
696
|
+
let shown = ''
|
|
697
|
+
const pushState = () => {
|
|
698
|
+
for (const what of new Set(takeIn(cfg).map(whatOf))) changed(what)
|
|
699
|
+
const now = state()
|
|
700
|
+
const text = JSON.stringify(now)
|
|
701
|
+
if (text !== shown && listeners.size) emit('state', now)
|
|
702
|
+
shown = text
|
|
703
|
+
}
|
|
704
|
+
let pending = null
|
|
705
|
+
try {
|
|
706
|
+
const w = fs.watch(HOME, { recursive: true }, (_, f) => {
|
|
707
|
+
if (!SETTINGS_FILES.includes(String(f ?? '').split(path.sep)[0])) return
|
|
708
|
+
clearTimeout(pending)
|
|
709
|
+
pending = setTimeout(pushState, 150) // a file written in parts, several at once: one look
|
|
710
|
+
})
|
|
711
|
+
w.on('error', () => {})
|
|
712
|
+
w.unref()
|
|
713
|
+
} catch (e) {
|
|
714
|
+
log('web', `can't watch ${HOME}: ${e.message}`)
|
|
715
|
+
}
|
|
683
716
|
const server = http.createServer(async (req, res) => {
|
|
684
717
|
const send = (code, body, type = 'application/json') => {
|
|
685
718
|
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY', 'referrer-policy': 'no-referrer' })
|
|
@@ -705,6 +738,8 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
705
738
|
}
|
|
706
739
|
const route = routes[`${req.method} ${url.pathname}`]
|
|
707
740
|
if (!route) return send(404, { error: 'not found' })
|
|
741
|
+
// what another HeartCode (or a hand) changed meanwhile, first: shown, and changed from, not undone
|
|
742
|
+
for (const what of new Set(takeIn(cfg).map(whatOf))) changed(what)
|
|
708
743
|
let raw = ''
|
|
709
744
|
for await (const ch of req) {
|
|
710
745
|
raw += ch
|