| 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 |