Skill: yslenjoy/chat-visualizer-ymind (version 1.0.5)

--- name: chat-visualizer-ymind description: Turn AI chat transcripts into interactive D3.js thinking maps with reasoning nodes, thinking shifts, and action items. Invoke this skill when the user shares a public share link from any AI chatbot or assistant (ChatGPT, Gemini, Claude, DeepSeek, Doubao, etc.), or pastes conversation text directly. Also applies when the user wants to visualize, analyze, or extract insights from a chat, even without explicit mention of a graph (e.g. "help me understand this chat", "what was decided here", "summarize the key takeaways"). --- # Chat Visualizer - YMind ## Version Check Run **once** at the start of each skill session. First locate the script (works regardless of which client installed the skill): ```bash find ~/.codex/skills ~/.claude/skills ~/.openclaw/skills ~/skills -maxdepth 3 -name "check-version.py" 2>/dev/null | head -1 ``` Then run it with `python3 <found_path>`. - Empty output → already up to date or network unavailable, proceed silently. - Output `UPDATE|X.X.X|Y.Y.Y|<skill_dir>|<notes>` → tell the user in English: **"chat-visualizer-ymind vY.Y.Y is available** (you're on vX.X.X).\n\<notes\>\nUpdate: `cd <skill_dir> && git fetch && git checkout vY.Y.Y` — continue with current version?" Wait for their reply before proceeding. ## Input Two ways to get conversation data: ### Way 1: Share URL (auto-fetch) Requires Playwright. Check first: ```bash python3 -c "import playwright" 2>/dev/null && echo "OK" || echo "NOT INSTALLED" ``` If not installed, ask: "Playwright 可以自动抓取对话内容，要装吗？（`pip install playwright && playwright install chromium`，一次性操作）" If they decline, use Way 2. If available, fetch: ```bash bash scripts/run.sh fetch "<url>" # prints RUN_DIR — read <run_dir>/raw_chat.json, use items[0].messages ``` ChatGPT 403: script retries with Playwright headed mode. If still fails, fall back to Way 2. ### Way 2: Paste text (universal fallback) User opens the conversation in browser → Ctrl+A → Ctrl+C → pastes into chat. No share link needed — any conversation page works. When handling pasted text or a local JSON file: 1. Create run_dir manually. **The suffix must always be `_paste` — do not use the filename, title, or any other label:** ```bash YMIND_DIR="${YMIND_DIR:-$HOME/ymind-ws}" RUN_DIR="$YMIND_DIR/$(date +%Y%m%d-%H%M%S)_paste" mkdir -p "$RUN_DIR" ``` 2. Parse the pasted text into messages (identify user vs AI turns by context), then write `raw_chat.json` in the same format as fetch produces: ```json { "fetched_at": "...", "items": [{ "url": null, "provider": "paste", "title": "...", "messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...], "message_count": N }] } ``` 3. Write `meta.json`: `{"provider": "paste", "url": null, "created_at": "..."}` 4. Proceed to Extract Graph using the parsed messages. ### Share Link Guide (Way 1 only) Users often give the wrong link type. Correct them if needed: | Platform | Share link ✓ | Wrong link ✗ | |----------|-------------|-------------| | ChatGPT | `chatgpt.com/share/xxx` | `chatgpt.com/c/xxx` (private chat URL) | | Claude | `claude.ai/share/xxx` | `claude.ai/chat/xxx` (private chat URL) | | Gemini | `gemini.google.com/share/xxx` or `g.co/gemini/share/xxx` | `gemini.google.com/app/xxx` (app URL) | | DeepSeek | `chat.deepseek.com/share/xxx` | `chat.deepseek.com/a/xxx` (private chat URL) | | Doubao | `www.doubao.com/thread/xxx` | `www.doubao.com/chat/xxx` (private chat URL) | Note: `g.co/gemini/share/...` short links work — script auto-resolves them. ## Extract Graph Read `references/graph-schema.md` for node types, edge types, label rules, and output schema. Critical rules (non-obvious): - `turn_id`: assign actual turn number (1-based). One user message + one AI response = 1 turn — not one message per turn. A 3-round conversation yields turn_id 1, 2, 3 only. Never default all nodes to the same value — this drives horizontal spread in the D3 visualization. - Extraction density: scale with the substance of each turn. Brief or routine turns may yield 1-2 nodes; rich, multi-point turns can yield more. Let the content guide the count — the goal is to capture the meaningful thinking, not to hit a fixed number. - Edges: only add if the connection passes the "obviously yes" test. - Reasoning shifts: look for moments where thinking fundamentally changed. Capture what changed, from what, to what, and why. - Chinese strings: use `「」` not curly quotes `""` inside JSON values — curly quotes break JSON parsing. ## Output 1. Write `<run_dir>/graph.json` Before proceeding: verify `graph.json` exists and contains at least one node. If extraction failed or produced an empty graph, delete `<run_dir>` entirely and stop — do not render, do not update the index. 2. Render (path consistency rule: index root is auto-locked to `dirname(<run_dir>)`, so graph and index always stay in the same workspace tree): ```bash bash scripts/run.sh render <run_dir> # validates JSON, renders graph.html + graph.png (screenshot, requires Playwright) # then rebuilds <ymind_dir>/index.json and <ymind_dir>/index.html automatically ``` `graph.html` is a **split-view** output when `raw_chat.json` is present in `<run_dir>`: left panel shows the original conversation, right panel shows the thinking map. If `raw_chat.json` is absent, only the graph is shown (no split). 3. Output Markdown summary (format in `references/graph-schema.md`). 4. If running as a bot with chat output capability, send the graph image — see **Bot Send** below. Run dir files: `raw_chat.json`, `graph.json`, `graph.html`, `graph.png` (requires Playwright), `meta.json`. Workspace files (auto-updated after every render, in the same root as `<run_dir>`): - `<ymind_dir>/index.json` — machine-readable session registry - `<ymind_dir>/index.html` — visual timeline index, opens each session's graph.html To rebuild the index manually at any time: ```bash bash scripts/run.sh index ``` ## Bot Send If `message` tool is available and `graph.png` was generated, send the graph image (works on OpenClaw, Kimi Claw, and similar — detect by tool availability): - If `graph.png` is outside the bot's media allow-roots, copy it to `<workspace>/.outbox/` first, then send the copied path. - Send via `message` tool using the `filePath` (or `media`) field — not as plain text, not as base64. - If send fails, skip silently and continue with the HTML link and Markdown summary. ## Language Rule All output (labels, summaries, analysis) must match the conversation language. ## Setup **Minimal (paste text only — zero dependencies):** No pip install needed. `render-html.py` is stdlib-only, and the paste path skips `fetch-chat.py` entirely. **Full (auto-fetch URLs + screenshot):** ```bash pip install requests playwright && playwright install chromium ``` Without Playwright: paste works fully, render produces `graph.html` but skips `graph.png` screenshot. ## Notes - Long conversations (20+ turns): focus on most significant nodes, skip low-substance turns. - Multiple topics: group nodes by topic.

path: requirements.txt
content: # YMind chat-visualizer-ymind — Python dependencies (requires Python 3.10+)
#
# Minimal (paste text only): no install needed — render-html.py is stdlib only.
# Full (auto-fetch URLs + screenshot):
#   pip install requests playwright && playwright install chromium

requests>=2.28      # fetch-chat.py only
playwright>=1.40    # fetch-chat.py (Gemini/Claude) + render-html.py (screenshot)


path: references/graph-schema.md
content: # YMind Graph Schema Reference

## Node Types - Physics of Thought

All human thinking follows a universal pattern:

| Type | Meaning | Examples |
|------|---------|---------|
| `fact` | Context, anchors, established information | Background, status quo, constraints, options mentioned |
| `friction` | Tensions, blockers, unresolved questions | Contradictions, doubts, fears, trade-offs, gaps |
| `spark` | Insights, reframes, breakthroughs | "Aha!" moments, new perspectives, realizations |
| `action` | Concrete next steps, decisions | Commitments, plans, things to do |

### Thinking Flow Pattern

```
fact -> friction -> spark -> action
(context)  (tension)  (insight)  (next step)
```

Not every conversation follows this linearly. Real conversations loop, branch, and revisit.

## Edge Types

| Relation | Direction | Meaning | Example |
|----------|-----------|---------|---------|
| `causes` | Fact -> Friction | This context creates this tension | "Limited budget" causes "Can't hire enough" |
| `opposes` | Node <-> Node | These are in conflict | "Move fast" opposes "Be thorough" |
| `resolves` | Spark/Action -> Friction | This addresses the tension | "Hire contractors" resolves "Can't hire enough" |
| `leads_to` | Spark -> Action | This insight leads to this step | "Realized MVP is enough" leads_to "Ship by Friday" |

### Relation Quality Rule

Only add a relation if it passes the "obviously yes" test:
"If I explained this connection to the user, would they immediately agree?"

Avoid:
- Connecting nodes just because they share a topic
- Forcing relations between every node
- Creating chains that don't reflect actual reasoning

## Reasoning Shift Types

| Type | Meaning | Signal |
|------|---------|--------|
| `reframe` | Perspective changed | "Actually, looking at it differently..." |
| `contradiction` | Counterargument shifted direction | "But that doesn't work because..." |
| `breakthrough` | Sudden clarity | "Oh! That means..." |
| `pivot` | Abandoned one path for another | "Let's try a completely different approach" |
| `decision` | Chose between options | "OK, I'll go with option B" |

## Label Extraction Rules

Labels should be extracted from actual words, not invented abstractions:

| Bad (abstract) | Good (from source) |
|---|---|
| "Monetization Confusion" | "How To Monetize Skills" |
| "Execution Gap" | "Missing Next Steps" |
| "变现困惑" | "怎么变现能力" |

Rules:
- Keep short enough to fit on a graph node — a concise phrase, not a sentence
- English: Title Case
- Chinese: natural phrasing
- Must be recognizable if user sees it

## Markdown Summary Format

After the JSON block, output a human-readable summary:

```
## Thinking Map: {title}

### Reasoning Timeline
{For each reasoning shift, show the flow with arrows}

Problem/Context
  ->
Hypothesis/Exploration
  ->
Turning Point: {what changed}
  ->
Insight: {key realization}
  ->
Action: {what to do}

### Key Insights
- {spark nodes, ranked by importance}

### Action Items
- [ ] {action 1} (priority)
- [ ] {action 2} (priority)

### Node Summary
| Type | Count | Key Examples |
|------|-------|-------------|
| Fact | N | ... |
| Friction | N | ... |
| Spark | N | ... |
| Action | N | ... |
```

## Output JSON Schema

```json
{
  "meta": {
    "title": "string - short conversation title",
    "total_turns": "number - total conversation turns",
    "language": "string - detected language code (en/zh/etc)"
  },
  "graph": {
    "nodes": [
      {
        "id": "string - unique identifier (n1, n2, ...) ",
        "label": "string - concise phrase from actual conversation",
        "type": "fact | friction | spark | action",
        "rich_summary": "string - 1-2 sentence self-contained description",
        "source": "user | ai",
        "turn_id": "number - which conversation turn (1-based). One user message + one AI response = 1 turn. A 3-round conversation has turn_id 1, 2, 3 — never higher."
      }
    ],
    "edges": [
      {
        "source": "string - source node id",
        "target": "string - target node id",
        "relation_type": "causes | opposes | resolves | leads_to"
      }
    ]
  },
  "reasoning_shifts": [
    {
      "from_node": "string - node id where old thinking was",
      "to_node": "string - node id where new thinking emerged",
      "type": "reframe | contradiction | breakthrough | pivot | decision",
      "description": "string - what changed and why"
    }
  ],
  "actions": [
    {
      "content": "string - specific action item",
      "priority": "high | medium | low",
      "related_nodes": ["string - related node ids"]
    }
  ],
  "summary": "string - 2-3 sentence overview"
}
```


path: README.md
content: **English** | [中文](README-zh.md)

# chat-visualizer-ymind

An AI agent skill for OpenClaw, Claude Code, and Codex. Visualize any AI chat conversation into a structured thinking map — extract the reasoning behind decisions, surface friction points, and trace how ideas evolved.

**Philosophy:** Chat is linear. Thinking isn't. AI conversations are dense with insight — but insights scatter across lengthy threads. You forget key breakthroughs, repeat conversations unknowingly, miss the connections. This skill externalizes your thinking: every conversation becomes a navigable graph of what you actually figured out.

## What it does

Share a conversation URL → get an interactive force graph with:
- **Reasoning nodes** — facts, frictions, sparks, and actions extracted from the conversation
- **Thinking shift detection** — where did the conversation turn?
- **Action items** — extracted, prioritized, and grouped by friction context
- **D3.js visualization** — dark-mode interactive graph, shareable as a single HTML file

![Titanic and the Utilitarian Filter](assets/graph-demo.png)

## Quick Start

