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("")}${listType}>`);
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(``);
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();});