@firstlovecenter/ai-chat 0.5.0 → 0.6.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.
@@ -139,16 +139,26 @@ type ChatMessage = {
139
139
  createdAt: Date;
140
140
  };
141
141
  /**
142
- * Singleton row controlling the active tool-calling provider, the Vertex
143
- * region, and which chat UI (Custom vs. Vercel) renders globally. All three
142
+ * Singleton row controlling global AI runtime settings. All open-string
144
143
  * fields are validated at runtime against the registries the host configures
145
144
  * — they are not enums in the package's types so consumers can register
146
145
  * additional providers / interfaces without a schema change.
146
+ *
147
+ * `maxOutputTokens` caps both the agent loop's per-turn output AND each
148
+ * narrator's prose pass. Reasoning models (e.g. `xai/grok-4.1-fast-reasoning`)
149
+ * charge internal thinking against this budget — set it generously when
150
+ * those are in use.
151
+ *
152
+ * `rolePrompt` is the persona the assistant adopts. When non-null, it
153
+ * takes precedence over the host's static `rolePrompt` configureAiChat
154
+ * option, so admins can edit live in the settings UI.
147
155
  */
148
156
  type AiSettings = {
149
157
  toolProvider: string;
150
158
  gcpLocation: string;
151
159
  chatInterface: string;
160
+ maxOutputTokens: number;
161
+ rolePrompt: string | null;
152
162
  updatedAt: Date | null;
153
163
  updatedByUserId: number | null;
154
164
  };
@@ -242,6 +252,9 @@ type AiSettingsPatch = {
242
252
  toolProvider?: string;
243
253
  gcpLocation?: string;
244
254
  chatInterface?: string;
255
+ maxOutputTokens?: number;
256
+ /** Pass `null` to clear back to the host's static fallback. */
257
+ rolePrompt?: string | null;
245
258
  };
246
259
  /**
247
260
  * The whole reason this package is ORM-agnostic. Implemented by:
@@ -139,16 +139,26 @@ type ChatMessage = {
139
139
  createdAt: Date;
140
140
  };
141
141
  /**
142
- * Singleton row controlling the active tool-calling provider, the Vertex
143
- * region, and which chat UI (Custom vs. Vercel) renders globally. All three
142
+ * Singleton row controlling global AI runtime settings. All open-string
144
143
  * fields are validated at runtime against the registries the host configures
145
144
  * — they are not enums in the package's types so consumers can register
146
145
  * additional providers / interfaces without a schema change.
146
+ *
147
+ * `maxOutputTokens` caps both the agent loop's per-turn output AND each
148
+ * narrator's prose pass. Reasoning models (e.g. `xai/grok-4.1-fast-reasoning`)
149
+ * charge internal thinking against this budget — set it generously when
150
+ * those are in use.
151
+ *
152
+ * `rolePrompt` is the persona the assistant adopts. When non-null, it
153
+ * takes precedence over the host's static `rolePrompt` configureAiChat
154
+ * option, so admins can edit live in the settings UI.
147
155
  */
148
156
  type AiSettings = {
149
157
  toolProvider: string;
150
158
  gcpLocation: string;
151
159
  chatInterface: string;
160
+ maxOutputTokens: number;
161
+ rolePrompt: string | null;
152
162
  updatedAt: Date | null;
153
163
  updatedByUserId: number | null;
154
164
  };
@@ -242,6 +252,9 @@ type AiSettingsPatch = {
242
252
  toolProvider?: string;
243
253
  gcpLocation?: string;
244
254
  chatInterface?: string;
255
+ maxOutputTokens?: number;
256
+ /** Pass `null` to clear back to the host's static fallback. */
257
+ rolePrompt?: string | null;
245
258
  };
