PageSourceSearch

https://www.narakenkoland.net/assets/hr-chat-widget-core-2168bfa6e…d484b7d1e4459d385142e63abbdacaafdc26e034.js

js narakenkoland.net collected 2026-10-02 17:34:54 UTC 16,743 bytes, 453 lines download raw bytes

1(function () {
2// ===== セキュリティヘルパー =====
3const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
4  c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
5const safeUrl = (u) => /^https?:\/\//i.test(u) ? u : '#';
6
7  'use strict';
8
9  // CONFIGは各施設ファイル(hr-chat-widget-{facility}.js)で定義すること
10
11  // ===== HTML構築 =====
12  const quickBtnsHtml = CONFIG.quickBtns
13    .map(b => `<button class="hw-quick-btn" onclick="hwQuick('${b.text}')">${b.label}</button>`)
14    .join('\n        ');
15
16  const avatarHtml = CONFIG.avatarImg
17    ? `<img src="${CONFIG.avatarImg}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;">`
18    : CONFIG.avatarLetter;
19
20  const headerAvatarHtml = CONFIG.avatarImg
21    ? `<img src="${CONFIG.avatarImg}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">`
22    : CONFIG.avatarLetter;
23
24  const html = `
25    <div id="hw-root">
26      <div id="hw-bubble">${CONFIG.bubbleMsg}</div>
27
28      <button id="hw-toggle-btn" aria-label="AIコンシェルジュを開く">
29        <svg class="icon-chat" viewBox="0 0 24 24" fill="none" stroke="#f5f0e8" stroke-width="1.8">
30          <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
31          <line x1="9" y1="10" x2="15" y2="10" stroke-linecap="round"/>
32          <line x1="9" y1="13" x2="13" y2="13" stroke-linecap="round"/>
33        </svg>
34        <svg class="icon-close" viewBox="0 0 24 24" fill="none" stroke="#f5f0e8" stroke-width="2">
35          <line x1="18" y1="6" x2="6" y2="18"/>
36          <line x1="6" y1="6" x2="18" y2="18"/>
37        </svg>
38      </button>
39
40      <div id="hw-panel">
41        <div class="hw-header">
42          <div class="hw-avatar">${headerAvatarHtml}</div>
43          <div class="hw-header-info">
44            <div class="hw-name">${CONFIG.headerName}</div>
45            <div class="hw-status"><div class="hw-dot"></div>オンライン</div>
46          </div>
47          <button class="hw-expand-btn" id="hwExpandBtn" onclick="hwToggleExpand()" title="拡大表示">⤢</button>
48        </div>
49
50        <div class="hw-messages" id="hw-messages">
51          <div class="hw-msg bot">
52            <div class="hw-msg-avatar">${avatarHtml}</div>
53            <div class="hw-bubble">${CONFIG.welcomeMsg}</div>
54          </div>
55        </div>
56
57        <div class="hw-typing" id="hw-typing">
58          <div class="hw-msg-avatar">${avatarHtml}</div>
59          <div class="hw-typing-dots">
60            <div class="hw-typing-dot"></div>
61            <div class="hw-typing-dot"></div>
62            <div class="hw-typing-dot"></div>
63          </div>
64        </div>
65
66        <div class="hw-quick" id="hw-quick">
67          ${quickBtnsHtml}
68        </div>
69
70        <div class="hw-input-area">
71          <textarea class="hw-input" id="hw-input" placeholder="ご質問をどうぞ..." rows="1"
72            onkeydown="hwHandleKey(event)" oninput="hwResize(this)"></textarea>
73          <button class="hw-send" id="hw-send" onclick="hwSend()">
74            <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
75          </button>
76        </div>
77      </div>
78    </div>
79  `;
80
81  const wrapper = document.createElement('div');
82  wrapper.innerHTML = html;
83  document.body.appendChild(wrapper);
84
85  // ===== 状態 =====
86  let hwHistory           = [];
87  let hwLoading           = false;
88  let hwBubbleHidden      = false;
89  let hwMsgCount          = 0;
90
91  // ===== LocalStorage =====
92  const hwStorageKey = 'hw_history_' + CONFIG.facilityId;
93  const hwStorageTTL = 24 * 60 * 60 * 1000; // 24時間
94
95  function hwSaveHistory() {
96    try {
97      localStorage.setItem(hwStorageKey, JSON.stringify({
98        ts: Date.now(),
99        sessionId: hwSessionId,
100        history: hwHistory,
101      }));
102    } catch (e) { /* 容量超過等は無視 */ }
103  }
104
105  function hwLoadHistory() {
106    try {
107      const raw = localStorage.getItem(hwStorageKey);
108      if (!raw) return null;
109      const data = JSON.parse(raw);
110      if (Date.now() - data.ts > hwStorageTTL) {
111        localStorage.removeItem(hwStorageKey);
112        return null;
113      }
114      return data.history;
115    } catch (e) { return null; }
116  }
117
118  // セッションIDは保存済み履歴があれば引き継ぐ、なければ新規生成
119  const _savedRaw = localStorage.getItem(hwStorageKey);
120  const _savedData = (() => { try { return _savedRaw ? JSON.parse(_savedRaw) : null; } catch { return null; } })();
121  const hwSessionId = (_savedData && _savedData.sessionId)
122    ? _savedData.sessionId
123    : 'sess_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
124
125  // ===== 履歴復元 =====
126  (function hwRestoreHistory() {
127    const saved = hwLoadHistory();
128    if (!saved || saved.length === 0) return;
129
130    // ウェルカムメッセージを一旦クリアして履歴を再描画
131    const container = document.getElementById('hw-messages');
132    container.innerHTML = '';
133
134    saved.forEach(msg => {
135      hwHistory.push(msg);
136      // DOM再描画(hwAddMsgはフィードバックボタン等を付けるので直接描画)
137      const div = document.createElement('div');
138      div.className = `hw-msg ${msg.role === 'user' ? 'user' : 'bot'}`;
139
140      const avatar = document.createElement('div');
141      avatar.className = 'hw-msg-avatar';
142      if (msg.role !== 'user') {
143        if (CONFIG.avatarImg) {
144          avatar.innerHTML = `<img src="${CONFIG.avatarImg}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;">`;
145        } else {
146          avatar.textContent = CONFIG.avatarLetter;
147          avatar.style.cssText = `background:${CONFIG.themeColor};color:${CONFIG.accentColor};font-size:10px;`;
148        }
149      } else {
150        avatar.textContent = '👤';
151      }
152
153      const bubble = document.createElement('div');
154      bubble.className = 'hw-bubble';
155      let t = escapeHtml(msg.content)
156
157      t = t.replace(
158        /https?:\/\/[^\s\u3000-\u9fff\uff00-\uffef、。!?「」()【】『』〔〕…―]+/g,
159        url => {
160          url = url.replace(/[\s.,;:!?))>\]」』]+$/, '');
161          return `<a href="${safeUrl(url)}" target="_blank" rel="noopener" style="color:${CONFIG.themeColor};word-break:break-all;text-decoration:underline;">こちら</a>`;
162        }
163      );
164      bubble.innerHTML = t.replace(/\n/g, '<br>');
165
166      div.appendChild(avatar);
167      div.appendChild(bubble);
168      container.appendChild(div);
169    });
170
171    // クイックボタンを非表示
172    document.getElementById('hw-quick').style.display = 'none';
173    container.scrollTop = container.scrollHeight;
174  })();
175
176  // ===== 開閉 =====
177  document.getElementById('hw-toggle-btn').addEventListener('click', function () {
178    const panel  = document.getElementById('hw-panel');
179    const bubble = document.getElementById('hw-bubble');
180    panel.classList.toggle('open');
181    this.classList.toggle('open');
182    if (!hwBubbleHidden) {
183      bubble.style.display = 'none';
184      hwBubbleHidden = true;
185    }
186  });
187
188  document.getElementById('hw-bubble').addEventListener('click', function () {
189    document.getElementById('hw-panel').classList.add('open');
190    document.getElementById('hw-toggle-btn').classList.add('open');
191    this.style.display = 'none';
192    hwBubbleHidden = true;
193  });
194
195  // バブルを5秒後に自動非表示
196  setTimeout(() => {
197    const bubble = document.getElementById('hw-bubble');
198    if (bubble && !hwBubbleHidden) {
199      bubble.style.animation  = 'none';
200      bubble.style.opacity    = '0';
201      bubble.style.transition = 'opacity 0.5s';
202      setTimeout(() => { if (!hwBubbleHidden) bubble.style.display = 'none'; }, 500);
203    }
204  }, 5000);
205
206  // ===== 拡大・縮小 =====
207  window.hwToggleExpand = function () {
208    const panel      = document.getElementById('hw-panel');
209    const btn        = document.getElementById('hwExpandBtn');
210    const isExpanded = panel.classList.contains('expanded');
211    panel.classList.toggle('expanded');
212    btn.textContent = isExpanded ? '⤢' : '⤡';
213    btn.title       = isExpanded ? '拡大表示' : '縮小表示';
214    setTimeout(() => {
215      const msgs = document.getElementById('hw-messages');
216      if (msgs) msgs.scrollTop = msgs.scrollHeight;
217    }, 300);
218  };
219
220  // ===== 入力補助 =====
221  window.hwResize = function (el) {
222    el.style.height = 'auto';
223    el.style.height = Math.min(el.scrollHeight, 100) + 'px';
224  };
225
226  window.hwHandleKey = function (e) {
227    if (e.key === 'Enter' && !e.shiftKey) {
228      e.preventDefault();
229      hwSend();
230    }
231  };
232
233  window.hwQuick = function (text) {
234    document.getElementById('hw-input').value = text;
235    hwSend();
236  };
237
238  // ===== メッセージ追加 =====
239  function hwAddMsg(role, content) {
240    const container  = document.getElementById('hw-messages');
241
242    const div        = document.createElement('div');
243    div.className    = `hw-msg ${role}`;
244
245    const avatar     = document.createElement('div');
246    avatar.className = 'hw-msg-avatar';
247    if (role === 'bot') {
248      if (CONFIG.avatarImg) {
249        avatar.innerHTML = `<img src="${CONFIG.avatarImg}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;">`;
250        avatar.style.cssText = '';
251      } else {
252        avatar.textContent = CONFIG.avatarLetter;
253        avatar.style.cssText = `background:${CONFIG.themeColor};color:${CONFIG.accentColor};font-size:10px;`;
254      }
255    } else {
256      avatar.textContent = '👤';
257    }
258
259    const bubble     = document.createElement('div');
260    bubble.className = 'hw-bubble';
261    bubble.innerHTML = (() => {
262      let t = escapeHtml(content)
263
264      t = t.replace(
265        /https?:\/\/[^\s\u3000-\u9fff\uff00-\uffef、。!?「」()【】『』〔〕…―]+/g,
266        url => {
267          url = url.replace(/[\s.,;:!?))>\]」』]+$/, '');
268          return `<a href="${safeUrl(url)}" target="_blank" rel="noopener" style="color:${CONFIG.themeColor};word-break:break-all;text-decoration:underline;">こちら</a>`;
269        }
270      );
271      return t.replace(/\n/g, '<br>');
272    })();
273
274    div.appendChild(avatar);
275    div.appendChild(bubble);
276    container.appendChild(div);
277
278    // AIメッセージにフィードバックボタンを追加
279    if (role === 'bot') hwAttachFeedback(div);
280
281    container.scrollTop = container.scrollHeight;
282  }
283
284  // ===== フィードバックボタン追加(共通) =====
285  // hwAddMsg / hwSend(ストリーミング)の両パスから呼ぶことでデグレを防ぐ
286  function hwAttachFeedback(msgDiv) {
287    hwMsgCount++;
288    const msgId = 'msg_' + hwMsgCount;
289    msgDiv.dataset.msgId = msgId;
290
291    const fbDiv = document.createElement('div');
292    fbDiv.className = 'hw-feedback';
293
294    const goodBtn = document.createElement('button');
295    goodBtn.className = 'hw-fb-btn';
296    goodBtn.title = '役に立った';
297    goodBtn.textContent = '👍';
298    goodBtn.onclick = function () {
299      const content = msgDiv.querySelector('.hw-bubble')?.innerText || '';
300      hwFeedback(msgId, 1, this, content);
301    };
302
303    const badBtn = document.createElement('button');
304    badBtn.className = 'hw-fb-btn';
305    badBtn.title = '改善が必要';
306    badBtn.textContent = '👎';
307    badBtn.onclick = function () {
308      const content = msgDiv.querySelector('.hw-bubble')?.innerText || '';
309      hwFeedback(msgId, -1, this, content);
310    };
311
312    fbDiv.appendChild(goodBtn);
313    fbDiv.appendChild(badBtn);
314    document.getElementById('hw-messages').appendChild(fbDiv);
315
316    document.getElementById('hw-messages').scrollTop = 99999;
317  }
318
319  // ===== フィードバック送信 =====
320  async function hwFeedback(msgId, value, btnEl, messageContent) {
321    btnEl.parentElement.querySelectorAll('.hw-fb-btn').forEach(b => b.classList.remove('active-good', 'active-bad'));
322    btnEl.classList.add(value === 1 ? 'active-good' : 'active-bad');
323    try {
324      await fetch(CONFIG.workerUrl + '/feedback', {
325        method: 'POST',
326        headers: { 'Content-Type': 'application/json' },
327        body: JSON.stringify({
328          facility_id:     CONFIG.facilityId,
329          session_id:      hwSessionId,
330          type:            'message',
331          value,
332          message_content: (messageContent || '').slice(0, 500),
333        }),
334      });
335    } catch (e) { console.warn('フィードバック送信失敗:', e); }
336  }
337
338  // ===== 満足度パネル表示 =====
339  // ===== タイピング表示 =====
340  function hwSetTyping(show) {
341    const t = document.getElementById('hw-typing');
342    t.classList.toggle('show', show);
343    if (show) document.getElementById('hw-messages').scrollTop = 99999;
344  }
345
346  // ===== メッセージ送信 =====
347  window.hwSend = async function () {
348    if (hwLoading) return;
349
350    const input = document.getElementById('hw-input');
351    const text  = input.value.trim();
352    if (!text) return;
353
354    hwAddMsg('user', text);
355    hwHistory.push({ role: 'user', content: text });
356    hwSaveHistory();
357
358    input.value        = '';
359    input.style.height = 'auto';
360    document.getElementById('hw-quick').style.display = 'none';
361    document.getElementById('hw-send').disabled        = true;
362    hwLoading = true;
363    hwSetTyping(true);
364
365    try {
366      const res = await fetch(CONFIG.workerUrl + '/chat', {
367        method: 'POST',
368        headers: { 'Content-Type': 'application/json' },
369        body: JSON.stringify({
370          facility_id: CONFIG.facilityId,
371          session_id:  hwSessionId,
372          messages:    hwHistory,
373        }),
374      });
375
376      if (!res.ok) {
377        const err = await res.json();
378        throw new Error(err.error || 'APIエラー');
379      }
380
381      // ストリーミング受け取り
382      hwSetTyping(false);
383      const msgDiv = document.createElement('div');
384      msgDiv.className = 'hw-msg bot';
385      const avatar = document.createElement('div');
386      avatar.className = 'hw-msg-avatar';
387      if (CONFIG.avatarImg) {
388        avatar.innerHTML = `<img src="${CONFIG.avatarImg}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;">`;
389        avatar.style.cssText = '';
390      } else {
391        avatar.textContent = CONFIG.avatarLetter;
392        avatar.style.cssText = `background:${CONFIG.themeColor};color:${CONFIG.accentColor};font-size:10px;`;
393      }
394      const bubble = document.createElement('div');
395      bubble.className = 'hw-bubble';
396      bubble.innerHTML = '';
397      msgDiv.appendChild(avatar);
398      msgDiv.appendChild(bubble);
399      document.getElementById('hw-messages').appendChild(msgDiv);
400
401      const reader = res.body.getReader();
402      const decoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true });
403      let fullText = '';
404      let sourceUrl = null;
405      let buffer = '';
406
407      while (true) {
408        const { done, value } = await reader.read();
409        if (done) break;
410        buffer += decoder.decode(value, { stream: true });
411        const lines = buffer.split('\n');
412        buffer = lines.pop();
413        for (const line of lines) {
414          if (!line.startsWith('data: ')) continue;
415          const data = line.slice(6).trim();
416          try {
417            const parsed = JSON.parse(data);
418            if (parsed.delta) {
419              fullText += parsed.delta;
420              bubble.innerHTML = escapeHtml(fullText).replace(
421                /https?:\/\/[^\s\u3000-\u9fff\uff00-\uffef、。!?「」()【】『』〔〕…―]+/g,
422                url => {
423                  url = url.replace(/[\s.,;:!?))>\]」』]+$/, '');
424                  return `<a href="${safeUrl(url)}" target="_blank" rel="noopener" style="color:${CONFIG.themeColor};word-break:break-all;text-decoration:underline;">こちら</a>`;
425                }
426              ).replace(/\n/g, '<br>');
427              document.getElementById('hw-messages').scrollTop = 99999;
428            }
429            if (parsed.done) {
430              sourceUrl = parsed.sourceUrl || null;
431            }
432          } catch {}
433        }
434      }
435
436      if (sourceUrl) {
437        bubble.innerHTML += '<br><br>' + `<a href="${safeUrl(sourceUrl)}" target="_blank" rel="noopener" style="display:inline-block;margin-top:6px;font-size:12px;color:${CONFIG.themeColor};text-decoration:underline;">🔗 参考ページ</a>`;
438      }
439
440      hwHistory.push({ role: 'assistant', content: fullText });
441      hwSaveHistory();
442      hwAttachFeedback(msgDiv);
443
444    } catch (e) {
445      hwSetTyping(false);
446      hwAddMsg('bot', `申し訳ございません。エラーが発生いたしました。\n${CONFIG.errorPhone ? 'お電話(' + CONFIG.errorPhone + ')にてお問い合わせください。' : 'お問い合わせください。'}`);
447    } finally {
448      document.getElementById('hw-send').disabled = false;
449      hwLoading = false;
450    }
451  };
452
453})();

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.