メインコンテンツへスキップ
見出し画像
Photo byelas_g

OpenCodeデスクトップアプリ用ログビューアの作成

    こんにちは。
    今回は、OpenCodeアプリ用のログビューアを(Geminiが)作成しました。

    公式アプリと、アプリから共有で表示したWEBページ(サーバー保管?)のログ確認の使い勝手が正直いまいちだったので、ローカルDB(sqlite3?)からデータを取り出してパースし、HTML生成するpythonスクリプトを生成してもらいました。
    DBの解析なども全部やってもらったので詳しいことはわかりませんが、どうやら会話の中身はすべて
    C:\Users\<ユーザー名>\.local\share\opencode
    などに保存さているようです(単純テキストやバイナリではなくDBに格納)。

    なのでそれを取り出して、HTMLを出力してもらうようにしました。
    コード内で、
    SQLITE_PATH = r"C:\Users\a\.local\share\opencode\opencode.db"
    OUTPUT_HTML = "opencode_viewer.html"
    となっているので、ユーザ名(a)は適宜書き換えてもらい、出力ファイルも必要に応じて変更してもらえればと思います。
    もしかするとインストール方法などでログの場所が違う可能性もあるので、その場合には環境に合わせて変更してください。

    実行するとカレントフォルダにHTMLが書き出されるので、そのまま表示するだけです。ブラウザの機能とは別の検索機能も付けました。

    ということでコードを貼り付けておきます。

    念のため内容もそのまま貼っておきます。
    内容に問題があればコメントなどでお知らせください。
    以上です。
    (以下コードの下には文章はありません)

    import sqlite3
    import json
    import html
    from datetime import datetime, timezone, timedelta
    
    SQLITE_PATH = r"C:\Users\a\.local\share\opencode\opencode.db"
    OUTPUT_HTML = "opencode_viewer.html"
    
    JST = timezone(timedelta(hours=9))
    
    
    def fmt_time(ts):
        return datetime.fromtimestamp(ts / 1000, JST).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
    
    
    def get_projects(conn):
        rows = conn.execute(
            """
            SELECT DISTINCT directory
            FROM session
            ORDER BY directory
            """
        ).fetchall()
        return [r[0] for r in rows]
    
    
    def get_sessions(conn, directory):
        rows = conn.execute(
            """
            SELECT
                id,
                slug,
                title,
                time_created
            FROM session
            WHERE directory=?
            ORDER BY time_created DESC
            """,
            (directory,),
        ).fetchall()
        return rows
    
    
    def get_messages(conn, session_id):
        rows = conn.execute(
            """
            SELECT
                id,
                time_created,
                data
            FROM message
            WHERE session_id=?
            ORDER BY time_created
            """,
            (session_id,),
        ).fetchall()
    
        result = []
        for mid, t, data in rows:
            meta = json.loads(data)
            role = meta.get("role", "?")
    
            parts_rows = conn.execute(
                """
                SELECT data
                FROM part
                WHERE message_id=?
                ORDER BY time_created
                """,
                (mid,),
            ).fetchall()
    
            parsed_parts = []
            for (p_data,) in parts_rows:
                try:
                    parsed_parts.append(json.loads(p_data))
                except:
                    pass
    
            result.append(
                {
                    "id": mid,
                    "role": role,
                    "time": t,
                    "parts": parsed_parts,
                }
            )
        return result
    
    
    def extract_user_text_preview(msg, length=40):
        full_text = ""
        for part in msg.get("parts", []):
            if part.get("type") == "text":
                full_text += part.get("text", "")
        full_text = full_text.strip().replace("\n", " ")
        if not full_text:
            return "(テキストなしの発言)"
        if len(full_text) > length:
            return full_text[:length] + "..."
        return full_text
    
    
    def extract_user_text_full(msg):
        full_text = ""
        for part in msg.get("parts", []):
            if part.get("type") == "text":
                full_text += part.get("text", "")
        return full_text.strip()
    
    
    def render_part(part):
        ptype = part.get("type", "")
    
        if ptype == "text":
            text = part.get("text", "")
            return f"""
            <div class="text">
                <pre>{html.escape(text)}</pre>
            </div>
            """
    
        if ptype == "reasoning":
            text = part.get("text", "")
            return f"""
            <details class="reasoning">
                <summary>Reasoning</summary>
                <pre>{html.escape(text)}</pre>
            </details>
            """
    
        if ptype == "tool":
            tool = part.get("tool", "?")
            state = part.get("state", {})
            inp = json.dumps(state.get("input", {}), ensure_ascii=False, indent=2)
            out = json.dumps(state.get("output", {}), ensure_ascii=False, indent=2)
    
            return f"""
            <details class="tool">
                <summary>Tool: {html.escape(tool)}</summary>
                <h4>Input</h4>
                <pre>{html.escape(inp)}</pre>
                <h4>Output</h4>
                <pre>{html.escape(out[:20000])}</pre>
            </details>
            """
        return ""
    
    
    def build_html():
        conn = sqlite3.connect(SQLITE_PATH)
    
        projects_html = []
        js_session_data = {}
    
        for directory in get_projects(conn):
            project_sessions_html = []
    
            for sid, slug, title, created in get_sessions(conn, directory):
                messages = get_messages(conn, sid)
                
                user_nav_html = []
                html_messages = []
                searchable_texts = []  # JSでの最速検索用にプレーンテキストを溜める
    
                for msg in messages:
                    role = msg["role"]
                    msg_id = msg["id"]
                    css_class = "user" if role == "user" else "assistant"
    
                    # メッセージ内の全テキストパートを結合して検索対象にする
                    msg_pure_text = ""
                    for part in msg.get("parts", []):
                        if part.get("type") in ["text", "reasoning"]:
                            msg_pure_text += part.get("text", "") + " "
                    
                    searchable_texts.append({
                        "id": msg_id,
                        "role": role,
                        "text": msg_pure_text
                    })
    
                    if role == "user":
                        preview = extract_user_text_preview(msg)
                        full_txt = extract_user_text_full(msg)
                        user_nav_html.append(
                            f"""
                            <div class="nav-user-msg" 
                                 title="{html.escape(full_txt)}" 
                                 onclick="scrollToMessage('{msg_id}', event)">
                                ー {html.escape(preview)}
                            </div>
                            """
                        )
    
                    parts_html = "".join([render_part(p) for p in msg["parts"]])
                    
                    html_messages.append(
                        f"""
                        <div class="message {css_class}" id="msg-{msg_id}">
                            <div class="meta">
                                {role} | {fmt_time(msg['time'])}
                            </div>
                            {parts_html}
                        </div>
                        """
                    )
    
                session_title = title or slug or "Untitled Session"
                
                js_session_data[str(sid)] = {
                    "title": session_title,
                    "html": "".join(html_messages),
                    "searchTexts": searchable_texts  # 検索用プレーンテキスト配列
                }
    
                project_sessions_html.append(
                    f"""
                    <details class="session" id="side-session-{sid}">
                        <summary onclick="loadSession('{sid}')">
                            {html.escape(session_title)}
                        </summary>
                        <div class="session-meta">ID: {sid}</div>
                        <div class="nav-user-list">
                            {"".join(user_nav_html) if user_nav_html else '<div class="nav-no-msg">※ユーザー発言なし</div>'}
                        </div>
                    </details>
                    """
                )
    
            projects_html.append(
                f"""
                <details class="project" open>
                    <summary>{html.escape(directory)}</summary>
                    {"".join(project_sessions_html)}
                </details>
                """
            )
    
        conn.close()
        
        js_session_data_json = json.dumps(js_session_data, ensure_ascii=False)
        all_projects_html_str = "".join(projects_html)
    
        html_template = r"""<!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <title>OpenCode Log Viewer</title>
    <style>
    body {
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        background: #1e1e1e ;
        color: #d4d4d4 ;
        margin: 0;
        padding: 0;
        display: flex;
        height: 100vh;
        overflow: hidden;
    }
    
    /* 左カラム:ナビゲーション */ #sidebar  {
        width: 350px;
        background: #252526 ;
        border-right: 1px solid #3c3c3c ;
        display: flex;
        flex-direction: column;
        height: 100%;
    }
     #sidebar -header {
        padding: 15px;
        border-bottom: 1px solid #3c3c3c ;
        background: #2d2d2d ;
    }
     #sidebar -header h1 {
        font-size: 16px;
        margin: 0;
        color: #ffffff ;
    }
     #sidebar -content {
        flex: 1;
        overflow-y: auto;
        padding: 10px;
    }
    
    /* 右カラム:コンテンツ表示 */ #content -area {
        flex: 1;
        display: flex;
        flex-direction: column;
        height: 100%;
        background: #1e1e1e ;
    }
     #search -bar {
        padding: 10px 20px;
        background: #2d2d2d ;
        border-bottom: 1px solid #3c3c3c ;
        display: flex;
        align-items: center;
        gap: 10px;
    }
     #search -input {
        flex: 1;
        max-width: 350px;
        background: #3c3c3c ;
        border: 1px solid #6b6b6b ;
        color: #fff ;
        padding: 6px 10px;
        border-radius: 4px;
        outline: none;
    }
     #search -input:focus {
        border-color: #007acc ;
    }
    
    .search-btn {
        background: #007acc ;
        color: white;
        border: none;
        padding: 6px 15px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 13px;
    }
    .search-btn:hover {
        background: #0098ff ;
    }
    .search-btn:disabled {
        background: #444 ;
        color: #777 ;
        cursor: not-allowed;
    }
    
    .clear-btn {
        background: #444 ;
        color: #ccc ;
        border: none;
        padding: 6px 10px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 13px;
    }
    .clear-btn:hover {
        background: #555 ;
        color: #fff ;
    }
     #search -count {
        font-size: 13px;
        color: #aaaaaa ;
        margin-left: 10px;
    }
    
    /* 📂 New: Grep風検索結果プレビューエリア */ #search -results-preview {
        background: #252526 ;
        border-bottom: 1px solid #3c3c3c ;
        max-height: 150px;
        overflow-y: auto;
        display: none; /* 通常時は非表示 */
        padding: 5px 20px;
    }
    
    .grep-item {
        padding: 6px 10px;
        margin: 4px 0;
        background: #1e1e1e ;
        border-radius: 4px;
        cursor: pointer;
        font-size: 13px;
        border-left: 3px solid #666 ;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .grep-item:hover {
        background: #2a2d2e ;
        border-left-color: #007acc ;
    }
    .grep-item .role-badge {
        font-size: 11px;
        font-weight: bold;
        padding: 1px 4px;
        border-radius: 3px;
        margin-right: 8px;
        text-transform: uppercase;
    }
    .grep-item .role-user { background: #004b87 ; color: #fff ; }
    .grep-item .role-assistant { background: #1a4d3e ; color: #fff ; }
    .grep-item mark {
        background: #ffdf00 ;
        color: #000 ;
        font-weight: bold;
        border-radius: 2px;
        padding: 0 2px;
    }
     #conversation -container {
        flex: 1;
        overflow-y: auto;
        padding: 20px 40px;
        scroll-behavior: smooth;
    }
    
    /* ツリー構造のスタイリング */
    summary {
        cursor: pointer;
        padding: 6px;
        border-radius: 4px;
        user-select: none;
        font-weight: bold;
        color: #cccccc ;
    }
    
    summary:hover {
        background: #2a2d2e ;
        color: #fff ;
    }
    
    .project {
        margin-bottom: 15px;
    }
    
    .session {
        margin-left: 15px;
        margin-top: 5px;
    }
    
    .session.active > summary {
        background: #37373d ;
        color: #0098ff ;
    }
    
    .session-meta {
        font-size: 11px;
        color: #777 ;
        margin-left: 24px;
        margin-bottom: 4px;
    }
    
    /* ユーザー発言ベースの目次スタイル */
    .nav-user-list {
        margin-left: 20px;
        border-left: 1px solid #3c3c3c ;
        padding-left: 5px;
        margin-bottom: 10px;
    }
    
    .nav-user-msg {
        font-size: 12px;
        color: #aaaaaa ;
        padding: 4px 6px;
        cursor: pointer;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        border-radius: 3px;
    }
    
    .nav-user-msg:hover {
        background: #2a2d2e ;
        color: #4fc1ff ;
    }
    
    .nav-no-msg {
        font-size: 11px;
        color: #666 ;
        padding-left: 6px;
    }
    
    /* メッセージ本体のスタイリング */
    .message {
        margin-bottom: 24px;
        padding: 15px;
        border-radius: 6px;
        border-left: 4px solid transparent;
        transition: background-color 0.2s;
    }
    
    /* 🎯 ジャンプ先を強調するフラッシュエフェクト */
    .message.target-flash {
        animation: flash-highlight 2.0s ease-out;
    }
    
    @keyframes flash-highlight {
        0% { background-color: #614d00 ; border-left-color: #ffdf00 ; }
        100% { background-color: transparent; }
    }
    
    .user {
        background: #1c2c3e ;
        border-left-color: #007acc ;
    }
    
    .assistant {
        background: #252526 ;
        border-left-color: #4ec9b0 ;
    }
    
    .meta {
        font-size: 12px;
        color: #858585 ;
        margin-bottom: 10px;
        border-bottom: 1px solid #333 ;
        padding-bottom: 4px;
    }
    
    pre {
        white-space: pre-wrap;
        word-break: break-all;
        font-family: 'Consolas', 'Courier New', Courier, monospace;
        font-size: 14px;
        margin: 0;
    }
    
    .reasoning, .tool {
        margin-top: 10px;
        background: #1e1e1e ;
        border: 1px solid #333 ;
        border-radius: 4px;
        padding: 5px;
    }
    
    .reasoning summary, .tool summary {
        font-size: 13px;
        color: #adadad ;
        font-weight: normal;
    }
    
    h4 {
        margin: 10px 0 5px 0;
        font-size: 12px;
        color: #858585 ;
    }
     #welcome -msg {
        text-align: center;
        color: #666 ;
        margin-top: 100px;
        font-size: 18px;
    }
    
    </style>
    </head>
    <body>
    
    <div id="sidebar">
        <div id="sidebar-header">
            <h1>OpenCode Log Viewer</h1>
        </div>
        <div id="sidebar-content">
            @PROJECTS_HTML@
        </div>
    </div>
    
    <div id="content-area">
        <div id="search-bar">
            <input type="text" id="search-input" placeholder="検索ワードを入力..." onkeydown="handleSearchKeyDown(event)" disabled>
            <button class="search-btn" id="search-submit-btn" onclick="performSearch()" disabled>検索</button>
            <button class="clear-btn" id="search-clear-btn" onclick="clearSearch()" disabled>×</button>
            <div id="search-count">セッションを選択してください</div>
        </div>
        
        <div id="search-results-preview"></div>
    
        <div id="conversation-container">
            <div id="welcome-msg">左カラムからセッションを選択してください。</div>
        </div>
    </div>
    
    <script>
    const sessionsData = @SESSIONS_JSON@;
    let currentSessionId = null;
    
    function loadSession(sessionId) {
        currentSessionId = sessionId;
        const container = document.getElementById('conversation-container');
        const searchInput = document.getElementById('search-input');
        const searchBtn = document.getElementById('search-submit-btn');
        const clearBtn = document.getElementById('search-clear-btn');
        const searchCount = document.getElementById('search-count');
        
        document.querySelectorAll('.session').forEach(el => el.classList.remove('active'));
        
        const targetSide = document.getElementById('side-session-' + sessionId);
        if (targetSide) {
            targetSide.classList.add('active');
        }
    
        if (sessionsData[sessionId]) {
            container.innerHTML = sessionsData[sessionId].html;
            searchInput.disabled = false;
            searchBtn.disabled = false;
            clearBtn.disabled = false;
            searchInput.value = '';
            searchCount.textContent = 'Enterまたは検索ボタンで実行';
            hidePreviewArea();
            container.scrollTop = 0;
        }
    }
    
    function scrollToMessage(msgId, event) {
        if (event) event.stopPropagation();
        
        const target = document.getElementById('msg-' + msgId);
        if (target) {
            target.scrollIntoView({ behavior: 'smooth', block: 'center' });
            
            // フラッシュアニメーションのトリガー
            target.classList.remove('target-flash');
            void target.offsetWidth; 
            target.classList.add('target-flash');
        }
    }
    
    function handleSearchKeyDown(event) {
        if (event.key === 'Enter') {
            performSearch();
        }
    }
    
    function hidePreviewArea() {
        const previewArea = document.getElementById('search-results-preview');
        previewArea.innerHTML = '';
        previewArea.style.display = 'none';
    }
    
    function clearSearch() {
        if (!currentSessionId) return;
        const searchInput = document.getElementById('search-input');
        const searchCount = document.getElementById('search-count');
        
        searchInput.value = '';
        searchCount.textContent = 'Enterまたは検索ボタンで実行';
        hidePreviewArea();
    }
    
    // 📂 ハイブリッド検索ロジック (超軽量設計)
    function performSearch() {
        if (!currentSessionId) return;
        
        const query = document.getElementById('search-input').value.trim();
        const searchCount = document.getElementById('search-count');
        const previewArea = document.getElementById('search-results-preview');
        
        if (!query) {
            clearSearch();
            return;
        }
        
        const sessionInfo = sessionsData[currentSessionId];
        if (!sessionInfo || !sessionInfo.searchTexts) return;
        
        let matchCount = 0;
        let htmlResult = [];
        
        const lowerQuery = query.toLowerCase();
        
        // HTMLのパースを一切せず、生のプレーンテキスト配列を最速ループ
        for (const item of sessionInfo.searchTexts) {
            const text = item.text;
            const idx = text.toLowerCase().indexOf(lowerQuery);
            
            if (idx !== -1) {
                matchCount++;
                
                // マッチした単語の前後15文字を抽出してGrepスニペットを作成
                const start = Math.max(0, idx - 15);
                const end = Math.min(text.length, idx + query.length + 15);
                let snippet = text.substring(start, end).replace(/\\n/g, ' ');
                
                // スニペット内の該当キーワードだけを安全にハイライト化
                const escapedQuery = escapeRegExp(query);
                const regex = new RegExp('(' + escapedQuery + ')', 'gi');
                const highlightedSnippet = htmlEscape(snippet).replace(regex, '<mark>$1</mark>');
                
                const badgeClass = item.role === 'user' ? 'role-user' : 'role-assistant';
                
                htmlResult.push(`
                    <div class="grep-item" onclick="scrollToMessage('${item.id}')" title="クリックしてこの発言へ移動">
                        <span class="role-badge ${badgeClass}">${item.role}</span>
                        ... ${highlightedSnippet} ...
                    </div>
                `);
            }
        }
        
        if (matchCount > 0) {
            searchCount.textContent = matchCount + ' 件の発言がヒット';
            previewArea.innerHTML = htmlResult.join('');
            previewArea.style.display = 'block';
        } else {
            searchCount.textContent = 'ヒットなし';
            previewArea.innerHTML = '<div style="font-size:12px; color:#777; padding:5px;">一致するテキストが見つかりませんでした。</div>';
            previewArea.style.display = 'block';
        }
    }
    
    function htmlEscape(str) {
        return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }
    
    // 2. この関数の中身を raw文字列 に合わせて以下のようにシンプルに修正
    function escapeRegExp(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }</script>
    
    </body>
    </html>
    """
    
        final_html = html_template.replace("@PROJECTS_HTML@", all_projects_html_str)
        final_html = final_html.replace("@SESSIONS_JSON@", js_session_data_json)
        
        return final_html
    
    
    html_text = build_html()
    
    with open(OUTPUT_HTML, "w", encoding="utf-8") as f:
        f.write(html_text)
    
    print("written:", OUTPUT_HTML)

    あなたへのおすすめ