PageSourceSearch

https://commono.co.jp/wp2025/wp-content/plugins/wp-chatbot-flow/public/chatbot.js?ver=1782399955

js commono.co.jp collected 2026-09-29 18:29:13 UTC 63,596 bytes, 1,912 lines download raw bytes

1/**
2 * Chatbot Flow Frontend Library
3 * A lightweight library to embed and run chatbot flows on any website
4 */
5
6(function(window) {
7  'use strict';
8
9  const ChatbotFlow = {
10    instances: new Map(),
11    flowCache: new Map(),
12
13    buildFlowCacheKey: function(apiUrl, flowId) {
14      return `${apiUrl}::${flowId}`;
15    },
16
17    cloneFlowData: function(flowData) {
18      return JSON.parse(JSON.stringify(flowData));
19    },
20
21    getCachedFlow: function(cacheKey) {
22      const entry = this.flowCache.get(cacheKey);
23      if (!entry) return null;
24
25      const now = Date.now();
26      if (entry.expiresAt > 0 && now > entry.expiresAt) {
27        this.flowCache.delete(cacheKey);
28        return null;
29      }
30
31      return this.cloneFlowData(entry.flowData);
32    },
33
34    setCachedFlow: function(cacheKey, flowData, ttlMs) {
35      const parsedTtl = Number(ttlMs);
36      const safeTtl = Number.isFinite(parsedTtl) ? Math.max(0, parsedTtl) : 300000;
37      const expiresAt = safeTtl === 0 ? 0 : Date.now() + safeTtl;
38      this.flowCache.set(cacheKey, {
39        flowData: this.cloneFlowData(flowData),
40        expiresAt,
41      });
42    },
43
44    clearFlowCache: function(options) {
45      const target = options || {};
46      const hasSpecificTarget = !!(target.cacheKey || (target.apiUrl && target.flowId));
47      if (!hasSpecificTarget) {
48        this.flowCache.clear();
49        return;
50      }
51
52      const cacheKey = target.cacheKey || this.buildFlowCacheKey(target.apiUrl, target.flowId);
53      this.flowCache.delete(cacheKey);
54    },
55
56    prefetchFlow: async function(options) {
57      const opts = options || {};
58      const apiUrl = opts.apiUrl || '/wp-json/chatbot-flow/v1/flows';
59      const flowId = opts.flowId;
60      if (!flowId) {
61        throw new Error('prefetchFlow requires flowId');
62      }
63
64      const cacheTtlMs = typeof opts.flowCacheTtlMs === 'number' ? opts.flowCacheTtlMs : 300000;
65      const cacheKey = this.buildFlowCacheKey(apiUrl, flowId);
66
67      if (opts.forceRefresh !== true) {
68        const cached = this.getCachedFlow(cacheKey);
69        if (cached) {
70          return cached;
71        }
72      }
73
74      const response = await fetch(`${apiUrl}/${flowId}`);
75      if (!response.ok) {
76        throw new Error('Failed to prefetch flow');
77      }
78
79      const flow = await response.json();
80      const parsedFlow = JSON.parse(flow.flow_data);
81      this.setCachedFlow(cacheKey, parsedFlow, cacheTtlMs);
82      return this.cloneFlowData(parsedFlow);
83    },
84    
85    /**
86     * Initialize a chatbot instance
87     */
88    init: function(options) {
89      return this.create(options);
90    },
91
92    /**
93     * Create a new chatbot instance
94     */
95    create: function(options) {
96      const opts = options || {};
97      const localizedFlowConfig = (typeof chatbotFlowData !== 'undefined') ? chatbotFlowData : {};
98      const config = {
99        containerId: opts.containerId || 'chatbot-container',
100        flowId: opts.flowId,
101        apiUrl: opts.apiUrl || '/wp-json/chatbot-flow/v1/flows',
102        theme: opts.theme || {},
103        renderSequentialDelay: typeof opts.renderSequentialDelay === 'number' ? opts.renderSequentialDelay : 600,
104        debugPerformance: opts.debugPerformance === true,
105        enableFlowCache: opts.enableFlowCache !== false,
106        flowCacheTtlMs: typeof opts.flowCacheTtlMs === 'number' ? Math.max(0, opts.flowCacheTtlMs) : 300000,
107        prefetchOnCreate: opts.prefetchOnCreate === true,
108        loadFlowOnInit: opts.loadFlowOnInit !== false,
109        autoStart: opts.autoStart !== false,
110        welcomeMessage: opts.welcomeMessage,
111        onReady: opts.onReady || function() {},
112        onMessage: opts.onMessage || function() {},
113        onNodeChange: opts.onNodeChange || function() {},
114        onComplete: opts.onComplete || function() {},
115        customActions: opts.customActions || {},
116        submitLocalStorageKeys: Array.isArray(opts.submitLocalStorageKeys)
117          ? opts.submitLocalStorageKeys
118          : (Array.isArray(localizedFlowConfig.submitLocalStorageKeys) ? localizedFlowConfig.submitLocalStorageKeys : []),
119        formDataInputName: opts.formDataInputName
120      };
121
122      const container = document.getElementById(config.containerId);
123      if (!container) {
124        console.error('Container not found:', config.containerId);
125        return null;
126      }
127
128      const instance = new ChatbotInstance(container, config);
129      this.instances.set(config.containerId, instance);
130
131      if (config.prefetchOnCreate && config.enableFlowCache && !config.loadFlowOnInit) {
132        this.prefetchFlow({
133          apiUrl: config.apiUrl,
134          flowId: config.flowId,
135          flowCacheTtlMs: config.flowCacheTtlMs,
136        }).catch(() => {
137          // Prefetch failures should not block regular chatbot startup.
138        });
139      }
140
141      if (config.autoStart) {
142        instance.start();
143      }
144
145      return instance;
146    },
147
148    /**
149     * Get existing instance
150     */
151    getInstance: function(containerId) {
152      return this.instances.get(containerId);
153    },
154
155    prefetch: function(options) {
156      return this.prefetchFlow(options);
157    }
158  };
159
160  /**
161   * Chatbot Instance Class
162   */
163  function ChatbotInstance(container, config) {
164    this.container = container;
165    this.config = config;
166    this.flowData = null;
167    this.currentNodeId = null;
168    this.conversationHistory = [];
169    this.contexts = new Set();
170    this.formData = {
171      flowId: config.flowId,
172      answers: [],
173      conversation: [],
174      localStorage: []
175    };
176    this.formDataInput = null;
177    this._inputAreaAnimationFrame = null;
178    this.answerStartNodeId = null;
179    this.answerStartReached = false;
180    this.currentConfirmNodeId = null;
181    this.reEditTargetNodeId = null;
182    this.isReEditingFromConfirm = false;
183    this.currentFreeInputValidation = 'none';
184    this.currentFreeInputPlaceholder = 'メッセージを入力...';
185    this.currentInputAreaState = 'hidden';
186    this._inputInnerTransitionTimer = null;
187    this._isFlowLoading = false;
188    this._isFlowLoaded = false;
189    this._pendingStart = false;
190    this._flowCompleted = false;
191    this._submitCompleted = false;
192    this._cacheKey = ChatbotFlow.buildFlowCacheKey(this.config.apiUrl, this.config.flowId);
193    this._perf = {
194      initAt: 0,
195      loadFlowStartAt: 0,
196      loadFlowFetchedAt: 0,
197      startRequestedAt: 0,
198      firstBotRenderedAt: 0,
199      firstBotRenderedForCurrentStart: false,
200    };
201    
202    this.init();
203  }
204
205  ChatbotInstance.prototype = {
206    _now: function() {
207      return (window.performance && typeof window.performance.now === 'function')
208        ? window.performance.now()
209        : Date.now();
210    },
211
212    _logPerf: function(eventName, payload) {
213      if (!this.config.debugPerformance) return;
214      const elapsed = this._perf.initAt ? Math.round(this._now() - this._perf.initAt) : 0;
215      console.info('[ChatbotFlow][Perf]', eventName, {
216        elapsedFromInitMs: elapsed,
217        ...(payload || {}),
218      });
219    },
220
221    // Public runtime control for sequential reveal interval.
222    setRenderSequentialDelay: function(delayMs) {
223      const parsed = Number(delayMs);
224      if (!Number.isFinite(parsed)) return;
225      this.config.renderSequentialDelay = Math.max(0, parsed);
226    },
227
228    // Public helper to toggle performance diagnostics at runtime.
229    setPerformanceDebug: function(enabled) {
230      this.config.debugPerformance = enabled === true;
231    },
232
233    init: function() {
234      this._perf.initAt = this._now();
235      this._logPerf('init');
236      this.createUI();
237      if (this.config.loadFlowOnInit) {
238        this.loadFlow();
239      }
240    },
241
242    prefetchFlow: function(forceRefresh) {
243      return this.loadFlow({
244        prefetchOnly: true,
245        forceRefresh: forceRefresh === true,
246      });
247    },
248
249    clearFlowCache: function() {
250      ChatbotFlow.clearFlowCache({ cacheKey: this._cacheKey });
251    },
252
253    createUI: function() {
254      const theme = this.config.theme && typeof this.config.theme === 'object' ? this.config.theme : {};
255
256      this.container.innerHTML = `
257        <div class="chatbot-container">
258          <div class="chatbot-messages" id="${this.config.containerId}-messages"></div>
259          <div class="chatbot-input-area" id="${this.config.containerId}-input">
260            <div class="chatbot-input-area-inner">
261              <div class="chatbot-input-hint" aria-live="polite">メッセージ表示エリアで回答を選択してください</div>
262              <div class="chatbot-input-row">
263                <textarea class="chatbot-input" placeholder="メッセージを入力..." rows="2"></textarea>
264                <div class="chatbot-input-loading" aria-hidden="true">
265                  <span class="chatbot-input-loading-spinner"></span>
266                </div>
267                <button type="button" class="chatbot-send">送信</button>
268              </div>
269            </div>
270          </div>
271        </div>
272      `;
273
274      const chatbotRoot = this.container.querySelector('.chatbot-container');
275      if (chatbotRoot) {
276        const setThemeVarIfDefined = (varName, value) => {
277          if (value !== undefined && value !== null && value !== '') {
278            chatbotRoot.style.setProperty(varName, String(value));
279          }
280        };
281
282        // Only apply inline CSS variables when theme options are explicitly provided.
283        setThemeVarIfDefined('--chatbot-background-color', theme.backgroundColor);
284        setThemeVarIfDefined('--chatbot-text-color', theme.textColor);
285        setThemeVarIfDefined('--chatbot-primary-color', theme.primaryColor);
286        setThemeVarIfDefined('--chatbot-user-bubble-color', theme.userBubbleColor);
287        setThemeVarIfDefined('--chatbot-bot-bubble-color', theme.botBubbleColor);
288        setThemeVarIfDefined('--chatbot-container-radius', theme.borderRadius);
289        setThemeVarIfDefined('--chatbot-bubble-radius', theme.borderRadius);
290        setThemeVarIfDefined('--chatbot-font-size', theme.fontSize);
291      }
292
293      this.messagesContainer = document.getElementById(`${this.config.containerId}-messages`);
294      this.inputArea = document.getElementById(`${this.config.containerId}-input`);
295      this.initFormDataStorage();
296    },
297
298    loadFlow: async function(loadOptions) {
299      const options = loadOptions || {};
300      const forceRefresh = options.forceRefresh === true;
301      const prefetchOnly = options.prefetchOnly === true;
302
303      if (this.config.enableFlowCache && !forceRefresh) {
304        const cachedFlow = ChatbotFlow.getCachedFlow(this._cacheKey);
305        if (cachedFlow) {
306          this.flowData = cachedFlow;
307          this._isFlowLoaded = true;
308          this._isFlowLoading = false;
309          this._logPerf('loadFlow:cacheHit', {
310            cacheKey: this._cacheKey,
311            prefetchOnly,
312          });
313          if (!prefetchOnly) {
314            if (this._pendingStart) {
315              this._pendingStart = false;
316              this.start();
317            }
318            this.config.onReady();
319          }
320          return this.flowData;
321        }
322      }
323
324      try {
325        this._isFlowLoading = true;
326        this._perf.loadFlowStartAt = this._now();
327        this._logPerf('loadFlow:start', {
328          flowId: this.config.flowId,
329          apiUrl: this.config.apiUrl,
330        });
331        const url = `${this.config.apiUrl}/${this.config.flowId}`;
332        const response = await fetch(url);
333        this._perf.loadFlowFetchedAt = this._now();
334        this._logPerf('loadFlow:fetched', {
335          status: response.status,
336          fetchDurationMs: Math.round(this._perf.loadFlowFetchedAt - this._perf.loadFlowStartAt),
337        });
338        
339        if (!response.ok) {
340          throw new Error('Failed to load flow');
341        }
342
343        const flow = await response.json();
344        this.flowData = JSON.parse(flow.flow_data);
345        this._isFlowLoaded = true;
346        this._isFlowLoading = false;
347
348        if (this.config.enableFlowCache) {
349          ChatbotFlow.setCachedFlow(this._cacheKey, this.flowData, this.config.flowCacheTtlMs);
350        }
351
352        this._logPerf('loadFlow:parsed', {
353          parseDurationMs: Math.round(this._now() - this._perf.loadFlowFetchedAt),
354          nodeCount: Array.isArray(this.flowData.nodes) ? this.flowData.nodes.length : 0,
355          edgeCount: Array.isArray(this.flowData.edges) ? this.flowData.edges.length : 0,
356        });
357
358        if (!prefetchOnly) {
359          if (this._pendingStart) {
360            this._pendingStart = false;
361            this.start();
362          }
363          this.config.onReady();
364        }
365        return this.flowData;
366      } catch (error) {
367        this._isFlowLoading = false;
368        this._isFlowLoaded = false;
369        console.error('Error loading flow:', error);
370        if (!prefetchOnly) {
371          this.showError('チャットボットの読み込みに失敗しました');
372        }
373        throw error;
374      }
375    },
376
377    start: function() {
378      this._perf.startRequestedAt = this._now();
379      this._perf.firstBotRenderedForCurrentStart = false;
380      this._perf.firstBotRenderedAt = 0;
381      this._logPerf('start:requested', {
382        isFlowLoaded: this._isFlowLoaded,
383        isFlowLoading: this._isFlowLoading,
384      });
385
386      if (!this.flowData) {
387        // Flow is still loading; queue start and continue after loadFlow completes.
388        if (this._isFlowLoading) {
389          this._pendingStart = true;
390          return;
391        }
392
393        // If loading hasn't started for any reason, trigger it and queue start.
394        if (!this._isFlowLoaded) {
395          this._pendingStart = true;
396          this.loadFlow();
397          return;
398        }
399
400        console.error('Flow data not loaded');
401        return;
402      }
403
404      // Prefer node with training phrase containing HELLO
405      const helloNode = this.flowData.nodes.find(n => {
406        const phrases = (n.data && n.data.trainingPhrases) || [];
407        return phrases.some(p => String(p).toUpperCase().includes('HELLO'));
408      });
409
410      // Prefer contextless node as start (same behavior as preview)
411      const contextlessNode = this.flowData.nodes.find(n => {
412        const contexts = (n.data && n.data.contexts) || [];
413        return contexts.length === 0;
414      });
415
416      // Fallback: node with no incoming edges
417      const targetIds = new Set(this.flowData.edges.map(e => e.target));
418      const startNode = this.flowData.nodes.find(n => !targetIds.has(n.id));
419
420      const initialNode = helloNode || contextlessNode || startNode;
421
422      if (initialNode) {
423        this.showNode(initialNode.id);
424      } else {
425        this.showError('開始ノードが見つかりません');
426      }
427    },
428
429    showNode: function(nodeId) {
430      const node = this.flowData.nodes.find(n => n.id === nodeId);
431      if (!node) {
432        console.error('Node not found:', nodeId);
433        return;
434      }
435
436      const data = node.data || {};
437
438      this.currentNodeId = nodeId;
439      this.config.onNodeChange(nodeId, data.intentName);
440
441      if (!data.freeInputEnabled) {
442        this.showSelectionGuide();
443      }
444
445      if (data.nodeRole === 'answer_start') {
446        this.answerStartNodeId = nodeId;
447        this.answerStartReached = true;
448      }
449
450      if (data.nodeRole === 'confirm_submit') {
451        this.currentConfirmNodeId = nodeId;
452      }
453      const isConfirmSubmitNode = data.nodeRole === 'confirm_submit';
454
455      const renderTasks = [];
456
457      // Queue responses first.
458      if (!isConfirmSubmitNode && data.responses && data.responses.length > 0) {
459        data.responses.forEach((response) => {
460          renderTasks.push(() => {
461            this.addBotMessage(response);
462          });
463        });
464      }
465
466      // Queue images after responses.
467      if (data.images && data.images.length > 0) {
468        data.images.forEach((img) => {
469          renderTasks.push(() => {
470            this.addBotImage(img.url || img.rawUrl, img.alt || img.accessibilityText);
471          });
472        });
473      }
474
475      // Normalize node buttons once and reuse.
476      const nodeButtons = this.getNodeButtons(data);
477
478      // Update contexts and custom actions without waiting for visual sequence.
479      if (data.customPayload) {
480        try {
481          const payload = JSON.parse(data.customPayload);
482          if (payload.action && this.config.customActions[payload.action]) {
483            this.config.customActions[payload.action](payload);
484          }
485        } catch (e) {
486          console.error('Invalid custom payload:', e);
487        }
488      }
489
490      if (data.outputContexts) {
491        data.outputContexts.forEach(ctx => this.contexts.add(ctx));
492      }
493
494      // Confirm node should appear after queued messages/images.
495      if (data.nodeRole === 'confirm_submit') {
496        renderTasks.push(() => {
497          this.showConfirmSummary(nodeId, data);
498        });
499        this.runSequentialRenderQueue(renderTasks);
500        return;
501      }
502
503      // Auto transition waits for current render sequence to finish.
504      if (data.autoTransition) {
505        const delay = data.autoTransitionDelay || 1000;
506        const nextEdge = this.findNextDefaultEdge(nodeId);
507        this.runSequentialRenderQueue(renderTasks, () => {
508          if (nextEdge) {
509            setTimeout(() => {
510              this.showNode(nextEdge.target);
511            }, delay);
512          }
513        });
514        return;
515      }
516
517      // Normal ordering: responses -> buttons -> chips/freeInput.
518      if (nodeButtons.length > 0) {
519        renderTasks.push(() => {
520          this.showButtons(nodeButtons);
521        });
522      }
523
524      if (data.chips && data.chips.length > 0) {
525        renderTasks.push(() => {
526          this.showChips(data.chips, nodeId);
527        });
528        this.runSequentialRenderQueue(renderTasks);
529        return;
530      }
531
532      if (data.freeInputEnabled) {
533        renderTasks.push(() => {
534          this.showFreeInput(data.freeInputPlaceholder, data.freeInputValidation);
535        });
536        this.runSequentialRenderQueue(renderTasks);
537        return;
538      }
539
540      // No chips/freeInput: complete after rendering finishes if there is no next path.
541      const nextEdge = this.findNextDefaultEdge(nodeId);
542      this.runSequentialRenderQueue(renderTasks, () => {
543        if (!nextEdge) {
544          this.completeFlow(nodeId);
545        }
546      });
547    },
548
549    buildCompleteDetail: function(nodeId) {
550      return {
551        containerId: this.config.containerId,
552        flowId: this.config.flowId,
553        nodeId: nodeId || this.currentNodeId,
554        answerStartNodeId: this.answerStartNodeId,
555        answers: this.getEditableAnswers(),
556        allAnswers: this.getAllAnswers(),
557        conversation: this.formData.conversation,
558        formData: this.getFormData(),
559        timestamp: new Date().toISOString()
560      };
561    },
562
563    completeFlow: function(nodeId) {
564      if (this._flowCompleted) {
565        return;
566      }
567
568      this._flowCompleted = true;
569      const detail = this.buildCompleteDetail(nodeId);
570
571      this.config.onComplete(detail);
572      this.container.dispatchEvent(new CustomEvent('chatbot:complete', {
573        detail,
574        bubbles: true
575      }));
576    },
577
578    runSequentialRenderQueue: function(tasks, onComplete) {
579      const queue = Array.isArray(tasks) ? tasks : [];
580      const stepDelay = typeof this.config.renderSequentialDelay === 'number'
581        ? Math.max(0, this.config.renderSequentialDelay)
582        : 200;
583
584      this._logPerf('renderQueue:start', {
585        queueLength: queue.length,
586        stepDelayMs: stepDelay,
587      });
588
589      if (queue.length === 0) {
590        if (onComplete) onComplete();
591        return;
592      }
593
594      queue.forEach((task, index) => {
595        setTimeout(() => {
596          task();
597        }, index * stepDelay);
598      });
599
600      const completeDelay = queue.length * stepDelay;
601      setTimeout(() => {
602        this._logPerf('renderQueue:complete', {
603          queueLength: queue.length,
604          totalDelayMs: completeDelay,
605        });
606        if (onComplete) onComplete();
607      }, completeDelay);
608    },
609
610    formatTimestamp: function() {
611      return new Date().toLocaleTimeString('ja-JP', { hour: 'numeric', minute: '2-digit', hour12: true });
612    },
613
614    addBotMessage: function(text, options) {
615      const opts = options || {};
616      const messageDiv = document.createElement('div');
617      messageDiv.className = `chatbot-message bot${opts.isError ? ' error' : ''}`;
618      messageDiv.innerHTML = `<div class="chatbot-bubble bot"><span class="chatbot-bubble-text">${this.escapeHtml(text)}</span></div><span class="chatbot-timestamp">${this.escapeHtml(this.formatTimestamp())}</span>`;
619      const animateByDelta = opts.animateByDelta !== false;
620      this.appendMessageElement(messageDiv, {
621        animateByDelta,
622        durationMs: typeof opts.durationMs === 'number' ? opts.durationMs : 220,
623        skipAutoScroll: opts.skipAutoScroll === true,
624      });
625
626      if (!this._perf.firstBotRenderedForCurrentStart) {
627        this._perf.firstBotRenderedForCurrentStart = true;
628        this._perf.firstBotRenderedAt = this._now();
629        this._logPerf('firstBotMessage:rendered', {
630          latencyFromStartMs: Math.round(this._perf.firstBotRenderedAt - this._perf.startRequestedAt),
631          textPreview: String(text || '').slice(0, 40),
632        });
633      }
634
635      this.recordConversation('bot', text, this.currentNodeId);
636      this.config.onMessage(text, 'bot');
637    },
638
639    addUserMessage: function(text, options) {
640      const opts = options || {};
641      const messageDiv = document.createElement('div');
642      messageDiv.className = 'chatbot-message user';
643      messageDiv.innerHTML = `<div class="chatbot-bubble user"><span class="chatbot-bubble-text">${this.escapeHtml(text)}</span></div><span class="chatbot-timestamp">${this.escapeHtml(this.formatTimestamp())}</span>`;
644      this.appendMessageElement(messageDiv, {
645        animateByDelta: opts.animateByDelta === true,
646        durationMs: typeof opts.durationMs === 'number' ? opts.durationMs : 220,
647        skipAutoScroll: opts.skipAutoScroll === true,
648      });
649      this.recordConversation('user', text, this.currentNodeId);
650      this.config.onMessage(text, 'user');
651    },
652
653    prepareMessageReveal: function(element) {
654      if (!element || !element.classList) return;
655      element.classList.remove('is-visible');
656    },
657
658    revealMessage: function(element) {
659      if (!element || !element.classList) return;
660      void element.offsetWidth;
661      element.classList.add('is-visible');
662    },
663
664    appendMessageElement: function(element, options) {
665      const opts = options || {};
666      const container = this.messagesContainer;
667
668      if (!container) return Promise.resolve();
669
670      const shouldAnimateByDelta = opts.animateByDelta === true;
671      const skipAutoScroll = opts.skipAutoScroll === true;
672      const durationMs = typeof opts.durationMs === 'number' ? opts.durationMs : 220;
673
674      this.prepareMessageReveal(element);
675
676      container.appendChild(element);
677
678      const targetTop = Math.max(0, container.scrollHeight - container.clientHeight);
679      const needsScroll = Math.abs(targetTop - container.scrollTop) > 1;
680
681      if (skipAutoScroll || !needsScroll) {
682        this.revealMessage(element);
683        return Promise.resolve();
684      }
685
686      if (!shouldAnimateByDelta) {
687        container.scrollTop = targetTop;
688        this.revealMessage(element);
689        return Promise.resolve();
690      }
691
692      return this.animateScrollToBottom(durationMs).then(() => {
693        this.revealMessage(element);
694      });
695    },
696
697    animateScrollToBottom: function(durationMs) {
698      const container = this.messagesContainer;
699      const startTop = container.scrollTop;
700      const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
701      const targetTop = maxTop;
702      const duration = Math.max(0, Number(durationMs) || 0);
703
704      if (targetTop <= startTop || duration === 0) {
705        container.scrollTop = targetTop;
706        return Promise.resolve();
707      }
708
709      return new Promise((resolve) => {
710        const startAt = this._now();
711        const delta = targetTop - startTop;
712
713        const tick = () => {
714          const elapsed = this._now() - startAt;
715          const progress = Math.min(1, elapsed / duration);
716          container.scrollTop = startTop + (delta * progress);
717          if (progress < 1) {
718            window.requestAnimationFrame(tick);
719          } else {
720            this.easeFinalScrollAdjustment(this.getFinalScrollAdjustmentDuration()).then(resolve);
721          }
722        };
723
724        window.requestAnimationFrame(tick);
725      });
726    },
727
728    getFinalScrollAdjustmentDuration: function() {
729      const container = this.messagesContainer;
730      if (!container) return 0;
731
732      const remaining = Math.abs(Math.max(0, container.scrollHeight - container.clientHeight) - container.scrollTop);
733      if (remaining < 1) return 0;
734      if (remaining < 8) return 40;
735      if (remaining < 24) return 60;
736      if (remaining < 60) return 80;
737      return 100;
738    },
739
740    easeFinalScrollAdjustment: function(durationMs) {
741      const container = this.messagesContainer;
742      if (!container) return Promise.resolve();
743
744      const startTop = container.scrollTop;
745      const targetTop = Math.max(0, container.scrollHeight - container.clientHeight);
746      const duration = Math.max(0, Number(durationMs) || 0);
747
748      if (duration === 0 || Math.abs(targetTop - startTop) < 1) {
749        container.scrollTop = targetTop;
750        return Promise.resolve();
751      }
752
753      return new Promise((resolve) => {
754        const startAt = this._now();
755
756        const tick = () => {
757          const elapsed = this._now() - startAt;
758          const progress = Math.min(1, elapsed / duration);
759          const eased = 1 - Math.pow(1 - progress, 3);
760          container.scrollTop = startTop + ((targetTop - startTop) * eased);
761
762          if (progress < 1) {
763            window.requestAnimationFrame(tick);
764            return;
765          }
766
767          container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
768          resolve();
769        };
770
771        window.requestAnimationFrame(tick);
772      });
773    },
774
775    animateScrollByDelta: function(deltaY, durationMs) {
776      const container = this.messagesContainer;
777      const distance = Number(deltaY) || 0;
778      const duration = Math.max(0, Number(durationMs) || 0);
779
780      if (distance <= 0 || duration === 0) {
781        return this.easeFinalScrollAdjustment(this.getFinalScrollAdjustmentDuration());
782      }
783
784      return new Promise((resolve) => {
785        const startTop = container.scrollTop;
786        const targetTop = startTop + distance;
787        const startAt = this._now();
788
789        const tick = () => {
790          const elapsed = this._now() - startAt;
791          const progress = Math.min(1, elapsed / duration);
792          container.scrollTop = startTop + ((targetTop - startTop) * progress);
793
794          if (progress < 1) {
795            window.requestAnimationFrame(tick);
796          } else {
797            this.easeFinalScrollAdjustment(this.getFinalScrollAdjustmentDuration()).then(resolve);
798          }
799        };
800
801        window.requestAnimationFrame(tick);
802      });
803    },
804
805    stopInputAreaAnimation: function() {
806      if (this._inputAreaAnimationFrame) {
807        window.cancelAnimationFrame(this._inputAreaAnimationFrame);
808        this._inputAreaAnimationFrame = null;
809      }
810    },
811
812    hideInputArea: function(durationMs) {
813      const area = this.inputArea;
814      const container = this.messagesContainer;
815      if (!area) return Promise.resolve();
816
817      const defaultHideDuration = 130;
818      const parsedDuration = Number(durationMs);
819      const duration = durationMs === undefined
820        ? defaultHideDuration
821        : (Number.isFinite(parsedDuration) ? Math.max(0, parsedDuration) : defaultHideDuration);
822      const isVisible = area.style.display !== 'none' && area.offsetHeight > 0;
823
824      this.stopInputAreaAnimation();
825
826      if (!isVisible) {
827        this.currentInputAreaState = 'hidden';
828        area.style.display = 'none';
829        area.style.height = '';
830        area.style.overflow = '';
831        area.style.pointerEvents = '';
832        return Promise.resolve();
833      }
834
835      if (!container || duration === 0) {
836        this.currentInputAreaState = 'hidden';
837        area.style.display = 'none';
838        area.style.height = '';
839        area.style.overflow = '';
840        area.style.pointerEvents = '';
841        return Promise.resolve();
842      }
843
844      const startHeight = area.offsetHeight;
845      const startTop = container.scrollTop;
846
847      area.style.display = 'block';
848      area.style.height = `${startHeight}px`;
849      area.style.overflow = 'hidden';
850      area.style.pointerEvents = 'none';
851
852      return new Promise((resolve) => {
853        const startAt = this._now();
854
855        const tick = () => {
856          const elapsed = this._now() - startAt;
857          const progress = Math.min(1, elapsed / duration);
858          const currentHeight = startHeight * (1 - progress);
859          const collapsedHeight = startHeight - currentHeight;
860
861          area.style.height = `${currentHeight}px`;
862          container.scrollTop = Math.max(0, startTop - collapsedHeight);
863
864          if (progress < 1) {
865            this._inputAreaAnimationFrame = window.requestAnimationFrame(tick);
866            return;
867          }
868
869          this._inputAreaAnimationFrame = null;
870          this.currentInputAreaState = 'hidden';
871          area.style.display = 'none';
872          area.style.height = '';
873          area.style.overflow = '';
874          area.style.pointerEvents = '';
875          resolve();
876        };
877
878        this._inputAreaAnimationFrame = window.requestAnimationFrame(tick);
879      });
880    },
881
882    showInputArea: function(durationMs) {
883      const area = this.inputArea;
884      const container = this.messagesContainer;
885      if (!area) return Promise.resolve();
886
887      const duration = Math.max(0, Number(durationMs) || 0);
888      const inner = area.querySelector('.chatbot-input-area-inner');
889      const wasVisible = area.style.display !== 'none' && area.offsetHeight > 0;
890
891      this.stopInputAreaAnimation();
892
893      if (wasVisible) {
894        area.style.height = '';
895        area.style.overflow = '';
896        area.style.pointerEvents = '';
897        return Promise.resolve();
898      }
899
900      area.style.display = 'block';
901
902      const targetHeight = inner ? inner.offsetHeight : area.scrollHeight;
903      if (targetHeight <= 0 || duration === 0) {
904        area.style.height = '';
905        area.style.overflow = '';
906        area.style.pointerEvents = '';
907        if (container) {
908          return this.easeFinalScrollAdjustment(this.getFinalScrollAdjustmentDuration());
909        }
910        return Promise.resolve();
911      }
912
913      area.style.height = '0px';
914      area.style.overflow = 'hidden';
915      area.style.pointerEvents = 'none';
916
917      const startTop = container ? container.scrollTop : 0;
918
919      return new Promise((resolve) => {
920        const startAt = this._now();
921
922        const tick = () => {
923          const elapsed = this._now() - startAt;
924          const progress = Math.min(1, elapsed / duration);
925          const currentHeight = targetHeight * progress;
926
927          area.style.height = `${currentHeight}px`;
928          if (container) {
929            container.scrollTop = startTop + currentHeight;
930          }
931
932          if (progress < 1) {
933            this._inputAreaAnimationFrame = window.requestAnimationFrame(tick);
934            return;
935          }
936
937          this._inputAreaAnimationFrame = null;
938          area.style.height = '';
939          area.style.overflow = '';
940          area.style.pointerEvents = '';
941          if (container) {
942            this.easeFinalScrollAdjustment(this.getFinalScrollAdjustmentDuration()).then(resolve);
943            return;
944          }
945          resolve();
946        };
947
948        this._inputAreaAnimationFrame = window.requestAnimationFrame(tick);
949      });
950    },
951
952    showChipSelectionWithPreScroll: async function(chipText, sourceNodeId, chipIndex) {
953      await this.addUserMessage(chipText, {
954        animateByDelta: true,
955        durationMs: 220,
956      });
957      this.recordAnswer(chipText, sourceNodeId, 'chip');
958
959      // Keep the existing delay before moving to the next node.
960      setTimeout(() => {
961        this.handleChipSelection(chipText, sourceNodeId, chipIndex);
962      }, 500);
963    },
964
965    addBotImage: function(url, alt) {
966      if (!url) return;
967      const messageDiv = document.createElement('div');
968      messageDiv.className = 'chatbot-message bot';
969      messageDiv.innerHTML = `
970        <div class="chatbot-bubble bot">
971          <img src="${this.escapeHtml(url)}" alt="${this.escapeHtml(alt || '')}" class="chatbot-image" />
972        </div>
973      `;
974      this.appendMessageElement(messageDiv, {
975        animateByDelta: true,
976        durationMs: 220,
977      });
978    },
979
980    getNodeButtons: function(data) {
981      const normalized = [];
982      const seen = new Set();
983
984      const toArray = (value) => {
985        if (!value) return [];
986        if (Array.isArray(value)) return value;
987        if (typeof value === 'object') return Object.values(value);
988        return [];
989      };
990
991      const pushButton = (button) => {
992        if (!button) return;
993        const text = button.text || button.title || button.label;
994        if (!text) return;
995        const entry = {
996          text: String(text),
997          link: (button.link || button.url || button.href) ? String(button.link || button.url || button.href) : undefined,
998          target: button.target || '_self'
999        };
1000        const key = `${entry.text}__${entry.link || ''}__${entry.target}`;
1001        if (!seen.has(key)) {
1002          seen.add(key);
1003          normalized.push(entry);
1004        }
1005      };
1006
1007      const fromDataButtons = typeof data.buttons === 'string' ? (() => {
1008            try {
1009              const parsed = JSON.parse(data.buttons);
1010              return toArray(parsed);
1011            } catch (_e) {
1012              return [];
1013            }
1014          })() : toArray(data.buttons);
1015
1016      fromDataButtons.forEach(pushButton);
1017
1018      if (data.customPayload) {
1019        try {
1020          const payload = typeof data.customPayload === 'string'
1021            ? JSON.parse(data.customPayload)
1022            : data.customPayload;
1023          const richContent = payload && Array.isArray(payload.richContent) ? payload.richContent : [];
1024          richContent.forEach((row) => {
1025            if (!Array.isArray(row)) return;
1026            row.forEach((item) => {
1027              if (item && item.type === 'button') {
1028                pushButton(item);
1029              }
1030            });
1031          });
1032        } catch (_e) {
1033          // Ignore malformed custom payload.
1034        }
1035      }
1036
1037      return normalized;
1038    },
1039
1040    showButtons: function(buttons) {
1041      if (!buttons || buttons.length === 0) return;
1042
1043      const messageDiv = document.createElement('div');
1044      messageDiv.className = 'chatbot-message bot';
1045
1046      const bubbleDiv = document.createElement('div');
1047      bubbleDiv.className = 'chatbot-bubble bot';
1048
1049      const buttonsContainer = document.createElement('div');
1050      buttonsContainer.className = 'chatbot-buttons';
1051
1052      buttons.forEach((button) => {
1053        if (!button || !button.text) return;
1054
1055        if (button.link) {
1056          const link = document.createElement('a');
1057          link.className = 'chatbot-button';
1058          link.textContent = button.text;
1059          link.href = button.link;
1060          link.target = button.target || '_self';
1061          if ((button.target || '_self') === '_blank') {
1062            link.rel = 'noopener noreferrer';
1063          }
1064          buttonsContainer.appendChild(link);
1065          return;
1066        }
1067
1068        const buttonEl = document.createElement('button');
1069        buttonEl.type = 'button';
1070        buttonEl.className = 'chatbot-button';
1071        buttonEl.textContent = button.text;
1072        buttonEl.disabled = true;
1073        buttonsContainer.appendChild(buttonEl);
1074      });
1075
1076      if (!buttonsContainer.childNodes.length) {
1077        return;
1078      }
1079
1080      bubbleDiv.appendChild(buttonsContainer);
1081      messageDiv.appendChild(bubbleDiv);
1082      this.appendMessageElement(messageDiv, {
1083        animateByDelta: true,
1084        durationMs: 220,
1085      });
1086    },
1087
1088    showConfirmSummary: function(confirmNodeId, confirmNodeData) {
1089      const summaryDiv = document.createElement('div');
1090      summaryDiv.className = 'chatbot-message bot';
1091
1092      const bubbleDiv = document.createElement('div');
1093      bubbleDiv.className = 'chatbot-bubble bot confirm';
1094
1095      const title = document.createElement('div');
1096      title.textContent = '送信内容はこちらでよろしいですか?';
1097      bubbleDiv.appendChild(title);
1098
1099      const answers = this.getEditableAnswers();
1100      const list = document.createElement('div');
1101      list.className = 'chatbot-summary-list';
1102
1103      if (answers.length === 0) {
1104        const empty = document.createElement('div');
1105        empty.className = 'chatbot-summary-empty';
1106        empty.textContent = '確認できる回答がまだありません。';
1107        bubbleDiv.appendChild(empty);
1108      } else {
1109        answers.forEach((answer, index) => {
1110          const item = document.createElement('button');
1111          item.type = 'button';
1112          item.className = 'chatbot-summary-item';
1113          if (this._submitCompleted) {
1114            item.disabled = true;
1115          }
1116          const label = answer.label || answer.intentName || `質問${index + 1}`;
1117          const labelSpan = document.createElement('span');
1118          labelSpan.className = 'chatbot-summary-item-label';
1119          labelSpan.textContent = `${label}: `;
1120          const valueSpan = document.createElement('span');
1121          valueSpan.className = 'chatbot-summary-item-value';
1122          valueSpan.textContent = answer.value;
1123          item.appendChild(labelSpan);
1124          item.appendChild(valueSpan);
1125          item.onclick = () => {
1126            this.recordConversation('user', `修正: ${label}`, confirmNodeId);
1127            this.reEditTargetNodeId = answer.nodeId;
1128            this.isReEditingFromConfirm = true;
1129            this.showNode(answer.nodeId);
1130          };
1131          list.appendChild(item);
1132        });
1133        bubbleDiv.appendChild(list);
1134      }
1135
1136      const privacyPolicyUrl = (confirmNodeData && typeof confirmNodeData.privacyPolicyUrl === 'string')
1137        ? confirmNodeData.privacyPolicyUrl.trim()
1138        : '';
1139      const hasPrivacyPolicy = privacyPolicyUrl.length > 0;
1140      const isSafePrivacyUrl = hasPrivacyPolicy && /^(https?:\/\/|\/)/i.test(privacyPolicyUrl);
1141
1142      const submitButton = document.createElement('button');
1143      submitButton.type = 'button';
1144      submitButton.className = 'chatbot-submit-button';
1145      submitButton.innerHTML = '<span class="chatbot-submit-button-label">送信する</span><span class="chatbot-submit-button-spinner" aria-hidden="true"></span>';
1146      if (this._submitCompleted) {
1147        submitButton.disabled = true;
1148        submitButton.classList.add('is-submitted');
1149      } else if (hasPrivacyPolicy) {
1150        submitButton.disabled = true;
1151      }
1152
1153      if (hasPrivacyPolicy) {
1154        const consentWrapper = document.createElement('label');
1155        consentWrapper.className = 'chatbot-consent';
1156
1157        const consentCheckbox = document.createElement('input');
1158        consentCheckbox.type = 'checkbox';
1159        consentCheckbox.className = 'chatbot-consent-checkbox';
1160
1161        const consentText = document.createElement('span');
1162        consentText.className = 'chatbot-consent-text';
1163        if (isSafePrivacyUrl) {
1164          const policyLink = document.createElement('a');
1165          policyLink.className = 'chatbot-consent-link';
1166          policyLink.href = privacyPolicyUrl;
1167          policyLink.target = '_blank';
1168          policyLink.rel = 'noopener noreferrer';
1169          policyLink.textContent = 'プライバシーポリシー';
1170          consentText.appendChild(policyLink);
1171          consentText.appendChild(document.createTextNode('に同意する'));
1172        } else {
1173          consentText.textContent = 'プライバシーポリシーに同意する';
1174        }
1175
1176        consentCheckbox.onchange = () => {
1177          if (this._submitCompleted) {
1178            consentCheckbox.checked = true;
1179            return;
1180          }
1181          submitButton.disabled = !consentCheckbox.checked;
1182        };
1183
1184        consentWrapper.appendChild(consentCheckbox);
1185        consentWrapper.appendChild(consentText);
1186        bubbleDiv.appendChild(consentWrapper);
1187      }
1188
1189      submitButton.onclick = () => {
1190        this.handleSubmit(confirmNodeId, confirmNodeData, { submitButton: submitButton });
1191      };
1192      bubbleDiv.appendChild(submitButton);
1193
1194      summaryDiv.appendChild(bubbleDiv);
1195      this.appendMessageElement(summaryDiv, {
1196        animateByDelta: true,
1197        durationMs: 220,
1198      });
1199      this.recordConversation('bot', '送信内容はこちらでよろしいですか?', confirmNodeId);
1200    },
1201
1202    setConfirmSummaryItemsDisabled: function(disabled) {
1203      if (!this.messagesContainer) return;
1204      const items = this.messagesContainer.querySelectorAll('.chatbot-bubble.confirm .chatbot-summary-item');
1205      items.forEach((item) => {
1206        item.disabled = !!disabled;
1207      });
1208    },
1209
1210    showChips: function(chips, sourceNodeId) {
1211      const chipsContainer = document.createElement('div');
1212      chipsContainer.className = 'chatbot-chips';
1213      const chipButtons = [];
1214
1215      chips.forEach((chip, chipIndex) => {
1216        const chipText = typeof chip === 'string' ? chip : chip.text;
1217        if (!chipText) return;
1218
1219        const button = document.createElement('button');
1220        button.className = 'chatbot-chip';
1221        button.textContent = chipText;
1222        button.onclick = () => {
1223          // すべてのチップをdisabledにする
1224          chipButtons.forEach(btn => btn.disabled = true);
1225
1226          this.showChipSelectionWithPreScroll(chipText, sourceNodeId, chipIndex).catch(() => {
1227            this.addUserMessage(chipText, {
1228              animateByDelta: true,
1229              durationMs: 220,
1230            });
1231            this.recordAnswer(chipText, sourceNodeId, 'chip');
1232            setTimeout(() => {
1233              this.handleChipSelection(chipText, sourceNodeId, chipIndex);
1234            }, 500);
1235          });
1236        };
1237        chipButtons.push(button);
1238        chipsContainer.appendChild(button);
1239      });
1240
1241      const messageDiv = document.createElement('div');
1242      messageDiv.className = 'chatbot-message bot';
1243
1244      messageDiv.appendChild(chipsContainer);
1245      this.appendMessageElement(messageDiv, {
1246        animateByDelta: true,
1247        durationMs: 220,
1248      });
1249    },
1250
1251    showFreeInput: function(placeholder, validationType) {
1252      const input = this.inputArea.querySelector('.chatbot-input');
1253      const sendButton = this.inputArea.querySelector('.chatbot-send');
1254      let isComposing = false;
1255      this.currentFreeInputPlaceholder = placeholder || 'メッセージを入力...';
1256      this.currentFreeInputValidation = validationType || 'none';
1257
1258      input.placeholder = this.currentFreeInputPlaceholder;
1259      input.value = '';
1260      input.disabled = false;
1261      input.removeAttribute('aria-busy');
1262
1263      const submitValue = () => {
1264        const value = input.value.trim();
1265        if (!value) return;
1266        this.handleFreeInput(value);
1267        input.value = '';
1268        if (sendButton) {
1269          sendButton.disabled = true;
1270        }
1271      };
1272
1273      const updateSendButtonState = () => {
1274        if (!sendButton) return;
1275        sendButton.disabled = input.value.trim().length === 0;
1276      };
1277
1278      input.oninput = () => {
1279        updateSendButtonState();
1280      };
1281
1282      input.oncompositionstart = () => {
1283        isComposing = true;
1284      };
1285
1286      input.oncompositionend = () => {
1287        isComposing = false;
1288      };
1289
1290      input.onkeydown = (e) => {
1291        // Ignore Enter during IME composition (e.g., Japanese conversion confirmation).
1292        if (e.isComposing || isComposing || e.keyCode === 229) {
1293          return;
1294        }
1295
1296        // Enter sends; Shift+Enter keeps newline for multi-line input.
1297        if (e.key === 'Enter' && !e.shiftKey) {
1298          e.preventDefault();
1299          submitValue();
1300        }
1301      };
1302
1303      if (sendButton) {
1304        sendButton.disabled = true;
1305        sendButton.onclick = () => {
1306          submitValue();
1307        };
1308      }
1309
1310      updateSendButtonState();
1311
1312      this.transitionInputAreaState('free-input', { durationMs: 220 }).then(() => {
1313        input.focus();
1314      });
1315    },
1316
1317    showSelectionGuide: function(messageText) {
1318      const area = this.inputArea;
1319      if (!area) return;
1320
1321      const hint = area.querySelector('.chatbot-input-hint');
1322      const input = area.querySelector('.chatbot-input');
1323      const sendButton = area.querySelector('.chatbot-send');
1324
1325      if (hint) {
1326        hint.textContent = messageText || 'メッセージ表示エリアで回答を選択してください';
1327      }
1328
1329      if (input) {
1330        input.value = '';
1331        input.disabled = true;
1332        input.setAttribute('aria-busy', 'false');
1333        input.oninput = null;
1334        input.onkeydown = null;
1335        input.oncompositionstart = null;
1336        input.oncompositionend = null;
1337      }
1338
1339      if (sendButton) {
1340        sendButton.disabled = true;
1341        sendButton.onclick = null;
1342      }
1343
1344      this.transitionInputAreaState('selection-guide', { durationMs: 180 });
1345    },
1346
1347    showPendingFreeInputState: function() {
1348      const area = this.inputArea;
1349      if (!area) return;
1350
1351      const input = area.querySelector('.chatbot-input');
1352      const sendButton = area.querySelector('.chatbot-send');
1353
1354      if (input) {
1355        input.disabled = true;
1356        input.setAttribute('aria-busy', 'true');
1357      }
1358
1359      if (sendButton) {
1360        sendButton.disabled = true;
1361      }
1362
1363      this.transitionInputAreaState('pending-free-input', { durationMs: 140 });
1364    },
1365
1366    transitionInputAreaState: function(nextState, options) {
1367      const area = this.inputArea;
1368      if (!area) return Promise.resolve();
1369
1370      const inner = area.querySelector('.chatbot-input-area-inner');
1371      const duration = Math.max(0, Number(options && options.durationMs) || 0);
1372      const isVisible = area.style.display !== 'none' && area.offsetHeight > 0;
1373
1374      if (!inner) {
1375        this.applyInputAreaState(nextState);
1376        if (!isVisible) {
1377          return this.showInputArea(duration);
1378        }
1379        return Promise.resolve();
1380      }
1381
1382      if (this._inputInnerTransitionTimer) {
1383        window.clearTimeout(this._inputInnerTransitionTimer);
1384        this._inputInnerTransitionTimer = null;
1385      }
1386
1387      const finalize = (resolve) => {
1388        inner.style.height = '';
1389        inner.style.overflow = '';
1390        inner.style.transition = '';
1391        area.classList.remove('is-transitioning');
1392        resolve();
1393      };
1394
1395      if (!isVisible) {
1396        this.applyInputAreaState(nextState);
1397        return this.showInputArea(duration).then(() => {
1398          this.currentInputAreaState = nextState;
1399        });
1400      }
1401
1402      if (this.currentInputAreaState === nextState) {
1403        this.applyInputAreaState(nextState);
1404        return Promise.resolve();
1405      }
1406
1407      const startHeight = inner.offsetHeight;
1408      inner.style.height = `${startHeight}px`;
1409      inner.style.overflow = 'hidden';
1410      area.classList.add('is-transitioning');
1411
1412      inner.getBoundingClientRect();
1413      this.applyInputAreaState(nextState);
1414
1415      const targetHeight = inner.scrollHeight;
1416
1417      if (duration === 0) {
1418        this.currentInputAreaState = nextState;
1419        return new Promise((resolve) =>
1419 finalize(resolve));
1420      }
1421
1422      inner.style.transition = `height ${duration}ms ease`;
1423
1424      return new Promise((resolve) => {
1425        window.requestAnimationFrame(() => {
1426          inner.style.height = `${targetHeight}px`;
1427        });
1428
1429        this._inputInnerTransitionTimer = window.setTimeout(() => {
1430          this._inputInnerTransitionTimer = null;
1431          this.currentInputAreaState = nextState;
1432          finalize(resolve);
1433        }, duration);
1434      });
1435    },
1436
1437    applyInputAreaState: function(nextState) {
1438      const area = this.inputArea;
1439      if (!area) return;
1440
1441      area.classList.remove('is-selection-guide', 'is-free-input', 'is-loading');
1442
1443      if (nextState === 'selection-guide') {
1444        area.classList.add('is-selection-guide');
1445        return;
1446      }
1447
1448      if (nextState === 'free-input') {
1449        area.classList.add('is-free-input');
1450        return;
1451      }
1452
1453      if (nextState === 'pending-free-input') {
1454        area.classList.add('is-free-input', 'is-loading');
1455      }
1456    },
1457
1458    isValidFreeInput: function(value, validationType) {
1459      if (validationType === 'none') return true;
1460      if (validationType === 'email') {
1461        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
1462      }
1463
1464      const phonePattern = /^[0-9+()\-\s]+$/;
1465      const digits = value.replace(/\D/g, '');
1466      return phonePattern.test(value) && digits.length >= 10 && digits.length <= 15;
1467    },
1468
1469    getFreeInputValidationError: function(validationType) {
1470      if (validationType === 'email') {
1471        return 'メールアドレスの形式で入力してください(例: [email protected])';
1472      }
1473      if (validationType === 'phone') {
1474        return '電話番号を入力してください(例: 09012345678 または 090-1234-5678)';
1475      }
1476      return '入力内容を確認して、もう一度お試しください。';
1477    },
1478
1479    handleChipSelection: function(chipText, sourceNodeId, chipIndex) {
1480      if (this.isReEditingFromConfirm && this.reEditTargetNodeId === sourceNodeId && this.currentConfirmNodeId) {
1481        this.isReEditingFromConfirm = false;
1482        this.reEditTargetNodeId = null;
1483        this.showNode(this.currentConfirmNodeId);
1484        return;
1485      }
1486
1487      const edge = this.findNextEdgeForChip(sourceNodeId, chipText, chipIndex);
1488
1489      if (edge) {
1490        this.showNode(edge.target);
1491      }
1492    },
1493
1494    handleFreeInput: function(text) {
1495      if (!this.isValidFreeInput(text, this.currentFreeInputValidation)) {
1496        this.addBotMessage(this.getFreeInputValidationError(this.currentFreeInputValidation), {
1497          isError: true,
1498        });
1499        this.showFreeInput(this.currentFreeInputPlaceholder, this.currentFreeInputValidation);
1500        return;
1501      }
1502
1503      this.addUserMessage(text, {
1504        animateByDelta: true,
1505        durationMs: 220,
1506      });
1507      this.recordAnswer(text, this.currentNodeId, 'freeInput');
1508
1509      if (this.isReEditingFromConfirm && this.reEditTargetNodeId === this.currentNodeId && this.currentConfirmNodeId) {
1510        this.hideInputArea();
1511        this.isReEditingFromConfirm = false;
1512        this.reEditTargetNodeId = null;
1513        setTimeout(() => {
1514          this.showNode(this.currentConfirmNodeId);
1515        }, 300);
1516        return;
1517      }
1518
1519      // Move to next node
1520      const nextEdge = this.findNextDefaultEdge(this.currentNodeId);
1521      const nextNode = nextEdge
1522        ? this.flowData.nodes.find((node) => node.id === nextEdge.target)
1523        : null;
1524
1525      if (nextNode) {
1526        this.showPendingFreeInputState();
1527      } else {
1528        this.hideInputArea();
1529      }
1530
1531      if (nextEdge) {
1532        setTimeout(() => {
1533          this.showNode(nextEdge.target);
1534        }, 300);
1535      } else {
1536        this.completeFlow(this.currentNodeId);
1537      }
1538    },
1539
1540    findNextDefaultEdge: function(sourceNodeId) {
1541      const outgoingEdges = this.flowData.edges.filter(e => e.source === sourceNodeId);
1542      if (outgoingEdges.length === 0) return null;
1543
1544      const defaultEdge = outgoingEdges.find(e => !e.sourceHandle || e.sourceHandle === 'default');
1545      if (defaultEdge) return defaultEdge;
1546
1547      if (outgoingEdges.length === 1) {
1548        return outgoingEdges[0];
1549      }
1550
1551      return null;
1552    },
1553
1554    findNextEdgeForChip: function(sourceNodeId, chipText, chipIndex) {
1555      const outgoingEdges = this.flowData.edges.filter(e => e.source === sourceNodeId);
1556      if (outgoingEdges.length === 0) return null;
1557
1558      if (typeof chipIndex === 'number') {
1559        const edgeByIndexHandle = outgoingEdges.find(e => e.sourceHandle === `chip-${chipIndex}`);
1560        if (edgeByIndexHandle) return edgeByIndexHandle;
1561      }
1562
1563      const edgeByTextHandle = outgoingEdges.find(e => e.sourceHandle === chipText);
1564      if (edgeByTextHandle) return edgeByTextHandle;
1565
1566      const defaultEdge = outgoingEdges.find(e => !e.sourceHandle || e.sourceHandle === 'default');
1567      if (defaultEdge) return defaultEdge;
1568
1569      if (outgoingEdges.length === 1) {
1570        return outgoingEdges[0];
1571      }
1572
1573      return null;
1574    },
1575
1576    initFormDataStorage: function() {
1577      const input = document.createElement('input');
1578      input.type = 'hidden';
1579      input.name = this.config.formDataInputName || 'chatbotFormData';
1580      input.setAttribute('data-chatbot-form-data', this.config.containerId);
1581      this.formDataInput = input;
1582      this.container.appendChild(input);
1583      this.syncFormData();
1584    },
1585
1586    recordConversation: function(role, text, nodeId) {
1587      const entry = {
1588        role: role,
1589        text: text,
1590        nodeId: nodeId,
1591        timestamp: new Date().toISOString()
1592      };
1593      this.conversationHistory.push(entry);
1594      this.formData.conversation.push(entry);
1595      this.syncFormData();
1596    },
1597
1598    recordAnswer: function(text, nodeId, source) {
1599      const node = this.flowData ? this.flowData.nodes.find(n => n.id === nodeId) : null;
1600      const responses = node && node.data && Array.isArray(node.data.responses) ? node.data.responses : [];
1601      const lastResponse = responses.length > 0 ? responses[responses.length - 1] : undefined;
1602      const answerEntry = {
1603        nodeId: nodeId,
1604        intentName: node && node.data ? node.data.intentName : undefined,
1605        label: lastResponse || undefined,
1606        value: text,
1607        editable: this.answerStartReached === true,
1608        source: source,
1609        timestamp: new Date().toISOString()
1610      };
1611
1612      const existingIndex = this.formData.answers.findIndex(answer => answer.nodeId === nodeId);
1613      if (existingIndex >= 0) {
1614        this.formData.answers[existingIndex] = {
1615          ...this.formData.answers[existingIndex],
1616          ...answerEntry
1617        };
1618      } else {
1619        this.formData.answers.push(answerEntry);
1620      }
1621
1622      this.syncFormData();
1623    },
1624
1625    normalizeSubmitLocalStorageEntries: function() {
1626      if (!Array.isArray(this.config.submitLocalStorageKeys)) {
1627        return [];
1628      }
1629
1630      return this.config.submitLocalStorageKeys.reduce((entries, item) => {
1631        if (typeof item === 'string') {
1632          const trimmedKey = item.trim();
1633          if (trimmedKey) {
1634            entries.push({ key: trimmedKey, label: trimmedKey });
1635          }
1636          return entries;
1637        }
1638
1639        if (!item || typeof item !== 'object') {
1640          return entries;
1641        }
1642
1643        const trimmedKey = typeof item.key === 'string' ? item.key.trim() : '';
1644        if (!trimmedKey) {
1645          return entries;
1646        }
1647
1648        const trimmedLabel = typeof item.label === 'string' && item.label.trim()
1649          ? item.label.trim()
1650          : trimmedKey;
1651
1652        entries.push({ key: trimmedKey, label: trimmedLabel });
1653        return entries;
1654      }, []);
1655    },
1656
1657    collectSubmitLocalStorageData: function() {
1658      if (typeof window === 'undefined' || !window.localStorage) {
1659        return [];
1660      }
1661
1662      return this.normalizeSubmitLocalStorageEntries().reduce((items, entry) => {
1663        try {
1664          const value = window.localStorage.getItem(entry.key);
1665          if (value === null) {
1666            return items;
1667          }
1668
1669          items.push({
1670            key: entry.key,
1671            label: entry.label,
1672            value: value,
1673          });
1674        } catch (error) {
1675          console.warn('Failed to read localStorage key for chatbot submit:', entry.key, error);
1676        }
1677
1678        return items;
1679      }, []);
1680    },
1681
1682    refreshSubmitMetadata: function() {
1683      this.formData.localStorage = this.collectSubmitLocalStorageData();
1684      this.syncFormData();
1685    },
1686
1687    getEditableAnswers: function() {
1688      return this.formData.answers.filter(answer => answer.editable === true);
1689    },
1690
1691    getAllAnswers: function() {
1692      return this.formData.answers.map((answer) => ({ ...answer }));
1693    },
1694
1695    handleSubmit: function(confirmNodeId, confirmNodeData, options) {
1696      const opts = options || {};
1697      const submitBtn = opts.submitButton || null;
1698
1699      if (this._submitCompleted) {
1700        return;
1701      }
1702
1703      // Disable the button immediately to prevent double submission.
1704      if (submitBtn) {
1705        submitBtn.disabled = true;
1706        submitBtn.classList.add('is-submitting');
1707        submitBtn.setAttribute('aria-busy', 'true');
1708      }
1709      this.setConfirmSummaryItemsDisabled(true);
1710
1711      this.refreshSubmitMetadata();
1712
1713      const detail = {
1714        containerId: this.config.containerId,
1715        flowId: this.config.flowId,
1716        confirmNodeId: confirmNodeId,
1717        answerStartNodeId: this.answerStartNodeId,
1718        answers: this.getEditableAnswers(),
1719        allAnswers: this.getAllAnswers(),
1720        conversation: this.formData.conversation,
1721        localStorage: this.collectSubmitLocalStorageData(),
1722        formData: this.getFormData(),
1723        timestamp: new Date().toISOString()
1724      };
1725
1726      const submitEvent = new CustomEvent('chatbot:submit', {
1727        detail,
1728        bubbles: true,
1729        cancelable: true
1730      });
1731      const shouldContinue = this.container.dispatchEvent(submitEvent);
1732
1733      if (confirmNodeData && confirmNodeData.customPayload) {
1734        try {
1735          const payload = JSON.parse(confirmNodeData.customPayload);
1736          if (payload.action && this.config.customActions[payload.action]) {
1737            this.config.customActions[payload.action](payload, detail);
1738          }
1739        } catch (e) {
1740          console.error('Invalid custom payload on confirm submit:', e);
1741        }
1742      }
1743
1744      // AJAX submission via WordPress REST API (nonce-protected).
1745      const flowConfig = (typeof chatbotFlowData !== 'undefined') ? chatbotFlowData : {};
1746      const hasAjaxSubmission = !!(flowConfig.submitUrl && flowConfig.submitNonce);
1747      if (hasAjaxSubmission) {
1748        this._sendAjaxSubmit(detail, flowConfig, submitBtn);
1749      } else if (!shouldContinue && submitBtn) {
1750        submitBtn.disabled = false;
1751        submitBtn.classList.remove('is-submitting');
1752        submitBtn.removeAttribute('aria-busy');
1753      }
1754
1755      if (shouldContinue) {
1756        const form = this.container.closest('form');
1757        if (form) {
1758          if (typeof form.requestSubmit === 'function') {
1759            form.requestSubmit();
1760          } else {
1761            form.submit();
1762          }
1763        }
1764      }
1765    },
1766
1767    getSubmitTokenUrl: function(flowConfig) {
1768      if (flowConfig && flowConfig.submitTokenUrl) {
1769        return flowConfig.submitTokenUrl;
1770      }
1771
1772      const submitUrl = flowConfig && flowConfig.submitUrl ? String(flowConfig.submitUrl) : '';
1773      if (!submitUrl) {
1774        return '';
1775      }
1776
1777      return submitUrl.replace(/\/submit(?:\?.*)?$/, '/submit-token');
1778    },
1779
1780    fetchFreshSubmitNonce: function(flowConfig) {
1781      const tokenUrl = this.getSubmitTokenUrl(flowConfig);
1782      const fallbackNonce = flowConfig && flowConfig.submitNonce ? flowConfig.submitNonce : '';
1783
1784      if (!tokenUrl) {
1785        return Promise.resolve(fallbackNonce);
1786      }
1787
1788      return fetch(tokenUrl, {
1789        method: 'GET',
1790        credentials: 'same-origin',
1791        cache: 'no-store'
1792      })
1793      .then((response) => {
1794        if (!response.ok) {
1795          throw new Error('Failed to refresh submit nonce');
1796        }
1797        return response.json();
1798      })
1799      .then((data) => {
1800        const freshNonce = data && typeof data.nonce === 'string' ? data.nonce : '';
1801        return freshNonce || fallbackNonce;
1802      })
1803      .catch(() => fallbackNonce);
1804    },
1805
1806    _sendAjaxSubmit: function(detail, flowConfig, submitBtn) {
1807      const submitUrl = flowConfig.submitUrl;
1808
1809      this.fetchFreshSubmitNonce(flowConfig).then((nonce) => fetch(submitUrl, {
1810        method: 'POST',
1811        credentials: 'same-origin',
1812        headers: { 'Content-Type': 'application/json' },
1813        body: JSON.stringify({
1814          nonce:        nonce,
1815          flow_id:      detail.flowId,
1816          answers:      Array.isArray(detail.allAnswers) ? detail.allAnswers : detail.answers,
1817          editable_answers: detail.answers,
1818          conversation: detail.conversation,
1819          local_storage: Array.isArray(detail.localStorage) ? detail.localStorage : [],
1820        }),
1821      }))
1822      .then((response) => response.json())
1823      .then((data) => {
1824        if (data.success) {
1825          this._submitCompleted = true;
1826          if (submitBtn) {
1827            submitBtn.disabled = true;
1828            submitBtn.classList.remove('is-submitting');
1829            submitBtn.classList.add('is-submitted');
1830            submitBtn.removeAttribute('aria-busy');
1831          }
1832          this.addBotMessage('送信が完了しました。お問い合わせありがとうございました。');
1833          this.completeFlow(this.currentNodeId);
1834        } else {
1835          this.addBotMessage('送信中にエラーが発生しました。しばらくしてから再度お試しください。', { isError: true });
1836          if (submitBtn) {
1837            submitBtn.disabled = false;
1838            submitBtn.classList.remove('is-submitting');
1839            submitBtn.removeAttribute('aria-busy');
1840          }
1841        }
1842      })
1843      .catch(() => {
1844        this.addBotMessage('送信中にエラーが発生しました。しばらくしてから再度お試しください。', { isError: true });
1845        if (submitBtn) {
1846          submitBtn.disabled = false;
1847          submitBtn.classList.remove('is-submitting');
1848          submitBtn.removeAttribute('aria-busy');
1849        }
1850      });
1851    },
1852
1853    syncFormData: function() {
1854      if (!this.formDataInput) return;
1855      this.formDataInput.value = JSON.stringify(this.formData);
1856    },
1857
1858    getFormData: function() {
1859      return JSON.parse(JSON.stringify(this.formData));
1860    },
1861
1862    showError: function(message) {
1863      this.addBotMessage('エラー: ' + message);
1864    },
1865
1866    scrollToBottom: function() {
1867      setTimeout(() => {
1868        this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
1869      }, 100);
1870    },
1871
1872    escapeHtml: function(text) {
1873      const div = document.createElement('div');
1874      div.textContent = text;
1875      return div.innerHTML;
1876    },
1877
1878    reset: function() {
1879      this.conversationHistory = [];
1880      this.contexts.clear();
1881      this.currentNodeId = null;
1882      this.answerStartNodeId = null;
1883      this.answerStartReached = false;
1884      this.currentConfirmNodeId = null;
1885      this.reEditTargetNodeId = null;
1886      this.isReEditingFromConfirm = false;
1887      this.currentFreeInputValidation = 'none';
1888      this.currentFreeInputPlaceholder = 'メッセージを入力...';
1889      this._flowCompleted = false;
1890      this._submitCompleted = false;
1891      this.formData = {
1892        flowId: this.config.flowId,
1893        answers: [],
1894        conversation: [],
1895        localStorage: []
1896      };
1897      this.syncFormData();
1898      this.messagesContainer.innerHTML = '';
1899      this.hideInputArea(0);
1900      this.start();
1901    },
1902
1903    destroy: function() {
1904      this.container.innerHTML = '';
1905      ChatbotFlow.instances.delete(this.config.containerId);
1906    }
1907  };
1908
1909  // Export to global scope
1910  window.ChatbotFlow = ChatbotFlow;
1911
1912})(window);

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.