PageSourceSearch

https://solution.crossvision-inc.com/solution/js/chat.js?v=cta1

js crossvision-inc.com collected 2026-10-04 15:28:14 UTC 26,781 bytes, 754 lines download raw bytes

1/* ==========================================================
2   CrossVision AI見積もりチャット
3   - API設定あり → Bedrock(Lambda経由)
4   - API未設定/障害 → ルールベースフォールバック
5   ========================================================== */
6
7(function () {
8  "use strict";
9
10  var config = window.CV_CHAT_CONFIG || {};
11  var apiUrl = config.apiUrl || "";
12  var abandonUrl = config.abandonUrl || (apiUrl ? apiUrl.replace(/\/chat\/?$/, "/abandon") : "");
13  var leadUrl = config.leadUrl || (apiUrl ? apiUrl.replace(/\/chat\/?$/, "/lead") : "");
14  var useFallback = config.fallback !== false;
15  var apiEnabled = !!apiUrl;
16
17  var fab = document.getElementById("chatFab");
18  var panel = document.getElementById("chatPanel");
19  var closeBtn = document.getElementById("chatClose");
20  var body = document.getElementById("chatBody");
21  var form = document.getElementById("chatForm");
22  var input = document.getElementById("chatInput");
23  var suggestionsEl = document.getElementById("chatSuggestions");
24  var openChatBtn = document.getElementById("openChatBtn");
25  var chatProgress = document.getElementById("chatProgress");
26
27  var opened = false;
28  var greeted = false;
29  var chatStarted = sessionStorage.getItem("cv_chat_started") === "1";
30  var storyType = sessionStorage.getItem("cv_chat_story") || null;
31  var slotState = loadSlotState();
32  var sessionId = getSessionId();
33  var apiMessages = loadApiMessages();
34  var apiMode = apiEnabled;
35  var leadSubmitted = sessionStorage.getItem("cv_chat_lead") === "1";
36  var chatDisabled = false;
37  var abandonSent = sessionStorage.getItem("cv_chat_abandon") === "1";
38  var leadFormShown = false;
39  var leadCtaReady = sessionStorage.getItem("cv_chat_lead_ready") === "1";
40  var leadBar = document.getElementById("chatLeadBar");
41  var leadBarBtn = document.getElementById("chatLeadBarBtn");
42
43  var STORY_OPTIONS = [
44    { text: "こんなシステムを作りたい", type: "system_dev", label: "システム開発の相談" },
45    { text: "新規事業を立ち上げたい", type: "new_business", label: "新規事業支援" },
46    { text: "AIを自社に取り入れたい", type: "entertainment", label: "エンタメ事業に関する相談" }
47  ];
48
49  /* ---------- ルールベース知識(フォールバック) ---------- */
50  var knowledge = [
51    {
52      keys: ["サービス", "事業", "何をして", "できること", "支援"],
53      reply: "私たちは<strong>AI伴走型の新規事業開発支援</strong>を行っています。アプローチは2つ。「既存顧客基盤を起点とした事業拡張」と「新規市場を探索するゼロイチ事業開発」です。",
54      followups: ["アプリ開発の見積もり", "料金プランを教えて"]
55    },
56    {
57      keys: ["見積", "料金", "費用", "価格", "プラン", "いくら"],
58      reply: "<strong>アプリ開発プラン</strong><br>・ライト 100万円〜(1ヶ月〜)<br>・ミドル 300万円〜(認証規模、3ヶ月〜)<br>・Pro 1,000万円〜(プラットフォーム、3ヶ月〜)<br>・オプション:社内SEエージェント +20万円+α<br><br>詳しい要件をお聞かせいただければ、AIが概算をご提案します。",
59      followups: ["iOS/Android両方", "ユーザー認証あり", "社内SEエージェントについて"]
60    },
61    {
62      keys: ["技術", "AI", "ai", "XR", "xr", "Web3", "web3", "生成", "LLM", "llm"],
63      reply: "カスタマーサポートAI、AI映像解析、XR、モバイルアプリ、オープンイノベーションなど幅広く対応しています。DEMOセクションに一覧があります。",
64      followups: ["アプリ開発の見積もり", "会社について教えて"]
65    },
66    {
67      keys: ["会社", "概要", "どこ", "所在地", "設立", "代表"],
68      reply: "株式会社CrossVision(2024年設立、代表:奥井颯平)。docomo STARTUP発。東京・大阪に拠点があります。",
69      followups: ["アプリ開発の見積もり", "問い合わせしたい"]
70    },
71    {
72      keys: ["問い合わせ", "相談", "連絡", "コンタクト"],
73      reply: "ページ下部の<strong>お問い合わせボタン</strong>からご連絡ください。無料の壁打ち相談も大歓迎です。",
74      followups: ["アプリ開発の見積もり"]
75    },
76    {
77      keys: ["こんにちは", "はじめまして", "hello", "hi", "やあ"],
78      reply: "こんにちは。CrossVisionのAI見積もりアシスタントです。アプリ開発の概算見積など、お気軽にご相談ください。",
79      followups: ["アプリ開発の見積もり", "プランの違いを教えて"]
80    }
81  ];
82
83  var fallback = {
84    reply: "うまく理解できませんでした。アプリ開発の見積もり、料金プラン、会社概要などについてお答えできます。",
85    followups: ["アプリ開発の見積もり", "料金プランを教えて", "問い合わせしたい"]
86  };
87
88  /* ---------- セッション管理 ---------- */
89  function getSessionId() {
90    var key = "cv_chat_session";
91    var id = sessionStorage.getItem(key);
92    if (!id) {
93      id = "cv-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 9);
94      sessionStorage.setItem(key, id);
95    }
96    return id;
97  }
98
99  function loadApiMessages() {
100    try {
101      return JSON.parse(sessionStorage.getItem("cv_chat_messages") || "[]");
102    } catch {
103      return [];
104    }
105  }
106
107  function saveApiMessages() {
108    sessionStorage.setItem("cv_chat_messages", JSON.stringify(apiMessages));
109  }
110
111  function markLeadSubmitted() {
112    leadSubmitted = true;
113    sessionStorage.setItem("cv_chat_lead", "1");
114    hideLeadCta();
115  }
116
117  function loadSlotState() {
118    try {
119      return JSON.parse(sessionStorage.getItem("cv_chat_slots") || "{}");
120    } catch {
121      return {};
122    }
123  }
124
125  function saveSlotState(slots) {
126    if (!slots) return;
127    var next = Object.assign({}, slotState);
128    slots.forEach(function (s) {
129      if (s.filled && s.value && s.value !== "(会話から取得)") {
130        next[s.id] = s.value;
131      }
132    });
133    slotState = next;
134    sessionStorage.setItem("cv_chat_slots", JSON.stringify(slotState));
135  }
136
137  function saveStoryType(type) {
138    storyType = type;
139    if (type) sessionStorage.setItem("cv_chat_story", type);
140  }
141
142  function markChatStarted() {
143    chatStarted = true;
144    sessionStorage.setItem("cv_chat_started", "1");
145  }
146
147  function setInputEnabled(enabled) {
148    if (form) form.classList.toggle("is-disabled", !enabled);
149    if (input) input.disabled = !enabled;
150  }
151
152  function setSuggestionsEnabled(enabled) {
153    if (suggestionsEl) suggestionsEl.classList.toggle("is-disabled", !enabled);
154  }
155
156  function setChatInputEnabled(enabled) {
157    setInputEnabled(enabled);
158    setSuggestionsEnabled(enabled);
159  }
160
161  function storyFromText(text) {
162    var found = STORY_OPTIONS.find(function (s) {
163      return s.text === text;
164    });
165    return found ? found.type : null;
166  }
167
168  function markAbandonSent() {
169    abandonSent = true;
170    sessionStorage.setItem("cv_chat_abandon", "1");
171  }
172
173  function updateProgressBar(progress) {
174    var bar = document.getElementById("chatProgressBar");
175    var label = document.getElementById("chatProgressLabel");
176    if (!bar || !progress) return;
177    bar.style.width = (progress.percent || 0) + "%";
178    if (chatProgress) {
179      chatProgress.setAttribute("aria-hidden", progress.slots && progress.slots.length ? "false" : "true");
180    }
181    if (label) {
182      if (progress.slots && progress.slots.length) {
183        var filled = progress.slots.filter(function (s) {
184          return s.filled;
185        }).length;
186        var req = progress.slots.filter(function (s) {
187          return s.required;
188        });
189        var reqFilled = req.filter(function (s) {
190          return s.filled;
191        }).length;
192        label.textContent =
193          (progress.stage || "ヒアリング") +
194          " — 必須 " +
195          reqFilled +
196          "/" +
197          req.length +
198          " · 全体 " +
199          filled +
200          "/" +
201          progress.slots.length;
202      } else {
203        label.textContent = progress.stage || "";
204      }
205    }
206  }
207
208  /* ---------- UI ---------- */
209  function scrollToBottom() {
210    body.scrollTop = body.scrollHeight;
211  }
212
213  function avatarSpeak(html) {
214    if (!window.cvAvatar) return;
215    var len = html.replace(/<[^>]*>/g, "").length;
216    window.cvAvatar.speak(Math.min(7000, 1200 + len * 55));
217  }
218
219  function escapeHtml(text) {
220    return text.replace(/[<>&]/g, function (c) {
221      return { "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c];
222    });
223  }
224
225  function formatReply(text) {
226    return escapeHtml(text).replace(/\n/g, "<br>");
227  }
228
229  function addMessage(text, who, isHtml) {
230    var el = document.createElement("div");
231    el.className = "chat-msg " + who;
232    el.innerHTML = isHtml ? text : formatReply(text);
233    body.appendChild(el);
234    scrollToBottom();
235    return el;
236  }
237
238  function showTyping() {
239    var el = document.createElement("div");
240    el.className = "chat-msg bot typing";
241    el.innerHTML = "<i></i><i></i><i></i>";
242    body.appendChild(el);
243    scrollToBottom();
244    return el;
245  }
246
247  function setSuggestions(list, onSelect) {
248    suggestionsEl.replaceChildren();
249    (list || []).forEach(function (item) {
250      var text = typeof item === "string" ? item : item.text;
251      var btn = document.createElement("button");
252      btn.type = "button";
253      btn.textContent = text;
254      btn.addEventListener("click", function () {
255        if (onSelect) {
256          onSelect(item);
257        } else {
258          handleUserMessage(text);
259        }
260      });
261      suggestionsEl.appendChild(btn);
262    });
263  }
264
265  function setStorySuggestions() {
266    setSuggestions(STORY_OPTIONS, function (item) {
267      selectStory(item);
268    });
269  }
270
271  /* ---------- セクション誘導リンク ---------- */
272  function addSectionLink(link) {
273    if (!link || typeof link.index !== "number") return;
274    /* 準備中のソリューションへは誘導しない */
275    var targetTab = document.querySelector(
276      '#solutionTabs [data-index="' + link.index + '"]'
277    );
278    if (!targetTab || targetTab.disabled) return;
279    var wrap = document.createElement("div");
280    wrap.className = "chat-msg bot chat-section-link";
281    var btn = document.createElement("button");
282    btn.type = "button";
283    btn.innerHTML = "▶ 事例を見る:<strong>" + escapeHtml(link.title || "") + "</strong>";
284    btn.addEventListener("click", function () {
285      closePanel();
286      var target = document.getElementById("solutions");
287      if (target) target.scrollIntoView({ behavior: "smooth" });
288      setTimeout(function () {
289        var tab = document.querySelector('#solutionTabs [data-index="' + link.index + '"]');
290        if (tab) tab.click();
291      }, 700);
292    });
293    wrap.appendChild(btn);
294    body.appendChild(wrap);
295    scrollToBottom();
296  }
297
298  /* ---------- 詳細見積依頼(CTA + フォーム) ----------
299     CTAを会話の流れに置くと、次の発言で上へ流れて見失う。
300     一度出たら入力欄の上に出したままにする */
301  function showLeadCta() {
302    if (leadSubmitted || !leadBar) return;
303    if (!leadCtaReady) {
304      leadCtaReady = true;
305      sessionStorage.setItem("cv_chat_lead_ready", "1");
306    }
307    leadBar.hidden = false;
308  }
309
310  function hideLeadCta() {
311    if (leadBar) leadBar.hidden = true;
312  }
313
314  function showLeadForm() {
315    if (leadSubmitted) return;
316    if (document.getElementById("chatLeadOverlay")) return;
317    leadFormShown = true;
318
319    var overlay = document.createElement("div");
320    overlay.className = "chat-lead-overlay";
321    overlay.id = "chatLeadOverlay";
322    overlay.innerHTML =
323      '<div class="chat-lead-overlay-inner">' +
324      '<button type="button" class="chat-lead-overlay-close" id="leadOverlayClose" aria-label="閉じる">×</button>' +
325      "<strong>詳細見積のご依頼</strong>" +
326      "<p>担当者よりご連絡いたします。以下をご入力ください。</p>" +
327      '<label>会社名 <span class="req">必須</span><input type="text" id="leadCompany" placeholder="株式会社〇〇" maxlength="100"></label>' +
328      '<label>役職<input type="text" id="leadRole" placeholder="例:事業開発部 部長" maxlength="50"></label>' +
329      '<label>ご予算<select id="leadBudget">' +
330      '<option value="">選択してください</option>' +
331      '<option>〜100万円</option>' +
332      '<option>100〜300万円</option>' +
333      '<option>300〜1,000万円</option>' +
334      '<option>1,000万円〜</option>' +
335      '<option>未定・相談したい</option>' +
336      "</select></label>" +
337      '<label>メールアドレス <span class="req">必須</span><input type="email" id="leadEmail" placeholder="[email protected]" maxlength="100"></label>' +
338      '<p class="chat-lead-error" id="leadError" hidden></p>' +
339      '<button type="button" id="leadSubmitBtn">送信する</button>' +
340      "</div>";
341
342    panel.appendChild(overlay);
343    requestAnimationFrame(function () {
344      overlay.classList.add("show");
345    });
346
347    document.getElementById("leadOverlayClose").addEventListener("click", function () {
348      closeLeadOverlay(overlay);
349    });
350    document.getElementById("leadSubmitBtn").addEventListener("click", function () {
351      submitLeadForm(overlay);
352    });
353    setTimeout(function () {
354      var first = document.getElementById("leadCompany");
355      if (first) first.focus();
356    }, 300);
357  }
358
359  function closeLeadOverlay(overlay) {
360    leadFormShown = false;
361    overlay.classList.remove("show");
362    setTimeout(function () {
363      overlay.remove();
364    }, 300);
365  }
366
367  function submitLeadForm(overlay) {
368    var company = document.getElementById("leadCompany").value.trim();
369    var role = document.getElementById("leadRole").value.trim();
370    var budget = document.getElementById("leadBudget").value;
371    var email = document.getElementById("leadEmail").value.trim();
372    var errEl = document.getElementById("leadError");
373    var btn = document.getElementById("leadSubmitBtn");
374
375    var emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
376    if (!company) {
377      errEl.textContent = "会社名を入力してください";
378      errEl.hidden = false;
379      return;
380    }
381    if (!emailRe.test(email)) {
382      errEl.textContent = "メールアドレスの形式が正しくありません";
383      errEl.hidden = false;
384      return;
385    }
386    errEl.hidden = true;
387    btn.disabled = true;
388    btn.textContent = "送信中…";
389
390    fetch(leadUrl, {
391      method: "POST",
392      headers: { "Content-Type": "application/json" },
393      body: JSON.stringify({
394        sessionId: sessionId,
395        companyName: company,
396        role: role,
397        budget: budget,
398        email: email,
399        messages: apiMessages,
400        storyType: storyType || undefined
401      })
402    })
403      .then(function (res) {
404        if (!res.ok) throw new Error("API " + res.status);
405        return res.json();
406      })
407      .then(function () {
408        markLeadSubmitted();
409        overlay.querySelector(".chat-lead-overlay-inner").innerHTML =
410          '<div class="chat-lead-done">' +
411          '<span class="chat-lead-done-icon">✓</span>' +
412          "<strong>ご依頼を受け付けました</strong>" +
413          "<p>担当者より <strong>" +
414          escapeHtml(email) +
415          "</strong> 宛にご連絡いたします。</p>" +
416          '<button type="button" id="leadDoneClose">チャットに戻る</button>' +
417          "</div>";
418        document.getElementById("leadDoneClose").addEventListener("click", function () {
419          overlay.classList.remove("show");
420          setTimeout(function () { overlay.remove(); }, 300);
421        });
422        addMessage(
423          '<p class="chat-lead-notice">✓ 詳細見積のご依頼を受け付けました。担当者よりご連絡いたします。</p>',
424          "bot",
425          true
426        );
427        setSuggestions(["ありがとうございます", "他に質問がある"]);
428      })
429      .catch(function (err) {
430        console.warn("Lead submit error:", err);
431        btn.disabled = false;
432        btn.textContent = "送信する";
433        errEl.textContent = "送信に失敗しました。時間をおいてお試しください。";
434        errEl.hidden = false;
435      });
436  }
437
438  /* ---------- ルールベース ---------- */
439  function findAnswer(text) {
440    var best = null, bestScore = 0;
441    knowledge.forEach(function (item) {
442      var score = 0;
443      item.keys.forEach(function (k) {
444        if (text.indexOf(k) !== -1) score += k.length;
445      });
446      if (score > bestScore) { bestScore = score; best = item; }
447    });
448    return best || fallback;
449  }
450
451  function botReplyFallback(text) {
452    var typing = showTyping();
453    var answer = findAnswer(text);
454    setTimeout(function () {
455      typing.remove();
456      addMessage(answer.reply, "bot", true);
457      avatarSpeak(answer.reply);
458      setSuggestions(answer.followups);
459    }, 700 + Math.random() * 600);
460  }
461
462  /* ---------- Bedrock API ---------- */
463  function callChatApi() {
464    return fetch(apiUrl, {
465      method: "POST",
466      headers: { "Content-Type": "application/json" },
467      body: JSON.stringify({
468        sessionId: sessionId,
469        messages: apiMessages,
470        storyType: storyType || undefined,
471        slotState: slotState
472      })
473    }).then(function (res) {
474      if (!res.ok) throw new Error("API " + res.status);
475      return res.json();
476    });
477  }
478
479  function botReplyApi(text) {
480    var typing = showTyping();
481    apiMessages.push({ role: "user", content: text });
482    saveApiMessages();
483
484    callChatApi()
485      .then(function (data) {
486        typing.remove();
487        var reply = data.reply || "申し訳ありません。もう一度お試しください。";
488        apiMessages.push({ role: "assistant", content: reply });
489        saveApiMessages();
490
491        var html = formatReply(reply);
492        if (data.estimate && data.estimate.plan) {
493          html +=
494            '<div class="chat-estimate-card">' +
495            "<strong>概算見積</strong><br>" +
496            "プラン:" + escapeHtml(data.estimate.plan) + "<br>" +
497            "レンジ:" + escapeHtml(data.estimate.range || "") +
498            "</div>";
499        }
500        if (data.leadSubmitted) {
501          markLeadSubmitted();
502          html += '<p class="chat-lead-notice">✓ 担当者へ情報を送信しました。改めてご連絡いたします。</p>';
503        }
504
505        if (data.chatDisabled) {
506          chatDisabled = true;
507          html += '<p class="chat-lead-notice">現在AIチャットの上限に達しています。お問い合わせフォームをご利用ください。</p>';
508        }
509
510        addMessage(html, "bot", true);
511        avatarSpeak(reply);
512        setSuggestions(data.suggestions || []);
513        updateProgressBar(data.hearingProgress);
514        if (data.hearingProgress && data.hearingProgress.slots) {
515          saveSlotState(data.hearingProgress.slots);
516        }
517
518        if (data.sectionLink) {
519          addSectionLink(data.sectionLink);
520        }
521        if (data.leadFormReady && !data.leadSubmitted) {
522          showLeadCta();
523        }
524      })
525      .catch(function (err) {
526        console.warn("Chat API error:", err);
527        typing.remove();
528        apiMessages.pop();
529        saveApiMessages();
530
531        if (useFallback) {
532          apiMode = false;
533          addMessage("(AI接続に問題があったため、簡易モードで回答します)", "bot");
534          botReplyFallback(text);
535        } else {
536          addMessage("接続エラーが発生しました。しばらくしてからお試しください。", "bot");
537        }
538      });
539  }
540
541  /* ---------- メッセージ処理 ---------- */
542  function handleUserMessage(text) {
543    if (!text.trim()) return;
544    if (chatDisabled) {
545      addMessage("現在AIチャットは利用上限のため停止中です。お問い合わせフォームからご連絡ください。", "bot");
546      return;
547    }
548
549    if (apiMode && apiEnabled && !storyType) {
550      var detected = storyFromText(text);
551      if (detected) saveStoryType(detected);
552    }
553
554    addMessage(text, "user");
555    if (apiMode && apiEnabled) {
556      botReplyApi(text);
557    } else {
558      botReplyFallback(text);
559    }
560  }
561
562  function sendAbandonmentBeacon() {
563    if (!abandonUrl || !apiEnabled || leadSubmitted || abandonSe
563nt) return;
564    if (apiMessages.length === 0) return;
565
566    var payload = JSON.stringify({
567      sessionId: sessionId,
568      messages: apiMessages,
569      storyType: storyType || undefined
570    });
571
572    if (navigator.sendBeacon) {
573      navigator.sendBeacon(abandonUrl, new Blob([payload], { type: "application/json" }));
574      markAbandonSent();
575      return;
576    }
577
578    fetch(abandonUrl, {
579      method: "POST",
580      headers: { "Content-Type": "application/json" },
581      body: payload,
582      keepalive: true
583    }).then(function () {
584      markAbandonSent();
585    }).catch(function () {});
586  }
587
588  function selectStory(option) {
589    if (!option || storyType) return;
590    saveStoryType(option.type);
591    markChatStarted();
592    setChatInputEnabled(true);
593    var intro = document.getElementById("chatIntro");
594    if (intro) intro.remove();
595    handleUserMessage(option.text);
596  }
597
598  var AVATAR_CHOICES = [
599    { id: "baku", label: "社長アバター" },
600    { id: "shino", label: "女の子アバター" }
601  ];
602
603  /** ヘッダーの切り替えボタンから、いま表示中のアバターを読む */
604  function currentAvatar() {
605    var active = document.querySelector(".avatar-choice.active");
606    return (active && active.dataset.avatar) || AVATAR_CHOICES[0].id;
607  }
608
609  function pickAvatar(id) {
610    /* avatar.js の初期化前に選ばれた場合はここを見て最初のモデルを決める */
611    window.__cvAvatarPending = id;
612    window.dispatchEvent(new CustomEvent("cv:avatar-select", { detail: { id: id } }));
613  }
614
615  function showIntro() {
616    if (document.getElementById("chatIntro")) return;
617    var active = currentAvatar();
618    var el = document.createElement("div");
619    el.className = "chat-intro";
620    el.id = "chatIntro";
621    el.innerHTML =
622      "<p>このチャットを通じて、お客様の課題に対して弊社が提供できるソリューションやそのお見積りを行うことができます。<br>ご用件をより詳しく、テキストチャットから打ち込んでください。</p>" +
623      '<p class="chat-intro-pick">話し相手を選んでスタート</p>' +
624      '<div class="chat-intro-avatars">' +
625      AVATAR_CHOICES.map(function (a) {
626        return '<button type="button" class="chat-intro-avatar' +
627          (a.id === active ? " is-active" : "") +
628          '" data-avatar="' + a.id + '">' + a.label + "</button>";
629      }).join("") +
630      "</div>";
631    body.appendChild(el);
632    scrollToBottom();
633
634    Array.prototype.forEach.call(el.querySelectorAll(".chat-intro-avatar"), function (btn) {
635      btn.addEventListener("click", function () {
636        pickAvatar(btn.dataset.avatar);
637        startStorySelect();
638      });
639    });
640
641    setInputEnabled(false);
642    setSuggestionsEnabled(false);
643    setSuggestions([]);
644  }
645
646  function startStorySelect() {
647    markChatStarted();
648    var intro = document.getElementById("chatIntro");
649    if (intro) intro.remove();
650    addMessage("ご相談の種類をお選びください。", "bot");
651    setInputEnabled(false);
652    setSuggestionsEnabled(true);
653    setStorySuggestions();
654  }
655
656  function greet() {
657    if (greeted) return;
658    greeted = true;
659
660    if (apiMessages.length > 0 && storyType) {
661      setChatInputEnabled(true);
662      apiMessages.forEach(function (m) {
663        addMessage(m.content, m.role === "user" ? "user" : "bot", m.role !== "user");
664      });
665      /* 前回すでに条件を満たしていたなら、復元後もCTAを出しておく */
666      if (leadCtaReady && !leadSubmitted) showLeadCta();
667      return;
668    }
669
670    if (apiMode && apiEnabled) {
671      showIntro();
672      return;
673    }
674
675    var typing = showTyping();
676    setTimeout(function () {
677      typing.remove();
678      var msg =
679        "こんにちは。CrossVisionの<strong>AI見積もりアシスタント</strong>です。<br>アプリ開発の概算見積など、お気軽にご相談ください。";
680      addMessage(msg, "bot", true);
681      avatarSpeak(msg);
682      setSuggestions(["アプリ開発の見積もり", "料金プランを教えて", "会社について教えて"]);
683      setChatInputEnabled(true);
684    }, 900);
685  }
686
687  function openPanel() {
688    panel.classList.add("open");
689    opened = true;
690    window.__cvChatOpened = true;
691    window.dispatchEvent(new CustomEvent("cv:chat-open"));
692    greet();
693    setTimeout(function () { input.focus(); }, 400);
694  }
695
696  function closePanel() {
697    if (embedded) return; /* 埋め込み中はモーダル側で管理 */
698    sendAbandonmentBeacon();
699    panel.classList.remove("open");
700    opened = false;
701  }
702
703  /* ---------- ソリューションモーダルへの埋め込み ----------
704     「カスタマーサポートAI」デモとして、本物のチャットパネルを
705     モーダル内に移設して描画する(状態・イベントはそのまま維持) */
706  var embedded = false;
707  var homeParent = panel.parentElement;
708
709  window.cvChat = {
710    embed: function (container) {
711      if (embedded || !container) return false;
712      embedded = true;
713      container.appendChild(panel);
714      panel.classList.add("embedded", "open");
715      opened = true;
716      /* avatar.js は cv:chat-open で VRM ロードを開始する */
717      if (!window.__cvChatOpened) {
718        window.__cvChatOpened = true;
719        window.dispatchEvent(new CustomEvent("cv:chat-open"));
720      }
721      greet();
722      return true;
723    },
724    unembed: function () {
725      if (!embedded) return;
726      embedded = false;
727      panel.classList.remove("embedded", "open");
728      opened = false;
729      homeParent.appendChild(panel);
730    },
731    isEmbedded: function () {
732      return embedded;
733    }
734  };
735
736  document.addEventListener("visibilitychange", function () {
737    if (document.visibilityState === "hidden") sendAbandonmentBeacon();
738  });
739
740  fab.addEventListener("click", function () {
741    if (embedded) return;
742    opened ? closePanel() : openPanel();
743  });
744  closeBtn.addEventListener("click", closePanel);
745  if (openChatBtn) openChatBtn.addEventListener("click", openPanel);
746  if (leadBarBtn) leadBarBtn.addEventListener("click", showLeadForm);
747
748  form.addEventListener("submit", function (e) {
749    e.preventDefault();
750    var text = input.value;
751    input.value = "";
752    handleUserMessage(text);
753  });
754})();

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.