@schlessera/brain-ui-react 0.21.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/chat/message-bubble.js +10 -1
- package/dist/components/chat/message-bubble.js.map +1 -1
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -1
- package/dist/components/chat/renderers/claude-tools.js +21 -1
- package/dist/components/chat/renderers/claude-tools.js.map +1 -1
- package/dist/components/chat/renderers/index.d.ts.map +1 -1
- package/dist/components/chat/renderers/index.js +2 -0
- package/dist/components/chat/renderers/index.js.map +1 -1
- package/dist/components/chat/renderers/pi-tools.d.ts +3 -0
- package/dist/components/chat/renderers/pi-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/pi-tools.js +112 -0
- package/dist/components/chat/renderers/pi-tools.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +14 -2
- package/dist/components/chat/risk-hints.d.ts.map +1 -1
- package/dist/components/chat/risk-hints.js +54 -19
- package/dist/components/chat/risk-hints.js.map +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +25 -14
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +49 -2
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/components/settings/pi-accounts.d.ts +13 -0
- package/dist/components/settings/pi-accounts.d.ts.map +1 -0
- package/dist/components/settings/pi-accounts.js +151 -0
- package/dist/components/settings/pi-accounts.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +26 -2
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/lib/api-client.d.ts +46 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +28 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/tool-names.d.ts.map +1 -1
- package/dist/lib/tool-names.js +5 -1
- package/dist/lib/tool-names.js.map +1 -1
- package/dist/stores/chat-store.d.ts +8 -0
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +4 -0
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/message-bubble.tsx +9 -1
- package/src/components/chat/renderers/claude-tools.tsx +32 -1
- package/src/components/chat/renderers/index.ts +2 -0
- package/src/components/chat/renderers/pi-tools.tsx +144 -0
- package/src/components/chat/risk-hints.ts +73 -25
- package/src/components/chat/tool-call-timeline.tsx +57 -16
- package/src/components/settings/models-tab.tsx +154 -1
- package/src/components/settings/pi-accounts.tsx +264 -0
- package/src/hooks/use-websocket.ts +29 -2
- package/src/lib/api-client.ts +66 -0
- package/src/lib/tool-names.ts +6 -1
- package/src/stores/chat-store.ts +16 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { Eye, EyeOff, Loader2, RefreshCw } from "lucide-react";
|
|
2
|
+
import { Eye, EyeOff, Loader2, RefreshCw, X } from "lucide-react";
|
|
3
3
|
import type {
|
|
4
4
|
BillingMode,
|
|
5
5
|
ModelCatalogEntry,
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { api } from "../../lib/api-client.js";
|
|
9
9
|
import { useProviderStore } from "../../stores/provider-store.js";
|
|
10
10
|
import { cn } from "../../lib/utils.js";
|
|
11
|
+
import { PiAccountsSection } from "./pi-accounts.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The model picker's contents, and which of them to show.
|
|
@@ -131,6 +132,22 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
131
132
|
);
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
async function changeDefault(next: string | null) {
|
|
136
|
+
if (!catalog) return;
|
|
137
|
+
await commitCatalog(
|
|
138
|
+
{ ...catalog, defaultModelId: next },
|
|
139
|
+
() => api.setDefaultModel(next)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function setCustom(models: string[]) {
|
|
144
|
+
if (!catalog) return;
|
|
145
|
+
await commitCatalog(
|
|
146
|
+
{ ...catalog, customModels: models },
|
|
147
|
+
() => api.setCustomModels(models)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
async function onRefresh() {
|
|
135
152
|
if (refreshing) return;
|
|
136
153
|
setRefreshing(true);
|
|
@@ -160,6 +177,10 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
160
177
|
ones you never pick — running sessions are unaffected.
|
|
161
178
|
</p>
|
|
162
179
|
|
|
180
|
+
{catalog && (
|
|
181
|
+
<DefaultModelSelect catalog={catalog} onChange={changeDefault} />
|
|
182
|
+
)}
|
|
183
|
+
|
|
163
184
|
{loading ? (
|
|
164
185
|
<div className="mt-6 flex justify-center">
|
|
165
186
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
@@ -200,6 +221,15 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
200
221
|
profiles are listed.
|
|
201
222
|
</p>
|
|
202
223
|
)}
|
|
224
|
+
|
|
225
|
+
{catalog && (
|
|
226
|
+
<OpenRouterSection
|
|
227
|
+
models={catalog.customModels ?? []}
|
|
228
|
+
onChange={setCustom}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<PiAccountsSection active={active} />
|
|
203
233
|
</div>
|
|
204
234
|
|
|
205
235
|
<div className="flex items-center justify-between gap-3 border-t border-border p-4">
|
|
@@ -219,6 +249,129 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
219
249
|
);
|
|
220
250
|
}
|
|
221
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The default-model choice: which profile answers when a turn names none —
|
|
254
|
+
* a fresh device's first conversation, a share filed into the brain, any
|
|
255
|
+
* host-initiated action. "Auto" prefers a connected subscription account
|
|
256
|
+
* (e.g. ChatGPT for the gpt profiles) and falls back to the built-in default.
|
|
257
|
+
*/
|
|
258
|
+
function DefaultModelSelect({
|
|
259
|
+
catalog,
|
|
260
|
+
onChange,
|
|
261
|
+
}: {
|
|
262
|
+
catalog: ModelCatalogResponse;
|
|
263
|
+
onChange: (next: string | null) => void;
|
|
264
|
+
}) {
|
|
265
|
+
const stored = catalog.defaultModelId ?? null;
|
|
266
|
+
const byId = new Map(catalog.models.map((model) => [model.id, model]));
|
|
267
|
+
const resolved = catalog.resolvedDefaultId
|
|
268
|
+
? byId.get(catalog.resolvedDefaultId)
|
|
269
|
+
: undefined;
|
|
270
|
+
// Offer everything visible, plus a stored default that has since been
|
|
271
|
+
// hidden (dropping it from the list would silently rewrite the choice).
|
|
272
|
+
const options = catalog.models.filter(
|
|
273
|
+
(model) => !model.hidden || model.id === stored
|
|
274
|
+
);
|
|
275
|
+
return (
|
|
276
|
+
<div className="mt-4 flex items-center gap-3 rounded-lg border border-border-subtle bg-surface p-3">
|
|
277
|
+
<div className="min-w-0 flex-1">
|
|
278
|
+
<p className="text-sm text-foreground">Default model</p>
|
|
279
|
+
<p className="text-[11px] text-muted-foreground">
|
|
280
|
+
Used for new conversations, shares, and actions that don't pick one.
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<select
|
|
284
|
+
value={stored ?? "auto"}
|
|
285
|
+
onChange={(e) => onChange(e.target.value === "auto" ? null : e.target.value)}
|
|
286
|
+
aria-label="Default model"
|
|
287
|
+
className="h-8 max-w-[45%] shrink-0 truncate rounded-lg border border-border-subtle bg-surface px-1.5 text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
|
|
288
|
+
>
|
|
289
|
+
<option value="auto">
|
|
290
|
+
{!stored && resolved ? `Auto (${resolved.label})` : "Auto"}
|
|
291
|
+
</option>
|
|
292
|
+
{options.map((model) => (
|
|
293
|
+
<option key={model.id} value={model.id}>
|
|
294
|
+
{model.label}
|
|
295
|
+
</option>
|
|
296
|
+
))}
|
|
297
|
+
</select>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* User-managed OpenRouter models: added and removed here by model id, no env
|
|
304
|
+
* change or redeploy. They appear in the roster above as ordinary profiles
|
|
305
|
+
* (api-billed via OPENROUTER_API_KEY).
|
|
306
|
+
*/
|
|
307
|
+
function OpenRouterSection({
|
|
308
|
+
models,
|
|
309
|
+
onChange,
|
|
310
|
+
}: {
|
|
311
|
+
models: string[];
|
|
312
|
+
onChange: (models: string[]) => void;
|
|
313
|
+
}) {
|
|
314
|
+
const [draft, setDraft] = useState("");
|
|
315
|
+
|
|
316
|
+
function add() {
|
|
317
|
+
const id = draft.trim();
|
|
318
|
+
if (!id || models.includes(id)) return;
|
|
319
|
+
onChange([...models, id]);
|
|
320
|
+
setDraft("");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className="mt-6">
|
|
325
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
326
|
+
OpenRouter models
|
|
327
|
+
</h3>
|
|
328
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
329
|
+
Add any OpenRouter model by its id (needs OPENROUTER_API_KEY on the
|
|
330
|
+
server). Added models join the list above.
|
|
331
|
+
</p>
|
|
332
|
+
{models.length > 0 && (
|
|
333
|
+
<ul className="mt-3 flex flex-col gap-1.5">
|
|
334
|
+
{models.map((model) => (
|
|
335
|
+
<li
|
|
336
|
+
key={model}
|
|
337
|
+
className="flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2"
|
|
338
|
+
>
|
|
339
|
+
<span className="min-w-0 flex-1 truncate font-[family-name:var(--font-mono)] text-xs text-foreground">
|
|
340
|
+
{model}
|
|
341
|
+
</span>
|
|
342
|
+
<button
|
|
343
|
+
onClick={() => onChange(models.filter((m) => m !== model))}
|
|
344
|
+
title={`Remove ${model}`}
|
|
345
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-surface-raised hover:text-destructive"
|
|
346
|
+
>
|
|
347
|
+
<X className="h-3.5 w-3.5" />
|
|
348
|
+
</button>
|
|
349
|
+
</li>
|
|
350
|
+
))}
|
|
351
|
+
</ul>
|
|
352
|
+
)}
|
|
353
|
+
<div className="mt-3 flex gap-2">
|
|
354
|
+
<input
|
|
355
|
+
value={draft}
|
|
356
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
357
|
+
onKeyDown={(e) => {
|
|
358
|
+
if (e.key === "Enter") add();
|
|
359
|
+
}}
|
|
360
|
+
placeholder="z.ai/glm-5.3-flash"
|
|
361
|
+
className="h-8 min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface px-2 font-[family-name:var(--font-mono)] text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none"
|
|
362
|
+
/>
|
|
363
|
+
<button
|
|
364
|
+
onClick={add}
|
|
365
|
+
disabled={!draft.trim()}
|
|
366
|
+
className="h-8 shrink-0 rounded-lg border border-border-subtle bg-surface px-3 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
367
|
+
>
|
|
368
|
+
Add
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
222
375
|
/**
|
|
223
376
|
* Request-ordering guard for optimistic commits: `begin()` claims a token
|
|
224
377
|
* and returns a predicate that holds only while no later request has begun.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Check, ExternalLink, Loader2, LogOut, X } from "lucide-react";
|
|
3
|
+
import { api, type PiAuthProviderStatus, type PiLoginFlow } from "../../lib/api-client.js";
|
|
4
|
+
import { useProviderStore } from "../../stores/provider-store.js";
|
|
5
|
+
import { cn } from "../../lib/utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Provider sign-in for the pi backend's OAuth vendors — most importantly
|
|
9
|
+
* OpenAI (ChatGPT Plus/Pro), whose device-code flow needs no browser callback
|
|
10
|
+
* on the server: the user gets a short code here, enters it at the provider's
|
|
11
|
+
* verification page on ANY device, and the server stores the credential.
|
|
12
|
+
*
|
|
13
|
+
* Renders nothing when the server reports no pi providers (pi not
|
|
14
|
+
* configured), so the Models tab is unchanged for Claude-only deployments.
|
|
15
|
+
*/
|
|
16
|
+
export function PiAccountsSection({ active }: { active: boolean }) {
|
|
17
|
+
const [providers, setProviders] = useState<PiAuthProviderStatus[]>([]);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
20
|
+
const [flow, setFlow] = useState<PiLoginFlow | null>(null);
|
|
21
|
+
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
|
+
|
|
23
|
+
function stopPolling() {
|
|
24
|
+
if (pollTimer.current) {
|
|
25
|
+
clearTimeout(pollTimer.current);
|
|
26
|
+
pollTimer.current = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function reload() {
|
|
31
|
+
try {
|
|
32
|
+
const { providers } = await api.piAuthProviders();
|
|
33
|
+
setProviders(providers);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
setError(err instanceof Error ? err.message : "Could not load accounts");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!active) return;
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
api
|
|
43
|
+
.piAuthProviders()
|
|
44
|
+
.then(({ providers }) => {
|
|
45
|
+
if (!cancelled) setProviders(providers);
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
// A server without the endpoint (older release) just hides the card.
|
|
49
|
+
if (!cancelled) setProviders([]);
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [active]);
|
|
55
|
+
|
|
56
|
+
// Poll the pending flow until it settles. Chained timeouts rather than an
|
|
57
|
+
// interval, so a slow response never stacks requests.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!flow || flow.status !== "pending") return;
|
|
60
|
+
let disposed = false;
|
|
61
|
+
const delayMs = (flow.intervalSeconds ?? 5) * 1000;
|
|
62
|
+
const tick = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const { flow: next } = await api.piAuthFlow(flow.id);
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
setFlow(next);
|
|
67
|
+
if (next.status === "success") {
|
|
68
|
+
void reload();
|
|
69
|
+
// A connected subscription account can change the picker's default
|
|
70
|
+
// ordering — refresh the composer's roster copy too.
|
|
71
|
+
void useProviderStore.getState().loadProviders();
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
if (disposed) return;
|
|
75
|
+
// Transient poll failure: keep trying until the flow expires.
|
|
76
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
81
|
+
return () => {
|
|
82
|
+
disposed = true;
|
|
83
|
+
stopPolling();
|
|
84
|
+
};
|
|
85
|
+
}, [flow]);
|
|
86
|
+
|
|
87
|
+
async function connect(providerId: string) {
|
|
88
|
+
setBusy(providerId);
|
|
89
|
+
setError(null);
|
|
90
|
+
try {
|
|
91
|
+
const { flow } = await api.piAuthStart(providerId);
|
|
92
|
+
setFlow(flow);
|
|
93
|
+
if (flow.status === "error") setError(flow.error ?? "Login failed");
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setError(err instanceof Error ? err.message : "Could not start login");
|
|
96
|
+
} finally {
|
|
97
|
+
setBusy(null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function cancel() {
|
|
102
|
+
if (!flow) return;
|
|
103
|
+
stopPolling();
|
|
104
|
+
try {
|
|
105
|
+
await api.piAuthCancel(flow.id);
|
|
106
|
+
} catch {
|
|
107
|
+
// The flow record may already be gone; clearing locally is enough.
|
|
108
|
+
}
|
|
109
|
+
setFlow(null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function disconnect(providerId: string) {
|
|
113
|
+
setBusy(providerId);
|
|
114
|
+
setError(null);
|
|
115
|
+
try {
|
|
116
|
+
await api.piAuthLogout(providerId);
|
|
117
|
+
await reload();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : "Could not disconnect");
|
|
120
|
+
} finally {
|
|
121
|
+
setBusy(null);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (providers.length === 0) return null;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="mt-6">
|
|
129
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
130
|
+
Accounts
|
|
131
|
+
</h3>
|
|
132
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
133
|
+
Model providers that sign in with an account instead of an API key.
|
|
134
|
+
</p>
|
|
135
|
+
<ul className="mt-4 flex flex-col gap-2">
|
|
136
|
+
{providers.map((provider) => (
|
|
137
|
+
<li
|
|
138
|
+
key={provider.providerId}
|
|
139
|
+
className="rounded-lg border border-border-subtle bg-surface p-3"
|
|
140
|
+
>
|
|
141
|
+
<div className="flex items-center gap-3">
|
|
142
|
+
<div className="min-w-0 flex-1">
|
|
143
|
+
<p className="truncate text-sm text-foreground">{provider.name}</p>
|
|
144
|
+
<p className="truncate text-[11px] text-muted-foreground">
|
|
145
|
+
{provider.configured
|
|
146
|
+
? `Connected${provider.source ? ` · ${provider.source}` : ""}`
|
|
147
|
+
: "Not connected"}
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
{provider.configured && (
|
|
151
|
+
<Check className="h-4 w-4 shrink-0 text-accent" />
|
|
152
|
+
)}
|
|
153
|
+
{provider.oauth && !provider.configured && (
|
|
154
|
+
<button
|
|
155
|
+
onClick={() => connect(provider.providerId)}
|
|
156
|
+
disabled={busy !== null || flow?.status === "pending"}
|
|
157
|
+
className="shrink-0 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
158
|
+
>
|
|
159
|
+
{busy === provider.providerId ? (
|
|
160
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
161
|
+
) : (
|
|
162
|
+
"Connect"
|
|
163
|
+
)}
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
{provider.configured && provider.source === "stored" && (
|
|
167
|
+
<button
|
|
168
|
+
onClick={() => disconnect(provider.providerId)}
|
|
169
|
+
disabled={busy !== null}
|
|
170
|
+
title="Disconnect"
|
|
171
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-raised hover:text-destructive"
|
|
172
|
+
>
|
|
173
|
+
<LogOut className="h-4 w-4" />
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{flow && flow.providerId === provider.providerId && (
|
|
179
|
+
<LoginFlowCard flow={flow} onCancel={cancel} onDismiss={() => setFlow(null)} />
|
|
180
|
+
)}
|
|
181
|
+
</li>
|
|
182
|
+
))}
|
|
183
|
+
</ul>
|
|
184
|
+
{error && (
|
|
185
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
186
|
+
{error}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function LoginFlowCard({
|
|
194
|
+
flow,
|
|
195
|
+
onCancel,
|
|
196
|
+
onDismiss,
|
|
197
|
+
}: {
|
|
198
|
+
flow: PiLoginFlow;
|
|
199
|
+
onCancel: () => void;
|
|
200
|
+
onDismiss: () => void;
|
|
201
|
+
}) {
|
|
202
|
+
if (flow.status === "pending") {
|
|
203
|
+
return (
|
|
204
|
+
<div className="mt-3 rounded-lg border border-primary/40 bg-primary/5 p-3">
|
|
205
|
+
<p className="text-xs text-muted-foreground">
|
|
206
|
+
Enter this code at the provider's device page — on this or any other
|
|
207
|
+
device:
|
|
208
|
+
</p>
|
|
209
|
+
<p className="mt-2 select-all text-center font-[family-name:var(--font-mono)] text-xl font-semibold tracking-widest text-foreground">
|
|
210
|
+
{flow.userCode ?? "…"}
|
|
211
|
+
</p>
|
|
212
|
+
<div className="mt-3 flex items-center justify-center gap-2">
|
|
213
|
+
{flow.verificationUri && (
|
|
214
|
+
<a
|
|
215
|
+
href={flow.verificationUri}
|
|
216
|
+
target="_blank"
|
|
217
|
+
rel="noreferrer"
|
|
218
|
+
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110"
|
|
219
|
+
>
|
|
220
|
+
<ExternalLink className="h-3 w-3" />
|
|
221
|
+
Open verification page
|
|
222
|
+
</a>
|
|
223
|
+
)}
|
|
224
|
+
<button
|
|
225
|
+
onClick={onCancel}
|
|
226
|
+
className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
227
|
+
>
|
|
228
|
+
<X className="h-3 w-3" />
|
|
229
|
+
Cancel
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
<p className="mt-2 flex items-center justify-center gap-1.5 text-center text-[11px] text-muted-foreground">
|
|
233
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
234
|
+
Waiting for approval…
|
|
235
|
+
</p>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const message =
|
|
241
|
+
flow.status === "success"
|
|
242
|
+
? "Connected."
|
|
243
|
+
: flow.status === "cancelled"
|
|
244
|
+
? "Login cancelled."
|
|
245
|
+
: flow.error ?? "Login failed.";
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
className={cn(
|
|
249
|
+
"mt-3 flex items-center justify-between rounded-lg border p-3 text-xs",
|
|
250
|
+
flow.status === "success"
|
|
251
|
+
? "border-border-subtle text-muted-foreground"
|
|
252
|
+
: "border-destructive/30 text-destructive"
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<span>{message}</span>
|
|
256
|
+
<button
|
|
257
|
+
onClick={onDismiss}
|
|
258
|
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
259
|
+
>
|
|
260
|
+
<X className="h-3.5 w-3.5" />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -60,6 +60,15 @@ function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
|
|
|
60
60
|
* payload, so an answered question survives session resume (rendered in its
|
|
61
61
|
* chronological slot, collapsed) instead of vanishing.
|
|
62
62
|
*/
|
|
63
|
+
function isBareAnswersMap(v: unknown): v is Record<string, string> {
|
|
64
|
+
return (
|
|
65
|
+
!!v &&
|
|
66
|
+
typeof v === "object" &&
|
|
67
|
+
!Array.isArray(v) &&
|
|
68
|
+
Object.values(v).every((x) => typeof x === "string")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
function reconstructAskUserExchanges(
|
|
64
73
|
toolCalls: SessionHistoryMessage["toolCalls"]
|
|
65
74
|
): AskUserExchange[] | undefined {
|
|
@@ -74,8 +83,14 @@ function reconstructAskUserExchanges(
|
|
|
74
83
|
answers?: Record<string, string>;
|
|
75
84
|
annotations?: AskUserExchange["annotations"];
|
|
76
85
|
};
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (payload && typeof payload === "object" && payload.answers) {
|
|
87
|
+
exchange.answers = payload.answers;
|
|
88
|
+
exchange.annotations = payload.annotations;
|
|
89
|
+
} else if (isBareAnswersMap(payload)) {
|
|
90
|
+
// The pi backend persists the bare answers map, without the
|
|
91
|
+
// `{answers}` envelope the Claude tool writes.
|
|
92
|
+
exchange.answers = payload;
|
|
93
|
+
}
|
|
79
94
|
} catch {
|
|
80
95
|
// Non-JSON output means the question was dismissed or errored out.
|
|
81
96
|
exchange.cancelled = true;
|
|
@@ -281,6 +296,18 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
281
296
|
|
|
282
297
|
case "session_info": {
|
|
283
298
|
ensureActivitySubscription(msg.sessionId);
|
|
299
|
+
// Record backend ownership for renderer scoping — for ANY session, since
|
|
300
|
+
// background sessions keep their own transcript buffers. Older servers
|
|
301
|
+
// omit backendId; derive it from the pinned profile when still possible
|
|
302
|
+
// (fails only for since-hidden profiles, which then use the default).
|
|
303
|
+
const ownerBackendId =
|
|
304
|
+
msg.backendId ??
|
|
305
|
+
useProviderStore
|
|
306
|
+
.getState()
|
|
307
|
+
.available.find((p) => p.id === msg.providerId)?.backendId;
|
|
308
|
+
if (ownerBackendId) {
|
|
309
|
+
useChatStore.getState().setSessionBackend(msg.sessionId, ownerBackendId);
|
|
310
|
+
}
|
|
284
311
|
// bindDraftSession above handled draft adoption; an info frame may still
|
|
285
312
|
// re-pin the provider picker when it concerns the session in view.
|
|
286
313
|
const current = useChatStore.getState();
|
package/src/lib/api-client.ts
CHANGED
|
@@ -80,6 +80,30 @@ export interface BackendInfo {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** Auth status of one configured pi provider (mirror of the server view). */
|
|
84
|
+
export interface PiAuthProviderStatus {
|
|
85
|
+
providerId: string;
|
|
86
|
+
/** Human label for the credential, e.g. "OpenAI (ChatGPT Plus/Pro)". */
|
|
87
|
+
name: string;
|
|
88
|
+
configured: boolean;
|
|
89
|
+
source?: string;
|
|
90
|
+
/** Whether this provider can be signed in through the UI. */
|
|
91
|
+
oauth: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One OAuth device-code login flow (mirror of the server view). */
|
|
95
|
+
export interface PiLoginFlow {
|
|
96
|
+
id: string;
|
|
97
|
+
providerId: string;
|
|
98
|
+
status: "pending" | "success" | "error" | "cancelled";
|
|
99
|
+
userCode?: string;
|
|
100
|
+
verificationUri?: string;
|
|
101
|
+
intervalSeconds?: number;
|
|
102
|
+
expiresInSeconds?: number;
|
|
103
|
+
error?: string;
|
|
104
|
+
startedAt: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
84
108
|
const res = await fetch(`${apiBase()}${path}`, {
|
|
85
109
|
...init,
|
|
@@ -209,6 +233,48 @@ export const api = {
|
|
|
209
233
|
/** Pricing-table freshness for the Activity staleness indicator (see `PricingState`). */
|
|
210
234
|
pricingState: () => fetchJson<PricingState>("/models/pricing"),
|
|
211
235
|
|
|
236
|
+
/** Auth status of configured pi providers; empty when pi is not in play. */
|
|
237
|
+
piAuthProviders: () =>
|
|
238
|
+
fetchJson<{ providers: PiAuthProviderStatus[] }>("/pi-auth/providers"),
|
|
239
|
+
|
|
240
|
+
/** Start an OAuth device-code login; resolves once the user code exists. */
|
|
241
|
+
piAuthStart: (providerId: string) =>
|
|
242
|
+
fetchJson<{ flow: PiLoginFlow }>("/pi-auth/login", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
body: JSON.stringify({ providerId }),
|
|
245
|
+
}),
|
|
246
|
+
|
|
247
|
+
/** Poll one login flow. */
|
|
248
|
+
piAuthFlow: (id: string) =>
|
|
249
|
+
fetchJson<{ flow: PiLoginFlow }>(`/pi-auth/login/${encodeURIComponent(id)}`),
|
|
250
|
+
|
|
251
|
+
/** Abort a pending login flow. */
|
|
252
|
+
piAuthCancel: (id: string) =>
|
|
253
|
+
fetchJson<{ ok: boolean }>(`/pi-auth/login/${encodeURIComponent(id)}`, {
|
|
254
|
+
method: "DELETE",
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
/** Remove the stored credential for a pi provider. */
|
|
258
|
+
piAuthLogout: (providerId: string) =>
|
|
259
|
+
fetchJson<{ ok: boolean }>("/pi-auth/logout", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
body: JSON.stringify({ providerId }),
|
|
262
|
+
}),
|
|
263
|
+
|
|
264
|
+
/** Set the default model (a profile id, or null for auto); returns the new catalog. */
|
|
265
|
+
setDefaultModel: (defaultId: string | null) =>
|
|
266
|
+
fetchJson<ModelCatalogResponse>("/models/default", {
|
|
267
|
+
method: "PUT",
|
|
268
|
+
body: JSON.stringify({ defaultId }),
|
|
269
|
+
}),
|
|
270
|
+
|
|
271
|
+
/** Replace the custom OpenRouter model list (full list, not a delta). */
|
|
272
|
+
setCustomModels: (models: string[]) =>
|
|
273
|
+
fetchJson<ModelCatalogResponse>("/models/custom", {
|
|
274
|
+
method: "PUT",
|
|
275
|
+
body: JSON.stringify({ models }),
|
|
276
|
+
}),
|
|
277
|
+
|
|
212
278
|
/** Force a discovery refresh, bypassing the TTL. */
|
|
213
279
|
refreshModels: () =>
|
|
214
280
|
fetchJson<ModelCatalogResponse>("/models/refresh", { method: "POST" }),
|
package/src/lib/tool-names.ts
CHANGED
|
@@ -21,7 +21,12 @@ export function normalizeToolName(name: string): string {
|
|
|
21
21
|
: name;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// The pi backend registers its ask-user tool under the bare name.
|
|
25
|
+
const PI_ASK_USER_TOOL_NAME = "ask_user";
|
|
26
|
+
|
|
24
27
|
export const isAskUserTool = (name: string | undefined): boolean =>
|
|
25
|
-
!!name &&
|
|
28
|
+
!!name &&
|
|
29
|
+
(normalizeToolName(name) === ASK_USER_TOOL_NAME ||
|
|
30
|
+
name === PI_ASK_USER_TOOL_NAME);
|
|
26
31
|
export const isLocationTool = (name: string | undefined): boolean =>
|
|
27
32
|
!!name && normalizeToolName(name) === GET_LOCATION_TOOL_NAME;
|
package/src/stores/chat-store.ts
CHANGED
|
@@ -123,6 +123,12 @@ interface ChatState {
|
|
|
123
123
|
* absence of a running/queued turn.
|
|
124
124
|
*/
|
|
125
125
|
runStates: Record<string, "streaming" | "queued" | "idle">;
|
|
126
|
+
/**
|
|
127
|
+
* Backend that owns each session, from `session_info` (or the session list
|
|
128
|
+
* for history sessions). Scopes tool-call renderer resolution per backend;
|
|
129
|
+
* absence falls back to the deployment default.
|
|
130
|
+
*/
|
|
131
|
+
backendIds: Record<string, string>;
|
|
126
132
|
/**
|
|
127
133
|
* Per-session note attached to a `queued` status — the host sends one once a
|
|
128
134
|
* session's follow-up queue grows heavy. Cleared when the session leaves the
|
|
@@ -190,6 +196,8 @@ interface ChatState {
|
|
|
190
196
|
state: "streaming" | "queued" | "idle",
|
|
191
197
|
note?: string
|
|
192
198
|
) => void;
|
|
199
|
+
/** Record which backend owns a session (idempotent). */
|
|
200
|
+
setSessionBackend: (sessionId: string, backendId: string) => void;
|
|
193
201
|
}
|
|
194
202
|
|
|
195
203
|
let messageCounter = 0;
|
|
@@ -350,6 +358,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
350
358
|
activeSessionId: readPersistedSessionId(),
|
|
351
359
|
runStates: {},
|
|
352
360
|
queueNotes: {},
|
|
361
|
+
backendIds: {},
|
|
353
362
|
|
|
354
363
|
// createIfMissing: a user-initiated send must never be dropped, even when
|
|
355
364
|
// the active session's buffer hasn't been materialized yet (cold start
|
|
@@ -659,6 +668,13 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
659
668
|
return { runStates, queueNotes };
|
|
660
669
|
}),
|
|
661
670
|
|
|
671
|
+
setSessionBackend: (sessionId, backendId) =>
|
|
672
|
+
set((state) =>
|
|
673
|
+
state.backendIds[sessionId] === backendId
|
|
674
|
+
? state
|
|
675
|
+
: { backendIds: { ...state.backendIds, [sessionId]: backendId } }
|
|
676
|
+
),
|
|
677
|
+
|
|
662
678
|
clearMessages: () => {
|
|
663
679
|
const state = get();
|
|
664
680
|
if (state.draft) revokeAttachmentUrls(state.draft.messages);
|