free-coding-models 0.5.94 → 0.5.96
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/README.md +12 -8
- package/bin/free-coding-models.js +2 -0
- package/changelog/v0.5.95.md +20 -0
- package/changelog/v0.5.96.md +22 -0
- package/package.json +1 -1
- package/sources.js +84 -27
- package/src/core/config.js +16 -0
- package/src/core/i18n/index.js +49 -0
- package/src/core/i18n/locales/en.js +991 -0
- package/src/core/i18n/locales/zh-CN.js +991 -0
- package/src/core/opencode.js +15 -8
- package/src/core/ping.js +31 -1
- package/src/core/playground.js +20 -19
- package/src/core/provider-key-tester.js +1 -0
- package/src/core/provider-metadata.js +9 -0
- package/src/core/quota-capabilities.js +2 -0
- package/src/core/router-daemon.js +37 -6
- package/src/core/router-dashboard.js +15 -14
- package/src/core/router-v2/tui-dashboard.js +27 -26
- package/src/tui/app.js +2 -0
- package/src/tui/cli-help.js +45 -21
- package/src/tui/command-palette.js +19 -1
- package/src/tui/key-handler.js +21 -1
- package/src/tui/overlays.js +113 -107
- package/src/tui/render-helpers.js +4 -3
- package/src/tui/render-table.js +137 -132
- package/src/tui/theme.js +2 -0
- package/web/dist/assets/index-Cq9X7sN8.js +45 -0
- package/web/dist/assets/index-DZFdxy2N.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +58 -6
- package/web/dist/assets/index-CCaxIOti.css +0 -1
- package/web/dist/assets/index-ClaihG9x.js +0 -45
package/src/core/opencode.js
CHANGED
|
@@ -165,6 +165,11 @@ async function createZaiProxy(apiKey) {
|
|
|
165
165
|
// ─── Shared OpenCode spawn helper ─────────────────────────────────────────────
|
|
166
166
|
|
|
167
167
|
// 📖 spawnOpenCode: Resolve API keys + spawn opencode CLI with correct env.
|
|
168
|
+
// 📖 Never pass --model on the command line: opencode v2 rejects flags its
|
|
169
|
+
// 📖 entry points don't know ("Unrecognized flag: --model", issue #194) and
|
|
170
|
+
// 📖 `opencode web` never accepted it. Every caller writes `config.model`
|
|
171
|
+
// 📖 into opencode.json first, so a bare launch picks the right model on
|
|
172
|
+
// 📖 both v1 and v2.
|
|
168
173
|
async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = null) {
|
|
169
174
|
const envVarName = ENV_VAR_NAMES[providerKey]
|
|
170
175
|
const resolvedKey = getApiKey(fcmConfig, providerKey)
|
|
@@ -295,7 +300,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
295
300
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
296
301
|
console.log()
|
|
297
302
|
|
|
298
|
-
await spawnOpenCode([
|
|
303
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
299
304
|
return
|
|
300
305
|
}
|
|
301
306
|
|
|
@@ -359,7 +364,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
359
364
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
360
365
|
console.log()
|
|
361
366
|
|
|
362
|
-
await spawnOpenCode([
|
|
367
|
+
await spawnOpenCode([], providerKey, fcmConfig, zaiProxyServer)
|
|
363
368
|
return
|
|
364
369
|
}
|
|
365
370
|
|
|
@@ -394,7 +399,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
394
399
|
}
|
|
395
400
|
console.log()
|
|
396
401
|
|
|
397
|
-
await spawnOpenCode([
|
|
402
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
398
403
|
return
|
|
399
404
|
}
|
|
400
405
|
|
|
@@ -584,7 +589,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
584
589
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
585
590
|
console.log()
|
|
586
591
|
|
|
587
|
-
await spawnOpenCode([
|
|
592
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
588
593
|
}
|
|
589
594
|
|
|
590
595
|
// ─── Start OpenCode Web ───────────────────────────────────────────────────────
|
|
@@ -640,7 +645,9 @@ export async function startOpenCodeWeb(model, fcmConfig) {
|
|
|
640
645
|
console.log(chalk.dim(' Starting OpenCode Web...'))
|
|
641
646
|
console.log()
|
|
642
647
|
|
|
643
|
-
|
|
648
|
+
// 📖 `opencode web` accepts no --model flag; the default written in
|
|
649
|
+
// 📖 opencode.json above is what the web UI starts with.
|
|
650
|
+
await spawnOpenCode(['web'], providerKey, fcmConfig)
|
|
644
651
|
}
|
|
645
652
|
|
|
646
653
|
// ─── Start OpenCode Desktop ───────────────────────────────────────────────────
|
|
@@ -672,9 +679,9 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
|
|
|
672
679
|
execFile(cmd, args, (err) => resolve(!err))
|
|
673
680
|
})
|
|
674
681
|
const launched =
|
|
675
|
-
(await tryExecFile('opencode-desktop', [
|
|
676
|
-
(await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode'
|
|
677
|
-
(await tryExecFile('snap', ['run', 'opencode'
|
|
682
|
+
(await tryExecFile('opencode-desktop', [])) ||
|
|
683
|
+
(await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode'])) ||
|
|
684
|
+
(await tryExecFile('snap', ['run', 'opencode'])) ||
|
|
678
685
|
(await tryExecFile('xdg-open', ['/usr/share/applications/opencode.desktop']))
|
|
679
686
|
if (!launched) printDesktopLaunchError()
|
|
680
687
|
return
|
package/src/core/ping.js
CHANGED
|
@@ -200,6 +200,30 @@ async function isDisabledThinkingRejected(resp, req) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// 📖 Pollinations paid-wall sniffing (issue #190): models that left the free
|
|
204
|
+
// 📖 tier answer HTTP 200 with the error embedded in the completion content
|
|
205
|
+
// ("The account behind this API key doesn't have enough credits. This model
|
|
206
|
+
// needs paid Pollen..."). Status codes alone mark them healthy, so sniff the
|
|
207
|
+
// body and surface 402 (Payment Required) instead. Scoped to pollinations to
|
|
208
|
+
// avoid false positives on other providers.
|
|
209
|
+
const POLLINATIONS_PAID_WALL_MARKERS = [
|
|
210
|
+
// 📖 Apostrophe-free fragments: the API may return straight ' or typographic ’
|
|
211
|
+
'have enough credits',
|
|
212
|
+
'needs paid pollen',
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
async function isPollinationsPaidWall(resp) {
|
|
216
|
+
try {
|
|
217
|
+
const body = await resp.clone().json()
|
|
218
|
+
const content = body?.choices?.[0]?.message?.content
|
|
219
|
+
if (typeof content !== 'string') return false
|
|
220
|
+
const lower = content.toLowerCase()
|
|
221
|
+
return POLLINATIONS_PAID_WALL_MARKERS.some((marker) => lower.includes(marker))
|
|
222
|
+
} catch {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
203
227
|
// 📖 ping: Send a single chat completion request to measure model availability and latency.
|
|
204
228
|
// 📖 providerKey and url determine provider-specific request format.
|
|
205
229
|
// 📖 apiKey can be null — in that case no Authorization header is sent.
|
|
@@ -234,7 +258,13 @@ export async function ping(apiKey, modelId, providerKey, url) {
|
|
|
234
258
|
if (retryMs > 0) pauseProviderQuota(providerKey, retryMs)
|
|
235
259
|
}
|
|
236
260
|
// 📖 Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
|
|
237
|
-
|
|
261
|
+
let code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
|
|
262
|
+
// 📖 Pollinations paid-wall (issue #190): a 200 whose completion content is
|
|
263
|
+
// 📖 the credits error means the model is NOT usable on a free key. Report
|
|
264
|
+
// 📖 402 so rows show a failure instead of a fake-healthy green.
|
|
265
|
+
if (code === '200' && providerKey === 'pollinations' && (await isPollinationsPaidWall(resp))) {
|
|
266
|
+
code = '402'
|
|
267
|
+
}
|
|
238
268
|
// 📖 Passive quota tracker (t2): parse the response headers via the shared module.
|
|
239
269
|
// 📖 1) Write to the passive snapshot map so /stats + TUI footer see live quota.
|
|
240
270
|
// 📖 2) Return the percent number for the per-call consumer below.
|
package/src/core/playground.js
CHANGED
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
* @see ../tui/overlays.js — overlay factory that mounts this renderer
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
24
|
+
import { displayWidth, sliceOverlayLines, tintOverlayLines, truncateAnsiWidth } from '../tui/render-helpers.js'
|
|
25
25
|
import { ROUTER_PORT_PATH, getRouterPortPath } from './router-daemon.js'
|
|
26
26
|
import { existsSync, readFileSync } from 'node:fs'
|
|
27
27
|
import { themeColors } from '../tui/theme.js'
|
|
28
|
+
import { t } from './i18n/index.js'
|
|
28
29
|
|
|
29
30
|
// 📖 Width budget for the wrapped input + transcript columns inside the
|
|
30
31
|
// 📖 overlay. Slightly tighter than the full terminal so borders have room
|
|
@@ -162,7 +163,7 @@ export async function playgroundSubmit(state, deps = {}) {
|
|
|
162
163
|
|
|
163
164
|
const port = await readDaemonPort()
|
|
164
165
|
if (!port) {
|
|
165
|
-
PLAYGROUND_OVERLAY_STATE.lastError = '
|
|
166
|
+
PLAYGROUND_OVERLAY_STATE.lastError = t('playground.routerStartError')
|
|
166
167
|
return
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -173,7 +174,7 @@ export async function playgroundSubmit(state, deps = {}) {
|
|
|
173
174
|
PLAYGROUND_OVERLAY_STATE.cursor = 0
|
|
174
175
|
PLAYGROUND_OVERLAY_STATE.busy = true
|
|
175
176
|
PLAYGROUND_OVERLAY_STATE.lastError = null
|
|
176
|
-
PLAYGROUND_OVERLAY_STATE.statusMessage = '
|
|
177
|
+
PLAYGROUND_OVERLAY_STATE.statusMessage = t('playground.sending')
|
|
177
178
|
|
|
178
179
|
const controller = new AbortController()
|
|
179
180
|
PLAYGROUND_OVERLAY_STATE.abortController = controller
|
|
@@ -403,22 +404,22 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
403
404
|
const innerWidth = Math.max(40, terminalCols - 8)
|
|
404
405
|
|
|
405
406
|
// 📖 Header
|
|
406
|
-
lines.push(themeColors.accentBold(
|
|
407
|
-
lines.push(themeColors.dim(
|
|
407
|
+
lines.push(themeColors.accentBold(` 💬 ${t('playground.title')} — ${t('playground.chatViaRouter')}`))
|
|
408
|
+
lines.push(themeColors.dim(` ${t('playground.tuiHotkeys')}`))
|
|
408
409
|
lines.push('')
|
|
409
410
|
|
|
410
411
|
// 📖 Persona pill (one liner preview of the pre-prompt)
|
|
411
412
|
const pre = PLAYGROUND_OVERLAY_STATE.prePrompt
|
|
412
413
|
if (pre && pre.enabled && pre.text) {
|
|
413
|
-
const preview = pre.text
|
|
414
|
-
lines.push(themeColors.dim(`
|
|
414
|
+
const preview = displayWidth(pre.text) > innerWidth - 16 ? truncateAnsiWidth(pre.text.replace(/\n+/g, ' '), innerWidth - 16, { ellipsis: '…' }) : pre.text.replace(/\n+/g, ' ')
|
|
415
|
+
lines.push(themeColors.dim(` ${t('playground.persona')}: ${preview}`))
|
|
415
416
|
} else {
|
|
416
|
-
lines.push(themeColors.dim('
|
|
417
|
+
lines.push(themeColors.dim(` ${t('playground.persona')}: ${t('common.none')}`))
|
|
417
418
|
}
|
|
418
419
|
|
|
419
420
|
// 📖 Model + mode row
|
|
420
|
-
const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? '
|
|
421
|
-
lines.push(themeColors.dim(`
|
|
421
|
+
const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? t('playground.streamOn') : t('playground.streamOff')
|
|
422
|
+
lines.push(themeColors.dim(` ${t('playground.model')}: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
|
|
422
423
|
if (PLAYGROUND_OVERLAY_STATE.lastError) {
|
|
423
424
|
lines.push(themeColors.errorBold(` ⚠ ${PLAYGROUND_OVERLAY_STATE.lastError}`))
|
|
424
425
|
}
|
|
@@ -428,7 +429,7 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
428
429
|
const transcriptLines = []
|
|
429
430
|
for (const msg of PLAYGROUND_OVERLAY_STATE.messages) {
|
|
430
431
|
const role = msg.role === 'user'
|
|
431
|
-
? themeColors.accentBold(
|
|
432
|
+
? themeColors.accentBold(` ❯ ${t('playground.you')}`)
|
|
432
433
|
: msg.role === 'assistant'
|
|
433
434
|
? themeColors.dim(' ✦ fcm')
|
|
434
435
|
: themeColors.dim(` · ${msg.role}`)
|
|
@@ -439,18 +440,18 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
439
440
|
}
|
|
440
441
|
if (msg.role === 'assistant' && msg.meta) {
|
|
441
442
|
const metaChips = []
|
|
442
|
-
if (msg.meta.provider) metaChips.push(
|
|
443
|
+
if (msg.meta.provider) metaChips.push(`${t('playground.routed')} ${msg.meta.provider}/${msg.meta.model || '?'}`)
|
|
443
444
|
if (msg.meta.latencyMs != null) metaChips.push(`${msg.meta.latencyMs}ms`)
|
|
444
|
-
if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens}
|
|
445
|
-
if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts}
|
|
445
|
+
if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} ${t('playground.tokensShort')}`)
|
|
446
|
+
if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} ${t('playground.fallbackShort')}`)
|
|
446
447
|
if (metaChips.length) {
|
|
447
448
|
transcriptLines.push(themeColors.dim(` [ ${metaChips.join(' · ')} ]`))
|
|
448
449
|
}
|
|
449
450
|
if (msg.meta.aborted) {
|
|
450
|
-
transcriptLines.push(themeColors.dim(
|
|
451
|
+
transcriptLines.push(themeColors.dim(` [ ${t('playground.stopped')} ]`))
|
|
451
452
|
}
|
|
452
453
|
if (msg.meta.error) {
|
|
453
|
-
transcriptLines.push(themeColors.error(` [ error: ${msg.meta.error} ]`))
|
|
454
|
+
transcriptLines.push(themeColors.error(` [ ${t('common.error')}: ${msg.meta.error} ]`))
|
|
454
455
|
}
|
|
455
456
|
}
|
|
456
457
|
transcriptLines.push('')
|
|
@@ -465,16 +466,16 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
465
466
|
// 📖 Input box
|
|
466
467
|
lines.push(themeColors.dim(' ─'.repeat(Math.max(8, Math.floor(innerWidth / 4)))))
|
|
467
468
|
if (PLAYGROUND_OVERLAY_STATE.busy) {
|
|
468
|
-
lines.push(themeColors.accent(
|
|
469
|
+
lines.push(themeColors.accent(` ⏳ ${t('playground.waitingResponse')}`))
|
|
469
470
|
} else {
|
|
470
|
-
const draft = PLAYGROUND_OVERLAY_STATE.draft || '
|
|
471
|
+
const draft = PLAYGROUND_OVERLAY_STATE.draft || t('playground.draftPlaceholder')
|
|
471
472
|
const draftDisplay = PLAYGROUND_OVERLAY_STATE.draft ? draft : themeColors.dim(draft)
|
|
472
473
|
const wrappedDraft = wrapMessage(draftDisplay, innerWidth - 4)
|
|
473
474
|
for (const line of wrappedDraft) {
|
|
474
475
|
lines.push(` ❯ ${line}`)
|
|
475
476
|
}
|
|
476
477
|
if (!PLAYGROUND_OVERLAY_STATE.draft) {
|
|
477
|
-
lines.push(themeColors.dim(
|
|
478
|
+
lines.push(themeColors.dim(` (${t('playground.enterToSend')} · Shift+Tab ${t('playground.pinModel')})`))
|
|
478
479
|
}
|
|
479
480
|
}
|
|
480
481
|
|
|
@@ -70,6 +70,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
|
|
|
70
70
|
requesty: null,
|
|
71
71
|
orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
|
|
72
72
|
'vercel-gateway': { url: 'https://ai-gateway.vercel.sh/v1/models', method: 'GET' },
|
|
73
|
+
onomeo: { url: 'https://onomeo.com/v1/models', method: 'GET' },
|
|
73
74
|
together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
|
|
74
75
|
perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
|
|
75
76
|
chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
|
|
@@ -67,6 +67,7 @@ export const ENV_VAR_NAMES = {
|
|
|
67
67
|
requesty: 'REQUESTY_API_KEY',
|
|
68
68
|
orcarouter: 'ORCAROUTER_API_KEY',
|
|
69
69
|
'vercel-gateway': 'VERCEL_AI_GATEWAY_API_KEY',
|
|
70
|
+
onomeo: 'ONOMEO_API_KEY',
|
|
70
71
|
// 📖 Providers whose OpenCode/Kilo config writers reference {env:VAR} - names
|
|
71
72
|
// 📖 must match those tables exactly (see opencode.js / kilo.js).
|
|
72
73
|
huggingface: 'HUGGINGFACE_API_KEY',
|
|
@@ -358,6 +359,14 @@ export const PROVIDER_METADATA = {
|
|
|
358
359
|
rateLimits: 'Free tier · $5 credits/30 days + $0 models',
|
|
359
360
|
detailedLimits: 'Official Vercel gateway at list prices, zero markup.\nEvery account gets $5 of credits every 30 days, no card required.\nThe monthly credit covers a subset of the catalog; the explicit $0 models stay free.\nCaution: purchasing credits once permanently moves the account to the paid tier and the monthly free credit stops.',
|
|
360
361
|
},
|
|
362
|
+
onomeo: {
|
|
363
|
+
label: 'onomeo',
|
|
364
|
+
color: chalk.rgb(240, 120, 60),
|
|
365
|
+
signupUrl: 'https://onomeo.com/dashboard',
|
|
366
|
+
signupHint: 'Sign in (email, Google or GitHub, no card) → check in daily → Dashboard → API key',
|
|
367
|
+
rateLimits: 'Daily check-in credits (50,000-200,000/day) · 12 req/min per key · 60 req/5h per account',
|
|
368
|
+
detailedLimits: 'Public beta: not every feature is guaranteed to work; feedback is welcome at https://onomeo.com/feedback.\nCredits come from a daily check-in: 50,000 on day 1, rising to 200,000/day from day 7 of a streak. No card needed.\nEvery call spends credits and the cost per reply varies by model (see https://onomeo.com/models), so this is a small allowance. Each account that has not paid can spend up to 50,000 credits/day on premium models. Optional: $5 one-time buys 1,000,000 credits.\nLimits: 12 req/min per key, 60 req per 5 hours per account, 120 per 5 hours per IP, 450 per 5 hours site-wide for accounts that have not bought credits. Health probes count against them.\nSome upstreams may train on prompts; each model page on onomeo.com says which.',
|
|
369
|
+
},
|
|
361
370
|
'ollama-cloud': {
|
|
362
371
|
label: 'Ollama Cloud',
|
|
363
372
|
color: chalk.rgb(230, 230, 230),
|
|
@@ -67,6 +67,8 @@ export const PROVIDER_CAPABILITIES = {
|
|
|
67
67
|
orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
|
|
68
68
|
// 📖 Vercel AI Gateway exposes GET /v1/credits but no fetcher is wired yet; treat as monthly reset.
|
|
69
69
|
'vercel-gateway': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'monthly' },
|
|
70
|
+
// 📖 onomeo: daily check-in credits; balance is at GET /api/me but no fetcher is wired yet.
|
|
71
|
+
onomeo: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** Fallback for unrecognized providers */
|
|
@@ -55,6 +55,8 @@ import { TIER_ORDER } from './utils.js'
|
|
|
55
55
|
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
56
56
|
import { normalizeRequestBody } from './schema-normalizer.js'
|
|
57
57
|
import { pickNextCandidate } from './model-family.js'
|
|
58
|
+
// 📖 Shared install-target list (same order the TUI Z-cycle and web/server.js use)
|
|
59
|
+
import { getInstallTargetModes } from './endpoint-installer.js'
|
|
58
60
|
import {
|
|
59
61
|
loadCache as loadProbeCache,
|
|
60
62
|
flushCache as flushProbeCache,
|
|
@@ -3686,12 +3688,41 @@ export class RouterRuntime {
|
|
|
3686
3688
|
sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
|
|
3687
3689
|
return
|
|
3688
3690
|
}
|
|
3689
|
-
// 📖
|
|
3690
|
-
//
|
|
3691
|
-
//
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3691
|
+
// 📖 Web dashboard hooks (useToolMode, useFavorites, useUpdateChecker).
|
|
3692
|
+
// These were 404 before - minimal shapes that match what the dashboard
|
|
3693
|
+
// hooks expect. See PR #108 for context.
|
|
3694
|
+
// 📖 /api/tool-mode must handle POST too: useUrlState re-persists
|
|
3695
|
+
// `?toolMode=` from the URL on load, and a POST falling through to the
|
|
3696
|
+
// OpenAI-style 404 catch-all showed "[object Object]" toasts (issue #189).
|
|
3697
|
+
if (url.pathname === '/api/tool-mode') {
|
|
3698
|
+
const webToolModes = getInstallTargetModes()
|
|
3699
|
+
const normalizeToolMode = (m) => (typeof m === 'string' && webToolModes.includes(m) ? m : 'opencode')
|
|
3700
|
+
if (req.method === 'GET') {
|
|
3701
|
+
sendJson(res, 200, { mode: normalizeToolMode(this.config?.settings?.preferredToolMode), tools: webToolModes }, { 'x-request-id': requestId })
|
|
3702
|
+
return
|
|
3703
|
+
}
|
|
3704
|
+
if (req.method === 'POST') {
|
|
3705
|
+
if (!isSameOriginOrLocal(req)) {
|
|
3706
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
3707
|
+
return
|
|
3708
|
+
}
|
|
3709
|
+
const body = await readJsonBody(req)
|
|
3710
|
+
if (!body || typeof body.mode !== 'string' || !webToolModes.includes(body.mode)) {
|
|
3711
|
+
sendError(res, 422, 'Invalid tool mode', 'invalid_request_error', 'invalid_tool_mode', requestId)
|
|
3712
|
+
return
|
|
3713
|
+
}
|
|
3714
|
+
const mode = normalizeToolMode(body.mode)
|
|
3715
|
+
if (!this.config || typeof this.config !== 'object') this.config = {}
|
|
3716
|
+
if (!this.config.settings || typeof this.config.settings !== 'object') this.config.settings = {}
|
|
3717
|
+
this.config.settings.preferredToolMode = mode
|
|
3718
|
+
const saveResult = this.saveRouterConfig()
|
|
3719
|
+
if (!saveResult?.success) {
|
|
3720
|
+
sendError(res, 500, saveResult?.error || 'Failed to save tool mode', 'api_error', 'save_failed', requestId)
|
|
3721
|
+
return
|
|
3722
|
+
}
|
|
3723
|
+
sendJson(res, 200, { mode }, { 'x-request-id': requestId })
|
|
3724
|
+
return
|
|
3725
|
+
}
|
|
3695
3726
|
}
|
|
3696
3727
|
if (req.method === 'GET' && (url.pathname === '/api/favorites')) {
|
|
3697
3728
|
const cfg = this.config || {}
|
|
@@ -44,6 +44,7 @@ import chalk from 'chalk'
|
|
|
44
44
|
import { existsSync, readFileSync } from 'node:fs'
|
|
45
45
|
import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
46
46
|
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
|
|
47
|
+
import { t } from './i18n/index.js'
|
|
47
48
|
import { themeColors, getTierRgb } from '../tui/theme.js'
|
|
48
49
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
49
50
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
@@ -871,12 +872,12 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
871
872
|
const { defaultPort: currentDefaultPort } = getRouterPortRange()
|
|
872
873
|
const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
|
|
873
874
|
const baseUrl = `http://localhost:${port}/v1`
|
|
874
|
-
lines.push(` ${themeColors.textBold('
|
|
875
|
+
lines.push(` ${themeColors.textBold(t('router.quickSetup'))} ${themeColors.dim(`— ${t('router.pasteIntoToolConfig')}`)}`)
|
|
875
876
|
lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
|
|
876
|
-
lines.push(` ${themeColors.dim('
|
|
877
|
+
lines.push(` ${themeColors.dim(t('router.model'))} ${themeColors.infoBold('fcm')}`)
|
|
877
878
|
lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
|
|
878
879
|
if (isRunning) {
|
|
879
|
-
lines.push(` ${themeColors.dim('
|
|
880
|
+
lines.push(` ${themeColors.dim(t('router.uptime'))} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim(`${t('router.requests')}:`)} ${themeColors.info(String(snapshot.requestsRouted))}`)
|
|
880
881
|
} else {
|
|
881
882
|
lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
|
|
882
883
|
}
|
|
@@ -886,7 +887,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
886
887
|
// ── Favorites / Router Fallback Models ──────────────────────────────────────
|
|
887
888
|
// 📖 Instead of the old "sets" system, show the user's favorites from the main
|
|
888
889
|
// 📖 table as the router fallback chain. #1 = tried first, #2 = next, etc.
|
|
889
|
-
lines.push(` ${themeColors.textBold('
|
|
890
|
+
lines.push(` ${themeColors.textBold(t('router.routerModels'))} ${themeColors.dim(`— ${t('router.favoritesFallbackOrder')}`)}`)
|
|
890
891
|
lines.push(` ${themeColors.dim('Star models with F in the main table. Shift+↑↓ to reorder here.')}`)
|
|
891
892
|
lines.push('')
|
|
892
893
|
|
|
@@ -895,7 +896,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
895
896
|
const cursor = state.routerDashboardCursorIndex ?? 0
|
|
896
897
|
|
|
897
898
|
if (favorites.length === 0) {
|
|
898
|
-
lines.push(` ${themeColors.warning('
|
|
899
|
+
lines.push(` ${themeColors.warning(t('router.noFavoritesTui'))}`)
|
|
899
900
|
lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
|
|
900
901
|
} else {
|
|
901
902
|
// 📖 Priority keycap glyphs for the fallback order
|
|
@@ -1010,7 +1011,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1010
1011
|
lines.push('')
|
|
1011
1012
|
const btnCursor = favorites.length
|
|
1012
1013
|
const isBtnCursor = cursor === btnCursor
|
|
1013
|
-
const btnText = isStopped ?
|
|
1014
|
+
const btnText = isStopped ? `▶ ${t('router.start')}` : `⏹ ${t('router.stop')}`
|
|
1014
1015
|
const btnRowText = ` [ ${btnText} ]`
|
|
1015
1016
|
|
|
1016
1017
|
if (isBtnCursor) {
|
|
@@ -1022,7 +1023,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1022
1023
|
// 📖 Install Endpoint Button
|
|
1023
1024
|
const installBtnCursor = favorites.length + 1
|
|
1024
1025
|
const isInstallBtnCursor = cursor === installBtnCursor
|
|
1025
|
-
const installBtnText =
|
|
1026
|
+
const installBtnText = `🔌 ${t('router.installEndpointCli')}`
|
|
1026
1027
|
const installBtnRowText = ` [ ${installBtnText} ]`
|
|
1027
1028
|
|
|
1028
1029
|
if (isInstallBtnCursor) {
|
|
@@ -1036,11 +1037,11 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1036
1037
|
lines.push('')
|
|
1037
1038
|
|
|
1038
1039
|
// ── Token Summary (compact, visual) ─────────────────────────────────────────
|
|
1039
|
-
lines.push(` ${themeColors.textBold('📊
|
|
1040
|
+
lines.push(` ${themeColors.textBold('📊 Token')} ${themeColors.dim(`${t('analytics.today')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.today.requests }))} ${themeColors.dim(`${t('router.lifetime')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.all_time.requests }))}`)
|
|
1040
1041
|
|
|
1041
1042
|
// ── Active Requests ────────────────────────────────────────────────────────
|
|
1042
1043
|
lines.push('')
|
|
1043
|
-
lines.push(` ${themeColors.warningBold(
|
|
1044
|
+
lines.push(` ${themeColors.warningBold(`⚡ ${t('router.activeRequests')}`)} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
|
|
1044
1045
|
if (snapshot.activeRequests?.length > 0) {
|
|
1045
1046
|
for (const req of snapshot.activeRequests) {
|
|
1046
1047
|
const model = compactText(req.model, 24)
|
|
@@ -1052,15 +1053,15 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1052
1053
|
lines.push(` ${themeColors.dim('•')} ${shortId}${model} → ${themeColors.info(current)} ${status}${tokens} ${themeColors.dim(duration)}`)
|
|
1053
1054
|
}
|
|
1054
1055
|
} else {
|
|
1055
|
-
lines.push(` ${themeColors.dim('
|
|
1056
|
+
lines.push(` ${themeColors.dim(t('router.noActiveRequests'))}`)
|
|
1056
1057
|
}
|
|
1057
1058
|
|
|
1058
1059
|
// ── Live Request Log (compact) ──────────────────────────────────────────────
|
|
1059
1060
|
const requestRows = requestLogRows(state, snapshot)
|
|
1060
1061
|
lines.push('')
|
|
1061
|
-
lines.push(` ${themeColors.textBold('
|
|
1062
|
+
lines.push(` ${themeColors.textBold(t('router.recentRequests'))}`)
|
|
1062
1063
|
if (requestRows.length > 0) {
|
|
1063
|
-
const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay('
|
|
1064
|
+
const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay(t('router.time'), 10)} ${padEndDisplay(t('router.model'), 34)} ${padEndDisplay(t('router.state'), 8)} ${padEndDisplay(t('router.lastLatency'), 9)} ${t('router.detail')}`
|
|
1064
1065
|
lines.push(themeColors.dim(header))
|
|
1065
1066
|
for (const row of requestRows.slice(0, 6)) {
|
|
1066
1067
|
const atMs = Date.parse(row.at)
|
|
@@ -1086,7 +1087,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1086
1087
|
)
|
|
1087
1088
|
}
|
|
1088
1089
|
} else {
|
|
1089
|
-
lines.push(` ${themeColors.dim('
|
|
1090
|
+
lines.push(` ${themeColors.dim(t('router.noRequests'))}`)
|
|
1090
1091
|
}
|
|
1091
1092
|
|
|
1092
1093
|
// ── Health check speed ──────────────────────────────────────────────────────
|
|
@@ -1111,7 +1112,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1111
1112
|
// ── Footer ──────────────────────────────────────────────────────────────────
|
|
1112
1113
|
lines.push('')
|
|
1113
1114
|
lines.push(` ${separator}`)
|
|
1114
|
-
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('
|
|
1115
|
+
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim(t('settings.navigate'))} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim(t('router.reorder'))} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? t('router.start') : t('router.stop'))} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`${t('router.healthCheck')}: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim(t('router.clearLog'))} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim(t('router.syncBest'))} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim(t('common.back'))}`)
|
|
1115
1116
|
|
|
1116
1117
|
const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
|
|
1117
1118
|
state.routerDashboardScrollOffset = offset
|
|
@@ -49,6 +49,7 @@ import { discoverRouterV2Port, testModelViaRouter, testSetViaRouter } from './be
|
|
|
49
49
|
// 📖 After the merge, the v2 engine lives in the MAIN router daemon: all
|
|
50
50
|
// discovery and lifecycle calls target the historical daemon paths/ports.
|
|
51
51
|
import { getRouterPidPath, getRouterPortPath, getRouterPortRange } from '../router-daemon.js'
|
|
52
|
+
import { t } from '../i18n/index.js'
|
|
52
53
|
|
|
53
54
|
export const ROUTER_V2_DASHBOARD_POLL_INTERVAL_MS = 2000
|
|
54
55
|
export const ROUTER_V2_DASHBOARD_FETCH_TIMEOUT_MS = 1500
|
|
@@ -429,15 +430,15 @@ function formatDurationV2(seconds) {
|
|
|
429
430
|
|
|
430
431
|
function stateBadgeV2(modelState) {
|
|
431
432
|
switch (modelState) {
|
|
432
|
-
case 'CLOSED': return { text:
|
|
433
|
-
case 'DEGRADED': return { text:
|
|
434
|
-
case 'HALF_OPEN': return { text:
|
|
435
|
-
case 'OPEN': return { text:
|
|
436
|
-
case 'AUTH_ERROR': return { text:
|
|
437
|
-
case 'QUOTA_PAUSED': return { text:
|
|
438
|
-
case 'STALE': return { text:
|
|
439
|
-
case 'UNSUPPORTED': return { text:
|
|
440
|
-
default: return { text:
|
|
433
|
+
case 'CLOSED': return { text: `✅ ${t('dashboard.working').toUpperCase()}`, color: themeColors.success }
|
|
434
|
+
case 'DEGRADED': return { text: `🟠 ${t('router.circuit.degraded').toUpperCase()}`, color: themeColors.warningBold }
|
|
435
|
+
case 'HALF_OPEN': return { text: `🔁 ${t('router.circuit.probing').toUpperCase()}`, color: themeColors.warning }
|
|
436
|
+
case 'OPEN': return { text: `⛔ ${t('router.circuit.down').toUpperCase()}`, color: themeColors.error }
|
|
437
|
+
case 'AUTH_ERROR': return { text: `🔐 ${t('router.circuit.authError').toUpperCase()}`, color: themeColors.errorBold }
|
|
438
|
+
case 'QUOTA_PAUSED': return { text: `🔥 ${t('router.circuit.quotaPaused').toUpperCase()}`, color: themeColors.warningBold }
|
|
439
|
+
case 'STALE': return { text: `👻 ${t('router.circuit.deprecated').toUpperCase()}`, color: themeColors.dim }
|
|
440
|
+
case 'UNSUPPORTED': return { text: `🚫 ${t('router.circuit.unsupported').toUpperCase()}`, color: themeColors.dim }
|
|
441
|
+
default: return { text: `⏳ ${t('dashboard.pending').toUpperCase()}`, color: themeColors.dim }
|
|
441
442
|
}
|
|
442
443
|
}
|
|
443
444
|
|
|
@@ -489,17 +490,17 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
489
490
|
// ── Quick Setup ─────────────────────────────────────────────────────────────
|
|
490
491
|
const { defaultPort } = getRouterPortRange()
|
|
491
492
|
const port = state.routerV2Port || defaultPort
|
|
492
|
-
lines.push(` ${themeColors.textBold('
|
|
493
|
+
lines.push(` ${themeColors.textBold(t('router.quickSetup'))} ${themeColors.dim(`(${t('router.v2.beta')})`)} ${themeColors.dim(`— ${t('router.v2.pointToolAtV2')}`)}`)
|
|
493
494
|
lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(`http://localhost:${port}/v1`)} ${themeColors.dim('Anthropic:')} ${themeColors.infoBold(`http://localhost:${port}`)} ${themeColors.dim('(POST /v1/messages)')}`)
|
|
494
|
-
lines.push(` ${themeColors.dim('
|
|
495
|
+
lines.push(` ${themeColors.dim(t('router.model'))} ${themeColors.infoBold('fcm')} ${themeColors.dim(t('router.v2.orPinOne'))} ${themeColors.infoBold('fcm:@provider/model')}`)
|
|
495
496
|
lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
|
|
496
497
|
if (isRunning) {
|
|
497
|
-
lines.push(` ${themeColors.dim('
|
|
498
|
+
lines.push(` ${themeColors.dim(t('router.uptime'))} ${themeColors.success(formatDurationV2(toFiniteNumber(stats?.uptimeSeconds, 0)))} ${themeColors.dim(`${t('router.requests')}:`)} ${themeColors.info(String(toFiniteNumber(stats?.requestsRouted, 0)))} ${themeColors.dim(`${t('router.v2.failoverRate')}:`)} ${themeColors.info(`${Math.round(toFiniteNumber(stats?.history?.failover_rate, 0) * 100)}%`)}`)
|
|
498
499
|
}
|
|
499
500
|
lines.push(` ${separator}`)
|
|
500
501
|
|
|
501
502
|
// ── Fallback chain with live breaker states ─────────────────────────────────
|
|
502
|
-
lines.push(` ${themeColors.textBold('
|
|
503
|
+
lines.push(` ${themeColors.textBold(t('router.v2.fallbackChain'))} ${themeColors.dim(`— ${t('router.v2.exactOrder')}`)}`)
|
|
503
504
|
const routingOrder = Array.isArray(stats?.routingOrder) ? stats.routingOrder : []
|
|
504
505
|
const models = Array.isArray(stats?.models) ? stats.models : []
|
|
505
506
|
const healthByKey = new Map(models.map((m) => [m.key, m]))
|
|
@@ -507,11 +508,11 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
507
508
|
const cursor = state.routerV2CursorIndex ?? 0
|
|
508
509
|
|
|
509
510
|
if (!isRunning) {
|
|
510
|
-
lines.push(` ${themeColors.dim('
|
|
511
|
+
lines.push(` ${themeColors.dim(t('router.v2.startToSeeChain'))}`)
|
|
511
512
|
} else if (routingOrder.length === 0) {
|
|
512
|
-
lines.push(` ${themeColors.warning('
|
|
513
|
+
lines.push(` ${themeColors.warning(t('router.v2.emptyCandidates'))}`)
|
|
513
514
|
} else {
|
|
514
|
-
lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('
|
|
515
|
+
lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay(t('router.model'), 44))} ${themeColors.dim(padEndDisplay(t('router.state'), 15))} ${themeColors.dim(padEndDisplay(t('router.uptime'), 7))} ${themeColors.dim(padEndDisplay('V2 ' + t('router.v2.testViaRouter'), 12))} ${themeColors.dim(t('router.v2.lastError'))}`)
|
|
515
516
|
const maxRows = Math.max(1, routingOrder.length)
|
|
516
517
|
routingOrder.forEach((entry, i) => {
|
|
517
518
|
const health = healthByKey.get(entry.key)
|
|
@@ -535,21 +536,21 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
535
536
|
|
|
536
537
|
// ── Recent requests WITH fallback chains (the v1 gap) ───────────────────────
|
|
537
538
|
lines.push('')
|
|
538
|
-
lines.push(` ${themeColors.textBold('
|
|
539
|
+
lines.push(` ${themeColors.textBold(t('router.v2.requestChains'))} ${themeColors.dim(`— ${t('router.v2.attemptsHint')}`)}`)
|
|
539
540
|
const historyEntries = Array.isArray(state.routerV2History?.entries) ? state.routerV2History.entries : []
|
|
540
541
|
if (!isRunning) {
|
|
541
|
-
lines.push(` ${themeColors.dim('
|
|
542
|
+
lines.push(` ${themeColors.dim(t('router.v2.noHistory'))}`)
|
|
542
543
|
} else if (historyEntries.length === 0) {
|
|
543
|
-
lines.push(` ${themeColors.dim('
|
|
544
|
+
lines.push(` ${themeColors.dim(t('router.noRequests'))}`)
|
|
544
545
|
} else {
|
|
545
546
|
for (const entry of historyEntries.slice(0, 6)) {
|
|
546
547
|
const atMs = Date.parse(entry.at)
|
|
547
548
|
const time = Number.isFinite(atMs) ? new Date(atMs).toLocaleTimeString() : '-'
|
|
548
549
|
const outcome = entry.outcome === 'served'
|
|
549
|
-
? themeColors.success('served')
|
|
550
|
+
? themeColors.success(t('router.v2.outcome.served'))
|
|
550
551
|
: entry.outcome === 'client_aborted'
|
|
551
|
-
? themeColors.dim('aborted')
|
|
552
|
-
: themeColors.error(
|
|
552
|
+
? themeColors.dim(t('router.v2.outcome.aborted'))
|
|
553
|
+
: themeColors.error(t('router.v2.outcome.failed'))
|
|
553
554
|
const chain = attemptChainLabel(entry)
|
|
554
555
|
const skips = Array.isArray(entry.skipped) && entry.skipped.length > 0
|
|
555
556
|
? ` ${themeColors.dim(`[skips: ${entry.skipped.map((s) => s.reason).join(', ')}]`)}`
|
|
@@ -572,7 +573,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
572
573
|
`🔐 auth ${stateCounts.AUTH_ERROR ?? 0}`,
|
|
573
574
|
`🔥 quota ${stateCounts.QUOTA_PAUSED ?? 0}`,
|
|
574
575
|
]
|
|
575
|
-
lines.push(` ${themeColors.textBold('
|
|
576
|
+
lines.push(` ${themeColors.textBold(`${t('router.models')}:`)} ${chips.map((c) => themeColors.dim(c)).join(' ')}`)
|
|
576
577
|
const pauses = Array.isArray(stats?.quotaPauses) ? stats.quotaPauses : []
|
|
577
578
|
if (pauses.length > 0) {
|
|
578
579
|
for (const pause of pauses.slice(0, 3)) {
|
|
@@ -592,7 +593,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
592
593
|
const isStopped = !isRunning && !isLoading
|
|
593
594
|
const cursorBase = Math.max(1, routingOrder.length)
|
|
594
595
|
const startBtnCursor = cursorBase
|
|
595
|
-
const startBtnText = isStopped ? '
|
|
596
|
+
const startBtnText = isStopped ? `▶ ${t('router.start')} (v2)` : `⏹ ${t('router.stop')} (v2)`
|
|
596
597
|
const startBtnRow = ` [ ${startBtnText} ]`
|
|
597
598
|
lines.push(cursor === startBtnCursor
|
|
598
599
|
? themeColors.bgCursor(startBtnRow + ' '.repeat(Math.max(0, width - displayWidth(startBtnRow) - 3)))
|
|
@@ -616,7 +617,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
616
617
|
lines.push('')
|
|
617
618
|
lines.push(` ${separator}`)
|
|
618
619
|
const probeMode = stats?.probeMode || 'balanced'
|
|
619
|
-
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('
|
|
620
|
+
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim(t('settings.navigate'))} ${themeColors.dim('•')} ${themeColors.hotkey('T')} ${themeColors.dim(t('router.v2.testViaRouter'))} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? t('router.start') : t('router.stop'))} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`${t('router.v2.probes')}: ${probeMode}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim(t('router.clearHistory'))} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim(t('common.back'))}`)
|
|
620
621
|
lines.push(` ${themeColors.dim('BETA: the v2 engine now powers the main router daemon. Ctrl+T tests the selected table model, Ctrl+Shift+T tests all visible models through the router.')}`)
|
|
621
622
|
|
|
622
623
|
const { visible, offset } = sliceOverlayLines(lines, state.routerV2ScrollOffset || 0, state.terminalRows || 24)
|
|
@@ -628,5 +629,5 @@ export function renderRouterV2Dashboard(state, deps = {}) {
|
|
|
628
629
|
function compactTextV2(value, width) {
|
|
629
630
|
const text = String(value ?? '')
|
|
630
631
|
if (displayWidth(text) <= width) return themeColors.dim(text)
|
|
631
|
-
return themeColors.dim(
|
|
632
|
+
return themeColors.dim(truncateAnsiWidth(text, width, { ellipsis: '…' }))
|
|
632
633
|
}
|
package/src/tui/app.js
CHANGED
|
@@ -95,6 +95,7 @@ import { randomUUID } from 'crypto'
|
|
|
95
95
|
import { MODELS, sources } from '../../sources.js'
|
|
96
96
|
import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatResultsAsJSON } from '../core/utils.js'
|
|
97
97
|
import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
|
|
98
|
+
import { setLocale } from '../core/i18n/index.js'
|
|
98
99
|
import { buildMergedModels } from '../core/model-merger.js'
|
|
99
100
|
import { loadOpenCodeConfig, saveOpenCodeConfig } from '../core/opencode-config.js'
|
|
100
101
|
import { usageForRow as _usageForRow } from '../core/usage-reader.js'
|
|
@@ -256,6 +257,7 @@ const LOCAL_VERSION = pkg.version
|
|
|
256
257
|
// 📖 OpenCode helpers are imported from ../src/opencode.js
|
|
257
258
|
|
|
258
259
|
export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
260
|
+
setLocale(config.settings?.language || 'en')
|
|
259
261
|
// 📖 t4: prime the sync extended-benchmark stats so the footer chip is
|
|
260
262
|
// 📖 accurate on the first render (no async, no network — just a sync read).
|
|
261
263
|
ensureBenchStatsLoaded()
|