@schlessera/brain-ui-react 0.20.0 → 0.23.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/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +49 -10
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/span-bits.d.ts +30 -3
- package/dist/components/activity/span-bits.d.ts.map +1 -1
- package/dist/components/activity/span-bits.js +59 -6
- package/dist/components/activity/span-bits.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 +2 -1
- 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 +146 -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 +42 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +18 -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/activity-store.d.ts +10 -0
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +34 -1
- package/dist/stores/activity-store.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/activity/activity-page.tsx +168 -21
- package/src/components/activity/span-bits.tsx +74 -17
- 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 +3 -0
- package/src/components/settings/pi-accounts.tsx +258 -0
- package/src/hooks/use-websocket.ts +29 -2
- package/src/lib/api-client.ts +52 -0
- package/src/lib/tool-names.ts +6 -1
- package/src/stores/activity-store.ts +36 -3
- package/src/stores/chat-store.ts +16 -0
|
@@ -0,0 +1,258 @@
|
|
|
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 { cn } from "../../lib/utils.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider sign-in for the pi backend's OAuth vendors — most importantly
|
|
8
|
+
* OpenAI (ChatGPT Plus/Pro), whose device-code flow needs no browser callback
|
|
9
|
+
* on the server: the user gets a short code here, enters it at the provider's
|
|
10
|
+
* verification page on ANY device, and the server stores the credential.
|
|
11
|
+
*
|
|
12
|
+
* Renders nothing when the server reports no pi providers (pi not
|
|
13
|
+
* configured), so the Models tab is unchanged for Claude-only deployments.
|
|
14
|
+
*/
|
|
15
|
+
export function PiAccountsSection({ active }: { active: boolean }) {
|
|
16
|
+
const [providers, setProviders] = useState<PiAuthProviderStatus[]>([]);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
19
|
+
const [flow, setFlow] = useState<PiLoginFlow | null>(null);
|
|
20
|
+
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
|
+
|
|
22
|
+
function stopPolling() {
|
|
23
|
+
if (pollTimer.current) {
|
|
24
|
+
clearTimeout(pollTimer.current);
|
|
25
|
+
pollTimer.current = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function reload() {
|
|
30
|
+
try {
|
|
31
|
+
const { providers } = await api.piAuthProviders();
|
|
32
|
+
setProviders(providers);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : "Could not load accounts");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!active) return;
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
api
|
|
42
|
+
.piAuthProviders()
|
|
43
|
+
.then(({ providers }) => {
|
|
44
|
+
if (!cancelled) setProviders(providers);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
// A server without the endpoint (older release) just hides the card.
|
|
48
|
+
if (!cancelled) setProviders([]);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [active]);
|
|
54
|
+
|
|
55
|
+
// Poll the pending flow until it settles. Chained timeouts rather than an
|
|
56
|
+
// interval, so a slow response never stacks requests.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!flow || flow.status !== "pending") return;
|
|
59
|
+
let disposed = false;
|
|
60
|
+
const delayMs = (flow.intervalSeconds ?? 5) * 1000;
|
|
61
|
+
const tick = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const { flow: next } = await api.piAuthFlow(flow.id);
|
|
64
|
+
if (disposed) return;
|
|
65
|
+
setFlow(next);
|
|
66
|
+
if (next.status === "success") void reload();
|
|
67
|
+
} catch {
|
|
68
|
+
if (disposed) return;
|
|
69
|
+
// Transient poll failure: keep trying until the flow expires.
|
|
70
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
75
|
+
return () => {
|
|
76
|
+
disposed = true;
|
|
77
|
+
stopPolling();
|
|
78
|
+
};
|
|
79
|
+
}, [flow]);
|
|
80
|
+
|
|
81
|
+
async function connect(providerId: string) {
|
|
82
|
+
setBusy(providerId);
|
|
83
|
+
setError(null);
|
|
84
|
+
try {
|
|
85
|
+
const { flow } = await api.piAuthStart(providerId);
|
|
86
|
+
setFlow(flow);
|
|
87
|
+
if (flow.status === "error") setError(flow.error ?? "Login failed");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setError(err instanceof Error ? err.message : "Could not start login");
|
|
90
|
+
} finally {
|
|
91
|
+
setBusy(null);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function cancel() {
|
|
96
|
+
if (!flow) return;
|
|
97
|
+
stopPolling();
|
|
98
|
+
try {
|
|
99
|
+
await api.piAuthCancel(flow.id);
|
|
100
|
+
} catch {
|
|
101
|
+
// The flow record may already be gone; clearing locally is enough.
|
|
102
|
+
}
|
|
103
|
+
setFlow(null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function disconnect(providerId: string) {
|
|
107
|
+
setBusy(providerId);
|
|
108
|
+
setError(null);
|
|
109
|
+
try {
|
|
110
|
+
await api.piAuthLogout(providerId);
|
|
111
|
+
await reload();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError(err instanceof Error ? err.message : "Could not disconnect");
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (providers.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="mt-6">
|
|
123
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
124
|
+
Accounts
|
|
125
|
+
</h3>
|
|
126
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
127
|
+
Model providers that sign in with an account instead of an API key.
|
|
128
|
+
</p>
|
|
129
|
+
<ul className="mt-4 flex flex-col gap-2">
|
|
130
|
+
{providers.map((provider) => (
|
|
131
|
+
<li
|
|
132
|
+
key={provider.providerId}
|
|
133
|
+
className="rounded-lg border border-border-subtle bg-surface p-3"
|
|
134
|
+
>
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<div className="min-w-0 flex-1">
|
|
137
|
+
<p className="truncate text-sm text-foreground">{provider.name}</p>
|
|
138
|
+
<p className="truncate text-[11px] text-muted-foreground">
|
|
139
|
+
{provider.configured
|
|
140
|
+
? `Connected${provider.source ? ` · ${provider.source}` : ""}`
|
|
141
|
+
: "Not connected"}
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
{provider.configured && (
|
|
145
|
+
<Check className="h-4 w-4 shrink-0 text-accent" />
|
|
146
|
+
)}
|
|
147
|
+
{provider.oauth && !provider.configured && (
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => connect(provider.providerId)}
|
|
150
|
+
disabled={busy !== null || flow?.status === "pending"}
|
|
151
|
+
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"
|
|
152
|
+
>
|
|
153
|
+
{busy === provider.providerId ? (
|
|
154
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
155
|
+
) : (
|
|
156
|
+
"Connect"
|
|
157
|
+
)}
|
|
158
|
+
</button>
|
|
159
|
+
)}
|
|
160
|
+
{provider.configured && provider.source === "stored" && (
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => disconnect(provider.providerId)}
|
|
163
|
+
disabled={busy !== null}
|
|
164
|
+
title="Disconnect"
|
|
165
|
+
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"
|
|
166
|
+
>
|
|
167
|
+
<LogOut className="h-4 w-4" />
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{flow && flow.providerId === provider.providerId && (
|
|
173
|
+
<LoginFlowCard flow={flow} onCancel={cancel} onDismiss={() => setFlow(null)} />
|
|
174
|
+
)}
|
|
175
|
+
</li>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
{error && (
|
|
179
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
180
|
+
{error}
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function LoginFlowCard({
|
|
188
|
+
flow,
|
|
189
|
+
onCancel,
|
|
190
|
+
onDismiss,
|
|
191
|
+
}: {
|
|
192
|
+
flow: PiLoginFlow;
|
|
193
|
+
onCancel: () => void;
|
|
194
|
+
onDismiss: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
if (flow.status === "pending") {
|
|
197
|
+
return (
|
|
198
|
+
<div className="mt-3 rounded-lg border border-primary/40 bg-primary/5 p-3">
|
|
199
|
+
<p className="text-xs text-muted-foreground">
|
|
200
|
+
Enter this code at the provider's device page — on this or any other
|
|
201
|
+
device:
|
|
202
|
+
</p>
|
|
203
|
+
<p className="mt-2 select-all text-center font-[family-name:var(--font-mono)] text-xl font-semibold tracking-widest text-foreground">
|
|
204
|
+
{flow.userCode ?? "…"}
|
|
205
|
+
</p>
|
|
206
|
+
<div className="mt-3 flex items-center justify-center gap-2">
|
|
207
|
+
{flow.verificationUri && (
|
|
208
|
+
<a
|
|
209
|
+
href={flow.verificationUri}
|
|
210
|
+
target="_blank"
|
|
211
|
+
rel="noreferrer"
|
|
212
|
+
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"
|
|
213
|
+
>
|
|
214
|
+
<ExternalLink className="h-3 w-3" />
|
|
215
|
+
Open verification page
|
|
216
|
+
</a>
|
|
217
|
+
)}
|
|
218
|
+
<button
|
|
219
|
+
onClick={onCancel}
|
|
220
|
+
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"
|
|
221
|
+
>
|
|
222
|
+
<X className="h-3 w-3" />
|
|
223
|
+
Cancel
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
<p className="mt-2 flex items-center justify-center gap-1.5 text-center text-[11px] text-muted-foreground">
|
|
227
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
228
|
+
Waiting for approval…
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const message =
|
|
235
|
+
flow.status === "success"
|
|
236
|
+
? "Connected."
|
|
237
|
+
: flow.status === "cancelled"
|
|
238
|
+
? "Login cancelled."
|
|
239
|
+
: flow.error ?? "Login failed.";
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
className={cn(
|
|
243
|
+
"mt-3 flex items-center justify-between rounded-lg border p-3 text-xs",
|
|
244
|
+
flow.status === "success"
|
|
245
|
+
? "border-border-subtle text-muted-foreground"
|
|
246
|
+
: "border-destructive/30 text-destructive"
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<span>{message}</span>
|
|
250
|
+
<button
|
|
251
|
+
onClick={onDismiss}
|
|
252
|
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
253
|
+
>
|
|
254
|
+
<X className="h-3.5 w-3.5" />
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -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,34 @@ 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
|
+
|
|
212
264
|
/** Force a discovery refresh, bypassing the TTL. */
|
|
213
265
|
refreshModels: () =>
|
|
214
266
|
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;
|
|
@@ -407,6 +407,11 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
|
|
|
407
407
|
return state.events[spanId] ?? EMPTY_EVENTS;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/** Payload event types the tool expander owns — every OTHER type belongs to
|
|
411
|
+
* the narrative stream (`narrativeEventsFor`) so nothing recorded is
|
|
412
|
+
* rendered nowhere. */
|
|
413
|
+
const TOOL_PAYLOAD_TYPES = new Set(["tool_input", "tool_output"]);
|
|
414
|
+
|
|
410
415
|
/**
|
|
411
416
|
* The recorded input/output payload events of a tool span (AE7). Returns a
|
|
412
417
|
* fresh array per call — subscribe through `useShallow` (like `childSpans`).
|
|
@@ -414,12 +419,40 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
|
|
|
414
419
|
export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
|
|
415
420
|
const events = state.events[spanId];
|
|
416
421
|
if (!events) return EMPTY_EVENTS;
|
|
417
|
-
const payloads = events.filter(
|
|
418
|
-
(e) => e.eventType === "tool_input" || e.eventType === "tool_output"
|
|
419
|
-
);
|
|
422
|
+
const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
|
|
420
423
|
return payloads.length > 0 ? payloads : EMPTY_EVENTS;
|
|
421
424
|
}
|
|
422
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Everything recorded against a span that is NOT a tool input/output payload:
|
|
428
|
+
* transcript excerpts, job output, and any span-sink event type a producer
|
|
429
|
+
* invents. Rendered as the span's narrative so an unknown type degrades to a
|
|
430
|
+
* labelled block rather than to invisibility.
|
|
431
|
+
*/
|
|
432
|
+
export function narrativeEventsFor(
|
|
433
|
+
state: ActivityState,
|
|
434
|
+
spanId: string
|
|
435
|
+
): ActivitySpanEvent[] {
|
|
436
|
+
const events = state.events[spanId];
|
|
437
|
+
if (!events) return EMPTY_EVENTS;
|
|
438
|
+
const rest = events.filter((e) => !TOOL_PAYLOAD_TYPES.has(e.eventType));
|
|
439
|
+
return rest.length > 0 ? rest : EMPTY_EVENTS;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Every event recorded under a run, ordered by time — the run detail's
|
|
443
|
+
* narrative stream and the raw-trace dump both read through this. */
|
|
444
|
+
export function runEvents(state: ActivityState, runId: string): ActivitySpanEvent[] {
|
|
445
|
+
const byId = state.spans[runId];
|
|
446
|
+
if (!byId) return EMPTY_EVENTS;
|
|
447
|
+
const out: ActivitySpanEvent[] = [];
|
|
448
|
+
for (const spanId of Object.keys(byId)) {
|
|
449
|
+
const events = state.events[spanId];
|
|
450
|
+
if (events) out.push(...events);
|
|
451
|
+
}
|
|
452
|
+
if (out.length === 0) return EMPTY_EVENTS;
|
|
453
|
+
return out.sort((a, b) => a.ts - b.ts || a.eventIndex - b.eventIndex);
|
|
454
|
+
}
|
|
455
|
+
|
|
423
456
|
/** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
|
|
424
457
|
export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
|
|
425
458
|
const runId = state.spanRun[toolUseId];
|
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);
|