246
259
  /**
247
260
  * The whole reason this package is ORM-agnostic. Implemented by:
package/dist/ui/index.cjs CHANGED
@@ -489,17 +489,22 @@ function sanitiseBlock(input) {
489
489
  }
490
490
  return { kind: "callout", tone: input.tone, text: input.text };
491
491
  }
492
+ function isBlankString(v) {
493
+ return typeof v !== "string" || !v.trim();
494
+ }
492
495
  function isBlockEmpty(b) {
493
496
  if (b.kind === "paragraph_brief") {
494
- if (b.prose && b.prose.trim()) return false;
495
- return b.key_facts.length === 0 || b.key_facts.every((f) => !f.trim());
497
+ if (typeof b.prose === "string" && b.prose.trim()) return false;
498
+ const facts = Array.isArray(b.key_facts) ? b.key_facts : [];
499
+ return facts.length === 0 || facts.every(isBlankString);
496
500
  }
497
501
  if (b.kind === "list") {
498
- return b.items.length === 0 || b.items.every((i) => !i.trim());
502
+ const items = Array.isArray(b.items) ? b.items : [];
503
+ return items.length === 0 || items.every(isBlankString);
499
504
  }
500
- if (b.kind === "callout") return !b.text.trim();
501
- if (b.kind === "chart") return b.data.length === 0;
502
- if (b.kind === "table") return b.rows.length === 0;
505
+ if (b.kind === "callout") return isBlankString(b.text);
506
+ if (b.kind === "chart") return !Array.isArray(b.data) || b.data.length === 0;
507
+ if (b.kind === "table") return !Array.isArray(b.rows) || b.rows.length === 0;
503
508
  return false;
504
509
  }
505
510
  function AnswerBlocks({ blocks }) {
@@ -684,6 +689,7 @@ function AiChat({
684
689
  const textareaRef = React.useRef(null);
685
690
  const lastAnswerRef = React.useRef(null);
686
691
  const prevAnswersLen = React.useRef(0);
692
+ const autoOpenedRef = React.useRef(false);
687
693
  React.useLayoutEffect(() => {
688
694
  const el = textareaRef.current;
689
695
  if (!el) return;
@@ -698,7 +704,34 @@ function AiChat({
698
704
  const res = await fetch("/api/chat/sessions", { cache: "no-store" });
699
705
  if (!res.ok) return;
700
706
  const data = await res.json();
701
- if (!cancelled) setSessions(data.sessions ?? []);
707
+ if (cancelled) return;
708
+ const list = data.sessions ?? [];
709
+ setSessions(list);
710
+ if (!autoOpenedRef.current && list.length > 0) {
711
+ autoOpenedRef.current = true;
712
+ const mostRecentId = list[0].id;
713
+ setLoadingSession(true);
714
+ setActiveSessionId(mostRecentId);
715
+ try {
716
+ const sres = await fetch(`/api/chat/sessions/${mostRecentId}`, {
717
+ cache: "no-store"
718
+ });
719
+ if (cancelled) return;
720
+ if (!sres.ok) {
721
+ setAnswers([]);
722
+ return;
723
+ }
724
+ const sdata = await sres.json();
725
+ if (cancelled) return;
726
+ setAnswers(messagesToAnswers(sdata.messages ?? []));
727
+ } catch {
728
+ if (!cancelled) setAnswers([]);
729
+ } finally {
730
+ if (!cancelled) setLoadingSession(false);
731
+ }
732
+ } else if (!autoOpenedRef.current) {
733
+ autoOpenedRef.current = true;
734
+ }
702
735
  } catch {
703
736
  }
704
737
  }
@@ -1475,6 +1508,7 @@ function VercelChat({
1475
1508
  const textareaRef = React.useRef(null);
1476
1509
  const lastAnswerRef = React.useRef(null);
1477
1510
  const prevAnswersLen = React.useRef(0);
1511
+ const autoOpenedRef = React.useRef(false);
1478
1512
  const activeSessionIdRef = React.useRef(activeSessionId);
1479
1513
  const providerRef = React.useRef(provider);
1480
1514
  React.useEffect(() => {
@@ -1529,7 +1563,49 @@ function VercelChat({
1529
1563
  const res = await fetch("/api/chat/sessions", { cache: "no-store" });
1530
1564
  if (!res.ok) return;
1531
1565
  const json = await res.json();
1532
- if (!cancelled) setSessions(json.sessions ?? []);
1566
+ if (cancelled) return;
1567
+ const list = json.sessions ?? [];
1568
+ setSessions(list);
1569
+ if (!autoOpenedRef.current && list.length > 0) {
1570
+ autoOpenedRef.current = true;
1571
+ const mostRecentId = list[0].id;
1572
+ setLoadingSession(true);
1573
+ setActiveSessionId(mostRecentId);
1574
+ try {
1575
+ const sres = await fetch(`/api/chat/sessions/${mostRecentId}`, {
1576
+ cache: "no-store"
1577
+ });
1578
+ if (cancelled) return;
1579
+ if (!sres.ok) {
1580
+ setMessages([]);
1581
+ setHydratedBlocks({});
1582
+ setHydratedProse({});
1583
+ setHydratedErrors({});
1584
+ return;
1585
+ }
1586
+ const sjson = await sres.json();
1587
+ if (cancelled) return;
1588
+ const { uiMessages, blocksMap, proseMap, errorsMap } = storedToUseChat(
1589
+ sjson.messages ?? []
1590
+ );
1591
+ setMessages(uiMessages);
1592
+ setHydratedBlocks(blocksMap);
1593
+ setHydratedProse(proseMap);
1594
+ setHydratedErrors(errorsMap);
1595
+ setStartedAt({});
1596
+ } catch {
1597
+ if (!cancelled) {
1598
+ setMessages([]);
1599
+ setHydratedBlocks({});
1600
+ setHydratedProse({});
1601
+ setHydratedErrors({});
1602
+ }
1603
+ } finally {
1604
+ if (!cancelled) setLoadingSession(false);
1605
+ }
1606
+ } else if (!autoOpenedRef.current) {
1607
+ autoOpenedRef.current = true;
1608
+ }
1533
1609
  } catch {
1534
1610
  }
1535
1611
  }
@@ -1537,7 +1613,7 @@ function VercelChat({
1537
1613
  return () => {
1538
1614
  cancelled = true;
1539
1615
  };
1540
- }, []);
1616
+ }, [setMessages]);
1541
1617
  const answers = React.useMemo(() => {
1542
1618
  const liveBlocks = [];
1543
1619
  const liveErrors = [];