525 lines
20 KiB
HTML
525 lines
20 KiB
HTML
<!doctype html>
|
|
<html lang="ja">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>AIbow</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #131314;
|
|
--surface: #1e1f20;
|
|
--surface-2: #282a2c;
|
|
--surface-3: #333537;
|
|
--outline: #444746;
|
|
--on-surface: #e3e3e3;
|
|
--on-surface-dim: #9aa0a6;
|
|
--primary: #a8c7fa;
|
|
--on-primary: #062e6f;
|
|
--secondary: #7fd8b0;
|
|
--elevation-1: 0 1px 2px rgba(0,0,0,.5), 0 1px 3px 1px rgba(0,0,0,.3);
|
|
--elevation-2: 0 1px 2px rgba(0,0,0,.5), 0 2px 6px 2px rgba(0,0,0,.35);
|
|
--elevation-3: 0 4px 8px rgba(0,0,0,.4), 0 6px 20px 4px rgba(0,0,0,.4);
|
|
--radius-lg: 24px;
|
|
--radius-md: 16px;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Roboto", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
|
|
background: var(--bg);
|
|
color: var(--on-surface);
|
|
}
|
|
.material-symbols-outlined {
|
|
font-family: 'Material Symbols Outlined';
|
|
font-weight: normal; font-style: normal;
|
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
font-size: 20px; line-height: 1; vertical-align: middle; user-select: none;
|
|
display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr;
|
|
}
|
|
.app { display: grid; grid-template-columns: 1fr 380px; height: 100vh; }
|
|
.videoPane { display: flex; flex-direction: column; min-width: 0; padding: 12px; gap: 10px; position: relative; }
|
|
|
|
.topBar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; margin-right: auto; }
|
|
.brand .material-symbols-outlined { font-size: 22px; color: var(--primary); }
|
|
|
|
.mdSelect {
|
|
appearance: none; -webkit-appearance: none;
|
|
background: var(--surface-2); color: var(--on-surface);
|
|
border: 1px solid var(--outline); border-radius: 20px;
|
|
padding: 8px 32px 8px 14px; font-family: Roboto; font-size: 13px; cursor: pointer;
|
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239aa0a6'><path d='M7 10l5 5 5-5z'/></svg>");
|
|
background-repeat: no-repeat; background-position: right 8px center; background-size: 18px;
|
|
}
|
|
.mdSelect:focus { outline: none; border-color: var(--primary); }
|
|
.selectWrap { display: flex; align-items: center; gap: 4px; }
|
|
.selectWrap .material-symbols-outlined { font-size: 16px; color: var(--on-surface-dim); }
|
|
.selectWrap .material-symbols-outlined.unlocked { color: #ffd479; }
|
|
|
|
.pillInput {
|
|
display: flex; align-items: center; gap: 4px;
|
|
background: var(--surface-2); border: 1px solid var(--outline);
|
|
border-radius: var(--radius-lg); padding: 4px 4px 4px 18px;
|
|
}
|
|
.pillInput:focus-within { border-color: var(--primary); }
|
|
.pillInput input {
|
|
flex: 1; border: none; background: transparent; color: var(--on-surface);
|
|
font-family: Roboto; font-size: 14px; outline: none; padding: 10px 0; min-width: 0;
|
|
}
|
|
.pillInput input::placeholder { color: var(--on-surface-dim); }
|
|
.iconBtn {
|
|
width: 40px; height: 40px; border-radius: 50%; border: none; flex-shrink: 0;
|
|
background: var(--primary); color: var(--on-primary); cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.iconBtn:hover { filter: brightness(1.08); }
|
|
|
|
.ytRow { display: flex; gap: 10px; padding: 8px; border-radius: 10px; cursor: pointer; align-items: flex-start; white-space: normal; }
|
|
.ytRow:hover { background: var(--surface-3); }
|
|
.ytRow:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; background: var(--surface-3); }
|
|
.iconBtn:focus-visible, .mdSelect:focus-visible, .searchStage .searchHeader button:focus-visible {
|
|
outline: 2px solid var(--primary); outline-offset: 2px;
|
|
}
|
|
.ytRow img { width: 120px; aspect-ratio: 16/9; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
|
|
.ytRow .meta { min-width: 0; }
|
|
.ytRow .title { font-size: 13px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; }
|
|
.ytRow .channel { font-size: 11.5px; color: var(--on-surface-dim); margin-top: 4px; white-space: normal; }
|
|
|
|
.inlineSearch { margin-top: 6px; background: var(--surface-2); border-radius: var(--radius-md); padding: 4px; white-space: normal; }
|
|
.inlineSearch .ytRow img { width: 90px; }
|
|
.inlineSearch .ytRow .title { font-size: 12px; }
|
|
|
|
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; position: relative; }
|
|
.playerWrap {
|
|
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
|
|
background: #000; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-2);
|
|
transition: width .25s ease, height .25s ease, right .25s ease, bottom .25s ease, box-shadow .25s ease;
|
|
}
|
|
.playerWrap.pip {
|
|
position: fixed; right: 20px; bottom: 20px; width: 260px; height: 146px; max-width: none;
|
|
z-index: 40; box-shadow: var(--elevation-3); border: 1px solid var(--outline); cursor: pointer;
|
|
}
|
|
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
|
|
.searchStage {
|
|
display: none; flex-direction: column; width: 100%; height: 100%;
|
|
background: var(--surface-2); border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-1);
|
|
}
|
|
.searchStage.show { display: flex; }
|
|
.searchStage .searchHeader {
|
|
display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--outline); flex-shrink: 0;
|
|
}
|
|
.searchStage .searchHeader button {
|
|
background: none; border: none; color: var(--on-surface); cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center; border-radius: 50%; width: 32px; height: 32px;
|
|
}
|
|
.searchStage .searchHeader button:hover { background: var(--surface-3); }
|
|
.searchStage .searchHeader .label { font-size: 13px; color: var(--on-surface-dim); }
|
|
.searchStage .searchList { flex: 1; overflow-y: auto; padding: 8px; }
|
|
|
|
.chatPane {
|
|
display: flex; flex-direction: column; min-width: 0;
|
|
background: var(--surface); border-left: 1px solid var(--outline);
|
|
}
|
|
.chatHeader { padding: 14px 16px; border-bottom: 1px solid var(--outline); display: flex; flex-direction: column; gap: 8px; }
|
|
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
|
.chatHeader .name .material-symbols-outlined { font-size: 20px; color: var(--primary); }
|
|
.selectorRow { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
|
|
.unlockRow { display: none; gap: 6px; }
|
|
.unlockRow.show { display: flex; }
|
|
.unlockRow input {
|
|
flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid var(--outline);
|
|
background: var(--surface-2); color: var(--on-surface); font-size: 12px; outline: none;
|
|
}
|
|
.unlockRow button {
|
|
padding: 8px 14px; border-radius: 999px; border: none; background: var(--primary);
|
|
color: var(--on-primary); font-size: 12px; cursor: pointer; font-weight: 500;
|
|
}
|
|
|
|
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
|
|
.bubble { padding: 10px 14px; border-radius: var(--radius-md); max-width: 92%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
|
|
.bubble.user { align-self: flex-end; background: var(--primary); color: var(--on-primary); border-bottom-right-radius: 4px; }
|
|
.bubble.model { align-self: flex-start; background: var(--surface-3); border-bottom-left-radius: 4px; max-width: 100%; }
|
|
.bubble.status { display: flex; align-items: center; gap: 8px; }
|
|
.bubble .statusText { font-size: 13px; color: var(--on-surface-dim); }
|
|
.bubble .dots { display: inline-flex; gap: 3px; }
|
|
.bubble .dots .dot {
|
|
width: 6px; height: 6px; border-radius: 50%; background: var(--on-surface-dim);
|
|
animation: dotBlink 1.2s infinite ease-in-out;
|
|
}
|
|
.bubble .dots .dot:nth-child(2) { animation-delay: .2s; }
|
|
.bubble .dots .dot:nth-child(3) { animation-delay: .4s; }
|
|
@keyframes dotBlink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
|
|
|
|
#chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
|
|
|
|
@media (max-width: 900px) {
|
|
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
|
|
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
|
|
.playerWrap.pip { width: 180px; height: 101px; right: 12px; bottom: 12px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="videoPane">
|
|
<div class="topBar">
|
|
<div class="brand"><span class="material-symbols-outlined">smart_toy</span>AIbow</div>
|
|
</div>
|
|
<div class="pillInput">
|
|
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
|
|
<button id="loadBtn" class="iconBtn" title="読み込み/検索"><span class="material-symbols-outlined">search</span></button>
|
|
</div>
|
|
<div class="playerStage">
|
|
<div id="searchStage" class="searchStage">
|
|
<div class="searchHeader">
|
|
<button id="searchCancelBtn" title="動画に戻る"><span class="material-symbols-outlined">arrow_back</span></button>
|
|
<span class="label">検索結果 - 動画をクリックして再生</span>
|
|
</div>
|
|
<div id="searchList" class="searchList"></div>
|
|
</div>
|
|
<div id="playerWrap" class="playerWrap"><div id="player"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chatPane">
|
|
<div class="chatHeader">
|
|
<div class="name"><span class="material-symbols-outlined">smart_toy</span>AIbow</div>
|
|
<div class="selectorRow">
|
|
<select id="personaSelect" class="mdSelect">
|
|
<option value="neutral">性格: ふつう</option>
|
|
<option value="energetic">性格: 活発</option>
|
|
<option value="downer">性格: ダウナー</option>
|
|
<option value="goofy">性格: ひょうきん</option>
|
|
</select>
|
|
<div class="selectWrap">
|
|
<select id="modelSelect" class="mdSelect">
|
|
<option value="gemma">モデル: Gemma (無料)</option>
|
|
<option value="gemini">モデル: Gemini (要コード)</option>
|
|
</select>
|
|
<span id="lockIcon" class="material-symbols-outlined">lock_open</span>
|
|
</div>
|
|
</div>
|
|
<div id="unlockRow" class="unlockRow">
|
|
<input id="unlockInput" type="text" placeholder="解放コード">
|
|
<button id="unlockBtn">解放</button>
|
|
</div>
|
|
</div>
|
|
<div id="chatLog"></div>
|
|
<form id="chatForm">
|
|
<div class="pillInput">
|
|
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
|
<button type="submit" class="iconBtn" title="送信"><span class="material-symbols-outlined">send</span></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://www.youtube.com/iframe_api"></script>
|
|
<script>
|
|
let player;
|
|
|
|
function extractVideoId(input) {
|
|
input = input.trim();
|
|
if (/^[\w-]{11}$/.test(input)) return input;
|
|
try {
|
|
const u = new URL(input);
|
|
if (u.hostname.includes('youtu.be')) return u.pathname.slice(1);
|
|
if (u.searchParams.get('v')) return u.searchParams.get('v');
|
|
const liveMatch = u.pathname.match(/\/(live|embed|shorts)\/([\w-]{11})/);
|
|
if (liveMatch) return liveMatch[2];
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
function onYouTubeIframeAPIReady() {
|
|
player = new YT.Player('player', {
|
|
height: '100%',
|
|
width: '100%',
|
|
playerVars: { autoplay: 1 },
|
|
});
|
|
}
|
|
|
|
let videoTitle = '';
|
|
let videoDescription = '';
|
|
let chatHistory = [];
|
|
let unlockCode = '';
|
|
|
|
async function loadVideoMeta(id) {
|
|
videoTitle = '';
|
|
videoDescription = '';
|
|
try {
|
|
const res = await fetch('/api/video-meta?id=' + encodeURIComponent(id));
|
|
if (!res.ok) throw new Error('meta fetch failed');
|
|
const meta = await res.json();
|
|
videoTitle = meta.title || '';
|
|
videoDescription = meta.description || '';
|
|
} catch (e) {
|
|
console.error('video meta error', e);
|
|
}
|
|
}
|
|
|
|
function setPip(active) {
|
|
document.getElementById('playerWrap').classList.toggle('pip', active);
|
|
}
|
|
|
|
function showSearchStage(items) {
|
|
const list = document.getElementById('searchList');
|
|
list.innerHTML = '';
|
|
for (const item of (items || [])) {
|
|
list.appendChild(buildYtRow(item));
|
|
}
|
|
document.getElementById('searchStage').classList.add('show');
|
|
setPip(true);
|
|
}
|
|
|
|
function exitSearchMode() {
|
|
document.getElementById('searchStage').classList.remove('show');
|
|
setPip(false);
|
|
}
|
|
|
|
document.getElementById('playerWrap').addEventListener('click', () => {
|
|
if (document.getElementById('playerWrap').classList.contains('pip')) {
|
|
exitSearchMode();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && document.getElementById('playerWrap').classList.contains('pip')) {
|
|
exitSearchMode();
|
|
}
|
|
});
|
|
|
|
document.getElementById('searchCancelBtn').addEventListener('click', exitSearchMode);
|
|
|
|
function loadVideo(id, opts) {
|
|
opts = opts || {};
|
|
const notifyAI = opts.notifyAI !== false;
|
|
if (player && player.loadVideoById) {
|
|
player.loadVideoById(id);
|
|
}
|
|
exitSearchMode();
|
|
document.getElementById('urlInput').value = id;
|
|
loadVideoMeta(id).then(() => {
|
|
if (notifyAI) {
|
|
sendChatMessage('(視聴中の動画が切り替わりました。新しい動画について一言リアクションして)', { showUserBubble: false });
|
|
}
|
|
});
|
|
}
|
|
|
|
const entityDecoder = document.createElement('textarea');
|
|
function decodeEntities(text) {
|
|
entityDecoder.innerHTML = text || '';
|
|
return entityDecoder.value;
|
|
}
|
|
|
|
function buildYtRow(item) {
|
|
const row = document.createElement('div');
|
|
row.className = 'ytRow';
|
|
row.tabIndex = 0;
|
|
row.setAttribute('role', 'button');
|
|
row.setAttribute('aria-label', decodeEntities(item.title));
|
|
const title = document.createElement('div');
|
|
title.className = 'title';
|
|
title.textContent = decodeEntities(item.title);
|
|
const channel = document.createElement('div');
|
|
channel.className = 'channel';
|
|
channel.textContent = decodeEntities(item.channelTitle);
|
|
const meta = document.createElement('div');
|
|
meta.className = 'meta';
|
|
meta.appendChild(title);
|
|
meta.appendChild(channel);
|
|
const img = document.createElement('img');
|
|
img.src = item.thumbnail;
|
|
img.alt = '';
|
|
row.appendChild(img);
|
|
row.appendChild(meta);
|
|
row.addEventListener('click', () => loadVideo(item.videoId));
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
loadVideo(item.videoId);
|
|
}
|
|
});
|
|
return row;
|
|
}
|
|
|
|
async function runSearch(query) {
|
|
try {
|
|
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
|
|
if (!res.ok) throw new Error('search failed');
|
|
const items = await res.json();
|
|
showSearchStage(items);
|
|
} catch (e) {
|
|
console.error('search error', e);
|
|
}
|
|
}
|
|
|
|
document.getElementById('loadBtn').addEventListener('click', () => {
|
|
const raw = document.getElementById('urlInput').value.trim();
|
|
if (!raw) return;
|
|
const id = extractVideoId(raw);
|
|
if (id) {
|
|
loadVideo(id);
|
|
} else {
|
|
runSearch(raw);
|
|
}
|
|
});
|
|
|
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
|
if (e.key === 'Escape') exitSearchMode();
|
|
});
|
|
|
|
const modelSelect = document.getElementById('modelSelect');
|
|
const lockIcon = document.getElementById('lockIcon');
|
|
modelSelect.addEventListener('change', () => {
|
|
if (modelSelect.value === 'gemini' && !unlockCode) {
|
|
document.getElementById('unlockRow').classList.add('show');
|
|
} else {
|
|
document.getElementById('unlockRow').classList.remove('show');
|
|
}
|
|
lockIcon.textContent = (modelSelect.value === 'gemini' && unlockCode) ? 'lock_open' : (modelSelect.value === 'gemini' ? 'lock' : 'lock_open');
|
|
lockIcon.classList.toggle('unlocked', modelSelect.value === 'gemini' && !!unlockCode);
|
|
});
|
|
|
|
document.getElementById('unlockInput').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') document.getElementById('unlockBtn').click();
|
|
});
|
|
|
|
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
|
const code = document.getElementById('unlockInput').value.trim();
|
|
if (!code) return;
|
|
try {
|
|
const res = await fetch('/api/unlock', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
unlockCode = code;
|
|
document.getElementById('unlockRow').classList.remove('show');
|
|
lockIcon.textContent = 'lock_open';
|
|
lockIcon.classList.add('unlocked');
|
|
} else {
|
|
alert('コードが違います');
|
|
modelSelect.value = 'gemma';
|
|
}
|
|
} catch (e) {
|
|
alert('解放に失敗しました');
|
|
modelSelect.value = 'gemma';
|
|
}
|
|
});
|
|
|
|
function appendBubble(role, text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'bubble ' + role;
|
|
div.textContent = text;
|
|
document.getElementById('chatLog').appendChild(div);
|
|
scrollChatToBottom();
|
|
return div;
|
|
}
|
|
|
|
function appendStatusBubble() {
|
|
const div = document.createElement('div');
|
|
div.className = 'bubble model status';
|
|
div.innerHTML = '<span class="statusText">考え中</span><span class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>';
|
|
document.getElementById('chatLog').appendChild(div);
|
|
scrollChatToBottom();
|
|
return div;
|
|
}
|
|
|
|
function setStatusText(div, text) {
|
|
div.querySelector('.statusText').textContent = text;
|
|
}
|
|
|
|
function finalizeBubble(div, text, videos) {
|
|
div.className = 'bubble model';
|
|
div.textContent = text;
|
|
if (videos && videos.length) {
|
|
const box = document.createElement('div');
|
|
box.className = 'inlineSearch';
|
|
for (const item of videos) {
|
|
box.appendChild(buildYtRow(item));
|
|
}
|
|
div.appendChild(box);
|
|
}
|
|
scrollChatToBottom();
|
|
}
|
|
|
|
function scrollChatToBottom() {
|
|
const log = document.getElementById('chatLog');
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
async function sendChatMessage(message, opts) {
|
|
opts = opts || {};
|
|
if (opts.showUserBubble !== false) {
|
|
appendBubble('user', message);
|
|
}
|
|
const statusDiv = appendStatusBubble();
|
|
const wantsGemini = modelSelect.value === 'gemini';
|
|
|
|
try {
|
|
const res = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
videoTitle, videoDescription,
|
|
history: chatHistory,
|
|
message,
|
|
unlockCode: wantsGemini ? unlockCode : '',
|
|
persona: document.getElementById('personaSelect').value,
|
|
}),
|
|
});
|
|
if (!res.ok || !res.body) throw new Error('chat failed: ' + res.status);
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop();
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
const evt = JSON.parse(line);
|
|
if (evt.type === 'status') {
|
|
setStatusText(statusDiv, evt.text);
|
|
} else if (evt.type === 'final') {
|
|
finalizeBubble(statusDiv, evt.reply, evt.videos);
|
|
chatHistory.push({ role: 'user', text: message });
|
|
chatHistory.push({ role: 'model', text: evt.reply });
|
|
if (evt.autoPlayVideoId) {
|
|
loadVideo(evt.autoPlayVideoId, { notifyAI: false });
|
|
}
|
|
} else if (evt.type === 'error') {
|
|
finalizeBubble(statusDiv, '(エラー: ' + evt.text + ')');
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
finalizeBubble(statusDiv, '(エラー: ' + err.message + ')');
|
|
}
|
|
}
|
|
|
|
document.getElementById('chatForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const input = document.getElementById('chatInput');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
input.value = '';
|
|
sendChatMessage(message);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|