@btst/stack 4.2.0 → 4.4.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.
Files changed (72) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.cjs +249 -0
  3. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +241 -0
  4. package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +20 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +20 -0
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
  7. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +38 -3
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +38 -3
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +173 -72
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +176 -75
  12. package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.cjs +30 -0
  13. package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.mjs +27 -0
  14. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +2 -0
  15. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +2 -0
  16. package/dist/packages/ui/src/components/markdown-content.cjs +9 -9
  17. package/dist/packages/ui/src/components/markdown-content.mjs +9 -9
  18. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  19. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  20. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  21. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  22. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  23. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  24. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  25. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  26. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  27. package/dist/plugins/ai-chat/client/index.d.cts +3 -3
  28. package/dist/plugins/ai-chat/client/index.d.mts +3 -3
  29. package/dist/plugins/ai-chat/client/index.d.ts +3 -3
  30. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  31. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  32. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  33. package/dist/plugins/blog/api/index.d.cts +2 -2
  34. package/dist/plugins/blog/api/index.d.mts +2 -2
  35. package/dist/plugins/blog/api/index.d.ts +2 -2
  36. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  37. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  38. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  39. package/dist/plugins/blog/client/index.d.cts +2 -2
  40. package/dist/plugins/blog/client/index.d.mts +2 -2
  41. package/dist/plugins/blog/client/index.d.ts +2 -2
  42. package/dist/plugins/blog/query-keys.d.cts +2 -2
  43. package/dist/plugins/blog/query-keys.d.mts +2 -2
  44. package/dist/plugins/blog/query-keys.d.ts +2 -2
  45. package/dist/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
  46. package/dist/shared/{stack.DBH1Bjko.d.ts → stack.C4zspX0T.d.ts} +13 -2
  47. package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
  48. package/dist/shared/{stack.D5b9l24t.d.ts → stack.Clil75lW.d.ts} +35 -35
  49. package/dist/shared/{stack.xAiy2VtO.d.cts → stack.DDQlE2Pm.d.cts} +10 -3
  50. package/dist/shared/{stack.Ce6V7eDj.d.cts → stack.DNw6Hv5o.d.cts} +35 -35
  51. package/dist/shared/{stack.CTneIW0u.d.mts → stack.DhZtP2JZ.d.mts} +35 -35
  52. package/dist/shared/{stack.C-eQN_Jt.d.ts → stack.DlehgNZZ.d.ts} +10 -3
  53. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
  54. package/dist/shared/{stack.B7Mj74BQ.d.mts → stack.Ps3MDhTC.d.mts} +13 -2
  55. package/dist/shared/{stack.C5FZr-jJ.d.mts → stack.tAUeRAtd.d.mts} +10 -3
  56. package/dist/shared/{stack.Dgt3MCtW.d.cts → stack.ymWsP4HZ.d.cts} +13 -2
  57. package/package.json +2 -2
  58. package/src/__tests__/markdown-link-navigation.test.tsx +29 -0
  59. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +22 -2
  60. package/src/plugins/ai-chat/__tests__/compaction.test.ts +272 -0
  61. package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +103 -1
  62. package/src/plugins/ai-chat/api/compaction.ts +310 -0
  63. package/src/plugins/ai-chat/api/operations.ts +25 -0
  64. package/src/plugins/ai-chat/api/plugin.ts +14 -0
  65. package/src/plugins/ai-chat/client/components/chat-interface.tsx +58 -3
  66. package/src/plugins/ai-chat/client/components/chat-layout.tsx +197 -72
  67. package/src/plugins/ai-chat/client/context-checkpoint.ts +47 -0
  68. package/src/plugins/ai-chat/client/localization/index.ts +5 -0
  69. package/src/plugins/ai-chat/schemas.ts +2 -0
  70. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  71. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  72. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
@@ -219,13 +219,33 @@ describe("ai-chat classNames overrides (issue #36)", () => {
219
219
  expect(list.className).toContain("flex-col");
220
220
  });
221
221
 
