const { useState, useRef, useEffect } = React;
const { createRoot } = ReactDOM;
// 統合オプション:チャットの目的と質問の起点をまとめた構造
const GIIN_CHAT_OPTIONS = [
{ value: '', label: '選択してください', purpose: '', origin: '', example: '', group: null },
// 一般質問
{ value: 'ippan_shitsumon:市民からの困りごとの声', label: '市民からの困りごとの声', purpose: 'ippan_shitsumon', origin: '市民からの困りごとの声', example: '地域限定ポイント「〇〇ペイ」が配られたが、徒歩圏内に使える店舗がない', group: '一般質問' },
{ value: 'ippan_shitsumon:公約・マニフェストの実現', label: '公約・マニフェストの実現', purpose: 'ippan_shitsumon', origin: '公約・マニフェストの実現', example: '選挙時に掲げた「学校給食の無償化」を進めたい', group: '一般質問' },
{ value: 'ippan_shitsumon:既存施策の進捗確認・改善', label: '既存施策の進捗確認・改善', purpose: 'ippan_shitsumon', origin: '既存施策の進捗確認・改善', example: '3年前に始まった空き家バンク制度の利用状況と改善点を問いたい', group: '一般質問' },
{ value: 'ippan_shitsumon:国・県の新制度への対応', label: '国・県の新制度への対応', purpose: 'ippan_shitsumon', origin: '国・県の新制度への対応', example: 'こども家庭庁の新しい補助金制度を活用できないか', group: '一般質問' },
{ value: 'ippan_shitsumon:統計データ・指標の変化', label: '統計データ・指標の変化', purpose: 'ippan_shitsumon', origin: '統計データ・指標の変化', example: '当市の出生率が県内ワーストになった原因と対策を問いたい', group: '一般質問' },
{ value: 'ippan_shitsumon:予算・決算への疑問', label: '予算・決算への疑問', purpose: 'ippan_shitsumon', origin: '予算・決算への疑問', example: '○○事業の費用対効果が低いのではないか', group: '一般質問' },
{ value: 'ippan_shitsumon:報道で取り上げられた問題', label: '報道で取り上げられた問題', purpose: 'ippan_shitsumon', origin: '報道で取り上げられた問題', example: '市内の介護施設での虐待報道を受けて、市の対応を確認したい', group: '一般質問' },
{ value: 'ippan_shitsumon:視察・調査で得た知見', label: '視察・調査で得た知見', purpose: 'ippan_shitsumon', origin: '視察・調査で得た知見', example: '先日視察した○○市の取り組みを参考に提案したい', group: '一般質問' },
{ value: 'ippan_shitsumon:災害・緊急事態への備え', label: '災害・緊急事態への備え', purpose: 'ippan_shitsumon', origin: '災害・緊急事態への備え', example: '近年の豪雨災害を踏まえ、避難所の収容能力を確認したい', group: '一般質問' },
// 調査
{ value: 'chousa:資料調査', label: '資料調査', purpose: 'chousa', origin: '資料調査', example: '〇〇についての資料・行政文書を調べたい', group: '調査' },
{ value: 'chousa:過去の言及のリストアップ', label: '過去の言及のリストアップ', purpose: 'chousa', origin: '過去の言及のリストアップ', example: '過去の委員会・本会議での〇〇に関する言及を調べたい', group: '調査' },
// 雑談
{ value: 'zatsudan', label: '雑談', purpose: 'zatsudan', origin: '', example: '', group: '雑談' },
];
const sourceBadgeConfig = {
'事例': { color: 'info', icon: 'bi-lightbulb' },
'議事録': { color: 'warning', icon: 'bi-file-text' },
'資料': { color: 'success', icon: 'bi-file-earmark' },
'Web検索結果': { color: 'primary', icon: 'bi-globe' },
'関心事項': { color: 'success', icon: 'bi-star' }
};
const SourcePreview = ({ source }) => {
if (!source) return null;
const badge = sourceBadgeConfig[source.badge] || { color: 'secondary', icon: 'bi-file' };
// url が無い(デモの資料など閲覧ページが存在しない)場合はリンクにせず div で表示する
const Wrapper = source.url ? 'a' : 'div';
const linkProps = source.url ? { href: source.url, target: '_blank', rel: 'noopener noreferrer' } : {};
return (
このコンテンツを元にチャット
{source.badge}
{source.shichoson_name && (
{source.shichoson_name}
)}
{source.title}
{source.excerpt && (
{source.excerpt}
{source.url && }
)}
);
};
// 対象議員の表示・検索・選択UI(市区町村名→議員名で検索して変更できる)
const TargetGiinSelector = ({ targetGiin, setTargetGiin, disabled, apiBase }) => {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [shichosonName, setShichosonName] = useState('');
const [giinName, setGiinName] = useState('');
const [results, setResults] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [searchError, setSearchError] = useState('');
const handleSearch = async () => {
if (!shichosonName.trim() || isLoading) return;
setIsLoading(true);
setSearchError('');
try {
const query = new URLSearchParams({ shichoson_name: shichosonName, name: giinName });
const response = await fetch(`${apiBase}/giins?${query}`);
if (response.ok) {
const data = await response.json();
setResults(data.giins);
} else {
setSearchError('検索に失敗しました');
}
} catch (error) {
console.error('Error:', error);
setSearchError('ネットワークエラーが発生しました');
} finally {
setIsLoading(false);
}
};
// 外側のformをsubmitさせずにEnterキーで検索する
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
};
const handleSelect = (giin) => {
setTargetGiin(giin);
setIsSearchOpen(false);
setResults(null);
};
return (
{targetGiin ? (
{targetGiin.shichoson_name} {targetGiin.name}
{targetGiin.kaiha && ({targetGiin.kaiha})}
) : (
未選択
)}
{isSearchOpen && (
{searchError && (
{searchError}
)}
{results && (
results.length === 0 ? (
議員が見つかりませんでした
) : (
{results.map((giin) => (
))}
)
)}
)}
);
};
const NewGiinChat = ({ bodyMaxLength, source, defaultTargetGiin, apiBase }) => {
const [content, setContent] = useState('');
const [selected, setSelected] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [searchEnableds, setSearchEnableds] = useState(['jirei', 'gijiroku', 'google', 'shiryo']);
const [targetGiin, setTargetGiin] = useState(defaultTargetGiin);
const isVoiceRecognitionEnabledRef = useRef(false);
const isSubmittingRef = useRef(false);
// 選択された情報を取得
const selectedData = GIIN_CHAT_OPTIONS.find(o => o.value === selected);
const purpose = selectedData?.purpose || '';
const origin = selectedData?.origin || '';
const example = selectedData?.example || '';
// 音声認識カスタムフックを使用
const {
isVoiceRecognitionEnabled,
setIsVoiceRecognitionEnabled,
interimTranscript
} = window.useVoiceRecognition({
onFinalTranscript: (transcript) => {
// 確定した音声をcontentに追加
setContent((prev) => prev + transcript);
},
onError: (errorMessage) => {
setError(errorMessage);
}
});
// refを最新の状態に同期
useEffect(() => {
isVoiceRecognitionEnabledRef.current = isVoiceRecognitionEnabled;
isSubmittingRef.current = isSubmitting;
}, [isVoiceRecognitionEnabled, isSubmitting]);
// プログラム的にメッセージを送信する関数
const sendMessageProgrammatically = async (messageContent) => {
if (!messageContent.trim() || isSubmittingRef.current) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
setError('');
setContent('');
try {
const body = { content: messageContent, search_enableds: searchEnableds, purpose: purpose };
if (targetGiin) body.target_giin_id = targetGiin.id;
if (source) {
if (source.type === 'jirei') body.jirei_id = source.id;
else if (source.type === 'gijiroku') { body.gijiroku_id = source.id; body.keyword = source.keyword; }
else if (source.type === 'giin_shiryou') body.giin_shiryou_id = source.id;
else if (source.type === 'giin_interest') body.giin_interest_id = source.id;
}
const response = await fetch(`${apiBase}/giin_chats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify(body)
});
if (response.ok) {
const data = await response.json();
// 音声認識がONの場合、遷移先でも継続するようにsessionStorageに保存
if (isVoiceRecognitionEnabledRef.current) {
sessionStorage.setItem('voiceRecognitionEnabled', 'true');
}
window.location.href = data.redirect_url;
} else {
const errorData = await response.json();
// 利用上限に達した場合
if (response.status === 429) {
setError(errorData.error || '今月の利用上限に達しました。来月までお待ちください。');
// プロフィール未設定の場合はリダイレクト
} else if (errorData.info && errorData.redirect_url) {
setError(errorData.info);
setTimeout(() => {
window.location.href = errorData.redirect_url;
}, 1000);
} else {
setError(errorData.error || 'スレッドの作成に失敗しました');
}
isSubmittingRef.current = false;
setIsSubmitting(false);
setContent(messageContent);
}
} catch (error) {
console.error('Error:', error);
setError('ネットワークエラーが発生しました');
isSubmittingRef.current = false;
setIsSubmitting(false);
setContent(messageContent);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!content.trim() || isSubmitting) return;
// 起点が選択されている場合、プロンプトに起点を含める
let messageContent = content;
if (origin) {
messageContent = `【起点:${origin}】\n${content}`;
}
await sendMessageProgrammatically(messageContent);
};
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 'Enter') {
handleSubmit(e);
}
};
return (
新しいチャットを開始
{error && (
{error}
)}
);
};
function initializeApp() {
const container = document.getElementById('new-giin-chat-root');
if (container) {
try {
const apiBase = container.dataset.apiBase;
const bodyMaxLength = parseInt(container.dataset.bodyMaxLength, 10);
const source = container.dataset.source ? JSON.parse(container.dataset.source) : null;
const defaultTargetGiin = container.dataset.targetGiin ? JSON.parse(container.dataset.targetGiin) : null;
const root = createRoot(container);
root.render();
} catch (error) {
console.error('Error rendering NewGiinChat component:', error);
}
} else {
console.error('new-giin-chat-root element not found!');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}