/**
* @file Geminiチャットボットのフロントエンド用JavaScript
* @description UI操作、API通信、状態管理など、すべてのフロントエンドロジックを管理します。
* @version 4.1.0
*/
document.addEventListener('DOMContentLoaded', () => {
// --- 1. UI要素の取得 ---
const ui = {
sidebar: document.getElementById('sidebar'),
menuButton: document.getElementById('menu-button'),
chatWindow: document.getElementById('chat-window'),
chatForm: document.getElementById('chat-form'),
messageInput: document.getElementById('message-input'),
sendButton: document.getElementById('send-button'),
sessionList: document.getElementById('session-list'),
newChatButton: document.getElementById('new-chat-button'),
toggleSettingsButton: document.getElementById('toggle-settings-button'),
settingsPanel: document.getElementById('settings-panel'),
personalityInput: document.getElementById('personality-input'),
savePersonalityButton: document.getElementById('save-personality-button'),
fileInput: document.getElementById('file-input'),
filePreviewContainer: document.getElementById('file-preview-container'),
fileNameSpan: document.getElementById('file-name'),
removeFileButton: document.getElementById('remove-file-button'),
chatTitle: document.getElementById('chat-title'),
};
// --- 2. 状態管理変数 ---
let state = {
currentSessionId: localStorage.getItem('active_chat_session_id') || null,
attachedFile: null,
isLoading: false,
};
// --- 3. API通信モジュール ---
const api = {
getSessions: () => fetch('/api/sessions').then(res => res.json()),
getSession: (id) => fetch(`/api/session/${id}`).then(res => res.json()),
deleteSession: (id) => fetch(`/api/session/${id}`, { method: 'DELETE' }),
renameSession: (id, title) => fetch(`/api/session/${id}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }),
pinSession: (id) => fetch(`/api/session/${id}/pin`, { method: 'POST' }),
setPersonality: (personality) => fetch('/api/set_personality', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ personality }) }),
postChat: (formData) => fetch('/api/chat', { method: 'POST', body: formData }).then(res => res.json()),
};
// --- 4. UI描画モジュール ---
const render = {
sessions: (sessions) => {
ui.sessionList.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = 'session-item';
item.dataset.sessionId = session.id;
item.innerHTML = `
${session.title}
`;
ui.sessionList.appendChild(item);
});
render.activeSessionHighlight();
},
chatHistory: (history) => {
ui.chatWindow.innerHTML = '';
history.forEach(msg => {
const content = {
text: msg.parts.filter(p => !p.startsWith('/uploads/')).join(' '),
imageUrl: msg.parts.find(p => p.startsWith('/uploads/'))
};
render.message({ sender: msg.role === 'user' ? 'user' : 'bot', ...content });
});
},
message: ({ sender, text, imageFile, imageUrl }) => {
const el = document.createElement('div');
el.className = `chat-message ${sender}-message`;
const imageSrc = imageUrl || (imageFile ? URL.createObjectURL(imageFile) : null);
if (imageSrc) {
const img = document.createElement('img');
img.src = imageSrc;
img.className = 'message-image';
el.prepend(img);
}
if (text) {
const textEl = document.createElement('div');
textEl.innerHTML = DOMPurify.sanitize(marked.parse(text));
el.appendChild(textEl);
}
ui.chatWindow.appendChild(el);
ui.chatWindow.scrollTop = ui.chatWindow.scrollHeight;
return el;
},
activeSessionHighlight: () => {
document.querySelectorAll('.session-item').forEach(item => {
item.classList.toggle('active', item.dataset.sessionId === state.currentSessionId);
});
},
filePreview: () => {
if (state.attachedFile) {
ui.fileNameSpan.textContent = state.attachedFile.name;
ui.filePreviewContainer.classList.remove('hidden');
} else {
ui.filePreviewContainer.classList.add('hidden');
ui.fileInput.value = '';
}
},
};
// --- 5. アプリケーションロジック ---
const actions = {
startNewChat: async () => {
state.currentSessionId = `session-${Date.now()}`;
localStorage.setItem('active_chat_session_id', state.currentSessionId);
ui.chatWindow.innerHTML = '';
ui.chatTitle.textContent = "新規チャット";
render.message({ sender: 'bot', text: 'こんにちは!新しい会話を始めましょう。' });
await actions.updateSessions();
},
loadSession: async (sessionId) => {
if (!sessionId) return;
ui.chatTitle.textContent = "読み込み中...";
const data = await api.getSession(sessionId);
if (data && data.history) {
ui.chatTitle.textContent = data.title || "新規チャット";
render.chatHistory(data.history);
} else { await actions.startNewChat(); }
},
updateSessions: async () => render.sessions(await api.getSessions()),
handleFile: (file) => {
if (file && file.type.startsWith('image/')) {
state.attachedFile = file;
render.filePreview();
} else { alert('画像ファイル(PNG, JPGなど)のみ添付できます。'); }
},
setFormEnabled: (enabled) => {
state.isLoading = !enabled;
ui.messageInput.disabled = !enabled;
ui.sendButton.disabled = !enabled;
}
};
// --- 6. イベントリスナー ---
ui.menuButton.addEventListener('click', () => ui.sidebar.classList.toggle('visible'));
ui.newChatButton.addEventListener('click', () => { actions.startNewChat(); ui.sidebar.classList.remove('visible'); });
ui.toggleSettingsButton.addEventListener('click', () => ui.settingsPanel.classList.toggle('hidden'));
ui.removeFileButton.addEventListener('click', () => { state.attachedFile = null; render.filePreview(); });
ui.fileInput.addEventListener('change', () => ui.fileInput.files.length > 0 && actions.handleFile(ui.fileInput.files[0]));
ui.savePersonalityButton.addEventListener('click', async () => {
await api.setPersonality(ui.personalityInput.value);
alert('人格を更新しました。');
ui.settingsPanel.classList.add('hidden');
});
ui.sessionList.addEventListener('click', async (e) => {
const item = e.target.closest('.session-item');
if (!item) return;
const sessionId = item.dataset.sessionId;
const action = e.target.closest('.session-action-button');
if (action) {
e.stopPropagation();
if (action.classList.contains('delete-button')) {
if (confirm(`「${item.querySelector('.session-title').textContent}」を削除しますか?`)) {
await api.deleteSession(sessionId);
if (state.currentSessionId === sessionId) {
state.currentSessionId = null; localStorage.removeItem('active_chat_session_id');
}
await actions.updateSessions();
if (!state.currentSessionId) await actions.startNewChat();
}
} else if (action.classList.contains('rename-button')) {
const newTitle = prompt("新しい名前:", item.querySelector('.session-title').textContent);
if (newTitle && newTitle.trim()) await api.renameSession(sessionId, newTitle.trim());
await actions.updateSessions();
} else if (action.classList.contains('pin-button')) {
await api.pinSession(sessionId); await actions.updateSessions();
}
} else {
state.currentSessionId = sessionId; localStorage.setItem('active_chat_session_id', state.currentSessionId);
await actions.loadSession(sessionId); render.activeSessionHighlight();
ui.sidebar.classList.remove('visible');
}
});
document.addEventListener('paste', e => {
const file = Array.from(e.clipboardData.items).find(item => item.type.startsWith('image/'))?.getAsFile();
if (file) { actions.handleFile(file); e.preventDefault(); }
});
ui.chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const userMessage = ui.messageInput.value.trim();
if ((!userMessage && !state.attachedFile) || !state.currentSessionId || state.isLoading) return;
actions.setFormEnabled(false);
render.message({ sender: 'user', text: userMessage, imageFile: state.attachedFile });
const formData = new FormData();
formData.append('session_id', state.currentSessionId);
formData.append('message', userMessage);
if (state.attachedFile) formData.append('file', state.attachedFile);
state.attachedFile = null; render.filePreview(); ui.messageInput.value = '';
const loadingMessage = render.message({ sender: 'bot', text: '考え中...' });
try {
const data = await api.postChat(formData);
ui.chatWindow.removeChild(loadingMessage);
render.message({ sender: 'bot', text: data.reply || data.error });
if (data.title) ui.chatTitle.textContent = data.title;
await actions.updateSessions();
} catch (error) {
loadingMessage.textContent = "エラー: メッセージの送信に失敗しました。";
} finally {
actions.setFormEnabled(true);
}
});
// --- 7. 初期化 ---
const initialize = async () => {
await actions.updateSessions();
const firstSession = ui.sessionList.querySelector('.session-item');
let sessionToLoad = state.currentSessionId;
if (sessionToLoad && !document.querySelector(`.session-item[data-session-id="${sessionToLoad}"]`)) {
sessionToLoad = null;
}
if (!sessionToLoad && firstSession) {
sessionToLoad = firstSession.dataset.sessionId;
}
if (sessionToLoad) {
state.currentSessionId = sessionToLoad;
localStorage.setItem('active_chat_session_id', state.currentSessionId);
await actions.loadSession(state.currentSessionId);
} else {
await actions.startNewChat();
}
ui.sidebar.classList.remove('visible');
};
initialize();
});