| 1 | import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| 3 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; |
| 4 | |
| 5 | /** Question responses carry text, so send thread chips as links with the actual thread IDs. */ |
| 6 | export function projectPendingUserInputAnswer( |
| 7 | answer: unknown, |
| 8 | threadContexts: ReadonlyArray<ThreadContextRecord>, |
| 9 | ) { |
| 10 | if (typeof answer !== "string" || threadContexts.length === 0) return answer; |
| 11 | const recordsById = new Map(threadContexts.map((record) => [record.contextId, record])); |
| 12 | return replaceComposerContextReferences(answer, (reference) => { |
| 13 | const record = reference.kind === "thread" ? recordsById.get(reference.contextId) : undefined; |
| 14 | return record ? formatThreadLink(record.threadId, reference.label) : reference.source; |
| 15 | }); |
| 16 | } |
| 17 | |
| 18 | export interface PendingUserInputDraftAnswer { |
| 19 | selectedOptionValues?: string[]; |
| 20 | customAnswer?: string; |
| 21 | attachmentCount?: number; |
| 22 | attachmentsBlocked?: boolean; |
| 23 | } |
| 24 | |
| 25 | export interface PendingUserInputProgress { |
| 26 | questionIndex: number; |
| 27 | activeQuestion: UserInputQuestion | null; |
| 28 | activeDraft: PendingUserInputDraftAnswer | undefined; |
| 29 | selectedOptionValues: string[]; |
| 30 | customAnswer: string; |
| 31 | resolvedAnswer: string | string[] | null; |
| 32 | usingCustomAnswer: boolean; |
| 33 | answeredQuestionCount: number; |
| 34 | isLastQuestion: boolean; |
| 35 | isComplete: boolean; |
| 36 | canAdvance: boolean; |
| 37 | } |
| 38 | |
| 39 | function normalizeDraftAnswer(value: string | undefined): string | null { |
| 40 | if (typeof value !== "string") { |
| 41 | return null; |
| 42 | } |
| 43 | |
| 44 | const trimmed = value.trim(); |
| 45 | return trimmed.length > 0 ? trimmed : null; |
| 46 | } |
| 47 | |
| 48 | function normalizeSelectedOptionValues(value: string[] | undefined): string[] { |
| 49 | if (!Array.isArray(value)) { |
| 50 | return []; |
| 51 | } |
| 52 | |
| 53 | // Provider option IDs must stay unchanged, including whitespace. |
| 54 | return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); |
| 55 | } |
| 56 | |
| 57 | export function resolvePendingUserInputAnswer( |
| 58 | question: UserInputQuestion, |
| 59 | draft: PendingUserInputDraftAnswer | undefined, |
| 60 | ): string | string[] | null { |
| 61 | if (draft?.attachmentsBlocked) return null; |
| 62 | const customAnswer = |
| 63 | question.allowCustomAnswer === false |
| 64 | ? null |
| 65 | : question.initialAnswer !== undefined |
| 66 | ? (draft?.customAnswer ?? null) |
| 67 | : normalizeDraftAnswer(draft?.customAnswer); |
| 68 | if (customAnswer !== null && customAnswer.length > 0) { |
| 69 | return customAnswer; |
| 70 | } |
| 71 | |
| 72 | const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( |
| 73 | (value) => question.options.some((option) => (option.value ?? option.label) === value), |
| 74 | ); |
| 75 | if (question.multiSelect) { |
| 76 | return selectedOptionValues.length > 0 |
| 77 | ? selectedOptionValues |
| 78 | : (customAnswer ?? |
| 79 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); |
| 80 | } |
| 81 | |
| 82 | return ( |
| 83 | selectedOptionValues[0] ?? |
| 84 | customAnswer ?? |
| 85 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | export function setPendingUserInputCustomAnswer( |
| 90 | draft: PendingUserInputDraftAnswer | undefined, |
| 91 | customAnswer: string, |
| 92 | question?: Pick<UserInputQuestion, "initialAnswer">, |
| 93 | ): PendingUserInputDraftAnswer { |
| 94 | const selectedOptionValues = |
| 95 | question?.initialAnswer !== undefined || customAnswer.trim().length > 0 |
| 96 | ? undefined |
| 97 | : normalizeSelectedOptionValues(draft?.selectedOptionValues); |
| 98 | |
| 99 | return { |
| 100 | customAnswer, |
| 101 | ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | const DISPLACED_ANSWER_SEPARATOR = "\n\n"; |
| 106 | |
| 107 | /** |
| 108 | * Selecting an option replaces the custom answer, because a non-empty custom |
| 109 | * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the |
| 110 | * user typed into the answer field must not vanish on that click: it moves back |
| 111 | * into the thread draft, after whatever was already waiting there. |
| 112 | */ |
| 113 | export function carryDisplacedCustomAnswerIntoPrompt( |
| 114 | prompt: string, |
| 115 | customAnswer: string | undefined, |
| 116 | ): string { |
| 117 | const displaced = customAnswer?.trim() ?? ""; |
| 118 | if (displaced.length === 0) { |
| 119 | return prompt; |
| 120 | } |
| 121 | if (prompt.trim().length === 0) { |
| 122 | return displaced; |
| 123 | } |
| 124 | return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; |
| 125 | } |
| 126 | |
| 127 | export function togglePendingUserInputOptionSelection( |
| 128 | question: UserInputQuestion, |
| 129 | draft: PendingUserInputDraftAnswer | undefined, |
| 130 | optionValue: string, |
| 131 | ): PendingUserInputDraftAnswer { |
| 132 | if (question.multiSelect) { |
| 133 | const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); |
| 134 | const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) |
| 135 | ? selectedOptionValues.filter((value) => value !== optionValue) |
| 136 | : [...selectedOptionValues, optionValue]; |
| 137 | |
| 138 | return { |
| 139 | customAnswer: "", |
| 140 | ...(nextSelectedOptionValues.length > 0 |
| 141 | ? { selectedOptionValues: nextSelectedOptionValues } |
| 142 | : {}), |
| 143 | }; |
| 144 | } |
| 145 | |
| 146 | return { |
| 147 | customAnswer: "", |
| 148 | selectedOptionValues: [optionValue], |
| 149 | }; |
| 150 | } |
| 151 | |
| 152 | export function buildPendingUserInputAnswers( |
| 153 | questions: ReadonlyArray<UserInputQuestion>, |
| 154 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 155 | ): Record<string, string | string[]> | null { |
| 156 | const answers: Record<string, string | string[]> = {}; |
| 157 | |
| 158 | for (const question of questions) { |
| 159 | const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); |
| 160 | if (answer === null) { |
| 161 | return null; |
| 162 | } |
| 163 | answers[question.id] = answer; |
| 164 | } |
| 165 | |
| 166 | return answers; |
| 167 | } |
| 168 | |
| 169 | export function countAnsweredPendingUserInputQuestions( |
| 170 | questions: ReadonlyArray<UserInputQuestion>, |
| 171 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 172 | ): number { |
| 173 | return questions.reduce((count, question) => { |
| 174 | return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null |
| 175 | ? count + 1 |
| 176 | : count; |
| 177 | }, 0); |
| 178 | } |
| 179 | |
| 180 | export function findFirstUnansweredPendingUserInputQuestionIndex( |
| 181 | questions: ReadonlyArray<UserInputQuestion>, |
| 182 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 183 | ): number { |
| 184 | const unansweredIndex = questions.findIndex( |
| 185 | (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null, |
| 186 | ); |
| 187 | |
| 188 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; |
| 189 | } |
| 190 | |
| 191 | export function derivePendingUserInputProgress( |
| 192 | questions: ReadonlyArray<UserInputQuestion>, |
| 193 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 194 | questionIndex: number, |
| 195 | ): PendingUserInputProgress { |
| 196 | const normalizedQuestionIndex = |
| 197 | questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1)); |
| 198 | const activeQuestion = questions[normalizedQuestionIndex] ?? null; |
| 199 | const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; |
| 200 | const resolvedAnswer = activeQuestion |
| 201 | ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) |
| 202 | : null; |
| 203 | const customAnswer = |
| 204 | activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? ""); |
| 205 | const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); |
| 206 | const isLastQuestion = |
| 207 | questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; |
| 208 | |
| 209 | return { |
| 210 | questionIndex: normalizedQuestionIndex, |
| 211 | activeQuestion, |
| 212 | activeDraft, |
| 213 | selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), |
| 214 | customAnswer, |
| 215 | resolvedAnswer, |
| 216 | usingCustomAnswer: |
| 217 | (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0, |
| 218 | answeredQuestionCount, |
| 219 | isLastQuestion, |
| 220 | isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, |
| 221 | canAdvance: resolvedAnswer !== null, |
| 222 | }; |
| 223 | } |