@personaai/react 0.7.3 → 0.7.4

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/README.md CHANGED
@@ -109,35 +109,21 @@ started once a committed user line has intervened.
109
109
 
110
110
  ### Showing voice turns in a text `useChat` feed
111
111
 
112
- If you drive a `useVoice({ agentId, threadId })` session alongside `useChat({ threadId })` and
113
- inject live turns into the chat feed yourself, dedupe before injecting. Because the session
114
- persists to the same thread, `useChat.messages` may already carry a turn once it refreshes or on
115
- reload — re-injecting the live copy appends it a second time:
112
+ Share the same `threadId` between `useVoice` and `useChat`, and pass the voice hook's return
113
+ value into `useChat`'s `voice` option — that's the whole integration:
116
114
 
117
115
  ```tsx
118
- const { transcript, ... } = useVoice({ agentId, threadId });
119
- const { messages, appendMessage, ... } = useChat({ threadId });
120
-
121
- // Turn a committed voice line into a chat message once, and only if the feed
122
- // does not already hold this content (from an earlier live inject OR a reload
123
- // that pulled the persisted thread).
124
- const pushVoiceLine = (line: { id: string; speaker: "user" | "agent"; text: string }) => {
125
- if (messages.some((m) => m.id === line.id)) return; // injected before
126
- if (messages.some((m) =>
127
- !String(m.id).startsWith("voice-") &&
128
- m.role === line.speaker &&
129
- String(m.content).trim() === line.text.trim(),
130
- )) return; // already on the thread
131
- appendMessage({
132
- id: `voice-${line.id}`,
133
- role: line.speaker,
134
- content: line.text,
135
- });
136
- };
116
+ const voice = useVoice({ agentId, threadId });
117
+ const { messages, ... } = useChat({ threadId, voice });
137
118
  ```
138
119
 
139
- Voice turns are intentionally **live-only** in `useVoice` — history is the thread's job, not the
140
- hook's, so when you share a thread with `useChat` you own the dedup at the injection boundary.
120
+ `useChat` merges live voice turns into `messages` for you: one bubble per utterance, deduped
121
+ against thread history and against a voice turn that already persisted back into the shared
122
+ thread, consecutive same-speaker fragments folded into the line they opened, and the
123
+ in-progress agent line updated in place while it's still being spoken (`isStreaming: true`, same
124
+ as a text response). Injected messages get a `voice-`-prefixed id. You still call `voice.start()`
125
+ / `voice.stop()` yourself — `useChat` only owns the transcript-to-feed sync, not the call
126
+ lifecycle.
141
127
 
142
128
  ## Devtools
143
129
 
package/dist/index.cjs CHANGED
@@ -435,6 +435,9 @@ function useChat(options = {}) {
435
435
  const [presentedFile, setPresentedFile] = (0, import_react2.useState)(null);
436
436
  const abortControllerRef = (0, import_react2.useRef)(null);
437
437
  const loadedThreadIdRef = (0, import_react2.useRef)(void 0);
438
+ const voiceThreadRef = (0, import_react2.useRef)(threadId);
439
+ const voicePrevLenRef = (0, import_react2.useRef)(0);
440
+ const voiceStreamingIdRef = (0, import_react2.useRef)(null);
438
441
  const stop = (0, import_react2.useCallback)(() => {
439
442
  if (abortControllerRef.current) {
440
443
  chatLogger.info("stop streaming", {});
@@ -540,6 +543,75 @@ function useChat(options = {}) {
540
543
  chatLogger.debug("loadThreadMessages trigger", { threadId });
541
544
  void loadThreadMessages(threadId);
542
545
  }, [threadId, isStreaming, messages.length, loadThreadMessages, chatLogger]);
546
+ const voice = options.voice;
547
+ (0, import_react2.useEffect)(() => {
548
+ if (!voice) return;
549
+ if (voiceThreadRef.current !== threadId) {
550
+ voiceThreadRef.current = threadId;
551
+ voicePrevLenRef.current = voice.transcript.length;
552
+ voiceStreamingIdRef.current = null;
553
+ return;
554
+ }
555
+ const isVoiceActive = voice.state !== "idle" && voice.state !== "ended" && voice.state !== "error";
556
+ const curLen = voice.transcript.length;
557
+ if (curLen < voicePrevLenRef.current) {
558
+ voicePrevLenRef.current = 0;
559
+ voiceStreamingIdRef.current = null;
560
+ }
561
+ if (!isVoiceActive) {
562
+ voicePrevLenRef.current = voice.transcript.length;
563
+ if (voiceStreamingIdRef.current) {
564
+ const doneId = voiceStreamingIdRef.current;
565
+ voiceStreamingIdRef.current = null;
566
+ setMessages(
567
+ (prev) => prev.map((m) => m.id === doneId ? { ...m, isStreaming: false } : m)
568
+ );
569
+ }
570
+ return;
571
+ }
572
+ if (curLen > voicePrevLenRef.current) {
573
+ const newLines = voice.transcript.slice(voicePrevLenRef.current);
574
+ voicePrevLenRef.current = curLen;
575
+ for (const line of newLines) {
576
+ const text = (line.text || "").trim();
577
+ if (!text) continue;
578
+ const role = line.speaker === "user" ? "user" : "assistant";
579
+ const voiceId = `voice-${line.id}`;
580
+ setMessages((prev) => {
581
+ if (prev.some((m) => m.id === voiceId)) return prev;
582
+ if (prev.some(
583
+ (m) => !m.id.startsWith("voice-") && m.role === role && m.content.trim() === text
584
+ ))
585
+ return prev;
586
+ const last = prev[prev.length - 1];
587
+ if (last?.id.startsWith("voice-") && last.role === "assistant" && role === "assistant") {
588
+ const merged = text.startsWith(last.content) ? text : `${last.content} ${text}`.trim();
589
+ return [...prev.slice(0, -1), { ...last, content: merged, isStreaming: false }];
590
+ }
591
+ return [...prev, { id: voiceId, role, content: text, createdAt: /* @__PURE__ */ new Date() }];
592
+ });
593
+ }
594
+ }
595
+ const partialText = voice.partial?.speaker === "agent" ? voice.partial.text.trim() : "";
596
+ if (partialText) {
597
+ setMessages((prev) => {
598
+ const last = prev[prev.length - 1];
599
+ if (last?.id.startsWith("voice-") && last.role === "assistant") {
600
+ voiceStreamingIdRef.current = last.id;
601
+ return [...prev.slice(0, -1), { ...last, content: partialText, isStreaming: true }];
602
+ }
603
+ const id = voiceStreamingIdRef.current || `voice-partial-${Date.now()}`;
604
+ voiceStreamingIdRef.current = id;
605
+ return [...prev, { id, role: "assistant", content: partialText, isStreaming: true, createdAt: /* @__PURE__ */ new Date() }];
606
+ });
607
+ } else if (voice.state !== "speaking" && voiceStreamingIdRef.current) {
608
+ const doneId = voiceStreamingIdRef.current;
609
+ voiceStreamingIdRef.current = null;
610
+ setMessages(
611
+ (prev) => prev.map((m) => m.id === doneId ? { ...m, isStreaming: false } : m)
612
+ );
613
+ }
614
+ }, [voice?.transcript, voice?.partial, voice?.state, threadId]);
543
615
  const sendMessage = (0, import_react2.useCallback)(
544
616
  async (contentToSend, overrideOptions) => {
545
617
  const prompt = (contentToSend ?? input).trim();