222
- it("applies classNames.container to the widget root", async () => {
223
- await render(<ChatLayout layout="widget" />);
222
+ it("preserves container customizations when the widget expands", async () => {
223
+ await render(
224
+ <ChatLayout
225
+ layout="widget"
226
+ defaultOpen
227
+ expandable
228
+ className="chat-theme right-10 bottom-20 z-40"
229
+ style={{ fontFamily: "monospace", right: 24, zIndex: 40 }}
230
+ />,
231
+ );
224
232
 
225
233
  const trigger = query('[data-testid="widget-trigger"]');
226
234
  const widgetRoot = trigger.parentElement!;
227
235
  expect(widgetRoot.className).toContain(CLASS_NAMES.container);
228
236
  expect(widgetRoot.className).toContain("items-end");
237
+ await act(async () => query('[aria-label="Expand chat"]').click());
238
+ const expanded = document.querySelector<HTMLElement>(
239
+ '[data-slot="dialog-content"]',
240
+ )!;
241
+ expect(expanded.className).toContain("chat-theme");
242
+ expect(expanded.className).toContain(CLASS_NAMES.container);
243
+ expect(expanded.className).not.toContain("right-10");
244
+ expect(expanded.className).not.toContain("bottom-20");
245
+ expect(expanded.className).not.toContain("z-40");
246
+ expect(expanded.style.fontFamily).toBe("monospace");
247
+ expect(expanded.style.right).toBe("0px");
248
+ expect(expanded.style.zIndex).toBe("50");
229
249
  });
230
250
 
231
251
  it("leaves base classes untouched when no classNames override is supplied", async () => {
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import { MockLanguageModelV2 } from "ai/test";
4
+ import { simulateReadableStream, type ModelMessage, type UIMessage } from "ai";
5
+ import { createBackendStack } from "../../../api";
6
+ import { aiChatBackendPlugin } from "../api";
7
+ import {
8
+ compactConversation,
9
+ createStepCompactor,
10
+ createSummarizer,
11
+ estimateContextTokens,
12
+ } from "../api/compaction";
13
+ import {
14
+ captureContextCheckpoint,
15
+ contextRequest,
16
+ } from "../client/context-checkpoint";
17
+
18
+ const message = (
19
+ id: string,
20
+ role: "user" | "assistant",
21
+ text: string,
22
+ ): UIMessage => ({ id, role, parts: [{ type: "text", text }] });
23
+ const history = [
24
+ message("u1", "user", "Remember my goal. " + "research ".repeat(2000)),
25
+ message("a1", "assistant", "https://example.org/source; evidence uncertain"),
26
+ message("u2", "user", "Compare the alternatives"),
27
+ message("a2", "assistant", "A comparison"),
28
+ message("u3", "user", "What was the uncertainty?"),
29
+ ];
30
+
31
+ describe("public context compaction", () => {
32
+ it("uses size rather than message count and leaves the transcript untouched", async () => {
33
+ const summarize = vi
34
+ .fn()
35
+ .mockResolvedValue("Goal, sources, and uncertainty");
36
+ const original = structuredClone(history);
37
+ const result = await compactConversation({
38
+ messages: history,
39
+ budget: 3200,
40
+ overhead: "rules",
41
+ summarize,
42
+ });
43
+ expect(result.throughMessageId).toBe("a1");
44
+ expect(result.messages.map((m) => m.id)).toEqual(["u2", "a2", "u3"]);
45
+ expect(summarize.mock.calls[0]?.[0].messages).toEqual(history.slice(0, 2));
46
+ expect(history).toEqual(original);
47
+ const short = Array.from({ length: 100 }, (_, i) =>
48
+ message(String(i), i % 2 ? "assistant" : "user", "Hi"),
49
+ );
50
+ await compactConversation({
51
+ messages: short,
52
+ budget: 10000,
53
+ overhead: "",
54
+ summarize,
55
+ });
56
+ expect(summarize).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("reuses a checkpoint only while all summarized messages are unchanged", () => {
60
+ const checkpoint = captureContextCheckpoint(history, "summary", "a1");
61
+ expect(contextRequest(history, checkpoint)).toEqual({
62
+ messages: history.slice(2),
63
+ contextSummary: "summary",
64
+ });
65
+ const edited = [
66
+ message("u1", "user", "Different goal"),
67
+ ...history.slice(1),
68
+ ];
69
+ expect(contextRequest(edited, checkpoint)).toEqual({ messages: edited });
70
+ expect(contextRequest(history.slice(0, 1), checkpoint)).toEqual({
71
+ messages: history.slice(0, 1),
72
+ });
73
+ expect(contextRequest([], checkpoint)).toEqual({ messages: [] });
74
+ });
75
+
76
+ it("retains tool pairs, recent questions, and system instructions between steps", async () => {
77
+ const messages: ModelMessage[] = [
78
+ { role: "system", content: "Keep original instructions" },
79
+ { role: "user", content: "Read this profile" },
80
+ {
81
+ role: "assistant",
82
+ content: [
83
+ {
84
+ type: "tool-call",
85
+ toolName: "readPage",
86
+ toolCallId: "read-1",
87
+ input: { path: "/one" },
88
+ },
89
+ ],
90
+ },
91
+ {
92
+ role: "tool",
93
+ content: [
94
+ {
95
+ type: "tool-result",
96
+ toolName: "readPage",
97
+ toolCallId: "read-1",
98
+ output: { type: "text", value: "evidence ".repeat(10000) },
99
+ },
100
+ ],
101
+ },
102
+ ];
103
+ const summarize = vi
104
+ .fn()
105
+ .mockResolvedValue(
106
+ "Source /one; preclinical evidence; uncertainty remains.",
107
+ );
108
+ const status = vi.fn();
109
+ const prepare = createStepCompactor({
110
+ budget: 3000,
111
+ overhead: "",
112
+ summarize,
113
+ onCompacting: status,
114
+ });
115
+ const result = await prepare({ messages });
116
+ expect(result.messages[0]).toEqual(messages[0]);
117
+ expect(result.messages[1]).toEqual(messages[1]);
118
+ expect(result.messages[2]).toEqual(messages[2]);
119
+ expect(JSON.stringify(result.messages[3])).toContain(
120
+ '"toolCallId":"read-1"',
121
+ );
122
+ expect(JSON.stringify(result.messages[3])).toContain(
123
+ "preclinical evidence",
124
+ );
125
+ expect(estimateContextTokens(result.messages)).toBeLessThan(3000);
126
+ await prepare({
127
+ messages: [...messages, { role: "assistant", content: "Answer" }],
128
+ });
129
+ expect(summarize).toHaveBeenCalledOnce();
130
+ expect(status.mock.calls).toEqual([[true], [false]]);
131
+ });
132
+
133
+ it("does not discard context when summarization fails", async () => {
134
+ const original = structuredClone(history);
135
+ await expect(
136
+ compactConversation({
137
+ messages: history,
138
+ budget: 3000,
139
+ overhead: "",
140
+ summarize: async () => {
141
+ throw new Error("provider unavailable");
142
+ },
143
+ }),
144
+ ).rejects.toThrow("provider unavailable");
145
+ expect(history).toEqual(original);
146
+ });
147
+
148
+ it("chunks oversized Unicode histories and includes earlier summaries", async () => {
149
+ const model = new MockLanguageModelV2({
150
+ doGenerate: async () => ({
151
+ content: [{ type: "text", text: "Retained facts" }],
152
+ finishReason: "stop",
153
+ usage: { inputTokens: 100, outputTokens: 10, totalTokens: 110 },
154
+ warnings: [],
155
+ }),
156
+ });
157
+ await createSummarizer(model, 4096)({ text: "研究".repeat(4000) });
158
+ expect(model.doGenerateCalls.length).toBeGreaterThan(1);
159
+ for (const call of model.doGenerateCalls)
160
+ expect(estimateContextTokens(call.prompt)).toBeLessThan(4096);
161
+ expect(JSON.stringify(model.doGenerateCalls[1]?.prompt)).toContain(
162
+ "Retained facts",
163
+ );
164
+ });
165
+
166
+ it("streams status/checkpoints and continues with a compact model prompt", async () => {
167
+ const model = new MockLanguageModelV2({
168
+ doGenerate: async () => ({
169
+ content: [
170
+ {
171
+ type: "text",
172
+ text: "Earlier goal and uncertain evidence: https://example.org/source",
173
+ },
174
+ ],
175
+ finishReason: "stop",
176
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
177
+ warnings: [],
178
+ }),
179
+ doStream: async () => ({
180
+ stream: simulateReadableStream({
181
+ chunks: [
182
+ { type: "text-start" as const, id: "t" },
183
+ {
184
+ type: "text-delta" as const,
185
+ id: "t",
186
+ delta: "Continuing the conversation.",
187
+ },
188
+ { type: "text-end" as const, id: "t" },
189
+ {
190
+ type: "finish" as const,
191
+ finishReason: "stop" as const,
192
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
193
+ },
194
+ ],
195
+ initialDelayInMs: null,
196
+ chunkDelayInMs: null,
197
+ }),
198
+ }),
199
+ });
200
+ const before = vi.fn();
201
+ const app = createBackendStack({
202
+ basePath: "/api",
203
+ plugins: {
204
+ aiChat: aiChatBackendPlugin({
205
+ model,
206
+ access: "public",
207
+ systemPrompt: "Original rules",
208
+ compaction: { contextWindowTokens: 4096 },
209
+ hooks: { onBeforeChat: before },
210
+ }),
211
+ },
212
+ adapter: (db) => createMemoryAdapter(db)({}),
213
+ });
214
+ const send = (body: unknown) =>
215
+ app.handler(
216
+ new Request("http://localhost/api/chat", {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ body: JSON.stringify(body),
220
+ }),
221
+ );
222
+ const attachment = await send({
223
+ messages: [
224
+ {
225
+ id: "file",
226
+ role: "user",
227
+ parts: [
228
+ {
229
+ type: "file",
230
+ mediaType: "image/png",
231
+ url: "https://example.org/image.png",
232
+ },
233
+ ],
234
+ },
235
+ ],
236
+ });
237
+ expect(attachment.status).toBe(400);
238
+ expect(await attachment.text()).toContain("text-only");
239
+ expect(model.doGenerateCalls).toHaveLength(0);
240
+ expect(model.doStreamCalls).toHaveLength(0);
241
+ const response = await send({ messages: history });
242
+ expect(response.status).toBe(200);
243
+ const text = await response.text();
244
+ expect(text).toContain('"data-context-status"');
245
+ expect(text).toContain('"throughMessageId":"a1"');
246
+ expect(text).toContain("Continuing the conversation.");
247
+ expect(before).toHaveBeenCalledOnce();
248
+ expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).toContain(
249
+ "Original rules",
250
+ );
251
+ expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).not.toContain(
252
+ "research ".repeat(100),
253
+ );
254
+ const checkpointLine = text
255
+ .split("\n")
256
+ .find((line) => line.includes('"data-context-checkpoint"'))!;
257
+ const checkpoint = JSON.parse(checkpointLine.slice(6)).data;
258
+ const next = await send({
259
+ messages: history.slice(2),
260
+ contextSummary: checkpoint.summary,
261
+ });
262
+ await next.text();
263
+ expect(JSON.stringify(model.doStreamCalls.at(-1)?.prompt)).toContain(
264
+ "Earlier goal",
265
+ );
266
+ const oversized = await send({
267
+ messages: [message("large", "user", "research ".repeat(4000))],
268
+ });
269
+ expect(await oversized.text()).toContain("Shorten it and retry");
270
+ expect(model.doStreamCalls).toHaveLength(2);
271
+ });
272
+ });
@@ -124,7 +124,7 @@ async function render(props: ChatLayoutProps, strict = false) {
124
124
  );
125
125
  }
126
126
  async function click(label: string) {
127
- const button = container.querySelector<HTMLButtonElement>(
127
+ const button = document.querySelector<HTMLButtonElement>(
128
128
  `button[aria-label="${label}"]`,
129
129
  );
130
130
  expect(button).not.toBeNull();
@@ -139,6 +139,29 @@ const page = {
139
139
  };
140
140
 
141
141
  describe("page widget", () => {
142
+ it("shows a custom launcher icon while closed and keeps the close control", async () => {
143
+ await render({
144
+ layout: "widget",
145
+ triggerIcon: (
146
+ <span data-testid="custom-trigger-icon" aria-hidden="true" />
147
+ ),
148
+ });
149
+ const trigger = () =>
150
+ container.querySelector('[data-testid="widget-trigger"]');
151
+ expect(
152
+ trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
153
+ ).not.toBeNull();
154
+ await click("Open chat");
155
+ expect(trigger()?.getAttribute("aria-label")).toBe("Close chat");
156
+ expect(
157
+ trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
158
+ ).toBeNull();
159
+ expect(trigger()?.querySelector("svg")).not.toBeNull();
160
+ await click("Close chat");
161
+ expect(
162
+ trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
163
+ ).not.toBeNull();
164
+ });
142
165
  it.each([false, true])(
143
166
  "checks readPage authorization for send, edit and retry when enabled: %s",
144
167
  async (pageContent) => {
@@ -374,6 +397,85 @@ describe("page widget", () => {
374
397
  expect(panel.style.height).toBe("400px");
375
398
  expect(trigger.parentElement?.className).not.toContain("fixed");
376
399
  });
400
+ it("makes expansion opt-in and preserves the chat when expanding, collapsing and closing", async () => {
401
+ const props = {
402
+ layout: "widget" as const,
403
+ defaultOpen: true,
404
+ widgetWidth: 320,
405
+ widgetHeight: 400,
406
+ initialMessages: [
407
+ {
408
+ id: "one",
409
+ role: "user" as const,
410
+ parts: [{ type: "text" as const, text: "Keep this question" }],
411
+ },
412
+ ],
413
+ };
414
+ await render(props);
415
+ expect(container.querySelector('[aria-label="Expand chat"]')).toBeNull();
416
+ await render({ ...props, expandable: true });
417
+ const panel = container.querySelector<HTMLElement>('[role="dialog"]')!;
418
+ const input = container.querySelector("textarea")!;
419
+ const id = options().id;
420
+ await click("Expand chat");
421
+ const dialog = document.querySelector('[data-slot="dialog-content"]')!;
422
+ expect(dialog.querySelector("textarea")).toBe(input);
423
+ expect(dialog.getAttribute("role")).toBe("dialog");
424
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(true);
425
+ await click("Collapse chat");
426
+ expect(panel.style.width).toBe("320px");
427
+ expect(panel.style.height).toBe("400px");
428
+ expect(panel.querySelector("textarea")).toBe(input);
429
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
430
+ await click("Expand chat");
431
+ await act(async () => {
432
+ document.dispatchEvent(
433
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
434
+ );
435
+ });
436
+ expect(panel.querySelector("textarea")).toBe(input);
437
+ await click("Expand chat");
438
+ await click("Close chat");
439
+ expect(panel.classList.contains("hidden")).toBe(true);
440
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
441
+ await click("Open chat");
442
+ expect(panel.querySelector("textarea")).toBe(input);
443
+ expect(container.textContent).toContain("Keep this question");
444
+ expect(options().id).toBe(id);
445
+ expect(mocks.stop).not.toHaveBeenCalled();
446
+ });
447
+ it("restores scrolling when expansion is disabled, the page changes, or the widget unmounts", async () => {
448
+ const props = {
449
+ layout: "widget" as const,
450
+ defaultOpen: true,
451
+ expandable: true,
452
+ showTrigger: false,
453
+ pageKey: "one",
454
+ };
455
+ await render(props, true);
456
+ const input = container.querySelector("textarea");
457
+ await click("Expand chat");
458
+ await render({ ...props, expandable: false }, true);
459
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
460
+ await vi.waitFor(() =>
461
+ expect(document.activeElement).toBe(
462
+ container.querySelector('[role="dialog"]'),
463
+ ),
464
+ );
465
+ await render(props, true);
466
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
467
+ expect(container.querySelector("textarea")).toBe(input);
468
+ expect(
469
+ container.querySelector('[aria-label="Expand chat"]'),
470
+ ).not.toBeNull();
471
+ await click("Expand chat");
472
+ await render({ ...props, pageKey: "two" }, true);
473
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
474
+ await click("Expand chat");
475
+ await act(async () => root.render(null));
476
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
477
+ });
478
+
377
479
  it("emits content-free lifecycle analytics once under StrictMode", async () => {
378
480
  vi.useFakeTimers();
379
481
  await render(