Thomas G. Lopes commited on
Commit
893b0be
·
unverified ·
1 Parent(s): 9129dc5

fix reset; add some tests (#90)

Browse files
e2e/home.test.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { expect, test } from "@playwright/test";
 
2
 
3
  const HF_TOKEN = "hf_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4
  const HF_TOKEN_STORAGE_KEY = "hf_token";
@@ -59,5 +60,52 @@ test.describe.serial("Token Handling and Subsequent Tests", () => {
59
  await expect(userInputAfterReload).toBeVisible();
60
  expect(await userInputAfterReload.inputValue()).toBe("Hello Hugging Face!");
61
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  });
63
  });
 
1
  import { expect, test } from "@playwright/test";
2
+ import { TEST_IDS } from "../src/lib/constants.js";
3
 
4
  const HF_TOKEN = "hf_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
5
  const HF_TOKEN_STORAGE_KEY = "hf_token";
 
60
  await expect(userInputAfterReload).toBeVisible();
61
  expect(await userInputAfterReload.inputValue()).toBe("Hello Hugging Face!");
62
  });
63
+
64
+ test("checkpoints, resetting, and restoring", async ({ page }) => {
65
+ await page.goto("/");
66
+ const userMsg = "user message: hi";
67
+ const assistantMsg = "assistant message: hey";
68
+
69
+ // Fill user message
70
+ await page.getByRole("textbox", { name: "Enter user message" }).click();
71
+ await page.getByRole("textbox", { name: "Enter user message" }).fill(userMsg);
72
+ // Blur
73
+ await page.locator(".relative > div:nth-child(2) > div").first().click();
74
+
75
+ // Fill assistant message
76
+ await page.getByRole("button", { name: "Add message" }).click();
77
+ await page.getByRole("textbox", { name: "Enter assistant message" }).fill(assistantMsg);
78
+ // Blur
79
+ await page.locator(".relative > div:nth-child(2) > div").first().click();
80
+
81
+ // Create Checkpoint
82
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_trigger}"]`).click();
83
+ await page.getByRole("button", { name: "Create new" }).click();
84
+
85
+ // Check that there are checkpoints
86
+ await expect(page.locator(`[data-test-id="${TEST_IDS.checkpoint}"] `)).toBeVisible();
87
+
88
+ // Get out of menu
89
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape");
90
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape");
91
+
92
+ // Reset
93
+ await page.locator(`[data-test-id="${TEST_IDS.reset}"]`).click();
94
+
95
+ // Check that messages are gone now
96
+ await expect(page.getByRole("textbox", { name: "Enter user message" })).toHaveValue("");
97
+
98
+ // Call in a checkpoint
99
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_trigger}"]`).click();
100
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoint}"] `).click();
101
+
102
+ // Get out of menu
103
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape");
104
+ await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape");
105
+
106
+ // Check that the messages are back
107
+ await expect(page.getByRole("textbox", { name: "Enter user message" })).toHaveValue(userMsg);
108
+ await expect(page.getByRole("textbox", { name: "Enter assistant message" })).toHaveValue(assistantMsg);
109
+ });
110
  });
111
  });
playwright.config.ts CHANGED
@@ -2,8 +2,9 @@ import { defineConfig } from "@playwright/test";
2
 
