@frontera-sdk/chat 1.50.40
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/LICENSE +202 -0
- package/README.md +215 -0
- package/package.json +110 -0
- package/src/auth.ts +27 -0
- package/src/callback-page.ts +133 -0
- package/src/chat-client.ts +472 -0
- package/src/connect-popup.ts +352 -0
- package/src/doctor.ts +176 -0
- package/src/hooks.ts +202 -0
- package/src/provider.tsx +25 -0
- package/src/stream.ts +61 -0
- package/src/token-session.ts +182 -0
- package/src/transport.ts +200 -0
- package/src/types.ts +370 -0
- package/src/ui-stream.ts +49 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth callback page, as a string of HTML.
|
|
3
|
+
*
|
|
4
|
+
* The callback has to live on YOUR domain — a provider only authorises
|
|
5
|
+
* redirect URIs under a domain its project owner has verified — so this is one
|
|
6
|
+
* page every integration must host, and the only page in the whole flow they
|
|
7
|
+
* cannot get from us pre-built. What it does is small and entirely mechanical:
|
|
8
|
+
* take `code` and `state` off its own URL and hand them back to the chat.
|
|
9
|
+
*
|
|
10
|
+
* So we ship the mechanism instead of the instructions. Any JS server can
|
|
11
|
+
* serve this — a Next route handler, Express, Hono, Fastify, Workers, Bun,
|
|
12
|
+
* Deno — and a backend in another language can serve the same markup as a
|
|
13
|
+
* static file (`bunx frontera-chat-callback > callback.html`, or copy it from
|
|
14
|
+
* the docs).
|
|
15
|
+
*
|
|
16
|
+
* No import runs on the page itself: the script is inlined, because a callback
|
|
17
|
+
* page that has to load a bundle is a callback page that can fail to load one
|
|
18
|
+
* while holding an authorization code.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ConnectCallbackPageOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Origin allowed to receive the code in popup mode. Defaults to the page's
|
|
24
|
+
* own origin, correct when the chat and the callback are the same app.
|
|
25
|
+
*
|
|
26
|
+
* Never `'*'`. The message carries an authorization code, and any page that
|
|
27
|
+
* can guess your callback could otherwise listen for it.
|
|
28
|
+
*/
|
|
29
|
+
targetOrigin?: string
|
|
30
|
+
/** Shown while the page does its work. Replace it to match your product. */
|
|
31
|
+
message?: string
|
|
32
|
+
/** Fallback when a redirect return has no stored address. Defaults to `/`. */
|
|
33
|
+
fallbackReturnTo?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MESSAGE = 'Finishing up…'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `JSON.stringify` also escapes `<` when we ask, which is what keeps a value
|
|
40
|
+
* from closing the script element it sits inside.
|
|
41
|
+
*/
|
|
42
|
+
function jsLiteral(value: string): string {
|
|
43
|
+
return JSON.stringify(value).replace(/</g, '\\u003c')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderConnectCallbackPage(options: ConnectCallbackPageOptions = {}): string {
|
|
47
|
+
const targetOrigin = options.targetOrigin ? jsLiteral(options.targetOrigin) : 'location.origin'
|
|
48
|
+
const fallback = jsLiteral(options.fallbackReturnTo ?? '/')
|
|
49
|
+
const message = (options.message ?? DEFAULT_MESSAGE).replace(/[<>&]/g, (c) =>
|
|
50
|
+
c === '<' ? '<' : c === '>' ? '>' : '&',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return `<!doctype html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
58
|
+
<meta name="robots" content="noindex">
|
|
59
|
+
<title>Connecting…</title>
|
|
60
|
+
<style>
|
|
61
|
+
body {
|
|
62
|
+
margin: 0; min-height: 100vh;
|
|
63
|
+
display: flex; align-items: center; justify-content: center;
|
|
64
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
65
|
+
color: #16201f; background: #fbfbfa;
|
|
66
|
+
}
|
|
67
|
+
@media (prefers-color-scheme: dark) { body { color: #e6ecea; background: #0e1413; } }
|
|
68
|
+
p { opacity: .7; }
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<p>${message}</p>
|
|
73
|
+
<script>
|
|
74
|
+
(function () {
|
|
75
|
+
var params = new URLSearchParams(location.search);
|
|
76
|
+
var payload = {
|
|
77
|
+
type: 'frontera:oauth-callback',
|
|
78
|
+
code: params.get('code') || undefined,
|
|
79
|
+
state: params.get('state') || undefined,
|
|
80
|
+
error: params.get('error') || undefined
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Popup: hand the result to the window that opened us, then get out of the
|
|
84
|
+
// way. The origin is pinned — this message carries an authorization code.
|
|
85
|
+
if (window.opener) {
|
|
86
|
+
try { window.opener.postMessage(payload, ${targetOrigin}); } catch (e) {}
|
|
87
|
+
window.close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Redirected tab — the normal path on mobile, where popups are blocked. The
|
|
92
|
+
// chat parked its return address before leaving, because the redirect URI is
|
|
93
|
+
// registered with the provider and cannot carry one.
|
|
94
|
+
var returnTo = ${fallback};
|
|
95
|
+
try {
|
|
96
|
+
var pending = JSON.parse(sessionStorage.getItem('frontera:connect-pending') || 'null');
|
|
97
|
+
if (pending && pending.returnTo) returnTo = pending.returnTo;
|
|
98
|
+
} catch (e) {}
|
|
99
|
+
|
|
100
|
+
var target = new URL(returnTo, location.origin);
|
|
101
|
+
if (payload.code) target.searchParams.set('frontera_code', payload.code);
|
|
102
|
+
if (payload.state) target.searchParams.set('frontera_state', payload.state);
|
|
103
|
+
if (payload.error) target.searchParams.set('frontera_error', payload.error);
|
|
104
|
+
location.replace(target.toString());
|
|
105
|
+
})();
|
|
106
|
+
</script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A `Response` carrying the page, for any runtime with the Fetch API.
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* // Next.js — app/oauth/callback/route.ts
|
|
116
|
+
* export const GET = () => connectCallbackResponse()
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* `no-store` matters: this page is only ever loaded with a single-use code in
|
|
120
|
+
* the query string, and a cached copy is a stale code waiting to confuse
|
|
121
|
+
* someone.
|
|
122
|
+
*/
|
|
123
|
+
export function connectCallbackResponse(options: ConnectCallbackPageOptions = {}): Response {
|
|
124
|
+
return new Response(renderConnectCallbackPage(options), {
|
|
125
|
+
headers: {
|
|
126
|
+
'content-type': 'text/html; charset=utf-8',
|
|
127
|
+
'cache-control': 'no-store',
|
|
128
|
+
// The page is not meant to be embedded, and framing it would put an
|
|
129
|
+
// authorization code inside someone else's document.
|
|
130
|
+
'x-frame-options': 'DENY',
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
|
+
|
|
4
|
+
import { chatDoctor } from './doctor'
|
|
5
|
+
import { resumeStream, startChat, type ChatStreamOptions } from './stream'
|
|
6
|
+
import type {
|
|
7
|
+
ArtifactVersionMeta,
|
|
8
|
+
ApprovalDecision,
|
|
9
|
+
ChatAgent,
|
|
10
|
+
ChatArtifact,
|
|
11
|
+
CompleteAppConnectionRequest,
|
|
12
|
+
CompleteAppConnectionResult,
|
|
13
|
+
PendingApproval,
|
|
14
|
+
ResumeToolCallResult,
|
|
15
|
+
StartAppConnectionRequest,
|
|
16
|
+
StartAppConnectionResult,
|
|
17
|
+
UploadedFile,
|
|
18
|
+
ChatClientOptions,
|
|
19
|
+
CreateSessionRequest,
|
|
20
|
+
ListSessionsQuery,
|
|
21
|
+
Paginated,
|
|
22
|
+
Session,
|
|
23
|
+
SessionMessage,
|
|
24
|
+
StartChatRequest,
|
|
25
|
+
StartChatResult,
|
|
26
|
+
UpdateSessionRequest,
|
|
27
|
+
} from './types'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Domain client for the chat surface, in the mould of `BlueprintClient`: one
|
|
31
|
+
* thin method per endpoint over a `FronteraClient`.
|
|
32
|
+
*
|
|
33
|
+
* `authorizeConversation` exists because under API-key auth the SERVER SKIPS
|
|
34
|
+
* conversation ownership checks — any conversation id under the key's agent is
|
|
35
|
+
* accepted. A backend serving several of its own users must therefore gate
|
|
36
|
+
* conversation access itself; wiring that gate in here means every access path
|
|
37
|
+
* goes through it. Methods throw `FronteraError` code `FORBIDDEN_LOCAL` when
|
|
38
|
+
* the callback denies. Under an end-user `cht_` token the server enforces
|
|
39
|
+
* isolation and the callback is unnecessary.
|
|
40
|
+
*/
|
|
41
|
+
export class ChatClient {
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly client: FronteraClient,
|
|
44
|
+
private readonly options: ChatClientOptions = {},
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
private async authorize(conversationId: string): Promise<void> {
|
|
48
|
+
const gate = this.options.authorizeConversation
|
|
49
|
+
if (!gate) return
|
|
50
|
+
if (!(await gate(conversationId))) {
|
|
51
|
+
throw new FronteraError(`access to conversation ${conversationId} denied locally`, {
|
|
52
|
+
code: 'FORBIDDEN_LOCAL',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- chat runs ---
|
|
58
|
+
|
|
59
|
+
/** Start (or continue) a run. See `stream.ts` for stream semantics. */
|
|
60
|
+
async startChat(request: StartChatRequest, options?: ChatStreamOptions): Promise<StartChatResult> {
|
|
61
|
+
if (request.conversationId) await this.authorize(request.conversationId)
|
|
62
|
+
return startChat(this.client, request, options)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Re-attach to a run's stream. Run ids are capabilities: they only leave the
|
|
67
|
+
* server through a response the caller was already authorized to receive, so
|
|
68
|
+
* there is no conversation gate here — treat them accordingly.
|
|
69
|
+
*/
|
|
70
|
+
resumeStream(
|
|
71
|
+
runId: string,
|
|
72
|
+
options?: { startIndex?: number } & ChatStreamOptions,
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
return resumeStream(this.client, runId, options)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cancelRun(runId: string): Promise<{ success: true }> {
|
|
78
|
+
return this.client.request(`/v1/chat/${encodeURIComponent(runId)}/cancel`, { method: 'POST' })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
steerRun(runId: string, message: string): Promise<{ success: true }> {
|
|
82
|
+
return this.client.request(`/v1/chat/${encodeURIComponent(runId)}/steer`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
body: { message },
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- approvals ---
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The tool calls this conversation has parked, waiting on a person.
|
|
92
|
+
*
|
|
93
|
+
* Poll or call this on mount: a run blocked on an approval stays blocked
|
|
94
|
+
* across a page reload, and the `tool-approval-request` chunk that announced
|
|
95
|
+
* it is not replayed by a fresh `useChat`. Without reading them back, the
|
|
96
|
+
* conversation looks like an agent that stopped for no reason.
|
|
97
|
+
*
|
|
98
|
+
* End-user (`cht_`) tokens only — see `respondToApproval`.
|
|
99
|
+
*/
|
|
100
|
+
async listPendingApprovals(conversationId: string): Promise<PendingApproval[]> {
|
|
101
|
+
// Reading the list counts as handling it: a surface that knows what is
|
|
102
|
+
// parked is a surface that can render it, and warning at one that already
|
|
103
|
+
// asked would be noise.
|
|
104
|
+
chatDoctor.resolved('approval')
|
|
105
|
+
chatDoctor.resolved('connect')
|
|
106
|
+
await this.authorize(conversationId)
|
|
107
|
+
const envelope = await this.client.requestEnvelope<{ data: PendingApproval[] }>(
|
|
108
|
+
'/v1/chat/approvals',
|
|
109
|
+
{ query: { conversationId } },
|
|
110
|
+
)
|
|
111
|
+
return envelope.data
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Approve or deny a parked tool call, releasing (or refusing) the run.
|
|
116
|
+
*
|
|
117
|
+
* Requires an end-user `cht_` token: the decision is recorded against the
|
|
118
|
+
* person who made it, and an `sak_` key is one credential shared by every
|
|
119
|
+
* user of your product — there is nobody it could be deciding as, so the
|
|
120
|
+
* server answers 403 (`FORBIDDEN`). An integration that wants its agent to
|
|
121
|
+
* act unattended sets the agent's approval mode to `auto` instead.
|
|
122
|
+
*
|
|
123
|
+
* Deciding twice is not an error: the first decision wins and this reports
|
|
124
|
+
* the settled status. A lapsed request answers `expired`.
|
|
125
|
+
*/
|
|
126
|
+
respondToApproval(
|
|
127
|
+
approvalId: string,
|
|
128
|
+
decision: ApprovalDecision,
|
|
129
|
+
): Promise<{ id: string; status: string }> {
|
|
130
|
+
chatDoctor.resolved('approval')
|
|
131
|
+
return this.client.request(`/v1/chat/approvals/${encodeURIComponent(approvalId)}`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: { decision },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- app connections ---
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Begin connecting the end user's own account for a parked `needs_connect`
|
|
141
|
+
* call, and get the URL to send them to.
|
|
142
|
+
*
|
|
143
|
+
* Three things must line up before this answers, and all three are one-time
|
|
144
|
+
* setup rather than per-user work:
|
|
145
|
+
* 1. the workspace has ITS OWN OAuth client for the provider (the
|
|
146
|
+
* deployment-managed one cannot serve this — your users are authorising
|
|
147
|
+
* your application, and only its owner can register your callback);
|
|
148
|
+
* 2. `redirectUri` is registered with that client AND on the workspace's
|
|
149
|
+
* embed allowlist;
|
|
150
|
+
* 3. the install allows external users, and this user's token carries the
|
|
151
|
+
* `plugin-connect` capability you granted when you minted it.
|
|
152
|
+
*
|
|
153
|
+
* Open the returned `authUrl` in a popup. Your callback receives `code` and
|
|
154
|
+
* `state` — hand them to `completeAppConnection`, then `resumeToolCall`.
|
|
155
|
+
*/
|
|
156
|
+
startAppConnection(request: StartAppConnectionRequest): Promise<StartAppConnectionResult> {
|
|
157
|
+
chatDoctor.resolved('connect')
|
|
158
|
+
return this.client.request('/v1/chat/app-connections/start', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: request,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Finish a connect from the code your own callback received.
|
|
166
|
+
*
|
|
167
|
+
* `state` is signed by us and names the install, the user and the redirect
|
|
168
|
+
* URI, so none of the three can be swapped between the authorize request and
|
|
169
|
+
* this call. Pass it back exactly as it arrived.
|
|
170
|
+
*/
|
|
171
|
+
completeAppConnection(
|
|
172
|
+
request: CompleteAppConnectionRequest,
|
|
173
|
+
): Promise<CompleteAppConnectionResult> {
|
|
174
|
+
return this.client.request('/v1/chat/app-connections/exchange', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: request,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Re-run the tool call that was waiting on the connection.
|
|
182
|
+
*
|
|
183
|
+
* Not optional: the agent is blocked on a tool RESULT, not on an account, so
|
|
184
|
+
* a connect without this leaves the conversation exactly as stuck as before.
|
|
185
|
+
*/
|
|
186
|
+
resumeToolCall(pendingToolCallId: string): Promise<ResumeToolCallResult> {
|
|
187
|
+
return this.client.request('/v1/chat/app-connections/resume', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
body: { pendingToolCallId },
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The agents this credential may talk to — what an agent picker renders.
|
|
195
|
+
*
|
|
196
|
+
* Scoped to the credential, so a token bound to one agent lists exactly that
|
|
197
|
+
* agent while a workspace-wide token lists them all. The Console listing is
|
|
198
|
+
* session-only and returns configuration an end user has no business seeing,
|
|
199
|
+
* which is why this exists separately.
|
|
200
|
+
*/
|
|
201
|
+
listAgents(): Promise<ChatAgent[]> {
|
|
202
|
+
return this.client.request('/v1/chat-agents')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Upload a file for this user, then reference the result in
|
|
207
|
+
* `startChat({ fileAttachments: [uploaded] })`.
|
|
208
|
+
*
|
|
209
|
+
* A chat-scoped route, not the platform's `/v1/upload`: that one authorises
|
|
210
|
+
* through workspace membership, which an end user of a customer's product
|
|
211
|
+
* does not have and should not need.
|
|
212
|
+
*/
|
|
213
|
+
async upload(file: File | Blob, filename?: string): Promise<UploadedFile> {
|
|
214
|
+
const form = new FormData()
|
|
215
|
+
form.append('file', file, filename ?? (file as File).name ?? 'upload')
|
|
216
|
+
// Not through `request`: that serialises JSON and would set the wrong
|
|
217
|
+
// content type. The boundary must be chosen by FormData itself.
|
|
218
|
+
const response = await this.client.requestRaw('/v1/chat-uploads', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: form as unknown as undefined,
|
|
221
|
+
})
|
|
222
|
+
const payload = (await response.json()) as { data?: UploadedFile } & UploadedFile
|
|
223
|
+
return payload.data ?? payload
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A live URL for a stored upload.
|
|
228
|
+
*
|
|
229
|
+
* The URL saved on a message is presigned and expires about an hour after
|
|
230
|
+
* upload, so anything rendered later re-signs from the stored path — without
|
|
231
|
+
* this, attachments in yesterday's messages become broken images.
|
|
232
|
+
*/
|
|
233
|
+
async signUpload(storagePath: string): Promise<string> {
|
|
234
|
+
const { url } = await this.client.request<{ url: string }>('/v1/chat-uploads/sign', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
body: { path: storagePath },
|
|
237
|
+
})
|
|
238
|
+
return url
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The artifacts this user owns — documents, decks and images the agent made. */
|
|
242
|
+
async listArtifacts(query: { search?: string; limit?: number; cursor?: string } = {}): Promise<
|
|
243
|
+
Paginated<ChatArtifact>
|
|
244
|
+
> {
|
|
245
|
+
const envelope = await this.client.requestEnvelope<{
|
|
246
|
+
data: ChatArtifact[]
|
|
247
|
+
nextCursor?: string | null
|
|
248
|
+
}>('/v1/chat-artifacts', { query })
|
|
249
|
+
return { data: envelope.data, nextCursor: envelope.nextCursor ?? undefined }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getArtifact(id: string): Promise<ChatArtifact> {
|
|
253
|
+
return this.client.request(`/v1/chat-artifacts/${encodeURIComponent(id)}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The artifact's contents as TEXT.
|
|
258
|
+
*
|
|
259
|
+
* Only for artifacts that are text — markdown, csv, code. Reading a PNG or a
|
|
260
|
+
* spreadsheet through here destroys it: `.text()` decodes the bytes as UTF-8,
|
|
261
|
+
* and every byte that is not valid UTF-8 becomes a replacement character on
|
|
262
|
+
* the way. Use `getArtifactFile` for anything binary.
|
|
263
|
+
*/
|
|
264
|
+
async getArtifactContent(id: string): Promise<string> {
|
|
265
|
+
const response = await this.client.requestRaw(
|
|
266
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/file`,
|
|
267
|
+
)
|
|
268
|
+
return response.text()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The artifact's raw bytes, with the server's own content type.
|
|
273
|
+
*
|
|
274
|
+
* The Blob carries the type the route reported rather than one guessed from
|
|
275
|
+
* the format, so an <img> or a PDF reader gets what it needs without the
|
|
276
|
+
* caller restating it — and a wrong guess here is an image that silently
|
|
277
|
+
* will not display.
|
|
278
|
+
*/
|
|
279
|
+
async getArtifactFile(id: string): Promise<Blob> {
|
|
280
|
+
const response = await this.client.requestRaw(
|
|
281
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/file`,
|
|
282
|
+
)
|
|
283
|
+
return response.blob()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A PDF rendering of an artifact a browser cannot open itself.
|
|
288
|
+
*
|
|
289
|
+
* `.docx` and `.pptx` are bytes with no source to fall back on, so the
|
|
290
|
+
* service renders a PDF beside them and this returns it — displaying one
|
|
291
|
+
* without shipping an OOXML reader.
|
|
292
|
+
*
|
|
293
|
+
* Only artifacts with `previewKey` set have one; anything else answers 404
|
|
294
|
+
* as `NOT_FOUND`. Check the field before asking, or catch it.
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* Save an edit made in the viewer — a sheet cell, a document's text.
|
|
298
|
+
*
|
|
299
|
+
* Produces a new REVISION rather than overwriting: the service re-renders to
|
|
300
|
+
* a versioned storage key and bumps the version, so the artifact keeps the
|
|
301
|
+
* history an edit implies.
|
|
302
|
+
*/
|
|
303
|
+
async updateArtifact(
|
|
304
|
+
id: string,
|
|
305
|
+
patch: { content: string; title?: string },
|
|
306
|
+
): Promise<ChatArtifact> {
|
|
307
|
+
return this.client.request<ChatArtifact>(
|
|
308
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}`,
|
|
309
|
+
{ method: 'PATCH', body: patch },
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One revision's content — what the inline "viewing v1" panel shows. */
|
|
314
|
+
async getArtifactVersion(id: string, version: number): Promise<ChatArtifact> {
|
|
315
|
+
return this.client.request<ChatArtifact>(
|
|
316
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Roll back to a revision.
|
|
322
|
+
*
|
|
323
|
+
* Writes a new revision holding the old content — nothing is deleted, so a
|
|
324
|
+
* restore made in error is itself undoable.
|
|
325
|
+
*/
|
|
326
|
+
async restoreArtifactVersion(id: string, version: number): Promise<ChatArtifact> {
|
|
327
|
+
return this.client.request<ChatArtifact>(
|
|
328
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}/restore`,
|
|
329
|
+
{ method: 'POST' },
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Revisions of one artifact, newest first. Metadata only. */
|
|
334
|
+
async listArtifactVersions(id: string): Promise<ArtifactVersionMeta[]> {
|
|
335
|
+
return this.client.request<ArtifactVersionMeta[]>(
|
|
336
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/versions`,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The rendition of a PAST version, for the viewer's version picker.
|
|
342
|
+
*
|
|
343
|
+
* Separate from `getArtifactPreview` rather than an optional argument: the
|
|
344
|
+
* current preview lives at a different path, and a version of `0` or `NaN`
|
|
345
|
+
* silently falling back to "current" is how a reader ends up looking at
|
|
346
|
+
* today's document under yesterday's heading.
|
|
347
|
+
*/
|
|
348
|
+
async getArtifactVersionPreview(id: string, version: number): Promise<Blob> {
|
|
349
|
+
const response = await this.client.requestRaw(
|
|
350
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}/preview`,
|
|
351
|
+
)
|
|
352
|
+
return response.blob()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async getArtifactPreview(id: string): Promise<Blob> {
|
|
356
|
+
const response = await this.client.requestRaw(
|
|
357
|
+
`/v1/chat-artifacts/${encodeURIComponent(id)}/preview`,
|
|
358
|
+
)
|
|
359
|
+
return response.blob()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- sessions ---
|
|
363
|
+
|
|
364
|
+
async listSessions(query: ListSessionsQuery = {}): Promise<Paginated<Session>> {
|
|
365
|
+
// Envelope-preserving request: `nextCursor` sits BESIDE `data` in the
|
|
366
|
+
// response envelope and the default unwrap would drop it.
|
|
367
|
+
const envelope = await this.client.requestEnvelope<{
|
|
368
|
+
data: Session[]
|
|
369
|
+
nextCursor?: string | null
|
|
370
|
+
}>('/v1/sessions', {
|
|
371
|
+
query: {
|
|
372
|
+
cursor: query.cursor,
|
|
373
|
+
limit: query.limit,
|
|
374
|
+
status: query.status,
|
|
375
|
+
label: query.label,
|
|
376
|
+
group: query.group,
|
|
377
|
+
search: query.search,
|
|
378
|
+
pinned: query.pinned,
|
|
379
|
+
source: query.source,
|
|
380
|
+
agentName: query.agentName,
|
|
381
|
+
agentId: query.agentId,
|
|
382
|
+
updatedAfter: query.updatedAfter,
|
|
383
|
+
updatedBefore: query.updatedBefore,
|
|
384
|
+
surface: query.surface,
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
return { data: envelope.data, nextCursor: envelope.nextCursor ?? undefined }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
createSession(request: CreateSessionRequest = {}): Promise<Session> {
|
|
391
|
+
return this.client.request('/v1/sessions', { method: 'POST', body: request })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async getSession(id: string): Promise<Session> {
|
|
395
|
+
await this.authorize(id)
|
|
396
|
+
return this.client.request(`/v1/sessions/${encodeURIComponent(id)}`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async getMessages(id: string, options: { limit?: number } = {}): Promise<SessionMessage[]> {
|
|
400
|
+
await this.authorize(id)
|
|
401
|
+
return this.client.request(`/v1/sessions/${encodeURIComponent(id)}/messages`, {
|
|
402
|
+
query: { limit: options.limit },
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The four below act on ONE person's own session, so they need a credential
|
|
407
|
+
// that names one: a `cht_` end-user token works, an `sak_` key answers 403
|
|
408
|
+
// (`FORBIDDEN`) because it stands for every user of your product at once.
|
|
409
|
+
//
|
|
410
|
+
// `getShare`/`createShare` are deliberately absent: a share link works with
|
|
411
|
+
// no credential at all, and whether your end users may publish a
|
|
412
|
+
// conversation is your product's decision to implement, not a default they
|
|
413
|
+
// inherit from us. Those routes are platform-only and answer 403.
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Rename, pin, or restatus a conversation.
|
|
417
|
+
*
|
|
418
|
+
* Answers `{ success: true }` rather than the updated row — the service
|
|
419
|
+
* confirms the write and nothing more. Typed as what actually arrives: a
|
|
420
|
+
* `Session` here would be a promise the server never made, and the first
|
|
421
|
+
* caller to read `.title` off it would get `undefined` with no explanation.
|
|
422
|
+
* Re-read with `getSession` when you need the new state.
|
|
423
|
+
*/
|
|
424
|
+
async updateSession(id: string, patch: UpdateSessionRequest): Promise<{ success: boolean }> {
|
|
425
|
+
await this.authorize(id)
|
|
426
|
+
return this.client.request(`/v1/sessions/${encodeURIComponent(id)}`, {
|
|
427
|
+
method: 'PATCH',
|
|
428
|
+
body: patch,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async deleteSession(id: string): Promise<void> {
|
|
433
|
+
await this.authorize(id)
|
|
434
|
+
await this.client.request(`/v1/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async forkSession(id: string, messageId: string): Promise<Session> {
|
|
438
|
+
await this.authorize(id)
|
|
439
|
+
return this.client.request(`/v1/sessions/${encodeURIComponent(id)}/fork`, {
|
|
440
|
+
method: 'POST',
|
|
441
|
+
body: { messageId },
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Returns the export as a raw string — this route has no JSON envelope. */
|
|
446
|
+
async exportSession(id: string, format: string): Promise<string> {
|
|
447
|
+
await this.authorize(id)
|
|
448
|
+
const response = await this.client.requestRaw(
|
|
449
|
+
`/v1/sessions/${encodeURIComponent(id)}/export`,
|
|
450
|
+
{ query: { format } },
|
|
451
|
+
)
|
|
452
|
+
return response.text()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- aux ---
|
|
456
|
+
|
|
457
|
+
async getFollowUps(
|
|
458
|
+
conversationId: string,
|
|
459
|
+
messageId: string,
|
|
460
|
+
): Promise<{ suggestions: unknown[] }> {
|
|
461
|
+
await this.authorize(conversationId)
|
|
462
|
+
return this.client.request('/v1/follow-ups', { query: { conversationId, messageId } })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async getMessageMetadata(
|
|
466
|
+
conversationId: string,
|
|
467
|
+
messageId: string,
|
|
468
|
+
): Promise<{ metadata: unknown }> {
|
|
469
|
+
await this.authorize(conversationId)
|
|
470
|
+
return this.client.request('/v1/message-metadata', { query: { conversationId, messageId } })
|
|
471
|
+
}
|
|
472
|
+
}
|