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 && (
setShichosonName(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} />
setGiinName(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} />
{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}
)}
{example && (
入力例:{example}
)}
{[ { value: 'jirei', label: '事例検索' }, { value: 'gijiroku', label: '議事録検索' }, { value: 'shiryo', label: 'マイ資料' }, { value: 'google', label: 'Web検索' }, ].map((source) => (
{ if (e.target.checked) { setSearchEnableds([...searchEnableds, source.value]); } else { setSearchEnableds(searchEnableds.filter(v => v !== source.value)); } }} disabled={isSubmitting} />
))}