議論用のプラットフォーム「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="主張・証拠・反論・質問を入力…&#10;&#10;投稿後、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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
議論用のプラットフォーム「Logos」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1