議論用のプラットフォーム「Logos」
【更新履歴】
・2026/3/14 バージョン1.0公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOGOS — 分散型議論プラットフォーム</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400;700&family=IBM+Plex+Mono:wght@400;600&family=Noto+Sans+JP:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════════════════════
LOGOS — ライトテーマ CSS
═══════════════════════════════════════════════════════════════ */
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--surface2: #f0f2f5;
--surface3: #e8ebf0;
--border: #d1d8e0;
--border2: #b8c2cc;
--text: #1a1f2e;
--text2: #4a5568;
--text3: #8896a5;
--accent: #2563eb;
--accent2: #1d4ed8;
--accentbg: #eff6ff;
--green: #059669;
--greenbg: #ecfdf5;
--greenborder:#a7f3d0;
--red: #dc2626;
--redbg: #fef2f2;
--redborder: #fca5a5;
--yellow: #d97706;
--yellowbg: #fffbeb;
--yellowborder:#fcd34d;
--purple: #7c3aed;
--purplebg: #f5f3ff;
--purpleborder:#c4b5fd;
--ai-color: #059669;
--com-color: #d97706;
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow2: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.04);
--mono: 'IBM Plex Mono', monospace;
--sans: 'Noto Sans JP', sans-serif;
--serif: 'Noto Serif JP', serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
/* ─── LAYOUT ─── */
#app { display: flex; flex-direction: column; min-height: 100vh; }
/* ─── HEADER / MENUBAR ─── */
header {
border-bottom: 1px solid var(--border);
padding: 0 0 0 20px;
height: 48px;
display: flex;
align-items: center;
background: var(--surface);
position: sticky;
top: 0;
z-index: 200;
box-shadow: var(--shadow);
gap: 0;
}
.logo {
font-family: var(--mono);
font-size: 17px;
font-weight: 600;
letter-spacing: 3px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
margin-right: 4px;
white-space: nowrap;
}
.logo-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent);
display: inline-block;
}
.menubar {
display: flex;
align-items: stretch;
height: 100%;
margin-left: 8px;
}
.menu-item {
position: relative;
display: flex;
align-items: center;
padding: 0 14px;
font-size: 13px;
font-weight: 500;
color: var(--text2);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
white-space: nowrap;
user-select: none;
gap: 4px;
}
.menu-item:hover { color: var(--accent); background: var(--accentbg); border-bottom-color: var(--accent); }
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
font-size: 12px;
color: var(--text3);
}
.peer-indicator { display: flex; align-items: center; gap: 5px; }
.peer-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--green);
box-shadow: 0 0 4px var(--green);
transition: background 0.3s;
}
.peer-dot.offline { background: var(--text3); box-shadow: none; }
.peer-dot.relay { background: var(--accent); box-shadow: 0 0 4px var(--accent); animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
.user-id-chip {
background: var(--surface2); border: 1px solid var(--border); border-radius: 20px;
padding: 2px 10px; font-family: var(--mono); font-size: 10px; color: var(--text2); cursor: pointer; transition: all 0.15s;
}
.user-id-chip:hover { border-color: var(--accent); color: var(--accent); }
/* ─── MAIN ─── */
#main { display: flex; flex: 1; }
/* ─── SIDEBAR ─── */
#sidebar {
width: 180px; min-width: 180px; border-right: 1px solid var(--border); background: var(--surface);
padding: 12px 0; display: flex; flex-direction: column; gap: 2px; height: calc(100vh - 48px); overflow-y: auto; position: sticky; top: 48px;
}
.sidebar-section { padding: 8px 14px 4px; font-size: 10px; font-weight: 700; color: var(--text3); letter-spacing: 1.5px; text-transform: uppercase; }
.sidebar-item {
padding: 7px 14px; cursor: pointer; display: flex; align-items: center; gap: 7px;
color: var(--text2); font-size: 13px; transition: all 0.12s; border-left: 3px solid transparent; border-radius: 0 4px 4px 0; margin-right: 8px;
}
.sidebar-item:hover { background: var(--surface2); color: var(--text); }
.sidebar-item.active { background: var(--accentbg); color: var(--accent); border-left-color: var(--accent); font-weight: 500; }
.sidebar-item .s-icon { font-size: 13px; width: 16px; text-align: center; flex-shrink: 0; }
.sidebar-badge { margin-left: auto; background: var(--accent); color: #fff; font-size: 10px; font-family: var(--mono); padding: 1px 6px; border-radius: 10px; min-width: 18px; text-align: center; flex-shrink: 0; }
/* ─── CONTENT ─── */
#content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
/* ─── TOPIC HEADER & REFERENCES ─── */
#topic-header { border-bottom: 1px solid var(--border); padding: 16px 24px 12px; background: var(--surface); }
.topic-breadcrumb { font-family: var(--mono); font-size: 10px; color: var(--text3); margin-bottom: 6px; }
.topic-title { font-family: var(--serif); font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 8px; line-height: 1.35; }
.topic-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
/* REFERENCES UI */
.references-area {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 14px;
margin-top: 10px;
}
.ref-header { font-size: 11px; font-weight: 700; color: var(--text2); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.ref-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.ref-item { font-size: 12px; display: flex; align-items: flex-start; gap: 8px; }
.ref-item-icon { color: var(--accent); font-size: 14px; line-height: 1; margin-top: 2px; }
.ref-item-content a { color: var(--accent); text-decoration: none; word-break: break-all; }
.ref-item-content a:hover { text-decoration: underline; }
.ref-item-meta { font-family: var(--mono); font-size: 9px; color: var(--text3); margin-top: 2px; }
.ref-input-row { display: flex; gap: 6px; align-items: center; }
.ref-input { flex: 1; background: var(--surface); border: 1px solid var(--border2); border-radius: 4px; padding: 4px 8px; font-size: 12px; outline: none; }
.ref-input:focus { border-color: var(--accent); }
.ref-btn { background: var(--surface); border: 1px solid var(--border2); color: var(--text2); font-size: 11px; padding: 4px 10px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.ref-btn:hover { background: var(--surface3); }
/* ─── TAG ─── */
.tag { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 4px; border: 1px solid; letter-spacing: 0.5px; white-space: nowrap; }
.tag-claim { color: var(--accent); border-color: #93c5fd; background: var(--accentbg); }
.tag-evidence { color: var(--green); border-color: var(--greenborder); background: var(--greenbg); }
.tag-counter { color: var(--red); border-color: var(--redborder); background: var(--redbg); }
.tag-question { color: var(--yellow); border-color: var(--yellowborder);background: var(--yellowbg); }
.tag-topic { color: var(--purple); border-color: var(--purpleborder);background: var(--purplebg); }
.topic-custom-tag { background: var(--surface2); border: 1px solid var(--border); color: var(--text2); font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
/* ─── POST CONTROLS ─── */
#post-controls { padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--surface2); }
.type-selector { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.type-btn { font-size: 12px; font-weight: 500; padding: 5px 12px; border-radius: 6px; border: 1.5px solid var(--border2); background: var(--surface); color: var(--text2); cursor: pointer; transition: all 0.15s; }
.type-btn:hover { border-color: var(--text2); color: var(--text); }
.type-btn.active-claim { border-color: var(--accent); color: var(--accent); background: var(--accentbg); font-weight:700; }
.type-btn.active-evidence { border-color: var(--green); color: var(--green); background: var(--greenbg); font-weight:700; }
.type-btn.active-counter { border-color: var(--red); color: var(--red); background: var(--redbg); font-weight:700; }
.type-btn.active-question { border-color: var(--yellow); color: var(--yellow); background: var(--yellowbg); font-weight:700; }
.post-input-row { display: flex; gap: 8px; align-items: flex-start; }
textarea#post-input { flex: 1; background: var(--surface); border: 1.5px solid var(--border2); border-radius: 8px; color: var(--text); font-family: var(--sans); font-size: 14px; padding: 10px 12px; resize: vertical; min-height: 76px; outline: none; transition: border-color 0.15s; box-shadow: var(--shadow); }
textarea#post-input:focus { border-color: var(--accent); }
textarea#post-input::placeholder { color: var(--text3); }
.post-side { display: flex; flex-direction: column; gap: 6px; min-width: 140px; }
#source-input { background: var(--surface); border: 1.5px solid var(--border2); border-radius: 6px; color: var(--text); font-family: var(--mono); font-size: 11px; padding: 6px 10px; outline: none; transition: border-color 0.15s; }
#source-input:focus { border-color: var(--accent); }
.post-btn { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 8px 16px; font-family: var(--sans); font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; box-shadow: var(--shadow); }
.post-btn:hover { background: var(--accent2); }
.post-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.pow-indicator { font-family: var(--mono); font-size: 10px; color: var(--text3); padding: 3px 0; }
.pow-indicator.computing { color: var(--yellow); }
.pow-indicator.done { color: var(--green); }
/* ─── 返信ターゲット ─── */
#reply-target { background: var(--accentbg); border: 1px solid #93c5fd; border-left: 3px solid var(--accent); border-radius: 6px; padding: 6px 10px; margin-bottom: 10px; font-size: 12px; color: var(--text2); display: flex; align-items: center; gap: 8px; }
#reply-target.hidden { display: none !important; }
.cancel-reply { margin-left: auto; cursor: pointer; color: var(--text3); font-size: 16px; line-height:1; }
.cancel-reply:hover { color: var(--red); }
/* ─── POSTS AREA ─── */
#posts-area { flex: 1; overflow-y: auto; padding: 16px 24px 24px; display: flex; flex-direction: column; gap: 0; background: var(--bg); }
.post-thread { margin-bottom: 10px; }
.post-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; transition: border-color 0.15s, box-shadow 0.15s; box-shadow: var(--shadow); }
.post-card:hover { border-color: var(--border2); box-shadow: var(--shadow2); }
.post-card.self-post { border-left: 3px solid var(--accent); }
.post-header { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; flex-wrap: wrap; }
/* ─── POST TOGGLE (COLLAPSE) ─── */
.post-toggle-btn {
background: none; border: none; color: var(--text3); cursor: pointer;
font-size: 10px; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
border-radius: 4px; transition: background 0.15s; margin-right: 2px; outline: none;
}
.post-toggle-btn:hover { background: var(--surface2); color: var(--text); }
.post-card.collapsed .post-body {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 0; color: var(--text2); font-size: 13px;
}
.post-card.collapsed .post-source, .post-card.collapsed .evidence-verify-badge, .post-card.collapsed .post-footer, .post-card.collapsed .notes-area, .post-card.collapsed .add-note-form {
display: none !important;
}
.post-card.collapsed + .replies-container { display: none; }
.post-author { font-family: var(--mono); font-size: 11px; color: var(--text3); }
.post-author.me { color: var(--accent); font-weight: 600; }
.ai-score-badge { font-size: 10px; font-family: var(--mono); font-weight: 700; padding: 1px 6px; border-radius: 10px; border: 1px solid; margin-left: 4px; }
.post-time { font-family: var(--mono); font-size: 10px; color: var(--text3); margin-left: auto; }
.post-hash { font-family: var(--mono); font-size: 9px; color: var(--text3); }
.post-body { font-size: 14px; line-height: 1.7; color: var(--text); margin-bottom: 10px; word-break: break-word; }
.post-source { font-family: var(--mono); font-size: 10px; margin-bottom: 8px; word-break: break-all; }
.post-source a { color: var(--accent); text-decoration: none; }
.post-source a:hover { text-decoration: underline; }
/* ─── 証拠検証バッジ ─── */
.evidence-verify-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; padding: 2px 8px; border-radius: 4px; font-family: var(--mono); margin-bottom: 6px; }
.evb-pending { background: var(--yellowbg); color: var(--yellow); border: 1px solid var(--yellowborder); }
.evb-verified { background: var(--greenbg); color: var(--green); border: 1px solid var(--greenborder); }
.evb-caution { background: var(--redbg); color: var(--red); border: 1px solid var(--redborder); }
.post-footer { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.reply-btn, .verify-btn, .note-toggle-btn { background: none; border: 1px solid transparent; color: var(--text3); font-size: 12px; padding: 3px 8px; cursor: pointer; border-radius: 5px; transition: all 0.15s; }
.reply-btn:hover { background: var(--surface2); color: var(--text2); border-color: var(--border); }
.verify-btn { color: var(--green); border-color: var(--greenborder); background: var(--greenbg); }
.verify-btn:hover { background: #d1fae5; }
.note-toggle-btn { color: var(--yellow); border-color: var(--yellowborder); background: var(--yellowbg); }
.note-toggle-btn:hover { background: #fef3c7; }
/* ─── NOTES ─── */
.notes-area { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.note-card { border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.6; display: flex; gap: 8px; align-items: flex-start; }
.note-card.ai-note { background: var(--greenbg); border: 1px solid var(--greenborder); border-left: 3px solid var(--ai-color); }
.note-card.community-note { background: var(--yellowbg); border: 1px solid var(--yellowborder); border-left: 3px solid var(--com-color); }
.note-label { font-size: 9px; font-weight: 700; letter-spacing: 1px; padding: 2px 5px; border-radius: 3px; white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
.ai-note .note-label { background: #a7f3d0; color: var(--ai-color); }
.community-note .note-label { background: #fde68a; color: var(--com-color); }
.note-text { color: var(--text2); flex: 1; }
.note-reason { font-family: var(--mono); font-size: 9px; color: var(--text3); margin-top: 3px; }
.replies-container { margin-left: 24px; margin-top: 4px; display: flex; flex-direction: column; gap: 4px; border-left: 2px solid var(--border); padding-left: 12px; }
/* ─── コミュニティノート入力 ─── */
.add-note-form { display: none; margin-top: 8px; gap: 6px; align-items: center; }
.add-note-form.open { display: flex; }
.note-input { flex: 1; background: var(--surface); border: 1.5px solid var(--border2); border-radius: 6px; color: var(--text); font-family: var(--sans); font-size: 12px; padding: 6px 10px; outline: none; }
.note-input:focus { border-color: var(--com-color); }
.note-submit-btn { background: var(--yellowbg); border: 1px solid var(--com-color); color: var(--com-color); font-size: 12px; font-weight: 600; padding: 5px 10px; border-radius: 5px; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.note-submit-btn:hover { background: #fde68a; }
/* ─── EMPTY STATE ─── */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text3); }
.empty-state .e-icon { font-size: 36px; margin-bottom: 12px; }
.empty-state p { font-size: 13px; line-height: 1.8; color: var(--text3); }
/* ─── SCROLLBAR ─── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text3); }
/* ═══════════════════════════════════════════════════════════════
MODALS — 共通スタイル
═══════════════════════════════════════════════════════════════ */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.5); backdrop-filter: blur(2px); z-index: 1000; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: 580px; max-width: 94vw; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); position: relative; }
.modal-box.wide { width: 720px; }
.modal-box.narrow { width: 440px; }
.modal-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
.modal-header h3 { font-family: var(--serif); font-size: 18px; font-weight: 700; color: var(--text); flex: 1; margin: 0; }
.modal-close { cursor: pointer; color: var(--text3); font-size: 22px; line-height: 1; transition: color 0.15s; padding: 0 4px; }
.modal-close:hover { color: var(--red); }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 11px; font-weight: 700; color: var(--text2); letter-spacing: 0.5px; margin-bottom: 5px; text-transform: uppercase; }
.form-group input, .form-group select, .form-group textarea { width: 100%; background: var(--surface); border: 1.5px solid var(--border2); border-radius: 7px; color: var(--text); font-family: var(--sans); font-size: 13px; padding: 8px 12px; outline: none; transition: border-color 0.15s; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: var(--accent); }
.form-group textarea { min-height: 80px; resize: vertical; }
.btn-primary { background: var(--accent); color: #fff; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
.btn-primary:hover { background: var(--accent2); }
.btn-secondary { background: var(--surface2); color: var(--text2); border: 1px solid var(--border2); border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.btn-secondary:hover { background: var(--surface3); color: var(--text); }
.btn-danger { background: var(--redbg); color: var(--red); border: 1px solid var(--redborder); border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
.btn-danger:hover { background: #fecaca; }
.btn-green { background: var(--greenbg); color: var(--green); border: 1px solid var(--greenborder); border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
.btn-green:hover { background: #d1fae5; }
.btn-icon-sm { background: none; border: 1px solid var(--border); border-radius: 5px; padding: 4px 9px; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.15s; }
.btn-icon-sm:hover { border-color: var(--text2); color: var(--text); }
.btn-icon-sm.danger:hover { border-color: var(--red); color: var(--red); background: var(--redbg); }
/* ═══════════════════════════════════════════════════════════════
MODAL SPECIFICS
═══════════════════════════════════════════════════════════════ */
.topic-card { background: var(--surface2); border: 1.5px solid var(--border); border-radius: 8px; padding: 10px 14px; display: flex; align-items: flex-start; gap: 10px; cursor: pointer; margin-bottom: 6px; transition: all 0.15s; }
.topic-card:hover { border-color: var(--accent); background: var(--accentbg); }
.topic-card.active { border-color: var(--accent); background: var(--accentbg); }
.topic-card-info { flex: 1; min-width: 0; }
.topic-card-title { font-size: 13px; font-weight: 500; color: var(--text); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topic-card-meta { font-family: var(--mono); font-size: 10px; color: var(--text3); margin-bottom: 4px; }
.topic-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
.integrity-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 12px; margin-bottom: 4px; }
.integrity-ok { background: var(--greenbg); border: 1px solid var(--greenborder); color: var(--green); }
.integrity-warn { background: var(--yellowbg); border: 1px solid var(--yellowborder); color: var(--yellow); }
.integrity-err { background: var(--redbg); border: 1px solid var(--redborder); color: var(--red); }
.chain-block { font-family: var(--mono); font-size: 10px; padding: 6px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 5px; margin-bottom: 4px; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
.chain-block-header { display: flex; align-items: center; gap: 8px; }
.chain-block .cb-id { color: var(--accent); flex-shrink: 0; }
.chain-block .cb-hash { color: var(--text3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.chain-block .cb-ok { color: var(--green); flex-shrink: 0; }
.chain-block .cb-err { color: var(--red); flex-shrink: 0; }
.cb-parents { font-size: 9px; color: var(--text3); padding-left: 12px; border-left: 2px solid var(--border2); }
.summary-content { font-size: 13px; line-height: 1.8; color: var(--text); white-space: pre-wrap; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; min-height: 120px; }
.summary-loading { text-align: center; padding: 30px; color: var(--text3); font-size: 13px; }
.settings-section { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
.settings-section h4 { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 12px; }
.llm-status-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.llm-status-badge { font-family: var(--mono); font-size: 11px; padding: 3px 10px; border-radius: 20px; font-weight: 600; }
.llm-status-badge.connected { background: var(--greenbg); color: var(--green); border: 1px solid var(--greenborder); }
.llm-status-badge.disconnected { background: var(--redbg); color: var(--red); border: 1px solid var(--redborder); }
.llm-status-badge.testing { background: var(--yellowbg); color: var(--yellow); border: 1px solid var(--yellowborder); }
/* TOAST */
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--text); color: var(--surface); border-radius: 8px; padding: 10px 18px; font-size: 13px; font-weight: 500; z-index: 9999; transform: translateY(10px); opacity: 0; transition: all 0.25s; max-width: 320px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); }
#toast.warn { background: var(--yellow); color: #fff; }
#toast.error { background: var(--red); color: #fff; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border2); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes computing { 0%{opacity:0.4}50%{opacity:1}100%{opacity:0.4} }
.computing-anim { animation: computing 0.7s ease-in-out infinite; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="logo"><span class="logo-dot"></span>LOGOS</div>
<nav class="menubar">
<div class="menu-item" onclick="openTopicSearchModal()">🗂 トピック検索・一覧</div>
<div class="menu-item" onclick="openTopicCreateModal()">➕ トピック作成</div>
<div class="menu-item" onclick="openSummaryModal()">📋 AI要約</div>
<div class="menu-item" onclick="saveToFile()">💾 保存</div>
<div class="menu-item" onclick="document.getElementById('import-file').click()">
📂 読込
<input type="file" id="import-file" accept=".json" style="display:none" onchange="importFromFile(event)">
</div>
<div class="menu-item" onclick="openSettingsModal()">⚙ 設定</div>
</nav>
<div class="header-right">
<div class="peer-indicator">
<div class="peer-dot" id="p2p-dot"></div>
<span id="p2p-status">読込中...</span>
</div>
<div class="user-id-chip" id="user-id-display" title="クリックでIDをコピー">···</div>
<span id="post-count-header" style="font-family:var(--mono);font-size:11px">0件</span>
</div>
</header>
<div id="main">
<div id="sidebar">
<div class="sidebar-section">表示フィルター</div>
<div class="sidebar-item active" id="filter-all" onclick="setFilter('all',this)"><span class="s-icon">◎</span>すべて<span class="sidebar-badge" id="sb-all">0</span></div>
<div class="sidebar-item" id="filter-claim" onclick="setFilter('claim',this)"><span class="s-icon" style="color:var(--accent)">◆</span>主張のみ<span class="sidebar-badge" id="sb-claim">0</span></div>
<div class="sidebar-item" id="filter-evidence" onclick="setFilter('evidence',this)"><span class="s-icon" style="color:var(--green)">◆</span>証拠のみ<span class="sidebar-badge" id="sb-evidence">0</span></div>
<div class="sidebar-item" id="filter-counter" onclick="setFilter('counter',this)"><span class="s-icon" style="color:var(--red)">◆</span>反論のみ<span class="sidebar-badge" id="sb-counter">0</span></div>
<div class="sidebar-item" id="filter-question" onclick="setFilter('question',this)"><span class="s-icon" style="color:var(--yellow)">◆</span>質問のみ<span class="sidebar-badge" id="sb-question">0</span></div>
<div style="height:1px;background:var(--border);margin:8px 14px;"></div>
<div class="sidebar-section">注釈統計</div>
<div class="sidebar-item" style="cursor:default;font-size:12px;flex-direction:column;align-items:flex-start;gap:3px;padding:8px 14px;">
<div style="display:flex;justify-content:space-between;width:100%;color:var(--text2)"><span>AI注釈</span><span id="sb-ainotes" style="font-family:var(--mono)">0</span></div>
<div style="display:flex;justify-content:space-between;width:100%;color:var(--text2)"><span>コミュニティ</span><span id="sb-comnotes" style="font-family:var(--mono)">0</span></div>
</div>
</div>
<div id="content">
<div id="topic-header">
<div class="topic-breadcrumb" id="topic-breadcrumb">LOGOS / トピックを選択してください</div>
<div class="topic-title" id="topic-title-display">分散型議論プラットフォーム</div>
<div class="topic-meta">
<span class="tag tag-topic" id="current-topic-tag">TOPIC</span>
<div id="topic-tags-display" style="display:flex;gap:4px;flex-wrap:wrap;margin-left:4px;"></div>
<div id="certainty-display" style="display:flex;align-items:center;gap:8px;margin-left:8px;">
<span style="font-size:11px;color:var(--text3)">確信度</span>
<div style="width:90px;height:5px;background:var(--surface3);border-radius:3px;overflow:hidden;border:1px solid var(--border)">
<div id="certainty-fill" style="height:100%;width:50%;background:linear-gradient(90deg,var(--red),var(--yellow),var(--green));border-radius:3px;transition:width 0.5s"></div>
</div>
<span id="certainty-val" style="font-family:var(--mono);font-size:11px;color:var(--text2)">0.50</span>
</div>
</div>
<div id="references-container" class="references-area" style="display:none;">
<div class="ref-header">📌 このトピックの参考資料 (ファクト・一次ソース)</div>
<div class="ref-list" id="ref-list"></div>
<div class="ref-input-row">
<input type="text" id="ref-url-input" class="ref-input" placeholder="URL (例: https://...)">
<input type="text" id="ref-title-input" class="ref-input" placeholder="資料名 / タイトル">
<button class="ref-btn" onclick="addReference()">+ 追加</button>
</div>
</div>
</div>
<div id="post-controls">
<div id="reply-target" class="hidden">
<span style="font-size:11px;font-weight:700;color:var(--accent)">↩ 返信先:</span>
<span id="reply-target-text" style="font-size:12px;color:var(--text2)"></span>
<span class="cancel-reply" onclick="cancelReply()">×</span>
</div>
<div class="type-selector">
<button class="type-btn active-claim" data-type="claim" onclick="selectType('claim', this)">◆ 主張</button>
<button class="type-btn" data-type="evidence" onclick="selectType('evidence', this)">◆ 証拠</button>
<button class="type-btn" data-type="counter" onclick="selectType('counter', this)">◆ 反論</button>
<button class="type-btn" data-type="question" onclick="selectType('question', this)">◆ 質問</button>
</div>
<div class="post-input-row">
<textarea id="post-input" placeholder="主張・証拠・反論・質問を入力… 投稿後、AIが「証拠の有無や内容の論理性」に基づき、あなたへの【評価スコア】を自動判定します。"></textarea>
<div class="post-side">
<input id="source-input" type="text" placeholder="出典URL(任意)">
<button class="post-btn" onclick="submitPost()" id="submit-btn">投稿</button>
<div class="pow-indicator" id="pow-indicator">⬡ PoW: 待機中</div>
</div>
</div>
</div>
<div id="posts-area">
<div class="empty-state" id="empty-state">
<div class="e-icon">🌐</div>
<p>メニューの「トピック検索・一覧」からトピックを選択するか、「トピック作成」から作成してください。<br><br>
データは <strong>IndexedDB</strong> によりブラウザに永続保存され、大容量の議論に耐えます。<br>
また、WebSocket リレーを指定することでインターネット越しの<strong>広域P2P同期</strong>が可能です。</p>
</div>
</div>
</div></div></div><div class="modal-overlay" id="topic-create-modal">
<div class="modal-box narrow">
<div class="modal-header">
<h3>➕ 新しいトピックを作成</h3>
<span class="modal-close" onclick="closeTopicCreateModal()">×</span>
</div>
<div class="form-group">
<label>トピックのタイトル</label>
<input type="text" id="new-topic-input" placeholder="タイトルを入力..." onkeydown="if(event.key==='Enter')createTopic()">
</div>
<div class="form-group">
<label>タグ(任意)</label>
<input type="text" id="new-topic-tags" placeholder="カンマ区切り。例: 政治,AI,経済" onkeydown="if(event.key==='Enter')createTopic()">
</div>
<div style="text-align:right;margin-top:16px;">
<button class="btn-primary" onclick="createTopic()">作成して開く</button>
</div>
</div>
</div>
<div class="modal-overlay" id="topic-search-modal">
<div class="modal-box wide">
<div class="modal-header">
<h3>🗂 トピック検索・一覧</h3>
<span class="modal-close" onclick="closeTopicSearchModal()">×</span>
</div>
<div style="margin-bottom:14px;">
<div class="form-group" style="margin-bottom: 8px;">
<label>トピックを検索</label>
<input type="text" id="search-topic-input" placeholder="タイトルやタグで検索..." oninput="renderTopicList()">
</div>
<div style="font-size:11px;font-weight:700;color:var(--text2);letter-spacing:0.5px;text-transform:uppercase;margin-bottom:8px;margin-top:12px;">トピック一覧</div>
<div id="topic-list-modal"></div>
</div>
<div style="border-top:1px solid var(--border);padding-top:14px;margin-top:16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div style="font-size:11px;font-weight:700;color:var(--text2);letter-spacing:0.5px;text-transform:uppercase">🔗 DAG整合性チェック</div>
<button class="btn-green" style="padding:5px 12px;font-size:12px" onclick="runIntegrityCheck()">チェック実行</button>
</div>
<div id="integrity-result"><p style="font-size:12px;color:var(--text3)">非同期環境でのブロックチェーン検証を行います。</p></div>
</div>
</div>
</div>
<div class="modal-overlay" id="summary-modal">
<div class="modal-box wide">
<div class="modal-header">
<h3>📋 AIトピック要約</h3>
<span class="modal-close" onclick="document.getElementById('summary-modal').classList.remove('open')">×</span>
</div>
<div id="summary-topic-label" style="font-size:12px;color:var(--text3);margin-bottom:12px"></div>
<div id="summary-content-area" class="summary-content">要約を生成するには下のボタンを押してください。</div>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
<button class="btn-primary" onclick="generateSummary()">🤖 AI要約を生成</button>
<button class="btn-secondary" onclick="saveSummaryToFile()">💾 ファイルに保存</button>
</div>
<div id="summary-llm-note" style="margin-top:10px;font-size:11px;color:var(--text3)"></div>
</div>
</div>
<div class="modal-overlay" id="settings-modal">
<div class="modal-box narrow">
<div class="modal-header">
<h3>⚙ 設定</h3>
<span class="modal-close" onclick="document.getElementById('settings-modal').classList.remove('open')">×</span>
</div>
<div class="settings-section">
<h4>🌐 P2P ネットワーク (Nostr形式リレー)</h4>
<div class="form-group">
<label>WebSocket リレーサーバーURL (任意)</label>
<input type="text" id="relay-url" placeholder="ws://localhost:8080 または wss://...">
</div>
<div style="margin-top:6px;font-size:11px;color:var(--text3);line-height:1.6">
※ 指定するとインターネット越しに他のピアと同期します。空欄の場合は「BroadcastChannel」による同一ブラウザ内のタブ間同期のみ動作します。
</div>
</div>
<div class="settings-section">
<h4>🤖 ローカルLLM(AI注釈・要約・証拠検証・評価判定)</h4>
<div class="form-group">
<label>エンドポイント(Ollama等)</label>
<input type="text" id="llm-endpoint" value="http://localhost:11434/api/generate">
</div>
<div class="form-group">
<label>モデル名 (軽量モデル推奨)</label>
<input type="text" id="llm-model" value="llama3" placeholder="qwen:1.5b / gemma:2b など">
</div>
<div class="llm-status-row">
<button class="btn-secondary" style="padding:6px 14px;font-size:12px" onclick="testLLM()">接続テスト</button>
<div class="llm-status-badge disconnected" id="llm-status">未接続</div>
</div>
</div>
<div class="settings-section">
<h4>⬡ Proof of Work 難易度</h4>
<div class="form-group">
<label>難易度</label>
<select id="pow-difficulty"><option value="1">1</option><option value="2" selected>2</option><option value="3">3</option></select>
</div>
</div>
<div class="settings-section">
<h4>🗑 データ管理 (IndexedDB)</h4>
<button class="btn-danger" style="font-size:12px;padding:7px 14px" onclick="clearData()">すべてのデータを削除</button>
</div>
<div style="text-align:right;margin-top:4px">
<button class="btn-primary" onclick="saveSettings()">保存して再接続</button>
</div>
</div>
</div>
<div class="modal-overlay" id="verify-modal">
<div class="modal-box narrow">
<div class="modal-header">
<h3>🔍 AI 証拠検証結果</h3>
<span class="modal-close" onclick="document.getElementById('verify-modal').classList.remove('open')">×</span>
</div>
<div id="verify-content"></div>
</div>
</div>
<div id="toast"></div>
<script>
// ═══════════════════════════════════════════════════════════════
// LOGOS — P2P Debate Platform
// Version 4.3 (Separated Modals + Tags + Search + Collapsible + IDB)
// ═══════════════════════════════════════════════════════════════
// ─── STATE ───────────────────────────────────────────────────
const state = {
userId: null,
posts: [],
topics: [],
currentTopicId: null,
replyToId: null,
currentType: 'claim',
currentFilter: 'all',
llmConnected: false,
llmEndpoint: 'http://localhost:11434/api/generate',
llmModel: 'llama3',
relayUrl: '',
powDifficulty: 2,
lastSummary: '',
collapsedPosts: new Set(),
};
const TYPE_LABEL = { claim: '主張', evidence: '証拠', counter: '反論', question: '質問' };
// ─── IndexedDB WRAPPER ─────────────────────────────────────────
const DB_NAME = 'LogosDB';
const DB_VERSION = 1;
const STORE_NAME = 'logos_state';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function loadFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get('main_data');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveToDB(data) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put(data, 'main_data');
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function clearDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
// ─── NETWORK LAYER (WebSocket + BroadcastChannel) ─────────────
let p2pChannel = null;
let wsRelay = null;
let reconnectTimer = null;
function initNetwork() {
try {
p2pChannel = new BroadcastChannel('logos_p2p_channel');
p2pChannel.onmessage = (event) => {
if (event.data.type === 'SYNC') mergeData(event.data.topics, event.data.posts);
};
} catch(e) { console.warn("BroadcastChannel not supported."); }
connectRelay();
}
function connectRelay() {
if (wsRelay) { wsRelay.close(); wsRelay = null; }
clearTimeout(reconnectTimer);
const dot = document.getElementById('p2p-dot');
const status = document.getElementById('p2p-status');
if (!state.relayUrl) {
dot.className = 'peer-dot';
status.textContent = 'ローカル (タブ間同期のみ)';
return;
}
try {
wsRelay = new WebSocket(state.relayUrl);
status.textContent = 'リレー接続中...';
dot.className = 'peer-dot offline';
wsRelay.onopen = () => {
status.textContent = '🌐 リレー接続済';
dot.className = 'peer-dot relay';
wsRelay.send(JSON.stringify({ type: 'SYNC', topics: state.topics, posts: state.posts }));
};
wsRelay.onmessage = (e) => {
try {
const payload = JSON.parse(e.data);
if (payload.type === 'SYNC') mergeData(payload.topics, payload.posts);
} catch(err) {}
};
wsRelay.onclose = () => {
status.textContent = 'リレー切断 (再試行中...)';
dot.className = 'peer-dot offline';
reconnectTimer = setTimeout(connectRelay, 5000);
};
wsRelay.onerror = () => { wsRelay.close(); };
} catch(e) {
status.textContent = 'URLエラー (リレー無効)';
dot.className = 'peer-dot offline';
}
}
function broadcastSync(specificPosts = null) {
const payload = { type: 'SYNC', topics: state.topics, posts: specificPosts || state.posts };
if (p2pChannel) p2pChannel.postMessage(payload);
if (wsRelay && wsRelay.readyState === WebSocket.OPEN) wsRelay.send(JSON.stringify(payload));
}
// ─── INITIALIZATION & MIGRATION ──────────────────────────────
async function init() {
state.userId = localStorage.getItem('logos_userId') || generateId(8);
localStorage.setItem('logos_userId', state.userId);
const chip = document.getElementById('user-id-display');
chip.textContent = state.userId.substring(0, 12) + '…';
chip.title = 'ID: ' + state.userId + ' (クリックでコピー)';
chip.onclick = () => { navigator.clipboard.writeText(state.userId).then(() => toast('✓ ユーザーIDをコピーしました', 'success')); };
const cfg = localStorage.getItem('logos_settings');
if (cfg) {
try {
const s = JSON.parse(cfg);
if (s.llmEndpoint) state.llmEndpoint = s.llmEndpoint;
if (s.llmModel) state.llmModel = s.llmModel;
if (s.powDifficulty) state.powDifficulty = s.powDifficulty;
if (s.relayUrl !== undefined) state.relayUrl = s.relayUrl;
} catch(e) {}
}
try {
let dbData = await loadFromDB();
if (!dbData) {
const oldData = localStorage.getItem('logos_data');
if (oldData) {
dbData = JSON.parse(oldData);
await saveToDB(dbData);
localStorage.removeItem('logos_data');
}
}
if (dbData) {
state.posts = dbData.posts || [];
state.topics = dbData.topics || [];
state.posts.forEach(p => migratePostData(p));
state.topics.forEach(t => {
if (!t.references) t.references = [];
if (!t.tags) t.tags = [];
});
// collapsedPostsはSetなのでArrayから復元
if (Array.isArray(dbData.collapsedPosts)) {
state.collapsedPosts = new Set(dbData.collapsedPosts);
}
}
} catch(e) { console.error("Database loading error", e); }
if (state.topics.length === 0) {
const id1 = generateId(6);
state.topics.push({ id: id1, title: '分散型SNSの未来について', createdAt: Date.now() - 7200000, chainRoot: 'GENESIS_' + id1, references: [], tags: ['テクノロジー', '分散型'] });
await triggerPersist();
}
initNetwork();
updateHeaderCount();
if (state.topics.length > 0) selectTopic(state.topics[0].id);
}
function migratePostData(p) {
if (!p.notes) p.notes = [];
if (!p.evidenceVerified) p.evidenceVerified = null;
if (!p.parentHashes) p.parentHashes = [ p.prevHash || 'GENESIS' ];
if (typeof p.aiScoreDelta !== 'number') p.aiScoreDelta = 0;
}
async function triggerPersist() {
const dataToSave = { posts: state.posts, topics: state.topics, collapsedPosts: Array.from(state.collapsedPosts), savedAt: Date.now() };
try { await saveToDB(dataToSave); } catch(e) { console.error("Failed to save to IndexedDB", e); }
}
async function saveSettings() {
state.llmEndpoint = document.getElementById('llm-endpoint').value.trim();
state.llmModel = document.getElementById('llm-model').value.trim();
state.relayUrl = document.getElementById('relay-url').value.trim();
state.powDifficulty = parseInt(document.getElementById('pow-difficulty').value);
localStorage.setItem('logos_settings', JSON.stringify({
llmEndpoint: state.llmEndpoint, llmModel: state.llmModel,
powDifficulty: state.powDifficulty, relayUrl: state.relayUrl
}));
document.getElementById('settings-modal').classList.remove('open');
connectRelay();
toast('✓ 設定を保存しました', 'success');
}
async function clearData() {
document.getElementById('settings-modal').classList.remove('open');
if (!confirm('すべてのデータを削除しますか?\nこの操作は元に戻せません。')) return;
state.posts = []; state.topics = []; state.currentTopicId = null; state.collapsedPosts.clear();
await clearDB();
renderPosts();
updateHeaderCount();
const certaintyFill = document.getElementById('certainty-fill');
const certaintyVal = document.getElementById('certainty-val');
if (certaintyFill) certaintyFill.style.width = '50%';
if (certaintyVal) certaintyVal.textContent = '0.50';
document.getElementById('topic-title-display').textContent = '分散型議論プラットフォーム';
document.getElementById('topic-breadcrumb').textContent = 'LOGOS / トピックを選択してください';
document.getElementById('topic-tags-display').innerHTML = '';
document.getElementById('references-container').style.display = 'none';
toast('🗑 データを削除しました');
}
// ─── CRDT MERGE LOGIC ─────────────────────────────────────────
async function mergeData(incomingTopics, incomingPosts) {
let changed = false;
const existingTopicMap = new Map();
state.topics.forEach(t => existingTopicMap.set(t.id, t));
incomingTopics.forEach(inT => {
if (!inT.references) inT.references = [];
if (!inT.tags) inT.tags = [];
const exT = existingTopicMap.get(inT.id);
if (!exT) {
state.topics.push(inT);
existingTopicMap.set(inT.id, inT);
changed = true;
} else {
if (inT.references.length > 0) {
const existingRefs = new Set(exT.references.map(r => r.url));
inT.references.forEach(r => {
if (!existingRefs.has(r.url)) { exT.references.push(r); changed = true; }
});
}
if (inT.tags && inT.tags.length > 0) {
const existingTags = new Set(exT.tags);
inT.tags.forEach(tag => {
if(!existingTags.has(tag)) { exT.tags.push(tag); changed = true; }
});
}
}
});
incomingPosts.forEach(inPost => {
migratePostData(inPost);
const exIndex = state.posts.findIndex(p => p.id === inPost.id);
if (exIndex === -1) {
state.posts.push(inPost);
changed = true;
} else {
const exPost = state.posts[exIndex];
inPost.notes.forEach(inNote => {
const noteExists = exPost.notes.some(n => n.ts === inNote.ts && n.author === inNote.author);
if (!noteExists) { exPost.notes.push(inNote); changed = true; }
});
if (inPost.evidenceVerified && !exPost.evidenceVerified) {
exPost.evidenceVerified = inPost.evidenceVerified; changed = true;
}
if (inPost.aiScoreDelta !== 0 && exPost.aiScoreDelta === 0) {
exPost.aiScoreDelta = inPost.aiScoreDelta; changed = true;
}
}
});
if (changed) {
await triggerPersist();
if (state.currentTopicId) {
renderReferences();
renderTopicHeaderTags();
renderPosts();
updateHeaderCount();
updateStats();
updateCertainty();
}
}
}
// ─── ID / HASH / DAG ──────────────────────────────────────────
function generateId(len = 12) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
return Array.from(crypto.getRandomValues(new Uint8Array(len))).map(b => chars[b % chars.length]).join('');
}
async function sha256(message) {
const buf = new TextEncoder().encode(message);
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
}
function getTopicHeads(topicId) {
const postsInTopic = state.posts.filter(p => p.topicId === topicId);
const topicObj = state.topics.find(t => t.id === topicId);
if (postsInTopic.length === 0) return [topicObj ? topicObj.chainRoot : 'GENESIS'];
const referencedHashes = new Set();
postsInTopic.forEach(p => { if (p.parentHashes) p.parentHashes.forEach(h => referencedHashes.add(h)); });
const heads = postsInTopic.filter(p => !referencedHashes.has(p.chainHash)).map(p => p.chainHash);
return heads.length > 0 ? heads : [topicObj ? topicObj.chainRoot : 'GENESIS'];
}
async function buildHashForPost(post, parentHashesArray) {
const sortedParentsStr = (parentHashesArray || []).slice().sort().join(',');
const raw = [post.id, post.author, post.timestamp, post.type, post.content, post.source || '', post.parentId || '', post.powNonce, sortedParentsStr].join('|');
return await sha256(raw);
}
async function computePoW(content, difficulty) {
const prefix = '0'.repeat(difficulty); let nonce = 0;
while (true) {
const hash = await sha256(content + nonce);
if (hash.startsWith(prefix)) return { nonce, hash };
nonce++; if (nonce % 300 === 0) await new Promise(r => setTimeout(r, 0));
}
}
// ─── TOPICS & REFERENCES & TAGS ──────────────────────────────────────
function openTopicSearchModal() {
document.getElementById('search-topic-input').value = '';
renderTopicList();
document.getElementById('topic-search-modal').classList.add('open');
}
function closeTopicSearchModal() { document.getElementById('topic-search-modal').classList.remove('open'); }
function openTopicCreateModal() {
document.getElementById('new-topic-input').value = '';
document.getElementById('new-topic-tags').value = '';
document.getElementById('topic-create-modal').classList.add('open');
setTimeout(() => document.getElementById('new-topic-input').focus(), 100);
}
function closeTopicCreateModal() { document.getElementById('topic-create-modal').classList.remove('open'); }
async function createTopic() {
const title = document.getElementById('new-topic-input').value.trim();
const tagsStr = document.getElementById('new-topic-tags').value.trim();
if (!title) { toast('⚠ タイトルを入力してください', 'warn'); return; }
let tags = [];
if (tagsStr) {
tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
}
const id = generateId(6); const now = Date.now();
const chainRoot = await sha256('LOGOS_GENESIS_' + id + now);
const topic = { id, title, createdAt: now, chainRoot, references: [], tags: tags };
state.topics.push(topic);
await triggerPersist(); broadcastSync(); selectTopic(id); closeTopicCreateModal();
toast('✓ トピックを作成しました', 'success');
}
async function deleteTopic(id) {
const topic = state.topics.find(t => t.id === id);
if (!topic) return;
if (!confirm(`「${topic.title}」を削除しますか?\n関連する投稿も削除されます。`)) return;
state.topics = state.topics.filter(t => t.id !== id);
state.posts = state.posts.filter(p => p.topicId !== id);
if (state.currentTopicId === id) {
state.currentTopicId = null;
document.getElementById('topic-title-display').textContent = '分散型議論プラットフォーム';
document.getElementById('topic-breadcrumb').textContent = 'LOGOS / トピックを選択してください';
document.getElementById('topic-tags-display').innerHTML = '';
document.getElementById('posts-area').innerHTML = '';
document.getElementById('posts-area').appendChild(document.getElementById('empty-state'));
document.getElementById('empty-state').style.display = '';
document.getElementById('references-container').style.display = 'none';
const certaintyFill = document.getElementById('certainty-fill');
const certaintyVal = document.getElementById('certainty-val');
if (certaintyFill) certaintyFill.style.width = '50%';
if (certaintyVal) certaintyVal.textContent = '0.50';
}
await triggerPersist(); broadcastSync(); renderTopicList(); updateHeaderCount();
toast('🗑 トピックを削除しました');
}
function selectTopic(id) {
state.currentTopicId = id;
const topic = state.topics.find(t => t.id === id);
if (!topic) return;
triggerPersist(); broadcastSync();
document.getElementById('topic-breadcrumb').textContent = 'LOGOS / ' + topic.title;
document.getElementById('topic-title-display').textContent = topic.title;
document.getElementById('current-topic-tag').textContent = 'ID: ' + topic.id.toUpperCase();
renderTopicHeaderTags();
renderReferences();
renderPosts();
updateHeaderCount();
updateStats();
updateCertainty();
}
function renderTopicHeaderTags() {
const topic = state.topics.find(t => t.id === state.currentTopicId);
const display = document.getElementById('topic-tags-display');
if (!topic || !topic.tags || topic.tags.length === 0) {
display.innerHTML = '';
return;
}
display.innerHTML = topic.tags.map(tag => `<span class="topic-custom-tag">${esc(tag)}</span>`).join('');
}
function renderReferences() {
const topic = state.topics.find(t => t.id === state.currentTopicId);
const container = document.getElementById('references-container');
const list = document.getElementById('ref-list');
if (!topic) { container.style.display = 'none'; return; }
container.style.display = 'block';
if (!topic.references || topic.references.length === 0) {
list.innerHTML = '<div style="font-size:11px;color:var(--text3)">登録された参考資料はありません。</div>';
} else {
list.innerHTML = topic.references.map(r => `
<div class="ref-item">
<div class="ref-item-icon">📄</div>
<div class="ref-item-content">
<a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.title || r.url)}</a>
<div class="ref-item-meta">追加者: ${esc(r.addedBy.substring(0,8))}</div>
</div>
</div>
`).join('');
}
}
async function addReference() {
const topic = state.topics.find(t => t.id === state.currentTopicId);
if (!topic) return;
const url = document.getElementById('ref-url-input').value.trim();
const title = document.getElementById('ref-title-input').value.trim();
if (!url || !url.startsWith('http')) { toast('⚠ 有効なURLを入力してください', 'warn'); return; }
if (!topic.references) topic.references = [];
if (topic.references.some(r => r.url === url)) { toast('⚠ すでに登録されているURLです', 'warn'); return; }
topic.references.push({ url, title, addedBy: state.userId, ts: Date.now() });
document.getElementById('ref-url-input').value = '';
document.getElementById('ref-title-input').value = '';
await triggerPersist(); broadcastSync(); renderReferences();
toast('✓ 参考資料を追加しました', 'success');
}
function renderTopicList() {
const el = document.getElementById('topic-list-modal');
const searchInput = document.getElementById('search-topic-input');
const searchWord = (searchInput ? searchInput.value : '').toLowerCase().trim();
let displayTopics = state.topics;
if (searchWord) {
displayTopics = state.topics.filter(t => {
const matchTitle = t.title.toLowerCase().includes(searchWord);
const matchTags = t.tags && t.tags.some(tag => tag.toLowerCase().includes(searchWord));
return matchTitle || matchTags;
});
}
if (displayTopics.length === 0) {
el.innerHTML = '<p style="font-size:12px;color:var(--text3);text-align:center;padding:16px 0">トピックが見つかりません</p>';
return;
}
el.innerHTML = displayTopics.map(t => {
const cnt = state.posts.filter(p => p.topicId === t.id).length;
const active = t.id === state.currentTopicId ? 'active' : '';
const tagsHtml = (t.tags && t.tags.length > 0)
? `<div class="topic-card-tags">${t.tags.map(tag => `<span class="topic-custom-tag">${esc(tag)}</span>`).join('')}</div>`
: '';
return `<div class="topic-card ${active}" onclick="selectTopic('${t.id}');closeTopicSearchModal()">
<div class="topic-card-info">
<div class="topic-card-title">${esc(t.title)}</div>
<div class="topic-card-meta">ID: ${t.id} · ${cnt}件 · ${timeAgo(t.createdAt)}</div>
${tagsHtml}
</div>
<button class="btn-icon-sm danger" onclick="event.stopPropagation();deleteTopic('${t.id}')">削除</button>
</div>`;
}).join('');
}
// ─── 整合性チェック ──────────────────────────────────────────
async function runIntegrityCheck() {
const el = document.getElementById('integrity-result');
el.innerHTML = '<div style="font-size:12px;color:var(--text3);display:flex;align-items:center;gap:6px"><span class="spinner"></span>DAG構造を検証中…</div>';
let html = ''; let allOk = true;
for (const topic of state.topics) {
const posts = state.posts.filter(p => p.topicId === topic.id).sort((a, b) => a.timestamp - b.timestamp);
if (posts.length === 0) { html += `<div class="integrity-row integrity-ok">✓ 「${esc(topic.title.substring(0,30))}」— 投稿なし(問題なし)</div>`; continue; }
let topicOk = true; let chainHtml = '';
const knownHashes = new Set(posts.map(p => p.chainHash));
knownHashes.add(topic.chainRoot);
for (const post of posts) {
const parents = post.parentHashes || [topic.chainRoot];
const expectedHash = await buildHashForPost(post, parents);
let blockOk = false; let reason = '';
if (!post.chainHash) { post.chainHash = expectedHash; blockOk = true; }
else if (post.chainHash !== expectedHash) { blockOk = false; reason = 'ハッシュ不一致(改ざん)'; }
else { blockOk = true; }
let missingParents = parents.filter(ph => !knownHashes.has(ph));
if (missingParents.length > 0) { blockOk = false; reason = '未受信の親ブロックあり'; }
if (!blockOk) topicOk = false;
chainHtml += `<div class="chain-block">
<div class="chain-block-header">
<span class="cb-id">#${post.id.substring(0,8)}</span><span class="cb-hash">${post.chainHash ? post.chainHash.substring(0,24)+'…' : '(未記録)'}</span>
<span class="${blockOk ? 'cb-ok' : 'cb-err'}">${blockOk ? '✓' : `✗ ${reason}`}</span>
</div>
<div class="cb-parents">親: ${parents.map(h => h.substring(0,8)).join(', ')}</div>
</div>`;
}
if (!topicOk) allOk = false;
html += `<div class="integrity-row ${topicOk ? 'integrity-ok' : 'integrity-err'}" style="margin-bottom:6px">${topicOk ? '✓' : '✗'} 「${esc(topic.title.substring(0,30))}」— ${posts.length}件</div>`;
html += `<div style="margin-bottom:12px">${chainHtml}</div>`;
}
await triggerPersist();
const summaryClass = allOk ? 'integrity-ok' : 'integrity-err';
el.innerHTML = `<div class="integrity-row ${summaryClass}" style="margin-bottom:12px;font-weight:700">${allOk ? '✅ DAGの整合性が証明されました' : '❌ 改ざん、または非同期による孤立ブロックが存在します'}</div>${html}`;
}
// ─── AI評価計算ロジック ─────────────────────────────────────────
function getUserScore(userId) {
let score = 50;
state.posts.forEach(p => { if (p.author === userId) score += (p.aiScoreDelta || 0); });
return Math.max(0, Math.min(100, score));
}
function getScoreDisplay(score) {
if (score >= 80) return { label: 'A', color: 'var(--green)' };
if (score >= 60) return { label: 'B', color: 'var(--accent)' };
if (score >= 40) return { label: 'C', color: 'var(--text2)' };
return { label: 'D', color: 'var(--red)' };
}
// ─── 投稿処理 ──────────────────────────────────────────────────
function selectType(type, btn) { state.currentType = type; document.querySelectorAll('.type-btn').forEach(b => b.className = 'type-btn'); btn.className = `type-btn active-${type}`; }
function setReplyTo(id) {
const post = state.posts.find(p => p.id === id); if (!post) return;
state.replyToId = id; document.getElementById('reply-target').classList.remove('hidden');
document.getElementById('reply-target-text').textContent = `[${TYPE_LABEL[post.type] || post.type}] ${post.content.substring(0, 50)}${post.content.length > 50 ? '…' : ''}`;
document.getElementById('post-input').focus();
}
function cancelReply() { state.replyToId = null; document.getElementById('reply-target').classList.add('hidden'); }
async function submitPost() {
if (!state.currentTopicId) { toast('⚠ トピックを選択してください', 'warn'); return; }
const content = document.getElementById('post-input').value.trim();
if (!content) { toast('⚠ 内容を入力してください', 'warn'); return; }
const source = document.getElementById('source-input').value.trim();
const btn = document.getElementById('submit-btn'); const powEl = document.getElementById('pow-indicator');
btn.disabled = true; powEl.className = 'pow-indicator computing'; powEl.innerHTML = '<span class="computing-anim">⬡ PoW 計算中…</span>';
const rawContent = content + state.userId + Date.now();
const { nonce, hash } = await computePoW(rawContent, state.powDifficulty);
const id = hash.substring(0, 20);
const currentHeads = getTopicHeads(state.currentTopicId);
const post = {
id, author: state.userId, timestamp: Date.now(), type: state.currentType, content, source: source || null,
parentId: state.replyToId || null, relation: getRelation(state.currentType), powNonce: nonce, parentHashes: currentHeads,
chainHash: null, notes: [], evidenceVerified: null, topicId: state.currentTopicId, aiScoreDelta: 0
};
post.chainHash = await buildHashForPost(post, currentHeads);
powEl.textContent = 'AI評価・解析中…';
await analyzePost(post);
if (post.type === 'evidence') { powEl.textContent = 'AI証拠検証中…'; await verifyEvidence(post); }
state.posts.push(post);
if (post.parentId && state.collapsedPosts.has(post.parentId)) {
state.collapsedPosts.delete(post.parentId);
}
await triggerPersist();
broadcastSync([post]);
document.getElementById('post-input').value = ''; document.getElementById('source-input').value = '';
cancelReply();
btn.disabled = false; powEl.className = 'pow-indicator done'; powEl.textContent = `✓ PoW: ${nonce}回 / AI判定完了`;
setTimeout(() => { powEl.className = 'pow-indicator'; powEl.textContent = '⬡ PoW: 待機中'; }, 4000);
renderPosts(); updateHeaderCount(); updateStats(); updateCertainty(); toast('✓ 投稿しました', 'success');
}
function getRelation(type) { if (type === 'counter') return 'REFUTES'; if (type === 'evidence') return 'SUPPORTS'; if (type === 'question') return 'QUESTIONS'; return 'SUPPORTS'; }
// ─── AI 解析・評価 ─────────────────────────────────────────────
async function analyzePost(post) {
if (state.llmConnected) {
try {
const prompt = `あなたはモデレーターAIです。以下の投稿を分析し、JSONで返答せよ。
投稿: ${post.content}\n出典: ${post.source || 'なし'}\n形式: {"note": "注釈またはnull", "reason": "理由", "author_score_delta": -10から10の整数}`;
const resp = await fetch(state.llmEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: state.llmModel, prompt, stream: false, options: { temperature: 0.1, num_predict: 200 } }), signal: AbortSignal.timeout(15000) });
const m = (await resp.json()).response.match(/\{[\s\S]*?\}/);
if (m) {
const result = JSON.parse(m[0]);
if (result.note) post.notes.push({ type: 'ai', text: result.note, reason: result.reason || 'AI解析', auto: true, ts: Date.now(), author: 'AI' });
if (typeof result.author_score_delta === 'number') post.aiScoreDelta += result.author_score_delta;
return;
}
} catch(e) {}
}
const aggressive = ['バカ', 'アホ', '死ね', 'クズ', '馬鹿', 'うそつき', '嘘つき', 'ゴミ', '最悪']; let delta = 2;
if (aggressive.some(w => post.content.includes(w))) {
post.notes.push({ type: 'ai', text: 'この投稿には攻撃的な表現が含まれています。建設的な議論のため、事実と論理に基づく表現を推奨します。', reason: 'ルールベース: 攻撃的語句を検出', auto: true, ts: Date.now(), author: 'AI' });
delta -= 10;
}
// 出典なし断言(主張・反論タイプ)
if ((post.type === 'claim' || post.type === 'counter') && !post.source) {
const assertive = ['必ず', '絶対', '証明されている', '確実に', '明らかに', '誰もが知っている', '常に'];
if (assertive.some(w => post.content.includes(w))) {
post.notes.push({ type: 'ai', text: '断定的な表現を含みますが、出典が提示されていません。根拠となるデータや研究を示すことで議論の質が向上します。', reason: 'ルールベース: 出典なし断定表現を検出', auto: true, ts: Date.now(), author: 'AI' });
delta -= 3;
}
}
// 医療情報
const medical = ['ワクチン', '副反応', '治療', '投薬', '医療', '感染', '抗体', '副作用'];
if (medical.some(w => post.content.includes(w)) && !post.source) {
post.notes.push({ type: 'ai', text: '医療に関する情報が含まれています。信頼できる情報源(査読論文・公的医療機関)の引用を強く推奨します。', reason: 'ルールベース: 医療関連キーワードを検出', auto: true, ts: Date.now(), author: 'AI' });
delta -= 2;
}
// 陰謀論的表現
const conspiracy = ['隠蔽', '洗脳', 'マスコミが隠', '真実は', '支配している', '黒幕'];
if (conspiracy.some(w => post.content.includes(w))) {
post.notes.push({ type: 'ai', text: '検証が困難な主張を含む可能性があります。具体的な証拠・一次情報源を提示してください。', reason: 'ルールベース: 検証困難な主張パターンを検出', auto: true, ts: Date.now(), author: 'AI' });
delta -= 5;
}
post.aiScoreDelta += delta;
}
// ─── AI 証拠検証 ───────────────────────────────────────────────
async function verifyEvidence(post) {
if (state.llmConnected) {
try {
const prompt = `あなたは証拠評価AIです。以下の証拠を評価しJSONで返答せよ。
内容: ${post.content}\n出典: ${post.source || 'なし'}\n形式: {"verdict": "verified"|"caution"|"unverified", "confidence": 0.0-1.0, "summary": "100字", "detail": "200字", "author_score_delta": -5から20}`;
const resp = await fetch(state.llmEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: state.llmModel, prompt, stream: false, options: { temperature: 0.1, num_predict: 400 } }), signal: AbortSignal.timeout(20000) });
const m = (await resp.json()).response.match(/\{[\s\S]*?\}/);
if (m) {
const result = JSON.parse(m[0]); post.evidenceVerified = result;
if (typeof result.author_score_delta === 'number') post.aiScoreDelta += result.author_score_delta;
return;
}
} catch(e) {}
}
const hasPeer = post.source && (post.source.includes('pubmed') || post.source.includes('doi.org') || post.source.includes('gov') || post.source.includes('who.int') || post.source.includes('nature.com') || post.source.includes('nejm.org'));
const hasUrl = !!post.source && post.source.startsWith('http');
if (hasPeer) {
post.evidenceVerified = { verdict: 'verified', confidence: 0.85, summary: '信頼性の高い出典を確認', detail: '査読論文・公的機関等の出典を確認しました。' };
post.aiScoreDelta += 15;
} else if (hasUrl) {
post.evidenceVerified = { verdict: 'caution', confidence: 0.50, summary: '出典あり・内容要確認', detail: 'URLによる出典はありますが、情報の正確性は自身でご確認ください。' };
post.aiScoreDelta += 5;
} else {
post.evidenceVerified = { verdict: 'unverified', confidence: 0.2, summary: '出典不十分・要検証', detail: '出典が提示されていません。根拠となる一次情報源の提示を推奨します。' };
post.aiScoreDelta -= 2;
}
}
async function triggerVerify(postId) {
const post = state.posts.find(p => p.id === postId); if (!post) return;
const modal = document.getElementById('verify-modal'); const content = document.getElementById('verify-content');
content.innerHTML = '<div class="summary-loading"><span class="spinner"></span> AI検証中…</div>'; modal.classList.add('open');
if (!post.evidenceVerified) { await verifyEvidence(post); await triggerPersist(); broadcastSync([post]); renderPosts(); }
const v = post.evidenceVerified;
if (!v) { content.innerHTML = '<p style="font-size:13px;color:var(--text2)">検証できませんでした。</p>'; return; }
const confPct = Math.round((v.confidence || 0) * 100);
const verdictLabel = { verified: '✅ 検証済', caution: '⚠ 要注意', unverified: '❌ 未検証' };
const verdictClass = { verified: 'integrity-ok', caution: 'integrity-warn', unverified: 'integrity-err' };
const usedModel = state.llmConnected ? state.llmModel + '(ローカルLLM)' : 'ルールベースAI';
content.innerHTML = `
<div class="integrity-row ${verdictClass[v.verdict] || 'integrity-warn'}" style="margin-bottom:12px;font-size:14px;font-weight:700">
${verdictLabel[v.verdict] || v.verdict}
</div>
<div style="margin-bottom:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:4px;text-transform:uppercase">検証の信頼度</div>
<div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;height:8px;background:var(--surface3);border-radius:4px;overflow:hidden;border:1px solid var(--border)">
<div style="height:100%;width:${confPct}%;background:linear-gradient(90deg,var(--red),var(--yellow),var(--green));border-radius:4px;transition:width 0.5s"></div>
</div>
<span style="font-family:var(--mono);font-size:12px;color:var(--text2)">${confPct}%</span>
</div>
</div>
<div style="margin-bottom:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:4px;text-transform:uppercase">サマリー</div>
<p style="font-size:13px;color:var(--text)">${esc(v.summary)}</p>
</div>
<div style="margin-bottom:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:4px;text-transform:uppercase">詳細</div>
<p style="font-size:12px;color:var(--text2);line-height:1.7">${esc(v.detail)}</p>
</div>
<div style="margin-top:8px;font-size:10px;font-family:var(--mono);color:var(--text3);padding-top:10px;border-top:1px solid var(--border)">
使用AI: ${usedModel}
</div>`;
}
// ─── コミュニティノート ────────────────────────────────────────
async function addCommunityNote(postId, text) {
const post = state.posts.find(p => p.id === postId); if (!post || !text.trim()) return;
post.notes.push({ type: 'community', author: state.userId, text: text.trim(), reason: 'コミュニティノート', auto: false, ts: Date.now() });
await triggerPersist(); broadcastSync([post]); renderPosts(); updateHeaderCount(); updateStats(); toast('✓ コミュニティノートを追加しました', 'success');
}
// ─── 開閉 (Collapse) ロジック ─────────────────────────────────
function togglePost(postId) {
const card = document.getElementById(`post-${postId}`);
const btn = card ? card.querySelector('.post-toggle-btn') : null;
if (!card || !btn) return;
if (state.collapsedPosts.has(postId)) {
state.collapsedPosts.delete(postId);
card.classList.remove('collapsed');
btn.textContent = '▼';
} else {
state.collapsedPosts.add(postId);
card.classList.add('collapsed');
btn.textContent = '▶';
}
triggerPersist();
}
// ─── 投稿レンダリング ──────────────────────────────────────────
function renderPosts() {
const area = document.getElementById('posts-area'); const emptyEl = document.getElementById('empty-state');
const filtered = state.posts.filter(p => p.topicId === state.currentTopicId && (state.currentFilter === 'all' || p.type === state.currentFilter));
if (!state.currentTopicId || filtered.length === 0) {
area.innerHTML = ''; area.appendChild(emptyEl); emptyEl.style.display = '';
if (filtered.length === 0 && state.currentTopicId) emptyEl.querySelector('p').innerHTML = 'まだ投稿がありません。';
return;
}
emptyEl.style.display = 'none'; area.innerHTML = ''; area.appendChild(emptyEl);
const postMap = {}; filtered.forEach(p => postMap[p.id] = p);
const roots = filtered.filter(p => !p.parentId || !postMap[p.parentId]);
const childMap = {}; filtered.forEach(p => { if (p.parentId && postMap[p.parentId]) { if (!childMap[p.parentId]) childMap[p.parentId] = []; childMap[p.parentId].push(p); } });
roots.sort((a, b) => a.timestamp - b.timestamp).forEach(root => {
const threadEl = document.createElement('div'); threadEl.className = 'post-thread';
threadEl.appendChild(buildPostCard(root)); appendReplies(threadEl, root.id, childMap, 0);
area.appendChild(threadEl);
});
}
function appendReplies(parentEl, parentId, childMap, depth) {
const children = childMap[parentId]; if (!children || depth > 3) return;
const repEl = document.createElement('div'); repEl.className = 'replies-container';
children.sort((a, b) => a.timestamp - b.timestamp).forEach(child => { repEl.appendChild(buildPostCard(child)); appendReplies(repEl, child.id, childMap, depth + 1); });
parentEl.appendChild(repEl);
}
function buildPostCard(post) {
const isCollapsed = state.collapsedPosts.has(post.id);
const div = document.createElement('div');
div.className = 'post-card' + (post.author === state.userId ? ' self-post' : '') + (isCollapsed ? ' collapsed' : '');
div.id = 'post-' + post.id;
const scoreDisp = getScoreDisplay(getUserScore(post.author));
const noteCount = post.notes.filter(n => n.type === 'community').length; const aiCount = post.notes.filter(n => n.type === 'ai').length;
let evBadge = '';
if (post.type === 'evidence') {
if (!post.evidenceVerified) {
evBadge = `<span class="evidence-verify-badge evb-pending">⏳ 未検証</span>`;
} else {
const v = post.evidenceVerified;
const cls = v.verdict === 'verified' ? 'evb-verified' : v.verdict === 'caution' ? 'evb-caution' : 'evb-caution';
const lbl = v.verdict === 'verified' ? '✅ 検証済' : v.verdict === 'caution' ? '⚠ 要注意' : '❌ 未検証(出典なし)';
evBadge = `<span class="evidence-verify-badge ${cls}">${lbl}</span>`;
}
}
div.innerHTML = `
<div class="post-header">
<button class="post-toggle-btn" title="投稿を折りたたむ/展開する" onclick="togglePost('${post.id}')">${isCollapsed ? '▶' : '▼'}</button>
<span class="tag tag-${post.type}">${TYPE_LABEL[post.type] || post.type}</span>
<span class="post-author ${post.author === state.userId ? 'me' : ''}">${post.author.substring(0,10)}… ${post.author === state.userId ? '(自分)' : ''}</span>
<span class="ai-score-badge" style="color:${scoreDisp.color}; border-color:${scoreDisp.color}">AI評価: ${scoreDisp.label}</span>
${post.relation && post.parentId ? `<span style="font-size:10px;color:var(--text3);margin-left:6px">← ${post.relation}</span>` : ''}
<span class="post-time">${timeAgo(post.timestamp)}</span><span class="post-hash">${post.id.substring(0,8)}</span>
</div>
${evBadge}
<div class="post-body">${esc(post.content)}</div>
${post.source ? `<div class="post-source">📎 出典: <a href="${esc(post.source)}" target="_blank">${esc(post.source.length > 70 ? post.source.substring(0,70)+'…' : post.source)}</a></div>` : ''}
<div class="post-footer">
<button class="reply-btn" onclick="setReplyTo('${post.id}')">↩ 返信</button>
${post.type === 'evidence' ? `<button class="verify-btn" onclick="triggerVerify('${post.id}')">🔍 AI再検証</button>` : ''}
<button class="note-toggle-btn" onclick="toggleNoteArea('${post.id}')">✎ ノート${(noteCount + aiCount) > 0 ? `(${noteCount + aiCount})` : ''}</button>
</div>
<div class="notes-area" id="notes-${post.id}" style="display:none"></div>
<div class="add-note-form" id="note-form-${post.id}">
<input class="note-input" id="note-input-${post.id}" placeholder="コミュニティノートを入力…" onkeydown="if(event.key==='Enter'){submitNote('${post.id}');event.preventDefault()}">
<button class="note-submit-btn" onclick="submitNote('${post.id}')">追加</button>
</div>`;
const notesArea = div.querySelector(`#notes-${post.id}`);
post.notes.forEach(note => {
const nd = document.createElement('div'); nd.className = `note-card ${note.type === 'ai' ? 'ai-note' : 'community-note'}`;
nd.innerHTML = `<span class="note-label">${note.type === 'ai' ? 'AI注釈' : 'コミュニティ'}</span><div><div class="note-text">${esc(note.text)}</div>${note.reason ? `<div class="note-reason">理由: ${esc(note.reason)}</div>` : ''}</div>`;
notesArea.appendChild(nd);
});
return div;
}
function toggleNoteArea(postId) {
const area = document.getElementById(`notes-${postId}`); const form = document.getElementById(`note-form-${postId}`);
const isOpen = area.style.display !== 'none'; area.style.display = isOpen ? 'none' : 'flex'; area.style.flexDirection = 'column'; form.classList.toggle('open', !isOpen);
if (!isOpen) document.getElementById(`note-input-${postId}`)?.focus();
}
function submitNote(postId) { const input = document.getElementById(`note-input-${postId}`); if (input && input.value.trim()) { addCommunityNote(postId, input.value); input.value = ''; } }
function updateHeaderCount() { document.getElementById('post-count-header').textContent = state.posts.length + '件'; }
function setFilter(filter, el) { state.currentFilter = filter; document.querySelectorAll('[id^="filter-"]').forEach(e => e.classList.remove('active')); el.classList.add('active'); renderPosts(); }
function updateStats() {
const tp = state.posts.filter(p => p.topicId === state.currentTopicId);
const setCount = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
setCount('sb-all', tp.length);
setCount('sb-claim', tp.filter(p => p.type === 'claim').length);
setCount('sb-evidence', tp.filter(p => p.type === 'evidence').length);
setCount('sb-counter', tp.filter(p => p.type === 'counter').length);
setCount('sb-question', tp.filter(p => p.type === 'question').length);
const allNotes = tp.flatMap(p => p.notes || []);
setCount('sb-ainotes', allNotes.filter(n => n.type === 'ai').length);
setCount('sb-comnotes', allNotes.filter(n => n.type === 'community').length);
}
function updateCertainty() {
const tp = state.posts.filter(p => p.topicId === state.currentTopicId);
const certaintyFill = document.getElementById('certainty-fill');
const certaintyVal = document.getElementById('certainty-val');
if (!certaintyFill || !certaintyVal) return;
if (tp.length === 0) return;
const ev = tp.filter(p => p.type === 'evidence').reduce((s, p) => s + (p.aiScoreDelta || 0), 0);
const co = tp.filter(p => p.type === 'counter').reduce((s, p) => s + (p.aiScoreDelta || 0), 0);
const posE = tp.filter(p => p.type === 'evidence').length;
const negC = tp.filter(p => p.type === 'counter').length;
let c = 0.5 + (posE - negC) * 0.05 + (ev - co) * 0.005;
c = Math.max(0.05, Math.min(0.95, c));
certaintyFill.style.width = (c * 100) + '%';
certaintyVal.textContent = c.toFixed(2);
}
// ─── AI要約モーダル ────────────────────────────────────────────
function openSummaryModal() {
if (!state.currentTopicId) { toast('⚠ トピックを選択してください', 'warn'); return; }
document.getElementById('summary-topic-label').textContent = 'トピック: ' + state.topics.find(t => t.id === state.currentTopicId).title;
document.getElementById('summary-content-area').textContent = state.lastSummary || '「AI要約を生成」ボタンを押してください。';
document.getElementById('summary-llm-note').textContent = ''; document.getElementById('summary-modal').classList.add('open');
}
async function generateSummary() {
if (!state.currentTopicId) return;
const posts = state.posts.filter(p => p.topicId === state.currentTopicId); if (posts.length === 0) { toast('⚠ 投稿がありません', 'warn'); return; }
const el = document.getElementById('summary-content-area'); const note = document.getElementById('summary-llm-note');
el.innerHTML = '<div class="summary-loading"><span class="spinner"></span> AI要約を生成中…</div>';
if (state.llmConnected) {
try {
const postsSummary = posts.map(p => `[${TYPE_LABEL[p.type]}] ${p.content}${p.source ? ' (出典: ' + p.source + ')' : ''}`).join('\n');
const topic = state.topics.find(t => t.id === state.currentTopicId);
const prompt = `あなたは議論を分析・要約する専門AIです。以下のトピックと投稿一覧を読んで、議論の要約を日本語で作成してください。\n\n【トピック】\n${topic ? topic.title : ''}\n\n【投稿一覧】\n${postsSummary}\n\n【要約の構成】\n1. 議論の概要(3〜5文)\n2. 主な主張・証拠\n3. 主な反論\n4. 未解決の疑問\n5. 現時点での合意点と対立点\n\n分かりやすく、公平に要約してください。`;
const resp = await fetch(state.llmEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: state.llmModel, prompt, stream: false, options: { temperature: 0.3, num_predict: 800 } }) });
state.lastSummary = (await resp.json()).response.trim(); el.textContent = state.lastSummary; note.textContent = `使用モデル: ${state.llmModel}`; return;
} catch(e) {}
}
// ルールベース要約
const topic = state.topics.find(t => t.id === state.currentTopicId);
const claims = posts.filter(p => p.type === 'claim');
const evidences = posts.filter(p => p.type === 'evidence');
const counters = posts.filter(p => p.type === 'counter');
const questions = posts.filter(p => p.type === 'question');
let summary = `【トピック】\n${topic ? topic.title : ''}\n\n`;
summary += `【概要】\n本トピックには${posts.length}件の投稿があります。主張${claims.length}件、証拠${evidences.length}件、反論${counters.length}件、質問${questions.length}件が寄せられています。\n\n`;
if (claims.length > 0) { summary += `【主な主張】\n`; claims.slice(0,3).forEach((p,i) => { summary += `${i+1}. ${p.content}\n`; }); summary += '\n'; }
if (evidences.length > 0) { summary += `【主な証拠】\n`; evidences.slice(0,3).forEach((p,i) => { summary += `${i+1}. ${p.content}${p.source ? ' (出典: ' + p.source + ')' : ''}\n`; }); summary += '\n'; }
if (counters.length > 0) { summary += `【主な反論】\n`; counters.slice(0,3).forEach((p,i) => { summary += `${i+1}. ${p.content}\n`; }); summary += '\n'; }
if (questions.length > 0) { summary += `【未解決の質問】\n`; questions.slice(0,3).forEach((p,i) => { summary += `${i+1}. ${p.content}\n`; }); summary += '\n'; }
summary += `【生成日時】\n${new Date().toLocaleString('ja-JP')}`;
state.lastSummary = summary; el.textContent = state.lastSummary; note.textContent = '※ ローカルLLM未接続のため、ルールベースで要約を生成しました。';
}
function saveSummaryToFile() {
if (!state.lastSummary) { toast('⚠ 先に要約を生成してください', 'warn'); return; }
const topic = state.topics.find(t => t.id === state.currentTopicId);
const safeTitle = (topic ? topic.title : 'topic').substring(0, 20).replace(/[\s\/\\:*?"<>|]/g, '_');
const fname = `logos_summary_${safeTitle}_${new Date().toISOString().slice(0, 10)}.txt`;
const blob = new Blob([state.lastSummary], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fname; a.click();
URL.revokeObjectURL(url);
toast('💾 要約ファイルを保存しました', 'success');
}
// ─── 設定モーダル ──────────────────────────────────────────────
function openSettingsModal() {
document.getElementById('llm-endpoint').value = state.llmEndpoint; document.getElementById('llm-model').value = state.llmModel;
document.getElementById('pow-difficulty').value = String(state.powDifficulty); document.getElementById('relay-url').value = state.relayUrl;
document.getElementById('llm-status').className = 'llm-status-badge ' + (state.llmConnected ? 'connected' : 'disconnected');
document.getElementById('llm-status').textContent = state.llmConnected ? '✓ 接続済' : '未接続';
document.getElementById('settings-modal').classList.add('open');
}
async function testLLM() {
const endpoint = document.getElementById('llm-endpoint').value.trim(); const model = document.getElementById('llm-model').value.trim();
const statusEl = document.getElementById('llm-status'); statusEl.className = 'llm-status-badge testing'; statusEl.textContent = '接続テスト中…';
try {
const resp = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, prompt: 'テスト', stream: false, options: { num_predict: 5 } }), signal: AbortSignal.timeout(8000) });
if (resp.ok) {
state.llmConnected = true; state.llmEndpoint = endpoint; state.llmModel = model;
statusEl.className = 'llm-status-badge connected'; statusEl.textContent = '✓ 接続済';
toast(`✓ ${model} に接続しました`, 'success');
} else throw new Error('HTTP ' + resp.status);
} catch(e) {
state.llmConnected = false; statusEl.className = 'llm-status-badge disconnected'; statusEl.textContent = '✗ 接続失敗';
toast('⚠ LLM接続失敗。ルールベースAIを使用します', 'warn');
}
}
// ─── JSONエクスポート / インポート ────────────────────────────
function saveToFile() {
const data = JSON.stringify({
version: '4.3',
exportedBy: state.userId,
exportedAt: new Date().toISOString(),
posts: state.posts,
topics: state.topics,
}, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logos_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('💾 JSONファイルを保存しました', 'success');
}
async function importFromFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
let mergedPosts = 0; let mergedTopics = 0;
const existingPostIds = new Set(state.posts.map(p => p.id));
const existingTopicIds = new Set(state.topics.map(t => t.id));
(data.topics || []).forEach(t => {
if (!existingTopicIds.has(t.id)) {
if (!t.references) t.references = [];
if (!t.tags) t.tags = [];
state.topics.push(t);
mergedTopics++;
}
});
(data.posts || []).forEach(p => {
if (!existingPostIds.has(p.id)) {
migratePostData(p);
state.posts.push(p);
mergedPosts++;
}
});
await triggerPersist();
renderTopicList();
if (state.currentTopicId) { renderPosts(); updateHeaderCount(); updateStats(); updateCertainty(); }
toast(`📂 投稿${mergedPosts}件・トピック${mergedTopics}件をインポートしました`, 'success');
} catch(err) {
toast('⚠ JSONの読み込みに失敗しました', 'error');
}
};
reader.readAsText(file);
event.target.value = '';
}
// ─── ユーティリティ ────────────────────────────────────────────
function esc(str) { return str == null ? '' : String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
function timeAgo(ts) { const diff = Date.now() - ts; if (diff < 60000) return 'たった今'; if (diff < 3600000) return Math.floor(diff / 60000) + '分前'; if (diff < 86400000) return Math.floor(diff / 3600000) + '時間前'; return Math.floor(diff / 86400000) + '日前'; }
function toast(msg, type = '', duration = 2800) { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'show ' + type; clearTimeout(el._timer); el._timer = setTimeout(() => { el.className = ''; }, duration); }
document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); }); });
init();
</script>
</body>
</html>

コメント