@dbx-tools/shared-genie 0.1.9
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 +113 -0
- package/index.ts +7 -0
- package/package.json +44 -0
- package/src/event.ts +350 -0
- package/src/genie-model.ts +554 -0
- package/test/event.test.ts +695 -0
- package/tsconfig.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @dbx-tools/shared-genie
|
|
2
|
+
|
|
3
|
+
Browser-safe Genie schemas, event vocabulary, and snapshot diff helpers.
|
|
4
|
+
|
|
5
|
+
Import this package when a UI, test, or server component needs to validate Genie
|
|
6
|
+
wire payloads or derive high-level events from raw Genie message snapshots
|
|
7
|
+
without importing the Databricks SDK. The Node driver that calls Genie is
|
|
8
|
+
[`@dbx-tools/node-genie`](../../node/genie).
|
|
9
|
+
|
|
10
|
+
Key features:
|
|
11
|
+
|
|
12
|
+
- Runtime schemas for Genie messages, responses, spaces, attachments, and
|
|
13
|
+
statuses, including fields observed on the live wire.
|
|
14
|
+
- A flat `GenieChatEvent` vocabulary for thinking, SQL, rows, result, status,
|
|
15
|
+
and error rendering.
|
|
16
|
+
- Snapshot diff helpers that emit only newly observed semantic events.
|
|
17
|
+
- Attachment and status helpers for UI display logic.
|
|
18
|
+
- Browser-safe types that allow clients to validate Genie payloads without
|
|
19
|
+
bundling Databricks SDK runtime code.
|
|
20
|
+
|
|
21
|
+
## Why Not Just AppKit Genie Types?
|
|
22
|
+
|
|
23
|
+
Use AppKit's Genie plugin and UI types when you are building directly against
|
|
24
|
+
the native AppKit Genie routes. Use this package when you need a browser-safe
|
|
25
|
+
event vocabulary independent of AppKit transport:
|
|
26
|
+
|
|
27
|
+
- validating raw Genie messages captured from the Databricks SDK;
|
|
28
|
+
- replaying persisted snapshots into semantic events;
|
|
29
|
+
- sharing a flat event union between `@dbx-tools/node-genie`,
|
|
30
|
+
`@dbx-tools/node-appkit-mastra`, and custom UI/tests;
|
|
31
|
+
- detecting attachment/status shapes that are present on the live wire but not
|
|
32
|
+
always convenient in generated SDK types.
|
|
33
|
+
|
|
34
|
+
## Validate Genie Wire Payloads
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { genieModel } from "@dbx-tools/shared-genie";
|
|
38
|
+
|
|
39
|
+
const message = genieModel.GenieMessageSchema.parse(rawMessage);
|
|
40
|
+
const attachments = genieModel.GenieResponseSchema.parse(rawMessage);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The schemas extend generated Databricks SDK Genie types with fields observed on
|
|
44
|
+
the wire, including query thoughts, attachment discriminators, and serialized
|
|
45
|
+
space data.
|
|
46
|
+
|
|
47
|
+
## Handle Typed Chat Events
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { type GenieChatEvent } from "@dbx-tools/shared-genie";
|
|
51
|
+
|
|
52
|
+
function render(event: GenieChatEvent) {
|
|
53
|
+
switch (event.type) {
|
|
54
|
+
case "thinking":
|
|
55
|
+
return showThought(event.thought_type, event.text);
|
|
56
|
+
case "query":
|
|
57
|
+
return showSql(event.sql);
|
|
58
|
+
case "result":
|
|
59
|
+
return markDone(event.status);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The event union is flat: every variant has `type` plus its own fields and shared
|
|
65
|
+
location fields such as `space_id`, `conversation_id`, and `message_id`.
|
|
66
|
+
|
|
67
|
+
## Derive Events From Snapshots
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { event } from "@dbx-tools/shared-genie";
|
|
71
|
+
|
|
72
|
+
for (const evt of event.eventsFromMessage(current, previous, spaceId)) {
|
|
73
|
+
render(evt);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use `eventsFromMessage()` when replaying persisted Genie messages, testing UI
|
|
78
|
+
event handling, or building a custom polling loop. Individual detectors are also
|
|
79
|
+
exported for targeted checks: `detectStatus`, `detectThinking`, `detectQuery`,
|
|
80
|
+
`detectRows`, and the other event-specific helpers.
|
|
81
|
+
|
|
82
|
+
## Work With Status And Attachments
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { genieModel } from "@dbx-tools/shared-genie";
|
|
86
|
+
|
|
87
|
+
if (genieModel.isTerminalStatus(message.status)) {
|
|
88
|
+
console.log(genieModel.humanizeStatus(message.status));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const kind = genieModel.detectAttachmentType(message.attachments?.[0]);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`humanizeStatus()` is the shared display-label function. `detectAttachmentType()`
|
|
95
|
+
lets consumers switch on `query`, `text`, or `suggested_questions` instead of
|
|
96
|
+
probing optional fields.
|
|
97
|
+
|
|
98
|
+
## Event Model
|
|
99
|
+
|
|
100
|
+
The event detector compares the current Genie message with the previous snapshot
|
|
101
|
+
from the same turn. This keeps streaming UIs from re-rendering duplicate thought
|
|
102
|
+
or query fragments while still allowing full history replay from persisted raw
|
|
103
|
+
messages. Consumers can use the full `eventsFromMessage()` helper or individual
|
|
104
|
+
detectors when a test needs to assert one specific behavior.
|
|
105
|
+
|
|
106
|
+
## Modules
|
|
107
|
+
|
|
108
|
+
- `genieModel` - Genie schemas, status helpers, attachment helpers, and event
|
|
109
|
+
union schemas/types.
|
|
110
|
+
- `event` - event detector factory, individual detectors, and
|
|
111
|
+
`eventsFromMessage()`.
|
|
112
|
+
|
|
113
|
+
Server-side streaming is in [`@dbx-tools/node-genie`](../../node/genie).
|
package/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export * as event from "./src/event";
|
|
6
|
+
export * as genieModel from "./src/genie-model";
|
|
7
|
+
export type { MessageStatus, GenieThoughtType, GenieThought, KnownAttachmentType, AttachmentType, GenieQueryAttachment, GenieAttachment, GenieMessage, GenieResponse, GenieSpace, TerminalStatus, GenieChatLocation, QuestionEvent, MessageEvent, StatusEvent, AttachmentEvent, ThinkingEvent, TextEvent, QueryEvent, StatementEvent, RowsEvent, SuggestedQuestionsEvent, ResultEvent, GenieChatEvent, GenieChatEventType, GenieChatEventFields } from "./src/genie-model";
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/shared-genie",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
+
"directory": "workspaces/shared/genie"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@types/node": "^24.6.0",
|
|
10
|
+
"tsx": "^4.23.0",
|
|
11
|
+
"typescript": "^5.9.3"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"zod": "^4.3.6",
|
|
15
|
+
"@dbx-tools/shared-core": "0.1.9",
|
|
16
|
+
"@dbx-tools/shared-sdk-model": "0.1.9"
|
|
17
|
+
},
|
|
18
|
+
"main": "index.ts",
|
|
19
|
+
"license": "UNLICENSED",
|
|
20
|
+
"version": "0.1.9",
|
|
21
|
+
"types": "index.ts",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./index.ts",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"dbxToolsConfig": {
|
|
28
|
+
"tags": [
|
|
29
|
+
"shared"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "projen build",
|
|
35
|
+
"compile": "projen compile",
|
|
36
|
+
"default": "projen default",
|
|
37
|
+
"package": "projen package",
|
|
38
|
+
"post-compile": "projen post-compile",
|
|
39
|
+
"pre-compile": "projen pre-compile",
|
|
40
|
+
"test": "projen test",
|
|
41
|
+
"watch": "projen watch",
|
|
42
|
+
"projen": "projen"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/event.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure event-detection for `genieEventChat`. Given two
|
|
3
|
+
* `GenieMessage` snapshots (current + prior) and the surrounding
|
|
4
|
+
* `space_id`, derive the semantic deltas (status transitions, new
|
|
5
|
+
* attachments, new thoughts, SQL emission, warehouse submission,
|
|
6
|
+
* row-count progress, follow-up suggestions, text deltas) and
|
|
7
|
+
* yield them as typed {@link GenieChatEvent}s.
|
|
8
|
+
*
|
|
9
|
+
* Detectors are built through a single typed factory that ties
|
|
10
|
+
* each event's name to its scope (whole-message vs per-attachment)
|
|
11
|
+
* and payload shape, so a misnamed event or a payload that doesn't
|
|
12
|
+
* match its event fails to compile. An orchestrator walks the
|
|
13
|
+
* snapshot diff and yields every detector that fires in a stable
|
|
14
|
+
* order (status first, then per-attachment field deltas) so a
|
|
15
|
+
* subscriber that logs events as they arrive sees a sensible
|
|
16
|
+
* sequence.
|
|
17
|
+
*
|
|
18
|
+
* The module is intentionally pure: no `EventEmitter`, no `this`,
|
|
19
|
+
* no I/O, no module-level mutable state. The `message` and
|
|
20
|
+
* `result` events are NOT derived here - they belong to the chat
|
|
21
|
+
* lifecycle layer (the `genieEventChat` driver) because they track
|
|
22
|
+
* per-yield / per-turn-completion semantics rather than a
|
|
23
|
+
* field-level snapshot diff.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
detectAttachmentType,
|
|
28
|
+
type GenieAttachment,
|
|
29
|
+
type GenieChatEvent,
|
|
30
|
+
type GenieChatEventFields,
|
|
31
|
+
type GenieChatEventType,
|
|
32
|
+
type GenieChatLocation,
|
|
33
|
+
type GenieMessage,
|
|
34
|
+
type GenieThought,
|
|
35
|
+
} from "./genie-model";
|
|
36
|
+
|
|
37
|
+
/* ----------------------------- contract ---------------------------- */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a single detector call returns: zero (`undefined`), one
|
|
41
|
+
* (fields object), or many (`fields[]`) events of the same type.
|
|
42
|
+
* Each result is the variant's payload fields **without** the
|
|
43
|
+
* `type` discriminator - the orchestrator stamps `type` when it
|
|
44
|
+
* yields the event.
|
|
45
|
+
*/
|
|
46
|
+
type DetectorResult<T extends GenieChatEventType> =
|
|
47
|
+
GenieChatEventFields<T> | GenieChatEventFields<T>[] | undefined;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where in the wire shape a given event is derived from. Drives
|
|
51
|
+
* which arguments `detect` receives. `"message"` events watch
|
|
52
|
+
* `GenieMessage` itself; `"attachment"` events watch one slot of
|
|
53
|
+
* `message.attachments[]`. `"lifecycle"` events (`message`,
|
|
54
|
+
* `result`) are emitted by the chat driver directly and can't be
|
|
55
|
+
* built with {@link eventDetector} - they have no diff signature.
|
|
56
|
+
*/
|
|
57
|
+
interface DetectorScope {
|
|
58
|
+
// Message-scoped: top-level field diff on `GenieMessage`.
|
|
59
|
+
status: "message";
|
|
60
|
+
// Per-attachment: field diff on one `GenieAttachment` slot.
|
|
61
|
+
attachment: "attachment";
|
|
62
|
+
thinking: "attachment";
|
|
63
|
+
text: "attachment";
|
|
64
|
+
query: "attachment";
|
|
65
|
+
statement: "attachment";
|
|
66
|
+
rows: "attachment";
|
|
67
|
+
suggested_questions: "attachment";
|
|
68
|
+
// Lifecycle: not derived via diff, handled by the chat driver.
|
|
69
|
+
question: "lifecycle";
|
|
70
|
+
message: "lifecycle";
|
|
71
|
+
result: "lifecycle";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `detect` callback signature for a given event type. Resolved
|
|
76
|
+
* from {@link DetectorScope}: `"message"` events get the top-level
|
|
77
|
+
* snapshot triple, `"attachment"` events get the per-slot quad,
|
|
78
|
+
* `"lifecycle"` events resolve to `never` (no diff-based detector
|
|
79
|
+
* exists for them).
|
|
80
|
+
*/
|
|
81
|
+
type DetectFn<T extends GenieChatEventType> = DetectorScope[T] extends "message"
|
|
82
|
+
? (
|
|
83
|
+
current: GenieMessage,
|
|
84
|
+
previous: GenieMessage | undefined,
|
|
85
|
+
space_id: string,
|
|
86
|
+
) => DetectorResult<T>
|
|
87
|
+
: DetectorScope[T] extends "attachment"
|
|
88
|
+
? (
|
|
89
|
+
current: GenieAttachment,
|
|
90
|
+
previous: GenieAttachment | undefined,
|
|
91
|
+
location: GenieChatLocation,
|
|
92
|
+
index: number,
|
|
93
|
+
) => DetectorResult<T>
|
|
94
|
+
: never;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Typed detector for one event in the {@link GenieChatEvent}
|
|
98
|
+
* union. The `type` field is the event name; `detect`'s signature
|
|
99
|
+
* is picked from {@link DetectorScope} based on `T`.
|
|
100
|
+
*/
|
|
101
|
+
interface EventDetector<T extends GenieChatEventType> {
|
|
102
|
+
readonly type: T;
|
|
103
|
+
detect: DetectFn<T>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ----------------------- detector factory ----------------------- */
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build an {@link EventDetector}. Pass the event name as the
|
|
110
|
+
* literal first arg and the matching `detect` callback as the
|
|
111
|
+
* second. TS infers `T` from the literal, narrows `detect`'s
|
|
112
|
+
* signature accordingly, and types the return as
|
|
113
|
+
* `EventDetector<T>`.
|
|
114
|
+
*
|
|
115
|
+
* Build-time guarantees:
|
|
116
|
+
*
|
|
117
|
+
* - `eventDetector("status2", ...)` fails - the name isn't in
|
|
118
|
+
* {@link GenieChatEvent}.
|
|
119
|
+
* - `eventDetector("status", attachmentArgsCallback)` fails -
|
|
120
|
+
* `"status"` is message-scoped, so `detect` must take
|
|
121
|
+
* `(GenieMessage, GenieMessage | undefined, string)`.
|
|
122
|
+
* - Returning a `ThinkingEvent`-shaped fields object from a
|
|
123
|
+
* `"status"` detector fails - the return type is constrained
|
|
124
|
+
* to `DetectorResult<"status">`.
|
|
125
|
+
*
|
|
126
|
+
* Lifecycle event names (`"message"`, `"result"`) resolve `detect`
|
|
127
|
+
* to `never` and won't compile, which is intentional: those have
|
|
128
|
+
* no diff signature and are emitted directly by the chat driver.
|
|
129
|
+
*/
|
|
130
|
+
export function eventDetector<T extends GenieChatEventType>(
|
|
131
|
+
type: T,
|
|
132
|
+
detect: DetectFn<T>,
|
|
133
|
+
): EventDetector<T> {
|
|
134
|
+
return { type, detect };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ---------------------------- detectors ---------------------------- */
|
|
138
|
+
|
|
139
|
+
/** Top-level `message.status` transitioned. */
|
|
140
|
+
export const detectStatus = eventDetector("status", (current, previous, space_id) => {
|
|
141
|
+
if (!current.status || current.status === previous?.status) return;
|
|
142
|
+
return {
|
|
143
|
+
status: current.status,
|
|
144
|
+
previous_status: previous?.status,
|
|
145
|
+
space_id,
|
|
146
|
+
conversation_id: current.conversation_id,
|
|
147
|
+
message_id: current.message_id,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** First time we see an attachment slot. */
|
|
152
|
+
export const detectAttachmentAdded = eventDetector(
|
|
153
|
+
"attachment",
|
|
154
|
+
(current, previous, location, index) => {
|
|
155
|
+
if (previous) return;
|
|
156
|
+
return {
|
|
157
|
+
...location,
|
|
158
|
+
index,
|
|
159
|
+
attachment_type: detectAttachmentType(current),
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One emit per new `(thought_type, content)` tuple on a query
|
|
166
|
+
* attachment. Value-based set diff: Genie can mutate existing
|
|
167
|
+
* thought slots in place (e.g. re-typing index 0 from
|
|
168
|
+
* `DATA_SOURCING` to `DESCRIPTION` while re-appending the
|
|
169
|
+
* original at index 1), so positional / append-only diff would
|
|
170
|
+
* miss re-types and double-count re-orders.
|
|
171
|
+
*/
|
|
172
|
+
export const detectThinking = eventDetector("thinking", (current, previous, location) => {
|
|
173
|
+
const currThoughts = current.query?.thoughts ?? [];
|
|
174
|
+
if (currThoughts.length === 0) return;
|
|
175
|
+
const seen = new Set((previous?.query?.thoughts ?? []).map(thoughtKey));
|
|
176
|
+
const out: GenieChatEventFields<"thinking">[] = [];
|
|
177
|
+
for (const t of currThoughts) {
|
|
178
|
+
const key = thoughtKey(t);
|
|
179
|
+
if (seen.has(key)) continue;
|
|
180
|
+
// Defensive: dedupe within a single snapshot in case Genie
|
|
181
|
+
// ever ships the same thought twice in one `thoughts[]`.
|
|
182
|
+
seen.add(key);
|
|
183
|
+
out.push({ ...location, text: t.content, thought_type: t.thought_type });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/** Text-attachment `content` appeared or changed. */
|
|
189
|
+
export const detectText = eventDetector("text", (current, previous, location) => {
|
|
190
|
+
const curr = current.text?.content;
|
|
191
|
+
const prev = previous?.text?.content;
|
|
192
|
+
if (curr === undefined || curr === prev) return;
|
|
193
|
+
return { ...location, text: curr };
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** SQL transitioned undefined -> string, or changed. */
|
|
197
|
+
export const detectQuery = eventDetector("query", (current, previous, location) => {
|
|
198
|
+
const curr = current.query?.query;
|
|
199
|
+
const prev = previous?.query?.query;
|
|
200
|
+
if (!curr || curr === prev) return;
|
|
201
|
+
return {
|
|
202
|
+
...location,
|
|
203
|
+
sql: curr,
|
|
204
|
+
title: current.query?.title,
|
|
205
|
+
description: current.query?.description,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** Warehouse-statement id assigned. */
|
|
210
|
+
export const detectStatement = eventDetector("statement", (current, previous, location) => {
|
|
211
|
+
const curr = current.query?.statement_id;
|
|
212
|
+
const prev = previous?.query?.statement_id;
|
|
213
|
+
if (!curr || curr === prev) return;
|
|
214
|
+
return { ...location, statement_id: curr };
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `row_count` changed - fires on every transition including the
|
|
219
|
+
* initial `undefined -> 0` and the post-execution `0 -> N`.
|
|
220
|
+
* Carries the statement id when available for correlation.
|
|
221
|
+
*/
|
|
222
|
+
export const detectRows = eventDetector("rows", (current, previous, location) => {
|
|
223
|
+
const curr = current.query?.query_result_metadata?.row_count;
|
|
224
|
+
const prev = previous?.query?.query_result_metadata?.row_count;
|
|
225
|
+
if (curr === undefined || curr === prev) return;
|
|
226
|
+
return {
|
|
227
|
+
...location,
|
|
228
|
+
row_count: curr,
|
|
229
|
+
previous_row_count: prev,
|
|
230
|
+
statement_id: current.query?.statement_id ?? previous?.query?.statement_id,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Follow-up suggested-questions array appeared or changed.
|
|
236
|
+
* Compares JSON-stringified arrays so a length-preserving content
|
|
237
|
+
* rewrite still fires.
|
|
238
|
+
*/
|
|
239
|
+
export const detectSuggestedQuestions = eventDetector(
|
|
240
|
+
"suggested_questions",
|
|
241
|
+
(current, previous, location) => {
|
|
242
|
+
const curr = current.suggested_questions?.questions;
|
|
243
|
+
const prev = previous?.suggested_questions?.questions;
|
|
244
|
+
if (!curr || curr.length === 0) return;
|
|
245
|
+
if (JSON.stringify(curr) === JSON.stringify(prev)) return;
|
|
246
|
+
return { ...location, questions: curr };
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
/* --------------------------- orchestrator --------------------------- */
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Walk the diff between `current` and `previous` and yield every
|
|
254
|
+
* derived event the snapshot produced. Detector order mirrors
|
|
255
|
+
* Genie's wire ordering (status first, then per-attachment field
|
|
256
|
+
* deltas) so a subscriber that simply logs events as they arrive
|
|
257
|
+
* sees them in a sensible sequence.
|
|
258
|
+
*
|
|
259
|
+
* Caller responsibilities (not handled here):
|
|
260
|
+
*
|
|
261
|
+
* - Yield `{ type: "message", message: current }` BEFORE
|
|
262
|
+
* calling this, once per poll yield.
|
|
263
|
+
* - Yield `{ type: "result", ... }` AFTER calling this when
|
|
264
|
+
* `isTerminalStatus(current.status)` - per-turn lifecycle,
|
|
265
|
+
* not a per-snapshot field diff.
|
|
266
|
+
* - Decide what counts as a "fresh turn" and pass `undefined`
|
|
267
|
+
* for `previous` on turn boundaries, so anonymous-attachment
|
|
268
|
+
* state from a prior turn doesn't bleed in.
|
|
269
|
+
*
|
|
270
|
+
* Sync generator: the diff is pure CPU work, no awaits. Use
|
|
271
|
+
* `yield*` from an async generator to splice the events into a
|
|
272
|
+
* stream.
|
|
273
|
+
*/
|
|
274
|
+
export function* eventsFromMessage(
|
|
275
|
+
current: GenieMessage,
|
|
276
|
+
previous: GenieMessage | undefined,
|
|
277
|
+
space_id: string,
|
|
278
|
+
): Generator<GenieChatEvent, void, void> {
|
|
279
|
+
// Stamp `type` onto each detector result and yield it. Returning
|
|
280
|
+
// a typed generator keeps the `yield*` callsite tidy. The double
|
|
281
|
+
// cast (`unknown` -> `GenieChatEvent`) is needed because the
|
|
282
|
+
// generic merge `{type: T} & GenieChatEventFields<T>` doesn't
|
|
283
|
+
// structurally narrow back to a discriminated-union member -
|
|
284
|
+
// each detector's runtime output is shaped correctly by
|
|
285
|
+
// construction, so the cast is sound.
|
|
286
|
+
function* emit<T extends GenieChatEventType>(
|
|
287
|
+
detector: EventDetector<T>,
|
|
288
|
+
result: DetectorResult<T>,
|
|
289
|
+
): Generator<GenieChatEvent, void, void> {
|
|
290
|
+
if (result === undefined) return;
|
|
291
|
+
if (Array.isArray(result)) {
|
|
292
|
+
for (const fields of result) {
|
|
293
|
+
yield { type: detector.type, ...fields } as unknown as GenieChatEvent;
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
yield { type: detector.type, ...result } as unknown as GenieChatEvent;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Message-scoped detectors run once per snapshot.
|
|
301
|
+
yield* emit(detectStatus, detectStatus.detect(current, previous, space_id));
|
|
302
|
+
|
|
303
|
+
// Per-attachment detectors run once per attachment slot.
|
|
304
|
+
const currAtts = current.attachments ?? [];
|
|
305
|
+
const prevAtts = previous?.attachments ?? [];
|
|
306
|
+
for (let i = 0; i < currAtts.length; i++) {
|
|
307
|
+
const curr = currAtts[i]!;
|
|
308
|
+
const prev = matchPrevAttachment(curr, prevAtts, i);
|
|
309
|
+
const location: GenieChatLocation = {
|
|
310
|
+
space_id,
|
|
311
|
+
conversation_id: current.conversation_id,
|
|
312
|
+
message_id: current.message_id,
|
|
313
|
+
attachment_id: curr.attachment_id,
|
|
314
|
+
};
|
|
315
|
+
yield* emit(detectAttachmentAdded, detectAttachmentAdded.detect(curr, prev, location, i));
|
|
316
|
+
yield* emit(detectThinking, detectThinking.detect(curr, prev, location, i));
|
|
317
|
+
yield* emit(detectText, detectText.detect(curr, prev, location, i));
|
|
318
|
+
yield* emit(detectQuery, detectQuery.detect(curr, prev, location, i));
|
|
319
|
+
yield* emit(detectStatement, detectStatement.detect(curr, prev, location, i));
|
|
320
|
+
yield* emit(detectRows, detectRows.detect(curr, prev, location, i));
|
|
321
|
+
yield* emit(detectSuggestedQuestions, detectSuggestedQuestions.detect(curr, prev, location, i));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ----------------------------- helpers ----------------------------- */
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Find the prior version of `curr` in `prevAtts`. Attachments
|
|
329
|
+
* with ids match by id (Genie keeps ids stable across polls);
|
|
330
|
+
* anonymous attachments (Genie's main-answer text doesn't get
|
|
331
|
+
* one) match positionally against an anonymous prev at the same
|
|
332
|
+
* index, so they don't accidentally bind to an id'd predecessor
|
|
333
|
+
* that happened to share the slot.
|
|
334
|
+
*/
|
|
335
|
+
function matchPrevAttachment(
|
|
336
|
+
curr: GenieAttachment,
|
|
337
|
+
prevAtts: GenieAttachment[],
|
|
338
|
+
i: number,
|
|
339
|
+
): GenieAttachment | undefined {
|
|
340
|
+
if (curr.attachment_id) {
|
|
341
|
+
return prevAtts.find((a) => a.attachment_id === curr.attachment_id);
|
|
342
|
+
}
|
|
343
|
+
const p = prevAtts[i];
|
|
344
|
+
return p && !p.attachment_id ? p : undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Stable key for {@link detectThinking}'s value-based set diff. */
|
|
348
|
+
function thoughtKey(t: GenieThought): string {
|
|
349
|
+
return `${t.thought_type}|${t.content}`;
|
|
350
|
+
}
|