function initBackToTop() { const buttons = document.querySelectorAll('[data-back-to-top], #js-back-to-top'); if (!buttons.length) return; let ticking = false; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { buttons.forEach((button) => { button.classList.toggle('is-visible', window.scrollY > 300); }); ticking = false; }); }); buttons.forEach((button) => { button.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); } function initMobileNav() { const toggle = document.getElementById('js-nav-toggle'); const nav = document.getElementById('js-nav'); if (!toggle || !nav) return; const desktopQuery = window.matchMedia('(min-width: 1025px)'); const wideDesktopQuery = window.matchMedia('(min-width: 1541px)'); const closeNav = () => { toggle.setAttribute('aria-expanded', 'false'); nav.classList.remove('is-open'); }; toggle.addEventListener('click', () => { const expanded = toggle.getAttribute('aria-expanded') === 'true'; toggle.setAttribute('aria-expanded', String(!expanded)); nav.classList.toggle('is-open', !expanded); document.querySelector('.l-header')?.classList.add('is-scroll-visible'); }); desktopQuery.addEventListener('change', (event) => { if (event.matches) closeNav(); }); wideDesktopQuery.addEventListener('change', (event) => { if (event.matches) closeNav(); }); } function initAutoHideHeader() { const header = document.querySelector('.l-header'); if (!header) return; const revealDistance = 48; const hideDistance = 24; const placeholder = document.createElement('div'); placeholder.className = 'l-header-placeholder'; placeholder.setAttribute('aria-hidden', 'true'); header.before(placeholder); let headerStartY = 0; let headerEndY = 0; let headerHeight = 0; let lastScrollY = Math.max(0, window.scrollY); let directionAnchorY = lastScrollY; let lastDirection = 'none'; let ticking = false; const refreshGeometry = () => { headerHeight = header.offsetHeight; headerStartY = placeholder.getBoundingClientRect().top + Math.max(0, window.scrollY); headerEndY = headerStartY + headerHeight; if (header.classList.contains('is-scroll-fixed')) { placeholder.style.setProperty('--header-placeholder-height', `${headerHeight}px`); } }; const activateFloatingHeader = () => { if (header.classList.contains('is-scroll-fixed')) return; placeholder.style.setProperty('--header-placeholder-height', `${headerHeight}px`); placeholder.classList.add('is-active'); header.classList.remove('is-scroll-visible'); header.classList.add('is-scroll-fixed'); header.getBoundingClientRect(); }; const showFloatingHeader = () => { activateFloatingHeader(); header.classList.add('is-scroll-visible'); }; const hideFloatingHeader = () => { header.classList.remove('is-scroll-visible'); }; const restoreFlowHeader = () => { header.classList.remove('is-scroll-visible', 'is-scroll-fixed'); placeholder.classList.remove('is-active'); placeholder.style.removeProperty('--header-placeholder-height'); }; const updateHeader = () => { const currentScrollY = Math.max(0, window.scrollY); const direction = currentScrollY > lastScrollY ? 'down' : currentScrollY < lastScrollY ? 'up' : lastDirection; const navOpen = document.getElementById('js-nav')?.classList.contains('is-open'); const focusInsideHeader = header.contains(document.activeElement) && document.activeElement?.matches(':focus-visible'); if (currentScrollY <= headerStartY) { restoreFlowHeader(); directionAnchorY = currentScrollY; } else { if (direction !== lastDirection) directionAnchorY = lastScrollY; const distanceFromAnchor = Math.abs(currentScrollY - directionAnchorY); if (navOpen || focusInsideHeader) { if (currentScrollY > headerEndY) showFloatingHeader(); } else if (currentScrollY > headerEndY) { if (direction === 'up' && distanceFromAnchor >= revealDistance) { showFloatingHeader(); directionAnchorY = currentScrollY; } if (direction === 'down' && header.classList.contains('is-scroll-fixed') && distanceFromAnchor >= hideDistance) { hideFloatingHeader(); directionAnchorY = currentScrollY; } } } lastDirection = direction; lastScrollY = currentScrollY; ticking = false; }; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; window.requestAnimationFrame(updateHeader); }, { passive: true }); header.addEventListener('focusin', () => { if (window.scrollY > headerEndY) showFloatingHeader(); }); window.addEventListener('resize', () => { refreshGeometry(); }, { passive: true }); window.addEventListener('pageshow', () => { refreshGeometry(); lastScrollY = Math.max(0, window.scrollY); directionAnchorY = lastScrollY; lastDirection = 'none'; if (lastScrollY <= headerStartY) restoreFlowHeader(); }); refreshGeometry(); } function subsidyEscapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[char]); } const SUBSIDY_AI_CITATION_PATTERNS = [ /\uE200[\s\S]*?\uE201/g, // 不可視マーカー(囲み) /[\uE200-\uE2FF]/g, // 取りこぼした単独マーカー /\u3010[^\u3011]*\u2020[^\u3011]*\u3011/g, // 【4:0†source】 /\u3010\s*turn\d+[a-zA-Z]*\d*\s*\u3011/g, // 【turn1file13】 /\u3010\s*\d+(?::\d+)+[^\u3011]*\u3011/g, // 【4:0】 /\u3010\s*(?:oai_citation|filecite|cite)[^\u3011]*\u3011/gi, /\[[^\]]*oai_citation[^\]]*\]\([^)]*\)/gi, // [oai_citation:1‡…](URL) /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g // ゼロ幅・BOM等 ]; function subsidyStripCitationMarkers(md) { let text = String(md || ""); SUBSIDY_AI_CITATION_PATTERNS.forEach(pattern => { text = text.replace(pattern, ""); }); return text .replace(/[ \t\u3000]+(?=[\u3001\u3002\u300D\u300F\uFF09])/g, "") .replace(/[ \t\u3000]+$/gm, ""); } function subsidyRenderInline(text) { let html = subsidyEscapeHtml(text); // インラインコードは他の変換対象から退避しておく const codes = []; html = html.replace(/`([^`]+)`/g, (_m, body) => { codes.push(body); return `\u0000CODE${codes.length - 1}\u0000`; }); // リンクは http(s) とサイト内絶対パスのみ許可する html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, (_m, label, url) => `${label}`); html = html .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") .replace(/~~([^~]+)~~/g, "$1"); return html.replace(/\u0000CODE(\d+)\u0000/g, (_m, index) => `${codes[Number(index)]}`); } function subsidySplitTableRow(line) { return line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map(cell => cell.trim()); } function subsidyRenderMarkdown(md) { const lines = subsidyStripCitationMarkers(md).replace(/\r\n?/g, "\n").split("\n"); const out = []; let paragraph = []; let listType = null; let listItems = []; let quote = []; const flushParagraph = () => { if (!paragraph.length) return; out.push(`

${paragraph.map(subsidyRenderInline).join("
")}

`); paragraph = []; }; const flushList = () => { if (!listItems.length) { listType = null; return; } out.push(`<${listType}>${listItems.map(item => `
  • ${subsidyRenderInline(item)}
  • `).join("")}`); listItems = []; listType = null; }; const flushQuote = () => { if (!quote.length) return; out.push(`
    ${quote.map(subsidyRenderInline).join("
    ")}
    `); quote = []; }; const flushAll = () => { flushParagraph(); flushList(); flushQuote(); }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // コードブロック if (/^\s*```/.test(line)) { flushAll(); const body = []; i++; while (i < lines.length && !/^\s*```/.test(lines[i])) { body.push(lines[i]); i++; } out.push(`
    ${subsidyEscapeHtml(body.join("\n"))}
    `); continue; } // 空行 if (!line.trim()) { flushAll(); continue; } // 水平線 if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) { flushAll(); out.push("
    "); continue; } // 見出し const heading = line.match(/^(#{1,6})\s+(.*)$/); if (heading) { flushAll(); const level = Math.min(6, heading[1].length + 2); // h1/h2 は本文中では大きすぎるため下げる out.push(`${subsidyRenderInline(heading[2])}`); continue; } // テーブル(次の行が区切り行の場合のみ) if (line.includes("|") && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes("-")) { flushAll(); const header = subsidySplitTableRow(line); const aligns = subsidySplitTableRow(lines[i + 1]).map(cell => { if (/^:.*:$/.test(cell)) return "center"; if (/:$/.test(cell)) return "right"; return "left"; }); i += 2; const bodyRows = []; while (i < lines.length && lines[i].includes("|") && lines[i].trim()) { bodyRows.push(subsidySplitTableRow(lines[i])); i++; } i--; const th = header.map((cell, idx) => `${subsidyRenderInline(cell)}`).join(""); const tb = bodyRows.map(row => `${row.map((cell, idx) => `${subsidyRenderInline(cell)}`).join("")}` ).join(""); out.push(`
    ${th}${tb}
    `); continue; } // 引用 if (/^\s*>\s?/.test(line)) { flushParagraph(); flushList(); quote.push(line.replace(/^\s*>\s?/, "")); continue; } // 箇条書き const ul = line.match(/^\s*[-*+]\s+(.*)$/); const ol = line.match(/^\s*\d+[.)]\s+(.*)$/); if (ul || ol) { flushParagraph(); flushQuote(); const type = ul ? "ul" : "ol"; if (listType && listType !== type) flushList(); listType = type; listItems.push((ul ? ul[1] : ol[1])); continue; } // リスト継続行(インデントされた折り返し) if (listItems.length && /^\s{2,}\S/.test(line)) { listItems[listItems.length - 1] += ` ${line.trim()}`; continue; } flushList(); flushQuote(); paragraph.push(line.trim()); } flushAll(); return out.join(""); } // Read UTF-8 SSE frames across arbitrary network boundaries. A result event is mandatory. async function readSubsidyAiStream(response, onEvent) { if (!response.body?.getReader) throw new Error('ストリーミング接続を開始できませんでした。'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let result = null; let completed = false; const processBlock = (block) => { let type = 'message'; const data = []; for (const line of block.split(/\r?\n/)) { if (line.startsWith('event:')) type = line.slice(6).trim(); if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')); } if (!data.length) return; let payload; try { payload = JSON.parse(data.join('\n')); } catch { throw new Error('回答データを受信できませんでした。再度お試しください。'); } if (!payload || typeof payload !== 'object') throw new Error('回答データの形式が不正です。'); if (type === 'error') { const failure = new Error(typeof payload.message === 'string' ? payload.message : '回答の生成に失敗しました。'); failure.payload = payload; throw failure; } if (type === 'result') { if (typeof payload.answer !== 'string' || !payload.answer.trim()) throw new Error('回答データを確認できませんでした。'); result = payload; completed = true; } if (type === 'status' || type === 'delta' || type === 'result') onEvent?.(type, payload); }; const drain = () => { let boundary; while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) { const block = buffer.slice(0, boundary.index); buffer = buffer.slice(boundary.index + boundary[0].length); processBlock(block); if (completed) return; } if (buffer.length > 524288) throw new Error('回答データが大きすぎます。'); }; try { while (!completed) { const { done, value } = await reader.read(); buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); drain(); if (done) { if (!completed && buffer.trim()) processBlock(buffer); break; } } if (!completed) throw new Error('回答の受信が途中で終了しました。時間をおいて再度お試しください。'); return result; } finally { try { await reader.cancel(); } catch { /* Network may already be closed. */ } reader.releaseLock(); } } // Cookies identify this browser; only VisionCompass verifies password-derived access tickets. function getSubsidyPublicApi(endpoint = 'https://visioncompass.svltd.co.jp/api/ai_public.php') { const readCookie = (name) => { const entry = document.cookie.split('; ').find((item) => item.startsWith(name + '=')); if (!entry) return ''; try { return decodeURIComponent(entry.slice(name.length + 1)); } catch { return ''; } }; const writeCookie = (name, value, seconds) => { document.cookie = name + '=' + encodeURIComponent(value) + '; Path=/; Max-Age=' + seconds + '; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : ''); }; let clientId = readCookie('sv_subsidy_client'); if (!/^[a-f0-9]{32}$/.test(clientId)) { // Preserve the identity of visitors who used the previous trial implementation. try { clientId = window.localStorage.getItem('svltd-subsidy-ai-client-v1'); } catch { clientId = ''; } if (!/^[a-f0-9]{32}$/.test(clientId || '')) { clientId = Array.from(window.crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join(''); } } writeCookie('sv_subsidy_client', clientId, 400 * 86400); const request = async (action, body, signal, onEvent) => { if (readCookie('sv_subsidy_client') !== clientId) { throw new Error('この機能を利用するにはCookieを有効にしてください。'); } const url = new URL(endpoint, window.location.href); if (action !== 'chat') url.searchParams.set('action', action); const headers = { Accept: onEvent ? 'text/event-stream, application/json' : 'application/json', 'X-Subsidy-AI-Client': clientId }; const accessToken = readCookie('sv_subsidy_access'); if (accessToken) headers['X-Subsidy-AI-Access'] = accessToken; if (body !== undefined) headers['Content-Type'] = 'application/json'; const response = await window.fetch(url.href, { method: body === undefined ? 'GET' : 'POST', credentials: 'omit', headers, signal, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (response.ok && (response.headers.get('content-type') || '').includes('text/event-stream')) { return { response, payload: await readSubsidyAiStream(response, onEvent) }; } let payload = {}; if ((response.headers.get('content-type') || '').includes('application/json')) { try { payload = await response.json(); } catch { /* Report invalid response below. */ } } if (response.status === 401 && payload.code === 'session_expired') { writeCookie('sv_subsidy_access', '', 0); } if (response.ok && action === 'auth' && typeof payload.accessToken === 'string' && Number.isFinite(payload.accessExpiresAt)) { writeCookie('sv_subsidy_access', payload.accessToken, Math.max(0, Math.floor(payload.accessExpiresAt - Date.now() / 1000))); } return { response, payload }; }; return { request, logout: () => writeCookie('sv_subsidy_access', '', 0) }; } function initSubsidyAiAssistant() { document.querySelectorAll('[data-subsidy-ai]').forEach((root) => { const panel = root.querySelector('[data-subsidy-ai-panel]'); const launcher = root.querySelector('[data-subsidy-ai-open]'); const closeButton = root.querySelector('[data-subsidy-ai-close]'); const expandButton = root.querySelector('[data-subsidy-ai-expand]'); const form = root.querySelector('[data-subsidy-ai-form]'); const input = root.querySelector('[data-subsidy-ai-input]'); const sendButton = root.querySelector('[data-subsidy-ai-send]'); const characterCount = root.querySelector('[data-subsidy-ai-count]'); const remaining = root.querySelector('[data-subsidy-ai-remaining]'); const error = root.querySelector('[data-subsidy-ai-error]'); const lock = root.querySelector('[data-subsidy-ai-lock]'); const log = root.querySelector('[data-subsidy-ai-log]'); const messages = root.querySelector('[data-subsidy-ai-messages]'); const options = Array.from(root.querySelectorAll('[data-subsidy-ai-option]')); if (!panel || !launcher || !form || !input || !sendButton || !remaining || !error || !lock || !log) return; const maxQuestions = Math.max(1, Number(root.dataset.maxQuestions) || 3); const apiEndpoint = root.dataset.apiEndpoint || ''; const usageEndpoint = root.dataset.usageEndpoint || ''; const api = getSubsidyPublicApi(apiEndpoint); const authForm = root.querySelector('[data-subsidy-ai-auth-form]'); const passwordInput = root.querySelector('[data-subsidy-ai-password]'); const authButton = root.querySelector('[data-subsidy-ai-auth-submit]'); const authError = root.querySelector('[data-subsidy-ai-auth-error]'); const authToggle = root.querySelector('[data-subsidy-ai-auth-toggle]'); const authSuccess = root.querySelector('[data-subsidy-ai-auth-success]'); const limitNotice = root.querySelector('[data-subsidy-ai-limit-notice]'); let authLoading = false; let accessOpen = false; let loading = false; let usageRequested = false; let usageRevision = 0; let usage = { remainingQuestions: maxQuestions, locked: false, trialLimited: true, resetAt: 0, dailyLocked: false, dailyRemaining: 50, dailyResetAt: 0, }; const clearError = () => { error.hidden = true; error.textContent = ''; input.removeAttribute('aria-invalid'); if (!loading) root.dataset.state = 'default'; }; const showError = (message) => { root.dataset.state = 'error'; error.textContent = message; error.hidden = false; }; const applyUsagePayload = (payload, optionsForUsage = {}) => { if (!payload || typeof payload !== 'object') return; if (Number.isFinite(payload.resetAt)) usage.resetAt = payload.resetAt; const serverRemaining = typeof payload.remainingQuestions === 'number' ? payload.remainingQuestions : NaN; if (Number.isFinite(serverRemaining)) { usage.remainingQuestions = Math.max(0, serverRemaining); } else if (optionsForUsage.countSuccessfulAnswer && usage.trialLimited) { usage.remainingQuestions = Math.max(0, usage.remainingQuestions - 1); } if (payload.trialLimited === false || payload.authenticated === true) { usage.trialLimited = false; usage.locked = false; } else { usage.trialLimited = true; usage.locked = payload.locked === true || usage.remainingQuestions === 0; } usage.dailyLocked = payload.dailyLocked === true; if (Number.isFinite(payload.dailyRemaining)) usage.dailyRemaining = payload.dailyRemaining; if (Number.isFinite(payload.dailyResetAt)) usage.dailyResetAt = payload.dailyResetAt; }; const renderUsage = () => { const trialLocked = usage.trialLimited && usage.locked; const locked = trialLocked || usage.dailyLocked; root.dataset.locked = String(locked); lock.hidden = !usage.trialLimited || (!trialLocked && !accessOpen); if (limitNotice) limitNotice.hidden = !trialLocked; if (authToggle) authToggle.hidden = !usage.trialLimited || trialLocked || accessOpen; if (authSuccess) authSuccess.hidden = usage.trialLimited; input.disabled = locked || loading || authLoading; sendButton.disabled = locked || loading || authLoading; if (authButton) authButton.disabled = loading || authLoading; options.forEach((option) => { option.disabled = locked || loading; }); if (usage.dailyLocked) { remaining.textContent = '本日の同一IPからの利用上限(50回)に達しました'; } else if (!usage.trialLimited) { remaining.textContent = 'メルマガ登録者として利用中(同一IP:本日あと' + usage.dailyRemaining + '回)'; } else if (locked) { remaining.textContent = '一般利用:3回分を利用しました'; } else { remaining.textContent = '一般利用:あと' + usage.remainingQuestions + '回質問できます'; } if (locked) { input.value = ''; if (characterCount) characterCount.textContent = '0'; input.placeholder = usage.dailyLocked ? '本日の利用上限に達しました。翌日(日本時間)に再度お試しください' : '一般利用の上限に達しました'; sendButton.textContent = '利用上限'; } else { input.placeholder = '補助金について質問を入力してください'; if (!loading) sendButton.textContent = '送信'; } }; const setLoading = (nextLoading) => { loading = nextLoading; root.dataset.state = nextLoading ? 'loading' : 'default'; sendButton.toggleAttribute('aria-busy', nextLoading); if (nextLoading) sendButton.textContent = '回答中'; renderUsage(); }; const setOpen = (isOpen) => { panel.hidden = !isOpen; launcher.hidden = isOpen; launcher.setAttribute('aria-expanded', String(isOpen)); if (isOpen) { window.requestAnimationFrame(() => input.focus({ preventScroll: true })); } else { panel.classList.remove('is-expanded'); expandButton?.setAttribute('aria-pressed', 'false'); expandButton?.setAttribute('aria-label', '表示を拡大する'); } }; const addMessage = (kind, text) => { const item = document.createElement('li'); item.className = 'p-subsidy-ai-message p-subsidy-ai-message--' + kind; if (kind === 'assistant') { const avatar = document.createElement('span'); avatar.className = 'p-subsidy-ai-message__avatar'; avatar.setAttribute('aria-hidden', 'true'); avatar.textContent = 'AI'; item.append(avatar); } const bubble = document.createElement(kind === 'assistant' ? 'div' : 'p'); if (kind === 'assistant') { bubble.className = 'p-subsidy-ai-markdown'; bubble.innerHTML = subsidyRenderMarkdown(text); } else { bubble.textContent = text; } item.append(bubble); log.append(item); window.requestAnimationFrame(() => { if (messages) messages.scrollTop = messages.scrollHeight; }); return bubble; }; const addFeedback = (bubble, logId) => { if (!Number.isInteger(logId) || logId <= 0) return; const controls = document.createElement('div'); controls.className = 'p-subsidy-ai-feedback'; controls.setAttribute('aria-label', '回答の評価'); const status = document.createElement('span'); status.setAttribute('role', 'status'); let selected = ''; const buttons = ['good', 'bad'].map((value) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = value === 'good' ? '役に立った' : '役に立たなかった'; button.setAttribute('aria-pressed', 'false'); button.addEventListener('click', async () => { const feedback = selected === value ? '' : value; buttons.forEach((item) => { item.disabled = true; }); status.textContent = '保存中…'; try { const result = await api.request('feedback', { log_id: logId, feedback }, AbortSignal.timeout(10000)); if (!result.response.ok || result.payload.status !== 'ok') throw new Error('save failed'); selected = feedback; buttons.forEach((item, index) => item.setAttribute('aria-pressed', String(selected === ['good', 'bad'][index]))); status.textContent = feedback ? '評価を保存しました' : '評価を取り消しました'; } catch { status.textContent = '評価を保存できませんでした。再度お試しください。'; } finally { buttons.forEach((item) => { item.disabled = false; }); } }); controls.append(button); return button; }); controls.append(status); bubble.append(controls); }; const refreshUsage = async () => { if (!usageEndpoint || usageRequested || loading) return; usageRequested = true; const revision = usageRevision; try { let result = await api.request('usage', undefined, AbortSignal.timeout(10000)); if (result.payload.code === 'session_expired') { result = await api.request('usage', undefined, AbortSignal.timeout(10000)); } if (!result.response.ok) return; const payload = result.payload; if (!loading && revision === usageRevision) { applyUsagePayload(payload); renderUsage(); } } catch { // Cached display state is retained when the usage endpoint is unavailable. } finally { usageRequested = false; } }; launcher.addEventListener('click', () => { setOpen(true); refreshUsage(); }); closeButton?.addEventListener('click', () => { setOpen(false); launcher.focus({ preventScroll: true }); }); expandButton?.addEventListener('click', () => { const expanded = panel.classList.toggle('is-expanded'); expandButton.setAttribute('aria-pressed', String(expanded)); expandButton.setAttribute('aria-label', expanded ? '元の表示サイズに戻す' : '表示を拡大する'); }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || panel.hidden) return; setOpen(false); launcher.focus({ preventScroll: true }); }); input.addEventListener('input', () => { if (characterCount) characterCount.textContent = String(input.value.length); if (input.hasAttribute('aria-invalid') || root.dataset.state === 'success') clearError(); }); options.forEach((option) => option.addEventListener('change', clearError)); form.addEventListener('submit', async (event) => { event.preventDefault(); if (loading || authLoading || usage.dailyLocked || (usage.trialLimited && usage.locked)) return; const question = input.value.trim(); if (!question) { input.setAttribute('aria-invalid', 'true'); showError('質問が空です。補助金について知りたい内容を入力してください。'); input.focus({ preventScroll: true }); return; } if (Array.from(question).length > 1000) { showError('質問は1000文字以内で入力してください。'); return; } if (!apiEndpoint) { showError('現在、回答機能を準備中です。時間をおいて再度お試しください。'); return; } clearError(); usageRevision += 1; setLoading(true); const selectedSubsidies = options.filter((option) => option.checked).map((option) => option.value); const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 110000); const questionLabel = selectedSubsidies.length ? '【対象:' + selectedSubsidies.join('、') + '】' + question : question; addMessage('user', questionLabel); const answerBubble = addMessage('assistant', '回答を作成しています…'); answerBubble.setAttribute('aria-busy', 'true'); answerBubble.dataset.streaming = 'true'; let partialAnswer = false; let answered = false; const updateAnswer = (text) => { const follow = !messages || messages.scrollHeight - messages.scrollTop - messages.clientHeight < 100; answerBubble.innerHTML = subsidyRenderMarkdown(text); if (follow) window.requestAnimationFrame(() => { if (messages) messages.scrollTop = messages.scrollHeight; }); }; try { const { response, payload } = await api.request('chat', { question, targetSubsidies: selectedSubsidies, stream: true, }, controller.signal, (type, eventPayload) => { if (type === 'status' && !partialAnswer && typeof eventPayload.label === 'string') { answerBubble.textContent = eventPayload.label; } if (type === 'delta' && typeof eventPayload.text === 'string') { partialAnswer = true; updateAnswer(eventPayload.text); } }); if (!response.ok) { if (payload.code === 'session_expired') { usage.trialLimited = true; accessOpen = true; } if (payload.dailyLocked === true) applyUsagePayload(payload); if (payload.locked === true) { applyUsagePayload(payload); renderUsage(); answerBubble.textContent = payload.message || '一般利用の上限に達しました。'; return; } const retryAfter = Number(payload.retryAfter || response.headers.get('Retry-After')); const retryHint = response.status === 429 && retryAfter > 0 ? '(約' + Math.ceil(retryAfter / 60) + '分後に再試行できます)' : ''; const responseMessage = typeof payload.message === 'string' ? payload.message + retryHint : ''; throw new Error(responseMessage || '現在、回答を取得できません。時間をおいて再度お試しください。'); } if (typeof payload.answer !== 'string' || !payload.answer.trim()) { throw new Error('回答データを確認できませんでした。時間をおいて再度お試しください。'); } updateAnswer(payload.answer.trim()); addFeedback(answerBubble, payload.log_id); answered = true; input.value = ''; if (characterCount) characterCount.textContent = '0'; applyUsagePayload(payload, { countSuccessfulAnswer: true }); root.dataset.state = 'success'; } catch (requestError) { const message = requestError?.name === 'AbortError' ? '回答に時間がかかっています。時間をおいて再度お試しください。' : requestError?.message || '現在、回答を取得できません。時間をおいて再度お試しください。'; if (requestError?.payload) applyUsagePayload(requestError.payload); if (!partialAnswer) answerBubble.textContent = message; else { const interrupted = document.createElement('p'); interrupted.className = 'p-subsidy-ai-stream-error'; interrupted.textContent = '回答が途中で終了しました。' + message; answerBubble.append(interrupted); } showError(message); } finally { answerBubble.removeAttribute('aria-busy'); answerBubble.dataset.streaming = 'false'; answerBubble.dataset.complete = String(answered); window.clearTimeout(timeoutId); loading = false; sendButton.removeAttribute('aria-busy'); renderUsage(); } }); authToggle?.addEventListener('click', () => { accessOpen = true; renderUsage(); passwordInput?.focus({ preventScroll: true }); }); authForm?.addEventListener('submit', async (event) => { event.preventDefault(); if (loading || authLoading || !passwordInput || !authButton || !authError) return; authError.hidden = true; if (!passwordInput.value) { authError.textContent = '制限解除パスワードを入力してください。'; authError.hidden = false; return; } authLoading = true; authButton.disabled = true; authButton.textContent = '認証中'; renderUsage(); try { const { response, payload } = await api.request('auth', { password: passwordInput.value }, AbortSignal.timeout(15000)); if (!response.ok || payload.authenticated !== true) { throw new Error(payload.message || '認証できませんでした。時間をおいて再度お試しください。'); } usageRevision += 1; applyUsagePayload(payload); accessOpen = false; clearError(); } catch (failure) { authError.textContent = (failure?.message || '認証できませんでした。').replace(/(?:共通)?パスワード/g, '制限解除パスワード').replace(/(?:制限解除){2,}/g, '制限解除'); authError.hidden = false; } finally { passwordInput.value = ''; authLoading = false; authButton.disabled = false; authButton.textContent = '解除する'; renderUsage(); } }); renderUsage(); }); } function initSiteV7(){ if(window.__siteV7Ready)return; window.__siteV7Ready=true; initBackToTop();initAutoHideHeader();initMobileNav();initSubsidyAiAssistant(); } if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",initSiteV7,{once:true});else initSiteV7(); function initNewsTabs() { const tabs = document.querySelectorAll('.p-news-tabs__tab'); const items = document.querySelectorAll('.p-news-tabs__grid .c-news-list__item'); if (!tabs.length || !items.length) return; tabs.forEach((tab) => { tab.addEventListener('click', () => { tabs.forEach((t) => { t.classList.remove('is-active'); t.setAttribute('aria-selected', 'false'); }); tab.classList.add('is-active'); tab.setAttribute('aria-selected', 'true'); const filter = tab.dataset.filter; items.forEach((item) => { item.hidden = filter !== 'all' && item.dataset.category !== filter; }); }); }); } function initNewsPagination() { const list = document.querySelector('[data-news-list]'); if (!list) return; const items = Array.from(list.querySelectorAll('[data-news-item]')); const buttons = Array.from(document.querySelectorAll('[data-news-page]')); const previous = document.querySelector('[data-news-previous]'); const next = document.querySelector('[data-news-next]'); const status = document.querySelector('[data-news-status]'); const perPage = Number(list.dataset.newsPerPage || 12); const maxPage = Math.max(1, Math.ceil(items.length / perPage)); const queryPage = Number(new URLSearchParams(window.location.search).get('page') || 1); let current = Math.min(Math.max(queryPage, 1), maxPage); const apply = (page) => { current = Math.min(Math.max(page, 1), maxPage); items.forEach((item, index) => { item.hidden = index < (current - 1) * perPage || index >= current * perPage; }); buttons.forEach((button) => { const active = Number(button.dataset.newsPage) === current; button.classList.toggle('is-active', active); if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current'); }); if (previous) { previous.disabled = current === 1; previous.setAttribute('aria-disabled', String(previous.disabled)); } if (next) { next.disabled = current === maxPage; next.setAttribute('aria-disabled', String(next.disabled)); } if (status) { const first = (current - 1) * perPage + 1; const last = Math.min(current * perPage, items.length); status.textContent = `${first}–${last}件 / 全${items.length}件`; } const url = new URL(window.location.href); if (current === 1) url.searchParams.delete('page'); else url.searchParams.set('page', String(current)); window.history.replaceState({}, '', url); }; buttons.forEach((button) => { button.addEventListener('click', () => apply(Number(button.dataset.newsPage))); }); if (previous) previous.addEventListener('click', () => apply(current - 1)); if (next) next.addEventListener('click', () => apply(current + 1)); apply(current); } document.addEventListener("DOMContentLoaded",()=>{initNewsTabs();initNewsPagination();});