1import { Controller } from "@hotwired/stimulus" 2import { convertHtmlToYDocState } from 'tiptap_editor' 3import * as Sentry from "@sentry/browser"; 4import { useNotification } from 'mixins/useNotification'; 5import { useModal } from "mixins/useModal"; 6import { DOCUMENT_STREAM_CONTROL_MARKERS, LlmStreamEventParser, consumeDocumentStreamEvents } from 'utils/stream_filter' 7import { abortableDelay } from 'utils/abortable_delay' 8import { csrfHeaders, isFetchNetworkError } from 'utils/api_client' 9import { loadPendingDocumentGeneration } from 'utils/pending_document_generation' 10import { storePendingWindowNotification } from 'utils/pending_window_notification' 11 12const MAX_RETRIES = 3; 13const RETRY_DELAYS = [5000, 10000, 15000]; // 5s, 10s, 15s 14 15class DocumentTemplateRequiredError extends Error { 16 constructor(message) { 17 super(message); 18 this.name = 'DocumentTemplateRequiredError'; 19 } 20} 21 22export default class extends Controller { 23 #abortController = null; 24 #disconnected = false; 25 26 static get targets() { 27 return [ 28 'summaryGeneration', 29 'templateSelection' 30 ] 31 } 32 33 static get values() { 34 return { 35 content: String, 36 contentPath: String, 37 documentId: Number, 38 autoGenerateDocument: Boolean, 39 useDriveGenerationProgress: Boolean, 40 driveBackUrl: String, 41 translations: { type: Object, default: {} } 42 } 43 } 44 45 connect() { 46 this.#disconnected = false; 47 this.#abortController = null; 48 useNotification(this); 49 useModal(this); 50 51 // Reveal immediately when nothing is pending; resume is nested and connects too late. 52 if (this.useDriveGenerationProgressValue && !this.autoGenerateDocumentValue && !loadPendingDocumentGeneration()) { 53 this.#restoreDriveGenerationUi(); 54 } 55 56 if (this.autoGenerateDocumentValue) { 57 requestAnimationFrame(() => this.#submitSelectedTemplate()); 58 } 59 } 60 61 disconnect() { 62 this.#disconnected = true; 63 this.#abortController?.abort(); 64 } 65 66 async generateDocument(e) { 67 e.preventDefault(); 68 if (this.useDriveGenerationProgressValue) { 69 this.#dispatchDriveGenerationProgress("started", e.params?.templateName); 70 } else { 71 this.showProcessingModal({ 72 title: this.translationsValue.processingTitle, 73 description: this.translationsValue.processingDescription 74 }); 75 } 76 77 this.summaryGenerationTarget.controller?.setTemplateName(e.params.templateName); 78 this.templateSelectionTarget.classList.add('hidden'); 79 80 try { 81 const succeeded = await this.#attemptGeneration(e); 82 if (!succeeded) { 83 this.#restoreAfterGenerationFailure(); 84 } 85 } catch (error) { 86 this.#restoreAfterGenerationFailure(); 87 if (error.name === 'AbortError') return; 88 if (error instanceof DocumentTemplateRequiredError) { 89 console.error('Document template is required'); 90 this.showErrorNotification({ 91 message: this.translationsValue.templateRequired 92 }); 93 } else if (isFetchNetworkError(error)) { 94 console.warn('Skipping document generation error report after a transient network error:', error) 95 this.showErrorNotification({ 96 message: this.translationsValue.retryFailed || 'Generation failed. Please try again later.' 97 }) 98 } else { 99 Sentry.captureException(error); 100 this.showErrorNotification({ 101 message: this.translationsValue.retryFailed || 'Generation failed. Please try again later.' 102 }); 103 } 104 } 105 106 // Close the modal after the error is handled 107 if (!this.useDriveGenerationProgressValue) { 108 setTimeout(() => { 109 this.closeModal(); 110 }, 100); 111 } 112 return false; 113 } 114 115 #submitSelectedTemplate() { 116 if (!this.hasTemplateSelectionTarget) return; 117 118 const form = this.templateSelectionTarget.querySelector('form'); 119 const selectedTemplate = form?.querySelector('input[name="meeting_note[document_template_id]"]:checked'); 120 if (!form || !selectedTemplate) return; 121 122 form.requestSubmit(); 123 } 124 125 async #attemptGeneration(e, retryCount = 0) { 126 if (this.#disconnected) return; 127 128 this.#abortController?.abort(); 129 this.#abortController = new AbortController(); 130 131 this.dispatch('documentGenerationStarted', { 132 bubbles: true, 133 target: window, 134 prefix: null 135 }); 136 137 const formData = new FormData(e.target); 138 if (!formData.has('meeting_note[document_template_id]')) { 139 throw new DocumentTemplateRequiredError(); 140 } 141 142 const url = new URL(this.contentPathValue, window.location.href); 143 url.searchParams.set('retry_count', retryCount); 144 145 const response = await fetch(url, { 146 method: 'POST', 147 headers: csrfHeaders(), 148 body: formData, 149 signal: this.#abortController.signal 150 }); 151 if (!response.ok) { 152 throw new Error(`HTTP error! status: ${response.status}`); 153 } 154 155 const reader = response.body.getReader(); 156 const decoder = new TextDecoder(); 157 const eventParser = new LlmStreamEventParser(DOCUMENT_STREAM_CONTROL_MARKERS); 158 159 const read = (result = "") => reader.read().then(({ done, value }) => { 160 const events = done
161 ? [...eventParser.push(decoder.decode()), ...eventParser.flush()] 162 : eventParser.push(decoder.decode(value, { stream: true })); 163 const consumed = consumeDocumentStreamEvents(events); 164 165 if (consumed.status === 'contentFiltered') return false; 166 if (consumed.status === 'retry') return 'retry'; 167 if (consumed.status === 'errorStop') { 168 return { errorStop: true, body: consumed.body }; 169 } 170 171 if (consumed.content) { 172 const streamController = this.summaryGenerationTarget?.controller; 173 if (!streamController) return null; 174 result = streamController.apply(consumed.content); 175 } 176 177 return done ? result : read(result); 178 }); 179 180 const documentContent = await read(); 181 182 if (documentContent?.errorStop) { 183 Sentry.captureMessage('LLM streaming ERROR_STOP received', { 184 extra: { errorBody: documentContent.body }, 185 level: 'warning' 186 }); 187 if (this.#leaveDriveOnErrorStop()) { 188 return true; 189 } 190 this.showErrorNotification({ 191 message: this.translationsValue.errorStopFailed, 192 subMessage: this.translationsValue.errorStopFailedSubMessage 193 }); 194 return false; 195 } 196 197 // Handle error retry (rate limit, service unavailable, etc.) 198 if (documentContent === 'retry') { 199 if (retryCount < MAX_RETRIES) { 200 const delayMs = RETRY_DELAYS[retryCount] || RETRY_DELAYS[RETRY_DELAYS.length - 1]; 201 const delaySec = Math.round(delayMs / 1000); 202 203 Sentry.captureMessage(`LLM error retry triggered, waiting ${delaySec}s before retry`, { 204 extra: { retryCount: retryCount + 1 }, 205 level: 'warning' 206 }); 207 208 this.summaryGenerationTarget.controller?.reset(); 209 if (this.useDriveGenerationProgressValue) { 210 this.#dispatchDriveGenerationProgress("started"); 211 } else { 212 this.closeModal(); 213 this.showProcessingModal({ 214 title: this.translationsValue.retryingTitle || 'Creating document', 215 description: this.translationsValue.retryingDescription || 'Processing is taking longer due to high traffic. Please wait until completion.' 216 }); 217 } 218 219 await abortableDelay(delayMs, this.#abortController?.signal); 220 return this.#attemptGeneration(e, retryCount + 1); 221 } else { 222 Sentry.captureMessage('LLM error retry failed after max retries', { 223 extra: { retryCount }, 224 level: 'error' 225 }); 226 this.showErrorNotification({ 227 message: this.translationsValue.retryFailed || 'Generation failed after multiple attempts. Please try again later.' 228 }); 229 return false; 230 } 231 } 232 233 // Handle content filter retry 234 if (documentContent === false) { 235 const errorLevel = retryCount < MAX_RETRIES - 1 ? 'warning' : 'error'; 236 const errorMessage = (errorLevel === 'warning') ? 237 `Retrying to generate document content... (retry count: ${retryCount + 1})` : 238 'Document content generation was filtered out after max retries.'; 239 Sentry.captureMessage(errorMessage, { 240 extra: { retryCount: retryCount + 1 }, 241 level: errorLevel 242 }); 243 this.summaryGenerationTarget.controller?.reset(); 244 return this.#attemptGeneration(e, retryCount + 1); 245 } 246 247 // Success case 248 if (documentContent) { 249 try { 250 e.target['meeting_note[base64_content]'].value = this.#docState(documentContent); 251 } catch (error) { 252 Sentry.captureException(error, { 253 extra: { context: 'document_generation_doc_state' } 254 }); 255 this.showErrorNotification({ 256 message: this.translationsValue.retryFailed || 'Generation failed. Please try again later.' 257 }); 258 return false; 259 } 260 if (this.useDriveGenerationProgressValue) { 261 this.#dispatchDriveGenerationProgress("completed"); 262 } 263 e.target.submit(); 264 return true; 265 } 266 267 return false; 268 } 269 270 restoreDriveGenerationUi() { 271 this.#restoreDriveGenerationUi(); 272 } 273 274 showDriveTemplateSelection() { 275 if (!this.useDriveGenerationProgressValue) return; 276 277 this.templateSelectionTarget.classList.remove('hidden'); 278 } 279 280 #restoreAfterGenerationFailure() { 281 this.templateSelectionTarget.classList.remove('hidden'); 282 this.#restoreDriveGenerationUi(); 283 } 284 285 #restoreDriveGenerationUi() { 286 if (!this.useDriveGenerationProgressValue) return; 287 288 window.dispatchEvent(new CustomEvent("documentGenerationProgress:failed")); 289 } 290 291 #leaveDriveOnErrorStop() { 292 if (!this.useDriveGenerationProgressValue) return false;
293 294 const backUrl = this.driveBackUrlValue; 295 if (!backUrl) return false; 296 297 storePendingWindowNotification({ 298 type: 'error', 299 message: this.translationsValue.errorStopFailed, 300 subMessage: this.translationsValue.errorStopFailedSubMessage 301 }); 302 303 if (window.Turbo?.visit) { 304 window.Turbo.visit(backUrl); 305 } else { 306 window.location.assign(backUrl); 307 } 308 return true; 309 } 310 311 #dispatchDriveGenerationProgress(phase, templateName) { 312 window.dispatchEvent(new CustomEvent(`documentGenerationProgress:${phase}`, { 313 detail: { templateName }, 314 })); 315 } 316 317 #docState(content) { 318 return convertHtmlToYDocState(content); 319 } 320}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.