3
  export default defineConfig({
4
  webServer: {
5
- command: "npm run build && npm run preview",
6
- port: 4173,
 
7
  timeout: 1000 * 60 * 10,
8
  },
9
  testDir: "e2e",
 
2
 
3
  export default defineConfig({
4
  webServer: {
5
+ command: process.env.CI ? "npm run build && npm run preview" : "",
6
+ port: process.env.CI ? 4173 : 5173,
7
+ reuseExistingServer: !process.env.CI,
8
  timeout: 1000 * 60 * 10,
9
  },
10
  testDir: "e2e",
src/lib/components/inference-playground/checkpoints-menu.svelte CHANGED
@@ -12,6 +12,7 @@
12
  import IconStar from "~icons/carbon/star";
13
  import IconStarFilled from "~icons/carbon/star-filled";
14
  import IconDelete from "~icons/carbon/trash-can";
 
15
 
16
  const popover = new Popover({
17
  floatingConfig: {
@@ -27,7 +28,7 @@
27
  const projCheckpoints = $derived(checkpoints.for(projects.activeId));
28
  </script>
29
 
30
- <button class="btn relative size-[32px] p-0" {...popover.trigger}>
31
  <IconHistory />
32
  {#if projCheckpoints.length > 0}
33
  <div class="absolute -top-1 -right-1 size-2.5 rounded-full bg-amber-500" aria-label="Project has checkpoints"></div>
@@ -39,6 +40,7 @@
39
  class="mb-2 !overflow-visible rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
40
  {@attach clickOutside(() => (popover.open = false))}
41
  {...popover.content}
 
42
  >
43
  <div
44
  class="size-4 translate-x-3 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
@@ -74,6 +76,7 @@
74
  <div
75
  class="mb-2 flex w-full items-center rounded-md px-3 hover:bg-gray-100 dark:hover:bg-gray-700"
76
  {...tooltip.trigger}
 
77
  >
78
  <button
79
  class="flex flex-1 flex-col py-2 text-left text-sm transition-colors"
 
12
  import IconStar from "~icons/carbon/star";
13
  import IconStarFilled from "~icons/carbon/star-filled";
14
  import IconDelete from "~icons/carbon/trash-can";
15
+ import { TEST_IDS } from "$lib/constants.js";
16
 
17
  const popover = new Popover({
18
  floatingConfig: {
 
28
  const projCheckpoints = $derived(checkpoints.for(projects.activeId));
29
  </script>
30
 
31
+ <button class="btn relative size-[32px] p-0" {...popover.trigger} data-test-id={TEST_IDS.checkpoints_trigger}>
32
  <IconHistory />
33
  {#if projCheckpoints.length > 0}
34
  <div class="absolute -top-1 -right-1 size-2.5 rounded-full bg-amber-500" aria-label="Project has checkpoints"></div>
 
40
  class="mb-2 !overflow-visible rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
41
  {@attach clickOutside(() => (popover.open = false))}
42
  {...popover.content}
43
+ data-test-id={TEST_IDS.checkpoints_menu}
44
  >
45
  <div
46
  class="size-4 translate-x-3 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
 
76
  <div
77
  class="mb-2 flex w-full items-center rounded-md px-3 hover:bg-gray-100 dark:hover:bg-gray-700"
78
  {...tooltip.trigger}
79
+ data-test-id={TEST_IDS.checkpoint}
80
  >
81
  <button
82
  class="flex flex-1 flex-col py-2 text-left text-sm transition-colors"
src/lib/components/inference-playground/message.svelte CHANGED
@@ -15,6 +15,7 @@
15
  import IconCustom from "../icon-custom.svelte";
16
  import LocalToasts from "../local-toasts.svelte";
17
  import ImgPreview from "./img-preview.svelte";
 
18
 
19
  type Props = {
20
  conversation: ConversationClass;
@@ -106,6 +107,7 @@
106
  class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
107
  rows="1"
108
  data-message
 
109
  {@attach autofocusAction(autofocus)}
110
  {@attach autosized.attachment}
111
  ></textarea>
 
15
  import IconCustom from "../icon-custom.svelte";
16
  import LocalToasts from "../local-toasts.svelte";
17
  import ImgPreview from "./img-preview.svelte";
18
+ import { TEST_IDS } from "$lib/constants.js";
19
 
20
  type Props = {
21
  conversation: ConversationClass;
 
107
  class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
108
  rows="1"
109
  data-message
110
+ data-test-id={TEST_IDS.message}
111
  {@attach autofocusAction(autofocus)}
112
  {@attach autosized.attachment}
113
  ></textarea>
src/lib/components/inference-playground/playground.svelte CHANGED
@@ -29,6 +29,7 @@
29
  import ModelSelectorModal from "./model-selector-modal.svelte";
30
  import ModelSelector from "./model-selector.svelte";
31
  import ProjectSelect from "./project-select.svelte";
 
32
 
33
  const multiple = $derived(conversations.active.length > 1);
34
 
@@ -153,7 +154,13 @@
153
  {/if}
154
  <Tooltip>
155
  {#snippet trigger(tooltip)}
156
- <button type="button" onclick={conversations.reset} class="btn size-[39px]" {...tooltip.trigger}>
 
 
 
 
 
 
157
  <IconDelete />
158
  </button>
159
  {/snippet}
 
29
  import ModelSelectorModal from "./model-selector-modal.svelte";
30
  import ModelSelector from "./model-selector.svelte";
31
  import ProjectSelect from "./project-select.svelte";
32
+ import { TEST_IDS } from "$lib/constants.js";
33
 
34
  const multiple = $derived(conversations.active.length > 1);
35
 
 
154
  {/if}
155
  <Tooltip>
156
  {#snippet trigger(tooltip)}
157
+ <button
158
+ type="button"
159
+ onclick={conversations.reset}
160
+ class="btn size-[39px]"
161
+ {...tooltip.trigger}
162
+ data-test-id={TEST_IDS.reset}
163
+ >
164
  <IconDelete />
165
  </button>
166
  {/snippet}
src/lib/components/inference-playground/structured-output-modal.svelte CHANGED
@@ -99,11 +99,7 @@
99
  });
100
  }
101
 
102
- let textarea = $state<HTMLTextAreaElement>();
103
- new TextareaAutosize({
104
- element: () => textarea,
105
- input: () => tempSchema,
106
- });
107
  </script>
108
 
109
  <Dialog class="!w-2xl max-w-[90vw]" title="Edit Structured Output" {open} onClose={() => (open = false)}>
@@ -262,7 +258,6 @@
262
  {/await}
263
  </div>
264
  <textarea
265
- bind:this={textarea}
266
  autofocus
267
  value={conversation.data.structuredOutput?.schema ?? ""}
268
  {...onchange(v => {
@@ -270,6 +265,7 @@
270
  })}
271
  {...oninput(v => (tempSchema = v))}
272
  class="relative z-10 h-120 w-full resize-none overflow-hidden rounded-lg bg-transparent whitespace-pre-wrap text-transparent caret-black outline-none @2xl:px-3 dark:caret-white"
 
273
  ></textarea>
274
  </div>
275
  {/if}
 
99
  });
100
  }
101
 
102
+ const autosized = new TextareaAutosize();
 
 
 
 
103
  </script>
104
 
105
  <Dialog class="!w-2xl max-w-[90vw]" title="Edit Structured Output" {open} onClose={() => (open = false)}>
 
258
  {/await}
259
  </div>
260
  <textarea
 
261
  autofocus
262
  value={conversation.data.structuredOutput?.schema ?? ""}
263
  {...onchange(v => {
 
265
  })}
266
  {...oninput(v => (tempSchema = v))}
267
  class="relative z-10 h-120 w-full resize-none overflow-hidden rounded-lg bg-transparent whitespace-pre-wrap text-transparent caret-black outline-none @2xl:px-3 dark:caret-white"
268
+ {@attach autosized.attachment}
269
  ></textarea>
270
  </div>
271
  {/if}
src/lib/constants.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export enum TEST_IDS {
2
+ checkpoints_trigger,
3
+ checkpoints_menu,
4
+ checkpoint,
5
+
6
+ reset,
7
+
8
+ message,
9
+ }
src/lib/state/checkpoints.svelte.ts CHANGED
@@ -63,6 +63,11 @@ class Checkpoints {
63
  })
64
  );
65
 
 
 
 
 
 
66
  const prev: Checkpoint[] = this.#checkpoints[projectId] ?? [];
67
  this.#checkpoints[projectId] = [...prev, newCheckpoint];
68
  }
@@ -85,8 +90,8 @@ class Checkpoints {
85
  // conversations.deleteAllFrom(cloned.projectId);
86
  const prev = conversations.for(modified.projectId);
87
  modified.conversations.forEach((c, i) => {
88
- const p = prev[i];
89
- if (p) return p.update(c);
90
  conversations.create({
91
  ...c,
92
  projectId: modified.projectId,
 
63
  })
64
  );
65
 
66
+ // Hack because dates are formatted to string by save
67
+ newCheckpoint.conversations.forEach((c, i) => {
68
+ newCheckpoint.conversations[i] = { ...c, createdAt: new Date(c.createdAt) };
69
+ });
70
+
71
  const prev: Checkpoint[] = this.#checkpoints[projectId] ?? [];
72
  this.#checkpoints[projectId] = [...prev, newCheckpoint];
73
  }
 
90
  // conversations.deleteAllFrom(cloned.projectId);
91
  const prev = conversations.for(modified.projectId);
92
  modified.conversations.forEach((c, i) => {
93
+ const prevC = prev[i];
94
+ if (prevC) return prevC.update({ ...c });
95
  conversations.create({
96
  ...c,
97
  projectId: modified.projectId,
src/lib/state/conversations.svelte.ts CHANGED
@@ -110,7 +110,7 @@ export class ConversationClass {
110
  return this.isStructuredOutputAllowed && this.data.structuredOutput?.enabled;
111
  }
112
 
113
- async update(data: Partial<ConversationEntityMembers>) {
114
  if (this.data.id === -1) return;
115
  // if (this.data.id === undefined) return;
116
  const cloned = snapshot({ ...this.data, ...data });
@@ -122,16 +122,16 @@ export class ConversationClass {
122
  await conversationsRepo.update(this.data.id, cloned);
123
  this.#data = cloned;
124
  }
125
- }
126
 
127
- async addMessage(message: ConversationMessage) {
128
  this.update({
129
  ...this.data,
130
  messages: [...this.data.messages, snapshot(message)],
131
  });
132
- }
133
 
134
- async updateMessage(args: { index: number; message: Partial<ConversationMessage> }) {
135
  const prev = await poll(() => this.data.messages[args.index], { interval: 10, maxAttempts: 200 });
136
 
137
  if (!prev) return;
@@ -144,9 +144,9 @@ export class ConversationClass {
144
  ...this.data.messages.slice(args.index + 1),
145
  ],
146
  });
147
- }
148
 
149
- async deleteMessage(idx: number) {
150
  const imgKeys = this.data.messages.flatMap(m => m.images).filter(isString);
151
  await Promise.all([
152
  ...imgKeys.map(k => images.delete(k)),
@@ -155,9 +155,9 @@ export class ConversationClass {
155
  messages: this.data.messages.slice(0, idx),
156
  }),
157
  ]);
158
- }
159
 
160
- async deleteMessages(from: number) {
161
  const sliced = this.data.messages.slice(0, from);
162
  const notSliced = this.data.messages.slice(from);
163
 
@@ -169,9 +169,9 @@ export class ConversationClass {
169
  messages: sliced,
170
  }),
171
  ]);
172
- }
173
 
174
- async genNextMessage() {
175
  this.generating = true;
176
  const startTime = performance.now();
177
 
@@ -223,7 +223,7 @@ export class ConversationClass {
223
  const endTime = performance.now();
224
  this.generationStats.latency = Math.round(endTime - startTime);
225
  this.generating = false;
226
- }
227
 
228
  stopGenerating = () => {
229
  this.abortManager.abortAll();
@@ -236,7 +236,7 @@ class Conversations {
236
  generationStats = $derived(this.active.map(c => c.generationStats));
237
  loaded = $state(false);
238
 
239
- #active = $derived(this.for(projects.activeId));
240
 
241
  init = createInit(() => {
242
  const searchParams = new URLSearchParams(window.location.search);
@@ -271,7 +271,9 @@ class Conversations {
271
  return this.#active;
272
  }
273
 
274
- async create(args: { projectId: ProjectEntity["id"]; modelId?: Model["id"] } & Partial<ConversationEntityMembers>) {
 
 
275
  const conv = snapshot({
276
  ...getDefaultConversation(args.projectId),
277
  ...args,
@@ -286,9 +288,9 @@ class Conversations {
286
  };
287
 
288
  return id;
289
- }
290
 
291
- for(projectId: ProjectEntity["id"]): ConversationClass[] {
292
  // Async load from db
293
  if (!this.#conversations[projectId]?.length) {
294
  conversationsRepo.find({ where: { projectId } }).then(c => {
@@ -310,27 +312,27 @@ class Conversations {
310
  return res.slice(0, 2).toSorted((a, b) => {
311
  return a.data.createdAt.getTime() - b.data.createdAt.getTime();
312
  });
313
- }
314
 
315
- async delete({ id, projectId }: ConversationEntityMembers) {
316
  if (!id) return;
317
 
318
  await conversationsRepo.delete(id);
319
 
320
  const prev = this.#conversations[projectId] ?? [];
321
  this.#conversations = { ...this.#conversations, [projectId]: prev.filter(c => c.data.id != id) };
322
- }
323
 
324
- async deleteAllFrom(projectId: string) {
325
  this.for(projectId).forEach(c => this.delete(c.data));
326
- }
327
 
328
- async reset() {
329
- this.active.forEach(c => this.delete(c.data));
330
  this.create(getDefaultConversation(projects.activeId));
331
- }
332
 
333
- async migrate(from: ProjectEntity["id"], to: ProjectEntity["id"]) {
334
  const fromArr = this.#conversations[from] ?? [];
335
  await Promise.allSettled(fromArr.map(c => c.update({ projectId: to })));
336
  this.#conversations = {
@@ -338,18 +340,18 @@ class Conversations {
338
  [to]: [...fromArr],
339
  [from]: [],
340
  };
341
- }
342
 
343
- async duplicate(from: ProjectEntity["id"], to: ProjectEntity["id"]) {
344
  const fromArr = this.#conversations[from] ?? [];
345
  await Promise.allSettled(
346
  fromArr.map(async c => {
347
  conversations.create({ ...c.data, projectId: to });
348
  })
349
  );
350
- }
351
 
352
- async genNextMessages(conv: "left" | "right" | "both" | ConversationClass = "both") {
353
  if (!token.value) {
354
  token.showModal = true;
355
  return;
@@ -402,7 +404,7 @@ class Conversations {
402
  addToast({ title: "Error", description: "An unknown error occurred", variant: "error" });
403
  }
404
  }
405
- }
406
 
407
  stopGenerating = () => {
408
  this.active.forEach(c => c.abortManager.abortAll());
 
110
  return this.isStructuredOutputAllowed && this.data.structuredOutput?.enabled;
111
  }
112
 
113
+ update = async (data: Partial<ConversationEntityMembers>) => {
114
  if (this.data.id === -1) return;
115
  // if (this.data.id === undefined) return;
116
  const cloned = snapshot({ ...this.data, ...data });
 
122
  await conversationsRepo.update(this.data.id, cloned);
123
  this.#data = cloned;
124
  }
125
+ };
126
 
127
+ addMessage = async (message: ConversationMessage) => {
128
  this.update({
129
  ...this.data,
130
  messages: [...this.data.messages, snapshot(message)],
131
  });
132
+ };
133
 
134
+ updateMessage = async (args: { index: number; message: Partial<ConversationMessage> }) => {
135
  const prev = await poll(() => this.data.messages[args.index], { interval: 10, maxAttempts: 200 });
136
 
137
  if (!prev) return;
 
144
  ...this.data.messages.slice(args.index + 1),
145
  ],
146
  });
147
+ };
148
 
149
+ deleteMessage = async (idx: number) => {
150
  const imgKeys = this.data.messages.flatMap(m => m.images).filter(isString);
151
  await Promise.all([
152
  ...imgKeys.map(k => images.delete(k)),
 
155
  messages: this.data.messages.slice(0, idx),
156
  }),
157
  ]);
158
+ };
159
 
160
+ deleteMessages = async (from: number) => {
161
  const sliced = this.data.messages.slice(0, from);
162
  const notSliced = this.data.messages.slice(from);
163
 
 
169
  messages: sliced,
170
  }),
171
  ]);
172
+ };
173
 
174
+ genNextMessage = async () => {
175
  this.generating = true;
176
  const startTime = performance.now();
177
 
 
223
  const endTime = performance.now();
224
  this.generationStats.latency = Math.round(endTime - startTime);
225
  this.generating = false;
226
+ };
227
 
228
  stopGenerating = () => {
229
  this.abortManager.abortAll();
 
236
  generationStats = $derived(this.active.map(c => c.generationStats));
237
  loaded = $state(false);
238
 
239
+ #active = $derived.by(() => this.for(projects.activeId));
240
 
241
  init = createInit(() => {
242
  const searchParams = new URLSearchParams(window.location.search);
 
271
  return this.#active;
272
  }
273
 
274
+ create = async (
275
+ args: { projectId: ProjectEntity["id"]; modelId?: Model["id"] } & Partial<ConversationEntityMembers>
276
+ ) => {
277
  const conv = snapshot({
278
  ...getDefaultConversation(args.projectId),
279
  ...args,
 
288
  };
289
 
290
  return id;
291
+ };
292
 
293
+ for = (projectId: ProjectEntity["id"]): ConversationClass[] => {
294
  // Async load from db
295
  if (!this.#conversations[projectId]?.length) {
296
  conversationsRepo.find({ where: { projectId } }).then(c => {
 
312
  return res.slice(0, 2).toSorted((a, b) => {
313
  return a.data.createdAt.getTime() - b.data.createdAt.getTime();
314
  });
315
+ };
316
 
317
+ delete = async ({ id, projectId }: ConversationEntityMembers) => {
318
  if (!id) return;
319
 
320
  await conversationsRepo.delete(id);
321
 
322
  const prev = this.#conversations[projectId] ?? [];
323
  this.#conversations = { ...this.#conversations, [projectId]: prev.filter(c => c.data.id != id) };
324
+ };
325
 
326
+ deleteAllFrom = async (projectId: string) => {
327
  this.for(projectId).forEach(c => this.delete(c.data));
328
+ };
329
 
330
+ reset = async () => {
331
+ await Promise.allSettled(this.active.map(c => this.delete(c.data)));
332
  this.create(getDefaultConversation(projects.activeId));
333
+ };
334
 
335
+ migrate = async (from: ProjectEntity["id"], to: ProjectEntity["id"]) => {
336
  const fromArr = this.#conversations[from] ?? [];
337
  await Promise.allSettled(fromArr.map(c => c.update({ projectId: to })));
338
  this.#conversations = {
 
340
  [to]: [...fromArr],
341
  [from]: [],
342
  };
343
+ };
344
 
345
+ duplicate = async (from: ProjectEntity["id"], to: ProjectEntity["id"]) => {
346
  const fromArr = this.#conversations[from] ?? [];
347
  await Promise.allSettled(
348
  fromArr.map(async c => {
349
  conversations.create({ ...c.data, projectId: to });
350
  })
351
  );
352
+ };
353
 
354
+ genNextMessages = async (conv: "left" | "right" | "both" | ConversationClass = "both") => {
355
  if (!token.value) {
356
  token.showModal = true;
357
  return;
 
404
  addToast({ title: "Error", description: "An unknown error occurred", variant: "error" });
405
  }
406
  }
407
+ };
408
 
409
  stopGenerating = () => {
410
  this.active.forEach(c => c.abortManager.abortAll());