@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1
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 +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +114 -0
- package/install +75 -0
- package/package.json +94 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1047 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +654 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1304 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,1675 @@
|
|
|
1
|
+
import { Command, Panel, Plugin, Slot, usePlugin, type PanelTab, type PanelProps } from "@opencode/plugin/gui"
|
|
2
|
+
import { createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
3
|
+
import { SmartRouterRpc } from "../rpc.js"
|
|
4
|
+
import {
|
|
5
|
+
formatPercent,
|
|
6
|
+
formatUsd,
|
|
7
|
+
formatWindowUsed,
|
|
8
|
+
providerGroups,
|
|
9
|
+
statusLine,
|
|
10
|
+
summariseDecision,
|
|
11
|
+
toProviderRow,
|
|
12
|
+
type DecisionView,
|
|
13
|
+
type ProviderGroupView,
|
|
14
|
+
type ProviderRow,
|
|
15
|
+
} from "./viewmodel.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The Smart Router panel.
|
|
19
|
+
*
|
|
20
|
+
* Design intent: this should read as one product, not a debug dump with a title.
|
|
21
|
+
* Three rules hold it together.
|
|
22
|
+
*
|
|
23
|
+
* 1. **One scale.** Every size comes from `type`, every gap from `space`. The
|
|
24
|
+
* previous version used seven font sizes chosen ad hoc, which is what makes
|
|
25
|
+
* an interface look assembled rather than designed.
|
|
26
|
+
* 2. **Hierarchy by weight and space, not by lines.** Four full-width dividers
|
|
27
|
+
* with equal weight said "here are five unrelated things". Sections now
|
|
28
|
+
* carry their own label and use whitespace to separate.
|
|
29
|
+
* 3. **The brand earns its place.** The mark and its teal-to-cyan gradient are
|
|
30
|
+
* the same assets shipped in `assets/`. An accent used once, on the thing
|
|
31
|
+
* that matters, reads as deliberate; used everywhere it reads as decoration.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Type scale.
|
|
36
|
+
*
|
|
37
|
+
* Sized against published guidance rather than taste. Body text is 14px and
|
|
38
|
+
* secondary text 13px, which is the floor for comfortable reading on a desktop;
|
|
39
|
+
* the previous scale ran 9.5–13px and was a caption scale applied to prose. That
|
|
40
|
+
* is the single most common accessibility failure in dense interfaces, and it
|
|
41
|
+
* reads as miniature rather than compact.
|
|
42
|
+
*
|
|
43
|
+
* `label` exists for uppercase section headings only. Uppercase with letter
|
|
44
|
+
* spacing survives at 11px in a way lowercase prose does not, so it gets its own
|
|
45
|
+
* step instead of being borrowed for body copy.
|
|
46
|
+
*
|
|
47
|
+
* Inputs are 14px to match body: the iOS zoom-on-focus behaviour starts below
|
|
48
|
+
* 16px, and 16px looks oversized inside a side panel, so 14 is the compromise
|
|
49
|
+
* that stays readable without zoom.
|
|
50
|
+
*/
|
|
51
|
+
const type = {
|
|
52
|
+
label: "11px",
|
|
53
|
+
caption: "13px",
|
|
54
|
+
body: "14px",
|
|
55
|
+
title: "16px",
|
|
56
|
+
display: "20px",
|
|
57
|
+
} as const
|
|
58
|
+
|
|
59
|
+
const space = { xxs: "2px", xs: "4px", sm: "6px", md: "8px", lg: "12px", xl: "16px" } as const
|
|
60
|
+
const radius = { sm: "6px", md: "8px", lg: "10px" } as const
|
|
61
|
+
|
|
62
|
+
const theme = {
|
|
63
|
+
text: "var(--text-primary, #e8e8ec)",
|
|
64
|
+
muted: "var(--text-secondary, #9a9aa6)",
|
|
65
|
+
faint: "var(--text-tertiary, #6f6f7c)",
|
|
66
|
+
border: "var(--border-subtle, rgba(255,255,255,0.09))",
|
|
67
|
+
borderStrong: "var(--border-default, rgba(255,255,255,0.16))",
|
|
68
|
+
surface: "var(--surface-raised, rgba(255,255,255,0.04))",
|
|
69
|
+
raised: "var(--surface-overlay, rgba(255,255,255,0.07))",
|
|
70
|
+
good: "var(--text-success, #7ad19a)",
|
|
71
|
+
warn: "var(--text-warning, #e0b463)",
|
|
72
|
+
bad: "var(--text-danger, #e08a8a)",
|
|
73
|
+
/** Brand accents, taken from assets/logo.svg. */
|
|
74
|
+
brand: "#0f766e",
|
|
75
|
+
brandTo: "#0891b2",
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
const mono = "ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
79
|
+
const brandGradient = `linear-gradient(135deg, ${theme.brand} 0%, ${theme.brandTo} 100%)`
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Primitives
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** A labelled region. The label is the hierarchy; no rule line needed. */
|
|
86
|
+
function Section(props: {
|
|
87
|
+
label: string
|
|
88
|
+
aside?: unknown
|
|
89
|
+
children: unknown
|
|
90
|
+
first?: boolean
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
display: "flex",
|
|
96
|
+
"flex-direction": "column",
|
|
97
|
+
gap: space.sm,
|
|
98
|
+
...(props.first ? {} : { "padding-top": space.lg, "border-top": `1px solid ${theme.border}` }),
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "baseline", gap: space.md }}>
|
|
102
|
+
<span
|
|
103
|
+
style={{
|
|
104
|
+
color: theme.faint,
|
|
105
|
+
"font-size": type.label,
|
|
106
|
+
"text-transform": "uppercase",
|
|
107
|
+
"letter-spacing": "0.06em",
|
|
108
|
+
"font-weight": 600,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{props.label}
|
|
112
|
+
</span>
|
|
113
|
+
{props.aside as never}
|
|
114
|
+
</div>
|
|
115
|
+
{props.children as never}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A surface that groups related values. Used sparingly: too many cards is noise. */
|
|
121
|
+
function Card(props: { children: unknown; accent?: boolean }) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
style={{
|
|
125
|
+
display: "flex",
|
|
126
|
+
"flex-direction": "column",
|
|
127
|
+
gap: space.sm,
|
|
128
|
+
padding: "10px 11px",
|
|
129
|
+
"border-radius": radius.md,
|
|
130
|
+
border: `1px solid ${props.accent ? theme.borderStrong : theme.border}`,
|
|
131
|
+
background: theme.surface,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{props.children as never}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A label above a value. Numbers are tabular so columns line up. */
|
|
140
|
+
function Stat(props: { label: string; value: unknown; tone?: "good" | "warn" | "bad" | "muted" }) {
|
|
141
|
+
const colour =
|
|
142
|
+
props.tone === "good"
|
|
143
|
+
? theme.good
|
|
144
|
+
: props.tone === "warn"
|
|
145
|
+
? theme.warn
|
|
146
|
+
: props.tone === "bad"
|
|
147
|
+
? theme.bad
|
|
148
|
+
: theme.muted
|
|
149
|
+
return (
|
|
150
|
+
<span style={{ display: "flex", "flex-direction": "column", gap: "1px", "min-width": 0 }}>
|
|
151
|
+
<span style={{ color: theme.faint, "font-size": type.label }}>{props.label}</span>
|
|
152
|
+
<span style={{ color: colour, "font-size": type.body, "font-variant-numeric": "tabular-nums" }}>
|
|
153
|
+
{props.value as never}
|
|
154
|
+
</span>
|
|
155
|
+
</span>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A usage meter. Width is the value; colour is the urgency. */
|
|
160
|
+
function Meter(props: { usedPct: number; exhausted?: boolean }) {
|
|
161
|
+
const width = Math.min(100, Math.max(0, props.usedPct))
|
|
162
|
+
const colour = props.exhausted
|
|
163
|
+
? theme.bad
|
|
164
|
+
: width >= 80
|
|
165
|
+
? theme.warn
|
|
166
|
+
: width >= 50
|
|
167
|
+
? theme.brandTo
|
|
168
|
+
: theme.good
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
role="meter"
|
|
172
|
+
aria-valuenow={Math.round(width)}
|
|
173
|
+
aria-valuemin={0}
|
|
174
|
+
aria-valuemax={100}
|
|
175
|
+
style={{
|
|
176
|
+
flex: 1,
|
|
177
|
+
height: "3px",
|
|
178
|
+
"border-radius": "2px",
|
|
179
|
+
background: "rgba(255,255,255,0.09)",
|
|
180
|
+
overflow: "hidden",
|
|
181
|
+
"min-width": "36px",
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<div style={{ width: `${width}%`, height: "100%", background: colour, "border-radius": "2px" }} />
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Chip(props: { children: unknown; tone?: "good" | "warn" | "bad" | "neutral" | "brand" }) {
|
|
190
|
+
const bg =
|
|
191
|
+
props.tone === "good"
|
|
192
|
+
? "rgba(122,209,154,0.12)"
|
|
193
|
+
: props.tone === "warn"
|
|
194
|
+
? "rgba(224,180,99,0.12)"
|
|
195
|
+
: props.tone === "bad"
|
|
196
|
+
? "rgba(224,138,138,0.12)"
|
|
197
|
+
: props.tone === "brand"
|
|
198
|
+
? "rgba(8,145,178,0.16)"
|
|
199
|
+
: "rgba(255,255,255,0.06)"
|
|
200
|
+
const fg =
|
|
201
|
+
props.tone === "good"
|
|
202
|
+
? theme.good
|
|
203
|
+
: props.tone === "warn"
|
|
204
|
+
? theme.warn
|
|
205
|
+
: props.tone === "bad"
|
|
206
|
+
? theme.bad
|
|
207
|
+
: props.tone === "brand"
|
|
208
|
+
? theme.brandTo
|
|
209
|
+
: theme.muted
|
|
210
|
+
return (
|
|
211
|
+
<span
|
|
212
|
+
style={{
|
|
213
|
+
background: bg,
|
|
214
|
+
color: fg,
|
|
215
|
+
"font-size": type.label,
|
|
216
|
+
"font-weight": 600,
|
|
217
|
+
"text-transform": "uppercase",
|
|
218
|
+
"letter-spacing": "0.05em",
|
|
219
|
+
padding: "2px 6px",
|
|
220
|
+
"border-radius": "4px",
|
|
221
|
+
"white-space": "nowrap",
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{props.children as never}
|
|
225
|
+
</span>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Teaching empty state. Never a blank box: say what would fill it. */
|
|
230
|
+
function Empty(props: { title: string; body: string }) {
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
style={{
|
|
234
|
+
display: "flex",
|
|
235
|
+
"flex-direction": "column",
|
|
236
|
+
gap: "3px",
|
|
237
|
+
padding: "12px 11px",
|
|
238
|
+
"border-radius": radius.md,
|
|
239
|
+
border: `1px dashed ${theme.border}`,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<span style={{ color: theme.muted, "font-size": type.body, "font-weight": 550 }}>{props.title}</span>
|
|
243
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>{props.body}</span>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A row that opens. Caret, hover, cursor and aria together — one alone is invisible. */
|
|
249
|
+
function Disclosure(props: {
|
|
250
|
+
id: string
|
|
251
|
+
open: boolean
|
|
252
|
+
onToggle: () => void
|
|
253
|
+
hovered: boolean
|
|
254
|
+
onHover: (id: string | undefined) => void
|
|
255
|
+
children: unknown
|
|
256
|
+
aside?: unknown
|
|
257
|
+
size?: "section" | "row"
|
|
258
|
+
}) {
|
|
259
|
+
const section = props.size !== "row"
|
|
260
|
+
return (
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={props.onToggle}
|
|
264
|
+
onMouseEnter={() => props.onHover(props.id)}
|
|
265
|
+
onMouseLeave={() => props.onHover(undefined)}
|
|
266
|
+
aria-expanded={props.open}
|
|
267
|
+
title={props.open ? "Collapse" : "Expand"}
|
|
268
|
+
style={{
|
|
269
|
+
display: "flex",
|
|
270
|
+
"justify-content": "space-between",
|
|
271
|
+
"align-items": "center",
|
|
272
|
+
gap: space.md,
|
|
273
|
+
width: "100%",
|
|
274
|
+
background: props.hovered ? theme.surface : "transparent",
|
|
275
|
+
border: "none",
|
|
276
|
+
"border-radius": radius.sm,
|
|
277
|
+
padding: "5px 7px",
|
|
278
|
+
margin: "-5px -7px",
|
|
279
|
+
color: "inherit",
|
|
280
|
+
font: "inherit",
|
|
281
|
+
cursor: "pointer",
|
|
282
|
+
"text-align": "left",
|
|
283
|
+
transition: "background 100ms ease",
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<span style={{ display: "flex", gap: "7px", "align-items": "center", "min-width": 0 }}>
|
|
287
|
+
<span
|
|
288
|
+
aria-hidden="true"
|
|
289
|
+
style={{
|
|
290
|
+
display: "inline-block",
|
|
291
|
+
width: "9px",
|
|
292
|
+
color: props.hovered ? theme.text : theme.faint,
|
|
293
|
+
"font-size": "9px",
|
|
294
|
+
"flex-shrink": 0,
|
|
295
|
+
transform: props.open ? "rotate(90deg)" : "none",
|
|
296
|
+
transition: "transform 120ms ease",
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
▸
|
|
300
|
+
</span>
|
|
301
|
+
<span
|
|
302
|
+
style={
|
|
303
|
+
section
|
|
304
|
+
? {
|
|
305
|
+
color: theme.faint,
|
|
306
|
+
"font-size": type.label,
|
|
307
|
+
"text-transform": "uppercase",
|
|
308
|
+
"letter-spacing": "0.06em",
|
|
309
|
+
"font-weight": 600,
|
|
310
|
+
}
|
|
311
|
+
: { color: theme.text, "font-size": type.title, "font-weight": 550 }
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
{props.children as never}
|
|
315
|
+
</span>
|
|
316
|
+
</span>
|
|
317
|
+
<span style={{ display: "flex", gap: space.md, "align-items": "baseline", "flex-shrink": 0 }}>
|
|
318
|
+
{props.aside as never}
|
|
319
|
+
</span>
|
|
320
|
+
</button>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The brand mark, from assets/logo.svg: one input forking into three routes. */
|
|
325
|
+
function BrandMark(props: { size?: number }) {
|
|
326
|
+
const size = props.size ?? 22
|
|
327
|
+
return (
|
|
328
|
+
<svg
|
|
329
|
+
width={size}
|
|
330
|
+
height={size}
|
|
331
|
+
viewBox="0 0 60 60"
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
style={{ "flex-shrink": 0, display: "block" }}
|
|
334
|
+
>
|
|
335
|
+
<defs>
|
|
336
|
+
<linearGradient id="sr-brand" x1="0" y1="0" x2="1" y2="1">
|
|
337
|
+
<stop offset="0%" stop-color={theme.brand} />
|
|
338
|
+
<stop offset="100%" stop-color={theme.brandTo} />
|
|
339
|
+
</linearGradient>
|
|
340
|
+
</defs>
|
|
341
|
+
<g fill="none" stroke="url(#sr-brand)" stroke-width="4" stroke-linecap="round">
|
|
342
|
+
<path d="M8 30h12" />
|
|
343
|
+
<path d="M20 30c7 0 8-14 15-14h8" />
|
|
344
|
+
<path d="M20 30c7 0 8 14 15 14h8" />
|
|
345
|
+
<path d="M20 30h23" />
|
|
346
|
+
</g>
|
|
347
|
+
<circle cx="5" cy="30" r="4.5" fill="url(#sr-brand)" />
|
|
348
|
+
<g fill="url(#sr-brand)">
|
|
349
|
+
<rect x="45" y="10" width="9" height="9" rx="2.5" />
|
|
350
|
+
<rect x="45" y="25.5" width="9" height="9" rx="2.5" />
|
|
351
|
+
<rect x="45" y="41" width="9" height="9" rx="2.5" />
|
|
352
|
+
</g>
|
|
353
|
+
</svg>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function BrandHeader(props: { runtime: string; mode: string }) {
|
|
358
|
+
const tone = props.runtime === "paused" ? "warn" : props.runtime === "manual" ? "neutral" : "brand"
|
|
359
|
+
return (
|
|
360
|
+
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", gap: space.md }}>
|
|
361
|
+
<div style={{ display: "flex", gap: space.sm, "align-items": "center", "min-width": 0 }}>
|
|
362
|
+
<BrandMark />
|
|
363
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "0" }}>
|
|
364
|
+
<span style={{ color: theme.text, "font-size": type.title, "font-weight": 650, "letter-spacing": "-0.2px" }}>
|
|
365
|
+
Smart Router
|
|
366
|
+
</span>
|
|
367
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "letter-spacing": "0.09em", "font-weight": 600 }}>
|
|
368
|
+
FOR OPENCODE
|
|
369
|
+
</span>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div style={{ display: "flex", gap: space.xs, "align-items": "center" }}>
|
|
373
|
+
<Chip tone={tone}>{props.mode}</Chip>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Feature components
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
interface RouterStatus {
|
|
384
|
+
mode?: string
|
|
385
|
+
runtime?: string
|
|
386
|
+
current?: { providerID: string; id: string }
|
|
387
|
+
quota?: ReadonlyArray<unknown>
|
|
388
|
+
lastDecision?: unknown
|
|
389
|
+
approvedModels?: ReadonlyArray<unknown>
|
|
390
|
+
models?: Parameters<typeof providerGroups>[0]["models"]
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const relative = (at: number): string => {
|
|
394
|
+
const delta = Date.now() - at
|
|
395
|
+
if (delta < 5_000) return "just now"
|
|
396
|
+
if (delta < 60_000) return `${Math.round(delta / 1_000)}s ago`
|
|
397
|
+
if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`
|
|
398
|
+
return `${Math.round(delta / 3_600_000)}h ago`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Is the router choosing the model, or is the user? */
|
|
402
|
+
function AutoSwitch(props: { sessionID: string | undefined; auto: boolean; onChanged: () => void }) {
|
|
403
|
+
const ctx = usePlugin<typeof definition>()
|
|
404
|
+
const [busy, setBusy] = createSignal(false)
|
|
405
|
+
const [hovered, setHovered] = createSignal(false)
|
|
406
|
+
|
|
407
|
+
const toggle = async () => {
|
|
408
|
+
if (!props.sessionID || busy()) return
|
|
409
|
+
setBusy(true)
|
|
410
|
+
try {
|
|
411
|
+
await ctx.client.rpc(SmartRouterRpc).setSessionAuto({ sessionID: props.sessionID, auto: !props.auto })
|
|
412
|
+
props.onChanged()
|
|
413
|
+
} finally {
|
|
414
|
+
setBusy(false)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={() => void toggle()}
|
|
422
|
+
onMouseEnter={() => setHovered(true)}
|
|
423
|
+
onMouseLeave={() => setHovered(false)}
|
|
424
|
+
disabled={busy() || !props.sessionID}
|
|
425
|
+
aria-pressed={props.auto}
|
|
426
|
+
title={props.auto ? "Click to keep your current model" : "Click to let the router choose"}
|
|
427
|
+
style={{
|
|
428
|
+
display: "flex",
|
|
429
|
+
"align-items": "center",
|
|
430
|
+
"justify-content": "space-between",
|
|
431
|
+
gap: space.md,
|
|
432
|
+
width: "100%",
|
|
433
|
+
padding: "9px 11px",
|
|
434
|
+
"border-radius": radius.md,
|
|
435
|
+
border: `1px solid ${props.auto ? "transparent" : theme.border}`,
|
|
436
|
+
background: props.auto ? "rgba(8,145,178,0.10)" : hovered() ? theme.surface : "transparent",
|
|
437
|
+
color: theme.text,
|
|
438
|
+
cursor: busy() || !props.sessionID ? "default" : "pointer",
|
|
439
|
+
font: "inherit",
|
|
440
|
+
"text-align": "left",
|
|
441
|
+
transition: "background 120ms ease",
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
<span style={{ display: "flex", "flex-direction": "column", gap: "1px" }}>
|
|
445
|
+
<span style={{ "font-size": type.body, "font-weight": 600 }}>Automatic routing</span>
|
|
446
|
+
<span style={{ color: props.auto ? theme.brandTo : theme.faint, "font-size": type.caption }}>
|
|
447
|
+
{props.auto ? "The router picks the model for you" : "You keep your model; the router only observes"}
|
|
448
|
+
</span>
|
|
449
|
+
</span>
|
|
450
|
+
<span
|
|
451
|
+
aria-hidden="true"
|
|
452
|
+
style={{
|
|
453
|
+
width: "30px",
|
|
454
|
+
height: "16px",
|
|
455
|
+
"border-radius": "8px",
|
|
456
|
+
background: props.auto ? brandGradient : "rgba(255,255,255,0.14)",
|
|
457
|
+
position: "relative",
|
|
458
|
+
"flex-shrink": 0,
|
|
459
|
+
transition: "background 140ms ease",
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<span
|
|
463
|
+
style={{
|
|
464
|
+
position: "absolute",
|
|
465
|
+
top: "2px",
|
|
466
|
+
left: props.auto ? "16px" : "2px",
|
|
467
|
+
width: "12px",
|
|
468
|
+
height: "12px",
|
|
469
|
+
"border-radius": "50%",
|
|
470
|
+
background: "#fff",
|
|
471
|
+
transition: "left 140ms ease",
|
|
472
|
+
}}
|
|
473
|
+
/>
|
|
474
|
+
</span>
|
|
475
|
+
</button>
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
function DecisionInspector(props: { view: DecisionView | undefined; hovered: string | undefined; onHover: (id: string | undefined) => void; expanded: boolean; onToggle: () => void }) {
|
|
481
|
+
return (
|
|
482
|
+
<Section
|
|
483
|
+
label="Last decision"
|
|
484
|
+
aside={
|
|
485
|
+
<Show when={props.view}>
|
|
486
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
487
|
+
{props.view!.eligibleCount} eligible · {props.view!.blockedCount} blocked
|
|
488
|
+
</span>
|
|
489
|
+
</Show>
|
|
490
|
+
}
|
|
491
|
+
>
|
|
492
|
+
<Show
|
|
493
|
+
when={props.view}
|
|
494
|
+
fallback={
|
|
495
|
+
<Empty
|
|
496
|
+
title="No decision yet"
|
|
497
|
+
body="Send a prompt and the router will explain which model it chose, and why it beat the others."
|
|
498
|
+
/>
|
|
499
|
+
}
|
|
500
|
+
>
|
|
501
|
+
<Card accent>
|
|
502
|
+
<div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
|
|
503
|
+
<span
|
|
504
|
+
style={{
|
|
505
|
+
"font-family": mono,
|
|
506
|
+
"font-size": type.body,
|
|
507
|
+
color: theme.text,
|
|
508
|
+
"font-weight": 600,
|
|
509
|
+
overflow: "hidden",
|
|
510
|
+
"text-overflow": "ellipsis",
|
|
511
|
+
"white-space": "nowrap",
|
|
512
|
+
}}
|
|
513
|
+
>
|
|
514
|
+
{props.view!.chosen ?? "nothing eligible"}
|
|
515
|
+
</span>
|
|
516
|
+
<Chip tone={props.view!.chosen ? "good" : "bad"}>{props.view!.chosen ? "chosen" : "blocked"}</Chip>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div style={{ display: "flex", gap: space.xl, "flex-wrap": "wrap" }}>
|
|
520
|
+
<Stat label="est. cost" value={formatUsd(props.view!.estimatedUsd)} />
|
|
521
|
+
<Stat label="task" value={`${props.view!.taskKind} · T${props.view!.taskTier}`} />
|
|
522
|
+
<Stat label="context" value={`${Math.round(props.view!.contextTokens / 1000)}k`} />
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<Show when={props.view!.reasons.length > 0}>
|
|
526
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "3px", "padding-top": space.xs }}>
|
|
527
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "text-transform": "uppercase", "letter-spacing": "0.05em" }}>
|
|
528
|
+
Why
|
|
529
|
+
</span>
|
|
530
|
+
<For each={props.view!.reasons}>
|
|
531
|
+
{(reason) => (
|
|
532
|
+
<div style={{ display: "flex", gap: space.sm, "align-items": "baseline" }}>
|
|
533
|
+
<span style={{ color: theme.brandTo, "font-size": type.caption, "line-height": "1.6" }}>›</span>
|
|
534
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5 }}>{reason}</span>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
</For>
|
|
538
|
+
</div>
|
|
539
|
+
</Show>
|
|
540
|
+
|
|
541
|
+
<Show when={props.view!.selfheal}>
|
|
542
|
+
<div style={{ display: "flex", gap: space.sm, "align-items": "baseline", "padding-top": space.xs }}>
|
|
543
|
+
<span style={{ color: theme.brandTo, "font-size": type.caption, "line-height": "1.6" }}>›</span>
|
|
544
|
+
<span style={{ color: theme.text, "font-size": type.caption, "line-height": 1.5 }}>
|
|
545
|
+
Self-healed: {props.view!.selfheal}
|
|
546
|
+
</span>
|
|
547
|
+
</div>
|
|
548
|
+
</Show>
|
|
549
|
+
</Card>
|
|
550
|
+
|
|
551
|
+
<Show when={props.view!.rows.length > 0}>
|
|
552
|
+
<Disclosure
|
|
553
|
+
id="candidates"
|
|
554
|
+
size="row"
|
|
555
|
+
open={props.expanded}
|
|
556
|
+
onToggle={props.onToggle}
|
|
557
|
+
hovered={props.hovered === "candidates"}
|
|
558
|
+
onHover={props.onHover}
|
|
559
|
+
aside={
|
|
560
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
561
|
+
{props.view!.rows.length} considered
|
|
562
|
+
</span>
|
|
563
|
+
}
|
|
564
|
+
>
|
|
565
|
+
<span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>Candidates</span>
|
|
566
|
+
</Disclosure>
|
|
567
|
+
|
|
568
|
+
<Show when={props.expanded}>
|
|
569
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "1px" }}>
|
|
570
|
+
<For each={props.view!.rows}>
|
|
571
|
+
{(row) => (
|
|
572
|
+
<div
|
|
573
|
+
style={{
|
|
574
|
+
display: "flex",
|
|
575
|
+
"flex-direction": "column",
|
|
576
|
+
gap: "2px",
|
|
577
|
+
padding: "6px 8px",
|
|
578
|
+
"border-radius": radius.sm,
|
|
579
|
+
background: row.verdict === "chosen" ? "rgba(8,145,178,0.08)" : "transparent",
|
|
580
|
+
}}
|
|
581
|
+
>
|
|
582
|
+
<div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
|
|
583
|
+
<span
|
|
584
|
+
style={{
|
|
585
|
+
"font-family": mono,
|
|
586
|
+
"font-size": type.caption,
|
|
587
|
+
color: row.verdict === "blocked" ? theme.faint : theme.text,
|
|
588
|
+
overflow: "hidden",
|
|
589
|
+
"text-overflow": "ellipsis",
|
|
590
|
+
"white-space": "nowrap",
|
|
591
|
+
}}
|
|
592
|
+
>
|
|
593
|
+
{row.key}
|
|
594
|
+
</span>
|
|
595
|
+
<span style={{ display: "flex", gap: space.md, "flex-shrink": 0 }}>
|
|
596
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
|
|
597
|
+
{formatUsd(row.effectiveCost)}
|
|
598
|
+
</span>
|
|
599
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
|
|
600
|
+
{formatPercent(row.successProbability * 100)}
|
|
601
|
+
</span>
|
|
602
|
+
</span>
|
|
603
|
+
</div>
|
|
604
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.45 }}>
|
|
605
|
+
{row.reason}
|
|
606
|
+
{row.adjustments.length > 0 ? ` · ${row.adjustments.join(" · ")}` : ""}
|
|
607
|
+
</span>
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
</For>
|
|
611
|
+
</div>
|
|
612
|
+
</Show>
|
|
613
|
+
</Show>
|
|
614
|
+
</Show>
|
|
615
|
+
</Section>
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Providers, not their catalogs. See the note in providerGroups(). */
|
|
620
|
+
function ProviderList(props: { status: RouterStatus | undefined }) {
|
|
621
|
+
const [open, setOpen] = createSignal(true)
|
|
622
|
+
const [notConfiguredOpen, setNotConfiguredOpen] = createSignal(false)
|
|
623
|
+
const [hovered, setHovered] = createSignal<string | undefined>(undefined)
|
|
624
|
+
|
|
625
|
+
const groups = () => {
|
|
626
|
+
const status = props.status
|
|
627
|
+
if (!status?.models) return []
|
|
628
|
+
const active = ((status.quota ?? []) as Array<{ providerID?: string; error?: string }>)
|
|
629
|
+
.filter((snapshot) => !snapshot.error)
|
|
630
|
+
.map((snapshot) => snapshot.providerID ?? "")
|
|
631
|
+
return providerGroups({
|
|
632
|
+
models: status.models,
|
|
633
|
+
quota: (status.quota ?? []) as Array<{ providerID: string; label?: string }>,
|
|
634
|
+
activeProviders: active,
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const activeGroups = () => groups().filter((group) => group.state === "active")
|
|
639
|
+
const inactiveGroups = () => groups().filter((group) => group.state !== "active")
|
|
640
|
+
|
|
641
|
+
const Row = (rowProps: { group: ProviderGroupView }) => (
|
|
642
|
+
<div
|
|
643
|
+
style={{
|
|
644
|
+
display: "flex",
|
|
645
|
+
"justify-content": "space-between",
|
|
646
|
+
gap: space.md,
|
|
647
|
+
"align-items": "center",
|
|
648
|
+
padding: "7px 9px",
|
|
649
|
+
"border-radius": radius.sm,
|
|
650
|
+
border: `1px solid ${theme.border}`,
|
|
651
|
+
background: rowProps.group.state === "active" ? theme.surface : "transparent",
|
|
652
|
+
}}
|
|
653
|
+
>
|
|
654
|
+
<span style={{ display: "flex", "flex-direction": "column", gap: "2px", "min-width": 0 }}>
|
|
655
|
+
<span style={{ "font-size": type.body, "font-weight": 550, color: theme.text }}>{rowProps.group.label}</span>
|
|
656
|
+
<Show when={rowProps.group.note}>
|
|
657
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.4 }}>{rowProps.group.note}</span>
|
|
658
|
+
</Show>
|
|
659
|
+
</span>
|
|
660
|
+
<span style={{ display: "flex", gap: space.md, "align-items": "center", "flex-shrink": 0 }}>
|
|
661
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
662
|
+
{rowProps.group.approvedCount}/{rowProps.group.totalCount}
|
|
663
|
+
</span>
|
|
664
|
+
<Chip tone={rowProps.group.state === "active" ? "brand" : "neutral"}>
|
|
665
|
+
{rowProps.group.state === "active" ? "active" : "off"}
|
|
666
|
+
</Chip>
|
|
667
|
+
</span>
|
|
668
|
+
</div>
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<Section
|
|
673
|
+
label="Providers"
|
|
674
|
+
aside={
|
|
675
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
676
|
+
{activeGroups().length} active
|
|
677
|
+
{inactiveGroups().length > 0 ? ` · ${inactiveGroups().length} off` : ""}
|
|
678
|
+
</span>
|
|
679
|
+
}
|
|
680
|
+
>
|
|
681
|
+
<Disclosure
|
|
682
|
+
id="providers"
|
|
683
|
+
open={open()}
|
|
684
|
+
onToggle={() => setOpen((value) => !value)}
|
|
685
|
+
hovered={hovered() === "providers"}
|
|
686
|
+
onHover={setHovered}
|
|
687
|
+
aside={
|
|
688
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
689
|
+
{open() ? "hide" : "show"}
|
|
690
|
+
</span>
|
|
691
|
+
}
|
|
692
|
+
>
|
|
693
|
+
<span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>
|
|
694
|
+
{open() ? "Configured" : "Show configured"}
|
|
695
|
+
</span>
|
|
696
|
+
</Disclosure>
|
|
697
|
+
|
|
698
|
+
<Show when={open()}>
|
|
699
|
+
<For each={activeGroups()}>{(group) => <Row group={group} />}</For>
|
|
700
|
+
|
|
701
|
+
<Show when={inactiveGroups().length > 0}>
|
|
702
|
+
<Disclosure
|
|
703
|
+
id="not-configured"
|
|
704
|
+
open={notConfiguredOpen()}
|
|
705
|
+
onToggle={() => setNotConfiguredOpen((value) => !value)}
|
|
706
|
+
hovered={hovered() === "not-configured"}
|
|
707
|
+
onHover={setHovered}
|
|
708
|
+
aside={<span style={{ color: theme.faint, "font-size": type.caption }}>{inactiveGroups().length}</span>}
|
|
709
|
+
>
|
|
710
|
+
<span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>Not configured</span>
|
|
711
|
+
</Disclosure>
|
|
712
|
+
<Show when={notConfiguredOpen()}>
|
|
713
|
+
<For each={inactiveGroups()}>{(group) => <Row group={group} />}</For>
|
|
714
|
+
</Show>
|
|
715
|
+
</Show>
|
|
716
|
+
</Show>
|
|
717
|
+
</Section>
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Allowances, one card per provider, with every window it reports. */
|
|
722
|
+
function Allowances(props: { providers: ReadonlyArray<ProviderRow> }) {
|
|
723
|
+
return (
|
|
724
|
+
<Section
|
|
725
|
+
label="Allowances"
|
|
726
|
+
aside={
|
|
727
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
728
|
+
{props.providers.length} provider{props.providers.length === 1 ? "" : "s"}
|
|
729
|
+
</span>
|
|
730
|
+
}
|
|
731
|
+
>
|
|
732
|
+
<Show
|
|
733
|
+
when={props.providers.length > 0}
|
|
734
|
+
fallback={<Empty title="No quota data" body="No provider has reported an allowance yet." />}
|
|
735
|
+
>
|
|
736
|
+
<For each={props.providers}>
|
|
737
|
+
{(provider) => (
|
|
738
|
+
<Card>
|
|
739
|
+
<div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "center" }}>
|
|
740
|
+
<span style={{ "font-size": type.body, "font-weight": 550, color: provider.error ? theme.bad : theme.text }}>
|
|
741
|
+
{provider.label}
|
|
742
|
+
</span>
|
|
743
|
+
<span style={{ display: "flex", gap: space.xs, "align-items": "center" }}>
|
|
744
|
+
<Show when={provider.stale}>
|
|
745
|
+
<Chip tone="warn">stale</Chip>
|
|
746
|
+
</Show>
|
|
747
|
+
<Show when={provider.exhausted}>
|
|
748
|
+
<Chip tone="bad">{provider.exhaustedWindow} spent</Chip>
|
|
749
|
+
</Show>
|
|
750
|
+
<Show when={!provider.exhausted && !provider.stale}>
|
|
751
|
+
<Chip tone={provider.source === "provider" ? "good" : "neutral"}>
|
|
752
|
+
{provider.source === "provider" ? "verified" : "estimated"}
|
|
753
|
+
</Chip>
|
|
754
|
+
</Show>
|
|
755
|
+
</span>
|
|
756
|
+
</div>
|
|
757
|
+
|
|
758
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "4px" }}>
|
|
759
|
+
<For each={provider.windows}>
|
|
760
|
+
{(window) => (
|
|
761
|
+
<div style={{ display: "flex", "align-items": "center", gap: space.md }}>
|
|
762
|
+
<span
|
|
763
|
+
style={{
|
|
764
|
+
color: theme.faint,
|
|
765
|
+
"font-size": type.caption,
|
|
766
|
+
"min-width": "42px",
|
|
767
|
+
"font-variant-numeric": "tabular-nums",
|
|
768
|
+
}}
|
|
769
|
+
>
|
|
770
|
+
{window.label}
|
|
771
|
+
</span>
|
|
772
|
+
<Show when={window.reported} fallback={<span style={{ flex: 1 }} />}>
|
|
773
|
+
<Meter usedPct={window.usedPct} exhausted={window.exceeded} />
|
|
774
|
+
</Show>
|
|
775
|
+
<span
|
|
776
|
+
style={{
|
|
777
|
+
color: window.exceeded ? theme.bad : window.reported ? theme.muted : theme.faint,
|
|
778
|
+
"font-size": type.caption,
|
|
779
|
+
"min-width": "82px",
|
|
780
|
+
"text-align": "right",
|
|
781
|
+
"font-variant-numeric": "tabular-nums",
|
|
782
|
+
"flex-shrink": 0,
|
|
783
|
+
}}
|
|
784
|
+
>
|
|
785
|
+
{window.reported ? formatWindowUsed(window) : "—"}
|
|
786
|
+
{window.resetLabel ? ` · ${window.resetLabel}` : ""}
|
|
787
|
+
</span>
|
|
788
|
+
</div>
|
|
789
|
+
)}
|
|
790
|
+
</For>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<Show when={provider.pools.length > 0}>
|
|
794
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "2px", "padding-top": space.xs }}>
|
|
795
|
+
<For each={provider.pools}>
|
|
796
|
+
{(pool) => (
|
|
797
|
+
<div style={{ display: "flex", "justify-content": "space-between", gap: space.md }}>
|
|
798
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>{pool.label}</span>
|
|
799
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
|
|
800
|
+
{formatUsd(pool.remainingUsd)} of {formatUsd(pool.monthlyUsd)}
|
|
801
|
+
</span>
|
|
802
|
+
</div>
|
|
803
|
+
)}
|
|
804
|
+
</For>
|
|
805
|
+
</div>
|
|
806
|
+
</Show>
|
|
807
|
+
|
|
808
|
+
<Show when={provider.caveat}>
|
|
809
|
+
<span style={{ color: theme.warn, "font-size": type.caption, "line-height": 1.45 }}>{provider.caveat}</span>
|
|
810
|
+
</Show>
|
|
811
|
+
<Show when={provider.error}>
|
|
812
|
+
<span style={{ color: theme.bad, "font-size": type.caption }}>{provider.error}</span>
|
|
813
|
+
</Show>
|
|
814
|
+
</Card>
|
|
815
|
+
)}
|
|
816
|
+
</For>
|
|
817
|
+
</Show>
|
|
818
|
+
</Section>
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Paste any console page, once.
|
|
824
|
+
*
|
|
825
|
+
* Every console page is accepted and the type is detected, because making the
|
|
826
|
+
* user choose the right page first is a step that exists only because the
|
|
827
|
+
* software was not paying attention.
|
|
828
|
+
*/
|
|
829
|
+
function AccountPaste(props: { onChanged: () => void }) {
|
|
830
|
+
const ctx = usePlugin<typeof definition>()
|
|
831
|
+
const [page, setPage] = createSignal("")
|
|
832
|
+
const [preview, setPreview] = createSignal<any | undefined>(undefined)
|
|
833
|
+
const [status, setStatus] = createSignal<{ ok: boolean; text: string } | undefined>(undefined)
|
|
834
|
+
const [busy, setBusy] = createSignal(false)
|
|
835
|
+
const [focused, setFocused] = createSignal(false)
|
|
836
|
+
const [hovered, setHovered] = createSignal(false)
|
|
837
|
+
|
|
838
|
+
const reset = () => {
|
|
839
|
+
setPage("")
|
|
840
|
+
setPreview(undefined)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const read = async (confirm: boolean) => {
|
|
844
|
+
if (!page().trim() || busy()) return
|
|
845
|
+
setBusy(true)
|
|
846
|
+
try {
|
|
847
|
+
const result: any = await ctx.client.rpc(SmartRouterRpc).recordPlanPage({ page: page(), confirm })
|
|
848
|
+
setStatus({ ok: result?.ok !== false, text: result?.message ?? "" })
|
|
849
|
+
if (result?.ok && !confirm) setPreview(result.preview)
|
|
850
|
+
else if (result?.ok && confirm) {
|
|
851
|
+
reset()
|
|
852
|
+
props.onChanged()
|
|
853
|
+
}
|
|
854
|
+
} catch (cause) {
|
|
855
|
+
setStatus({ ok: false, text: cause instanceof Error ? cause.message : String(cause) })
|
|
856
|
+
} finally {
|
|
857
|
+
setBusy(false)
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const button = (label: string, enabled: boolean, onClick: () => void) => (
|
|
862
|
+
<button
|
|
863
|
+
type="button"
|
|
864
|
+
onClick={onClick}
|
|
865
|
+
disabled={!enabled}
|
|
866
|
+
style={{
|
|
867
|
+
padding: "5px 11px",
|
|
868
|
+
"border-radius": radius.sm,
|
|
869
|
+
border: `1px solid ${enabled ? theme.borderStrong : theme.border}`,
|
|
870
|
+
background: enabled ? theme.raised : "transparent",
|
|
871
|
+
color: enabled ? theme.text : theme.faint,
|
|
872
|
+
"font-size": type.caption,
|
|
873
|
+
font: enabled ? "inherit" : `${type.caption} inherit`,
|
|
874
|
+
"font-weight": 500,
|
|
875
|
+
cursor: enabled ? "pointer" : "default",
|
|
876
|
+
}}
|
|
877
|
+
>
|
|
878
|
+
{label}
|
|
879
|
+
</button>
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
return (
|
|
883
|
+
<Section label="Account reading">
|
|
884
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
|
|
885
|
+
OpenDesign exposes no usage API, so the router can only count what it routed itself. Paste your{" "}
|
|
886
|
+
<strong style={{ color: theme.text }}>Plan & balance</strong> page to replace the estimate with your
|
|
887
|
+
real position — it carries the percentage, the window it belongs to, and when it resets.
|
|
888
|
+
</span>
|
|
889
|
+
|
|
890
|
+
<textarea
|
|
891
|
+
value={page()}
|
|
892
|
+
onInput={(event) => {
|
|
893
|
+
setPage((event.currentTarget as HTMLTextAreaElement).value)
|
|
894
|
+
setPreview(undefined)
|
|
895
|
+
}}
|
|
896
|
+
onFocus={() => setFocused(true)}
|
|
897
|
+
onBlur={() => setFocused(false)}
|
|
898
|
+
placeholder="Paste your Plan & balance page — navigation and all is fine."
|
|
899
|
+
aria-label="Paste your OpenDesign Plan and balance page"
|
|
900
|
+
rows={3}
|
|
901
|
+
style={{
|
|
902
|
+
width: "100%",
|
|
903
|
+
resize: "vertical",
|
|
904
|
+
padding: "8px 9px",
|
|
905
|
+
"border-radius": radius.sm,
|
|
906
|
+
border: `1px solid ${focused() ? theme.brandTo : theme.border}`,
|
|
907
|
+
background: "transparent",
|
|
908
|
+
color: theme.text,
|
|
909
|
+
"font-size": type.caption,
|
|
910
|
+
"font-family": "inherit",
|
|
911
|
+
"line-height": 1.5,
|
|
912
|
+
"box-sizing": "border-box",
|
|
913
|
+
outline: "none",
|
|
914
|
+
transition: "border-color 120ms ease",
|
|
915
|
+
}}
|
|
916
|
+
/>
|
|
917
|
+
|
|
918
|
+
<Show when={preview()}>
|
|
919
|
+
<Card>
|
|
920
|
+
<span style={{ color: theme.text, "font-size": type.caption, "line-height": 1.5 }}>{preview().summary}</span>
|
|
921
|
+
<Show when={preview().missing?.length > 0}>
|
|
922
|
+
<span style={{ color: theme.warn, "font-size": type.caption }}>
|
|
923
|
+
Not on the page: {preview().missing.join(", ")}
|
|
924
|
+
</span>
|
|
925
|
+
</Show>
|
|
926
|
+
<Show when={preview().reconciliation}>
|
|
927
|
+
<span
|
|
928
|
+
style={{
|
|
929
|
+
color: preview().reconciliation.differ > 0 ? theme.warn : theme.good,
|
|
930
|
+
"font-size": type.caption,
|
|
931
|
+
"line-height": 1.45,
|
|
932
|
+
}}
|
|
933
|
+
>
|
|
934
|
+
{preview().reconciliation.summary}
|
|
935
|
+
</span>
|
|
936
|
+
<For each={preview().reconciliation.rows.filter((row: any) => row.verdict === "differs")}>
|
|
937
|
+
{(row: any) => (
|
|
938
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
939
|
+
{row.modelName}: charged {row.chargedUsd.toFixed(2)}, implied {row.impliedMultiplier?.toFixed(2)}× vs
|
|
940
|
+
catalog {row.catalogMultiplier?.toFixed(2)}×
|
|
941
|
+
</span>
|
|
942
|
+
)}
|
|
943
|
+
</For>
|
|
944
|
+
</Show>
|
|
945
|
+
</Card>
|
|
946
|
+
</Show>
|
|
947
|
+
|
|
948
|
+
<div
|
|
949
|
+
style={{ display: "flex", gap: space.sm }}
|
|
950
|
+
onMouseEnter={() => setHovered(true)}
|
|
951
|
+
onMouseLeave={() => setHovered(false)}
|
|
952
|
+
>
|
|
953
|
+
<Show
|
|
954
|
+
when={preview()}
|
|
955
|
+
fallback={button(busy() ? "Reading…" : "Read page", Boolean(page().trim()) && !busy(), () => void read(false))}
|
|
956
|
+
>
|
|
957
|
+
<Show when={preview().kind === "allowance"}>
|
|
958
|
+
{button(busy() ? "Saving…" : "Use this", !busy(), () => void read(true))}
|
|
959
|
+
</Show>
|
|
960
|
+
{button(preview().kind === "usage" ? "Done" : "Cancel", !busy(), reset)}
|
|
961
|
+
</Show>
|
|
962
|
+
</div>
|
|
963
|
+
|
|
964
|
+
<Show when={status()}>
|
|
965
|
+
<span
|
|
966
|
+
style={{
|
|
967
|
+
color: status()!.ok ? theme.good : theme.bad,
|
|
968
|
+
"font-size": type.caption,
|
|
969
|
+
"line-height": 1.45,
|
|
970
|
+
}}
|
|
971
|
+
>
|
|
972
|
+
{status()!.text}
|
|
973
|
+
</span>
|
|
974
|
+
</Show>
|
|
975
|
+
</Section>
|
|
976
|
+
)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// The panel
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* First run.
|
|
986
|
+
*
|
|
987
|
+
* The rule: show evidence, ask for nothing, offer one way forward.
|
|
988
|
+
*
|
|
989
|
+
* A new user has just installed a router they do not yet trust. A questionnaire
|
|
990
|
+
* asks them to do the work of finding out what is on the machine; this does that
|
|
991
|
+
* and reports back. Most of the time they will read it and press one button,
|
|
992
|
+
* because detection already did the thinking.
|
|
993
|
+
*
|
|
994
|
+
* The screen is a sequence of facts about *their* machine — what was found, what
|
|
995
|
+
* was imported, what it will cost them — and only then a single action. That
|
|
996
|
+
* ordering is the whole design: trust is built by being right about small things
|
|
997
|
+
* before asking for a decision.
|
|
998
|
+
*/
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Open the console, as a button that admits it is working.
|
|
1002
|
+
*
|
|
1003
|
+
* Two problems with the link it replaces.
|
|
1004
|
+
*
|
|
1005
|
+
* It did not look clickable. `↗` on tinted text at 10px reads as a footnote, not
|
|
1006
|
+
* an action, so it needed hover, underline and a border to say so.
|
|
1007
|
+
*
|
|
1008
|
+
* It also hung. An `<a target="_blank">` inside a panel hands the URL to the host
|
|
1009
|
+
* and waits for it to decide, which can take seconds with no feedback — the
|
|
1010
|
+
* exact conditions under which a user concludes the app has frozen. Opening
|
|
1011
|
+
* through `ctx.system.openExternal` is direct, and because this is a button it
|
|
1012
|
+
* can report that something happened.
|
|
1013
|
+
*
|
|
1014
|
+
* The fallback matters more than the happy path: if the system call throws, the
|
|
1015
|
+
* URL is shown so the page can still be reached. An action that fails silently is
|
|
1016
|
+
* worse than one that never existed.
|
|
1017
|
+
*/
|
|
1018
|
+
function OpenConsoleButton() {
|
|
1019
|
+
const ctx = usePlugin<typeof definition>()
|
|
1020
|
+
const [state, setState] = createSignal<"idle" | "opening" | "failed">("idle")
|
|
1021
|
+
const [hovered, setHovered] = createSignal(false)
|
|
1022
|
+
const url = "https://open-design.ai/cloud/dashboard"
|
|
1023
|
+
|
|
1024
|
+
const open = () => {
|
|
1025
|
+
if (state() === "opening") return
|
|
1026
|
+
setState("opening")
|
|
1027
|
+
try {
|
|
1028
|
+
ctx.system.openExternal(url)
|
|
1029
|
+
// There is no completion signal from the OS, so the spinner is cleared on
|
|
1030
|
+
// a timer rather than pretending to know when the browser appeared.
|
|
1031
|
+
setTimeout(() => setState("idle"), 900)
|
|
1032
|
+
} catch {
|
|
1033
|
+
setState("failed")
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return (
|
|
1038
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: space.xs }}>
|
|
1039
|
+
<button
|
|
1040
|
+
type="button"
|
|
1041
|
+
onClick={open}
|
|
1042
|
+
onMouseEnter={() => setHovered(true)}
|
|
1043
|
+
onMouseLeave={() => setHovered(false)}
|
|
1044
|
+
disabled={state() === "opening"}
|
|
1045
|
+
aria-busy={state() === "opening"}
|
|
1046
|
+
title={url}
|
|
1047
|
+
style={{
|
|
1048
|
+
display: "inline-flex",
|
|
1049
|
+
"align-items": "center",
|
|
1050
|
+
gap: "6px",
|
|
1051
|
+
"align-self": "flex-start",
|
|
1052
|
+
padding: "6px 10px",
|
|
1053
|
+
"border-radius": radius.sm,
|
|
1054
|
+
border: `1px solid ${hovered() ? theme.brandTo : theme.borderStrong}`,
|
|
1055
|
+
background: hovered() ? "rgba(8,145,178,0.12)" : "transparent",
|
|
1056
|
+
color: theme.brandTo,
|
|
1057
|
+
"font-size": type.caption,
|
|
1058
|
+
"font-weight": 550,
|
|
1059
|
+
"font-family": "inherit",
|
|
1060
|
+
cursor: state() === "opening" ? "default" : "pointer",
|
|
1061
|
+
"text-decoration": hovered() ? "underline" : "none",
|
|
1062
|
+
"text-underline-offset": "2px",
|
|
1063
|
+
transition: "background 120ms ease, border-color 120ms ease",
|
|
1064
|
+
}}
|
|
1065
|
+
>
|
|
1066
|
+
<span aria-hidden="true" style={{ "font-size": "11px" }}>
|
|
1067
|
+
{state() === "opening" ? "◌" : "↗"}
|
|
1068
|
+
</span>
|
|
1069
|
+
{state() === "opening" ? "Opening…" : "Open Plan & balance"}
|
|
1070
|
+
</button>
|
|
1071
|
+
|
|
1072
|
+
<Show when={state() === "opening"}>
|
|
1073
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
1074
|
+
Handing off to your browser — the page opens in a new tab.
|
|
1075
|
+
</span>
|
|
1076
|
+
</Show>
|
|
1077
|
+
|
|
1078
|
+
<Show when={state() === "failed"}>
|
|
1079
|
+
<span style={{ color: theme.warn, "font-size": type.caption, "line-height": 1.45 }}>
|
|
1080
|
+
Could not open the browser automatically. Open this address yourself:{" "}
|
|
1081
|
+
<span style={{ "font-family": mono, color: theme.text }}>{url}</span>
|
|
1082
|
+
</span>
|
|
1083
|
+
</Show>
|
|
1084
|
+
</div>
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function Onboarding(props: { state: any; onDone: () => void }) {
|
|
1089
|
+
const ctx = usePlugin<typeof definition>()
|
|
1090
|
+
const [busy, setBusy] = createSignal(false)
|
|
1091
|
+
const [hovered, setHovered] = createSignal<string | undefined>(undefined)
|
|
1092
|
+
const [reading, setReading] = createSignal("")
|
|
1093
|
+
const [readingDone, setReadingDone] = createSignal(false)
|
|
1094
|
+
const [readingBusy, setReadingBusy] = createSignal(false)
|
|
1095
|
+
const [readingFocused, setReadingFocused] = createSignal(false)
|
|
1096
|
+
const [readingStatus, setReadingStatus] = createSignal<{ ok: boolean; text: string } | undefined>(undefined)
|
|
1097
|
+
|
|
1098
|
+
/** Is OpenDesign actually configured on this machine? */
|
|
1099
|
+
const hasOpenDesign = () =>
|
|
1100
|
+
(props.state?.providers ?? []).some((provider: any) => provider.providerID === "opendesign" && provider.found)
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Record the pasted page, in place.
|
|
1104
|
+
*
|
|
1105
|
+
* Deliberately not a separate screen: the user is told *why* this matters and
|
|
1106
|
+
* asked for it in the same breath. Moving them somewhere else to do it breaks
|
|
1107
|
+
* the explanation away from the action.
|
|
1108
|
+
*/
|
|
1109
|
+
const recordReading = async (confirm: boolean) => {
|
|
1110
|
+
if (!reading().trim() || readingBusy()) return
|
|
1111
|
+
setReadingBusy(true)
|
|
1112
|
+
try {
|
|
1113
|
+
const result: any = await ctx.client.rpc(SmartRouterRpc).recordPlanPage({
|
|
1114
|
+
page: reading(),
|
|
1115
|
+
confirm: true,
|
|
1116
|
+
})
|
|
1117
|
+
setReadingStatus({ ok: result?.ok !== false, text: result?.message ?? "" })
|
|
1118
|
+
if (result?.ok !== false) setReadingDone(true)
|
|
1119
|
+
} catch (cause) {
|
|
1120
|
+
setReadingStatus({ ok: false, text: cause instanceof Error ? cause.message : String(cause) })
|
|
1121
|
+
} finally {
|
|
1122
|
+
setReadingBusy(false)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const finish = async () => {
|
|
1127
|
+
if (busy()) return
|
|
1128
|
+
setBusy(true)
|
|
1129
|
+
try {
|
|
1130
|
+
await ctx.client.rpc(SmartRouterRpc).completeOnboarding({})
|
|
1131
|
+
props.onDone()
|
|
1132
|
+
} finally {
|
|
1133
|
+
setBusy(false)
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const found = () => (props.state?.providers ?? []).filter((provider: any) => provider.found)
|
|
1138
|
+
const missing = () => (props.state?.providers ?? []).filter((provider: any) => !provider.found)
|
|
1139
|
+
|
|
1140
|
+
return (
|
|
1141
|
+
<div
|
|
1142
|
+
style={{
|
|
1143
|
+
display: "flex",
|
|
1144
|
+
"flex-direction": "column",
|
|
1145
|
+
gap: space.lg,
|
|
1146
|
+
padding: "18px 16px",
|
|
1147
|
+
color: theme.text,
|
|
1148
|
+
"font-size": type.body,
|
|
1149
|
+
height: "100%",
|
|
1150
|
+
"overflow-y": "auto",
|
|
1151
|
+
"box-sizing": "border-box",
|
|
1152
|
+
"font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
|
|
1153
|
+
"-webkit-font-smoothing": "antialiased",
|
|
1154
|
+
}}
|
|
1155
|
+
>
|
|
1156
|
+
{/* Hero. The first screen sets the register: this is a product with a
|
|
1157
|
+
point of view, not a preferences dialog. */}
|
|
1158
|
+
<div
|
|
1159
|
+
style={{
|
|
1160
|
+
display: "flex",
|
|
1161
|
+
"flex-direction": "column",
|
|
1162
|
+
gap: space.sm,
|
|
1163
|
+
"align-items": "center",
|
|
1164
|
+
"text-align": "center",
|
|
1165
|
+
padding: "10px 0 4px",
|
|
1166
|
+
}}
|
|
1167
|
+
>
|
|
1168
|
+
<BrandMark size={44} />
|
|
1169
|
+
<span
|
|
1170
|
+
style={{
|
|
1171
|
+
"font-size": type.display,
|
|
1172
|
+
"font-weight": 700,
|
|
1173
|
+
"letter-spacing": "-0.4px",
|
|
1174
|
+
"line-height": 1.25,
|
|
1175
|
+
}}
|
|
1176
|
+
>
|
|
1177
|
+
Your router is ready
|
|
1178
|
+
</span>
|
|
1179
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5, "max-width": "260px" }}>
|
|
1180
|
+
It found what is already on this machine and configured itself. Here is exactly what it did.
|
|
1181
|
+
</span>
|
|
1182
|
+
</div>
|
|
1183
|
+
|
|
1184
|
+
<Section label="Found on this machine" first>
|
|
1185
|
+
<Show
|
|
1186
|
+
when={found().length > 0}
|
|
1187
|
+
fallback={
|
|
1188
|
+
<Empty
|
|
1189
|
+
title="No provider credentials found"
|
|
1190
|
+
body="Add a key for OpenCode Go, Command Code or OpenDesign in OpenCode, then reopen this panel."
|
|
1191
|
+
/>
|
|
1192
|
+
}
|
|
1193
|
+
>
|
|
1194
|
+
<For each={found()}>
|
|
1195
|
+
{(provider: any) => (
|
|
1196
|
+
<div
|
|
1197
|
+
style={{
|
|
1198
|
+
display: "flex",
|
|
1199
|
+
"justify-content": "space-between",
|
|
1200
|
+
gap: space.md,
|
|
1201
|
+
"align-items": "center",
|
|
1202
|
+
padding: "8px 9px",
|
|
1203
|
+
"border-radius": radius.sm,
|
|
1204
|
+
border: `1px solid ${theme.border}`,
|
|
1205
|
+
background: theme.surface,
|
|
1206
|
+
}}
|
|
1207
|
+
>
|
|
1208
|
+
<span style={{ display: "flex", "flex-direction": "column", gap: "1px", "min-width": 0 }}>
|
|
1209
|
+
<span style={{ "font-size": type.body, "font-weight": 550 }}>{provider.label}</span>
|
|
1210
|
+
<Show when={provider.source}>
|
|
1211
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>{provider.source}</span>
|
|
1212
|
+
</Show>
|
|
1213
|
+
</span>
|
|
1214
|
+
<Chip tone={provider.enabled ? "good" : "neutral"}>
|
|
1215
|
+
{provider.enabled ? "enabled" : "off"}
|
|
1216
|
+
</Chip>
|
|
1217
|
+
</div>
|
|
1218
|
+
)}
|
|
1219
|
+
</For>
|
|
1220
|
+
</Show>
|
|
1221
|
+
</Section>
|
|
1222
|
+
|
|
1223
|
+
{/* What it learned. This is the part that makes the product feel like it
|
|
1224
|
+
did work on the user's behalf rather than asking them to do it. */}
|
|
1225
|
+
<Section
|
|
1226
|
+
label="Learned from your history"
|
|
1227
|
+
aside={
|
|
1228
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
1229
|
+
{props.state?.hasHistory ? "imported" : "none yet"}
|
|
1230
|
+
</span>
|
|
1231
|
+
}
|
|
1232
|
+
>
|
|
1233
|
+
<Show
|
|
1234
|
+
when={props.state?.hasHistory}
|
|
1235
|
+
fallback={
|
|
1236
|
+
<Empty
|
|
1237
|
+
title="Nothing to import yet"
|
|
1238
|
+
body="Once you have used OpenCode, the router reads that history so day one is not a guess."
|
|
1239
|
+
/>
|
|
1240
|
+
}
|
|
1241
|
+
>
|
|
1242
|
+
<Card accent>
|
|
1243
|
+
<span style={{ "font-size": type.body }}>
|
|
1244
|
+
<strong style={{ "font-weight": 650 }}>{props.state.historyImported}</strong>{" "}
|
|
1245
|
+
<span style={{ color: theme.muted }}>
|
|
1246
|
+
request{props.state.historyImported === 1 ? "" : "s"} from OpenCode's own database
|
|
1247
|
+
</span>
|
|
1248
|
+
</span>
|
|
1249
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
|
|
1250
|
+
Each one carries its own timestamp and the model that served it, so the seven-day window starts
|
|
1251
|
+
accurate instead of empty.
|
|
1252
|
+
</span>
|
|
1253
|
+
</Card>
|
|
1254
|
+
</Show>
|
|
1255
|
+
</Section>
|
|
1256
|
+
|
|
1257
|
+
<Section label="What it will do">
|
|
1258
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "5px" }}>
|
|
1259
|
+
<For
|
|
1260
|
+
each={[
|
|
1261
|
+
"Pick the cheapest model that can actually do the job, and say why.",
|
|
1262
|
+
"Stop before an allowance runs out, not after a request fails.",
|
|
1263
|
+
"Count what you spend here, and never guess silently.",
|
|
1264
|
+
]}
|
|
1265
|
+
>
|
|
1266
|
+
{(line) => (
|
|
1267
|
+
<div style={{ display: "flex", gap: space.sm, "align-items": "baseline" }}>
|
|
1268
|
+
<span style={{ color: theme.brandTo, "font-size": type.caption }}>›</span>
|
|
1269
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5 }}>{line}</span>
|
|
1270
|
+
</div>
|
|
1271
|
+
)}
|
|
1272
|
+
</For>
|
|
1273
|
+
</div>
|
|
1274
|
+
</Section>
|
|
1275
|
+
|
|
1276
|
+
{/* The one thing detection cannot know.
|
|
1277
|
+
OpenDesign publishes no usage API, so the router cannot see the account
|
|
1278
|
+
— only its own traffic. Rather than leave that as a footnote, the step
|
|
1279
|
+
is asked for here, once, with the reason stated plainly. Getting this
|
|
1280
|
+
wrong is what made a 42%-used account look 5% used. */}
|
|
1281
|
+
<Show when={hasOpenDesign()}>
|
|
1282
|
+
<Section
|
|
1283
|
+
label="One thing it cannot see"
|
|
1284
|
+
aside={<Chip tone="warn">needed for accuracy</Chip>}
|
|
1285
|
+
>
|
|
1286
|
+
<Card>
|
|
1287
|
+
<span style={{ "font-size": type.body, "font-weight": 550 }}>
|
|
1288
|
+
OpenDesign does not expose usage to an API key
|
|
1289
|
+
</span>
|
|
1290
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.55 }}>
|
|
1291
|
+
So the router can only count what <em>it</em> has routed. Anything you spent before installing
|
|
1292
|
+
it — in the desktop app, or with another tool — is invisible, which makes its estimate far too
|
|
1293
|
+
optimistic.
|
|
1294
|
+
</span>
|
|
1295
|
+
<span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.55 }}>
|
|
1296
|
+
Paste your <strong style={{ color: theme.text }}>Plan & balance</strong> page once and it
|
|
1297
|
+
knows your real position. This is the only page worth pasting: it carries the percentage, which
|
|
1298
|
+
window it belongs to, and when it resets.
|
|
1299
|
+
</span>
|
|
1300
|
+
|
|
1301
|
+
<Show when={!readingDone()}>
|
|
1302
|
+
<OpenConsoleButton />
|
|
1303
|
+
|
|
1304
|
+
<textarea
|
|
1305
|
+
value={reading()}
|
|
1306
|
+
onInput={(event) => setReading((event.currentTarget as HTMLTextAreaElement).value)}
|
|
1307
|
+
placeholder="Paste the whole page here — the navigation and all is fine."
|
|
1308
|
+
aria-label="Paste your OpenDesign Plan and balance page"
|
|
1309
|
+
rows={3}
|
|
1310
|
+
style={{
|
|
1311
|
+
width: "100%",
|
|
1312
|
+
resize: "vertical",
|
|
1313
|
+
padding: "8px 9px",
|
|
1314
|
+
"border-radius": radius.sm,
|
|
1315
|
+
border: `1px solid ${readingFocused() ? theme.brandTo : theme.border}`,
|
|
1316
|
+
background: "transparent",
|
|
1317
|
+
color: theme.text,
|
|
1318
|
+
"font-size": type.caption,
|
|
1319
|
+
"font-family": "inherit",
|
|
1320
|
+
"line-height": 1.5,
|
|
1321
|
+
"box-sizing": "border-box",
|
|
1322
|
+
outline: "none",
|
|
1323
|
+
}}
|
|
1324
|
+
/>
|
|
1325
|
+
<div style={{ display: "flex", gap: space.sm, "align-items": "center" }}>
|
|
1326
|
+
<button
|
|
1327
|
+
type="button"
|
|
1328
|
+
onClick={() => void recordReading(false)}
|
|
1329
|
+
disabled={!reading().trim() || readingBusy()}
|
|
1330
|
+
style={{
|
|
1331
|
+
padding: "5px 11px",
|
|
1332
|
+
"border-radius": radius.sm,
|
|
1333
|
+
border: `1px solid ${theme.borderStrong}`,
|
|
1334
|
+
background: reading().trim() ? theme.raised : "transparent",
|
|
1335
|
+
color: reading().trim() ? theme.text : theme.faint,
|
|
1336
|
+
"font-size": type.caption,
|
|
1337
|
+
"font-family": "inherit",
|
|
1338
|
+
cursor: reading().trim() ? "pointer" : "default",
|
|
1339
|
+
}}
|
|
1340
|
+
>
|
|
1341
|
+
{readingBusy() ? "Reading…" : "Check it"}
|
|
1342
|
+
</button>
|
|
1343
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
1344
|
+
Optional — you can skip and paste later.
|
|
1345
|
+
</span>
|
|
1346
|
+
</div>
|
|
1347
|
+
<Show when={readingStatus()}>
|
|
1348
|
+
{(current) => (
|
|
1349
|
+
<span
|
|
1350
|
+
style={{
|
|
1351
|
+
color: current().ok ? theme.good : theme.bad,
|
|
1352
|
+
"font-size": type.caption,
|
|
1353
|
+
"line-height": 1.45,
|
|
1354
|
+
}}
|
|
1355
|
+
>
|
|
1356
|
+
{current().text}
|
|
1357
|
+
</span>
|
|
1358
|
+
)}
|
|
1359
|
+
</Show>
|
|
1360
|
+
</Show>
|
|
1361
|
+
|
|
1362
|
+
<Show when={readingDone()}>
|
|
1363
|
+
<span style={{ color: theme.good, "font-size": type.caption }}>
|
|
1364
|
+
Recorded. Your allowance now starts from what the account actually shows.
|
|
1365
|
+
</span>
|
|
1366
|
+
</Show>
|
|
1367
|
+
</Card>
|
|
1368
|
+
</Section>
|
|
1369
|
+
</Show>
|
|
1370
|
+
|
|
1371
|
+
<Show when={missing().length > 0 && !hasOpenDesign()}>
|
|
1372
|
+
<Section label="Not needed yet">
|
|
1373
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
|
|
1374
|
+
{missing().length} provider{missing().length === 1 ? "" : "s"} had no credential and stayed off.
|
|
1375
|
+
They are listed under Providers whenever you want them.
|
|
1376
|
+
</span>
|
|
1377
|
+
</Section>
|
|
1378
|
+
</Show>
|
|
1379
|
+
|
|
1380
|
+
{/* One action. A first-run screen with a maze of choices is a settings
|
|
1381
|
+
page pretending to be a welcome. */}
|
|
1382
|
+
<button
|
|
1383
|
+
type="button"
|
|
1384
|
+
onClick={() => void finish()}
|
|
1385
|
+
onMouseEnter={() => setHovered("done")}
|
|
1386
|
+
onMouseLeave={() => setHovered(undefined)}
|
|
1387
|
+
disabled={busy()}
|
|
1388
|
+
style={{
|
|
1389
|
+
"margin-top": "auto",
|
|
1390
|
+
padding: "10px 12px",
|
|
1391
|
+
"border-radius": radius.md,
|
|
1392
|
+
border: "none",
|
|
1393
|
+
background: brandGradient,
|
|
1394
|
+
color: "#fff",
|
|
1395
|
+
"font-size": type.body,
|
|
1396
|
+
"font-weight": 600,
|
|
1397
|
+
font: "inherit",
|
|
1398
|
+
cursor: busy() ? "default" : "pointer",
|
|
1399
|
+
opacity: busy() ? 0.7 : hovered() === "done" ? 0.92 : 1,
|
|
1400
|
+
transition: "opacity 120ms ease",
|
|
1401
|
+
}}
|
|
1402
|
+
>
|
|
1403
|
+
{busy() ? "Starting…" : "Start routing"}
|
|
1404
|
+
</button>
|
|
1405
|
+
</div>
|
|
1406
|
+
)
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function SmartRouterPanel(props: { sessionID: string | undefined }) {
|
|
1410
|
+
const ctx = usePlugin<typeof definition>()
|
|
1411
|
+
const [status, setStatus] = createSignal<RouterStatus | undefined>(undefined)
|
|
1412
|
+
const [error, setError] = createSignal<string | undefined>(undefined)
|
|
1413
|
+
const [at, setAt] = createSignal<number | undefined>(undefined)
|
|
1414
|
+
const [expandedCandidates, setExpandedCandidates] = createSignal(false)
|
|
1415
|
+
const [hovered, setHovered] = createSignal<string | undefined>(undefined)
|
|
1416
|
+
const [onboarding, setOnboarding] = createSignal<any | undefined>(undefined)
|
|
1417
|
+
|
|
1418
|
+
const load = async () => {
|
|
1419
|
+
const sessionID = props.sessionID
|
|
1420
|
+
if (!sessionID) return
|
|
1421
|
+
try {
|
|
1422
|
+
const result = (await ctx.client.rpc(SmartRouterRpc).status({ sessionID })) as RouterStatus
|
|
1423
|
+
setStatus(result)
|
|
1424
|
+
setAt(Date.now())
|
|
1425
|
+
setError(undefined)
|
|
1426
|
+
} catch (cause) {
|
|
1427
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
onMount(() => {
|
|
1432
|
+
void load()
|
|
1433
|
+
/**
|
|
1434
|
+
* Ask about first run, but never wait for the answer.
|
|
1435
|
+
*
|
|
1436
|
+
* This used to be a gate: nothing rendered until the call settled. When the
|
|
1437
|
+
* method did not settle — an older server build that does not know it — the
|
|
1438
|
+
* panel rendered as an empty box forever. An optional check must never be
|
|
1439
|
+
* able to blank the whole surface.
|
|
1440
|
+
*
|
|
1441
|
+
* So the dashboard paints immediately and onboarding replaces it if it
|
|
1442
|
+
* arrives and says this is a fresh install. On a fresh install that swap
|
|
1443
|
+
* happens within a frame or two; everywhere else the answer is "no" and
|
|
1444
|
+
* nothing changes.
|
|
1445
|
+
*/
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
void (async () => {
|
|
1449
|
+
try {
|
|
1450
|
+
const state: any = await Promise.race([
|
|
1451
|
+
ctx.client.rpc(SmartRouterRpc).onboarding({}),
|
|
1452
|
+
// A stuck call must not hold the panel open on a blank state.
|
|
1453
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), 1_500)),
|
|
1454
|
+
])
|
|
1455
|
+
if (state?.firstRun) setOnboarding(state)
|
|
1456
|
+
} catch {
|
|
1457
|
+
// A router that cannot describe itself should still show the panel.
|
|
1458
|
+
}
|
|
1459
|
+
})()
|
|
1460
|
+
const timer = setInterval(() => void load(), 3_000)
|
|
1461
|
+
// The panel only reflects the window-scoped controller's state.
|
|
1462
|
+
onCleanup(() => clearInterval(timer))
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
const view = (): DecisionView | undefined => summariseDecision(status()?.lastDecision as never)
|
|
1467
|
+
const providers = (): ProviderRow[] =>
|
|
1468
|
+
((status()?.quota ?? []) as never[]).map((snapshot) => toProviderRow(snapshot))
|
|
1469
|
+
const needsReading = () => providers().some((provider) => provider.source === "estimate")
|
|
1470
|
+
|
|
1471
|
+
return (
|
|
1472
|
+
<Show
|
|
1473
|
+
when={!onboarding()}
|
|
1474
|
+
fallback={
|
|
1475
|
+
<Onboarding
|
|
1476
|
+
state={onboarding()}
|
|
1477
|
+
onDone={() => {
|
|
1478
|
+
setOnboarding(undefined)
|
|
1479
|
+
void load()
|
|
1480
|
+
}}
|
|
1481
|
+
/>
|
|
1482
|
+
}
|
|
1483
|
+
>
|
|
1484
|
+
<div
|
|
1485
|
+
style={{
|
|
1486
|
+
display: "flex",
|
|
1487
|
+
"flex-direction": "column",
|
|
1488
|
+
gap: space.lg,
|
|
1489
|
+
padding: "14px 14px 18px",
|
|
1490
|
+
color: theme.text,
|
|
1491
|
+
"font-size": type.body,
|
|
1492
|
+
height: "100%",
|
|
1493
|
+
"overflow-y": "auto",
|
|
1494
|
+
"box-sizing": "border-box",
|
|
1495
|
+
"font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
|
|
1496
|
+
"-webkit-font-smoothing": "antialiased",
|
|
1497
|
+
}}
|
|
1498
|
+
>
|
|
1499
|
+
<div style={{ display: "flex", "flex-direction": "column", gap: "3px" }}>
|
|
1500
|
+
<BrandHeader runtime={status()?.runtime ?? "connecting"} mode={status()?.mode ?? "—"} />
|
|
1501
|
+
<div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
|
|
1502
|
+
<span
|
|
1503
|
+
style={{
|
|
1504
|
+
"font-family": mono,
|
|
1505
|
+
"font-size": type.caption,
|
|
1506
|
+
color: status()?.current ? theme.muted : theme.faint,
|
|
1507
|
+
overflow: "hidden",
|
|
1508
|
+
"text-overflow": "ellipsis",
|
|
1509
|
+
"white-space": "nowrap",
|
|
1510
|
+
}}
|
|
1511
|
+
>
|
|
1512
|
+
{status()?.current ? `${status()!.current!.providerID}/${status()!.current!.id}` : "no model yet"}
|
|
1513
|
+
</span>
|
|
1514
|
+
<span style={{ color: theme.faint, "font-size": type.caption, "flex-shrink": 0 }}>
|
|
1515
|
+
{at() ? relative(at()!) : "connecting…"}
|
|
1516
|
+
</span>
|
|
1517
|
+
</div>
|
|
1518
|
+
</div>
|
|
1519
|
+
|
|
1520
|
+
<Show when={error()}>
|
|
1521
|
+
<Card>
|
|
1522
|
+
<span style={{ color: theme.bad, "font-size": type.caption }}>Router unreachable: {error()}</span>
|
|
1523
|
+
<span style={{ color: theme.faint, "font-size": type.caption }}>
|
|
1524
|
+
The plugin may have failed to load. Check Settings → Extensions → Plugins.
|
|
1525
|
+
</span>
|
|
1526
|
+
</Card>
|
|
1527
|
+
</Show>
|
|
1528
|
+
|
|
1529
|
+
<AutoSwitch
|
|
1530
|
+
sessionID={props.sessionID}
|
|
1531
|
+
auto={(status()?.runtime ?? "automatic") === "automatic"}
|
|
1532
|
+
onChanged={() => void load()}
|
|
1533
|
+
/>
|
|
1534
|
+
|
|
1535
|
+
<DecisionInspector
|
|
1536
|
+
view={view()}
|
|
1537
|
+
expanded={expandedCandidates()}
|
|
1538
|
+
onToggle={() => setExpandedCandidates((value) => !value)}
|
|
1539
|
+
hovered={hovered()}
|
|
1540
|
+
onHover={setHovered}
|
|
1541
|
+
/>
|
|
1542
|
+
|
|
1543
|
+
<Allowances providers={providers()} />
|
|
1544
|
+
|
|
1545
|
+
<Show when={needsReading()}>
|
|
1546
|
+
<AccountPaste onChanged={() => void load()} />
|
|
1547
|
+
</Show>
|
|
1548
|
+
|
|
1549
|
+
<ProviderList status={status()} />
|
|
1550
|
+
</div>
|
|
1551
|
+
</Show>
|
|
1552
|
+
)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const tab: PanelTab = {
|
|
1556
|
+
id: "router",
|
|
1557
|
+
get title() {
|
|
1558
|
+
return "Router"
|
|
1559
|
+
},
|
|
1560
|
+
closable: "hover",
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const definition = Plugin.define({
|
|
1564
|
+
i18n: {
|
|
1565
|
+
en: {
|
|
1566
|
+
"command.open": "Open Smart Router",
|
|
1567
|
+
"panel.title": "Router",
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* The tab id and the id the command toggles must be the same string:
|
|
1574
|
+
* `ctx.layout.toggle()` keys on `${plugin}:${tab id}` and `list()` receives the
|
|
1575
|
+
* stored *tab* ids. They disagreed once and the side region opened empty.
|
|
1576
|
+
*/
|
|
1577
|
+
export default Plugin.define({
|
|
1578
|
+
...definition,
|
|
1579
|
+
setup(ctx) {
|
|
1580
|
+
ctx.add(Command, () => ({
|
|
1581
|
+
id: "panel",
|
|
1582
|
+
title: ctx.t("command.open"),
|
|
1583
|
+
group: "Smart Router",
|
|
1584
|
+
bind: "mod+shift+r",
|
|
1585
|
+
slash: { name: "router-gui" },
|
|
1586
|
+
run: () => {
|
|
1587
|
+
const session = ctx.sessions.current()
|
|
1588
|
+
if (session) ctx.layout.toggle(`${ctx.id}:router`, session)
|
|
1589
|
+
},
|
|
1590
|
+
}))
|
|
1591
|
+
|
|
1592
|
+
ctx.add(Panel, {
|
|
1593
|
+
id: "router",
|
|
1594
|
+
region: "side",
|
|
1595
|
+
list: (input) => (input.open.includes("router") ? [tab] : []),
|
|
1596
|
+
render: (props: PanelProps) => <SmartRouterPanel sessionID={props.session.id} />,
|
|
1597
|
+
})
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Open the panel by itself, once, when a session first becomes available.
|
|
1601
|
+
*
|
|
1602
|
+
* Two constraints shape this.
|
|
1603
|
+
*
|
|
1604
|
+
* A side panel belongs to a session — `sessions.current()` is undefined on
|
|
1605
|
+
* Home and on a draft — so there is nothing to attach to until the user opens
|
|
1606
|
+
* a conversation. That is why this cannot fire at launch.
|
|
1607
|
+
*
|
|
1608
|
+
* And it must not fight the user. Closing the panel is a decision, so this
|
|
1609
|
+
* runs at most once per window and only when the tab is genuinely closed. The
|
|
1610
|
+
* `layout.ready()` guard matters: before the layout loads, every read reports
|
|
1611
|
+
* an empty layout, so acting earlier would reopen a tab somebody had
|
|
1612
|
+
* deliberately closed in a previous session.
|
|
1613
|
+
*/
|
|
1614
|
+
const panelKey = `${ctx.id}:router`
|
|
1615
|
+
/**
|
|
1616
|
+
* Open once per session, not once per window.
|
|
1617
|
+
*
|
|
1618
|
+
* The first version wrote a single `autoOpened` flag and gave up after thirty
|
|
1619
|
+
* seconds. Both were wrong for the ordinary sequence — launch the app, look
|
|
1620
|
+
* around on Home, then open a session — because no session exists for the
|
|
1621
|
+
* first thirty seconds, and the flag could never be reset for the next one.
|
|
1622
|
+
*
|
|
1623
|
+
* Keying on the session means each conversation gets the panel once, and
|
|
1624
|
+
* closing it stays closed for that conversation.
|
|
1625
|
+
*/
|
|
1626
|
+
const openedFor = new Set<string>()
|
|
1627
|
+
/**
|
|
1628
|
+
* Every step is wrapped: this runs inside `setup`, and a GUI whose setup throws
|
|
1629
|
+
* does not lose its panel, it loses the plugin.
|
|
1630
|
+
*/
|
|
1631
|
+
const tryAutoOpen = () => {
|
|
1632
|
+
try {
|
|
1633
|
+
if (!ctx.layout.ready()) return
|
|
1634
|
+
const session = ctx.sessions.current()
|
|
1635
|
+
if (!session) return
|
|
1636
|
+
if (openedFor.has(session.key)) return
|
|
1637
|
+
// Before the stored layout loads every read reports an empty layout, so
|
|
1638
|
+
// this must not act until `ready()` is true or it reopens a tab the user
|
|
1639
|
+
// deliberately closed.
|
|
1640
|
+
if (ctx.layout.state(panelKey, session) !== "closed") {
|
|
1641
|
+
openedFor.add(session.key)
|
|
1642
|
+
return
|
|
1643
|
+
}
|
|
1644
|
+
openedFor.add(session.key)
|
|
1645
|
+
ctx.layout.open(panelKey, session)
|
|
1646
|
+
} catch {
|
|
1647
|
+
// Auto-open is best effort; never surface as a plugin failure.
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* A slow poll rather than an event, because there is no session-routed event
|
|
1652
|
+
* to subscribe to and 800ms costs nothing. It runs for the life of the window
|
|
1653
|
+
* instead of stopping: the window is what owns the panel, and the user may
|
|
1654
|
+
* open their first session minutes after launch.
|
|
1655
|
+
*/
|
|
1656
|
+
const autoOpenTimer = setInterval(tryAutoOpen, 800)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
ctx.add(Slot, {
|
|
1660
|
+
at: "window.bottom",
|
|
1661
|
+
render: () => (
|
|
1662
|
+
<span style={{ display: "inline-flex", "align-items": "center", gap: "5px" }}>
|
|
1663
|
+
<BrandMark size={12} />
|
|
1664
|
+
<span style={{ "font-size": type.caption, color: theme.faint }}>Smart Router</span>
|
|
1665
|
+
</span>
|
|
1666
|
+
),
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
// Cleanup: the interval belongs to this plugin's lifetime. Placed after every
|
|
1670
|
+
// registration, because a return before `ctx.add` would skip that contribution.
|
|
1671
|
+
return () => {
|
|
1672
|
+
clearInterval(autoOpenTimer)
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
})
|