@campfire-interactive/ai-chat 0.0.0-stage → 0.1.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.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The wire contract between the browser and the hub's assistant API.
3
+ *
4
+ * Copied exactly from the meta repo's plan
5
+ * (`docs/plans/2026-10-20-platform-ai-chat-phase-1.md`, "Wire contract"). A
6
+ * change here is a change in every repo that builds against it: the hub, its
7
+ * TS client, campfire-mcp and every host app. Do not edit one side alone.
8
+ */
9
+ /** Where the user is. The host builds it on every navigation; every message carries the current one. */
10
+ type AiPageContext = {
11
+ /** 'pm' */
12
+ app: string;
13
+ /** '/programs/8f…/risks' */
14
+ path: string;
15
+ /** Stable page key: 'program.risks'. Starters are keyed by it. */
16
+ page: string;
17
+ /** 'Risks' */
18
+ title: string;
19
+ /** ['Portfolio', 'P-1042', 'Risks'] */
20
+ breadcrumbs: string[];
21
+ /** program, task, gate, process… */
22
+ entities: Array<{
23
+ type: string;
24
+ id: string;
25
+ label: string;
26
+ }>;
27
+ /** e.g. /work query params */
28
+ filters?: Record<string, string>;
29
+ };
30
+ /** A destination in an app. `path` starts with '/'. */
31
+ type AiLink = {
32
+ app: string;
33
+ path: string;
34
+ };
35
+ type AiTableColumnType = 'text' | 'number' | 'money' | 'percent' | 'date' | 'status';
36
+ type AiValueFormat = 'number' | 'money' | 'percent';
37
+ type AiBlock = {
38
+ kind: 'table';
39
+ title?: string;
40
+ columns: Array<{
41
+ key: string;
42
+ label: string;
43
+ type?: AiTableColumnType;
44
+ }>;
45
+ rows: Array<Record<string, string | number | boolean | null>>;
46
+ currency?: string;
47
+ } | {
48
+ kind: 'chart';
49
+ title?: string;
50
+ chartType: 'line' | 'bar' | 'area' | 'scatter' | 'waterfall';
51
+ xKey: string;
52
+ series: Array<{
53
+ key: string;
54
+ label: string;
55
+ }>;
56
+ data: Array<Record<string, string | number | null>>;
57
+ xLabel?: string;
58
+ yLabel?: string;
59
+ valueFormat?: AiValueFormat;
60
+ } | {
61
+ kind: 'kpis';
62
+ items: Array<{
63
+ label: string;
64
+ value: string | number;
65
+ format?: AiValueFormat;
66
+ change?: number;
67
+ tone?: 'good' | 'bad' | 'neutral';
68
+ }>;
69
+ } | {
70
+ kind: 'entities';
71
+ items: Array<{
72
+ type: string;
73
+ id: string;
74
+ label: string;
75
+ sublabel?: string;
76
+ link: AiLink;
77
+ }>;
78
+ } | {
79
+ kind: 'actions';
80
+ /**
81
+ * `openNow` (at most one item) is set when the user asked to be taken
82
+ * somewhere: the host opens it straight away, and the button stays for
83
+ * going back to it.
84
+ */
85
+ items: Array<{
86
+ label: string;
87
+ link: AiLink;
88
+ style?: 'navigate' | 'open_form';
89
+ openNow?: boolean;
90
+ primary?: boolean;
91
+ }>;
92
+ } | {
93
+ kind: 'question';
94
+ question: string;
95
+ options?: string[];
96
+ }
97
+ /** Text to send elsewhere (an email, a message), shown as its own card with a copy button. */
98
+ | {
99
+ kind: 'draft';
100
+ title: string;
101
+ to?: string;
102
+ subject?: string;
103
+ body: string;
104
+ } | {
105
+ kind: 'followups';
106
+ items: string[];
107
+ didYouMean?: boolean;
108
+ } | {
109
+ kind: 'citation';
110
+ items: Array<{
111
+ title: string;
112
+ url: string;
113
+ section?: string;
114
+ }>;
115
+ }
116
+ /** One line restating the question, shown above the answer as "Understood as". */
117
+ | {
118
+ kind: 'understood';
119
+ summary: string;
120
+ };
121
+ /** A change the hub proposed and did not run. Confirm posts `token` back. */
122
+ type PendingWrite = {
123
+ /** Signed; opaque to the browser. */
124
+ token: string;
125
+ tool: string;
126
+ /** "Change status of Tooling PO to Done" */
127
+ summary: string;
128
+ entity: {
129
+ type: string;
130
+ id: string;
131
+ label?: string;
132
+ };
133
+ before?: Record<string, unknown>;
134
+ after?: Record<string, unknown>;
135
+ file?: {
136
+ fileRef: string;
137
+ name: string;
138
+ size: number;
139
+ contentType: string;
140
+ };
141
+ /** The program the change belongs to, when it belongs to one — where an attach's upload is filed. */
142
+ programId?: string;
143
+ /** ISO */
144
+ expiresAt: string;
145
+ };
146
+ /** One `data:` line of the chat stream. */
147
+ type AssistantEvent = {
148
+ type: 'text';
149
+ delta: string;
150
+ }
151
+ /** Plain words, never a tool name. */
152
+ /**
153
+ * `source`: the page name for what was read ("Risks & issues"), shown as
154
+ * "Based on". `sourcePath`: that page in this app, when the lookup was about
155
+ * one record the hub could check; absent otherwise, and the chip is plain.
156
+ * `sourceApp`: set when that page is in another app than the one the chat
157
+ * is open in (the chat answers from every app the user is granted).
158
+ */
159
+ | {
160
+ type: 'step';
161
+ label: string;
162
+ source?: string;
163
+ sourcePath?: string;
164
+ sourceApp?: string;
165
+ } | {
166
+ type: 'block';
167
+ block: AiBlock;
168
+ } | {
169
+ type: 'write_pending';
170
+ write: PendingWrite;
171
+ } | {
172
+ type: 'done';
173
+ } | {
174
+ type: 'error';
175
+ code: string;
176
+ message: string;
177
+ };
178
+ /** A file dropped into the composer, as the model sees it: metadata only, never the bytes. */
179
+ type AssistantAttachment = {
180
+ fileRef: string;
181
+ name: string;
182
+ contentType: string;
183
+ size: number;
184
+ };
185
+ /** `POST /api/v1/assistant/chat/stream` */
186
+ type AssistantChatRequest = {
187
+ app: string;
188
+ /** Browser UUID. Groups this session's audit rows; never a key to stored chat. */
189
+ session_id: string;
190
+ /** This turn's question. */
191
+ message: string;
192
+ /** Recent turns, text only. */
193
+ history: Array<{
194
+ role: 'user' | 'assistant';
195
+ content: string;
196
+ }>;
197
+ page_context: AiPageContext;
198
+ attachments: AssistantAttachment[];
199
+ };
200
+ /** `POST /api/v1/assistant/writes/confirm` */
201
+ type ConfirmWriteRequest = {
202
+ token: string;
203
+ documentId?: string;
204
+ };
205
+ type ConfirmWriteResponse = {
206
+ actionId: string;
207
+ outcome: 'succeeded' | 'failed';
208
+ result?: unknown;
209
+ error?: string;
210
+ };
211
+ /** One executed write, from the hub's append-only audit. */
212
+ type AssistantAction = {
213
+ id: string;
214
+ tenantId: string;
215
+ userId: string;
216
+ userName: string | null;
217
+ app: string;
218
+ sessionId: string;
219
+ question: string;
220
+ page: string | null;
221
+ tool: string;
222
+ /** The card's own words ("Change status of Tooling PO to Complete"). */
223
+ summary: string | null;
224
+ args: Record<string, unknown>;
225
+ entityType: string | null;
226
+ entityId: string | null;
227
+ programId: string | null;
228
+ before: unknown;
229
+ after: unknown;
230
+ outcome: 'succeeded' | 'failed';
231
+ result: unknown;
232
+ error: string | null;
233
+ proposedAt: string;
234
+ confirmedAt: string;
235
+ };
236
+ /** `GET /api/v1/assistant/actions` filters. */
237
+ type ListActionsQuery = {
238
+ sessionId?: string;
239
+ entityId?: string;
240
+ userId?: string;
241
+ limit?: number;
242
+ };
243
+
244
+ /**
245
+ * The three hub calls the panel makes, typed against the wire contract.
246
+ *
247
+ * Deliberately small and self-contained: the hub's own TS client
248
+ * (`cfi-ai-hub-client`) gains an assistant resource separately, and this
249
+ * package must not wait on it or drag its other resources into every host.
250
+ */
251
+ interface AssistantClient {
252
+ chatStream(req: AssistantChatRequest, opts?: {
253
+ signal?: AbortSignal;
254
+ }): AsyncIterable<AssistantEvent>;
255
+ confirmWrite(req: ConfirmWriteRequest): Promise<ConfirmWriteResponse>;
256
+ listActions(query: ListActionsQuery): Promise<{
257
+ items: AssistantAction[];
258
+ }>;
259
+ }
260
+ interface AssistantClientOptions {
261
+ /** The hub's base URL, without `/api/v1`. */
262
+ hubUrl: string;
263
+ /** The user's own identity JWT, the same token the app sends its own API. */
264
+ getToken: () => Promise<string | null>;
265
+ /** Test seam; defaults to the global `fetch`. */
266
+ fetch?: typeof fetch;
267
+ }
268
+ /** A non-2xx answer from the hub. `message` is the hub's own wording when it sent one. */
269
+ declare class AssistantHttpError extends Error {
270
+ readonly status: number;
271
+ constructor(status: number, message: string);
272
+ }
273
+ declare function createAssistantClient({ hubUrl, getToken, fetch: fetchImpl }: AssistantClientOptions): AssistantClient;
274
+
275
+ export { type AssistantEvent as A, type ConfirmWriteRequest as C, type ListActionsQuery as L, type PendingWrite as P, type AiBlock as a, type AiLink as b, type AiPageContext as c, type AiTableColumnType as d, type AiValueFormat as e, type AssistantAction as f, type AssistantAttachment as g, type AssistantChatRequest as h, type AssistantClient as i, type AssistantClientOptions as j, AssistantHttpError as k, type ConfirmWriteResponse as l, createAssistantClient as m };
@@ -0,0 +1,210 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { c as AiPageContext, g as AssistantAttachment, a as AiBlock, P as PendingWrite, b as AiLink, f as AssistantAction, i as AssistantClient } from '../client-u2gNr000.js';
4
+ export { A as AssistantEvent, m as createAssistantClient } from '../client-u2gNr000.js';
5
+
6
+ /** The page a question was asked on, as its card shows it. */
7
+ type AskedOn = Pick<AiPageContext, 'page' | 'title' | 'breadcrumbs' | 'path'>;
8
+ type WriteState = 'pending' | 'confirming' | 'succeeded' | 'failed' | 'canceled' | 'discarded' | 'expired';
9
+ interface WriteItem {
10
+ write: PendingWrite;
11
+ state: WriteState;
12
+ /** The refusal, the upload failure, or what went wrong. */
13
+ error?: string;
14
+ actionId?: string;
15
+ }
16
+ /** An answer is an ordered run of parts: prose as it streams, typed blocks, and pending writes. */
17
+ type AnswerPart = {
18
+ kind: 'markdown';
19
+ text: string;
20
+ } | {
21
+ kind: 'block';
22
+ block: AiBlock;
23
+ } | {
24
+ kind: 'write';
25
+ item: WriteItem;
26
+ };
27
+ type ExchangeStatus = 'streaming' | 'done' | 'error';
28
+ /** One question and the answer to it. */
29
+ interface Exchange {
30
+ id: string;
31
+ question: string;
32
+ askedOn: AskedOn;
33
+ attachments: AssistantAttachment[];
34
+ parts: AnswerPart[];
35
+ /** "Checked N things", in plain words. */
36
+ steps: string[];
37
+ /** What the answer read, by page name, each once: "Based on …". */
38
+ sources?: AnswerSource[];
39
+ status: ExchangeStatus;
40
+ error?: string;
41
+ /** `Confirmed: <summary> (succeeded|failed)` lines, sent back to the model in history. */
42
+ confirmations: string[];
43
+ /** Epoch ms the question was sent, and the answer finished. */
44
+ askedAt: number;
45
+ answeredAt?: number;
46
+ }
47
+ /**
48
+ * A question-and-answer card. Usually one exchange; a reply to the model's own
49
+ * clarifying question folds into the same card as a second one.
50
+ */
51
+ interface Card {
52
+ id: string;
53
+ exchanges: Exchange[];
54
+ }
55
+ type AiStarter = string;
56
+ /**
57
+ * Fills `{placeholders}` in a starter from the page: an entity of that type
58
+ * (its label; `{item}` is the page's focus, any type), then a filter of that
59
+ * name, then `{title}`.
60
+ * `ready` is false when any placeholder is left, and the chip then loads into
61
+ * the composer instead of sending something vague.
62
+ */
63
+ declare function fillStarter(template: string, ctx: AiPageContext): {
64
+ text: string;
65
+ ready: boolean;
66
+ };
67
+ /** A "Based on" chip: the page name, and the page itself when there is exactly one. */
68
+ type AnswerSource = {
69
+ label: string;
70
+ path?: string; /** Another app's page, when not the host's. */
71
+ app?: string;
72
+ };
73
+
74
+ /** A topic in the question catalog. Questions take the same {placeholders} as starters. */
75
+ interface AiQuestionGroup {
76
+ label: string;
77
+ blurb?: string;
78
+ questions: string[];
79
+ }
80
+ interface ChangesState {
81
+ items: AssistantAction[];
82
+ loading: boolean;
83
+ loaded: boolean;
84
+ error?: string;
85
+ }
86
+ /** Everything the panel, the button and a host can read or do. */
87
+ interface AiChatState {
88
+ app: string;
89
+ pageContext: AiPageContext;
90
+ /** The starters for the current page. */
91
+ starters: AiStarter[];
92
+ /** Everything the app can answer, grouped, for the "Browse by topic" view. */
93
+ catalog: AiQuestionGroup[];
94
+ open: boolean;
95
+ setOpen: (open: boolean) => void;
96
+ toggle: () => void;
97
+ width: number;
98
+ setWidth: (width: number) => void;
99
+ /** Groups this session's audit rows. Never shown. */
100
+ sessionId: string;
101
+ cards: Card[];
102
+ /** An answer is streaming; the composer waits. */
103
+ busy: boolean;
104
+ draft: string;
105
+ setDraft: (text: string) => void;
106
+ /** Sends a message. `foldInto` puts it in an existing card (a reply to the model's question). */
107
+ send: (message: string, opts?: {
108
+ foldInto?: string;
109
+ }) => Promise<void>;
110
+ /**
111
+ * Opens the chat on a question: sent at once, or with `{ send: false }`
112
+ * loaded into the composer for the user to finish. For a page's own "ask"
113
+ * button, a tile, a notification. `?ask=` on any URL does the same on load.
114
+ */
115
+ ask: (question: string, opts?: {
116
+ send?: boolean;
117
+ }) => void;
118
+ /** Stops the answer that is streaming, keeping what arrived. */
119
+ stop: () => void;
120
+ /** Clears the thread, the dropped files and the session id. */
121
+ startOver: () => void;
122
+ /** Files dropped since the last message, shown in the composer. */
123
+ stagedFiles: AssistantAttachment[];
124
+ addFiles: (files: File[] | FileList) => void;
125
+ removeFile: (fileRef: string) => void;
126
+ confirmWrite: (exchangeId: string, token: string) => Promise<void>;
127
+ /** Confirms every change still pending in one answer. */
128
+ confirmAll: (exchangeId: string) => Promise<void>;
129
+ cancelWrite: (exchangeId: string, token: string) => void;
130
+ navigate: (link: AiLink) => void;
131
+ /** The panel covers the page area instead of docking beside it. */
132
+ expanded: boolean;
133
+ setExpanded: (expanded: boolean) => void;
134
+ /** "Changes made this session", from the hub's audit. */
135
+ changes: ChangesState;
136
+ /** The card summary of each write confirmed here, by audit id. */
137
+ summaries: Record<string, string>;
138
+ refreshChanges: () => Promise<void>;
139
+ }
140
+ /**
141
+ * The chat's state, or null when the chat is off (no provider, or a provider
142
+ * without a hub URL). The button and the surface render nothing then, so a
143
+ * host can place them unconditionally.
144
+ */
145
+ declare function useAiChat(): AiChatState | null;
146
+
147
+ interface AiChatProviderProps {
148
+ /** The app slug the hub knows: 'pm'. */
149
+ app: string;
150
+ /** The hub's base URL for this environment. Empty or undefined (and no `client`) = chat off; children render as-is. */
151
+ hubUrl?: string;
152
+ /** The user's own identity JWT, the same token the app sends its own API. */
153
+ getToken: () => Promise<string | null>;
154
+ /**
155
+ * Uploads a dropped file to DMS with the user's token and returns its id.
156
+ * Called only when the user confirms an attach, never at drop time. The host
157
+ * owns this (pm wires its dms-client); the package imports no service client.
158
+ */
159
+ uploadFile: (file: File, write: PendingWrite) => Promise<{
160
+ documentId: string;
161
+ }>;
162
+ /** Where the user is now. Rebuilt by the host on every navigation. */
163
+ pageContext: AiPageContext;
164
+ /** Everything the app can answer, grouped, shown as "Browse by topic". */
165
+ catalog?: AiQuestionGroup[];
166
+ /** Starter chips keyed by `pageContext.page`; `'*'` is the fallback for pages without their own. */
167
+ starters?: Record<string, AiStarter[]>;
168
+ /** Opens a link from an answer (entity cards, action buttons). The host decides how a cross-app link opens. */
169
+ navigate: (link: AiLink) => void;
170
+ /**
171
+ * Called after a confirmed change has run (succeeded or failed), so the host
172
+ * can refetch what the page shows — otherwise the screen beside the chat
173
+ * keeps the value the change just replaced. pm invalidates its query cache.
174
+ */
175
+ onChanged?: (write: PendingWrite, outcome: 'succeeded' | 'failed') => void;
176
+ /** Replaces the hub client built from `hubUrl`/`getToken`. For tests and the showcase. */
177
+ client?: AssistantClient;
178
+ children: ReactNode;
179
+ }
180
+ declare function AiChatProvider(props: AiChatProviderProps): react.JSX.Element;
181
+
182
+ /**
183
+ * The docked chat panel. Place it INSIDE the app's layout, as a flex sibling
184
+ * of the main content column, beside `<CampfireHelpSurfaces />`:
185
+ *
186
+ * <div className="flex flex-1 overflow-hidden">
187
+ * <SideNav … />
188
+ * <main className="min-w-0 flex-1 overflow-auto">…</main>
189
+ * <CampfireHelpSurfaces />
190
+ * <AiChatSurface />
191
+ * </div>
192
+ *
193
+ * The page reflows beside it, which needs `min-width: 0` on `<main>`. Renders
194
+ * nothing while the panel is closed or the chat is off.
195
+ */
196
+ declare function AiChatSurface(): react.JSX.Element | null;
197
+
198
+ /**
199
+ * The header icon. Pass it into the shell header's `rightActions`, so every
200
+ * page has it with no shell-header change. Toggles the panel; Ctrl/Cmd+J does
201
+ * the same from anywhere (BOM's shortcut: Find Part owns Ctrl+K).
202
+ *
203
+ * Renders nothing when the chat is off, so a host can place it unconditionally
204
+ * and gate only the provider.
205
+ */
206
+ declare function AiChatButton({ className }: {
207
+ className?: string;
208
+ }): react.JSX.Element | null;
209
+
210
+ export { AiBlock, AiChatButton, AiChatProvider, type AiChatProviderProps, type AiChatState, AiChatSurface, AiLink, AiPageContext, type AiQuestionGroup, type AiStarter, type AnswerPart, AssistantAction, AssistantClient, type Card, type ChangesState, type Exchange, PendingWrite, type WriteItem, type WriteState, fillStarter, useAiChat };