@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.
- package/CHANGELOG.md +99 -0
- package/dist/cjs/client/components/ask-ai-history.js +106 -0
- package/dist/cjs/client/components/ask-ai-turns.js +87 -0
- package/dist/cjs/client/components/ask-ai.js +155 -30
- package/dist/cjs/client/provider.js +2 -2
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +75 -0
- package/dist/cjs/server/resources/ai.js +55 -0
- package/dist/esm/client/components/ask-ai-history.js +103 -0
- package/dist/esm/client/components/ask-ai-turns.js +82 -0
- package/dist/esm/client/components/ask-ai.js +122 -30
- package/dist/esm/client/provider.js +2 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +75 -0
- package/dist/esm/server/resources/ai.js +55 -0
- package/dist/types/client/components/ask-ai-history.d.ts +25 -0
- package/dist/types/client/components/ask-ai-turns.d.ts +55 -0
- package/dist/types/client/components/ask-ai.d.ts +1 -1
- package/dist/types/client/provider.d.ts +8 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/ai.d.ts +49 -1
- package/dist/types/types.d.ts +48 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,105 @@
|
|
|
3
3
|
`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
|
|
4
4
|
versions may carry breaking changes** and patch versions never do.
|
|
5
5
|
|
|
6
|
+
## 0.30.1
|
|
7
|
+
|
|
8
|
+
`ConnectAppShell` was putting **462 KB** of markdown renderer on every page of
|
|
9
|
+
every application platform that mounts it — which is every page, because the
|
|
10
|
+
shell owns the layout. `streamdown` was a static import in `ConnectAskAi`, so
|
|
11
|
+
it was downloaded, parsed and evaluated on each route to render nothing until
|
|
12
|
+
somebody opened the assistant, which most page views never do. Measured on a
|
|
13
|
+
partner app: 48 of its 49 routes carried it, roughly a quarter of each route's
|
|
14
|
+
JavaScript.
|
|
15
|
+
|
|
16
|
+
It is now `React.lazy`, so the chunk is requested the first time there is
|
|
17
|
+
markdown to render and cached by React from then on. No API changed and no
|
|
18
|
+
host setup changed; `streamdown` stays a peer dependency of `/client` and is
|
|
19
|
+
still needed for formatted answers.
|
|
20
|
+
|
|
21
|
+
The Suspense fallback is the **raw text** rather than a spinner. An answer
|
|
22
|
+
streams in token by token, and unformatted prose is readable — so the first
|
|
23
|
+
words now appear *while* the renderer is being fetched instead of behind it,
|
|
24
|
+
and re-render as markdown when it lands. On a cold cache that makes the first
|
|
25
|
+
answer feel earlier than it did before, not later.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `ConnectAskAi` loads `streamdown` on demand instead of at module scope.
|
|
30
|
+
Partner apps get the saving by upgrading; nothing in their code moves.
|
|
31
|
+
|
|
32
|
+
## 0.30.0
|
|
33
|
+
|
|
34
|
+
`<ConnectAskAi>` forgot everything on reload. Connect's own panel has had
|
|
35
|
+
History and a New-conversation button since it shipped; this is the same two
|
|
36
|
+
controls, backed by the same storage, for the panel a partner mounts.
|
|
37
|
+
|
|
38
|
+
The reason it took a server change rather than a component one: the
|
|
39
|
+
application chat lane deliberately stored nothing, because a conversation
|
|
40
|
+
belongs to a *person* and an API key names an application. That is still true
|
|
41
|
+
of a server-to-server call — it persists nothing, exactly as before. What
|
|
42
|
+
changed is that the browser path was already sending a signed-in user's
|
|
43
|
+
session token, so there has been a real owner to file a transcript under for
|
|
44
|
+
some time, and nothing was doing it.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **History and New in `<ConnectAskAi>`.** Two buttons in the panel header. New
|
|
49
|
+
clears the transcript and starts a fresh conversation without deleting the
|
|
50
|
+
old one; History lists what this user has asked *in your application*, and
|
|
51
|
+
pressing a row reopens the transcript and keeps writing into it.
|
|
52
|
+
|
|
53
|
+
Nothing to wire up: if your API key carries `ai:chat` it already works. There
|
|
54
|
+
is no prop to turn it on, and none to turn it off — a chat panel that cannot
|
|
55
|
+
show you what you asked yesterday is the odd one out, not the default.
|
|
56
|
+
|
|
57
|
+
- `connect.ai.listConversations({ asUser, limit?, cursor? })`,
|
|
58
|
+
`connect.ai.getConversation({ conversationId, asUser })` and
|
|
59
|
+
`connect.ai.deleteConversation({ conversationId, asUser })`, plus the types
|
|
60
|
+
they answer with: `AiConversation`, `AiConversationList`,
|
|
61
|
+
`AiConversationTurn` and `AiConversationDetail`.
|
|
62
|
+
|
|
63
|
+
`asUser` is **required** on all three, unlike on `ai.chat` where it is
|
|
64
|
+
optional. There is no application-wide view of these rows to fall back to:
|
|
65
|
+
they are scoped to a person, and a version of this call that read every
|
|
66
|
+
user's transcripts is not a smaller feature, it is a different and worse one.
|
|
67
|
+
|
|
68
|
+
`turns[].parts` comes back as the AI SDK's own `UIMessage` parts, in the
|
|
69
|
+
order the model produced them — prose, reasoning and tool calls interleaved.
|
|
70
|
+
Hand them to your chat UI as they are. Flattening a turn to text loses the
|
|
71
|
+
model's own argument and makes the conversation unresumable, which is why
|
|
72
|
+
these are typed `unknown[]` rather than restated in a schema that would start
|
|
73
|
+
rejecting transcripts the day the AI SDK adds a part kind.
|
|
74
|
+
|
|
75
|
+
- `connect.ai.chat({ conversationId })`, optional. Generate one id per
|
|
76
|
+
conversation client-side and send it with every turn of it; Connect upserts
|
|
77
|
+
against it. **No id, nothing stored** — which is how a caller opts out of
|
|
78
|
+
history entirely, and it stays the behaviour for any call made without
|
|
79
|
+
`asUser`.
|
|
80
|
+
|
|
81
|
+
- `createConnectHandler` answers three more routes —
|
|
82
|
+
`GET /ai/conversations`, `GET /ai/conversation?id=`,
|
|
83
|
+
`DELETE /ai/conversation?id=` — and relays `conversationId` on
|
|
84
|
+
`POST /ai/chat`. The owner comes from the caller's own session on all four;
|
|
85
|
+
there is no parameter that could name somebody else.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- `mutateResource(path, body?, method?)` takes an optional third argument,
|
|
90
|
+
`"POST"` (the default, and what every existing call gets) or `"DELETE"`.
|
|
91
|
+
Existing calls are unaffected.
|
|
92
|
+
|
|
93
|
+
### Notes
|
|
94
|
+
|
|
95
|
+
- **Scoped to your application, not just to the workspace.** A workspace
|
|
96
|
+
subscribing to two products on Connect keeps two separate histories, and
|
|
97
|
+
neither can read the other's — nor the one that user keeps in the Connect
|
|
98
|
+
portal itself. An id from the wrong one answers `404`, not somebody else's
|
|
99
|
+
transcript.
|
|
100
|
+
|
|
101
|
+
- **A transcript is not an audit trail.** It records what was asked and
|
|
102
|
+
answered, and a user can delete their own. If you need a durable record of
|
|
103
|
+
something the assistant *did*, that is your own write and your own log.
|
|
104
|
+
|
|
6
105
|
## 0.29.0
|
|
7
106
|
|
|
8
107
|
Voice was readable and writing was not. `calls.list` has always let a partner
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.AskAiHistory = AskAiHistory;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const provider_js_1 = require("../provider.js");
|
|
10
|
+
/** Bridge paths, spelled once - the list is read and invalidated from three places. */
|
|
11
|
+
const LIST_PATH = "/ai/conversations";
|
|
12
|
+
const detailPath = (id) => `/ai/conversation?id=${encodeURIComponent(id)}`;
|
|
13
|
+
/**
|
|
14
|
+
* Past Ask AI conversations, for reopening or deleting.
|
|
15
|
+
*
|
|
16
|
+
* ## It fetches only while it is open
|
|
17
|
+
*
|
|
18
|
+
* `<ConnectAskAi>` is mounted for the whole application shell, so an
|
|
19
|
+
* unconditional read here would put a request on every page load to populate a
|
|
20
|
+
* list most people never open. It reads on open, and again whenever the panel
|
|
21
|
+
* has written a new conversation since - which is what `refreshKey` is for.
|
|
22
|
+
*
|
|
23
|
+
* ## Deletion confirms inline, not in a dialog
|
|
24
|
+
*
|
|
25
|
+
* Destructive actions confirm; this one does it at the weight it deserves.
|
|
26
|
+
* Losing a transcript is recoverable by asking again, not a cancelled
|
|
27
|
+
* subscription, and a modal over a 400px panel would be heavier than the thing
|
|
28
|
+
* it guards. The row swaps to "Delete? / Cancel" and stays there until
|
|
29
|
+
* answered.
|
|
30
|
+
*/
|
|
31
|
+
function AskAiHistory({ refreshKey, activeConversationId, onOpenConversation, }) {
|
|
32
|
+
const { fetchResource, mutateResource, invalidateResource } = (0, provider_js_1.useConnect)();
|
|
33
|
+
const [conversations, setConversations] = (0, react_1.useState)(null);
|
|
34
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
35
|
+
const [confirmingId, setConfirmingId] = (0, react_1.useState)(null);
|
|
36
|
+
const [busyId, setBusyId] = (0, react_1.useState)(null);
|
|
37
|
+
(0, react_1.useEffect)(() => {
|
|
38
|
+
let active = true;
|
|
39
|
+
// The cached entry is dropped first. This component mounts only when the
|
|
40
|
+
// user asks for the list, and the provider's cache would otherwise hand
|
|
41
|
+
// them the copy taken before the last three questions they asked.
|
|
42
|
+
invalidateResource(LIST_PATH);
|
|
43
|
+
fetchResource(LIST_PATH)
|
|
44
|
+
.then((page) => {
|
|
45
|
+
if (!active)
|
|
46
|
+
return;
|
|
47
|
+
setConversations(page.conversations);
|
|
48
|
+
setError(null);
|
|
49
|
+
})
|
|
50
|
+
.catch((cause) => {
|
|
51
|
+
if (!active)
|
|
52
|
+
return;
|
|
53
|
+
// An empty list rather than a permanent skeleton: the error is shown
|
|
54
|
+
// beside it, and a panel stuck loading says nothing at all.
|
|
55
|
+
setConversations([]);
|
|
56
|
+
setError(cause instanceof Error ? cause.message : "Could not load your conversations");
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
active = false;
|
|
60
|
+
};
|
|
61
|
+
}, [fetchResource, invalidateResource, refreshKey]);
|
|
62
|
+
const open = async (id) => {
|
|
63
|
+
setBusyId(id);
|
|
64
|
+
try {
|
|
65
|
+
const detail = await fetchResource(detailPath(id));
|
|
66
|
+
onOpenConversation(id, detail);
|
|
67
|
+
}
|
|
68
|
+
catch (cause) {
|
|
69
|
+
setError(cause instanceof Error ? cause.message : "Could not open that conversation");
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
setBusyId(null);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const remove = async (id) => {
|
|
76
|
+
setBusyId(id);
|
|
77
|
+
// Removed from the list before the response lands. The confirmation was the
|
|
78
|
+
// decision point, and a row that lingers while the request flies reads as a
|
|
79
|
+
// press that did not take - so the list is patched and put back on failure.
|
|
80
|
+
const previous = conversations;
|
|
81
|
+
setConversations((current) => current?.filter((row) => row.id !== id) ?? null);
|
|
82
|
+
setConfirmingId(null);
|
|
83
|
+
try {
|
|
84
|
+
await mutateResource(detailPath(id), undefined, "DELETE");
|
|
85
|
+
invalidateResource(LIST_PATH);
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
setConversations(previous);
|
|
89
|
+
setError(cause instanceof Error ? cause.message : "Could not delete that conversation");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
setBusyId(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (conversations === null) {
|
|
96
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "space-y-2 p-4", "aria-busy": "true", children: [0, 1, 2].map((row) => ((0, jsx_runtime_1.jsx)("div", { className: "h-9 animate-pulse rounded-md bg-surface-secondary" }, row))) }));
|
|
97
|
+
}
|
|
98
|
+
if (conversations.length === 0) {
|
|
99
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "p-4", children: error ? ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error })) : ((0, jsx_runtime_1.jsx)("p", { className: "text-center text-xs text-muted", children: "Nothing here yet. Conversations you have are saved automatically." })) }));
|
|
100
|
+
}
|
|
101
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "p-2", children: [error && (0, jsx_runtime_1.jsx)("p", { className: "px-2 pb-2 text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("ul", { className: "space-y-0.5", children: conversations.map((conversation) => {
|
|
102
|
+
const isConfirming = confirmingId === conversation.id;
|
|
103
|
+
const isActive = conversation.id === activeConversationId;
|
|
104
|
+
return ((0, jsx_runtime_1.jsx)("li", { children: isConfirming ? ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-md border border-border px-2 py-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "flex-1 truncate text-xs text-muted", children: "Delete this conversation?" }), (0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "danger", className: "h-7 px-2 text-xs", isDisabled: busyId === conversation.id, onPress: () => void remove(conversation.id), children: "Delete" }), (0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "tertiary", className: "h-7 px-2 text-xs", onPress: () => setConfirmingId(null), children: "Cancel" })] })) : ((0, jsx_runtime_1.jsxs)("div", { className: `group flex items-center gap-1 rounded-md ${isActive ? "bg-surface-secondary" : ""}`, children: [(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "ghost", className: "h-auto min-w-0 flex-1 justify-start gap-2 px-2 py-1.5 text-start", isDisabled: busyId === conversation.id, onPress: () => void open(conversation.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MessageSquare, { className: "size-3.5 shrink-0 text-muted" }), (0, jsx_runtime_1.jsx)("span", { className: "min-w-0 flex-1 truncate text-xs font-normal", children: conversation.title })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "ghost", className: "shrink-0", "aria-label": `Delete "${conversation.title}"`, onPress: () => setConfirmingId(conversation.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { className: "size-3.5 text-muted" }) })] })) }, conversation.id));
|
|
105
|
+
}) })] }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.lastTextBlockIndex = lastTextBlockIndex;
|
|
4
|
+
exports.appendText = appendText;
|
|
5
|
+
exports.turnsFromStored = turnsFromStored;
|
|
6
|
+
/**
|
|
7
|
+
* Index of the final text block, or `-1`. A reverse loop rather than
|
|
8
|
+
* `findLastIndex`, which is ES2023 - this package targets ES2022 so its
|
|
9
|
+
* published output stays usable on the runtimes its `engines` field claims.
|
|
10
|
+
*/
|
|
11
|
+
function lastTextBlockIndex(blocks) {
|
|
12
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
13
|
+
if (blocks[index]?.kind === "text")
|
|
14
|
+
return index;
|
|
15
|
+
}
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
/** Appends a text delta to the open text block, or opens one after a widget. */
|
|
19
|
+
function appendText(blocks, text) {
|
|
20
|
+
const last = blocks.at(-1);
|
|
21
|
+
if (last?.kind === "text") {
|
|
22
|
+
return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
|
|
23
|
+
}
|
|
24
|
+
return [...blocks, { kind: "text", text }];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Rebuilds the panel's turns from a stored transcript.
|
|
28
|
+
*
|
|
29
|
+
* The inverse of what the stream reader does live, and it has to agree with it
|
|
30
|
+
* or a reopened conversation renders differently from the one the user just
|
|
31
|
+
* had. Two shapes, one source: the stream delivers `text-delta`,
|
|
32
|
+
* `reasoning-delta` and `tool-input-available` frames, and storage keeps the
|
|
33
|
+
* settled `UIMessage` parts those frames built - `{ type: "text" }`,
|
|
34
|
+
* `{ type: "reasoning" }`, and `{ type: "tool-<name>" }` carrying the input the
|
|
35
|
+
* widget was drawn from.
|
|
36
|
+
*
|
|
37
|
+
* Three rules, each of which is a thing that would otherwise render wrong:
|
|
38
|
+
*
|
|
39
|
+
* - **A tool part with no input is dropped, not restored.** `input: null` means
|
|
40
|
+
* "still arriving" to the renderer, which draws a skeleton - correct live,
|
|
41
|
+
* and a shimmer that never resolves on a transcript that finished days ago.
|
|
42
|
+
* - **Reasoning is hoisted per turn**, not left in place. It is rendered above
|
|
43
|
+
* the answer in a disclosure, which is where it sat while streaming.
|
|
44
|
+
* - **A part this build cannot place is skipped rather than guessed at.**
|
|
45
|
+
* `parts` is the AI SDK's own shape and gains kinds over time; an unknown one
|
|
46
|
+
* costs a widget, and inventing a rendering for it costs the whole turn.
|
|
47
|
+
*/
|
|
48
|
+
function turnsFromStored(turns) {
|
|
49
|
+
return turns.map((turn) => {
|
|
50
|
+
let blocks = [];
|
|
51
|
+
let reasoning = "";
|
|
52
|
+
for (const raw of turn.parts) {
|
|
53
|
+
const part = raw;
|
|
54
|
+
if (typeof part?.type !== "string")
|
|
55
|
+
continue;
|
|
56
|
+
if (part.type === "text") {
|
|
57
|
+
if (typeof part.text === "string" && part.text.length > 0) {
|
|
58
|
+
blocks = appendText(blocks, part.text);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (part.type === "reasoning") {
|
|
63
|
+
if (typeof part.text === "string")
|
|
64
|
+
reasoning += part.text;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (part.type.startsWith("tool-")) {
|
|
68
|
+
// See the note above: an absent input is a skeleton forever.
|
|
69
|
+
if (part.input === undefined || part.input === null)
|
|
70
|
+
continue;
|
|
71
|
+
blocks = [
|
|
72
|
+
...blocks,
|
|
73
|
+
{
|
|
74
|
+
kind: "tool",
|
|
75
|
+
// The stored call id when there is one. Falling back to the block's
|
|
76
|
+
// position keeps React keys unique within the turn, which is all
|
|
77
|
+
// this id is used for once the turn is settled.
|
|
78
|
+
id: typeof part.toolCallId === "string" ? part.toolCallId : `${turn.id}-${blocks.length}`,
|
|
79
|
+
name: part.type.slice("tool-".length),
|
|
80
|
+
input: part.input,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { id: turn.id, role: turn.role, blocks, reasoning };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
3
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
37
|
exports.ConnectAskAi = ConnectAskAi;
|
|
5
38
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
39
|
const react_1 = require("react");
|
|
7
40
|
const react_2 = require("@heroui/react");
|
|
8
41
|
const lucide_react_1 = require("lucide-react");
|
|
9
|
-
const streamdown_1 = require("streamdown");
|
|
10
42
|
const provider_js_1 = require("../provider.js");
|
|
43
|
+
/**
|
|
44
|
+
* The markdown renderer, fetched the first time there is markdown to render.
|
|
45
|
+
*
|
|
46
|
+
* `streamdown` is **462 KB** of the host's client bundle, and a static import
|
|
47
|
+
* here put all of it on every page of every application platform that mounts
|
|
48
|
+
* `ConnectAppShell` - which is every page, because the shell owns the layout.
|
|
49
|
+
* It rendered nothing until somebody opened the assistant, which most page
|
|
50
|
+
* views never do. Measured on one partner app: 48 of 49 routes carrying it,
|
|
51
|
+
* about a quarter of each route's JavaScript.
|
|
52
|
+
*
|
|
53
|
+
* `lazy` rather than a conditional `import()` in an effect: React caches the
|
|
54
|
+
* module promise, so the chunk is requested once no matter how many messages
|
|
55
|
+
* render, and the boundary below is what covers the gap.
|
|
56
|
+
*
|
|
57
|
+
* The fallback is the **raw text**, not a spinner. An answer streams in token
|
|
58
|
+
* by token, and unformatted prose is readable - so the first words appear
|
|
59
|
+
* while the renderer is still arriving rather than behind it. Once it lands,
|
|
60
|
+
* the same text re-renders as markdown.
|
|
61
|
+
*/
|
|
62
|
+
const Streamdown = (0, react_1.lazy)(async () => {
|
|
63
|
+
const mod = await Promise.resolve().then(() => __importStar(require("streamdown")));
|
|
64
|
+
return { default: mod.Streamdown };
|
|
65
|
+
});
|
|
11
66
|
const use_active_path_js_1 = require("../use-active-path.js");
|
|
12
67
|
const ai_screens_js_1 = require("./ai-screens.js");
|
|
13
68
|
const ai_widgets_js_1 = require("./ai-widgets.js");
|
|
69
|
+
const ask_ai_history_js_1 = require("./ask-ai-history.js");
|
|
70
|
+
const ask_ai_turns_js_1 = require("./ask-ai-turns.js");
|
|
14
71
|
/**
|
|
15
72
|
* The panel's open width. The *inner* pane always carries it, so the content
|
|
16
73
|
* keeps its layout while the outer shell's width animates to zero around it.
|
|
@@ -170,26 +227,6 @@ async function readStream(response, onChunk) {
|
|
|
170
227
|
}
|
|
171
228
|
}
|
|
172
229
|
}
|
|
173
|
-
/**
|
|
174
|
-
* Index of the final text block, or `-1`. A reverse loop rather than
|
|
175
|
-
* `findLastIndex`, which is ES2023 - this package targets ES2022 so its
|
|
176
|
-
* published output stays usable on the runtimes its `engines` field claims.
|
|
177
|
-
*/
|
|
178
|
-
function lastTextBlockIndex(blocks) {
|
|
179
|
-
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
180
|
-
if (blocks[index]?.kind === "text")
|
|
181
|
-
return index;
|
|
182
|
-
}
|
|
183
|
-
return -1;
|
|
184
|
-
}
|
|
185
|
-
/** Appends a text delta to the open text block, or opens one after a widget. */
|
|
186
|
-
function appendText(blocks, text) {
|
|
187
|
-
const last = blocks.at(-1);
|
|
188
|
-
if (last?.kind === "text") {
|
|
189
|
-
return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
|
|
190
|
-
}
|
|
191
|
-
return [...blocks, { kind: "text", text }];
|
|
192
|
-
}
|
|
193
230
|
/**
|
|
194
231
|
* Ask AI - a chat panel grounded in the signed-in workspace's Context Index,
|
|
195
232
|
* streamed through your own bridge.
|
|
@@ -238,6 +275,31 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
238
275
|
const [input, setInput] = (0, react_1.useState)("");
|
|
239
276
|
const [isBusy, setIsBusy] = (0, react_1.useState)(false);
|
|
240
277
|
const [error, setError] = (0, react_1.useState)(null);
|
|
278
|
+
const [showHistory, setShowHistory] = (0, react_1.useState)(false);
|
|
279
|
+
/**
|
|
280
|
+
* The conversation being written to.
|
|
281
|
+
*
|
|
282
|
+
* Generated here, before the first message, because the server cannot hand
|
|
283
|
+
* one back: it is streaming a response, and there is no point in that stream
|
|
284
|
+
* at which a new id could reach the browser ahead of the first token. So the
|
|
285
|
+
* client picks it and Connect upserts - safe because ownership is checked
|
|
286
|
+
* rather than assumed, and an id naming somebody else's conversation is
|
|
287
|
+
* refused.
|
|
288
|
+
*
|
|
289
|
+
* Lazily initialised rather than computed in a `useMemo`: this has to survive
|
|
290
|
+
* re-renders and change only when the user starts a new conversation, which
|
|
291
|
+
* is precisely what `useMemo` does not promise.
|
|
292
|
+
*/
|
|
293
|
+
const [conversationId, setConversationId] = (0, react_1.useState)(() => newConversationId());
|
|
294
|
+
/**
|
|
295
|
+
* Bumped whenever a turn has been written, so the history list re-reads the
|
|
296
|
+
* next time it is opened.
|
|
297
|
+
*
|
|
298
|
+
* A counter rather than a refetch call, because the list is unmounted while
|
|
299
|
+
* closed - there is nothing to tell. It is *why* the list re-reads, not the
|
|
300
|
+
* list itself.
|
|
301
|
+
*/
|
|
302
|
+
const [writeCount, setWriteCount] = (0, react_1.useState)(0);
|
|
241
303
|
const abortRef = (0, react_1.useRef)(null);
|
|
242
304
|
const bottomRef = (0, react_1.useRef)(null);
|
|
243
305
|
// Ids only have to be unique within one mounted conversation, so a counter is
|
|
@@ -260,11 +322,11 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
260
322
|
*/
|
|
261
323
|
const resolvedPath = (0, use_active_path_js_1.useActivePath)(activePath);
|
|
262
324
|
const currentScreen = (0, react_1.useMemo)(() => (resolvedPath ? (0, ai_screens_js_1.matchConnectScreen)(screens, resolvedPath) : null), [screens, resolvedPath]);
|
|
263
|
-
const renderMarkdown = (0, react_1.useCallback)((text, isStreaming = false) => ((0, jsx_runtime_1.jsx)(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
325
|
+
const renderMarkdown = (0, react_1.useCallback)((text, isStreaming = false) => ((0, jsx_runtime_1.jsx)(react_1.Suspense, { fallback: (0, jsx_runtime_1.jsx)("div", { className: "connect-ai-body whitespace-pre-wrap", children: text }), children: (0, jsx_runtime_1.jsx)(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
|
|
326
|
+
// Retrieved documents cite real URLs; an interstitial on every one of
|
|
327
|
+
// them turns a citation into a chore. The renderer still sanitises the
|
|
328
|
+
// markup itself, which is the part that matters.
|
|
329
|
+
linkSafety: LINK_SAFETY, children: text }) })), [markdownPlugins]);
|
|
268
330
|
const stop = (0, react_1.useCallback)(() => {
|
|
269
331
|
abortRef.current?.abort();
|
|
270
332
|
abortRef.current = null;
|
|
@@ -312,6 +374,11 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
312
374
|
// decide what anyone may do. See `ConnectScreen`.
|
|
313
375
|
...(resolvedPath ? { screenContext: { pathname: resolvedPath } } : {}),
|
|
314
376
|
...(screens.length > 0 ? { screens } : {}),
|
|
377
|
+
// What makes the conversation resumable. Connect stores the turn
|
|
378
|
+
// against it and `<AskAiHistory>` reads it back; a turn sent
|
|
379
|
+
// without one is answered exactly the same way and remembered by
|
|
380
|
+
// nobody.
|
|
381
|
+
conversationId,
|
|
315
382
|
}, { signal: controller.signal });
|
|
316
383
|
// The assistant's turn is appended only once the stream is live, so a
|
|
317
384
|
// failed request leaves an error under the question rather than an
|
|
@@ -319,7 +386,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
319
386
|
setTurns((current) => [...current, { id: answerId, role: "assistant", blocks: [], reasoning: "" }]);
|
|
320
387
|
await readStream(response, (chunk) => {
|
|
321
388
|
if (chunk.text) {
|
|
322
|
-
patchAnswer((turn) => ({ ...turn, blocks: appendText(turn.blocks, chunk.text) }));
|
|
389
|
+
patchAnswer((turn) => ({ ...turn, blocks: (0, ask_ai_turns_js_1.appendText)(turn.blocks, chunk.text) }));
|
|
323
390
|
}
|
|
324
391
|
if (chunk.reasoning) {
|
|
325
392
|
patchAnswer((turn) => ({ ...turn, reasoning: turn.reasoning + chunk.reasoning }));
|
|
@@ -348,13 +415,46 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
348
415
|
if (abortRef.current === controller)
|
|
349
416
|
abortRef.current = null;
|
|
350
417
|
setIsBusy(false);
|
|
418
|
+
// Even on an aborted or failed turn: the user's own question has
|
|
419
|
+
// already been persisted by the time anything can go wrong downstream
|
|
420
|
+
// of it, so the list is stale either way.
|
|
421
|
+
setWriteCount((count) => count + 1);
|
|
351
422
|
}
|
|
352
|
-
}, [streamResource, turns]);
|
|
423
|
+
}, [streamResource, turns, conversationId]);
|
|
424
|
+
/**
|
|
425
|
+
* Clears the transcript and starts writing into a fresh conversation.
|
|
426
|
+
*
|
|
427
|
+
* The old one is not deleted - it is in History, which is the whole point.
|
|
428
|
+
*/
|
|
429
|
+
const startNewConversation = (0, react_1.useCallback)(() => {
|
|
430
|
+
abortRef.current?.abort();
|
|
431
|
+
abortRef.current = null;
|
|
432
|
+
setIsBusy(false);
|
|
433
|
+
setTurns([]);
|
|
434
|
+
setError(null);
|
|
435
|
+
setShowHistory(false);
|
|
436
|
+
setConversationId(newConversationId());
|
|
437
|
+
}, []);
|
|
438
|
+
/**
|
|
439
|
+
* Replaces the transcript with a stored one, and continues writing into it.
|
|
440
|
+
*
|
|
441
|
+
* Writing continues into the conversation that was opened rather than forking
|
|
442
|
+
* a new one, which is what makes "come back to this tomorrow" work at all.
|
|
443
|
+
*/
|
|
444
|
+
const openConversation = (0, react_1.useCallback)((id, detail) => {
|
|
445
|
+
abortRef.current?.abort();
|
|
446
|
+
abortRef.current = null;
|
|
447
|
+
setIsBusy(false);
|
|
448
|
+
setError(null);
|
|
449
|
+
setTurns((0, ask_ai_turns_js_1.turnsFromStored)(detail.turns));
|
|
450
|
+
setConversationId(id);
|
|
451
|
+
setShowHistory(false);
|
|
452
|
+
}, []);
|
|
353
453
|
const lastTurn = turns.at(-1);
|
|
354
454
|
const isAnswering = isBusy && lastTurn?.role === "assistant";
|
|
355
455
|
const rendered = (0, react_1.useMemo)(() => turns.map((turn) => {
|
|
356
456
|
const isLive = isAnswering && turn.id === lastTurn?.id;
|
|
357
|
-
const lastTextIndex = lastTextBlockIndex(turn.blocks);
|
|
457
|
+
const lastTextIndex = (0, ask_ai_turns_js_1.lastTextBlockIndex)(turn.blocks);
|
|
358
458
|
return ((0, jsx_runtime_1.jsxs)("div", { className: `connect-ai-host rounded-md border border-border p-2.5 text-sm ${turn.role === "user" ? "bg-surface-secondary" : "bg-accent/5"}`, children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: turn.role === "user" ? "You" : assistantName }), turn.role === "assistant" && ((0, jsx_runtime_1.jsx)(ThinkingDisclosure, { text: turn.reasoning, isStreaming: isLive && lastTextIndex === -1 })), turn.blocks.map((block, index) => block.kind === "text" ? (
|
|
359
459
|
// The user's own turn is shown as they typed it - running a
|
|
360
460
|
// question that happens to start with `#` through the markdown
|
|
@@ -371,9 +471,34 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
|
|
|
371
471
|
if (!text || isBusy)
|
|
372
472
|
return;
|
|
373
473
|
setInput("");
|
|
474
|
+
// Typing sends you back to the conversation: staying on the history list
|
|
475
|
+
// while an answer streams behind it would hide the thing just asked for.
|
|
476
|
+
setShowHistory(false);
|
|
374
477
|
void send(text);
|
|
375
478
|
};
|
|
376
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "
|
|
479
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "New conversation",
|
|
480
|
+
// Disabled on an empty one rather than hidden: a control that
|
|
481
|
+
// appears and disappears as you use the panel is harder to find
|
|
482
|
+
// than one that is always in the same place.
|
|
483
|
+
isDisabled: turns.length === 0, onPress: startNewConversation, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: (0, jsx_runtime_1.jsx)(lucide_react_1.History, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), showHistory ? ((0, jsx_runtime_1.jsx)("div", { className: "min-h-0 flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsx)(ask_ai_history_js_1.AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : ((0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] })), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
|
|
377
484
|
}
|
|
378
485
|
/** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
|
|
379
486
|
const LINK_SAFETY = { enabled: false };
|
|
487
|
+
/**
|
|
488
|
+
* A fresh conversation id.
|
|
489
|
+
*
|
|
490
|
+
* `crypto.randomUUID` where it exists, and it usually does - but it is gated on
|
|
491
|
+
* a *secure context*, so it is simply absent on an `http://` origin that is not
|
|
492
|
+
* localhost. That is somebody's LAN dev server or a staging box on a bare IP,
|
|
493
|
+
* and a panel that throws there rather than chatting would be a poor trade for
|
|
494
|
+
* four lines. The fallback is not cryptographically strong and does not need to
|
|
495
|
+
* be: the id selects among the caller's own conversations and grants nothing -
|
|
496
|
+
* Connect refuses one belonging to another user or another application - so its
|
|
497
|
+
* only real job is not to collide with itself.
|
|
498
|
+
*/
|
|
499
|
+
function newConversationId() {
|
|
500
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
501
|
+
return crypto.randomUUID();
|
|
502
|
+
}
|
|
503
|
+
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
|
504
|
+
}
|
|
@@ -108,9 +108,9 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
108
108
|
cache.current.set(key, pending);
|
|
109
109
|
return pending;
|
|
110
110
|
}, [basePath, fetchImpl]);
|
|
111
|
-
const mutateResource = (0, react_1.useCallback)(async (path, body) => {
|
|
111
|
+
const mutateResource = (0, react_1.useCallback)(async (path, body, method = "POST") => {
|
|
112
112
|
const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
|
|
113
|
-
method
|
|
113
|
+
method,
|
|
114
114
|
credentials: "same-origin",
|
|
115
115
|
headers: {
|
|
116
116
|
accept: "application/json",
|