@cosmoops/connect-sdk 0.29.0 → 0.30.1

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.
@@ -388,6 +388,12 @@ export function createConnectHandler(options) {
388
388
  // decision, so there is nothing for this layer to check more strictly.
389
389
  screenContext: body.screenContext,
390
390
  screens: body.screens,
391
+ // The browser's own id for this conversation, so the turn is stored
392
+ // and can be reopened. Safe to relay: it decides *which* of the
393
+ // caller's own conversations is written to, never whose - Connect
394
+ // refuses an id belonging to another user or another application, and
395
+ // the owner comes from `asUser` below.
396
+ ...(typeof body.conversationId === "string" ? { conversationId: body.conversationId } : {}),
391
397
  // Connect re-derives the workspace from this token, so the line above
392
398
  // is belt to its braces - the browser cannot reach another tenant's
393
399
  // Context Index even if this route were changed to trust the request.
@@ -406,6 +412,75 @@ export function createConnectHandler(options) {
406
412
  },
407
413
  });
408
414
  },
415
+ /**
416
+ * Ask AI history - the list, one transcript, and a deletion.
417
+ *
418
+ * The same rule as every other route: the user comes from the caller's own
419
+ * session, forwarded as `asUser`, and Connect reads both the person and
420
+ * the workspace from it. Nothing here takes an owner from the request, so
421
+ * a browser cannot page through anybody else's conversations - which
422
+ * matters more here than on most routes, because a transcript is the most
423
+ * candid record this API holds of what somebody was trying to do.
424
+ *
425
+ * The id is the only thing a browser supplies. It selects among the
426
+ * caller's own conversations and nothing more: an id belonging to another
427
+ * user is a 404, and one belonging to another application the same, so a
428
+ * shared workspace's two products cannot read each other's.
429
+ *
430
+ * It travels as `?id=` rather than as a path segment, and the store routes
431
+ * are why. This table is keyed by an exact `METHOD /path` string; the only
432
+ * thing that matches a *prefix* is `isStoreRoute`, kept to a closed list
433
+ * precisely so the bridge cannot drift into a generic proxy. Widening that
434
+ * matcher for one more resource would be the first step of exactly that
435
+ * drift, so the id goes in the query and the route keys stay exact.
436
+ *
437
+ * `requireToken` rather than `requireWorkspace`: Connect resolves the
438
+ * workspace from the token itself and refuses a staff session there, so
439
+ * the extra identity round trip would buy a differently-worded 403 and
440
+ * nothing else.
441
+ */
442
+ "GET /ai/conversations": async (request) => {
443
+ const url = new URL(request.url);
444
+ const limit = url.searchParams.get("limit");
445
+ const cursor = url.searchParams.get("cursor");
446
+ return json(await client.ai.listConversations({
447
+ asUser: await requireToken(request),
448
+ // Forwarded only when present, so Connect's own defaults apply
449
+ // rather than this layer inventing a second set of them.
450
+ ...(limit !== null ? { limit: Number(limit) } : {}),
451
+ ...(cursor ? { cursor } : {}),
452
+ }));
453
+ },
454
+ "GET /ai/conversation": async (request) => {
455
+ const conversationId = new URL(request.url).searchParams.get("id");
456
+ if (!conversationId) {
457
+ throw new ConnectError({
458
+ status: 400,
459
+ code: "validation_error",
460
+ message: "id is required",
461
+ endpoint: "GET /ai/conversation",
462
+ });
463
+ }
464
+ return json(await client.ai.getConversation({
465
+ conversationId,
466
+ asUser: await requireToken(request),
467
+ }));
468
+ },
469
+ "DELETE /ai/conversation": async (request) => {
470
+ const conversationId = new URL(request.url).searchParams.get("id");
471
+ if (!conversationId) {
472
+ throw new ConnectError({
473
+ status: 400,
474
+ code: "validation_error",
475
+ message: "id is required",
476
+ endpoint: "DELETE /ai/conversation",
477
+ });
478
+ }
479
+ return json(await client.ai.deleteConversation({
480
+ conversationId,
481
+ asUser: await requireToken(request),
482
+ }));
483
+ },
409
484
  /**
410
485
  * The store, forwarded.
411
486
  *
@@ -30,6 +30,10 @@ export class AiResource {
30
30
  json: {
31
31
  workspaceId: input.workspaceId,
32
32
  messages: input.messages,
33
+ // Omitted when absent, which is how a caller opts out of history: no
34
+ // id, nothing stored. Connect also stores nothing for a call carrying
35
+ // no `asUser`, because a conversation belongs to a person.
36
+ ...(input.conversationId ? { conversationId: input.conversationId } : {}),
33
37
  // Omitted entirely when absent, rather than sent as `undefined` - an
34
38
  // application that describes no screens should send no field.
35
39
  ...(input.screens ? { screens: input.screens } : {}),
@@ -40,6 +44,57 @@ export class AiResource {
40
44
  signal: options.signal,
41
45
  });
42
46
  }
47
+ /**
48
+ * The signed-in user's past conversations in your application, most recent
49
+ * first.
50
+ *
51
+ * `asUser` is required rather than optional here, unlike on `chat`: the rows
52
+ * are scoped to a person, and there is no application-wide view of them to
53
+ * fall back to. Connect reads the user and the workspace from the token.
54
+ *
55
+ * Cursor-paginated with no total - the list is ordered by last use, which
56
+ * moves while it is being read.
57
+ */
58
+ listConversations(input) {
59
+ const query = new URLSearchParams();
60
+ if (input.limit !== undefined)
61
+ query.set("limit", String(input.limit));
62
+ if (input.cursor)
63
+ query.set("cursor", input.cursor);
64
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
65
+ return request(this.transport, {
66
+ method: "GET",
67
+ path: `/ai/conversations${suffix}`,
68
+ headers: actingAs(input.asUser),
69
+ });
70
+ }
71
+ /**
72
+ * One stored transcript, oldest turn first.
73
+ *
74
+ * `turns[].parts` comes back exactly as the model produced it - prose,
75
+ * reasoning and tool calls in order - so it can be handed straight back to a
76
+ * chat UI. Not this user's, or not your application's, is a `404` either way.
77
+ */
78
+ getConversation(input) {
79
+ return request(this.transport, {
80
+ method: "GET",
81
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
82
+ headers: actingAs(input.asUser),
83
+ });
84
+ }
85
+ /**
86
+ * Deletes a conversation and every turn beneath it.
87
+ *
88
+ * Idempotent - one already gone answers `{ deleted: true }`, so a retry after
89
+ * a dropped response does not read as a failure.
90
+ */
91
+ deleteConversation(input) {
92
+ return request(this.transport, {
93
+ method: "DELETE",
94
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
95
+ headers: actingAs(input.asUser),
96
+ });
97
+ }
43
98
  /** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
44
99
  async extract(input) {
45
100
  const result = await request(this.transport, {
@@ -0,0 +1,25 @@
1
+ import type { AiConversationDetail } from "../../types.js";
2
+ /**
3
+ * Past Ask AI conversations, for reopening or deleting.
4
+ *
5
+ * ## It fetches only while it is open
6
+ *
7
+ * `<ConnectAskAi>` is mounted for the whole application shell, so an
8
+ * unconditional read here would put a request on every page load to populate a
9
+ * list most people never open. It reads on open, and again whenever the panel
10
+ * has written a new conversation since - which is what `refreshKey` is for.
11
+ *
12
+ * ## Deletion confirms inline, not in a dialog
13
+ *
14
+ * Destructive actions confirm; this one does it at the weight it deserves.
15
+ * Losing a transcript is recoverable by asking again, not a cancelled
16
+ * subscription, and a modal over a 400px panel would be heavier than the thing
17
+ * it guards. The row swaps to "Delete? / Cancel" and stays there until
18
+ * answered.
19
+ */
20
+ export declare function AskAiHistory({ refreshKey, activeConversationId, onOpenConversation, }: Readonly<{
21
+ /** Changes when the panel has written something, so the list re-reads. */
22
+ refreshKey: number;
23
+ activeConversationId: string;
24
+ onOpenConversation: (id: string, detail: AiConversationDetail) => void;
25
+ }>): import("react").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import type { AiConversationTurn } from "../../types.js";
2
+ /**
3
+ * One piece of an assistant turn, in the order it arrived.
4
+ *
5
+ * Blocks rather than one text string, because an answer interleaves prose and
6
+ * drawings - a sentence, a table, the sentence that reads the table - and
7
+ * flattening it would silently reorder the model's own argument.
8
+ */
9
+ export type AskAiBlock = {
10
+ kind: "text";
11
+ text: string;
12
+ } | {
13
+ kind: "tool";
14
+ id: string;
15
+ name: string;
16
+ input: unknown | null;
17
+ };
18
+ export interface AskAiTurn {
19
+ id: string;
20
+ role: "user" | "assistant";
21
+ blocks: AskAiBlock[];
22
+ /** The model's streamed thoughts, if it sent any. */
23
+ reasoning: string;
24
+ }
25
+ /**
26
+ * Index of the final text block, or `-1`. A reverse loop rather than
27
+ * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
28
+ * published output stays usable on the runtimes its `engines` field claims.
29
+ */
30
+ export declare function lastTextBlockIndex(blocks: AskAiBlock[]): number;
31
+ /** Appends a text delta to the open text block, or opens one after a widget. */
32
+ export declare function appendText(blocks: AskAiBlock[], text: string): AskAiBlock[];
33
+ /**
34
+ * Rebuilds the panel's turns from a stored transcript.
35
+ *
36
+ * The inverse of what the stream reader does live, and it has to agree with it
37
+ * or a reopened conversation renders differently from the one the user just
38
+ * had. Two shapes, one source: the stream delivers `text-delta`,
39
+ * `reasoning-delta` and `tool-input-available` frames, and storage keeps the
40
+ * settled `UIMessage` parts those frames built - `{ type: "text" }`,
41
+ * `{ type: "reasoning" }`, and `{ type: "tool-<name>" }` carrying the input the
42
+ * widget was drawn from.
43
+ *
44
+ * Three rules, each of which is a thing that would otherwise render wrong:
45
+ *
46
+ * - **A tool part with no input is dropped, not restored.** `input: null` means
47
+ * "still arriving" to the renderer, which draws a skeleton - correct live,
48
+ * and a shimmer that never resolves on a transcript that finished days ago.
49
+ * - **Reasoning is hoisted per turn**, not left in place. It is rendered above
50
+ * the answer in a disclosure, which is where it sat while streaming.
51
+ * - **A part this build cannot place is skipped rather than guessed at.**
52
+ * `parts` is the AI SDK's own shape and gains kinds over time; an unknown one
53
+ * costs a widget, and inventing a rendering for it costs the whole turn.
54
+ */
55
+ export declare function turnsFromStored(turns: readonly AiConversationTurn[]): AskAiTurn[];
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
- import { type StreamdownProps } from "streamdown";
2
+ import type { StreamdownProps } from "streamdown";
3
3
  import type { ConnectScreen } from "../../types.js";
