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.