返回 JoyAI-Echo
questions.ts
根目录 / echo_longvideo / Director_Agent / webui / src / lib / questions.ts
1 import { splitMediaByKind } from "@/lib/media";
2 import type { SessionWireMessage } from "@/lib/api";
3 import type { UIQuestionCard, UIMessage, WireQuestionCard } from "@/lib/types";
4
5 const BATCH_ID_RE =
6 /batch=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
7
8 const AGENT_INJECT_STARTS = [
9 "REFERENCE_IMAGE_GATE ",
10 "STORY_DIRECTION_CONFIRM ",
11 ] as const;
12
13 /** Drop LLM-only inject prefixes so the thread shows the user's card answer. */
14 export function visibleUserContent(content: string): string {
15 const stripped = content.trim();
16 if (!AGENT_INJECT_STARTS.some((prefix) => stripped.startsWith(prefix))) {
17 return content;
18 }
19 const parts = stripped.split(/\n\n/, 2);
20 if (parts.length === 2 && parts[1]?.trim()) {
21 return parts[1].trim();
22 }
23 const lines = stripped
24 .split("\n")
25 .map((line) => line.trim())
26 .filter(Boolean);
27 return lines.length >= 2 ? (lines[lines.length - 1] ?? "") : "";
28 }
29
30 function withVisibleUserContent(
31 messages: SessionWireMessage[],
32 ): SessionWireMessage[] {
33 return messages.map((row) => {
34 if (row.role !== "user" || typeof row.content !== "string") return row;
35 const visible = visibleUserContent(row.content);
36 return visible === row.content ? row : { ...row, content: visible };
37 });
38 }
39
40 /** Normalize backend / session question cards into UI shape. */
41 export function wireQuestionCards(raw: WireQuestionCard[]): UIQuestionCard[] {
42 return raw.flatMap((card, index): UIQuestionCard[] => {
43 if (!card || typeof card !== "object") return [];
44 const question =
45 typeof card.question === "string" ? card.question.trim() : "";
46 if (!question || !Array.isArray(card.options)) return [];
47
48 const options: { label: string }[] = [];
49 for (const opt of card.options) {
50 if (typeof opt === "string" && opt.trim()) {
51 options.push({ label: opt.trim() });
52 continue;
53 }
54 if (
55 typeof opt === "object" &&
56 opt !== null &&
57 typeof opt.label === "string" &&
58 opt.label.trim()
59 ) {
60 options.push({ label: opt.label.trim() });
61 }
62 }
63 if (options.length === 0) return [];
64
65 const allowCustom =
66 card.allow_custom === true || card.allowCustom === true;
67 const id =
68 typeof card.id === "string" && card.id.trim()
69 ? card.id.trim()
70 : `q-${index}`;
71 return [
72 {
73 id,
74 question,
75 options,
76 ...(allowCustom ? { allowCustom: true } : {}),
77 ...(card.answered != null ? { answered: card.answered } : {}),
78 },
79 ];
80 });
81 }
82
83 /** Extract question batch id from an ask_user tool result string. */
84 export function extractBatchIdFromToolResult(
85 content: string,
86 ): string | undefined {
87 const match = content.match(BATCH_ID_RE);
88 return match?.[1];
89 }
90
91 /** Parse one OpenAI-style tool_call for ask_user payload. */
92 export function parseAskUserToolCall(toolCall: unknown): {
93 toolCallId: string;
94 content: string;
95 questions: WireQuestionCard[];
96 } | null {
97 if (!toolCall || typeof toolCall !== "object") return null;
98 const tc = toolCall as {
99 id?: unknown;
100 function?: { name?: unknown; arguments?: unknown };
101 };
102 if (typeof tc.id !== "string" || !tc.id.trim()) return null;
103 const fn = tc.function;
104 if (!fn || fn.name !== "ask_user") return null;
105 if (typeof fn.arguments !== "string" || !fn.arguments.trim()) return null;
106
107 let parsed: unknown;
108 try {
109 parsed = JSON.parse(fn.arguments);
110 } catch {
111 return null;
112 }
113 if (!parsed || typeof parsed !== "object") return null;
114 const args = parsed as {
115 content?: unknown;
116 questions?: unknown;
117 };
118 if (!Array.isArray(args.questions) || args.questions.length === 0) {
119 return null;
120 }
121 const intro =
122 typeof args.content === "string" ? args.content.trim() : "";
123
124 const questions: WireQuestionCard[] = [];
125 for (const item of args.questions) {
126 if (!item || typeof item !== "object") continue;
127 const card = item as WireQuestionCard;
128 if (typeof card.question !== "string" || !Array.isArray(card.options)) {
129 continue;
130 }
131 questions.push({
132 id: typeof card.id === "string" ? card.id : `q-${questions.length}`,
133 question: card.question,
134 options: card.options,
135 ...(card.allow_custom === true || card.allowCustom === true
136 ? { allow_custom: true }
137 : {}),
138 ...(card.answered != null ? { answered: card.answered } : {}),
139 });
140 }
141 if (questions.length === 0) return null;
142
143 return {
144 toolCallId: tc.id,
145 content: intro,
146 questions,
147 };
148 }
149
150 /** Index tool_call_id → batch_id from raw session tool results. */
151 export function indexAskUserBatches(
152 messages: Array<{
153 role: string;
154 tool_call_id?: string;
155 name?: string;
156 content?: string;
157 }>,
158 ): Map<string, string> {
159 const index = new Map<string, string>();
160 for (const m of messages) {
161 if (m.role !== "tool") continue;
162 if (typeof m.tool_call_id !== "string" || !m.tool_call_id.trim()) {
163 continue;
164 }
165 const isAskUser =
166 m.name === "ask_user" ||
167 (typeof m.content === "string" &&
168 m.content.includes("Question cards sent") &&
169 m.content.includes("batch="));
170 if (!isAskUser || typeof m.content !== "string") continue;
171 const batchId = extractBatchIdFromToolResult(m.content);
172 if (batchId) index.set(m.tool_call_id, batchId);
173 }
174 return index;
175 }
176
177 function allAskUserToolCalls(
178 toolCalls: unknown,
179 ): NonNullable<ReturnType<typeof parseAskUserToolCall>>[] {
180 if (!Array.isArray(toolCalls)) return [];
181 const parsed: NonNullable<ReturnType<typeof parseAskUserToolCall>>[] = [];
182 for (const tc of toolCalls) {
183 const item = parseAskUserToolCall(tc);
184 if (item) parsed.push(item);
185 }
186 return parsed;
187 }
188
189 function firstAskUserToolCall(
190 toolCalls: unknown,
191 ): ReturnType<typeof parseAskUserToolCall> {
192 const all = allAskUserToolCalls(toolCalls);
193 return all[0] ?? null;
194 }
195
196 function mergeAskUserQuestions(
197 toolCalls: unknown,
198 ): WireQuestionCard[] {
199 const merged: WireQuestionCard[] = [];
200 for (const item of allAskUserToolCalls(toolCalls)) {
201 for (const card of item.questions) {
202 merged.push(card);
203 }
204 }
205 return merged;
206 }
207
208 function assistantHasQuestions(
209 row: Pick<SessionWireMessage, "role" | "questions" | "tool_calls">,
210 ): boolean {
211 if (row.role !== "assistant") return false;
212 if (Array.isArray(row.questions) && row.questions.length > 0) return true;
213 return mergeAskUserQuestions(row.tool_calls).length > 0;
214 }
215
216 function cardAcceptsReply(card: UIQuestionCard, reply: string): boolean {
217 if (card.options.some((opt) => opt.label === reply)) {
218 return true;
219 }
220 return card.allowCustom === true;
221 }
222
223 function assistantBatchAcceptsReply(
224 questions: UIQuestionCard[],
225 reply: string,
226 ): boolean {
227 const pending = questions.filter((q) => q.answered == null);
228 if (pending.length === 0) {
229 return false;
230 }
231 return pending.some((q) => cardAcceptsReply(q, reply));
232 }
233
234 function replyMatchesKnownCardAnswer(
235 messages: SessionWireMessage[],
236 text: string,
237 batchByToolCallId: Map<string, string>,
238 ): boolean {
239 for (const m of messages) {
240 if (m.role !== "assistant") continue;
241 const { questions } = wireQuestionsFromSessionRow(m, batchByToolCallId);
242 if (!questions?.length) continue;
243 if (
244 questions.some(
245 (q) =>
246 q.answered === text ||
247 q.options.some((opt) => opt.label === text),
248 )
249 ) {
250 return true;
251 }
252 }
253 return false;
254 }
255
256 /** Drop card-style user rows piled at the end or duplicated after refresh. */
257 function shouldDropMisplacedCardUserReply(
258 messages: SessionWireMessage[],
259 idx: number,
260 batchByToolCallId: Map<string, string>,
261 seenUserReplies: Set<string>,
262 ): boolean {
263 const row = messages[idx];
264 if (row.role !== "user") return false;
265 const text = typeof row.content === "string" ? row.content.trim() : "";
266 if (!text) return true;
267 if (seenUserReplies.has(text)) return true;
268
269 if (!replyMatchesKnownCardAnswer(messages, text, batchByToolCallId)) {
270 seenUserReplies.add(text);
271 return false;
272 }
273
274 let nearestCardIdx: number | null = null;
275 for (let i = idx - 1; i >= 0; i--) {
276 const prior = messages[i];
277 if (prior.role === "user") break;
278 if (prior.role === "assistant" && assistantHasQuestions(prior)) {
279 nearestCardIdx = i;
280 break;
281 }
282 }
283
284 if (nearestCardIdx === null) {
285 seenUserReplies.add(text);
286 return false;
287 }
288
289 const { questions } = wireQuestionsFromSessionRow(
290 messages[nearestCardIdx],
291 batchByToolCallId,
292 );
293 if (!questions?.length) {
294 seenUserReplies.add(text);
295 return false;
296 }
297
298 const matchesNearest =
299 questions.some((q) => q.answered === text) ||
300 assistantBatchAcceptsReply(questions, text);
301
302 if (matchesNearest) {
303 seenUserReplies.add(text);
304 return false;
305 }
306
307 return true;
308 }
309
310 function cardBatchDisplayReply(questions: UIQuestionCard[]): string | null {
311 for (const q of questions) {
312 if (typeof q.answered === "string" && q.answered.trim()) {
313 return q.answered.trim();
314 }
315 }
316 return null;
317 }
318
319 function hasProperUserReplyAfterCard(
320 messages: SessionWireMessage[],
321 cardIdx: number,
322 reply: string,
323 ): boolean {
324 for (let i = cardIdx + 1; i < messages.length; i++) {
325 const row = messages[i];
326 if (row.role === "user") {
327 const text = typeof row.content === "string" ? row.content.trim() : "";
328 if (text !== reply) continue;
329
330 let nearestCardIdx: number | null = null;
331 for (let j = i - 1; j >= 0; j--) {
332 const prior = messages[j];
333 if (prior.role === "user") break;
334 if (prior.role === "assistant" && assistantHasQuestions(prior)) {
335 nearestCardIdx = j;
336 break;
337 }
338 }
339 return nearestCardIdx === cardIdx;
340 }
341 if (row.role === "assistant" && assistantHasQuestions(row)) {
342 continue;
343 }
344 }
345 return false;
346 }
347
348 export function resolveQuestionCardsFromThread(
349 messages: UIMessage[],
350 ): UIMessage[] {
351 const next = messages.map((m) => ({
352 ...m,
353 questions: m.questions?.map((q) => ({ ...q })),
354 }));
355
356 for (let userIdx = 0; userIdx < next.length; userIdx++) {
357 const row = next[userIdx];
358 if (row.role !== "user") continue;
359 const reply = row.content.trim();
360 if (!reply) continue;
361
362 for (let i = userIdx - 1; i >= 0; i--) {
363 const prior = next[i];
364 if (prior.role !== "assistant" || !prior.questions?.length) continue;
365 if (!assistantBatchAcceptsReply(prior.questions, reply)) continue;
366
367 next[i] = {
368 ...prior,
369 questions: prior.questions.map((q) =>
370 q.answered == null && cardAcceptsReply(q, reply)
371 ? { ...q, answered: reply }
372 : q,
373 ),
374 };
375 break;
376 }
377 }
378
379 return next;
380 }
381
382 function questionFingerprint(
383 questions: UIQuestionCard[],
384 questionBatchId?: string,
385 ): string {
386 const body = questionBodyFingerprint(questions);
387 return questionBatchId ? `${questionBatchId}\0${body}` : body;
388 }
389
390 function questionBodyFingerprint(questions: UIQuestionCard[]): string {
391 return questions
392 .map((q) => `${q.id}\0${q.question.trim()}`)
393 .filter(Boolean)
394 .join("\n");
395 }
396
397 function wireQuestionsFromSessionRow(
398 m: SessionWireMessage,
399 batchByToolCallId: Map<string, string>,
400 ): { questions?: UIQuestionCard[]; questionBatchId?: string } {
401 let questions: UIQuestionCard[] | undefined;
402 let questionBatchId: string | undefined;
403
404 if (Array.isArray(m.questions) && m.questions.length > 0) {
405 const wired = wireQuestionCards(m.questions);
406 if (wired.length > 0) {
407 questions = wired;
408 if (typeof m.question_batch_id === "string" && m.question_batch_id.trim()) {
409 questionBatchId = m.question_batch_id;
410 }
411 }
412 }
413
414 if (!questions?.length && m.role === "assistant" && m.tool_calls) {
415 const askUser = firstAskUserToolCall(m.tool_calls);
416 const mergedQuestions = mergeAskUserQuestions(m.tool_calls);
417 const wired = wireQuestionCards(
418 mergedQuestions.length > 0
419 ? mergedQuestions
420 : (askUser?.questions ?? []),
421 );
422 if (wired.length > 0) {
423 questions = wired;
424 questionBatchId =
425 (typeof m.question_batch_id === "string" && m.question_batch_id.trim()
426 ? m.question_batch_id
427 : undefined) ??
428 (askUser ? batchByToolCallId.get(askUser.toolCallId) : undefined);
429 }
430 }
431
432 return { questions, questionBatchId };
433 }
434
435 function isPlainTextDuplicateOfNearbyQuestionCard(
436 messages: SessionWireMessage[],
437 idx: number,
438 seenQuestionBatches: Set<string>,
439 seenQuestionFingerprints: Set<string>,
440 seenQuestionBodies: Set<string>,
441 ): boolean {
442 const row = messages[idx];
443 if (row.role !== "assistant" || assistantHasQuestions(row)) {
444 return false;
445 }
446 if (row.tool_calls?.length) {
447 return false;
448 }
449 const text = typeof row.content === "string" ? row.content.trim() : "";
450 if (!text) {
451 return false;
452 }
453
454 for (let i = idx - 1; i >= Math.max(0, idx - 12); i--) {
455 const prior = messages[i];
456 if (prior.role === "user") {
457 return false;
458 }
459 if (prior.role !== "assistant" || !assistantHasQuestions(prior)) {
460 continue;
461 }
462 const batchByToolCallId = indexAskUserBatches(messages);
463 const { questions, questionBatchId } = wireQuestionsFromSessionRow(
464 prior,
465 batchByToolCallId,
466 );
467 if (!questions?.length) {
468 return false;
469 }
470 const parts = questions.map((q) => q.question.trim()).filter(Boolean);
471 if (!(text === parts.join("\n") || parts.some((part) => text.includes(part)))) {
472 continue;
473 }
474 const body = questionBodyFingerprint(questions);
475 if (seenQuestionBodies.has(body)) {
476 return true;
477 }
478 if (questionBatchId && seenQuestionBatches.has(questionBatchId)) {
479 return true;
480 }
481 const fingerprint = questionFingerprint(questions, questionBatchId);
482 if (fingerprint && seenQuestionFingerprints.has(fingerprint)) {
483 return true;
484 }
485 return false;
486 }
487 return false;
488 }
489
490 function recoverQuestionCardFromNearbyPlainText(
491 messages: SessionWireMessage[],
492 idx: number,
493 batchByToolCallId: Map<string, string>,
494 seenQuestionBatches: Set<string>,
495 seenQuestionFingerprints: Set<string>,
496 seenQuestionBodies: Set<string>,
497 ): UIMessage[] {
498 const row = messages[idx];
499 if (row.role !== "assistant" || assistantHasQuestions(row)) {
500 return [];
501 }
502 if (row.tool_calls?.length) {
503 return [];
504 }
505 const text = typeof row.content === "string" ? row.content.trim() : "";
506 if (!text) {
507 return [];
508 }
509
510 for (let i = idx - 1; i >= Math.max(0, idx - 12); i--) {
511 const prior = messages[i];
512 if (prior.role === "user") {
513 return [];
514 }
515 if (prior.role !== "assistant" || !assistantHasQuestions(prior)) {
516 continue;
517 }
518 const { questions, questionBatchId } = wireQuestionsFromSessionRow(
519 prior,
520 batchByToolCallId,
521 );
522 if (!questions?.length) {
523 return [];
524 }
525 const parts = questions.map((q) => q.question.trim()).filter(Boolean);
526 if (!(text === parts.join("\n") || parts.some((part) => text.includes(part)))) {
527 continue;
528 }
529 const body = questionBodyFingerprint(questions);
530 if (seenQuestionBodies.has(body)) {
531 return [];
532 }
533 if (questionBatchId && seenQuestionBatches.has(questionBatchId)) {
534 return [];
535 }
536 const fingerprint = questionFingerprint(questions, questionBatchId);
537 if (fingerprint && seenQuestionFingerprints.has(fingerprint)) {
538 return [];
539 }
540 if (questionBatchId) {
541 seenQuestionBatches.add(questionBatchId);
542 }
543 if (fingerprint) {
544 seenQuestionFingerprints.add(fingerprint);
545 }
546 seenQuestionBodies.add(body);
547 return [
548 {
549 id: `hist-${idx}`,
550 role: "assistant",
551 content: "",
552 createdAt: row.timestamp ? Date.parse(row.timestamp) : Date.now(),
553 questions,
554 ...(questionBatchId ? { questionBatchId } : {}),
555 },
556 ];
557 }
558 return [];
559 }
560
561 /** Drop assistant prose that re-lists options from an already-answered card. */
562 function isPlainTextRedundantQuestionListing(
563 messages: SessionWireMessage[],
564 idx: number,
565 ): boolean {
566 const row = messages[idx];
567 if (row.role !== "assistant" || assistantHasQuestions(row)) {
568 return false;
569 }
570 if (row.tool_calls?.length) {
571 return false;
572 }
573 const text = typeof row.content === "string" ? row.content.trim() : "";
574 if (!text) {
575 return false;
576 }
577
578 const batchByToolCallId = indexAskUserBatches(messages);
579 for (let i = 0; i < messages.length; i++) {
580 if (i === idx) continue;
581 const other = messages[i];
582 if (other.role !== "assistant") continue;
583 const { questions } = wireQuestionsFromSessionRow(other, batchByToolCallId);
584 if (!questions?.length) continue;
585
586 const labels = questions
587 .flatMap((q) => q.options.map((o) => o.label))
588 .filter(Boolean);
589 const matchCount = labels.filter((label) => text.includes(label)).length;
590 const question = questions[0]?.question ?? "";
591 const matchesQuestion =
592 question.length > 0 &&
593 text.includes(question) &&
594 matchCount >= 1;
595 if (matchCount < 2 && !matchesQuestion) {
596 continue;
597 }
598
599 const answered = questions.some((q) => q.answered != null);
600 let userRepliedAfter = false;
601 for (let j = i + 1; j < idx; j++) {
602 if (messages[j]?.role === "user") {
603 const reply =
604 typeof messages[j].content === "string"
605 ? messages[j].content.trim()
606 : "";
607 if (reply) {
608 userRepliedAfter = true;
609 break;
610 }
611 }
612 }
613 if (answered || userRepliedAfter) {
614 return true;
615 }
616 }
617 return false;
618 }
619
620 function isAskUserFollowupBlurb(
621 messages: SessionWireMessage[],
622 idx: number,
623 ): boolean {
624 const row = messages[idx];
625 if (row.role !== "assistant" || assistantHasQuestions(row)) {
626 return false;
627 }
628 if (typeof row.content !== "string" || !row.content.trim()) {
629 return false;
630 }
631 if (row.tool_calls?.length) {
632 return false;
633 }
634 let prev = idx - 1;
635 while (prev >= 0) {
636 const prior = messages[prev];
637 if (prior.role === "user") {
638 return false;
639 }
640 if (prior.role === "tool") {
641 prev -= 1;
642 continue;
643 }
644 if (prior.role === "assistant") {
645 if (!assistantHasQuestions(prior)) {
646 prev -= 1;
647 continue;
648 }
649 for (let i = prev + 1; i < idx; i++) {
650 if (messages[i]?.role === "user") {
651 return false;
652 }
653 }
654 return true;
655 }
656 prev -= 1;
657 }
658 return false;
659 }
660
661 /** True when a live UI assistant row is redundant text right after question cards. */
662 export function isUiAskUserFollowupBlurb(
663 messages: UIMessage[],
664 idx: number,
665 ): boolean {
666 const row = messages[idx];
667 if (row.role !== "assistant" || (row.questions?.length ?? 0) > 0) {
668 return false;
669 }
670 if (!row.content.trim()) {
671 return false;
672 }
673 let prev = idx - 1;
674 while (prev >= 0) {
675 const prior = messages[prev];
676 if (prior.role === "user") {
677 return false;
678 }
679 if (prior.role === "assistant") {
680 if (
681 prior.turnWaiting &&
682 !prior.content.trim() &&
683 !(prior.questions?.length ?? 0)
684 ) {
685 prev -= 1;
686 continue;
687 }
688 if (!(prior.questions?.length ?? 0)) {
689 prev -= 1;
690 continue;
691 }
692 for (let i = prev + 1; i < idx; i++) {
693 if (messages[i]?.role === "user") {
694 return false;
695 }
696 }
697 return true;
698 }
699 prev -= 1;
700 }
701 return false;
702 }
703
704 /** Drop assistant blurbs that duplicate ask_user cards (live thread or replay). */
705 export function stripAskUserFollowupBlurbs(
706 messages: UIMessage[],
707 ): UIMessage[] {
708 return messages.filter((_, idx) => !isUiAskUserFollowupBlurb(messages, idx));
709 }
710
711 /** Map persisted session messages into thread UI messages. */
712 export function wireSessionMessages(
713 rawMessages: SessionWireMessage[],
714 ): UIMessage[] {
715 const messages = withVisibleUserContent(rawMessages);
716 const batchByToolCallId = indexAskUserBatches(messages);
717 const seenQuestionBatches = new Set<string>();
718 const seenQuestionFingerprints = new Set<string>();
719 const seenQuestionBodies = new Set<string>();
720 const seenUserReplies = new Set<string>();
721
722 const ui = messages.flatMap((m, idx) => {
723 if (m.role !== "user" && m.role !== "assistant") return [];
724 if (typeof m.content !== "string") return [];
725
726 if (m.role === "user") {
727 const text = m.content.trim();
728 if (!text) return [];
729 if (
730 shouldDropMisplacedCardUserReply(
731 messages,
732 idx,
733 batchByToolCallId,
734 seenUserReplies,
735 )
736 ) {
737 return [];
738 }
739 }
740
741 if (isAskUserFollowupBlurb(messages, idx)) return [];
742 if (isPlainTextRedundantQuestionListing(messages, idx)) return [];
743 if (
744 isPlainTextDuplicateOfNearbyQuestionCard(
745 messages,
746 idx,
747 seenQuestionBatches,
748 seenQuestionFingerprints,
749 seenQuestionBodies,
750 )
751 ) {
752 return [];
753 }
754
755 const recovered = recoverQuestionCardFromNearbyPlainText(
756 messages,
757 idx,
758 batchByToolCallId,
759 seenQuestionBatches,
760 seenQuestionFingerprints,
761 seenQuestionBodies,
762 );
763 if (recovered.length > 0) {
764 return recovered;
765 }
766
767 const { images, videos } = splitMediaByKind(m.media_urls);
768
769 const { questions, questionBatchId } = wireQuestionsFromSessionRow(
770 m,
771 batchByToolCallId,
772 );
773
774 if (questionBatchId && seenQuestionBatches.has(questionBatchId)) {
775 return [];
776 }
777 const fingerprint = questions?.length
778 ? questionFingerprint(questions, questionBatchId)
779 : "";
780 const body = questions?.length ? questionBodyFingerprint(questions) : "";
781 if (body && seenQuestionBodies.has(body)) {
782 return [];
783 }
784 if (fingerprint && seenQuestionFingerprints.has(fingerprint)) {
785 return [];
786 }
787 if (questionBatchId) {
788 seenQuestionBatches.add(questionBatchId);
789 }
790 if (fingerprint) {
791 seenQuestionFingerprints.add(fingerprint);
792 }
793 if (body) {
794 seenQuestionBodies.add(body);
795 }
796
797 // Cards carry the prompt; avoid duplicating it as markdown text above.
798 const displayContent = questions?.length ? "" : m.content.trim();
799
800 if (!displayContent && !questions?.length && !images && !videos) {
801 return [];
802 }
803
804 const cardMsg: UIMessage = {
805 id: `hist-${idx}`,
806 role: m.role as UIMessage["role"],
807 content: displayContent,
808 createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
809 ...(images ? { images } : {}),
810 ...(videos ? { videos } : {}),
811 ...(questions?.length ? { questions } : {}),
812 ...(questionBatchId ? { questionBatchId } : {}),
813 };
814
815 const batchReply =
816 m.role === "assistant" && questions?.length
817 ? cardBatchDisplayReply(questions)
818 : null;
819 if (
820 batchReply &&
821 !hasProperUserReplyAfterCard(messages, idx, batchReply)
822 ) {
823 seenUserReplies.add(batchReply);
824 return [
825 cardMsg,
826 {
827 id: `hist-${idx}-reply`,
828 role: "user" as UIMessage["role"],
829 content: batchReply,
830 createdAt: m.timestamp ? Date.parse(m.timestamp) + 1 : Date.now(),
831 },
832 ];
833 }
834
835 return [cardMsg];
836 });
837 return resolveQuestionCardsFromThread(ui);
838 }
839
840
840 lines TYPESCRIPT