4
4
  export interface ConnectAskAiProps {
5
5
  /**
@@ -99,8 +99,15 @@ export interface ConnectContextValue {
99
99
  * `path` through `useConnectResource` still needs to call that hook's own
100
100
  * `refetch()` afterward; clearing the cache entry alone does not re-render
101
101
  * anything by itself.
102
+ *
103
+ * `method` widens this to the one other verb the bridge answers, `DELETE`,
104
+ * for the routes where removal is the whole operation and a body would be
105
+ * ceremony - Ask AI's history is the first. It stays a two-value union
106
+ * rather than `string`: everything else on this bridge is a POST, and a
107
+ * free-form verb here would be an invitation to invent REST semantics the
108
+ * bridge's exact-match route table does not have.
102
109
  */
103
- mutateResource<T>(path: string, body?: unknown): Promise<T>;
110
+ mutateResource<T>(path: string, body?: unknown, method?: "POST" | "DELETE"): Promise<T>;
104
111
  /**
105
112
  * Drops one path's cached read so the next `fetchResource` for it goes to
106
113
  * the bridge again.
@@ -126,4 +126,4 @@ export declare class ConnectClient {
126
126
  constructor(options: ConnectClientOptions);
127
127
  }
128
128
  /** Kept in step with `package.json` by `sdk.test.ts`. */
129
- export declare const SDK_VERSION = "0.29.0";
129
+ export declare const SDK_VERSION = "0.30.1";
@@ -1,5 +1,5 @@
1
1
  import { type TransportOptions } from "../http.js";
2
- import type { ChatMessage, ConnectScreen } from "../../types.js";
2
+ import type { AiConversationDetail, AiConversationList, ChatMessage, ConnectScreen } from "../../types.js";
3
3
  /**
4
4
  * RAG chat and structured extraction against a workspace's data.
5
5
  *
@@ -21,6 +21,15 @@ export declare class AiResource {
21
21
  workspaceId: string;
22
22
  messages: ChatMessage[];
23
23
  asUser?: string;
24
+ /**
25
+ * Your own id for this conversation, sent with every turn of it.
26
+ *
27
+ * What makes the conversation *resumable*: Connect upserts against it and
28
+ * `conversations.list`/`get` read it back. Persisted only alongside
29
+ * `asUser` - a conversation belongs to a person, and a server-to-server
30
+ * call acts as nobody. Omit it to keep a turn out of history entirely.
31
+ */
32
+ conversationId?: string;
24
33
  /**
25
34
  * Your application's screens, so the assistant answers in your product's
26
35
  * words and can link into it - see `ConnectScreen`. Prose and links only:
@@ -34,6 +43,45 @@ export declare class AiResource {
34
43
  }, options?: {
35
44
  signal?: AbortSignal;
36
45
  }): Promise<Response>;
46
+ /**
47
+ * The signed-in user's past conversations in your application, most recent
48
+ * first.
49
+ *
50
+ * `asUser` is required rather than optional here, unlike on `chat`: the rows
51
+ * are scoped to a person, and there is no application-wide view of them to
52
+ * fall back to. Connect reads the user and the workspace from the token.
53
+ *
54
+ * Cursor-paginated with no total - the list is ordered by last use, which
55
+ * moves while it is being read.
56
+ */
57
+ listConversations(input: {
58
+ asUser: string;
59
+ limit?: number;
60
+ cursor?: string;
61
+ }): Promise<AiConversationList>;
62
+ /**
63
+ * One stored transcript, oldest turn first.
64
+ *
65
+ * `turns[].parts` comes back exactly as the model produced it - prose,
66
+ * reasoning and tool calls in order - so it can be handed straight back to a
67
+ * chat UI. Not this user's, or not your application's, is a `404` either way.
68
+ */
69
+ getConversation(input: {
70
+ conversationId: string;
71
+ asUser: string;
72
+ }): Promise<AiConversationDetail>;
73
+ /**
74
+ * Deletes a conversation and every turn beneath it.
75
+ *
76
+ * Idempotent - one already gone answers `{ deleted: true }`, so a retry after
77
+ * a dropped response does not read as a failure.
78
+ */
79
+ deleteConversation(input: {
80
+ conversationId: string;
81
+ asUser: string;
82
+ }): Promise<{
83
+ deleted: true;
84
+ }>;
37
85
  /** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
38
86
  extract<T = unknown>(input: {
39
87
  schemaId: string;
@@ -538,6 +538,54 @@ export interface ChatMessage {
538
538
  role: "user" | "assistant" | "system";
539
539
  [field: string]: unknown;
540
540
  }
541
+ /**
542
+ * One row of Ask AI history.
543
+ *
544
+ * A conversation exists only if the turns that made it carried a
545
+ * `conversationId` *and* were sent on a user's behalf - see `AiResource.chat`.
546
+ * It belongs to one person in one of your workspaces, and never leaves your
547
+ * application: a workspace subscribing to two products keeps two lists.
548
+ */
549
+ export interface AiConversation {
550
+ id: string;
551
+ /** Derived from the first thing the user said - no model call, so it costs nothing. */
552
+ title: string;
553
+ /** ISO-8601. */
554
+ createdAt: string;
555
+ /**
556
+ * ISO-8601, and the sort key - a conversation returned to a week later is at
557
+ * the top, not where it was created.
558
+ */
559
+ updatedAt: string;
560
+ }
561
+ export interface AiConversationList {
562
+ conversations: AiConversation[];
563
+ /** Pass back as `cursor` for the next page; `null` at the end. */
564
+ nextCursor: string | null;
565
+ }
566
+ /** One stored turn, in the AI SDK's own shape. */
567
+ export interface AiConversationTurn {
568
+ id: string;
569
+ role: "user" | "assistant";
570
+ /**
571
+ * The message's parts exactly as the model produced them - prose, reasoning
572
+ * and tool calls, in the order they arrived.
573
+ *
574
+ * Opaque on purpose. The shape is the AI SDK's and moves with it, so a type
575
+ * restating it here would start rejecting transcripts the moment the SDK adds
576
+ * a part kind. Flattening it to text is the other mistake: the order is the
577
+ * model's own argument, and a flattened turn cannot be resumed.
578
+ */
579
+ parts: unknown[];
580
+ /** Position in the conversation. Turns arrive in this order. */
581
+ sequence: number;
582
+ /** ISO-8601. */
583
+ createdAt: string;
584
+ }
585
+ export interface AiConversationDetail {
586
+ conversation: AiConversation;
587
+ turns: AiConversationTurn[];
588
+ }
541
589
  export interface WorkspaceApp {
542
590
  applicationId: string;
543
591
  /** Stable, human-readable key - branch on this, not `applicationId`, which is per-environment. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",