@schlessera/brain-ui-react 0.14.0 → 0.16.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/README.md +25 -0
- package/dist/components/chat/brain-markdown.js +2 -2
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +4 -0
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/files/file-viewer-binary.js +2 -2
- package/dist/components/files/file-viewer-binary.js.map +1 -1
- package/dist/components/files/file-viewer.js +2 -2
- package/dist/components/files/file-viewer.js.map +1 -1
- package/dist/components/graph/graph-page.js +3 -3
- package/dist/components/graph/graph-page.js.map +1 -1
- package/dist/components/images/mask-editor.js +2 -2
- package/dist/components/images/mask-editor.js.map +1 -1
- package/dist/config.d.ts +37 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +2 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +49 -5
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/api-client.js +2 -2
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/backend.d.ts +3 -3
- package/dist/lib/backend.d.ts.map +1 -1
- package/dist/lib/backend.js +16 -15
- package/dist/lib/backend.js.map +1 -1
- package/dist/lib/share-intake.js +2 -2
- package/dist/lib/share-intake.js.map +1 -1
- package/dist/lib/share.js +2 -2
- package/dist/lib/share.js.map +1 -1
- package/dist/stores/chat-store.d.ts +13 -0
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +36 -8
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +18 -0
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +3 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/stores/file-store.d.ts.map +1 -1
- package/dist/stores/file-store.js +15 -5
- package/dist/stores/file-store.js.map +1 -1
- package/dist/stores/graph-store.d.ts.map +1 -1
- package/dist/stores/graph-store.js +8 -7
- package/dist/stores/graph-store.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +2 -2
- package/src/components/chat/chat-page.tsx +4 -0
- package/src/components/files/file-viewer-binary.tsx +2 -2
- package/src/components/files/file-viewer.tsx +2 -2
- package/src/components/graph/graph-page.tsx +3 -3
- package/src/components/images/mask-editor.tsx +2 -2
- package/src/config.ts +63 -4
- package/src/hooks/use-websocket.ts +55 -6
- package/src/index.ts +1 -1
- package/src/lib/api-client.ts +2 -2
- package/src/lib/backend.ts +16 -15
- package/src/lib/share-intake.ts +2 -2
- package/src/lib/share.ts +2 -2
- package/src/stores/chat-store.ts +50 -8
- package/src/stores/connection-store.ts +22 -0
- package/src/stores/file-store.ts +13 -5
- package/src/stores/graph-store.ts +7 -7
- package/dist/lib/ws-client.d.ts +0 -26
- package/dist/lib/ws-client.d.ts.map +0 -1
- package/dist/lib/ws-client.js +0 -93
- package/dist/lib/ws-client.js.map +0 -1
- package/src/lib/ws-client.ts +0 -109
package/src/config.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The package's single configuration chokepoint.
|
|
3
|
+
*
|
|
4
|
+
* `ui-server` resolves its configuration once, at the edge, in `createApp()`,
|
|
5
|
+
* and nothing deeper in that package touches the ambient environment. This is
|
|
6
|
+
* the browser-side mirror of that rule: the deployment shell calls
|
|
7
|
+
* `configureBrainUi()` once at boot, before the first render, and every module
|
|
8
|
+
* below reads the resolved values instead of reaching for build-tool globals.
|
|
9
|
+
*
|
|
10
|
+
* The rule exists because a library that reads `import.meta.env` pins its
|
|
11
|
+
* consumers to one bundler. `VITE_BACKEND_URL` used to be read here at module
|
|
12
|
+
* load, so a webpack or Next.js consumer had no way to point the client at a
|
|
13
|
+
* split-topology backend at all, and no way to discover that from the types.
|
|
14
|
+
* `scripts/check-env-access.ts` now refuses `import.meta.env` anywhere in a
|
|
15
|
+
* package's `src`, so the loophole cannot reopen.
|
|
16
|
+
*
|
|
17
|
+
* Values are a module-level singleton, matching the renderer/ASR registries.
|
|
18
|
+
* Runtime plugin-style reconfiguration is deliberately unsupported.
|
|
6
19
|
*/
|
|
7
20
|
export interface BrainUiConfig {
|
|
8
21
|
/** Product name shown on the login screen and connection status. */
|
|
@@ -13,6 +26,24 @@ export interface BrainUiConfig {
|
|
|
13
26
|
shareTitle: string;
|
|
14
27
|
/** Composer placeholder. */
|
|
15
28
|
composerPlaceholder: string;
|
|
29
|
+
/**
|
|
30
|
+
* Origin of the API/WebSocket backend, for a SPLIT topology (client and
|
|
31
|
+
* backend on different origins, e.g. a public frontend reaching its backend
|
|
32
|
+
* over a VPN). Empty — the default — means SAME-ORIGIN: API calls go to
|
|
33
|
+
* `/api` and the WebSocket derives its host from `window.location`.
|
|
34
|
+
*
|
|
35
|
+
* The shell resolves this however it likes (a Vite `VITE_*` define, a
|
|
36
|
+
* `<meta>` tag, a runtime fetch) and passes the result in. A trailing slash
|
|
37
|
+
* is stripped.
|
|
38
|
+
*/
|
|
39
|
+
backendUrl: string;
|
|
40
|
+
/**
|
|
41
|
+
* Install the `window.__chatStore` / `window.__graphStore` debug handles,
|
|
42
|
+
* which let browser automation inject fixture messages without a live agent
|
|
43
|
+
* session. The shell decides what "development" means — this package must
|
|
44
|
+
* not infer it from a bundler's DEV flag.
|
|
45
|
+
*/
|
|
46
|
+
devTools: boolean;
|
|
16
47
|
}
|
|
17
48
|
|
|
18
49
|
export const uiConfig: BrainUiConfig = {
|
|
@@ -20,8 +51,36 @@ export const uiConfig: BrainUiConfig = {
|
|
|
20
51
|
assistantName: "Brain",
|
|
21
52
|
shareTitle: "Shared from Brain",
|
|
22
53
|
composerPlaceholder: "Ask your brain anything...",
|
|
54
|
+
backendUrl: "",
|
|
55
|
+
devTools: false,
|
|
23
56
|
};
|
|
24
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Dev-handle installers, registered at module scope by the stores that own a
|
|
60
|
+
* handle. They cannot read `uiConfig.devTools` themselves: ES imports are
|
|
61
|
+
* hoisted, so a store's module body runs BEFORE the shell's
|
|
62
|
+
* `configureBrainUi()` call. Registering instead of reading lets the flag
|
|
63
|
+
* arrive late and still take effect.
|
|
64
|
+
*/
|
|
65
|
+
const devHandleInstallers: Array<() => void> = [];
|
|
66
|
+
let devHandlesInstalled = false;
|
|
67
|
+
|
|
68
|
+
function installDevHandles(): void {
|
|
69
|
+
if (devHandlesInstalled) return;
|
|
70
|
+
devHandlesInstalled = true;
|
|
71
|
+
for (const install of devHandleInstallers) install();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Register a debug handle to install if (and when) `devTools` is turned on. */
|
|
75
|
+
export function registerDevHandle(install: () => void): void {
|
|
76
|
+
devHandleInstallers.push(install);
|
|
77
|
+
if (uiConfig.devTools) install();
|
|
78
|
+
}
|
|
79
|
+
|
|
25
80
|
export function configureBrainUi(overrides: Partial<BrainUiConfig>): void {
|
|
26
81
|
Object.assign(uiConfig, overrides);
|
|
82
|
+
if (typeof overrides.backendUrl === "string") {
|
|
83
|
+
uiConfig.backendUrl = overrides.backendUrl.replace(/\/$/, "");
|
|
84
|
+
}
|
|
85
|
+
if (uiConfig.devTools) installDevHandles();
|
|
27
86
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useCallback } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { BrainUiClient } from "@schlessera/brain-ui-sdk/client";
|
|
3
3
|
import { useConnectionStore } from "../stores/connection-store.js";
|
|
4
4
|
import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
|
|
5
5
|
import { useProviderStore } from "../stores/provider-store.js";
|
|
@@ -96,6 +96,31 @@ export function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "
|
|
|
96
96
|
return "streaming";
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Does this frame announce the identity of the conversation THIS client just
|
|
101
|
+
* started?
|
|
102
|
+
*
|
|
103
|
+
* The old test was `session_info || result` for any unknown session, which
|
|
104
|
+
* adopted whichever arrived first — an older background turn finishing, or
|
|
105
|
+
* another client's new session, would capture the user's draft and bind it to
|
|
106
|
+
* a transcript they never wrote. `draftId` is minted per draft turn and echoed
|
|
107
|
+
* on `session_info`, so a match is proof.
|
|
108
|
+
*
|
|
109
|
+
* A server too old to echo it sends no `draftId`, and the pre-existing
|
|
110
|
+
* behaviour applies rather than the draft never binding at all: correctness
|
|
111
|
+
* where the information exists, compatibility where it does not.
|
|
112
|
+
*/
|
|
113
|
+
function isOurDraftAnnouncement(
|
|
114
|
+
state: ReturnType<typeof useChatStore.getState>,
|
|
115
|
+
msg: ServerMessage
|
|
116
|
+
): boolean {
|
|
117
|
+
if (msg.type !== "session_info" && msg.type !== "result") return false;
|
|
118
|
+
const pending = state.pendingDraftId;
|
|
119
|
+
if (msg.type === "session_info" && msg.draftId) return msg.draftId === pending;
|
|
120
|
+
// No echo to compare against.
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
export function handleServerMessage(msg: ServerMessage) {
|
|
100
125
|
const state = useChatStore.getState();
|
|
101
126
|
|
|
@@ -119,7 +144,7 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
119
144
|
key = state.activeSessionId; // null = the draft view
|
|
120
145
|
} else if (state.buffers[frameSessionId]) {
|
|
121
146
|
key = frameSessionId;
|
|
122
|
-
} else if (state.draft && (
|
|
147
|
+
} else if (state.draft && isOurDraftAnnouncement(state, msg)) {
|
|
123
148
|
// A draft run just got its server identity: adopt the draft buffer.
|
|
124
149
|
state.bindDraftSession(frameSessionId);
|
|
125
150
|
key = frameSessionId;
|
|
@@ -268,6 +293,11 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
268
293
|
}
|
|
269
294
|
|
|
270
295
|
case "error":
|
|
296
|
+
// ALWAYS recorded. Appending to the transcript only works while a
|
|
297
|
+
// message is streaming, and this used to be the whole handler — so an
|
|
298
|
+
// error arriving between turns (a rejected frame, a failed resume) was
|
|
299
|
+
// dropped as silently on the client as it was on the server.
|
|
300
|
+
useConnectionStore.getState().reportError(msg.code, msg.message);
|
|
271
301
|
if (buffer()?.isStreaming) {
|
|
272
302
|
state.appendText(key, `\n\n**Error:** ${msg.message}`);
|
|
273
303
|
state.finishAssistantMessage(key);
|
|
@@ -382,7 +412,7 @@ function handleStatusChange(status: "connecting" | "connected" | "disconnected")
|
|
|
382
412
|
}
|
|
383
413
|
|
|
384
414
|
// Singleton client - survives React re-renders
|
|
385
|
-
let wsClient:
|
|
415
|
+
let wsClient: BrainUiClient | null = null;
|
|
386
416
|
|
|
387
417
|
/**
|
|
388
418
|
* Send on the live socket from outside a component.
|
|
@@ -393,8 +423,8 @@ let wsClient: WSClient | null = null;
|
|
|
393
423
|
* socket for both. Anything that needs to send but not to own (the share
|
|
394
424
|
* intake) goes through here instead of calling the hook again.
|
|
395
425
|
*
|
|
396
|
-
* Returns false when there is no open socket
|
|
397
|
-
*
|
|
426
|
+
* Returns false when there is no open socket: `send` drops silently in that
|
|
427
|
+
* case and a caller that just staged an upload needs to know.
|
|
398
428
|
*/
|
|
399
429
|
export function sendClientMessage(msg: ClientMessage): boolean {
|
|
400
430
|
if (!wsClient) return false;
|
|
@@ -410,7 +440,26 @@ export function useWebSocket() {
|
|
|
410
440
|
if (initialized.current) return;
|
|
411
441
|
initialized.current = true;
|
|
412
442
|
|
|
413
|
-
wsClient = new
|
|
443
|
+
wsClient = new BrainUiClient({
|
|
444
|
+
url: getWsUrl(),
|
|
445
|
+
// One handler with a shared preamble, rather than sixteen copies of the
|
|
446
|
+
// session-buffer demux — see handleServerMessage.
|
|
447
|
+
handlers: { onAny: handleServerMessage },
|
|
448
|
+
onStatusChange: handleStatusChange,
|
|
449
|
+
// A frame the SDK refused. Surfaced rather than logged into a console
|
|
450
|
+
// nobody is attached to; the server-side counterpart is the
|
|
451
|
+
// ws.frames.dropped counter.
|
|
452
|
+
onProtocolError: (err) => {
|
|
453
|
+
useConnectionStore
|
|
454
|
+
.getState()
|
|
455
|
+
.reportError(
|
|
456
|
+
"PROTOCOL_ERROR",
|
|
457
|
+
err.frameType
|
|
458
|
+
? `Dropped a ${err.frameType} frame: ${err.detail}`
|
|
459
|
+
: `Dropped an unreadable frame: ${err.detail}`
|
|
460
|
+
);
|
|
461
|
+
},
|
|
462
|
+
});
|
|
414
463
|
wsClient.connect();
|
|
415
464
|
|
|
416
465
|
// Skip the exponential backoff when the network demonstrably returns.
|
package/src/index.ts
CHANGED
|
@@ -72,4 +72,4 @@ export { ShareIntake } from "./components/chat/share-card.js";
|
|
|
72
72
|
|
|
73
73
|
// API surface (typed REST client + backend URL helpers).
|
|
74
74
|
export { api } from "./lib/api-client.js";
|
|
75
|
-
export {
|
|
75
|
+
export { apiBase, getWsUrl, getBackendUrl } from "./lib/backend.js";
|
package/src/lib/api-client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { apiBase } from "./backend.js";
|
|
2
2
|
import type {
|
|
3
3
|
VoiceKeytermsResponse,
|
|
4
4
|
VoiceTokenResponse,
|
|
@@ -46,7 +46,7 @@ export interface BackendInfo {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
49
|
-
const res = await fetch(`${
|
|
49
|
+
const res = await fetch(`${apiBase()}${path}`, {
|
|
50
50
|
...init,
|
|
51
51
|
headers: {
|
|
52
52
|
"Content-Type": "application/json",
|
package/src/lib/backend.ts
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Where the API and WebSocket live.
|
|
3
3
|
*
|
|
4
4
|
* Default topology is SAME-ORIGIN: the server serves the built client and the
|
|
5
5
|
* API/WS from one origin, so API calls go to "/api" and the WebSocket derives
|
|
6
|
-
* its host from window.location.
|
|
6
|
+
* its host from window.location. Nothing needs configuring.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* A SPLIT topology (client and backend on different origins) sets
|
|
9
|
+
* `backendUrl` through `configureBrainUi()`. These are functions rather than
|
|
10
|
+
* module constants on purpose: a constant would freeze the value at import
|
|
11
|
+
* time, and ES imports are hoisted, so it would always capture the default
|
|
12
|
+
* instead of what the shell configured.
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
|
-
const BACKEND_URL = viteEnv?.VITE_BACKEND_URL?.replace(/\/$/, "") ?? "";
|
|
14
|
+
import { uiConfig } from "../config.js";
|
|
16
15
|
|
|
17
|
-
/** Base URL for API calls. Empty
|
|
18
|
-
export
|
|
16
|
+
/** Base URL for API calls. Empty backendUrl = same-origin "/api". */
|
|
17
|
+
export function apiBase(): string {
|
|
18
|
+
return `${uiConfig.backendUrl}/api`;
|
|
19
|
+
}
|
|
19
20
|
|
|
20
|
-
/** WebSocket URL. Derives wss/ws + host from
|
|
21
|
+
/** WebSocket URL. Derives wss/ws + host from backendUrl, else same-origin. */
|
|
21
22
|
export function getWsUrl(): string {
|
|
22
|
-
if (
|
|
23
|
-
const url = new URL("/ws",
|
|
23
|
+
if (uiConfig.backendUrl) {
|
|
24
|
+
const url = new URL("/ws", uiConfig.backendUrl);
|
|
24
25
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
25
26
|
return url.toString();
|
|
26
27
|
}
|
|
@@ -31,5 +32,5 @@ export function getWsUrl(): string {
|
|
|
31
32
|
|
|
32
33
|
/** Base URL for direct fetch calls (streaming endpoints). */
|
|
33
34
|
export function getBackendUrl(path: string): string {
|
|
34
|
-
return `${
|
|
35
|
+
return `${uiConfig.backendUrl}${path}`;
|
|
35
36
|
}
|
package/src/lib/share-intake.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type ShareIntakeResult,
|
|
5
5
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
6
6
|
import type { StoredShare } from "@schlessera/brain-ui-sdk/share-target";
|
|
7
|
-
import {
|
|
7
|
+
import { apiBase } from "./backend.js";
|
|
8
8
|
import {
|
|
9
9
|
fileToAttachment,
|
|
10
10
|
validateAttachments,
|
|
@@ -85,7 +85,7 @@ export async function uploadShare(
|
|
|
85
85
|
|
|
86
86
|
let response: Response;
|
|
87
87
|
try {
|
|
88
|
-
response = await fetchImpl(`${
|
|
88
|
+
response = await fetchImpl(`${apiBase()}/share`, {
|
|
89
89
|
method: "POST",
|
|
90
90
|
body: form,
|
|
91
91
|
});
|
package/src/lib/share.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { apiBase } from "./backend.js";
|
|
2
2
|
import type { RenderRequest } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
3
|
|
|
4
4
|
export type ShareKind = "file" | "text" | "richtext";
|
|
@@ -125,7 +125,7 @@ export async function renderToFile(
|
|
|
125
125
|
req: RenderRequest,
|
|
126
126
|
filename: string
|
|
127
127
|
): Promise<File> {
|
|
128
|
-
const res = await fetch(`${
|
|
128
|
+
const res = await fetch(`${apiBase()}/render`, {
|
|
129
129
|
method: "POST",
|
|
130
130
|
headers: { "Content-Type": "application/json" },
|
|
131
131
|
body: JSON.stringify(req),
|
package/src/stores/chat-store.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AskUserAnnotation,
|
|
7
7
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
8
8
|
import { useProviderStore } from "./provider-store.js";
|
|
9
|
+
import { registerDevHandle } from "../config.js";
|
|
9
10
|
|
|
10
11
|
export type { MessagePart };
|
|
11
12
|
|
|
@@ -102,6 +103,17 @@ interface ChatState {
|
|
|
102
103
|
buffers: Record<string, SessionChat>;
|
|
103
104
|
/** The unbound new-conversation buffer, if one is in progress. */
|
|
104
105
|
draft: SessionChat | null;
|
|
106
|
+
/**
|
|
107
|
+
* Correlation id for the conversation the draft is currently starting.
|
|
108
|
+
*
|
|
109
|
+
* A client starting a conversation has no session id, so it used to adopt
|
|
110
|
+
* the FIRST `session_info` for an unknown session — which could be an older
|
|
111
|
+
* background turn's, or another client's, silently binding the user's draft
|
|
112
|
+
* to someone else's transcript. The id is sent on `chat_message` and echoed
|
|
113
|
+
* on `session_info`; adoption now requires a match. Null when no draft turn
|
|
114
|
+
* is in flight.
|
|
115
|
+
*/
|
|
116
|
+
pendingDraftId: string | null;
|
|
105
117
|
/** The session in view; null = the draft / new-chat view. */
|
|
106
118
|
activeSessionId: string | null;
|
|
107
119
|
|
|
@@ -167,6 +179,8 @@ interface ChatState {
|
|
|
167
179
|
* If the draft view is active, the view follows. No-op draft = empty buffer.
|
|
168
180
|
*/
|
|
169
181
|
bindDraftSession: (sessionId: string) => void;
|
|
182
|
+
/** Mint and remember the correlation id for a draft turn about to be sent. */
|
|
183
|
+
startDraftTurn: () => string;
|
|
170
184
|
/** Switch the view to a session (creating an empty buffer if none), or to the draft (null). */
|
|
171
185
|
setActiveSession: (sessionId: string | null) => void;
|
|
172
186
|
/** New chat: drop the draft, unbind the view, unpin the provider. */
|
|
@@ -301,10 +315,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
301
315
|
): void {
|
|
302
316
|
mutateBuffer(key, (chat) => {
|
|
303
317
|
const msgs = [...chat.messages];
|
|
304
|
-
|
|
318
|
+
// The last ASSISTANT message, not the last message.
|
|
319
|
+
//
|
|
320
|
+
// A follow-up sent mid-stream appends a user message to the end of the
|
|
321
|
+
// buffer while the assistant is still streaming into the message before
|
|
322
|
+
// it. Indexing the end therefore aimed every subsequent delta at the
|
|
323
|
+
// user's own message, where `role === "assistant"` failed and the write
|
|
324
|
+
// was silently discarded — the turn kept running and its output stopped
|
|
325
|
+
// appearing. Scanning backwards costs nothing at these lengths and
|
|
326
|
+
// leaves single-message behaviour identical.
|
|
327
|
+
let index = -1;
|
|
328
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
329
|
+
if (msgs[i].role === "assistant") {
|
|
330
|
+
index = i;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
305
334
|
let out: Partial<SessionChat> = {};
|
|
306
|
-
if (
|
|
307
|
-
msgs[
|
|
335
|
+
if (index !== -1) {
|
|
336
|
+
msgs[index] = fn(msgs[index]);
|
|
308
337
|
out = { messages: msgs };
|
|
309
338
|
}
|
|
310
339
|
return extra ? { ...out, ...extra(chat) } : out;
|
|
@@ -314,6 +343,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
314
343
|
return {
|
|
315
344
|
buffers: {},
|
|
316
345
|
draft: null,
|
|
346
|
+
pendingDraftId: null,
|
|
317
347
|
// localStorage (not sessionStorage) so the active session id survives the
|
|
318
348
|
// PWA process being killed on mobile — that durability is what lets a cold
|
|
319
349
|
// relaunch re-request the full transcript instead of showing nothing.
|
|
@@ -573,6 +603,15 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
573
603
|
};
|
|
574
604
|
}),
|
|
575
605
|
|
|
606
|
+
startDraftTurn: () => {
|
|
607
|
+
const id =
|
|
608
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
609
|
+
? crypto.randomUUID()
|
|
610
|
+
: `draft-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
611
|
+
set({ pendingDraftId: id });
|
|
612
|
+
return id;
|
|
613
|
+
},
|
|
614
|
+
|
|
576
615
|
bindDraftSession: (sessionId) =>
|
|
577
616
|
set((state) => {
|
|
578
617
|
const adopted = state.buffers[sessionId] ?? state.draft ?? emptyChat();
|
|
@@ -585,6 +624,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
585
624
|
return {
|
|
586
625
|
buffers,
|
|
587
626
|
draft: null,
|
|
627
|
+
pendingDraftId: null,
|
|
588
628
|
...(followView ? { activeSessionId: sessionId } : {}),
|
|
589
629
|
};
|
|
590
630
|
}),
|
|
@@ -631,10 +671,12 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
631
671
|
});
|
|
632
672
|
|
|
633
673
|
// Dev-only handle so browser automation / manual debugging can inject
|
|
634
|
-
// fixture messages without a live Claude session.
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
674
|
+
// fixture messages without a live Claude session. Registered rather than
|
|
675
|
+
// installed: whether this is a development build is the shell's call
|
|
676
|
+
// (`configureBrainUi({ devTools: true })`), not something a component library
|
|
677
|
+
// infers from its bundler.
|
|
678
|
+
registerDevHandle(() => {
|
|
679
|
+
if (typeof window === "undefined") return;
|
|
638
680
|
(window as unknown as { __chatStore?: typeof useChatStore }).__chatStore =
|
|
639
681
|
useChatStore;
|
|
640
|
-
}
|
|
682
|
+
});
|
|
@@ -8,16 +8,38 @@ type VpnStatus =
|
|
|
8
8
|
| "unreachable"
|
|
9
9
|
| "checking";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The last thing that went wrong, whether or not a turn was running.
|
|
13
|
+
*
|
|
14
|
+
* Before this existed, a `error` frame was only surfaced when a message was
|
|
15
|
+
* mid-stream: it was appended to the streaming transcript, and outside a turn
|
|
16
|
+
* it went nowhere at all — no console, no store, no UI. A PARSE_ERROR between
|
|
17
|
+
* turns was dropped on the client as silently as it was on the server.
|
|
18
|
+
* Protocol-level drops from the SDK client land here too.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConnectionError {
|
|
21
|
+
code: string;
|
|
22
|
+
message: string;
|
|
23
|
+
/** Epoch millis, so a view can decide whether this is still interesting. */
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
interface ConnectionState {
|
|
12
28
|
wsStatus: WsStatus;
|
|
13
29
|
vpnStatus: VpnStatus;
|
|
30
|
+
lastError: ConnectionError | null;
|
|
14
31
|
setWsStatus: (status: WsStatus) => void;
|
|
15
32
|
setVpnStatus: (status: VpnStatus) => void;
|
|
33
|
+
reportError: (code: string, message: string) => void;
|
|
34
|
+
clearError: () => void;
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
export const useConnectionStore = create<ConnectionState>((set) => ({
|
|
19
38
|
wsStatus: "disconnected",
|
|
20
39
|
vpnStatus: "checking",
|
|
40
|
+
lastError: null,
|
|
21
41
|
setWsStatus: (wsStatus) => set({ wsStatus }),
|
|
22
42
|
setVpnStatus: (vpnStatus) => set({ vpnStatus }),
|
|
43
|
+
reportError: (code, message) => set({ lastError: { code, message, at: Date.now() } }),
|
|
44
|
+
clearError: () => set({ lastError: null }),
|
|
23
45
|
}));
|
package/src/stores/file-store.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
WikilinkMapResponse,
|
|
7
7
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
8
8
|
import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
|
|
9
|
-
import {
|
|
9
|
+
import { apiBase } from "../lib/backend.js";
|
|
10
10
|
import { isMermaidPath } from "../lib/mermaid.js";
|
|
11
11
|
|
|
12
12
|
export type ViewMode = "preview" | "raw";
|
|
@@ -76,7 +76,7 @@ interface FileState {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
async function fetchTree(path: string): Promise<FileEntry[]> {
|
|
79
|
-
const url = `${
|
|
79
|
+
const url = `${apiBase()}/files/tree${path ? `?path=${encodeURIComponent(path)}` : ""}`;
|
|
80
80
|
const res = await fetch(url);
|
|
81
81
|
if (!res.ok) {
|
|
82
82
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
@@ -87,7 +87,7 @@ async function fetchTree(path: string): Promise<FileEntry[]> {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
async function fetchContent(path: string): Promise<FileContentResponse> {
|
|
90
|
-
const res = await fetch(`${
|
|
90
|
+
const res = await fetch(`${apiBase()}/files/content?path=${encodeURIComponent(path)}`);
|
|
91
91
|
if (!res.ok) {
|
|
92
92
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
93
93
|
const err = new Error(body.error || `HTTP ${res.status}`);
|
|
@@ -99,7 +99,7 @@ async function fetchContent(path: string): Promise<FileContentResponse> {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
async function fetchResolve(path: string): Promise<FileResolveResponse> {
|
|
102
|
-
const res = await fetch(`${
|
|
102
|
+
const res = await fetch(`${apiBase()}/files/resolve?path=${encodeURIComponent(path)}`);
|
|
103
103
|
if (!res.ok) {
|
|
104
104
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
105
105
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
@@ -108,7 +108,7 @@ async function fetchResolve(path: string): Promise<FileResolveResponse> {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
async function fetchWikilinks(): Promise<WikilinkMapResponse> {
|
|
111
|
-
const res = await fetch(`${
|
|
111
|
+
const res = await fetch(`${apiBase()}/files/wikilinks`);
|
|
112
112
|
if (!res.ok) {
|
|
113
113
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
114
114
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
@@ -211,6 +211,11 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|
|
211
211
|
|
|
212
212
|
try {
|
|
213
213
|
const content = await fetchContent(normalized);
|
|
214
|
+
// Drop a response the user has already navigated away from. Two rapid
|
|
215
|
+
// clicks race, and without this the SLOWER fetch wins: the viewer showed
|
|
216
|
+
// the newer file's path with the older file's content, which reads as
|
|
217
|
+
// corruption rather than as a stale load.
|
|
218
|
+
if (get().currentPath !== normalized) return;
|
|
214
219
|
set({ currentContent: content, contentLoading: false });
|
|
215
220
|
// Default mode: prefer preview when available; persist otherwise
|
|
216
221
|
const { viewMode } = get();
|
|
@@ -226,6 +231,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|
|
226
231
|
if (e.status === 413 && e.size) msg = `File too large (${(e.size / 1024 / 1024).toFixed(2)} MB; cap ${(FILE_SIZE_CAP_BYTES / 1024 / 1024).toFixed(0)} MB).`;
|
|
227
232
|
else if (e.status === 404) msg = "File not found.";
|
|
228
233
|
else if (e.message === "invalid_path") msg = "Invalid path.";
|
|
234
|
+
// Same guard: a failure for a file the user already left must not
|
|
235
|
+
// replace the file they are now looking at with an error.
|
|
236
|
+
if (get().currentPath !== normalized) return;
|
|
229
237
|
set({ contentError: msg, contentLoading: false });
|
|
230
238
|
} finally {
|
|
231
239
|
await ancestorsPromise;
|
|
@@ -4,7 +4,8 @@ import type {
|
|
|
4
4
|
GraphSubgraphResponse,
|
|
5
5
|
GraphMaintenanceResponse,
|
|
6
6
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
7
|
-
import {
|
|
7
|
+
import { apiBase } from "../lib/backend.js";
|
|
8
|
+
import { registerDevHandle } from "../config.js";
|
|
8
9
|
import { buildQuery, mergeSubgraphs } from "../components/graph/lib/graph-helpers.js";
|
|
9
10
|
|
|
10
11
|
export type GraphMode = "clusters" | "discovery" | "local" | "maintenance";
|
|
@@ -135,7 +136,7 @@ function cachePut(key: string, value: GraphSubgraphResponse | GraphMaintenanceRe
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
async function fetchGraphJson<T>(pathAndQuery: string): Promise<T> {
|
|
138
|
-
const res = await fetch(`${
|
|
139
|
+
const res = await fetch(`${apiBase()}/graph${pathAndQuery}`);
|
|
139
140
|
if (!res.ok) {
|
|
140
141
|
const body = await res.json().catch(() => ({}) as Record<string, unknown>);
|
|
141
142
|
const err = new Error(
|
|
@@ -336,10 +337,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|
|
336
337
|
}));
|
|
337
338
|
|
|
338
339
|
// Dev-only handle for exercising the view with injected fixtures (the
|
|
339
|
-
// window.__chatStore precedent)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (typeof window !== "undefined" && devEnv?.DEV) {
|
|
340
|
+
// window.__chatStore precedent), gated on the shell's `devTools` flag.
|
|
341
|
+
registerDevHandle(() => {
|
|
342
|
+
if (typeof window === "undefined") return;
|
|
343
343
|
(window as unknown as { __graphStore?: typeof useGraphStore }).__graphStore =
|
|
344
344
|
useGraphStore;
|
|
345
|
-
}
|
|
345
|
+
});
|
package/dist/lib/ws-client.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { ClientMessage, ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
-
type MessageHandler = (msg: ServerMessage) => void;
|
|
3
|
-
type StatusHandler = (status: "connecting" | "connected" | "disconnected") => void;
|
|
4
|
-
export declare class WSClient {
|
|
5
|
-
private ws;
|
|
6
|
-
private url;
|
|
7
|
-
private onMessage;
|
|
8
|
-
private onStatusChange;
|
|
9
|
-
private reconnectAttempt;
|
|
10
|
-
private reconnectTimer;
|
|
11
|
-
private closed;
|
|
12
|
-
constructor(url: string, onMessage: MessageHandler, onStatusChange: StatusHandler);
|
|
13
|
-
connect(): void;
|
|
14
|
-
send(msg: ClientMessage): void;
|
|
15
|
-
close(): void;
|
|
16
|
-
get isConnected(): boolean;
|
|
17
|
-
/**
|
|
18
|
-
* Skip the remaining backoff and reconnect immediately (e.g. when the
|
|
19
|
-
* browser fires an `online` event). No-op if open/connecting or closed
|
|
20
|
-
* deliberately.
|
|
21
|
-
*/
|
|
22
|
-
reconnectNow(): void;
|
|
23
|
-
private scheduleReconnect;
|
|
24
|
-
}
|
|
25
|
-
export {};
|
|
26
|
-
//# sourceMappingURL=ws-client.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ws-client.d.ts","sourceRoot":"","sources":["../../src/lib/ws-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEtF,KAAK,cAAc,GAAG,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;AACnD,KAAK,aAAa,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW,GAAG,cAAc,KAAK,IAAI,CAAC;AAEnF,qBAAa,QAAQ;IACnB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,SAAS,CAAiB;IAClC,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,MAAM,CAAS;gBAGrB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,cAAc,EACzB,cAAc,EAAE,aAAa;IAO/B,OAAO;IAuCP,IAAI,CAAC,GAAG,EAAE,aAAa;IAMvB,KAAK;IAUL,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED;;;;OAIG;IACH,YAAY;IAYZ,OAAO,CAAC,iBAAiB;CAQ1B"}
|