1. Install the skill (see [Install](#install))
2. Share a conversation URL with your agent:
   ```
   Visualize this: https://chatgpt.com/share/xxx
   ```
3. The agent fetches the conversation, extracts the thinking graph, and renders an HTML file
4. Open `graph.html` — explore nodes, trace action paths, export insights

**Auto-fetch** (share link → agent fetches automatically): ChatGPT, Gemini, Claude, DeepSeek, Doubao.

**Paste** (no share link? Ctrl+A → copy → paste to your agent): works with any AI tool — no URL needed.

Want auto-fetch support for another provider? [Open an issue](https://github.com/yslenjoy/chat-visualizer-ymind/issues).

## Install

### ClawHub

```bash
npx clawhub@latest install chat-visualizer-ymind
```

### Manual Install

#### OpenClaw

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.openclaw/skills/chat-visualizer-ymind
```

#### Claude Code

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.claude/skills/chat-visualizer-ymind
```

#### Codex

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.codex/skills/chat-visualizer-ymind
```

## Output

Results are saved to `~/ymind-ws/` by default (override with `YMIND_DIR`). Each run is a self-contained folder — your personal thinking map library.

```
~/ymind-ws/
  index.html                    ← visual timeline of all sessions
  index.json                    ← machine-readable session registry
  20260319-143021_chatgpt/
    raw_chat.json               ← fetched conversation
    graph.json                  ← extracted thinking graph
    graph.html                  ← D3.js visualization (split view with chat panel if raw_chat.json exists)
    graph.png                   ← screenshot
    meta.json                   ← provider, url, title, created_at
```

## Privacy

- **No cookies stored** — fetching uses requests + Playwright in a fresh context; no session data is cached between runs
- **Everything stays local** — all output is saved to your machine only, nothing is uploaded
- **No external services for data** — all conversation data stays local; the rendered HTML loads fonts and visualization libraries (D3.js, html2canvas) from CDN and requires an internet connection to display correctly
- Conversation URLs are only used to fetch the public share page you provide

## Pipeline

```
Input
  ├─ Share URL   → scripts/run.sh fetch → fetch-chat.py → raw_chat.json
  └─ Paste text  → tell your agent the conversation directly (no fetch needed)
        │
  [LLM] SKILL.md → analyze → graph.json
        │
  scripts/run.sh render → render-html.py → graph.html + graph.png
        │
  (auto) rebuild index.html + index.json  ← your full session library
```

## Dependencies

Requires Python 3.10+.

**Minimal** (paste mode):  — no install needed.

**Full** (auto-fetch URLs + screenshot):
```bash
pip install requests playwright && playwright install chromium
```

## License

MIT


path: scripts/fetch-chat.py
content: #!/usr/bin/env python3
"""
Fetch shared chat content from chatbot share links.

Supported:
- ChatGPT:  https://chatgpt.com/share/...        (JSON API, Playwright fallback on 403)
- Gemini:   https://gemini.google.com/share/...  (Playwright)
- Claude:   https://claude.ai/share/...          (Playwright, headed mode for Cloudflare)
- DeepSeek: https://chat.deepseek.com/share/...  (Playwright, __NEXT_DATA__ extraction)
- Doubao:   https://www.doubao.com/thread/...    (JSON API, no Playwright needed)

Example:
  python3 fetch-chat.py "https://chatgpt.com/share/..." --out raw_chat.json
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse

import requests


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
}


def _fetch_html(url: str, timeout: int) -> requests.Response:
    resp = requests.get(url, headers=HEADERS, timeout=timeout)
    resp.raise_for_status()
    return resp


def _fetch_json(url: str, timeout: int) -> Dict[str, Any]:
    headers = {**HEADERS, "Accept": "application/json"}
    resp = requests.get(url, headers=headers, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


def _text_normalize(text: str) -> str:
    text = text.replace("\u200b", "").replace("\ufeff", "")
    text = re.sub(r"[ \t]+\n", "\n", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def _resolve_url(url: str) -> str:
    """Follow redirects to get the final URL (handles short links like g.co)."""
    try:
        r = requests.head(url, headers=HEADERS, allow_redirects=True, timeout=10)
        return r.url
    except Exception:
        return url


def _guess_provider(url: str) -> str:
    host = urlparse(url).netloc.lower()
    if "chatgpt.com" in host:
        return "chatgpt"
    if "claude.ai" in host:
        return "claude"
    if "gemini.google.com" in host:
        return "gemini"
    if "chat.deepseek.com" in host:
        return "deepseek"
    if "doubao.com" in host:
        return "doubao"
    # Short link: follow redirect then re-check
    if host in ("g.co", "goo.gl") or url.startswith("https://g.co/"):
        resolved = _resolve_url(url)
        if resolved != url:
            return _guess_provider(resolved)
    return "unknown"


def _extract_title(html: str) -> Optional[str]:
    m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S | re.I)
    if m:
        return m.group(1).strip()
    return None


# --- ChatGPT ---

def _collect_messages_from_mapping(mapping: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Parse ChatGPT's mapping structure into a message list."""
    messages: List[Dict[str, Any]] = []
    for node in mapping.values():
        msg = node.get("message") if isinstance(node, dict) else None
        if not msg:
            continue
        role = msg.get("author", {}).get("role") if isinstance(msg, dict) else None
        content = msg.get("content") if isinstance(msg, dict) else None
        parts = []
        if isinstance(content, dict):
            parts = content.get("parts") or []
        text = "\n".join(p for p in parts if isinstance(p, str))
        if not text:
            continue
        messages.append(
            {
                "id": msg.get("id"),
                "role": role or "unknown",
                "content": _text_normalize(text),
                "created_at": msg.get("create_time"),
            }
        )
    messages.sort(key=lambda m: m.get("created_at") or 0)
    return messages


def _fetch_chatgpt_playwright(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """ChatGPT Playwright fallback: render share page headlessly (no cookies saved)."""
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
        page.wait_for_timeout(8000)

        title = page.title() or None

        messages: List[Dict[str, Any]] = []
        turns = page.query_selector_all("[data-message-author-role]")
        for turn in turns:
            role_attr = turn.get_attribute("data-message-author-role")
            if role_attr not in ("user", "assistant"):
                continue
            text = _text_normalize(turn.inner_text() or "")
            if text:
                messages.append({"role": role_attr, "content": text})

        browser.close()

    return title, messages


def _fetch_chatgpt(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """ChatGPT: try /backend-api/share/{id} first; fall back to Playwright on 403."""
    import requests as _requests
    share_id = urlparse(url).path.rstrip("/").split("/")[-1]
    api_url = f"https://chatgpt.com/backend-api/share/{share_id}"
    try:
        data = _fetch_json(api_url, timeout)
        title = data.get("title")
        mapping = data.get("mapping", {})
        messages = _collect_messages_from_mapping(mapping)
        return title, messages
    except _requests.HTTPError as exc:
        if exc.response is not None and exc.response.status_code == 403:
            print("  [ChatGPT] API returned 403, falling back to Playwright ...")
            return _fetch_chatgpt_playwright(url, timeout)
        raise


# --- Gemini (Playwright) ---

def _fetch_gemini(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """Gemini: use Playwright to render the share page and extract messages."""
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
        # Wait for JS to render the conversation (Gemini share pages need ~12s)
        page.wait_for_timeout(12000)

        title = page.title() or None
        # Clean up default title
        if title and "gemini" in title.lower() and "direct access" in title.lower():
            title = None

        messages: List[Dict[str, Any]] = []

        # Each turn is a .share-turn-viewer containing one user query + one AI response
        turns = page.query_selector_all(".share-turn-viewer")
        if not turns:
            # Fallback: try extracting from the whole page
            turns = [page.query_selector("chat-app")]

        for turn in turns:
            if not turn:
                continue
            # User query (.query-text-line is legacy, .query-text is current)
            query_els = turn.query_selector_all(".query-text")
            if not query_els:
                query_els = turn.query_selector_all(".query-text-line")
            if query_els:
                user_text = "\n".join(
                    _text_normalize(el.inner_text() or "") for el in query_els
                )
                if user_text:
                    messages.append({
                        "role": "user",
                        "content": user_text,
                    })

            # AI response
            response_el = turn.query_selector(".message-content")
            if response_el:
                ai_text = _text_normalize(response_el.inner_text() or "")
                if ai_text:
                    messages.append({
                        "role": "assistant",
                        "content": ai_text,
                    })

        # Get conversation title from the h1 headline
        h1 = page.query_selector("h1.headline")
        if h1:
            title = _text_normalize(h1.inner_text() or "") or title

        browser.close()

    return title, messages


# --- Claude (Playwright, headed mode for Cloudflare) ---

def _launch_headed_browser(p, timeout_ms: int):
    """Launch a headed Chromium browser for platforms that require a standard browser environment (e.g. Cloudflare-protected share pages)."""
    browser = p.chromium.launch(
        headless=False,
        args=["--disable-blink-features=AutomationControlled"],
    )
    context = browser.new_context(
        user_agent=(
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
        ),
        viewport={"width": 1280, "height": 720},
        locale="en-US",
    )
    page = context.new_page()
    page.add_init_script("Object.defineProperty(navigator, 'webdriver', { get: () => false });")
    return browser, page


def _fetch_claude(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """Claude: use Playwright in headed mode to bypass Cloudflare and extract messages."""
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser, page = _launch_headed_browser(p, timeout * 1000)
        page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
        # Wait for Cloudflare challenge + page render
        page.wait_for_timeout(15000)

        title = page.title() or None
        # Clean up " | Claude" suffix
        if title:
            title = re.sub(r"\s*\|\s*Claude\s*$", "", title).strip() or None
        # Skip Cloudflare challenge page title
        if title and "just a moment" in title.lower():
            title = None

        messages: List[Dict[str, Any]] = []

        # User messages: div with class containing "font-user-message"
        user_els = page.query_selector_all('[class*="font-user-message"]')
        # AI responses: top-level div with class containing "font-claude-response"
        ai_els = page.query_selector_all('[class*="font-claude-response"]:not(p)')

        # Build ordered message list by interleaving user/assistant
        # They appear in DOM order, alternating
        u_idx, a_idx = 0, 0
        # Collect all with positions for proper ordering
        ordered = []
        for el in user_els:
            text = _text_normalize(el.inner_text() or "")
            if text:
                bbox = el.bounding_box()
                y = bbox["y"] if bbox else u_idx * 10000
                ordered.append((y, "user", text))
                u_idx += 1
        for el in ai_els:
            text = _text_normalize(el.inner_text() or "")
            if text:
                bbox = el.bounding_box()
                y = bbox["y"] if bbox else a_idx * 10000 + 5000
                ordered.append((y, "assistant", text))
                a_idx += 1

        ordered.sort(key=lambda x: x[0])
        for _, role, text in ordered:
            messages.append({"role": role, "content": text})

        browser.close()

    return title, messages


# --- DeepSeek (Playwright) ---

def _fetch_deepseek(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """DeepSeek: render share page with Playwright headed mode (CloudFront blocks headless)."""
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser, page = _launch_headed_browser(p, timeout * 1000)
        page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
        page.wait_for_timeout(8000)

        title = page.title() or None

        messages: List[Dict[str, Any]] = []

        # Strategy 1: extract from embedded __NEXT_DATA__ JSON (most reliable for Next.js apps)
        next_data_text: Optional[str] = page.evaluate(
            "() => { const el = document.getElementById('__NEXT_DATA__'); return el ? el.textContent : null; }"
        )
        if next_data_text:
            try:
                data = json.loads(next_data_text)
                page_props = data.get("props", {}).get("pageProps", {})
                # DeepSeek may nest conversation under different keys
                conversation = (
                    page_props.get("conversation")
                    or page_props.get("chat_session")
                    or page_props.get("session")
                )
                raw_messages = None
                if isinstance(conversation, dict):
                    raw_messages = (
                        conversation.get("messages")
                        or conversation.get("chat_messages")
                    )
                elif isinstance(page_props, dict):
                    raw_messages = (
                        page_props.get("messages")
                        or page_props.get("chat_messages")
                    )
                if raw_messages:
                    for msg in raw_messages:
                        role = msg.get("role") or msg.get("author_type") or ""
                        if role in ("user", "human"):
                            role = "user"
                        elif role in ("assistant", "bot", "ai"):
                            role = "assistant"
                        else:
                            continue
                        content = msg.get("content") or msg.get("text") or ""
                        if isinstance(content, list):
                            content = "\n".join(
                                c.get("text", "") if isinstance(c, dict) else str(c)
                                for c in content
                            )
                        content = _text_normalize(str(content))
                        if content:
                            messages.append({"role": role, "content": content})
            except (json.JSONDecodeError, KeyError, TypeError):
                pass

        # Strategy 2: DOM scraping
        # DeepSeek share page structure:
        #   User turn:      .ds-message.d29f3d7d  →  child .fbb737a4 holds text
        #   Assistant turn: .ds-message (no d29f3d7d)  →  .ds-markdown holds response
        if not messages:
            turn_els = page.query_selector_all(".ds-message")
            for el in turn_els:
                cls = el.get_attribute("class") or ""
                if "d29f3d7d" in cls:
                    # User message
                    text_el = el.query_selector(".fbb737a4")
                    text = _text_normalize((text_el.inner_text() if text_el else el.inner_text()) or "")
                    if text:
                        messages.append({"role": "user", "content": text})
                else:
                    # Assistant message — extract .ds-markdown outside the thinking block
                    # (._74c0879 is the chain-of-thought collapsible; skip its inner ds-markdown)
                    texts: List[str] = el.evaluate(
                        """el => {
                            const thinking = el.querySelector('._74c0879');
                            return [...el.querySelectorAll('.ds-markdown')]
                                .filter(md => !thinking || !thinking.contains(md))
                                .map(md => md.innerText || '');
                        }"""
                    )
                    text = "\n\n".join(_text_normalize(t) for t in texts if t).strip()
                    if text:
                        messages.append({"role": "assistant", "content": text})

        browser.close()

    return title, messages


# --- Doubao ---

def _extract_doubao_text(content_raw: str, is_user: bool) -> str:
    """Extract plain text from a Doubao message content JSON string."""
    try:
        blocks = json.loads(content_raw)
    except (json.JSONDecodeError, TypeError):
        return ""
    parts: List[str] = []
    for block in blocks:
        if block.get("block_type") != 10000:
            continue  # skip block_type 10040 (thinking metadata) and others
        c = block.get("content", {})
        if is_user:
            # User messages: content is a dict with text_block.text
            if isinstance(c, dict):
                text = c.get("text_block", {}).get("text", "")
                if text:
                    parts.append(text)
        else:
            # Assistant messages: content is a JSON string
            if isinstance(c, str):
                try:
                    inner = json.loads(c)
                except json.JSONDecodeError:
                    continue
            elif isinstance(c, dict):
                inner = c
            else:
                continue
            # Skip thinking blocks (they carry icon_url for the Deep Think icon)
            if "icon_url" in inner:
                continue
            text = inner.get("text", "")
            if text:
                parts.append(text)
    return _text_normalize("\n\n".join(parts))


def _fetch_doubao(url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    """Doubao: call the share snapshot API directly (no Playwright needed)."""
    share_id = urlparse(url).path.rstrip("/").split("/")[-1]
    api_url = "https://www.doubao.com/samantha/thread/share/snapshot/get"
    params = {
        "version_code": "20800",
        "language": "zh",
        "device_platform": "web",
        "aid": "497858",
        "real_aid": "497858",
        "pkg_type": "release_version",
        "device_id": "",
        "pc_version": "3.11.1",
        "samantha_web": "1",
        "use-olympus-account": "1",
    }
    headers = {
        **HEADERS,
        "Content-Type": "application/json",
        "Referer": url,
        "Origin": "https://www.doubao.com",
    }
    resp = requests.post(api_url, params=params, json={"share_id": share_id, "need_bot": False},
                         headers=headers, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    if data.get("code") != 0:
        raise ValueError(f"Doubao API error: {data.get('msg')}")

    share_info = data["data"]["share_info"]
    title: Optional[str] = share_info.get("share_name") or None
    raw_messages = data["data"]["message_snapshot"]["message_list"]

    messages: List[Dict[str, Any]] = []
    for msg in raw_messages:
        is_user = msg.get("reply_id") == "0"
        text = _extract_doubao_text(msg.get("content", ""), is_user)
        if text:
            messages.append({
                "role": "user" if is_user else "assistant",
                "content": text,
            })

    return title, messages


# --- Dispatch ---

def _fetch_provider(provider: str, url: str, timeout: int) -> Tuple[Optional[str], List[Dict[str, Any]]]:
    if provider == "chatgpt":
        return _fetch_chatgpt(url, timeout)
    if provider == "gemini":
        return _fetch_gemini(url, timeout)
    if provider == "claude":
        return _fetch_claude(url, timeout)
    if provider == "deepseek":
        return _fetch_deepseek(url, timeout)
    if provider == "doubao":
        return _fetch_doubao(url, timeout)
    raise ValueError(f"Unknown provider: {provider}")


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Fetch chatbot share pages and extract messages.")
    parser.add_argument("urls", nargs="+", help="Share URLs to fetch")
    parser.add_argument("--out", default="shared_chats.json", help="Output JSON path")
    parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout (seconds)")
    return parser.parse_args()


def main() -> int:
    args = _parse_args()
    results: List[Dict[str, Any]] = []
    errors: List[Dict[str, Any]] = []

    for url in args.urls:
        fetch_url = url
        provider = _guess_provider(url)
        if provider == "unknown":
            fetch_url = _resolve_url(url)
            provider = _guess_provider(fetch_url)
            if fetch_url != url:
                print(f"Resolved: {url} -> {fetch_url}")
        print(f"Fetching [{provider}]: {fetch_url} ...")
        try:
            title, messages = _fetch_provider(provider, fetch_url, timeout=args.timeout)
            results.append(
                {
                    "url": url,
                    "provider": provider,
                    "title": title,
                    "messages": messages,
                    "message_count": len(messages),
                }
            )
            print(f"  -> {len(messages)} message(s) extracted")
        except Exception as exc:
            print(f"  -> ERROR: {exc}", file=sys.stderr)
            errors.append({"url": url, "provider": provider, "error": str(exc)})

    payload = {
        "fetched_at": _now_iso(),
        "items": results,
        "errors": errors,
    }

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)

    print(f"\nWrote {len(results)} item(s) to {args.out}")
    if errors:
        print(f"{len(errors)} error(s) encountered. See 'errors' in output.", file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run.sh
content: #!/usr/bin/env bash
# run.sh — YMind pipeline helper
#
# Usage:
#   run.sh fetch "<url>"        Create run dir, fetch chat → prints RUN_DIR
#   run.sh render <run_dir>     Validate graph.json, render HTML + screenshot
#
# Output directory: $YMIND_DIR (default: ~/ymind-ws)

set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
YMIND_DIR="${YMIND_DIR:-$HOME/ymind-ws}"

usage() {
    echo "Usage:"
    echo "  run.sh fetch \"<url>\"       Create run dir, fetch chat"
    echo "  run.sh render <run_dir>    Validate graph.json, render HTML + screenshot"
    echo "  run.sh index               Rebuild $YMIND_DIR/index.json from all run dirs"
    exit 1
}

_detect_provider() {
    case "$1" in
      *chatgpt.com*)        echo "chatgpt" ;;
      *gemini.google.com*)  echo "gemini" ;;
      *g.co/gemini*)        echo "gemini" ;;
      *g.co/*)              echo "gemini" ;;
      *claude.ai*)          echo "claude" ;;
      *deepseek.com*)       echo "deepseek" ;;
      *doubao.com*)         echo "doubao" ;;
      *)                    echo "chat" ;;
    esac
}

cmd="${1:-}"

case "$cmd" in
  fetch)
    URL="${2:?run.sh fetch requires a URL}"
    PROVIDER="$(_detect_provider "$URL")"
    TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
    RUN_DIR="$YMIND_DIR/${TIMESTAMP}_${PROVIDER}"
    mkdir -p "$RUN_DIR"
    echo "Run dir: $RUN_DIR"

    # Write initial meta.json (title added later by render)
    PROVIDER="$PROVIDER" URL="$URL" RUN_DIR="$RUN_DIR" \
    CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" python3 - <<'PYEOF'
import json, os
data = {
    "provider": os.environ["PROVIDER"],
    "url": os.environ["URL"],
    "created_at": os.environ["CREATED_AT"],
}
with open(os.path.join(os.environ["RUN_DIR"], "meta.json"), "w", encoding="utf-8") as f:
    json.dump(data, f, ensure_ascii=False, indent=2)
print("meta.json: written")
PYEOF

    python3 "$SCRIPT_DIR/fetch-chat.py" "$URL" --out "$RUN_DIR/raw_chat.json"
    echo ""
    echo "RUN_DIR=$RUN_DIR"
    ;;

  render)
    RUN_DIR="${2:?run.sh render requires a run_dir path}"
    RUN_DIR="$(cd "$RUN_DIR" 2>/dev/null && pwd || true)"
    if [ -z "$RUN_DIR" ]; then
        echo "Error: invalid run_dir path" >&2
        exit 1
    fi
    # Force index workspace to match the run directory root.
    # This guarantees graph/meta/index stay under the same YMIND_DIR tree.
    YMIND_DIR="$(cd "$RUN_DIR/.." && pwd)"
    echo "Workspace dir: $YMIND_DIR"
    GRAPH="$RUN_DIR/graph.json"
    if [ ! -d "$RUN_DIR" ]; then
        echo "Error: run dir not found: $RUN_DIR" >&2
        exit 1
    fi
    if [ ! -f "$RUN_DIR/raw_chat.json" ]; then
        echo "Error: $RUN_DIR/raw_chat.json not found" >&2
        echo "Hint: use the RUN_DIR returned by fetch, and write graph.json in that same directory." >&2
        exit 1
    fi
    if [ ! -f "$RUN_DIR/meta.json" ]; then
        echo "Error: $RUN_DIR/meta.json not found" >&2
        echo "Hint: use the RUN_DIR returned by fetch, and write graph.json in that same directory." >&2
        exit 1
    fi
    if [ ! -f "$GRAPH" ]; then
        echo "Error: $GRAPH not found" >&2
        echo "Hint: extract output must be saved as $RUN_DIR/graph.json (same RUN_DIR as fetch)." >&2
        exit 1
    fi
    GRAPH="$GRAPH" python3 - <<'PYEOF'
import json, re, os, sys

graph_path = os.environ["GRAPH"]
with open(graph_path, encoding="utf-8") as f:
    raw = f.read()

# Try parsing first
try:
    json.loads(raw)
    print("graph.json: valid")
    sys.exit(0)
except json.JSONDecodeError as e:
    pass

# Parse failed — scan for unescaped " inside JSON string values
# Tokenize line by line: inside a string value, a bare " (0x22) that isn't
# the opening/closing delimiter or an already-escaped \" is the culprit.
issues = []
in_string = False
escape_next = False
line_num = 1
col_num = 0
string_start = None

for i, ch in enumerate(raw):
    col_num += 1
    if ch == '\n':
        line_num += 1
        col_num = 0

    if escape_next:
        escape_next = False
        continue

    if ch == '\\' and in_string:
        escape_next = True
        continue

    if ch == '"':
        if not in_string:
            in_string = True
            string_start = (line_num, col_num)
        else:
            in_string = False

# If no issues found via scan, just show the raw JSON error
if not issues:
    # Re-raise with context
    lines = raw.splitlines()
    try:
        json.loads(raw)
    except json.JSONDecodeError as e:
        ctx_line = lines[e.lineno - 1] if e.lineno <= len(lines) else ""
        pointer = ' ' * (e.colno - 1) + '^'
        print(f"graph.json: INVALID — {e.msg}", file=sys.stderr)
        print(f"  line {e.lineno}, col {e.colno}:", file=sys.stderr)
        print(f"  {ctx_line}", file=sys.stderr)
        print(f"  {pointer}", file=sys.stderr)
        # Check if a bare " appears near the error position in the source line
        near = ctx_line[max(0, e.colno-15):e.colno+5]
        if '"' in near:
            print(f'  Hint: bare " detected near error — replace with 「」 inside string values.', file=sys.stderr)
    sys.exit(1)

print(f"graph.json: INVALID — found {len(issues)} unescaped quote(s) inside string value(s)", file=sys.stderr)
print("Fix: replace bare \" with 「」 in the affected fields.", file=sys.stderr)
for line_no, col_no, ctx in issues:
    print(f"  line {line_no}, col {col_no}: ...{ctx}...", file=sys.stderr)
sys.exit(1)
PYEOF
    python3 "$SCRIPT_DIR/render-html.py" "$GRAPH" --out "$RUN_DIR/graph.html" --screenshot

    # Update meta.json with title extracted from graph.json
    RUN_DIR="$RUN_DIR" python3 - <<'PYEOF'
import json, os
run_dir = os.environ["RUN_DIR"]
meta_path = os.path.join(run_dir, "meta.json")
graph_path = os.path.join(run_dir, "graph.json")
meta = {}
if os.path.exists(meta_path):
    with open(meta_path, encoding="utf-8") as f:
        meta = json.load(f)
if os.path.exists(graph_path):
    with open(graph_path, encoding="utf-8") as f:
        graph = json.load(f)
    meta["title"] = graph.get("meta", {}).get("title", "")
with open(meta_path, "w", encoding="utf-8") as f:
    json.dump(meta, f, ensure_ascii=False, indent=2)
print("meta.json: updated")
PYEOF

    # Rebuild workspace index
    bash "$SCRIPT_DIR/run.sh" index

    echo ""
    echo "Done: $RUN_DIR"
    ls -1 "$RUN_DIR"
    ;;

  index)
    YMIND_DIR="$YMIND_DIR" python3 - <<'PYEOF'
import json, os
from pathlib import Path

ymind_dir = Path(os.environ["YMIND_DIR"])
ymind_dir.mkdir(parents=True, exist_ok=True)
runs = []
for meta_path in sorted(ymind_dir.glob("*/meta.json"), reverse=True):
    try:
        with open(meta_path, encoding="utf-8") as f:
            meta = json.load(f)
        meta["run_dir"] = meta_path.parent.name
        runs.append(meta)
    except Exception:
        pass

index = {"generated_at": __import__("datetime").datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "runs": runs}
out = ymind_dir / "index.json"
with open(out, "w", encoding="utf-8") as f:
    json.dump(index, f, ensure_ascii=False, indent=2)
print(f"index.json: {len(runs)} run(s) → {out}")
PYEOF
    python3 "$SCRIPT_DIR/render-index.py" --ws "$YMIND_DIR"
    ;;

  *)
    usage
    ;;
esac


path: scripts/render-index.py
content: #!/usr/bin/env python3
"""
Regenerate ~/ymind-ws/index.html from index.json.

Usage:
  python3 render-index.py
  python3 render-index.py --ws ~/ymind-ws
  python3 render-index.py --ws ~/ymind-ws --out ~/ymind-ws/index.html
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

TEMPLATE_PATH = Path(__file__).parent / "templates" / "workspace-index.html"
DEFAULT_WS = Path.home() / "ymind-ws"


def main() -> int:
    parser = argparse.ArgumentParser(description="Render YMind workspace index HTML.")
    parser.add_argument("--ws", default=str(DEFAULT_WS), help="Workspace directory (default: ~/ymind-ws)")
    parser.add_argument("--out", default=None, help="Output HTML path (default: <ws>/index.html)")
    parser.add_argument("--template", default=None, help="Custom template path")
    args = parser.parse_args()

    ws = Path(args.ws).expanduser()
    index_json = ws / "index.json"

    if not index_json.exists():
        print(f"Error: {index_json} not found", file=sys.stderr)
        return 1

    with open(index_json, "r", encoding="utf-8") as f:
        index_data = json.load(f)

    tmpl_path = Path(args.template).expanduser() if args.template else TEMPLATE_PATH
    if not tmpl_path.exists():
        print(f"Error: template not found at {tmpl_path}", file=sys.stderr)
        return 1

    template = tmpl_path.read_text(encoding="utf-8")
    sessions_json = json.dumps(index_data, ensure_ascii=False, indent=2)
    html = template.replace("{{SESSIONS_JSON}}", sessions_json)

    out_path = Path(args.out).expanduser() if args.out else ws / "index.html"
    out_path.write_text(html, encoding="utf-8")
    print(f"Rendered: {out_path}  ({len(index_data.get('runs', []))} sessions)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/render-html.py
content: #!/usr/bin/env python3
"""
Render a YMind graph JSON into a self-contained HTML visualization.

Usage:
  python3 render-html.py graph.json
  python3 render-html.py graph.json --out ymindmap-output.html

Input: graph.json produced by SKILL.md (agent mode) or extract-graph.py (script mode).
Output: A single HTML file with the graph data embedded inline.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import threading
import time
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path

TEMPLATE_PATH = Path(__file__).parent / "templates" / "ymindmap.html"


def slugify(text: str, max_len: int = 40) -> str:
    """Turn a title into a filename-safe slug."""
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[\s_]+", "-", text)
    return text[:max_len].rstrip("-")


def load_chat_messages(graph_input_path: Path) -> list | None:
    """Load and clean messages from raw_chat.json in the same directory, if present."""
    raw_chat_path = graph_input_path.parent / "raw_chat.json"
    if not raw_chat_path.exists():
        return None
    try:
        with open(raw_chat_path, "r", encoding="utf-8") as f:
            raw = json.load(f)
        items = raw.get("items") or []
        if not items:
            return None
        messages = items[0].get("messages") or []
        cleaned = []
        for msg in messages:
            role = msg.get("role", "")
            content = msg.get("content", "")
            if role == "user" and content.startswith("You said\n\n"):
                content = content[len("You said\n\n"):]
            cleaned.append({"role": role, "content": content})
        return cleaned if cleaned else None
    except Exception:
        return None


def render(graph_json: dict, template: str, chat_messages: list | None = None) -> str:
    """Inject graph data (and optional chat messages) into the HTML template."""
    title = ""
    if "meta" in graph_json and graph_json["meta"]:
        title = graph_json["meta"].get("title", "")

    html = template.replace("{{TITLE}}", title or "Untitled")
    html = html.replace("{{GRAPH_JSON}}", json.dumps(graph_json, ensure_ascii=False))
    chat_json_str = json.dumps(chat_messages, ensure_ascii=False) if chat_messages is not None else "null"
    html = html.replace("{{CHAT_JSON}}", chat_json_str)
    return html


def main() -> int:
    parser = argparse.ArgumentParser(description="Render YMind graph as HTML.")
    parser.add_argument("input", help="Path to graph.json")
    parser.add_argument("--out", "-o", default=None, help="Output HTML path (default: auto-named)")
    parser.add_argument("--template", "-t", default=None, help="Custom template path")
    parser.add_argument("--screenshot", "-s", action="store_true", help="Also capture a PNG screenshot (requires playwright)")
    args = parser.parse_args()

    # Read input
    input_path = Path(args.input)
    if not input_path.exists():
        print(f"Error: {input_path} not found", file=sys.stderr)
        return 1

    with open(input_path, "r", encoding="utf-8") as f:
        graph_data = json.load(f)

    # Read template
    tmpl_path = Path(args.template) if args.template else TEMPLATE_PATH
    if not tmpl_path.exists():
        print(f"Error: template not found at {tmpl_path}", file=sys.stderr)
        return 1

    template = tmpl_path.read_text(encoding="utf-8")

    # Load chat messages for split view (optional)
    chat_messages = load_chat_messages(input_path)

    # Render
    html = render(graph_data, template, chat_messages)

    # Output path
    if args.out:
        out_path = Path(args.out)
    else:
        title = ""
        if "meta" in graph_data and graph_data["meta"]:
            title = graph_data["meta"].get("title", "")
        slug = slugify(title) if title else input_path.stem
        out_path = input_path.parent / f"ymindmap-{slug}.html"

    out_path.write_text(html, encoding="utf-8")
    print(f"Rendered: {out_path}")

    if args.screenshot:
        shot_path = out_path.with_suffix(".png")
        _take_screenshot(out_path, shot_path)

    return 0


def _take_screenshot(html_path: Path, out_path: Path) -> None:
    """Start a temp HTTP server and use Playwright to screenshot the graph."""
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("Warning: playwright not installed, skipping screenshot. Run: pip install playwright && playwright install chromium", file=sys.stderr)
        return

    # Pick a free port
    import socket
    with socket.socket() as s:
        s.bind(("", 0))
        port = s.getsockname()[1]

    # Serve from the HTML file's directory
    serve_dir = html_path.parent

    class _QuietHandler(SimpleHTTPRequestHandler):
        def __init__(self, *a, **kw):
            super().__init__(*a, directory=str(serve_dir), **kw)
        def log_message(self, *_):
            pass

    server = HTTPServer(("127.0.0.1", port), _QuietHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()

    url = f"http://127.0.0.1:{port}/{html_path.name}"
    try:
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            page = browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=2)
            page.goto(url, wait_until="networkidle")
            # Wait for D3 simulation to settle
            time.sleep(3)
            # Hide chat panel, re-fit graph, then screenshot
            page.evaluate("""
                document.body.classList.remove('split-mode');
                ['chatPanel','splitDivider'].forEach(id => {
                    const el = document.getElementById(id);
                    if (el) el.style.display = 'none';
                });
                if (typeof fitAll === 'function') fitAll(400);
            """)
            time.sleep(0.6)
            page.screenshot(path=str(out_path), full_page=False)
            browser.close()
        print(f"Screenshot: {out_path}")
    finally:
        server.shutdown()


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/check-version.py
content: #!/usr/bin/env python3
"""
Version check for chat-visualizer-ymind.
Uses __file__ to locate the skill directory — no hardcoded paths.

Output (stdout):
  UPDATE|<local>|<remote>|<skill_dir>|<release_notes_first_3_lines>
  (empty if already up to date or on any error)
"""
import os, sys, urllib.request, json

skill_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
version_file = os.path.join(skill_dir, "VERSION")

if not os.path.exists(version_file):
    sys.exit(0)

local = open(version_file).read().strip()

try:
    resp = urllib.request.urlopen(
        "https://api.github.com/repos/yslenjoy/chat-visualizer-ymind/releases/latest",
        timeout=3,
    )
    data = json.loads(resp.read())
    remote = data["tag_name"].lstrip("v")
    if remote == local:
        sys.exit(0)
    notes = "\n".join((data.get("body") or "").strip().splitlines()[:3])
    print(f"UPDATE|{local}|{remote}|{skill_dir}|{notes}")
except Exception:
    pass


path: scripts/templates/workspace-index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>YMind — Memory Index</title>
    <link rel="icon"
        href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 256 256%22><defs><linearGradient id=%22g%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22><stop offset=%220%25%22 stop-color=%22%2300F0FF%22/><stop offset=%22100%25%22 stop-color=%22%23BD00FF%22/></linearGradient></defs><path fill=%22url(%23g)%22 d=%22M198.1,62.59a76,76,0,0,0-140.2,0A71.71,71.71,0,0,0,16,127.8C15.9,166,48,199,86.14,200A72.09,72.09,0,0,0,120,192.47V232a8,8,0,0,0,16,0V192.47A72.17,72.17,0,0,0,168,200l1.82,0C208,199,240.11,166,240,127.8A71.71,71.71,0,0,0,198.1,62.59Z%22/></svg>">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;800&display=swap" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;500;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">

    <script>
    const INDEX_DATA = {{SESSIONS_JSON}};
    </script>

    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        :root {
            --aura-base: #030508;
            --aura-surface: #0B0F19;
            --aura-highlight: #1A2133;
            --aura-cyan: #00F0FF;
            --aura-purple: #BD00FF;
            --aura-gold: #FFE600;
            --aura-red: #FF4D4D;
            --aura-orange: #FF9500;
            --aura-green: #00E87A;
        }

        html { scroll-behavior: smooth; }

        body {
            font-family: 'Inter', sans-serif;
            background: radial-gradient(circle at 50% 30%, #13161c 0%, #080a10 60%, #030508 100%);
            color: #9ca3af;
            min-height: 100vh;
            font-weight: 300;
            overflow-x: hidden;
        }

        ::-webkit-scrollbar { width: 4px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: #334155; border-radius: 2px; }

        /* ── Aurora Background ── */
        .ambient-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
        .ambient-blob {
            position: absolute; border-radius: 50%;
            filter: blur(80px); mix-blend-mode: screen;
            will-change: transform, opacity;
        }
        .ambient-blob.violet {
            top: -10%; left: -5%; width: 55%; height: 55%; opacity: 0.18;
            background: radial-gradient(circle, rgba(112,0,255,0.55) 0%, rgba(112,0,255,0.08) 60%, transparent 80%);
            animation: aurora-drift-1 14s ease-in-out infinite;
        }
        .ambient-blob.cyan {
            bottom: -10%; right: -5%; width: 50%; height: 50%; opacity: 0.15;
            background: radial-gradient(circle, rgba(0,240,255,0.45) 0%, rgba(0,240,255,0.06) 60%, transparent 80%);
            animation: aurora-drift-2 18s ease-in-out infinite;
        }
        .ambient-blob.pink {
            top: 30%; right: 5%; width: 45%; height: 45%; opacity: 0.12;
            background: radial-gradient(circle, rgba(255,0,128,0.35) 0%, rgba(255,0,128,0.05) 60%, transparent 80%);
            animation: aurora-drift-3 16s ease-in-out infinite;
        }
        .ambient-blob.deep-cyan {
            bottom: 20%; left: 10%; width: 40%; height: 40%; opacity: 0.1;
            background: radial-gradient(circle, rgba(0,200,255,0.35) 0%, rgba(0,200,255,0.05) 60%, transparent 80%);
            animation: aurora-drift-4 12s ease-in-out infinite;
        }
        @keyframes aurora-drift-1 {
            0%,100% { transform: translate(0,0) scale(1); }
            25% { transform: translate(5%,8%) scale(1.1); }
            50% { transform: translate(-3%,15%) scale(0.95); }
            75% { transform: translate(8%,5%) scale(1.05); }
        }
        @keyframes aurora-drift-2 {
            0%,100% { transform: translate(0,0) scale(1); }
            30% { transform: translate(-8%,-5%) scale(1.15); }
            60% { transform: translate(5%,-10%) scale(0.9); }
            80% { transform: translate(-3%,-3%) scale(1.05); }
        }
        @keyframes aurora-drift-3 {
            0%,100% { transform: translate(0,0) scale(1); }
            20% { transform: translate(-10%,5%) scale(1.1); }
            50% { transform: translate(5%,-8%) scale(0.95); }
            70% { transform: translate(-5%,10%) scale(1.08); }
        }
        @keyframes aurora-drift-4 {
            0%,100% { transform: translate(0,0) scale(1); }
            35% { transform: translate(8%,-5%) scale(1.1); }
            65% { transform: translate(-5%,8%) scale(0.95); }
        }

        /* ── Layout ── */
        .page-wrap {
            position: relative; z-index: 10;
            max-width: 860px; margin: 0 auto;
            padding: 48px 24px 80px;
        }

        /* ── Header ── */
        .header {
            display: flex; flex-direction: column; align-items: flex-start;
            margin-bottom: 48px; gap: 16px;
        }
        .logo-row {
            display: flex; align-items: center; gap: 12px;
        }
        .logo-icon {
            width: 36px; height: 36px;
            background: linear-gradient(135deg, var(--aura-cyan), var(--aura-purple));
            border-radius: 10px;
            display: flex; align-items: center; justify-content: center;
            font-size: 18px; color: #fff; font-weight: 800;
            box-shadow: 0 0 20px rgba(0,240,255,0.3);
            flex-shrink: 0;
        }
        .logo-wordmark {
            font-family: 'Playfair Display', serif;
            font-size: 18px; font-weight: 600;
            background: linear-gradient(90deg, var(--aura-cyan), var(--aura-purple));
            -webkit-background-clip: text; -webkit-text-fill-color: transparent;
            background-clip: text;
        }

        .header-title {
            font-family: 'Playfair Display', serif;
            font-size: 38px; font-weight: 400;
            color: #fff; line-height: 1.2;
            letter-spacing: -0.02em;
        }
        .header-title em {
            font-style: italic;
            background: linear-gradient(90deg, var(--aura-cyan) 0%, var(--aura-purple) 100%);
            -webkit-background-clip: text; -webkit-text-fill-color: transparent;
            background-clip: text;
        }
        .header-subtitle {
            font-size: 13px; color: #6b7280; font-weight: 300; letter-spacing: 0.02em;
        }

        /* ── Stats strip ── */
        .stats-strip {
            display: flex; gap: 2px;
            background: rgba(255,255,255,0.03);
            border: 1px solid rgba(255,255,255,0.06);
            border-radius: 12px; padding: 2px;
            margin-bottom: 40px; width: fit-content;
        }
        .stat-pill {
            padding: 8px 18px; border-radius: 10px;
            font-size: 12px; font-weight: 400; color: #6b7280;
            display: flex; align-items: center; gap: 6px;
        }
        .stat-pill .val { font-weight: 600; color: #e5e7eb; font-size: 14px; }
        .stat-pill.active {
            background: rgba(0,240,255,0.07);
            border: 1px solid rgba(0,240,255,0.12);
            color: #9ca3af;
        }
        .stat-pill.active .val { color: var(--aura-cyan); }

        /* ── Provider filter tabs ── */
        .filter-row {
            display: flex; gap: 8px; margin-bottom: 32px; flex-wrap: wrap;
        }
        .filter-tab {
            padding: 6px 14px; border-radius: 20px;
            font-size: 11px; font-weight: 500; letter-spacing: 0.03em;
            border: 1px solid rgba(255,255,255,0.1);
            background: rgba(255,255,255,0.03);
            color: #6b7280; cursor: pointer;
            transition: all 0.2s ease;
            text-transform: uppercase;
            user-select: none;
        }
        .filter-tab:hover { color: #e5e7eb; border-color: rgba(255,255,255,0.2); }
        .filter-tab.active {
            color: #fff;
            border-color: var(--provider-color, var(--aura-cyan));
            background: rgba(var(--provider-rgb, 0,240,255), 0.1);
            box-shadow: 0 0 12px rgba(var(--provider-rgb, 0,240,255), 0.15);
        }

        /* ── Timeline ── */
        .timeline { position: relative; }

        /* vertical line */
        .timeline::before {
            content: '';
            position: absolute; left: 14px; top: 6px; bottom: 6px;
            width: 1px;
            background: linear-gradient(
                to bottom,
                transparent 0%,
                rgba(0,240,255,0.3) 8%,
                rgba(189,0,255,0.2) 50%,
                rgba(0,240,255,0.15) 92%,
                transparent 100%
            );
        }

        /* ── Date group ── */
        .date-group { margin-bottom: 36px; }
        .date-label {
            display: flex; align-items: center; gap: 12px;
            margin-bottom: 16px; padding-left: 0;
        }
        .date-dot {
            width: 8px; height: 8px; border-radius: 50%;
            background: var(--aura-cyan);
            box-shadow: 0 0 8px var(--aura-cyan);
            flex-shrink: 0; margin-left: 11px;
            position: relative; z-index: 1;
        }
        .date-text {
            font-size: 11px; font-weight: 600;
            color: var(--aura-cyan); letter-spacing: 0.08em;
            text-transform: uppercase;
        }
        .date-count {
            font-size: 10px; color: #4b5563; font-weight: 400;
        }

        /* ── Cards grid ── */
        .cards-grid {
            display: grid; gap: 12px;
            grid-template-columns: 1fr;
            padding-left: 36px;
        }

        /* ── Session card ── */
        .session-card {
            display: flex; gap: 16px;
            background: rgba(11,15,25,0.7);
            backdrop-filter: blur(12px);
            border: 1px solid rgba(255,255,255,0.07);
            border-radius: 14px;
            padding: 16px;
            cursor: pointer;
            transition: all 0.25s ease;
            text-decoration: none;
            position: relative; overflow: hidden;
        }
        .session-card::before {
            content: '';
            position: absolute; inset: 0;
            background: linear-gradient(135deg,
                rgba(var(--card-rgb, 0,240,255), 0.04) 0%,
                transparent 60%);
            opacity: 0; transition: opacity 0.25s;
            pointer-events: none;
        }
        .session-card:hover {
            border-color: rgba(var(--card-rgb, 0,240,255), 0.3);
            box-shadow: 0 0 24px rgba(var(--card-rgb, 0,240,255), 0.08),
                        0 8px 32px rgba(0,0,0,0.4);
            transform: translateY(-2px);
        }
        .session-card:hover::before { opacity: 1; }

        /* Thumbnail */
        .card-thumb {
            width: 120px; min-width: 120px; height: 78px;
            border-radius: 8px; overflow: hidden;
            background: var(--aura-highlight);
            position: relative; flex-shrink: 0;
        }
        .card-thumb img {
            width: 100%; height: 100%; object-fit: cover;
            transition: transform 0.3s ease;
        }
        .session-card:hover .card-thumb img { transform: scale(1.04); }
        .card-thumb-placeholder {
            width: 100%; height: 100%;
            background: radial-gradient(circle at 30% 40%,
                rgba(var(--card-rgb, 0,240,255), 0.15) 0%,
                rgba(var(--card-rgb2, 189,0,255), 0.08) 50%,
                transparent 80%);
            display: flex; align-items: center; justify-content: center;
        }
        .card-thumb-placeholder svg { opacity: 0.3; }

        /* Card body */
        .card-body {
            flex: 1; min-width: 0;
            display: flex; flex-direction: column; justify-content: space-between;
            gap: 8px;
        }
        .card-top { display: flex; flex-direction: column; gap: 6px; }
        .card-title {
            font-size: 14px; font-weight: 500; color: #e5e7eb;
            line-height: 1.4;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .card-title.untitled { color: #4b5563; font-style: italic; }

        .card-meta {
            display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
        }
        .provider-badge {
            display: inline-flex; align-items: center; gap: 4px;
            padding: 2px 8px; border-radius: 20px;
            font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
            border: 1px solid rgba(var(--card-rgb, 0,240,255), 0.3);
            background: rgba(var(--card-rgb, 0,240,255), 0.08);
            color: rgb(var(--card-rgb, 0,240,255));
            text-transform: uppercase;
        }
        .provider-dot {
            width: 4px; height: 4px; border-radius: 50%;
            background: rgb(var(--card-rgb, 0,240,255));
        }
        .card-time {
            font-size: 10px; color: #4b5563; font-weight: 400;
        }

        .card-footer {
            display: flex; align-items: center; justify-content: space-between;
        }
        .open-btn {
            font-size: 10px; font-weight: 600;
            color: rgba(var(--card-rgb, 0,240,255), 0.7);
            letter-spacing: 0.05em;
            display: flex; align-items: center; gap: 4px;
            transition: color 0.2s;
        }
        .session-card:hover .open-btn {
            color: rgb(var(--card-rgb, 0,240,255));
        }
        .open-arrow {
            transition: transform 0.2s ease;
        }
        .session-card:hover .open-arrow { transform: translateX(3px); }

        .source-link {
            display: inline-flex; align-items: center; gap: 4px;
            font-size: 10px; font-weight: 500; letter-spacing: 0.03em;
            color: #4b5563;
            padding: 3px 8px; border-radius: 20px;
            border: 1px solid rgba(255,255,255,0.06);
            background: rgba(255,255,255,0.03);
            cursor: pointer;
            transition: all 0.2s ease;
            white-space: nowrap;
            user-select: none;
        }
        .source-link:hover {
            color: #9ca3af;
            border-color: rgba(255,255,255,0.15);
            background: rgba(255,255,255,0.06);
        }

        /* ── Empty state ── */
        .empty-state {
            text-align: center; padding: 60px 24px;
            color: #374151; font-size: 13px;
        }
        .empty-state .emoji { font-size: 32px; margin-bottom: 12px; }

        /* ── Hidden (filter) ── */
        .session-card.hidden { display: none; }
        .date-group.all-hidden { display: none; }

        /* ── Responsive ── */
        @media (max-width: 560px) {
            .card-thumb { width: 80px; min-width: 80px; height: 56px; }
            .header-title { font-size: 28px; }
            .stats-strip { flex-direction: column; width: 100%; }
        }
    </style>
</head>
<body>

<div class="ambient-bg">
    <div class="ambient-blob violet"></div>
    <div class="ambient-blob cyan"></div>
    <div class="ambient-blob pink"></div>
    <div class="ambient-blob deep-cyan"></div>
</div>

<div class="page-wrap">

    <header class="header">
        <div class="logo-row">
            <div class="logo-icon">Y</div>
            <span class="logo-wordmark">YMind</span>
        </div>
        <h1 class="header-title">Memory <em>Timeline</em></h1>
        <p class="header-subtitle" id="subtitle">Extracting minds from conversations</p>
    </header>

    <div class="stats-strip" id="statsStrip"></div>

    <div class="filter-row" id="filterRow"></div>

    <div class="timeline" id="timeline"></div>

</div>

<script>
// ── Language ──
const isZh = navigator.language.startsWith('zh');
document.documentElement.lang = isZh ? 'zh-CN' : 'en';
const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
const LOCALE = isZh ? 'zh-CN' : 'en-US';
const i18n = {
    subtitle:    isZh ? s => `最近更新：${s}` : s => `Last updated ${s}`,
    records:     isZh ? n => `${n} 条记录`    : n => `${n} sessions`,
    daySpan:     isZh ? n => `${n} 天跨度`    : n => `${n} days`,
    topProvider: isZh ? l => `最多 ${l}`      : l => `Most: ${l}`,
    filterAll:   isZh ? 'All'                 : 'All',
    untitled:    isZh ? '— 未命名对话 —'      : '— Untitled —',
    openGraph:   isZh ? '打开思维图'           : 'Open graph',
    sourceLink:  isZh ? '原始对话'             : 'Source',
    empty:       isZh ? '还没有分析记录'       : 'No sessions yet',
    countSuffix: isZh ? n => `${n} 条`        : n => `${n}`,
};

// ── Provider config ──
const PROVIDERS = {
    chatgpt:  { label: 'ChatGPT',  rgb: '16,163,127',   hex: '#10a37f' },
    gemini:   { label: 'Gemini',   rgb: '66,133,244',   hex: '#4285F4' },
    chat:     { label: 'DeepSeek', rgb: '77,107,254',   hex: '#4D6BFE' },
    deepseek: { label: 'DeepSeek', rgb: '77,107,254',   hex: '#4D6BFE' },
    paste:    { label: 'Paste',    rgb: '156,163,175',  hex: '#9ca3af' },
    claude:   { label: 'Claude',   rgb: '230,140,90',   hex: '#E68C5A' },
};
const DEFAULT_P = { label: 'Chat', rgb: '0,240,255', hex: '#00F0FF' };

function getProvider(key) {
    return PROVIDERS[key?.toLowerCase()] ?? DEFAULT_P;
}

// ── Date helpers ──
function parseDate(str) {
    // Handle both "2026-03-19T13:30:55Z" and "2026-03-20T20:36:15" (no Z)
    return new Date(str.endsWith('Z') || str.includes('+') ? str : str + 'Z');
}
function formatDateGroup(d) {
    return d.toLocaleDateString(LOCALE, {
        year: 'numeric', month: 'long', day: 'numeric', timeZone: TZ
    });
}
function formatTime(d) {
    return d.toLocaleTimeString(LOCALE, {
        hour: '2-digit', minute: '2-digit', timeZone: TZ
    });
}

// ── Build stats strip ──
function buildStats(runs) {
    const total = runs.length;
    const providerCounts = {};
    runs.forEach(r => {
        const p = r.provider ?? 'unknown';
        providerCounts[p] = (providerCounts[p] ?? 0) + 1;
    });
    const topProvider = Object.entries(providerCounts).sort((a,b) => b[1]-a[1])[0];

    // Date range
    const dates = runs.map(r => parseDate(r.created_at)).sort((a,b) => a-b);
    const first = dates[0], last = dates[dates.length-1];
    const daysDiff = Math.round((last - first) / 86400000) + 1;

    return `
        <div class="stat-pill active">${i18n.records(`<span class="val">${total}</span>`)}</div>
        <div class="stat-pill">${i18n.daySpan(`<span class="val">${daysDiff}</span>`)}</div>
        <div class="stat-pill">${i18n.topProvider(`<span class="val">${getProvider(topProvider?.[0]).label}</span>`)}</div>
    `;
}

// ── Build filter tabs ──
function buildFilters(runs, onFilter) {
    const providers = [...new Set(runs.map(r => r.provider ?? 'unknown'))];
    const strip = document.getElementById('filterRow');

    const allTab = document.createElement('button');
    allTab.className = 'filter-tab active';
    allTab.textContent = i18n.filterAll;
    allTab.style.setProperty('--provider-color', 'var(--aura-cyan)');
    allTab.style.setProperty('--provider-rgb', '0,240,255');
    allTab.addEventListener('click', () => {
        document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
        allTab.classList.add('active');
        onFilter(null);
    });
    strip.appendChild(allTab);

    providers.forEach(p => {
        const prov = getProvider(p);
        const tab = document.createElement('button');
        tab.className = 'filter-tab';
        tab.textContent = prov.label;
        tab.style.setProperty('--provider-color', prov.hex);
        tab.style.setProperty('--provider-rgb', prov.rgb);
        tab.addEventListener('click', () => {
            document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
            tab.classList.add('active');
            onFilter(p);
        });
        strip.appendChild(tab);
    });
}

// ── Build card ──
function buildCard(run) {
    const prov = getProvider(run.provider);
    const date = parseDate(run.created_at);
    const thumbSrc = `./${run.run_dir}/graph.png`;
    const graphHref = `./${run.run_dir}/graph.html`;
    const title = run.title ?? '';
    const hasTitle = title.trim().length > 0;

    const card = document.createElement('div');
    card.className = 'session-card';
    card.dataset.provider = run.provider ?? '';
    card.style.setProperty('--card-rgb', prov.rgb);
    card.style.setProperty('--card-rgb2', '189,0,255');
    card.style.cursor = 'pointer';
    card.addEventListener('click', (e) => {
        if (!e.target.closest('.source-link')) window.open(graphHref, '_blank');
    });

    card.innerHTML = `
        <div class="card-thumb">
            <img
                src="${thumbSrc}"
                alt="${hasTitle ? title : 'Graph'}"
                loading="lazy"
                onerror="this.style.display='none'; this.parentElement.querySelector('.card-thumb-placeholder').style.display='flex';"
            />
            <div class="card-thumb-placeholder" style="display:none;">
                <svg width="28" height="28" viewBox="0 0 256 256" fill="none">
                    <defs>
                        <linearGradient id="g${run.run_dir}" x1="0" y1="0" x2="1" y2="1">
                            <stop offset="0%" stop-color="#00F0FF"/>
                            <stop offset="100%" stop-color="#BD00FF"/>
                        </linearGradient>
                    </defs>
                    <path fill="url(#g${run.run_dir})" d="M198.1,62.59a76,76,0,0,0-140.2,0A71.71,71.71,0,0,0,16,127.8C15.9,166,48,199,86.14,200A72.09,72.09,0,0,0,120,192.47V232a8,8,0,0,0,16,0V192.47A72.17,72.17,0,0,0,168,200l1.82,0C208,199,240.11,166,240,127.8A71.71,71.71,0,0,0,198.1,62.59Z"/>
                </svg>
            </div>
        </div>
        <div class="card-body">
            <div class="card-top">
                <div class="card-title ${hasTitle ? '' : 'untitled'}">${hasTitle ? title : i18n.untitled}</div>
                <div class="card-meta">
                    <span class="provider-badge">
                        <span class="provider-dot"></span>
                        ${prov.label}
                    </span>
                    <span class="card-time">${formatTime(date)}</span>
                </div>
            </div>
            <div class="card-footer">
                <span class="open-btn">
                    ${i18n.openGraph}
                    <svg class="open-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none">
                        <path d="M2 6h8M7 3l3 3-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
                    </svg>
                </span>
                ${run.url ? `<a class="source-link" href="${run.url}" target="_blank" rel="noopener" title="${run.url}" onclick="event.stopPropagation();">
                    <svg width="10" height="10" viewBox="0 0 12 12" fill="none">
                        <path d="M5 2H2a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7M7 1h4m0 0v4m0-4L5.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
                    </svg>
                    ${i18n.sourceLink}
                </a>` : ''}
            </div>
        </div>
    `;
    return card;
}

// ── Build timeline ──
function buildTimeline(runs) {
    const timeline = document.getElementById('timeline');
    timeline.innerHTML = '';

    if (!runs || runs.length === 0) {
        timeline.innerHTML = `<div class="empty-state">
            <div class="emoji">🌀</div>
            ${i18n.empty}
        </div>`;
        return;
    }

    // Sort newest first
    const sorted = [...runs].sort((a, b) =>
        parseDate(b.created_at) - parseDate(a.created_at)
    );

    // Group by date
    const groups = {};
    sorted.forEach(run => {
        const key = formatDateGroup(parseDate(run.created_at));
        if (!groups[key]) groups[key] = [];
        groups[key].push(run);
    });

    Object.entries(groups).forEach(([dateStr, groupRuns]) => {
        const group = document.createElement('div');
        group.className = 'date-group';

        const label = document.createElement('div');
        label.className = 'date-label';
        label.innerHTML = `
            <div class="date-dot"></div>
            <span class="date-text">${dateStr}</span>
            <span class="date-count">${i18n.countSuffix(groupRuns.length)}</span>
        `;
        group.appendChild(label);

        const grid = document.createElement('div');
        grid.className = 'cards-grid';
        groupRuns.forEach(run => grid.appendChild(buildCard(run)));
        group.appendChild(grid);

        timeline.appendChild(group);
    });
}

// ── Filter logic ──
function applyFilter(providerKey) {
    document.querySelectorAll('.session-card').forEach(card => {
        const match = !providerKey || card.dataset.provider === providerKey;
        card.classList.toggle('hidden', !match);
    });
    // Hide empty date groups
    document.querySelectorAll('.date-group').forEach(group => {
        const visibleCards = group.querySelectorAll('.session-card:not(.hidden)');
        group.classList.toggle('all-hidden', visibleCards.length === 0);
    });
}

// ── Init ──
(function init() {
    const runs = INDEX_DATA.runs ?? [];

    // Stats
    document.getElementById('statsStrip').innerHTML = buildStats(runs);

    // Subtitle
    const last = runs.length > 0
        ? formatDateGroup(parseDate(runs[0].created_at))
        : null;
    if (last) {
        document.getElementById('subtitle').textContent = i18n.subtitle(last);
    }

    // Timeline
    buildTimeline(runs);

    // Filters
    buildFilters(runs, (p) => applyFilter(p));
})();
</script>

</body>
</html>


path: scripts/templates/ymindmap.html
content: <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>YMind — {{TITLE}}</title>
    <link rel="icon"
        href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 256 256%22><defs><linearGradient id=%22g%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22><stop offset=%220%25%22 stop-color=%22%2300F0FF%22/><stop offset=%22100%25%22 stop-color=%22%23BD00FF%22/></linearGradient></defs><path fill=%22url(%23g)%22 d=%22M198.1,62.59a76,76,0,0,0-140.2,0A71.71,71.71,0,0,0,16,127.8C15.9,166,48,199,86.14,200A72.09,72.09,0,0,0,120,192.47V232a8,8,0,0,0,16,0V192.47A72.17,72.17,0,0,0,168,200l1.82,0C208,199,240.11,166,240,127.8A71.71,71.71,0,0,0,198.1,62.59Z%22/></svg>">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;800&display=swap" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;500;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        :root {
            --aura-base: #030508;
            --aura-surface: #0B0F19;
            --aura-highlight: #1A2133;
            --aura-cyan: #00F0FF;
            --aura-purple: #BD00FF;
            --aura-gold: #FFE600;
            --aura-red: #FF4D4D;
            --aura-orange: #FF9500;
        }

        body {
            font-family: 'Inter', sans-serif;
            background: radial-gradient(circle at 50% 50%, #13161c 0%, #080a10 60%, #030508 100%);
            color: #9ca3af;
            height: 100vh; overflow: hidden; font-weight: 300;
        }

        ::-webkit-scrollbar { width: 4px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: #334155; border-radius: 2px; }

        /* Aurora Background */
        .ambient-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
        .ambient-blob {
            position: absolute; border-radius: 50%;
            filter: blur(80px); mix-blend-mode: screen;
            will-change: transform, opacity; opacity: 0.2;
        }
        .ambient-blob.violet {
            top: -10%; left: -5%; width: 55%; height: 55%;
            background: radial-gradient(circle, rgba(112,0,255,0.55) 0%, rgba(112,0,255,0.08) 60%, rgba(112,0,255,0) 80%);
            animation: aurora-drift-1 14s ease-in-out infinite;
        }
        .ambient-blob.cyan {
            bottom: -10%; right: -5%; width: 50%; height: 50%;
            background: radial-gradient(circle, rgba(0,240,255,0.45) 0%, rgba(0,240,255,0.06) 60%, rgba(0,240,255,0) 80%);
            animation: aurora-drift-2 18s ease-in-out infinite;
        }
        .ambient-blob.pink {
            top: 25%; right: 5%; width: 45%; height: 45%;
            background: radial-gradient(circle, rgba(255,0,128,0.35) 0%, rgba(255,0,128,0.05) 60%, rgba(255,0,128,0) 80%);
            animation: aurora-drift-3 16s ease-in-out infinite;
        }
        .ambient-blob.deep-cyan {
            bottom: 15%; left: 15%; width: 40%; height: 40%;
            background: radial-gradient(circle, rgba(0,200,255,0.35) 0%, rgba(0,200,255,0.05) 60%, rgba(0,200,255,0) 80%);
            animation: aurora-drift-4 12s ease-in-out infinite;
        }

        @keyframes aurora-drift-1 {
            0%,100% { transform: translate(0,0) scale(1); opacity: 0.8; }
            25% { transform: translate(5%,8%) scale(1.1); opacity: 1; }
            50% { transform: translate(-3%,15%) scale(0.95); opacity: 0.6; }
            75% { transform: translate(8%,5%) scale(1.05); opacity: 0.9; }
        }
        @keyframes aurora-drift-2 {
            0%,100% { transform: translate(0,0) scale(1); opacity: 0.7; }
            30% { transform: translate(-8%,-5%) scale(1.15); opacity: 1; }
            60% { transform: translate(5%,-10%) scale(0.9); opacity: 0.5; }
            80% { transform: translate(-3%,-3%) scale(1.05); opacity: 0.8; }
        }
        @keyframes aurora-drift-3 {
            0%,100% { transform: translate(0,0) scale(1); opacity: 0.6; }
            20% { transform: translate(-10%,5%) scale(1.1); opacity: 0.9; }
            50% { transform: translate(5%,-8%) scale(0.95); opacity: 0.5; }
            70% { transform: translate(-5%,10%) scale(1.08); opacity: 0.8; }
        }
        @keyframes aurora-drift-4 {
            0%,100% { transform: translate(0,0) scale(1); opacity: 0.5; }
            35% { transform: translate(8%,-5%) scale(1.1); opacity: 0.8; }
            65% { transform: translate(-5%,8%) scale(0.95); opacity: 0.4; }
        }

        /* Graph */
        .graph-container { position: relative; z-index: 10; width: 100%; height: 100vh; overflow: hidden; }
        svg { width: 100%; height: 100%; }

        /* Links */
        .link { fill: none; stroke-width: 1px; transition: stroke-opacity 0.3s; }
        .link.structural { stroke: #5a6a7a; stroke-opacity: 0.15; stroke-width: 1px; }
        .link.causes { stroke: var(--aura-red); stroke-dasharray: 4,3; stroke-opacity: 0.8; }
        .link.resolves { stroke: var(--aura-purple); stroke-width: 1px; stroke-dasharray: 4,4; stroke-opacity: 0.8; }
        .link.leads_to { stroke: var(--aura-cyan); stroke-opacity: 0.8; }
        .link.opposes { stroke: var(--aura-orange); stroke-dasharray: 2,2; stroke-opacity: 0.8; }

        /* Nodes */
        .node-group { cursor: pointer; }
        .node-shape { transition: all 0.3s ease; filter: drop-shadow(0 0 8px currentColor); }
        .node-group:hover .node-shape { filter: drop-shadow(0 0 12px currentColor); transform: scale(1.15); }
        .node-group.selected .node-shape {
            filter: drop-shadow(0 0 16px currentColor); transform: scale(1.2);
            stroke: #fff !important; stroke-width: 2px !important;
        }

        .node-label {
            font-family: 'Inter', sans-serif; font-weight: 400; font-size: 10px;
            fill: #e5e7eb; text-anchor: middle; pointer-events: none;
            text-shadow: 0 2px 6px rgba(0,0,0,0.9);
            text-transform: capitalize; letter-spacing: 0.01em;
        }
        .node-group.root .node-label {
            font-family: 'Playfair Display', serif; font-size: 20px;
            font-weight: 800; fill: #fff; text-transform: none;
            text-shadow: 0 0 30px rgba(255,255,255,0.3);
        }

        .turn-badge { font-size: 7px; fill: #6b7280; font-weight: 400; opacity: 0.7; }

        /* Collapse */
        .collapse-btn { cursor: pointer; transition: all 0.2s; }
        .collapse-btn circle { fill: rgba(255,255,255,0.1); stroke: rgba(255,255,255,0.3); stroke-width: 1px; transition: all 0.2s; }
        .collapse-btn:hover circle { fill: rgba(255,255,255,0.2); stroke: var(--aura-cyan); }
        .collapse-btn text { fill: #fff; font-size: 10px; font-weight: 600; text-anchor: middle; dominant-baseline: middle; }

        /* Tooltip */
        .tooltip {
            position: fixed; z-index: 100; pointer-events: none;
            opacity: 0; transition: opacity 0.2s; width: 260px; padding: 14px;
            border-radius: 12px; background: rgba(11,15,25,0.9);
            backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1);
        }
        .tooltip.visible { opacity: 1; }
        .tooltip-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 6px; }
        .tooltip-label { font-size: 13px; font-weight: 500; color: #fff; margin-bottom: 8px; }
        .tooltip-desc { font-size: 12px; color: #9ca3af; line-height: 1.5; }
        .tooltip-hint { font-size: 9px; color: #6b7280; margin-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 6px; }

        /* HUD */
        .hud-top-left { position: absolute; top: 16px; left: 16px; z-index: 20; display: flex; gap: 8px; align-items: center; }
        .hud-top-right { position: absolute; top: 16px; right: 16px; z-index: 20; display: flex; gap: 8px; align-items: center; }
        .hud-bottom-left { position: absolute; bottom: 16px; left: 16px; z-index: 20; }
        .hud-bottom-right { position: absolute; bottom: 16px; right: 16px; z-index: 20; display: flex; gap: 8px; }
        .hud-btn {
            width: 36px; height: 36px; border-radius: 10px;
            background: rgba(11,15,25,0.7); backdrop-filter: blur(8px);
            border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
            display: flex; align-items: center; justify-content: center;
            cursor: pointer; transition: all 0.2s;
        }
        .hud-btn:hover { border-color: var(--aura-cyan); color: #fff; }
        .hud-btn.accent { border-color: var(--aura-gold); color: var(--aura-gold); }
        .hud-btn.accent:hover { background: rgba(255,230,0,0.1); }
        .hud-btn.hidden { display: none; }
        .hud-title {
            font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.6);
            padding: 6px 12px; border-radius: 8px;
            background: rgba(11,15,25,0.5); backdrop-filter: blur(8px);
        }
        .hud-badge {
            position: absolute; top: -4px; right: -4px;
            width: 16px; height: 16px; border-radius: 50%;
            background: var(--aura-gold); color: #000;
            font-size: 9px; font-weight: 700;
            display: flex; align-items: center; justify-content: center;
        }

        /* Legend */
        .legend-panel {
            padding: 12px; border-radius: 12px;
            background: rgba(11,15,25,0.7); backdrop-filter: blur(8px);
            border: 1px solid rgba(255,255,255,0.08);
            font-size: 11px; min-width: 140px;
        }
        .legend-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; margin-bottom: 8px; }
        .legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
        .legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
        .legend-diamond { width: 8px; height: 8px; transform: rotate(45deg); flex-shrink: 0; }
        .legend-circle-hollow { width: 8px; height: 8px; border-radius: 50%; border: 2px solid; flex-shrink: 0; background: transparent; }
        .relation-item { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
        .relation-line { width: 20px; height: 2px; flex-shrink: 0; }
        .relation-line.causes { background: var(--aura-red); }
        .relation-line.resolves { background: var(--aura-purple); }
        .relation-line.leads_to { background: var(--aura-cyan); }
        .relation-line.opposes { background: var(--aura-orange); }

        /* Edit overlay */
        .edit-overlay { position: fixed; z-index: 200; display: none; }
        .edit-overlay.active { display: block; }
        .edit-input {
            font-family: 'Inter', sans-serif; font-size: 12px;
            background: rgba(11,15,25,0.95); color: #fff;
            border: 1px solid var(--aura-cyan); border-radius: 6px;
            padding: 6px 10px; outline: none; min-width: 120px;
            backdrop-filter: blur(12px);
        }

        /* Toast */
        .status-toast {
            position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
            padding: 8px 20px; border-radius: 20px; font-size: 12px;
            background: rgba(11,15,25,0.9); border: 1px solid rgba(255,255,255,0.1);
            color: #e5e7eb; opacity: 0; transition: opacity 0.3s; z-index: 300;
            pointer-events: none; backdrop-filter: blur(8px);
        }
        .status-toast.visible { opacity: 1; }

        /* View Toggle */
        .view-toggle {
            display: flex; border-radius: 10px; overflow: hidden;
            border: 1px solid rgba(255,255,255,0.1);
            background: rgba(11,15,25,0.7); backdrop-filter: blur(8px);
        }
        .toggle-btn {
            padding: 6px 14px; font-size: 12px; font-weight: 500;
            background: transparent; border: none; color: #6b7280;
            cursor: pointer; transition: all 0.2s; letter-spacing: 0.02em;
            font-family: 'Inter', sans-serif;
        }
        .toggle-btn:first-child { border-right: 1px solid rgba(255,255,255,0.08); }
        .toggle-btn:hover { color: #e5e7eb; }
        .toggle-btn.active { background: rgba(0,240,255,0.12); color: var(--aura-cyan); }

        /* Focus mode dimming */
        .graph-container.focus-mode .node-group { opacity: 0.15; }
        .graph-container.focus-mode .node-group.highlighted { opacity: 1; }
        .graph-container.focus-mode .link { stroke-opacity: 0.05 !important; }
        .graph-container.focus-mode .link.highlighted { stroke-opacity: 0.8 !important; }

        /* ============== Action Card Modal ============== */
        .modal-overlay {
            position: fixed; top: 0; right: 0; bottom: 0; left: 50%;
            z-index: 1000; display: flex; align-items: center; justify-content: center;
            background: rgba(3,5,8,0.85); backdrop-filter: blur(4px);
            animation: fadeIn 0.3s ease;
        }
        .modal-backdrop { position: absolute; inset: 0; }
        .manifesto-wrapper {
            position: relative; z-index: 10;
            display: flex; flex-direction: column; align-items: center;
            gap: 20px; width: 95%; max-width: 500px;
            animation: slideUp 0.4s cubic-bezier(0.2,0.8,0.2,1);
            max-height: 90vh;
        }
        .cards-container {
            display: flex; flex-direction: column; gap: 16px;
            overflow-y: auto; padding: 10px;
            scrollbar-width: thin; scrollbar-color: rgba(0,240,255,0.3) transparent;
            max-width: 100%; max-height: 60vh;
        }
        .cards-container::-webkit-scrollbar { height: 6px; }
        .cards-container::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 3px; }
        .cards-container::-webkit-scrollbar-thumb { background: rgba(0,240,255,0.3); border-radius: 3px; }

        .manifesto-card {
            flex: 0 0 auto; width: 100%; min-height: 0; max-height: none;
            background: linear-gradient(160deg, #1a1f2e 0%, #050608 100%);
            border: 1px solid rgba(255,255,255,0.08); border-radius: 16px;
            padding: 28px; position: relative; overflow: hidden;
            box-shadow: 0 20px 50px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05) inset;
            color: #e5e7eb; font-family: 'Noto Serif SC', serif;
            scroll-snap-align: start; display: flex; flex-direction: column;
        }
        .card-glow {
            position: absolute; top: -100px; left: -50%; width: 200%; height: 300px;
            background: radial-gradient(circle, rgba(0,240,255,0.15) 0%, transparent 70%);
            pointer-events: none; mix-blend-mode: screen;
        }
        .manifesto-card .card-header {
            display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px;
        }
        .card-title {
            font-family: 'Playfair Display', serif; font-size: 18px; font-weight: 500;
            color: #fff; letter-spacing: -0.3px;
            text-shadow: 0 0 20px rgba(255,255,255,0.3); line-height: 1.4; flex: 1;
        }
        .card-index {
            font-family: 'Inter', sans-serif; font-size: 11px; color: var(--aura-cyan);
            background: rgba(0,240,255,0.1); padding: 4px 10px;
            border-radius: 12px; margin-left: 12px; white-space: nowrap;
        }
        .card-divider {
            height: 1px; width: 40px; background: var(--aura-gold);
            margin-bottom: 20px; opacity: 0.7; box-shadow: 0 0 8px var(--aura-gold);
        }
        .card-content {
            font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.85);
            font-weight: 300; flex: 1; overflow-y: auto; padding-right: 8px;
        }
        .card-content::-webkit-scrollbar { width: 4px; }
        .card-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
        .card-item { margin-bottom: 12px; display: flex; gap: 10px; align-items: baseline; }
        .card-bullet { color: var(--aura-gold); font-size: 10px; transform: translateY(-1px); }
        .card-source {
            font-size: 9px; color: #6b7280; margin-top: 2px;
            font-family: 'Inter', sans-serif;
        }
        .card-footer {
            display: flex; justify-content: space-between; align-items: flex-end;
            border-top: 1px solid rgba(255,255,255,0.05);
            padding-top: 14px; margin-top: 16px;
        }
        .card-quote {
            font-size: 11px; font-style: italic;
            color: rgba(255,255,255,0.3); font-family: 'Playfair Display', serif;
        }
        .card-brand {
            font-family: 'Inter', sans-serif; font-size: 9px;
            font-weight: 600; letter-spacing: 2px; color: #fff; opacity: 0.2;
        }

        .modal-header { text-align: center; margin-bottom: 8px; }
        .modal-title {
            font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 500;
            color: #fff; letter-spacing: -0.5px;
            text-shadow: 0 0 30px rgba(0,240,255,0.3);
        }
        .modal-subtitle {
            font-family: 'Inter', sans-serif; font-size: 12px;
            color: rgba(255,255,255,0.4); margin-top: 8px;
        }
        .cards-indicator { display: flex; gap: 8px; justify-content: center; margin-top: 4px; }
        .cards-indicator-dot {
            width: 6px; height: 6px; border-radius: 50%;
            background: rgba(255,255,255,0.2); transition: all 0.2s;
        }
        .cards-indicator-dot.active { background: var(--aura-cyan); box-shadow: 0 0 8px var(--aura-cyan); }

        .modal-actions { display: flex; gap: 12px; }
        .action-btn {
            padding: 10px 24px; border-radius: 20px; font-size: 12px;
            cursor: pointer; transition: all 0.2s; font-family: 'Inter', sans-serif;
        }
        .action-btn.secondary {
            background: rgba(255,255,255,0.1); color: #fff;
            border: 1px solid rgba(255,255,255,0.1);
        }
        .action-btn.secondary:hover { background: rgba(255,255,255,0.2); }
        .action-btn.icon-btn {
            width: 28px; height: 28px; padding: 0;
            background: transparent; color: #4b5563;
            border: none; border-radius: 6px;
            display: flex; align-items: center; justify-content: center;
            transition: color 0.2s;
        }
        .action-btn.icon-btn:hover { color: #9ca3af; }

        /* Export scroll container */
        #exportContainer { position: fixed; left: -9999px; top: 0; }
        .manifesto-scroll {
            background: #050608; padding: 40px;
            display: flex; flex-direction: column; gap: 0; width: 520px;
        }
        .scroll-header { text-align: center; margin-bottom: 30px; }
        .scroll-footer { text-align: center; margin-top: 20px; font-size: 11px; color: #374151; font-family: 'Inter', sans-serif; }

        @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
        @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }

        /* ── Split mode ── */
        body.split-mode { display: flex; flex-direction: row; overflow: hidden; }
        body.split-mode .graph-container { flex: 1; min-width: 0; }

        .chat-panel {
            display: none;
            width: 38%; min-width: 280px; max-width: 520px;
            flex-direction: column;
            border-right: 1px solid rgba(255,255,255,0.07);
            background: #0b0f19;
            overflow: hidden; flex-shrink: 0;
            z-index: 5;
        }
        body.split-mode .chat-panel { display: flex; }

        .chat-panel-header {
            padding: 13px 16px; flex-shrink: 0;
            border-bottom: 1px solid rgba(255,255,255,0.07);
            background: rgba(11,15,25,0.95); backdrop-filter: blur(12px);
        }
        .chat-panel-title {
            font-size: 12px; font-weight: 500; color: #e5e7eb;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .chat-panel-sub { font-size: 10px; color: #6b7280; margin-top: 2px; }

        .chat-messages-wrap {
            flex: 1; overflow-y: auto; padding: 14px 12px;
            display: flex; flex-direction: column; gap: 12px;
            scroll-behavior: smooth;
        }
        .chat-messages-wrap::-webkit-scrollbar { width: 3px; }
        .chat-messages-wrap::-webkit-scrollbar-thumb { background: #334155; border-radius: 2px; }

        .chat-msg { display: flex; flex-direction: column; gap: 3px; max-width: 94%; }
        .chat-msg.user { align-self: flex-end; align-items: flex-end; }
        .chat-msg.assistant { align-self: flex-start; align-items: flex-start; }
        .chat-msg-role {
            font-size: 9px; font-weight: 600; letter-spacing: 0.06em;
            text-transform: uppercase;
        }
        .chat-msg.user .chat-msg-role { color: rgba(0,240,255,0.55); }
        .chat-msg.assistant .chat-msg-role { color: rgba(189,0,255,0.55); }
        .chat-msg-bubble {
            padding: 9px 12px; border-radius: 11px;
            font-size: 11.5px; line-height: 1.65; font-weight: 300;
            color: #e5e7eb; white-space: pre-wrap; word-break: break-word;
        }
        .chat-msg.user .chat-msg-bubble {
            background: rgba(0,240,255,0.07);
            border: 1px solid rgba(0,240,255,0.18);
            border-bottom-right-radius: 3px;
        }
        .chat-msg.assistant .chat-msg-bubble {
            background: rgba(189,0,255,0.05);
            border: 1px solid rgba(189,0,255,0.13);
            border-bottom-left-radius: 3px;
        }

        .split-divider {
            display: none;
            width: 4px; flex-shrink: 0; cursor: col-resize;
            background: linear-gradient(to bottom,
                rgba(189,0,255,0.3), rgba(0,240,255,0.3));
            transition: background 0.2s;
            z-index: 6;
        }
        body.split-mode .split-divider { display: block; }
        .split-divider:hover, .split-divider.dragging {
            background: linear-gradient(to bottom,
                rgba(189,0,255,0.7), rgba(0,240,255,0.7));
        }
        /* Block pointer events on svg during resize drag */
        body.resizing svg { pointer-events: none; }

        /* ── Light Mode (A: Enhanced Aurora) ── */
        body.light-mode {
            background: radial-gradient(circle at 50% 50%, #f5f8ff 0%, #edf1fb 50%, #e4eaf6 100%);
            color: #475569;
        }
        body.light-mode ::-webkit-scrollbar-thumb { background: #c7d0e0; }

        body.light-mode .ambient-blob { mix-blend-mode: multiply; opacity: 0.20; }
        body.light-mode .ambient-blob.violet {
            background: radial-gradient(circle, rgba(112,0,255,0.55) 0%, rgba(112,0,255,0.06) 60%, transparent 80%);
        }
        body.light-mode .ambient-blob.cyan {
            background: radial-gradient(circle, rgba(0,200,255,0.48) 0%, rgba(0,200,255,0.05) 60%, transparent 80%);
        }
        body.light-mode .ambient-blob.pink {
            background: radial-gradient(circle, rgba(255,0,128,0.38) 0%, rgba(255,0,128,0.04) 60%, transparent 80%);
        }
        body.light-mode .ambient-blob.deep-cyan {
            background: radial-gradient(circle, rgba(0,180,240,0.38) 0%, rgba(0,180,240,0.04) 60%, transparent 80%);
        }

        body.light-mode .hud-btn {
            background: rgba(255,255,255,0.75); border-color: rgba(0,0,0,0.08);
            color: #556070; box-shadow: 0 2px 10px rgba(80,100,160,0.1);
        }
        body.light-mode .hud-btn:hover { border-color: var(--aura-cyan); color: #0f172a; background: rgba(255,255,255,0.9); }
        body.light-mode .hud-title {
            color: rgba(0,0,0,0.5); background: rgba(255,255,255,0.65);
            box-shadow: 0 2px 8px rgba(80,100,160,0.08);
        }
        body.light-mode .view-toggle {
            background: rgba(255,255,255,0.75); border-color: rgba(0,0,0,0.08);
            box-shadow: 0 2px 10px rgba(80,100,160,0.1);
        }
        body.light-mode .toggle-btn { color: #8a9ab8; }
        body.light-mode .toggle-btn:first-child { border-right-color: rgba(0,0,0,0.06); }
        body.light-mode .toggle-btn:hover { color: #1e293b; }
        body.light-mode .toggle-btn.active { background: rgba(0,220,255,0.1); color: #0099bb; }

        body.light-mode .legend-panel {
            background: rgba(255,255,255,0.75); border-color: rgba(0,0,0,0.07);
            box-shadow: 0 2px 10px rgba(80,100,160,0.1);
        }
        body.light-mode .legend-title { color: #8a9ab8; }

        body.light-mode .tooltip {
            background: rgba(255,255,255,0.92); border-color: rgba(0,0,0,0.07);
            box-shadow: 0 4px 20px rgba(80,100,160,0.15);
        }
        body.light-mode .tooltip-label { color: #0f172a; }
        body.light-mode .tooltip-desc { color: #475569; }
        body.light-mode .tooltip-hint { color: #94a3b8; border-top-color: rgba(0,0,0,0.05); }

        body.light-mode .node-label { fill: #1e293b; text-shadow: none; }
        body.light-mode .node-group.root .node-label { fill: #0a0f1e; text-shadow: none; }
        body.light-mode .turn-badge { fill: #94a3b8; }

        body.light-mode .node-shape { filter: drop-shadow(0 0 5px currentColor) drop-shadow(0 1px 4px rgba(0,0,0,0.12)); }
        body.light-mode .node-group:hover .node-shape { filter: drop-shadow(0 0 9px currentColor) drop-shadow(0 2px 6px rgba(0,0,0,0.15)); }
        body.light-mode .node-group.selected .node-shape { filter: drop-shadow(0 0 12px currentColor) drop-shadow(0 2px 8px rgba(0,0,0,0.2)); }

        body.light-mode .link.structural { stroke: #a0aec0; stroke-opacity: 0.35; }
        body.light-mode .link.leads_to { stroke: #0088bb; stroke-opacity: 0.55; }
        body.light-mode .link.causes { stroke: #cc3333; stroke-opacity: 0.55; }
        body.light-mode .link.resolves { stroke: #7733bb; stroke-opacity: 0.55; }
        body.light-mode .link.opposes { stroke: #bb6600; stroke-opacity: 0.55; }

        body.light-mode .collapse-btn circle { fill: rgba(255,255,255,0.7); stroke: rgba(0,0,0,0.15); }
        body.light-mode .collapse-btn:hover circle { fill: rgba(255,255,255,0.9); }
        body.light-mode .collapse-btn text { fill: #334155; }

        body.light-mode .status-toast {
            background: rgba(255,255,255,0.92); border-color: rgba(0,0,0,0.07);
            color: #1e293b; box-shadow: 0 4px 16px rgba(80,100,160,0.12);
        }
        body.light-mode .edit-input { background: rgba(255,255,255,0.97); color: #0f172a; }

        body.light-mode .chat-panel { background: #f2f5fd; border-right-color: rgba(0,0,0,0.06); }
        body.light-mode .chat-panel-header {
            background: rgba(245,248,255,0.97); border-bottom-color: rgba(0,0,0,0.05);
        }
        body.light-mode .chat-panel-title { color: #1e293b; }
        body.light-mode .chat-panel-sub { color: #8a9ab8; }
        body.light-mode .chat-messages-wrap::-webkit-scrollbar-thumb { background: #c7d0e0; }
        body.light-mode .chat-msg-bubble { color: #1e293b; }
        body.light-mode .chat-msg.user .chat-msg-bubble {
            background: rgba(0,200,255,0.08); border-color: rgba(0,200,255,0.25);
        }
        body.light-mode .chat-msg.assistant .chat-msg-bubble {
            background: rgba(112,0,255,0.06); border-color: rgba(112,0,255,0.15);
        }
        body.light-mode .split-divider {
            background: linear-gradient(to bottom, rgba(112,0,255,0.18), rgba(0,200,255,0.18));
        }
        body.light-mode .split-divider:hover, body.light-mode .split-divider.dragging {
            background: linear-gradient(to bottom, rgba(112,0,255,0.45), rgba(0,200,255,0.45));
        }

        body.light-mode .modal-overlay { background: rgba(237,241,251,0.92); }
        body.light-mode .manifesto-card {
            background: linear-gradient(160deg, #ffffff 0%, #edf1fb 100%);
            border-color: rgba(0,0,0,0.06);
            box-shadow: 0 20px 50px rgba(80,100,160,0.1), 0 0 0 1px rgba(255,255,255,0.8) inset;
            color: #1e293b;
        }
        body.light-mode .card-glow { background: radial-gradient(circle, rgba(0,200,255,0.1) 0%, transparent 70%); }
        body.light-mode .modal-title { color: #0f172a; text-shadow: 0 0 20px rgba(0,200,255,0.2); }
        body.light-mode .modal-subtitle { color: rgba(0,0,0,0.38); }
        body.light-mode .card-title { color: #0f172a; text-shadow: none; }
        body.light-mode .card-content { color: rgba(0,0,0,0.72); }
        body.light-mode .card-source { color: #94a3b8; }
        body.light-mode .card-quote { color: rgba(0,0,0,0.25); }
        body.light-mode .card-brand { color: #000; opacity: 0.12; }
        body.light-mode .card-footer { border-top-color: rgba(0,0,0,0.05); }
        body.light-mode .action-btn.secondary {
            background: rgba(255,255,255,0.7); color: #1e293b;
            border-color: rgba(0,0,0,0.09); box-shadow: 0 1px 6px rgba(80,100,160,0.08);
        }
        body.light-mode .action-btn.secondary:hover { background: rgba(255,255,255,0.9); }
        body.light-mode .action-btn.icon-btn { color: #64748b; }
        body.light-mode .action-btn.icon-btn:hover { color: #1e293b; }
        body.light-mode .cards-indicator-dot { background: rgba(0,0,0,0.12); }
        body.light-mode .cards-container::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); }
    </style>
</head>
<body>
    <div class="ambient-bg">
        <div class="ambient-blob violet"></div>
        <div class="ambient-blob cyan"></div>
        <div class="ambient-blob pink"></div>
        <div class="ambient-blob deep-cyan"></div>
    </div>

    <!-- Chat panel (split mode only) -->
    <div class="chat-panel" id="chatPanel">
        <div class="chat-panel-header">
            <div class="chat-panel-title" id="chatPanelTitle"></div>
            <div class="chat-panel-sub" id="chatPanelSub"></div>
        </div>
        <div class="chat-messages-wrap" id="chatMessagesWrap"></div>
    </div>
    <div class="split-divider" id="splitDivider"></div>

    <main class="graph-container" id="graphContainer">
        <svg id="graph"></svg>

        <!-- HUD Top Left: Reset + Title -->
        <div class="hud-top-left">
            <div class="hud-btn" onclick="resetZoom()" title="Reset view">
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
                    <polyline points="9 22 9 12 15 12 15 22"/>
                </svg>
            </div>
            <div class="hud-title" id="graphTitle">YMind</div>
        </div>

        <!-- HUD Top Right: View toggle -->
        <div class="hud-top-right">
            <div class="view-toggle" id="viewToggle">
                <button class="toggle-btn active" id="btnTimeline" onclick="setView('timeline')" title="Timeline view — nodes spread by conversation turn">⏱ Timeline</button>
                <button class="toggle-btn" id="btnStructure" onclick="setView('structure')" title="Structure view — nodes grouped by type (fact / friction / spark / action)">◈ Structure</button>
            </div>
        </div>

        <!-- HUD Bottom Left: Legend -->
        <div class="hud-bottom-left">
            <div class="legend-panel">
                <div class="legend-title">Node Types</div>
                <div class="legend-item"><div class="legend-dot" style="background:#8b9dc3;"></div>Fact</div>
                <div class="legend-item"><div class="legend-diamond" style="background:#FF4D4D;"></div>Friction</div>
                <div class="legend-item"><div class="legend-diamond" style="background:#00F0FF;"></div>Spark</div>
                <div class="legend-item"><div class="legend-circle-hollow" style="border-color:#FFE600;"></div>Action</div>
                <div id="relationItems" style="margin-top: 8px;"></div>
            </div>
        </div>

        <!-- HUD Bottom Right: Action Cards button -->
        <div class="hud-bottom-right">
            <div class="hud-btn" id="btnTheme" onclick="toggleTheme()" title="Toggle light / dark mode">
                <svg id="themeIconMoon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
                </svg>
                <svg id="themeIconSun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none">
                    <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
                </svg>
            </div>
            <div class="hud-btn accent" id="btnActions" onclick="openActionCards()" title="Action Cards">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
                </svg>
                <div class="hud-badge" id="actionBadge">0</div>
            </div>
        </div>

        <!-- Tooltip -->
        <div class="tooltip" id="tooltip">
            <div class="tooltip-type" id="ttType">TYPE</div>
            <div class="tooltip-label" id="ttLabel">Label</div>
            <div class="tooltip-desc" id="ttDesc">Description</div>
            <div class="tooltip-hint">Click to select</div>
        </div>

        <!-- Toast -->
        <div id="statusToast" class="status-toast"></div>
    </main>

    <!-- Hidden export container -->
    <div id="exportContainer"></div>

    <!-- Action Card Modal -->
    <div id="manifestoModal" class="modal-overlay" style="display:none;">
        <div class="modal-backdrop" onclick="closeActionCards()"></div>
        <div class="manifesto-wrapper">
            <div class="modal-header">
                <h1 class="modal-title">Action Cards</h1>
                <div class="modal-subtitle" id="modalSubtitle"></div>
            </div>
            <div class="cards-container" id="cardsContainer"></div>
            <div class="cards-indicator" id="cardsIndicator"></div>
            <div class="modal-actions">
                <button class="action-btn secondary" onclick="exportImage()">Save Image</button>
                <button class="action-btn icon-btn" id="btnCopyText" onclick="copyCardText()" title="Copy">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
                    </svg>
                </button>
                <button class="action-btn secondary" onclick="closeActionCards()">Close</button>
            </div>
        </div>
    </div>

    <script>
        // ============== Data (injected by render-html.py) ==============
        const __YMIND_DATA__ = {{GRAPH_JSON}};
        const __CHAT_MESSAGES__ = {{CHAT_JSON}};

        // ============== State ==============
        let graphData = null;
        let simulation = null;
        let collapsedNodes = new Set();
        let zoom = null;
        let g = null;
        let selectedNode = null;
        let editingNode = null;
        let dirty = false;
        let focusedActionId = null;
        let currentCardData = null;

        // ============== i18n ==============
        const i18n = {
            en: {
                nodeTypes: 'Node Types',
                relations: 'Relations',
                nodeLabels: { fact: 'Fact', friction: 'Friction', spark: 'Spark', action: 'Action' },
                relationLabels: { causes: 'Causes', resolves: 'Resolves', leads_to: 'Leads to', opposes: 'Opposes' },
                timeline: '⏱ Timeline', timelineTitle: 'Timeline view — nodes spread by conversation turn',
                structure: '◈ Structure', structureTitle: 'Structure view — nodes grouped by type',
                clickToSelect: 'Click to select',
                actionCards: 'Action Cards',
                actionsFrom: (n, m) => `${n} actions from ${m} insights`,
                otherInsights: 'Other Insights',
                otherInsightsDesc: 'Actions without specific friction context',
                noActions: 'No actions found',
                labelUpdated: 'Label updated',
                jsonExported: 'JSON exported',
                htmlSaved: 'HTML saved',
                exportCards: 'Save Image',
                copyText: 'Copy',
                copied: 'Copied',
                close: 'Close',
            },
            zh: {
                nodeTypes: '节点类型',
                relations: '关系',
                nodeLabels: { fact: '事实', friction: '阻力', spark: '灵感', action: '行动' },
                relationLabels: { causes: '导致', resolves: '化解', leads_to: '推进', opposes: '对立' },
                timeline: '⏱ 时间线', timelineTitle: '时间线视图 — 按对话轮次展开',
                structure: '◈ 结构', structureTitle: '结构视图 — 按节点类型分组',
                clickToSelect: '点击选中',
                actionCards: '行动卡片',
                actionsFrom: (n, m) => `${m} 个主题，共 ${n} 条行动`,
                otherInsights: '其他洞见',
                otherInsightsDesc: '未关联具体阻力的行动',
                noActions: '未发现行动节点',
                labelUpdated: '标签已更新',
                jsonExported: 'JSON 已导出',
                htmlSaved: 'HTML 已保存',
                exportCards: '保存图片',
                copyText: '复制',
                copied: '已复制',
                close: '关闭',
            }
        };

        let lang = 'en';
        let t = i18n.en;

        function detectLang(nodes) {
            const allLabels = nodes.map(n => n.label || '').join('');
            const chCount = (allLabels.match(/[\u4e00-\u9fa5]/g) || []).length;
            return allLabels.length > 0 && chCount / allLabels.length > 0.15 ? 'zh' : 'en';
        }

        function applyLang() {
            // Legend: node types title + labels
            const legendTitles = document.querySelectorAll('.legend-title');
            if (legendTitles[0]) legendTitles[0].textContent = t.nodeTypes;
            const legendItems = document.querySelectorAll('.legend-item');
            const nodeTypeOrder = ['fact', 'friction', 'spark', 'action'];
            legendItems.forEach((item, i) => {
                const key = nodeTypeOrder[i];
                if (!key) return;
                const shape = item.querySelector('.legend-dot, .legend-diamond, .legend-circle-hollow');
                item.innerHTML = '';
                if (shape) item.appendChild(shape);
                item.appendChild(document.createTextNode(t.nodeLabels[key]));
            });
            // View toggle buttons
            const btnTl = document.getElementById('btnTimeline');
            const btnSt = document.getElementById('btnStructure');
            if (btnTl) { btnTl.textContent = t.timeline; btnTl.title = t.timelineTitle; }
            if (btnSt) { btnSt.textContent = t.structure; btnSt.title = t.structureTitle; }
            // Tooltip hint
            const hint = document.querySelector('.tooltip-hint');
            if (hint) hint.textContent = t.clickToSelect;
            // Modal title + buttons
            const modalTitle = document.querySelector('.modal-title');
            if (modalTitle) modalTitle.textContent = t.actionCards;
            const actionBtns = document.querySelectorAll('.modal-actions .action-btn.secondary');
            if (actionBtns[0]) actionBtns[0].textContent = t.exportCards;
            if (actionBtns[1]) actionBtns[1].textContent = t.close;
            const copyBtn = document.getElementById('btnCopyText');
            if (copyBtn) copyBtn.title = t.copyText;
        }

        // ============== Config ==============
        const config = {
            colors: { root: '#ffffff', fact: '#8b9dc3', friction: '#FF4D4D', spark: '#00F0FF', action: '#FFE600' },
            sizes: { root: 50, fact: 8, friction: 8, spark: 10, action: 8 },
            yOffsets: { root: 0, fact: -100, friction: -30, spark: 50, action: 130 },
            layout: { turnSpacing: 200, verticalCluster: 100, yStrength: 0.8 }
            // turnSpacing is overridden dynamically in renderGraph() based on node count
        };

        // ============== DOM ==============
        const svg = d3.select('#graph');
        const tooltip = document.getElementById('tooltip');

        // ============== Keyboard ==============
        document.addEventListener('keydown', e => {
            if (e.key === 'Escape') {
                deselectNode();
                if (focusedActionId) exitFocusMode();
            }
        });

        // ============== Init ==============
        function init() {
            const data = __YMIND_DATA__;
            const graph = data.graph || data;
            const nodes = graph.nodes || [];
            const links = (graph.edges || graph.links || []).map(e => ({
                source: e.source, target: e.target,
                relation_type: e.relation_type || e.relation || 'parent'
            }));
            const turnCount = data.meta ? data.meta.total_turns : (data.turn_count || 0);
            const title = data.meta ? data.meta.title : '';

            graphData = { nodes, links, turn_count: turnCount };

            if (title) {
                document.getElementById('graphTitle').textContent = title;
                document.title = 'YMind \u2014 ' + title;
            }

            lang = detectLang(nodes);
            t = i18n[lang];
            applyLang();
            updateActionBadge();
            initSplitMode();
            renderGraph(graphData);
        }

        // ============== Split Mode ==============
        function initSplitMode() {
            const msgs = __CHAT_MESSAGES__;
            if (!msgs || msgs.length === 0) return;

            document.body.classList.add('split-mode');

            // Header
            const titleEl = document.getElementById('chatPanelTitle');
            const subEl = document.getElementById('chatPanelSub');
            if (titleEl) titleEl.textContent = (graphData && graphData.title) ? graphData.title : '';
            if (subEl) subEl.textContent = Math.ceil(msgs.length / 2) + (navigator.language.startsWith('zh') ? ' 轮对话' : ' turns');

            // Messages
            const wrap = document.getElementById('chatMessagesWrap');
            msgs.forEach(msg => {
                const div = document.createElement('div');
                div.className = 'chat-msg ' + msg.role;
                const role = document.createElement('div');
                role.className = 'chat-msg-role';
                role.textContent = msg.role === 'user' ? 'You' : 'AI';
                const bubble = document.createElement('div');
                bubble.className = 'chat-msg-bubble';
                bubble.textContent = msg.content;
                div.appendChild(role);
                div.appendChild(bubble);
                wrap.appendChild(div);
            });

            // Drag resize
            const divider = document.getElementById('splitDivider');
            const panel = document.getElementById('chatPanel');
            let dragging = false;
            divider.addEventListener('mousedown', e => {
                dragging = true;
                divider.classList.add('dragging');
                document.body.classList.add('resizing');
                e.preventDefault();
            });
            document.addEventListener('mousemove', e => {
                if (!dragging) return;
                const totalW = document.body.offsetWidth;
                const newW = Math.max(240, Math.min(totalW * 0.6, e.clientX));
                panel.style.width = newW + 'px';
            });
            document.addEventListener('mouseup', () => {
                if (!dragging) return;
                dragging = false;
                divider.classList.remove('dragging');
                document.body.classList.remove('resizing');
            });
        }

        // ============== Graph Rendering ==============
        function renderGraph(data, preserveCollapsed) {
            if (!preserveCollapsed) collapsedNodes.clear();
            const visibleData = getVisibleData(data);

            svg.selectAll('*').remove();

            // Arrow markers
            const defs = svg.append('defs');
            ['causes', 'resolves', 'leads_to', 'opposes'].forEach(type => {
                const colorMap = { causes: 'var(--aura-red)', resolves: 'var(--aura-purple)', leads_to: 'var(--aura-cyan)', opposes: 'var(--aura-orange)' };
                const size = type === 'opposes' ? 5 : 6;
                defs.append('marker').attr('id', 'arrow-' + type)
                    .attr('viewBox', '0 0 10 10').attr('refX', 10).attr('refY', 5)
                    .attr('markerWidth', size).attr('markerHeight', size).attr('orient', 'auto')
                    .append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', colorMap[type]);
            });

            // Zoom
            g = svg.append('g');
            zoom = d3.zoom().scaleExtent([0.1, 4]).on('zoom', e => g.attr('transform', e.transform));
            svg.call(zoom).on('dblclick.zoom', null);

            // Click empty space to deselect
            svg.on('click', () => { deselectNode(); if (focusedActionId) exitFocusMode(); });

            // --- LINKS ---
            const links = g.append('g').selectAll('path').data(visibleData.links)
                .enter().append('path')
                .attr('class', d => {
                    if (d.relation_type === 'parent' || !d.relation_type) return 'link structural';
                    return 'link ' + d.relation_type;
                })
                .attr('stroke-opacity', d => {
                    const isRoot = (d.source.id || d.source) === 'root' || (d.target.id || d.target) === 'root';
                    return isRoot ? 0 : null;
                })
                .attr('marker-end', d => {
                    if (d.relation_type && d.relation_type !== 'parent') return 'url(#arrow-' + d.relation_type + ')';
                    return null;
                });

            // --- NODES ---
            const nodes = g.append('g').selectAll('g').data(visibleData.nodes)
                .enter().append('g')
                .attr('class', d => 'node-group ' + d.type + (selectedNode && selectedNode.id === d.id ? ' selected' : ''))
                .call(d3.drag().on('start', dragStarted).on('drag', dragged).on('end', dragEnded));

            // --- NODE SHAPES ---
            nodes.filter(d => d.type === 'fact').append('circle')
                .attr('r', d => config.sizes[d.type]).attr('fill', d => config.colors[d.type])
                .attr('stroke', 'rgba(139,157,195,0.4)').attr('stroke-width', 2)
                .attr('class', 'node-shape')
                .style('filter', 'drop-shadow(0 0 5px rgba(139,157,195,0.5))');

            nodes.filter(d => d.type === 'friction' || d.type === 'spark').append('path')
                .attr('d', d3.symbol().type(d3.symbolDiamond).size(d => config.sizes[d.type] * 30))
                .attr('fill', d => config.colors[d.type]).attr('class', 'node-shape')
                .style('filter', d => 'drop-shadow(0 0 8px ' + config.colors[d.type] + ')');

            nodes.filter(d => d.type === 'action').append('circle')
                .attr('r', d => config.sizes[d.type]).attr('fill', 'transparent')
                .attr('stroke', config.colors.action).attr('stroke-width', 2)
                .attr('class', 'node-shape')
                .style('filter', 'drop-shadow(0 0 8px ' + config.colors.action + ')');

            nodes.filter(d => d.type === 'root').append('circle')
                .attr('r', 5).attr('fill', '#fff').style('opacity', 0.5);

            // --- LABELS ---
            nodes.append('text').attr('class', 'node-label')
                .attr('dy', d => d.type === 'root' ? -15 : (config.sizes[d.type] || 8) + 12)
                .each(function(d) {
                    const el = d3.select(this);
                    if (d.type === 'root') { el.text(d.label.length > 21 ? d.label.slice(0,19)+'..' : d.label); return; }
                    const hasChinese = /[\u4e00-\u9fa5]/.test(d.label);
                    const maxLen = hasChinese ? 12 : 16;
                    if (d.label.length <= maxLen) { el.text(d.label); }
                    else if (hasChinese) { el.text(d.label.slice(0,11)+'..'); }
                    else {
                        const mid = Math.floor(d.label.length/2);
                        const before = d.label.lastIndexOf(' ',mid), after = d.label.indexOf(' ',mid);
                        const split = before > 0 ? before : (after > 0 ? after : -1);
                        if (split <= 0) { el.text(d.label.slice(0,19)+'..'); }
                        else {
                            const baseY = (config.sizes[d.type]||8)+12;
                            el.attr('dy',null).text(null);
                            el.append('tspan').attr('x',0).attr('y',baseY).text(d.label.substring(0,split));
                            el.append('tspan').attr('x',0).attr('y',baseY+12).text(d.label.substring(split+1));
                        }
                    }
                });

            // --- TURN BADGES ---
            nodes.filter(d => d.turn_id != null && d.type !== 'root')
                .append('text').attr('class','turn-badge')
                .attr('dy', d => -config.sizes[d.type]-6).attr('dx',-6)
                .text(d => 'T'+d.turn_id);

            // --- COLLAPSE BUTTON ---
            const nodesWithChildren = nodes.filter(d => hasChildren(d,data) && d.type !== 'root');
            const collapseBtn = nodesWithChildren.append('g').attr('class','collapse-btn')
                .attr('transform', d => 'translate('+(config.sizes[d.type]+6)+','+(config.sizes[d.type]+6)+')')
                .on('click', (e,d) => { e.stopPropagation(); toggleCollapse(d); });
            collapseBtn.append('circle').attr('r',8);
            collapseBtn.append('text').text(d => collapsedNodes.has(d.id) ? '+' : '-');

            // --- INTERACTIONS ---
            nodes
                .on('mouseover', (e,d) => showTooltip(e,d))
                .on('mouseout', hideTooltip)
                .on('click', (e,d) => { e.stopPropagation(); selectNode(d); });

            // --- ADAPTIVE TURN SPACING ---
            const maxTurnId = Math.max(...visibleData.nodes.filter(n=>n.turn_id).map(n=>n.turn_id), 1);
            const graphW = document.getElementById('graphContainer').offsetWidth || window.innerWidth;
            config.layout.turnSpacing = Math.min(180, Math.max(70, (graphW * 0.68) / (maxTurnId + 1)));

            // --- PRE-PROCESS: in-turn index ---
            const turnGroups = {};
            visibleData.nodes.forEach(n => {
                if (n.type === 'root') return;
                const key = (n.turn_id||0)+'-'+n.type;
                if (!turnGroups[key]) turnGroups[key] = [];
                turnGroups[key].push(n);
            });
            Object.values(turnGroups).forEach(group => {
                group.forEach((n,i) => { n.inTurnIndex = i-(group.length-1)/2; });
            });

            // --- PRE-POSITION: seed positions to avoid collinear starting layout ---
            visibleData.nodes.forEach(n => {
                if (n.type === 'root') { n.x = 0; n.y = 0; return; }
                n.x = (n.turn_id || 0.5) * config.layout.turnSpacing + (Math.random() - 0.5) * 90;
                n.y = (config.yOffsets[n.type] || 0) + (n.inTurnIndex || 0) * 60 + (Math.random() - 0.5) * 60;
            });

            // --- FORCE SIMULATION ---
            simulation = d3.forceSimulation(visibleData.nodes)
                .force('link', d3.forceLink(visibleData.links).id(d=>d.id)
                    .distance(d => (d.relation_type==='parent'||!d.relation_type) ? 80 : 140)
                    .strength(d => {
                        const isRoot = (d.source.id||d.source)==='root'||(d.target.id||d.target)==='root';
                        if (isRoot) return 0;
                        return (d.relation_type && d.relation_type!=='parent') ? 0.3 : 0.3;
                    })
                )
                .force('charge', d3.forceManyBody().strength(-500))
                .force('collision', d3.forceCollide().radius(d=>(config.sizes[d.type]||10)+36).strength(1.0).iterations(4))
                .force('x', d3.forceX().x(d => d.type==='root' ? 0 : (d.turn_id||0.5)*config.layout.turnSpacing).strength(0.8))
                .force('y', d3.forceY(d => (config.yOffsets[d.type]||0)+(d.inTurnIndex||0)*60).strength(config.layout.yStrength))
                .force('proximityRepel', function(alpha) {
                    const ns = visibleData.nodes;
                    for (let i=0;i<ns.length;i++) {
                        if (ns[i].type==='root') continue;
                        for (let j=i+1;j<ns.length;j++) {
                            if (ns[j].type==='root') continue;
                            const dx=Math.abs(ns[i].x-ns[j].x), dy=Math.abs(ns[i].y-ns[j].y);
                            if (dx<120 && dy<80 && dy>0) {
                                const f=(80-dy)*0.15*alpha;
                                if (ns[i].y<ns[j].y) { ns[i].vy-=f; ns[j].vy+=f; }
                                else { ns[i].vy+=f; ns[j].vy-=f; }
                            }
                        }
                    }
                })
                .force('edgeRepel', function(alpha) {
                    const ns = visibleData.nodes;
                    const ls = visibleData.links;
                    for (const link of ls) {
                        const a = link.source, b = link.target;
                        if (!a || !b || a.type==='root' || b.type==='root') continue;
                        const abx = b.x - a.x, aby = b.y - a.y;
                        const len2 = abx*abx + aby*aby;
                        if (len2 < 1) continue;
                        for (const n of ns) {
                            if (n === a || n === b || n.type === 'root') continue;
                            const t = Math.max(0, Math.min(1, ((n.x-a.x)*abx + (n.y-a.y)*aby) / len2));
                            const px = a.x + t*abx - n.x;
                            const py = a.y + t*aby - n.y;
                            const dist = Math.sqrt(px*px + py*py);
                            if (dist < 55 && dist > 0.5) {
                                const f = (55 - dist) / dist * 0.5 * alpha;
                                n.vx -= px * f;
                                n.vy -= py * f;
                            }
                        }
                    }
                });

            simulation.on('tick', () => {
                links.attr('d', d => {
                    let sx=d.source.x,sy=d.source.y,tx=d.target.x,ty=d.target.y;
                    const dx=tx-sx,dy=ty-sy,dist=Math.sqrt(dx*dx+dy*dy)||1;
                    const isSemantic = d.relation_type && d.relation_type!=='parent';
                    if (isSemantic) { const o=(config.sizes[d.target.type]||8)+1; tx-=(dx/dist)*o; ty-=(dy/dist)*o; }
                    else if (d.target.type==='action') { const o=(config.sizes.action||8)+3; tx-=(dx/dist)*o; ty-=(dy/dist)*o; }
                    if (isSemantic) {
                        const dr=Math.sqrt(dx*dx+dy*dy)*1.5;
                        const sweep=d.source.id<d.target.id?1:0;
                        return 'M'+sx+','+sy+'A'+dr+','+dr+' 0 0,'+sweep+' '+tx+','+ty;
                    }
                    return 'M'+sx+','+sy+'L'+tx+','+ty;
                });
                nodes.attr('transform', d => 'translate('+d.x+','+d.y+')');
            });

            const root = data.nodes.find(n => n.type==='root');
            if (root) { root.fx=0; root.fy=0; }

            // Fit immediately using pre-positioned coordinates, then refine when simulation settles
            setTimeout(() => fitAll(300), 30);
            simulation.on('end', () => fitAll(400));

            updateRelationLegend(data);
        }

        // ============== Fit to View ==============
        function fitAll(duration) {
            const ns = simulation ? simulation.nodes() : [];
            if (!ns.length) return;
            let x0=Infinity, x1=-Infinity, y0=Infinity, y1=-Infinity;
            ns.forEach(n => {
                const r = (config.sizes[n.type] || 10) + 20;
                x0=Math.min(x0,n.x-r); x1=Math.max(x1,n.x+r);
                y0=Math.min(y0,n.y-r); y1=Math.max(y1,n.y+r);
            });
            const w=svg.node().clientWidth, h=svg.node().clientHeight;
            const pad = 60;
            const scale = Math.min(0.95, Math.min((w-pad*2)/(x1-x0), (h-pad*2)/(y1-y0)));
            const tx = w/2 - scale*(x0+x1)/2;
            const ty = h/2 - scale*(y0+y1)/2;
            svg.transition().duration(duration||600)
               .call(zoom.transform, d3.zoomIdentity.translate(tx,ty).scale(scale));
        }

        // ============== View Toggle ==============
        let currentView = 'timeline';

        function setView(view) {
            if (!simulation || view === currentView) return;
            currentView = view;

            // Update button states
            document.getElementById('btnTimeline').classList.toggle('active', view === 'timeline');
            document.getElementById('btnStructure').classList.toggle('active', view === 'structure');

            if (view === 'timeline') {
                // Restore turn-based horizontal spread
                simulation
                    .force('x', d3.forceX().x(d => d.type==='root' ? 0 : (d.turn_id||0.5)*config.layout.turnSpacing).strength(0.8))
                    .force('y', d3.forceY(d => (config.yOffsets[d.type]||0)+(d.inTurnIndex||0)*60).strength(config.layout.yStrength));
            } else {
                // Structure: group by type into quadrants
                const w = window.innerWidth, h = window.innerHeight;
                const cx = 0, cy = 0; // simulation center
                const spread = Math.min(w, h) * 0.28;
                const typePos = {
                    root:     { x: cx,            y: cy },
                    fact:     { x: cx - spread*0.6, y: cy - spread*0.5 },
                    friction: { x: cx - spread*0.5, y: cy + spread*0.1 },
                    spark:    { x: cx + spread*0.5, y: cy - spread*0.1 },
                    action:   { x: cx + spread*0.3, y: cy + spread*0.6 },
                };
                simulation
                    .force('x', d3.forceX().x(d => (typePos[d.type]||typePos.fact).x).strength(0.5))
                    .force('y', d3.forceY().y(d => (typePos[d.type]||typePos.fact).y).strength(0.5));
            }

            simulation.alpha(0.5).restart();
            setTimeout(() => fitAll(600), 1800);
        }

        // ============== Helpers ==============
        function getVisibleData(data) {
            const hidden = new Set();
            function hideDesc(nodeId) {
                data.links.forEach(l => {
                    const src=typeof l.source==='object'?l.source.id:l.source;
                    const tgt=typeof l.target==='object'?l.target.id:l.target;
                    if (src===nodeId&&(l.relation_type==='parent'||!l.relation_type)) { hidden.add(tgt); hideDesc(tgt); }
                });
            }
            collapsedNodes.forEach(id => hideDesc(id));
            const vis = data.nodes.filter(n => !hidden.has(n.id));
            const ids = new Set(vis.map(n=>n.id));
            const vlinks = data.links.filter(l => {
                const s=typeof l.source==='object'?l.source.id:l.source;
                const t=typeof l.target==='object'?l.target.id:l.target;
                return ids.has(s)&&ids.has(t);
            });
            return { nodes: vis, links: vlinks };
        }

        function hasChildren(node, data) {
            return data.links.some(l => {
                const src=typeof l.source==='object'?l.source.id:l.source;
                return src===node.id&&(l.relation_type==='parent'||!l.relation_type);
            });
        }

        function toggleCollapse(node) {
            collapsedNodes.has(node.id)?collapsedNodes.delete(node.id):collapsedNodes.add(node.id);
            renderGraph(graphData, true);
        }

        // ============== Selection ==============
        function selectNode(d) {
            selectedNode = d;
            if (g) g.selectAll('.node-group').classed('selected', n => n.id===d.id);
        }
        function deselectNode() {
            selectedNode = null;
            if (g) g.selectAll('.node-group').classed('selected', false);
        }

        // ============== Legend ==============
        function updateRelationLegend(data) {
            const el=document.getElementById('relationItems'); if(!el) return;
            const types=new Set();
            data.links.forEach(l => { if(l.relation_type&&l.relation_type!=='parent') types.add(l.relation_type); });
            if (types.size===0) { el.style.display='none'; return; }
            el.style.display='block';
            el.innerHTML='<div class="legend-title" style="margin-top:4px;">'+t.relations+'</div>';
            types.forEach(type => { el.innerHTML+='<div class="relation-item"><span class="relation-line '+type+'"></span>'+(t.relationLabels[type]||type)+'</div>'; });
        }

        // ============== Drag ==============
        function dragStarted(e,d) { if(!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; }
        function dragged(e,d) { d.fx=e.x; d.fy=e.y; }
        function dragEnded(e,d) { if(!e.active) simulation.alphaTarget(0); }

        // ============== Tooltip ==============
        function showTooltip(e,d) {
            document.getElementById('ttType').textContent=d.type.toUpperCase();
            document.getElementById('ttType').style.color=config.colors[d.type]||'#fff';
            document.getElementById('ttLabel').textContent=d.label;
            document.getElementById('ttDesc').textContent=d.rich_summary||'No description';
            tooltip.style.left=(e.pageX+15)+'px'; tooltip.style.top=(e.pageY+15)+'px';
            tooltip.classList.add('visible');
        }
        function hideTooltip() { tooltip.classList.remove('visible'); }

        // ============== Inline Editing ==============
        function startEdit(d) {
            editingNode = d;
            selectNode(d);
            const overlay=document.getElementById('editOverlay');
            const input=document.getElementById('editInput');
            const svgRect=svg.node().getBoundingClientRect();
            const transform=d3.zoomTransform(svg.node());
            const screenX=transform.applyX(d.x)+svgRect.left;
            const screenY=transform.applyY(d.y)+svgRect.top+20;
            overlay.style.left=screenX+'px'; overlay.style.top=screenY+'px';
            overlay.classList.add('active');
            input.value=d.label; input.focus(); input.select();
        }

        function commitEdit() {
            if (!editingNode) return;
            const input=document.getElementById('editInput');
            const newLabel=input.value.trim();
            if (newLabel && newLabel!==editingNode.label) {
                editingNode.label=newLabel;
                dirty=true;
                document.getElementById('btnSave').classList.remove('hidden');
                renderGraph(graphData, true);
                showToast(t.labelUpdated);
            }
            cancelEdit();
        }

        function cancelEdit() {
            editingNode=null;
            document.getElementById('editOverlay').classList.remove('active');
        }

        // ============== Focus Mode (Action trace) ==============
        function traceBackPath(actionId) {
            if (!graphData) return { nodes: new Set(), links: new Set() };
            const pathNodes = new Set([actionId]);
            const pathLinks = new Set();
            const semanticLinks = graphData.links.filter(l => l.relation_type && l.relation_type !== 'parent');
            for (const link of semanticLinks) {
                const srcId = typeof link.source==='object'?link.source.id:link.source;
                const tgtId = typeof link.target==='object'?link.target.id:link.target;
                if (srcId===actionId) { pathNodes.add(tgtId); pathLinks.add(srcId+'->'+tgtId); }
                if (tgtId===actionId) { pathNodes.add(srcId); pathLinks.add(srcId+'->'+tgtId); }
            }
            return { nodes: pathNodes, links: pathLinks };
        }

        function enterFocusMode(actionId) {
            if (!graphData||!g) return;
            focusedActionId = actionId;
            const path = traceBackPath(actionId);
            document.getElementById('graphContainer').classList.add('focus-mode');
            g.selectAll('.node-group').classed('highlighted', d => path.nodes.has(d.id));
            g.selectAll('.link')
                .classed('highlighted', d => {
                    const s=typeof d.source==='object'?d.source.id:d.source;
                    const t=typeof d.target==='object'?d.target.id:d.target;
                    return path.links.has(s+'->'+t);
                })
                .attr('marker-end', d => {
                    const s=typeof d.source==='object'?d.source.id:d.source;
                    const t=typeof d.target==='object'?d.target.id:d.target;
                    if (path.links.has(s+'->'+t) && d.relation_type && d.relation_type!=='parent')
                        return 'url(#arrow-'+d.relation_type+')';
                    return null;
                });
        }

        function exitFocusMode() {
            focusedActionId = null;
            document.getElementById('graphContainer').classList.remove('focus-mode');
            if (g) {
                g.selectAll('.node-group').classed('highlighted', false);
                g.selectAll('.link').classed('highlighted', false)
                    .attr('marker-end', d => {
                        if (d.relation_type && d.relation_type!=='parent') return 'url(#arrow-'+d.relation_type+')';
                        return null;
                    });
            }
        }

        // ============== Action Cards ==============
        function findFrictionForAction(actionId) {
            if (!graphData) return null;
            const nodeMap = new Map(graphData.nodes.map(n=>[n.id,n]));
            const semanticLinks = graphData.links.filter(l => l.relation_type && l.relation_type!=='parent');
            const visited = new Set([actionId]);
            let frontier = [actionId];
            for (let iter=0; iter<10 && frontier.length>0; iter++) {
                const next = [];
                for (const nodeId of frontier) {
                    for (const link of semanticLinks) {
                        const srcId=typeof link.source==='object'?link.source.id:link.source;
                        const tgtId=typeof link.target==='object'?link.target.id:link.target;
                        let connId=null;
                        if (srcId===nodeId&&!visited.has(tgtId)) connId=tgtId;
                        if (tgtId===nodeId&&!visited.has(srcId)) connId=srcId;
                        if (connId) {
                            const node=nodeMap.get(connId);
                            if (node&&node.type==='friction') return node;
                            visited.add(connId); next.push(connId);
                        }
                    }
                }
                frontier=next;
            }
            return null;
        }

        function groupActionsByFriction(actions) {
            const groups = new Map();
            const ungrouped = [];
            for (const action of actions) {
                const friction = findFrictionForAction(action.id);
                if (friction) {
                    if (!groups.has(friction.id)) groups.set(friction.id, { friction, actions: [] });
                    groups.get(friction.id).actions.push(action);
                } else {
                    ungrouped.push(action);
                }
            }
            return { groups: Array.from(groups.values()), ungrouped };
        }

        function updateActionBadge() {
            if (!graphData) {
                const data = __YMIND_DATA__;
                const graph = data.graph || data;
                const count = (graph.nodes||[]).filter(n=>n.type==='action').length;
                const badge = document.getElementById('actionBadge');
                badge.textContent = count;
                if (count === 0) document.getElementById('btnActions').style.display = 'none';
                return;
            }
            const count = graphData.nodes.filter(n=>n.type==='action').length;
            const badge = document.getElementById('actionBadge');
            badge.textContent = count;
            if (count === 0) document.getElementById('btnActions').style.display = 'none';
        }

        function openActionCards() {
            if (!graphData) return;
            const actions = graphData.nodes.filter(n=>n.type==='action').sort((a,b)=>(a.turn_id||0)-(b.turn_id||0));
            if (actions.length===0) { showToast(t.noActions); return; }

            const { groups, ungrouped } = groupActionsByFriction(actions);
            const cardData = [];
            groups.forEach(({ friction, actions: fa }) => {
                cardData.push({ title: friction.label, subtitle: friction.rich_summary||'', actions: fa });
            });
            if (ungrouped.length>0) {
                cardData.push({ title: t.otherInsights, subtitle: t.otherInsightsDesc, actions: ungrouped });
            }

            currentCardData = cardData;
            document.getElementById('modalSubtitle').textContent = t.actionsFrom(actions.length, cardData.length);

            const container = document.getElementById('cardsContainer');
            const now = new Date();
            const dateStr = now.toLocaleDateString('en-GB',{day:'2-digit',month:'2-digit',year:'2-digit'});

            container.innerHTML = cardData.map((card,index) => `
                <div class="manifesto-card" data-card-index="${index}">
                    <div class="card-glow"></div>
                    <div class="card-header">
                        <h2 class="card-title">${escapeHtml(card.title)}</h2>
                        <span class="card-index">${index+1}/${cardData.length}</span>
                    </div>
                    <div class="card-divider"></div>
                    <div class="card-content">
                        ${card.actions.map(action => `
                            <div class="card-item"
                                 onmouseenter="enterFocusMode('${action.id}')"
                                 onmouseleave="exitFocusMode()">
                                <span class="card-bullet">&#10022;</span>
                                <div>
                                    <div>${escapeHtml(action.rich_summary||action.label)}</div>
                                    <div class="card-source">T${action.turn_id||'?'} · ${action.source||'unknown'}</div>
                                </div>
                            </div>
                        `).join('')}
                    </div>
                    <div class="card-footer">
                        <div class="card-quote">${dateStr}</div>
                        <div class="card-brand">YMIND</div>
                    </div>
                </div>
            `).join('');

            // Indicator dots
            const indicator = document.getElementById('cardsIndicator');
            indicator.innerHTML = cardData.map((_,i) =>
                '<div class="cards-indicator-dot'+(i===0?' active':'')+'" data-index="'+i+'"></div>'
            ).join('');
            container.addEventListener('scroll', updateCardIndicator);

            document.getElementById('manifestoModal').style.display = 'flex';
        }

        function updateCardIndicator() {
            const container=document.getElementById('cardsContainer');
            const cards=container.querySelectorAll('.manifesto-card');
            const dots=document.querySelectorAll('.cards-indicator-dot');
            if (!cards.length||!dots.length) return;
            const cr=container.getBoundingClientRect();
            const center=cr.left+cr.width/2;
            let closest=0, minDist=Infinity;
            cards.forEach((c,i) => {
                const r=c.getBoundingClientRect();
                const d=Math.abs(r.left+r.width/2-center);
                if (d<minDist) { minDist=d; closest=i; }
            });
            dots.forEach((dot,i) => dot.classList.toggle('active',i===closest));
        }

        function closeActionCards() {
            document.getElementById('cardsContainer').removeEventListener('scroll', updateCardIndicator);
            document.getElementById('manifestoModal').style.display='none';
            exitFocusMode();
        }

        // ============== Export / Save ==============
        async function exportImage() {
            if (!currentCardData || currentCardData.length === 0) return;
            const title = document.getElementById('graphTitle').textContent || 'YMind';
            const now = new Date();
            const dateStr = now.toLocaleDateString('zh-CN', { year:'numeric', month:'2-digit', day:'2-digit' }).replace(/\//g,'-');

            const renderCardHtml = (card) => `
                <div class="manifesto-card" style="width:100%;margin-bottom:20px;box-shadow:none;border:1px solid rgba(255,255,255,0.1);min-height:auto;max-height:none;">
                    <div class="card-header" style="margin-bottom:12px;">
                        <h2 class="card-title" style="font-size:16px;">${escapeHtml(card.title)}</h2>
                    </div>
                    <div class="card-divider" style="margin-bottom:12px;"></div>
                    <div class="card-content" style="flex:none;overflow:visible;">
                        ${card.actions.map(a => `
                            <div class="card-item">
                                <span class="card-bullet">✦</span>
                                <span>${escapeHtml(a.rich_summary || a.label)}</span>
                            </div>
                        `).join('')}
                    </div>
                </div>`;

            const container = document.getElementById('exportContainer');
            container.innerHTML = `
                <div class="manifesto-scroll">
                    <div class="scroll-header">
                        <div style="font-family:'Playfair Display',serif;font-size:22px;color:#fff;margin-bottom:6px;">${escapeHtml(title)}</div>
                        <div style="font-family:'Inter',sans-serif;font-size:11px;color:#00F0FF;">${escapeHtml(t.actionsFrom(currentCardData.reduce((s,c)=>s+c.actions.length,0),currentCardData.length))}</div>
                    </div>
                    ${currentCardData.map(renderCardHtml).join('')}
                    <div class="scroll-footer">YMIND · ${dateStr}</div>
                </div>`;

            try {
                const canvas = await html2canvas(container.querySelector('.manifesto-scroll'), {
                    backgroundColor: '#050608',
                    scale: 2,
                    useCORS: true,
                    logging: false,
                });
                const a = document.createElement('a');
                a.download = 'action-cards.png';
                a.href = canvas.toDataURL('image/png');
                a.click();
                showToast(lang === 'zh' ? '已保存' : 'Saved');
            } catch(err) {
                console.error(err);
                showToast(lang === 'zh' ? '导出失败' : 'Export failed');
            } finally {
                container.innerHTML = '';
            }
        }

        function copyCardText() {
            if (!currentCardData || currentCardData.length === 0) return;
            const lines = [];
            currentCardData.forEach(card => {
                lines.push('## ' + card.title);
                card.actions.forEach(action => {
                    lines.push('- [ ] ' + (action.rich_summary || action.label));
                });
                lines.push('');
            });
            navigator.clipboard.writeText(lines.join('\n').trim()).then(() => {
                showToast(t.copied);
            });
        }

        function buildExportData() {
            return {
                meta: __YMIND_DATA__.meta || { title: document.getElementById('graphTitle').textContent },
                graph: {
                    nodes: graphData.nodes.map(n => ({
                        id:n.id, label:n.label, type:n.type,
                        rich_summary:n.rich_summary, source:n.source, turn_id:n.turn_id
                    })),
                    edges: graphData.links.map(l => ({
                        source: typeof l.source==='object'?l.source.id:l.source,
                        target: typeof l.target==='object'?l.target.id:l.target,
                        relation_type: l.relation_type
                    }))
                },
                reasoning_shifts: __YMIND_DATA__.reasoning_shifts||[],
                actions: __YMIND_DATA__.actions||[],
                summary: __YMIND_DATA__.summary||''
            };
        }

        function exportJSON() {
            downloadFile(JSON.stringify(buildExportData(), null, 2), 'graph.json', 'application/json');
            showToast(t.jsonExported);
        }

        function saveHTML() {
            const currentData = buildExportData();
            const html = document.documentElement.outerHTML;
            const marker = 'const __YMIND_DATA__ = ';
            const start = html.indexOf(marker);
            const afterMarker = start + marker.length;
            const end = html.indexOf(';\n', afterMarker);
            const newHTML = '<!DOCTYPE html>\n' + html.substring(0,afterMarker) + JSON.stringify(currentData) + html.substring(end);
            downloadFile(newHTML, 'ymindmap-edited.html', 'text/html');
            dirty = false;
            document.getElementById('btnSave').classList.add('hidden');
            showToast(t.htmlSaved);
        }

        function downloadFile(content, filename, mime) {
            const blob=new Blob([content],{type:mime});
            const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
            a.download=filename; a.click(); URL.revokeObjectURL(a.href);
        }

        // ============== Utils ==============
        function resetZoom() { fitAll(750); }

        function showToast(msg, duration) {
            const el=document.getElementById('statusToast');
            el.textContent=msg; el.classList.add('visible');
            setTimeout(()=>el.classList.remove('visible'), duration||2000);
        }

        function escapeHtml(str) {
            return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
        }


        // ============== Theme Toggle ==============
        function toggleTheme() {
            const isLight = document.body.classList.toggle('light-mode');
            document.getElementById('themeIconMoon').style.display = isLight ? 'none' : '';
            document.getElementById('themeIconSun').style.display = isLight ? '' : 'none';
            localStorage.setItem('ymind-theme', isLight ? 'light' : 'dark');
        }
        function initTheme() {
            const saved = localStorage.getItem('ymind-theme');
            if (saved === 'light') {
                document.body.classList.add('light-mode');
                document.getElementById('themeIconMoon').style.display = 'none';
                document.getElementById('themeIconSun').style.display = '';
            }
        }
        // ============== Start ==============
        initTheme();
        init();
    </script>
</body>
</html>


path: README-zh.md
content: [English](README.md) | **中文**

# chat-visualizer-ymind

一个适用于 OpenClaw、Claude Code 和 Codex 的 AI agent skill。把任何 AI 对话可视化为结构化思维图 —— 提取推理路径、呈现思维阻力点、追踪想法的演化过程。

**核心理念：** 对话是线性的，思维不是。AI 对话里藏着大量洞察 —— 但它们散落在漫长的聊天线程里。你会忘记关键突破、重复走过的弯路、错过节点之间的连接。这个 skill 把你的思维外化：每一段对话，都变成一张可以导航的图，记录你真正想清楚了什么。

## 功能

分享一个对话链接 → 得到一张交互式力导向图：
- **推理节点** —— 从对话中提取事实、阻力、灵感和行动
- **思维转折检测** —— 对话在哪里发生了根本性的转变？
- **行动清单** —— 自动提取、按优先级排序、关联到具体的阻力上下文
- **D3.js 可视化** —— 暗色模式交互图，可以导出为单个 HTML 文件分享

![泰坦尼克号与功利性过滤器](assets/graph-demo.png)

## 快速开始

1. 安装 skill（见[安装](#安装)）
2. 把对话链接发给你的 agent：
   ```
   帮我可视化这个：https://chatgpt.com/share/xxx
   ```
3. agent 自动抓取对话、提取思维图、生成 HTML 文件
4. 打开 `graph.html` —— 浏览节点、追踪行动路径、导出洞察

**自动抓取**（分享链接 → agent 自动获取内容）：ChatGPT、Gemini、Claude、DeepSeek、豆包。

**粘贴模式**（没有分享链接？Ctrl+A → 复制 → 粘贴给 agent）：支持任何 AI 工具，不需要链接。

想支持其他平台的自动抓取？[提 issue](https://github.com/yslenjoy/chat-visualizer-ymind/issues)。

## 安装

### ClawHub

```bash
npx clawhub@latest install chat-visualizer-ymind
```

### 手动安装

#### OpenClaw

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.openclaw/skills/chat-visualizer-ymind
```

#### Claude Code

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.claude/skills/chat-visualizer-ymind
```

#### Codex

```bash
git clone https://github.com/yslenjoy/chat-visualizer-ymind.git ~/.codex/skills/chat-visualizer-ymind
```

## 输出结构

结果默认保存到 `~/ymind-ws/`（可通过 `YMIND_DIR` 覆盖）。每次运行生成一个独立文件夹 —— 你的个人思维图库。

```
~/ymind-ws/
  index.html                    ← 所有 session 的可视化时间线
  index.json                    ← 机器可读的 session 索引
  20260319-143021_chatgpt/
    raw_chat.json               ← 抓取的原始对话
    graph.json                  ← 提取的思维图数据
    graph.html                  ← D3.js 可视化（有 raw_chat.json 时左右分栏，左边原始对话）
    graph.png                   ← 截图
    meta.json                   ← provider、url、标题、创建时间
```

## 隐私

- **不存储 Cookie** —— 抓取使用全新浏览器上下文，不缓存任何会话数据
- **完全本地** —— 所有输出只保存在本机，不上传任何内容
- **数据不出本机** —— 所有对话数据仅保存在本地；渲染出的 HTML 通过 CDN 加载字体和可视化库（D3.js、html2canvas），需要联网才能正常显示
- 对话链接仅用于抓取你提供的公开分享页

## 处理流程

```
输入
  ├─ 分享链接  → scripts/run.sh fetch → fetch-chat.py → raw_chat.json
  └─ 粘贴文本  → 直接告诉 agent 对话内容（无需抓取）
        │
  [LLM] SKILL.md → 分析 → graph.json
        │
  scripts/run.sh render → render-html.py → graph.html + graph.png
        │
  （自动）重建 index.html + index.json  ← 你的完整 session 库
```

## 依赖

需要 Python 3.10+。

**最小安装**（粘贴模式）：无需安装任何依赖。

**完整功能**（自动抓取 URL + 截图）：
```bash
pip install requests playwright && playwright install chromium
```

## 许可证

MIT


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)