PageSourceSearch

https://3s.info/assets/controllers/search_ai_answer_controller-JjWZbJi.js

js 3s.info collected 2026-10-09 21:39:48 UTC 6,308 bytes, 209 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2
3export default class extends Controller {
4    static targets = [
5        'loader',
6        'spinner',
7        'panel',
8        'text',
9        'sources',
10        'sourcesTitle',
11        'sourcesList',
12        'feedback',
13    ];
14
15    static values = {
16        url: String,
17        sourcesTitle: String,
18        thinking: String,
19        showSources: { type: Boolean, default: true },
20    };
21
22    connect() {
23        this.typewriterTimer = null;
24        this.currentAnswerHtml = '';
25        this.boundBeforeCache = this.handleBeforeCache.bind(this);
26        document.addEventListener('turbo:before-cache', this.boundBeforeCache);
27
28        if (this.isAnswerAlreadyRendered()) {
29            this.currentAnswerHtml = this.textTarget.innerHTML;
30            this.markAnswerComplete();
31            return;
32        }
33
34        this.scheduleFetch();
35    }
36
37    disconnect() {
38        this.stopTypewriter();
39        document.removeEventListener('turbo:before-cache', this.boundBeforeCache);
40    }
41
42    handleBeforeCache() {
43        this.stopTypewriter();
44
45        if (!this.hasTextTarget) {
46            return;
47        }
48
49        const renderedText = this.textTarget.innerHTML.trim() || this.textTarget.textContent.trim();
50        if (renderedText === '') {
51            return;
52        }
53
54        if (this.textTarget.innerHTML.trim() === '' && this.currentAnswerHtml !== '') {
55            this.textTarget.innerHTML = this.currentAnswerHtml;
56        }
57
58        this.currentAnswerHtml = this.textTarget.innerHTML || this.currentAnswerHtml;
59        this.markAnswerComplete();
60    }
61
62    isAnswerAlreadyRendered() {
63        if (!this.hasPanelTarget || this.panelTarget.hidden || !this.hasTextTarget) {
64            return false;
65        }
66
67        return this.textTarget.innerHTML.trim() !== '' || this.textTarget.textContent.trim() !== '';
68    }
69
70    scheduleFetch() {
71        const run = () => {
72            this.fetchAnswer().catch(() => this.hideSection());
73        };
74
75        if (document.readyState === 'complete') {
76            window.requestAnimationFrame(run);
77            return;
78        }
79
80        window.addEventListener('load', () => window.requestAnimationFrame(run), { once: true });
81    }
82
83    async fetchAnswer() {
84        const response = await fetch(this.urlValue, {
85            method: 'GET',
86            headers: { Accept: 'application/json' },
87            credentials: 'same-origin',
88        });
89
90        if (!response.ok) {
91            this.hideSection();
92            return;
93        }
94
95        const data = await response.json();
96        if (!data || data.available !== true || typeof data.answerHtml !== 'string' || data.answerHtml.trim() === '') {
97            this.hideSection();
98            return;
99        }
100
101        this.renderAnswer(data);
102    }
103
104    renderAnswer(data) {
105        this.loaderTarget.hidden = true;
106
107        this.pendingSources = data;
108        this.currentAnswerHtml = data.answerHtml;
109        this.sourcesTarget.hidden = true;
110
111        this.panelTarget.hidden = false;
112        this.typewriter(data.answerHtml);
113    }
114
115    typewriter(html) {
116        const plain = this.stripHtml(html);
117        this.textTarget.textContent = '';
118
119        if (plain === '') {
120            this.textTarget.innerHTML = html;
121            this.finishAnswer();
122            return;
123        }
124
125        const speed = plain.length > 600 ? 4 : plain.length > 300 ? 6 : 9;
126        let index = 0;
127
128        this.stopTypewriter();
129
130        this.typewriterTimer = window.setInterval(() => {
131            index += 1;
132            this.textTarget.textContent = plain.slice(0, index);
133
134            if (index >= plain.length) {
135                this.stopTypewriter();
136                this.textTarget.innerHTML = html;
137                this.finishAnswer();
138            }
139        }, speed);
140    }
141
142    finishAnswer() {
143        const data = this.pendingSources;
144
145        try {
146            if (this.showSourcesValue && data && data.showSources === true && Array.isArray(data.sources) && data.sources.length > 0) {
147                this.sourcesTitleTarget.textContent = this.sourcesTitleValue;
148                this.sourcesListTarget.replaceChildren();
149
150                data.sources.forEach((source) => {
151                    if (!source || typeof source.url !== 'string' || source.url === '') {
152                        return;
153                    }
154
155                    const li = document.createElement('li');
156                    const link = document.createElement('a');
157                    link.href = source.url;
158                    link.textContent = typeof source.title === 'string' && source.title !== '' ? source.title : source.url;
159                    li.appendChild(link);
160                    this.sourcesListTarget.appendChild(li);
161                });
162
163                this.sourcesTarget.hidden = this.sourcesListTarget.children.length === 0;
164            }
165        } finally {
166            this.markAnswerComplete();
167        }
168    }
169
170    markAnswerComplete() {
171        this.element.classList.remove('search-ai-answer--loading');
172        this.element.setAttribute('aria-busy', 'false');
173
174        const panel = this.element.querySelector('.search-ai-answer__panel');
175        panel?.classList.add('search-ai-answer__panel--complete');
176
177        const feedbackEl = this.element.querySelector('.search-ai-answer__feedback');
178        if (feedbackEl) {
179            feedbackEl.hidden = false;
180            feedbackEl.removeAttribute('hidden');
181        }
182
183        if (this.currentAnswerHtml !== '') {
184            const aiAnswerInput = this.element.querySelector('[data-search-ai-answer-feedback-target="aiAnswerInput"]');
185            if (aiAnswerInput) {
186                aiAnswerInput.value = this.currentAnswerHtml;
187            }
188        }
189    }
190
191    stripHtml(html) {
192        const node = document.createElement('div');
193        node.innerHTML = html;
194        return (node.textContent || node.innerText || '').replace(/\s+/g, ' ').trim();
195    }
196
197    stopTypewriter() {
198        if (this.typewriterTimer !== null) {
199            window.clearInterval(this.typewriterTimer);
200            this.typewriterTimer = null;
201        }
202    }
203
204    hideSection() {
205        this.stopTypewriter();
206        this.element.hidden = true;
207        this.element.setAttribute('aria-busy', 'false');
208    }
209}

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.