PageSourceSearch

https://noman-ai.jp/assets/components/app/document_editor/component_controller-b01c72dc.js

js noman-ai.jp collected 2026-10-04 18:38:00 UTC 10,401 bytes, 320 lines download raw bytes

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.