ローカルファイルをAIに分析させる「Local Navigator」

【更新履歴】

・2026/2/19 バージョン1.0公開。
・2026/2/19 バージョン1.1公開。(ベクトル検索)
・2026/2/19 バージョン1.2公開。(手動モード廃止)

画像
バージョン1.1の画面
画像
バージョン1.2の画面

Local Navigator 1.1 ユーザーマニュアル


Local Navigator
は、あなたのローカル環境にある大量のファイル(ソースコードやドキュメント)を、外部のAI(ChatGPT, Claude, Geminiなど)を使って効率的に解析・編集するための「コンテキスト・エンジニアリングツール」です。

Pythonなどの環境構築は不要。ブラウザ(Chrome/Edge)とこのHTMLファイルだけで動作し、ローカルで動作するAIモデル(Transformers.js)によるベクトル検索までサポートしています。


はじめに:このツールの目的

通常のRAG(検索拡張生成)や、AIへのファイルアップロードには以下の課題があります。

  • トークン制限: 全ファイルを送るとコンテキストが溢れる、または課金が高額になる。

  • 検索精度: 単なるキーワード検索では、機能的な関連性(例:「認証」で「Login.ts」を探すなど)が見つからない。

  • プライバシー/手間: サーバーを立てたり、APIキーを管理するのが面倒。

LocalNavigator3は、**「AIにファイル構造(地図)だけを渡し、AIが必要なファイルだけを指名して読む」**というアプローチでこれらを解決します。さらに、ブラウザ内で完結するベクトル検索により、高度な意味検索も実現しました。


 セットアップと起動

  1. ファイルの準備:

    1. 提供されたHTMLコードを、index.html という名前でPC上の任意の場所に保存します。

  2. ブラウザで開く:

    1. index.html を Google Chrome または Microsoft Edge で開きます。

      • 注意: SafariやFirefoxでは「File System Access API」が完全にはサポートされていないため、動作しない場合があります。

  3. 初回ロード(重要):

    1. 起動直後、ブラウザは自動的に軽量AIモデル(約30MB〜)のダウンロードを開始します。

      • 画面右上の「VECTOR AI」バッジが点灯するまで数秒〜数分お待ちください。

      • 一度ダウンロードされれば、次回以降はキャッシュが使われるため一瞬で起動します。


画面構成

  • 左パネル(エクスプローラー):

    • 1. フォルダを開く: 解析対象のプロジェクトフォルダを選択します。

    • ファイル構造: ツリー形式でディレクトリ構造が表示されます。

    • オプション: スマート読込やベクトル検索のON/OFFが可能です。

  • 右パネル(インタラクション):

    • STEP 1: AIへの要望を入力し、最初の指示プロンプトを作成します。

    • STEP 2: AIからの返答(コマンド)を貼り付け、ファイルを読み込んで次のプロンプトを作成します。


使用ワークフロー

このツールは、**「Web上のAI(脳)」「ローカルのあなた(手足)」**を繋ぐブリッジとして機能します。

Step 0: フォルダの選択

  1. 左上の [1. フォルダを開く] ボタンをクリックします。

  2. 解析したいプロジェクトのルートフォルダを選択します。

  3. ブラウザが「ファイルの表示権限」を求めてくるので、「許可」 をクリックしてください。

    • ファイルパスのベクトル化(意味付け)がバックグラウンドで走ります。完了するとステータスバーに「準備完了」と表示されます。

Step 1: 要望の入力とプロンプト生成

  1. 右側の STEP 1 エリアにあるテキストボックスに、やりたいことを入力します。

    • 例:「認証周りのコードを読んで、セキュリティ上の脆弱性がないかチェックして」

    • 例:「src/components の中のReactコンポーネントをVue.jsに書き換える方法を教えて」

  2. [プロンプト生成 »] ボタンを押します。

  3. 下に生成された黒いボックスをクリックして、テキストをコピーします。

Step 2: AIとの対話(ループ)

  1. AIに送信:

    1. コピーしたテキストを、ChatGPT / Claude / Gemini などのチャット欄に貼り付けて送信します。

  2. AIの返答をコピー:

    1. AIはファイルの中身を知らないため、「では、src/auth/login.ts を読み込んでください(READ: src/auth/login.ts)」のように返してきます。

    2. このAIの返答テキストを、すべてコピーしてください。

  3. ツールで実行:

    1. LocalNavigator3に戻り、STEP 2 のテキストボックスに貼り付け、[実行 & 次のプロンプト生成 »] を押します。

      • ツールは自動的に READ: や SEARCH: コマンドを抽出し、指定されたファイルの中身や検索結果を取得します。

      • 挨拶文(「分かりました」など)は自動削除されます。

  4. 次のプロンプトをAIへ:

    1. 生成された「ファイルの中身入りプロンプト」をクリックしてコピーし、再びAIのチャット欄に貼り付けて送信します。

AIが「解決しました」と回答するまで、このStep 2の手順を繰り返してください。


高度な機能

ベクトル検索 (Neural Search)

このツールには all-MiniLM-L6-v2 モデルが内蔵されています。

AIがファイルパスを知らない場合でも、AIに SEARCH: ログイン処理 とコマンドを出させれば、ツールはファイル名に「ログイン」が含まれていなくても、意味的に近い AuthService.ts や SessionManager.js などを発見してAIに提示します。

スマート読込 (Smart Read)

左下のオプション [スマート読込] がONの場合(デフォルト)、300行を超える巨大なファイルは、「先頭200行 + 末尾50行」 だけを切り出してAIに渡します。

  • これにより、トークン消費を抑えつつ、ファイルの概要と重要なロジック(冒頭のimportや定義、末尾のexportなど)をAIに把握させることができます。


トラブルシューティング

  • Q. フォルダを選択しても何も起きない / エラーになる

    • ブラウザの権限設定を確認してください。また、システム保護されたフォルダ(Windowsフォルダなど)はアクセスできない場合があります。

  • Q. ベクトル検索が動かない(AIモデルロードエラー)

    • お使いのブラウザやハードウェアが WebGPU / WebAssembly に対応していない可能性があります。その場合でも、ツールは自動的に「キーワード一致検索」モードに切り替わって動作します。

  • Q. AIが READ: コマンドを出してくれない

    • Step 1で生成されるプロンプトには「ファイルを読むときは READ: パス と出力して」という指示が含まれていますが、AIによっては無視して勝手にコードを生成し始めることがあります。その場合は、「ファイルの中身を確認してから回答してください」とAIに追加で指示してください。


⚠️ 注意事項

  • データプライバシー:

    1. このツールはファイルを外部サーバーにアップロードしません。AIモデルのダウンロード以外で外部通信は行わず、ファイル解析はすべてあなたのブラウザ内(ローカル)で行われます。ただし、**生成されたプロンプトをChatGPT等に貼り付けた際、その内容は各AIサービスのプライバシーポリシーに従って扱われます。**機密情報の扱いにはご注意ください。

・ダウンロードされる方はこちら。↓

 


・1.1のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Local Navigator 1.1</title>
    <script type="module">
        import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.16.0';
        env.allowLocalModels = false;
        env.useBrowserCache = true;

        window.AI_PIPELINE = null;
        window.AI_MODEL_STATUS = "init"; 

        window.loadModel = async () => {
            try {
                updateStatus("AIモデルをダウンロード中... (初回のみ数分かかります)");
                window.AI_PIPELINE = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
                window.AI_MODEL_STATUS = "ready";
                updateStatus("AIモデル準備完了: ベクトル検索が可能です");
                document.getElementById('indicator-ai').classList.add('active');
            } catch (e) {
                console.error(e);
                window.AI_MODEL_STATUS = "error";
                updateStatus("AIモデルのロードに失敗しました (WebGPU/WASM非対応の可能性)");
            }
        };

        function updateStatus(msg) {
            const el = document.getElementById('sys-msg');
            if(el) el.innerText = msg;
        }

        window.loadModel();
    </script>
    <style>
        :root { --bg:#080808; --panel:#141414; --accent:#00ffcc; --text:#e0e0e0; --border:#333; --warn:#ffcc00; --danger:#ff4466; }
        body { margin:0; background:var(--bg); color:var(--text); font-family:'Segoe UI', sans-serif; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
        
        #header { height:50px; border-bottom:1px solid var(--border); display:flex; align-items:center; padding:0 20px; justify-content:space-between; background:#000; }
        .logo { font-weight:bold; color:var(--accent); font-size:18px; letter-spacing:1px; display:flex; align-items:center; gap:10px; }
        .badge { font-size:10px; padding:2px 6px; border-radius:4px; background:#333; color:#888; }
        .badge.active { background:rgba(0, 255, 204, 0.2); color:var(--accent); border:1px solid var(--accent); }

        #main { flex:1; display:flex; overflow:hidden; }
        
        #left { width:320px; background:var(--panel); border-right:1px solid var(--border); display:flex; flex-direction:column; padding:15px; gap:15px; font-size:12px; }
        #file-tree { flex:1; overflow-y:auto; border:1px solid #222; padding:8px; color:#999; white-space:pre; background:#0a0a0a; font-family:monospace; }
        
        #right { flex:1; display:flex; flex-direction:column; background:#0e0e0e; padding:20px; gap:20px; overflow-y:auto; }

        .step-card { background:#1a1a1a; border:1px solid var(--border); padding:20px; border-radius:6px; position:relative; transition:0.3s; }
        .step-card.active { border-color:var(--accent); box-shadow:0 0 15px rgba(0,255,204,0.05); }
        /* 修正点: pointer-events: none を削除し、操作可能にしました */
        .step-card.inactive { opacity:0.6; filter:grayscale(0.8); }
        .step-tag { position:absolute; top:-10px; left:15px; background:var(--bg); color:var(--accent); padding:0 8px; font-size:11px; font-weight:bold; border:1px solid var(--accent); }

        textarea { width:100%; height:80px; background:#050505; border:1px solid #333; color:#fff; padding:12px; font-family:monospace; box-sizing:border-box; resize:vertical; font-size:13px; }
        textarea:focus { border-color:var(--accent); outline:none; }
        
        .btn { background:#222; color:#ccc; border:1px solid #444; padding:10px 20px; cursor:pointer; font-size:12px; transition:0.2s; border-radius:4px; font-weight:bold; }
        .btn:hover { background:var(--accent); color:#000; border-color:var(--accent); }
        .btn-primary { background:rgba(0, 255, 204, 0.15); color:var(--accent); border:1px solid var(--accent); width:100%; }

        .result-box { margin-top:15px; background:#111; border-left:3px solid var(--accent); padding:10px; font-family:monospace; font-size:11px; color:#aaa; cursor:pointer; max-height:250px; overflow-y:auto; white-space:pre-wrap; }
        .result-box:hover { background:#181818; color:#fff; }
        .result-box::after { content:'クリックしてコピー'; display:block; text-align:right; font-size:10px; color:var(--accent); margin-top:8px; opacity:0.7; }
        
        .status-bar { font-size:11px; color:#666; display:flex; justify-content:space-between; }
        #sys-msg { color:var(--warn); animation: pulse 2s infinite; }
        @keyframes pulse { 0% { opacity:0.7; } 50% { opacity:1; } 100% { opacity:0.7; } }
    </style>
</head>
<body>

<div id="header">
    <div class="logo">
        Local Navigator <span style="font-size:10px; opacity:0.7;">NEURAL EDITION</span>
    </div>
    <div style="display:flex; gap:10px;">
        <span id="indicator-fs" class="badge">FILE SYSTEM</span>
        <span id="indicator-ai" class="badge">VECTOR AI</span>
    </div>
</div>

<div id="main">
    <div id="left">
        <button class="btn btn-primary" onclick="app.selectFolder()">1. フォルダを開く</button>
        <div id="folder-name" style="color:#fff; font-weight:bold; text-align:center;">未選択</div>
        
        <div class="status-bar">
            <span id="stat-files">0 ファイル</span>
            <span id="stat-vecs">0 ベクトル</span>
        </div>

        <div style="margin-top:10px; font-size:11px;">ファイル構造:</div>
        <div id="file-tree"></div>
        
        <div style="margin-top:auto; border-top:1px solid #333; padding-top:10px;">
            <div style="font-size:10px; color:#888; margin-bottom:5px;">オプション</div>
            <label style="display:flex; align-items:center; gap:5px; cursor:pointer;"><input type="checkbox" id="opt-smart" checked> スマート読込 (先頭200行+末尾50行)</label>
            <label style="display:flex; align-items:center; gap:5px; cursor:pointer;"><input type="checkbox" id="opt-vector" checked> ベクトル検索を有効化</label>
            <button class="btn" style="width:100%; margin-top:10px; font-size:10px;" onclick="location.reload()">リセット</button>
        </div>
    </div>

    <div id="right">
        <div style="font-size:11px;" id="sys-msg">システム待機中...</div>

        <div class="step-card active" id="step-1">
            <div class="step-tag">STEP 1: 要望の入力</div>
            <p style="font-size:12px; color:#888; margin:0 0 10px 0;">AIに依頼したい内容を入力してください。(例:「認証機能のコードを解析して」)</p>
            <textarea id="user-req" placeholder="ここに要望を入力..."></textarea>
            <div style="margin-top:10px; text-align:right;">
                <button class="btn" onclick="app.genPrompt()">プロンプト生成 &raquo;</button>
            </div>
            <div id="out-prompt-1" class="result-box" style="display:none;" onclick="app.copyText(this)"></div>
        </div>

        <div class="step-card inactive" id="step-2">
            <div class="step-tag">STEP 2: AI返答の処理</div>
            <p style="font-size:12px; color:#888; margin:0 0 10px 0;">Web上のAIからの返答(READ: xxx や SEARCH: xxx を含むもの)を貼り付けてください。</p>
            <textarea id="ai-resp" placeholder="AIの返答をここにペースト..."></textarea>
            <div style="margin-top:10px; text-align:right;">
                <button class="btn" onclick="app.processResponse()">実行 & 次のプロンプト生成 &raquo;</button>
            </div>
            <div id="out-prompt-2" class="result-box" style="display:none;" onclick="app.copyText(this)"></div>
        </div>
    </div>
</div>

<script type="module">
const app = {
    dirHandle: null,
    fileMap: {},
    vectors: [],
    treeText: "",
    
    selectFolder: async () => {
        try {
            app.dirHandle = await window.showDirectoryPicker();
            document.getElementById('indicator-fs').classList.add('active');
            document.getElementById('folder-name').innerText = app.dirHandle.name;
            
            app.fileMap = {};
            app.vectors = [];
            const lines = [];
            
            updateStatus("ファイルをスキャン中...");
            await app.scanDir(app.dirHandle, "", lines, 0);
            
            app.treeText = lines.join('\n');
            document.getElementById('file-tree').innerText = app.treeText;
            document.getElementById('stat-files').innerText = `${Object.keys(app.fileMap).length} ファイル`;
            
            if (window.AI_MODEL_STATUS === 'ready') {
                updateStatus("ファイルパスのベクトル化を実行中...");
                await app.vectorizePaths();
                updateStatus("準備完了。");
            } else {
                updateStatus("AIモデルのロード待ちです... ロード後に自動でベクトル化されます。");
            }

            // フォルダ選択後はStep 1にフォーカス
            app.switchStep(1);

        } catch(e) { console.error(e); alert("キャンセルされました"); }
    },

    scanDir: async (handle, path, lines, depth) => {
        const entries = [];
        for await (const entry of handle.values()) entries.push(entry);
        entries.sort((a,b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : (a.kind === 'directory' ? -1 : 1)));

        for (let i=0; i<entries.length; i++) {
            const entry = entries[i];
            if(['node_modules','.git','bin','obj','.vs','dist','build','vendor'].includes(entry.name)) continue;

            const isLast = (i === entries.length - 1);
            const prefix = "  ".repeat(depth) + (isLast ? "+-- " : "|-- ");
            
            if (entry.kind === 'file') {
                const ext = entry.name.split('.').pop().toLowerCase();
                if(!['exe','dll','png','jpg','jpeg','gif','zip','mp4','pdf'].includes(ext)) {
                    lines.push(prefix + entry.name);
                    const fullPath = path + entry.name;
                    app.fileMap[fullPath] = entry;
                    app.vectors.push({ path: fullPath, embedding: null });
                }
            } else {
                lines.push(prefix + entry.name + "/");
                await app.scanDir(entry, path + entry.name + "/", lines, depth + 1);
            }
        }
    },

    vectorizePaths: async () => {
        if (!window.AI_PIPELINE) return;
        const total = app.vectors.length;
        
        for (let i = 0; i < total; i++) {
            const item = app.vectors[i];
            const textToEmbed = item.path.replace(/[\/\._-]/g, ' '); 
            const output = await window.AI_PIPELINE(textToEmbed, { pooling: 'mean', normalize: true });
            item.embedding = output.data;
            
            if (i % 10 === 0) updateStatus(`ベクトル生成中... ${i}/${total}`);
        }
        document.getElementById('stat-vecs').innerText = `${total} 埋め込み済`;
    },

    searchFiles: async (query) => {
        if (!window.AI_PIPELINE || !document.getElementById('opt-vector').checked) {
            return app.vectors.filter(v => v.path.toLowerCase().includes(query.toLowerCase())).map(v => ({ item: v, score: 1 }));
        }

        const qOutput = await window.AI_PIPELINE(query, { pooling: 'mean', normalize: true });
        const qVec = qOutput.data;

        const results = app.vectors.map(item => {
            if (!item.embedding) return { item, score: 0 };
            
            let dot = 0;
            for (let i = 0; i < qVec.length; i++) {
                dot += qVec[i] * item.embedding[i];
            }
            if (item.path.toLowerCase().includes(query.toLowerCase())) {
                dot += 0.3; 
            }
            return { item, score: dot };
        });

        return results.sort((a,b) => b.score - a.score).slice(0, 5);
    },

    genPrompt: () => {
        const req = document.getElementById('user-req').value.trim();
        if(!req) return alert("要望を入力してください");

        const prompt = `
[システム指示]
あなたは高度なコーディングアシスタント「LocalNavigator」です。
現在、ユーザーのローカルファイルに直接アクセスできませんが、以下の「ファイル構造」は見えています。

[ユーザーの要望]
${req}

[プロトコル]
1. 要望とファイル構造を分析してください。
2. 情報を得るために、以下のコマンドを出力してください(挨拶や前置きは不要です)。
   - ファイルの中身を読む: "READ: パス"
   - ファイルを探す(パスが不明な場合): "SEARCH: 検索キーワード"
3. 情報が十分なら、回答を行ってください。

[ファイル構造]
${app.treeText}
`;
        app.showResult('out-prompt-1', prompt);
        
        // Step 2へフォーカスを移すが、Step 1は操作可能なままにする
        app.switchStep(2);
    },

    processResponse: async () => {
        const raw = document.getElementById('ai-resp').value;
        if(!raw.trim()) return;

        updateStatus("AI返答を解析中...");
        
        const lines = raw.split('\n');
        const commands = { reads: [], searches: [] };
        
        lines.forEach(line => {
            const r = line.match(/READ:\s*(.+)/);
            const s = line.match(/SEARCH:\s*(.+)/);
            if (r) commands.reads.push(r[1].trim().replace(/['"`]/g,''));
            if (s) commands.searches.push(s[1].trim().replace(/['"`]/g,''));
        });

        let output = "[システム] 以下の情報を取得しました:\n\n";
        let hasData = false;

        if (commands.searches.length > 0) {
            hasData = true;
            for (const q of commands.searches) {
                const hits = await app.searchFiles(q);
                output += `=== "SEARCH: ${q}" の結果 ===\n`;
                if(hits.length===0) output += "該当なし\n";
                else hits.forEach(h => output += `- ${h.item.path} (関連度: ${h.score.toFixed(2)})\n`);
                output += "\n";
            }
        }

        if (commands.reads.length > 0) {
            hasData = true;
            const useSmart = document.getElementById('opt-smart').checked;
            
            for (const path of commands.reads) {
                const cleanPath = Object.keys(app.fileMap).find(p => p.endsWith(path)) || path;
                
                if (app.fileMap[cleanPath]) {
                    try {
                        const f = await app.fileMap[cleanPath].getFile();
                        const text = await f.text();
                        const lines = text.split('\n');
                        let content = text;
                        
                        if (useSmart && lines.length > 300) {
                            content = lines.slice(0, 200).join('\n') + 
                                      `\n\n... (中略: ${lines.length - 250}行) ...\n\n` + 
                                      lines.slice(-50).join('\n');
                        }
                        
                        output += `=== FILE: ${cleanPath} ===\n${content}\n======================\n\n`;
                    } catch(e) {
                        output += `[エラー] ${cleanPath} を読み込めませんでした: ${e.message}\n`;
                    }
                } else {
                    output += `[エラー] ファイルが見つかりません: ${path}\n`;
                }
            }
        }

        if (!hasData) {
            output = "[システム] ファイル読み込み/検索コマンドが検出されませんでした。\nこれが最終回答であれば、ユーザーへの返答として扱ってください。\nもし操作が必要なら、正しい形式 (READ: xxx) で出力してください。";
        } else {
            output += "\n[指示] 上記の情報を元に回答を作成するか、さらに必要な情報があれば READ/SEARCH コマンドを出力してください。";
        }

        app.showResult('out-prompt-2', output);
        document.getElementById('ai-resp').value = ""; 
        updateStatus("処理完了。次のプロンプトをコピーしてください。");
    },

    switchStep: (n) => {
        // 両方のステップを「見た目」だけで切り替えるが、操作はブロックしない
        document.getElementById('step-1').className = n===1 ? 'step-card active' : 'step-card inactive';
        document.getElementById('step-2').className = n===2 ? 'step-card active' : 'step-card inactive';
    },

    showResult: (id, text) => {
        const el = document.getElementById(id);
        el.innerText = text;
        el.style.display = 'block';
    },

    copyText: (el) => {
        navigator.clipboard.writeText(el.innerText);
        const bg = el.style.backgroundColor;
        el.style.backgroundColor = '#333';
        setTimeout(() => el.style.backgroundColor = '', 200);
    }
};

window.app = app;

function updateStatus(msg) {
    const el = document.getElementById('sys-msg');
    if(el) el.innerText = msg;
}
</script>
</body>
</html>


・1.2のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>LocalNavigator 1.2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #060608;
    --surface: #0d0d12;
    --surface2: #141420;
    --border: #1e1e2e;
    --border2: #2a2a3e;
    --accent: #7c6af5;
    --accent2: #a89cf7;
    --green: #39d98a;
    --amber: #f5a623;
    --red: #f54262;
    --text: #c8c8e0;
    --text2: #6868a0;
    --text3: #3a3a5a;
    --mono: 'IBM Plex Mono', monospace;
    --display: 'Space Mono', monospace;
  }

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

  body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--mono);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    font-size: 13px;
  }

  /* ─── NOISE OVERLAY ─── */
  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
    pointer-events: none;
    z-index: 9999;
    opacity: 0.4;
  }

  /* ─── HEADER ─── */
  #header {
    height: 48px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    padding: 0 20px;
    justify-content: space-between;
    background: var(--surface);
    flex-shrink: 0;
  }

  .logo {
    font-family: var(--display);
    font-size: 13px;
    color: var(--accent2);
    letter-spacing: 2px;
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .logo-ver {
    font-size: 10px;
    color: var(--text3);
    letter-spacing: 1px;
  }

  .header-right {
    display: flex;
    align-items: center;
    gap: 16px;
  }

  .pill {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 10px;
    color: var(--text2);
    padding: 3px 10px;
    border: 1px solid var(--border2);
    border-radius: 100px;
    font-family: var(--mono);
    letter-spacing: 1px;
  }

  .pill-dot {
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--text3);
  }

  .pill.active .pill-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
  .pill.active { color: var(--green); border-color: rgba(57,217,138,0.3); }
  .pill.warn .pill-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
  .pill.warn { color: var(--amber); border-color: rgba(245,166,35,0.3); }

  /* ─── LAYOUT ─── */
  #main {
    flex: 1;
    display: flex;
    overflow: hidden;
  }

  /* ─── LEFT PANEL ─── */
  #left {
    width: 280px;
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    background: var(--surface);
    flex-shrink: 0;
  }

  .panel-header {
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
    font-size: 10px;
    color: var(--text2);
    letter-spacing: 2px;
    text-transform: uppercase;
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  #folder-btn {
    width: 100%;
    padding: 14px 16px;
    background: transparent;
    border: none;
    border-bottom: 1px solid var(--border);
    color: var(--text);
    font-family: var(--mono);
    font-size: 12px;
    text-align: left;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 10px;
    transition: background 0.2s;
  }

  #folder-btn:hover { background: var(--surface2); }

  .icon {
    width: 16px;
    height: 16px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }

  #folder-name {
    color: var(--accent2);
    font-size: 11px;
    padding: 8px 16px;
    border-bottom: 1px solid var(--border);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  #file-stats {
    display: flex;
    padding: 8px 16px;
    gap: 12px;
    border-bottom: 1px solid var(--border);
  }

  .stat-item {
    font-size: 10px;
    color: var(--text2);
  }

  .stat-val { color: var(--accent2); font-weight: 500; }

  #file-tree {
    flex: 1;
    overflow-y: auto;
    padding: 10px 8px;
    font-size: 11px;
    color: var(--text2);
    line-height: 1.8;
    font-family: var(--mono);
  }

  #file-tree::-webkit-scrollbar { width: 4px; }
  #file-tree::-webkit-scrollbar-track { background: transparent; }
  #file-tree::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }

  .tree-line { padding: 1px 4px; border-radius: 3px; cursor: default; }
  .tree-line.loaded { color: var(--green); }
  .tree-line.loading { color: var(--amber); }

  .tree-dir { color: var(--accent2); }
  .tree-lazy { color: var(--text3); cursor: pointer; }
  .tree-lazy:hover { color: var(--text2); }

  #left-bottom {
    padding: 12px 16px;
    border-top: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    gap: 8px;
  }

  .opt-label {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 11px;
    color: var(--text2);
    cursor: pointer;
  }

  .opt-label input[type=checkbox] {
    accent-color: var(--accent);
    width: 12px;
    height: 12px;
  }

  /* ─── RIGHT PANEL ─── */
  #right {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  /* ─── CHAT AREA ─── */
  #chat-area {
    flex: 1;
    overflow-y: auto;
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  #chat-area::-webkit-scrollbar { width: 4px; }
  #chat-area::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }

  /* ─── MESSAGE ─── */
  .msg-group {
    display: flex;
    flex-direction: column;
    margin-bottom: 24px;
    animation: fadeUp 0.3s ease;
  }

  @keyframes fadeUp {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
  }

  .msg-role {
    font-size: 10px;
    letter-spacing: 2px;
    text-transform: uppercase;
    color: var(--text3);
    margin-bottom: 6px;
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .msg-role.user-role { color: var(--accent2); }
  .msg-role.agent-role { color: var(--green); }
  .msg-role.system-role { color: var(--amber); }

  .msg-content {
    font-size: 13px;
    line-height: 1.7;
    color: var(--text);
    white-space: pre-wrap;
    word-break: break-word;
  }

  /* ─── TOOL CALL BLOCK ─── */
  .tool-block {
    margin: 8px 0;
    border: 1px solid var(--border2);
    border-radius: 4px;
    overflow: hidden;
    animation: fadeUp 0.2s ease;
  }

  .tool-header {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    background: var(--surface2);
    font-size: 10px;
    letter-spacing: 1px;
    text-transform: uppercase;
    cursor: pointer;
    user-select: none;
  }

  .tool-header.read-h { color: var(--accent2); border-bottom: 1px solid rgba(124,106,245,0.2); }
  .tool-header.search-h { color: var(--green); border-bottom: 1px solid rgba(57,217,138,0.2); }
  .tool-header.error-h { color: var(--red); border-bottom: 1px solid rgba(245,66,98,0.2); }
  .tool-header.expand-h { color: var(--amber); border-bottom: 1px solid rgba(245,166,35,0.2); }

  .tool-body {
    padding: 10px 12px;
    font-size: 11px;
    color: var(--text2);
    max-height: 300px;
    overflow-y: auto;
    font-family: var(--mono);
    white-space: pre-wrap;
    word-break: break-all;
    display: none;
  }

  .tool-body.open { display: block; }

  .tool-toggle {
    margin-left: auto;
    font-size: 10px;
    color: var(--text3);
    transition: transform 0.2s;
  }

  .tool-status {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 10px;
  }

  .spin {
    display: inline-block;
    width: 10px;
    height: 10px;
    border: 1px solid var(--border2);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }

  @keyframes spin { to { transform: rotate(360deg); } }

  /* ─── THINKING ─── */
  .thinking-block {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 0;
    color: var(--text3);
    font-size: 11px;
  }

  .thinking-dots span {
    display: inline-block;
    width: 4px;
    height: 4px;
    background: var(--accent);
    border-radius: 50%;
    margin: 0 2px;
    animation: blink 1.2s infinite;
    opacity: 0.3;
  }

  .thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
  .thinking-dots span:nth-child(3) { animation-delay: 0.4s; }

  @keyframes blink { 50% { opacity: 1; } }

  /* ─── DIVIDER ─── */
  .loop-divider {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 4px 0 16px;
    color: var(--text3);
    font-size: 10px;
    letter-spacing: 1px;
  }

  .loop-divider::before, .loop-divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border);
  }

  /* ─── INPUT AREA ─── */
  #input-area {
    border-top: 1px solid var(--border);
    background: var(--surface);
    padding: 16px 20px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    flex-shrink: 0;
  }

  #apikey-row {
    display: flex;
    align-items: center;
    gap: 10px;
  }

  .input-label {
    font-size: 10px;
    color: var(--text2);
    letter-spacing: 1px;
    white-space: nowrap;
  }

  input[type=text], input[type=password] {
    background: var(--surface2);
    border: 1px solid var(--border2);
    color: var(--text);
    font-family: var(--mono);
    font-size: 12px;
    padding: 8px 12px;
    border-radius: 4px;
    outline: none;
    transition: border-color 0.2s;
  }

  input[type=text]:focus, input[type=password]:focus {
    border-color: var(--accent);
  }

  #apikey-input {
    flex: 1;
    min-width: 0;
  }

  #msg-row {
    display: flex;
    gap: 10px;
    align-items: flex-end;
  }

  #msg-input {
    flex: 1;
    min-height: 44px;
    max-height: 120px;
    background: var(--surface2);
    border: 1px solid var(--border2);
    color: var(--text);
    font-family: var(--mono);
    font-size: 13px;
    padding: 12px 14px;
    border-radius: 4px;
    outline: none;
    resize: none;
    transition: border-color 0.2s;
    line-height: 1.5;
  }

  #msg-input:focus { border-color: var(--accent); }

  #send-btn {
    height: 44px;
    padding: 0 20px;
    background: var(--accent);
    color: #fff;
    border: none;
    border-radius: 4px;
    font-family: var(--display);
    font-size: 11px;
    cursor: pointer;
    letter-spacing: 1px;
    transition: opacity 0.2s, transform 0.1s;
    white-space: nowrap;
    flex-shrink: 0;
  }

  #send-btn:hover { opacity: 0.85; }
  #send-btn:active { transform: scale(0.97); }
  #send-btn:disabled { opacity: 0.4; cursor: not-allowed; }

  /* ─── EMPTY STATE ─── */
  #empty-state {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 16px;
    color: var(--text3);
    font-size: 12px;
    text-align: center;
  }

  .empty-logo {
    font-family: var(--display);
    font-size: 32px;
    color: var(--border2);
    letter-spacing: 4px;
  }

  .empty-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    font-size: 11px;
    color: var(--text3);
    text-align: left;
    border: 1px solid var(--border);
    padding: 16px 24px;
    border-radius: 4px;
  }

  .empty-step {
    display: flex;
    gap: 12px;
    align-items: flex-start;
  }

  .empty-step-num {
    color: var(--accent);
    font-weight: 700;
    flex-shrink: 0;
  }

  /* ─── SCROLLBAR ─── */
  textarea::-webkit-scrollbar { width: 4px; }
  textarea::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }

  /* ─── SYSTEM LOG LINE ─── */
  .sys-line {
    font-size: 10px;
    color: var(--text3);
    padding: 2px 0;
    font-family: var(--mono);
    letter-spacing: 0.5px;
  }

  /* ─── FILE LOADED BADGE ─── */
  .loaded-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 9px;
    padding: 1px 6px;
    border-radius: 100px;
    background: rgba(57,217,138,0.1);
    color: var(--green);
    border: 1px solid rgba(57,217,138,0.2);
    vertical-align: middle;
    margin-left: 6px;
  }

  /* ─── ITERATION COUNTER ─── */
  .iter-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 9px;
    padding: 1px 6px;
    border-radius: 100px;
    background: rgba(124,106,245,0.1);
    color: var(--accent2);
    border: 1px solid rgba(124,106,245,0.2);
    vertical-align: middle;
  }
</style>
</head>
<body>

<div id="header">
  <div class="logo">
    LOCAL NAVIGATOR
    <span class="logo-ver">v2.0 // AUTONOMOUS</span>
  </div>
  <div class="header-right">
    <div class="pill" id="pill-fs">
      <div class="pill-dot"></div>
      FILE SYSTEM
    </div>
    <div class="pill" id="pill-api">
      <div class="pill-dot"></div>
      API
    </div>
    <div class="pill" id="pill-agent" style="display:none;">
      <div class="spin"></div>
      RUNNING
    </div>
  </div>
</div>

<div id="main">

  <!-- LEFT PANEL -->
  <div id="left">
    <div class="panel-header">
      <span>WORKSPACE</span>
    </div>
    <button id="folder-btn" onclick="nav.selectFolder()">
      <svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
        <path d="M2 4a1 1 0 011-1h3.5l1.5 1.5H13a1 1 0 011 1V12a1 1 0 01-1 1H3a1 1 0 01-1-1V4z"/>
      </svg>
      フォルダを選択
    </button>
    <div id="folder-name" style="color:var(--text3); font-size:11px; padding:8px 16px; border-bottom:1px solid var(--border);">未選択</div>
    <div id="file-stats" style="display:none;">
      <span class="stat-item"><span class="stat-val" id="stat-files">0</span> files</span>
      <span class="stat-item"><span class="stat-val" id="stat-dirs">0</span> dirs</span>
      <span class="stat-item"><span class="stat-val" id="stat-loaded">0</span> loaded</span>
    </div>
    <div id="file-tree"><span style="color:var(--text3); font-size:11px; padding:10px 8px; display:block;">— フォルダを選択してください —</span></div>
    <div id="left-bottom">
      <label class="opt-label">
        <input type="checkbox" id="opt-smart" checked>
        スマート読込 (先頭200行+末尾50行)
      </label>
      <label class="opt-label">
        <input type="checkbox" id="opt-lazy" checked>
        遅延展開 (AIが必要とした時のみ)
      </label>
      <label class="opt-label">
        <input type="checkbox" id="opt-summary" checked>
        コンテキスト圧縮 (長い会話を要約)
      </label>
    </div>
  </div>

  <!-- RIGHT PANEL -->
  <div id="right">
    <div id="chat-area">
      <div id="empty-state">
        <div class="empty-logo">LN</div>
        <div style="color: var(--text2); font-size: 13px;">Autonomous File Intelligence</div>
        <div class="empty-steps">
          <div class="empty-step">
            <span class="empty-step-num">01</span>
            <span>Anthropic APIキーを入力</span>
          </div>
          <div class="empty-step">
            <span class="empty-step-num">02</span>
            <span>解析したいプロジェクトフォルダを選択</span>
          </div>
          <div class="empty-step">
            <span class="empty-step-num">03</span>
            <span>自然言語で質問するだけ</span>
          </div>
        </div>
        <div style="font-size: 10px; color: var(--text3); max-width: 360px; line-height: 1.8;">
          AIがファイル構造を把握し、必要なファイルだけを自律的に読み込みながら回答します。<br>
          コピペ不要。ループは自動。
        </div>
      </div>
    </div>

    <div id="input-area">
      <div id="apikey-row">
        <span class="input-label">API KEY</span>
        <input type="password" id="apikey-input" placeholder="sk-ant-..." style="flex:1; min-width:0;">
        <button class="pill" style="cursor:pointer; border:1px solid var(--accent); color:var(--accent2); background:transparent; font-family:var(--mono); font-size:10px; letter-spacing:1px;" onclick="nav.saveKey()">SAVE</button>
      </div>
      <div id="msg-row">
        <textarea id="msg-input" placeholder="このプロジェクトの認証フローを解析して..." rows="1" oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px';" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();nav.send();}"></textarea>
        <button id="send-btn" onclick="nav.send()">SEND ↵</button>
      </div>
    </div>
  </div>

</div>

<script>
const MAX_ITER = 10;
const SMART_HEAD = 200;
const SMART_TAIL = 50;

const nav = {
  apiKey: '',
  dirHandle: null,
  fileMap: {},         // path -> FileSystemFileHandle
  dirMap: {},          // path -> FileSystemDirectoryHandle  
  treeLines: [],       // { path, kind, depth, expanded }
  loadedFiles: {},     // path -> content (cache)
  loadedCount: 0,
  conversation: [],    // Anthropic messages array
  running: false,

  // ── KEY ──────────────────────────────────────────────
  saveKey() {
    const k = document.getElementById('apikey-input').value.trim();
    if (!k) return;
    this.apiKey = k;
    document.getElementById('apikey-input').value = '●'.repeat(20);
    pill('pill-api', true);
    this.sysLog('APIキーを設定しました');
  },

  // ── FOLDER ───────────────────────────────────────────
  async selectFolder() {
    try {
      this.dirHandle = await window.showDirectoryPicker({ mode: 'read' });
      this.fileMap = {};
      this.dirMap = {};
      this.treeLines = [];
      this.loadedFiles = {};
      this.loadedCount = 0;
      this.conversation = [];

      document.getElementById('folder-name').textContent = this.dirHandle.name;
      document.getElementById('folder-name').style.color = 'var(--accent2)';
      document.getElementById('stat-loaded').textContent = '0';

      this.sysLog('スキャン中...');
      pill('pill-fs', true);

      const isLazy = document.getElementById('opt-lazy').checked;
      if (isLazy) {
        // 遅延展開: ルートだけ展開
        await this.scanDir(this.dirHandle, '', 0, true /* rootOnly */);
      } else {
        await this.scanDir(this.dirHandle, '', 0, false);
      }

      this.renderTree();
      document.getElementById('file-stats').style.display = 'flex';
      document.getElementById('stat-files').textContent = Object.keys(this.fileMap).length;
      document.getElementById('stat-dirs').textContent = Object.keys(this.dirMap).length;

      // 会話リセット
      document.getElementById('chat-area').innerHTML = '';

      this.sysLog(`準備完了 — ${Object.keys(this.fileMap).length} files, ${Object.keys(this.dirMap).length} dirs`);
    } catch(e) {
      if (e.name !== 'AbortError') this.sysLog('フォルダ選択エラー: ' + e.message);
    }
  },

  SKIP: ['node_modules','.git','bin','obj','.vs','dist','build','vendor','.next','__pycache__','.cache'],
  SKIP_EXT: ['exe','dll','png','jpg','jpeg','gif','zip','mp4','pdf','ico','woff','woff2','ttf','eot','svg','webp','lock','map'],

  async scanDir(handle, path, depth, rootOnly = false) {
    const entries = [];
    for await (const e of handle.values()) entries.push(e);
    entries.sort((a,b) => a.kind === b.kind ? a.name.localeCompare(b.name) : (a.kind === 'directory' ? -1 : 1));

    for (const entry of entries) {
      if (this.SKIP.includes(entry.name)) continue;

      if (entry.kind === 'directory') {
        const fullPath = path + entry.name + '/';
        this.dirMap[fullPath] = entry;
        this.treeLines.push({ path: fullPath, kind: 'dir', depth, expanded: false });
        if (!rootOnly) {
          await this.scanDir(entry, fullPath, depth + 1, false);
          this.treeLines.find(l => l.path === fullPath).expanded = true;
        }
      } else {
        const ext = entry.name.split('.').pop().toLowerCase();
        if (this.SKIP_EXT.includes(ext)) continue;
        const fullPath = path + entry.name;
        this.fileMap[fullPath] = entry;
        this.treeLines.push({ path: fullPath, kind: 'file', depth });
      }
    }
  },

  async expandDir(dirPath) {
    const dirEntry = this.dirMap[dirPath];
    if (!dirEntry) return;

    const line = this.treeLines.find(l => l.path === dirPath);
    if (line && line.expanded) return;

    // insert after dirPath
    const idx = this.treeLines.findIndex(l => l.path === dirPath);
    const depth = line ? line.depth : 0;

    const newLines = [];
    const addToMap = async (handle, path, d) => {
      const entries = [];
      for await (const e of handle.values()) entries.push(e);
      entries.sort((a,b) => a.kind === b.kind ? a.name.localeCompare(b.name) : (a.kind === 'directory' ? -1 : 1));
      for (const entry of entries) {
        if (this.SKIP.includes(entry.name)) continue;
        if (entry.kind === 'directory') {
          const fp = path + entry.name + '/';
          this.dirMap[fp] = entry;
          newLines.push({ path: fp, kind: 'dir', depth: d, expanded: false });
        } else {
          const ext = entry.name.split('.').pop().toLowerCase();
          if (this.SKIP_EXT.includes(ext)) continue;
          const fp = path + entry.name;
          this.fileMap[fp] = entry;
          newLines.push({ path: fp, kind: 'file', depth: d });
        }
      }
    };

    await addToMap(dirEntry, dirPath, depth + 1);
    this.treeLines.splice(idx + 1, 0, ...newLines);
    if (line) line.expanded = true;

    this.renderTree();
    document.getElementById('stat-files').textContent = Object.keys(this.fileMap).length;
    document.getElementById('stat-dirs').textContent = Object.keys(this.dirMap).length;
  },

  renderTree() {
    const el = document.getElementById('file-tree');
    el.innerHTML = '';
    for (const line of this.treeLines) {
      const div = document.createElement('div');
      div.className = 'tree-line' + (line.kind === 'dir' ? ' tree-dir' : '');
      const indent = '  '.repeat(line.depth);
      const icon = line.kind === 'dir' ? (line.expanded ? '▾ ' : '▸ ') : '  ';

      if (this.loadedFiles[line.path]) div.classList.add('loaded');

      if (line.kind === 'dir' && !line.expanded) {
        div.classList.add('tree-lazy');
        div.title = 'クリックして展開';
        div.onclick = () => {
          this.expandDir(line.path);
        };
      }

      div.textContent = indent + icon + (line.kind === 'dir' ? line.path.split('/').filter(Boolean).pop() + '/' : line.path.split('/').pop());
      div.id = 'tree-' + btoa(line.path).replace(/=/g,'');
      el.appendChild(div);
    }
  },

  markTreeLoaded(path) {
    const id = 'tree-' + btoa(path).replace(/=/g,'');
    const el = document.getElementById(id);
    if (el) el.classList.add('loaded');
  },

  // ── FILE READ ─────────────────────────────────────────
  async readFile(path) {
    // cache
    if (this.loadedFiles[path]) return this.loadedFiles[path];

    // find handle
    const handle = this.fileMap[path] || Object.entries(this.fileMap).find(([k]) => k.endsWith(path) || path.endsWith(k))?.[1];
    if (!handle) return null;

    try {
      const f = await handle.getFile();
      const text = await f.text();
      const lines = text.split('\n');
      let content = text;

      if (document.getElementById('opt-smart').checked && lines.length > SMART_HEAD + SMART_TAIL) {
        content = lines.slice(0, SMART_HEAD).join('\n')
          + `\n\n[...中略: ${lines.length - SMART_HEAD - SMART_TAIL}行省略...]\n\n`
          + lines.slice(-SMART_TAIL).join('\n');
      }

      this.loadedFiles[path] = content;
      this.loadedCount++;
      document.getElementById('stat-loaded').textContent = this.loadedCount;
      this.markTreeLoaded(path);
      return content;
    } catch(e) {
      return '[読み込みエラー: ' + e.message + ']';
    }
  },

  // ── TREE TEXT ─────────────────────────────────────────
  getTreeText(expanded = false) {
    if (this.treeLines.length === 0) return '(フォルダ未選択)';
    return this.treeLines.map(l => {
      const indent = '  '.repeat(l.depth);
      const icon = l.kind === 'dir' ? (l.expanded ? '▾ ' : '▸ ') : '  ';
      return indent + icon + (l.kind === 'dir' ? l.path.split('/').filter(Boolean).pop() + '/' : l.path.split('/').pop());
    }).join('\n');
  },

  // ── SEND ──────────────────────────────────────────────
  async send() {
    if (this.running) return;
    const key = this.apiKey;
    if (!key) { this.sysLog('⚠ APIキーを入力してください'); return; }

    const msgEl = document.getElementById('msg-input');
    const userMsg = msgEl.value.trim();
    if (!userMsg) return;

    msgEl.value = '';
    msgEl.style.height = 'auto';
    this.running = true;
    document.getElementById('send-btn').disabled = true;
    pill('pill-agent', true, true);

    // hide empty state
    const empty = document.getElementById('empty-state');
    if (empty) empty.remove();

    this.addUserMsg(userMsg);

    // Build system
    const sysPrompt = this.buildSystem();
    const userContent = this.buildUserContent(userMsg);

    this.conversation.push({ role: 'user', content: userContent });

    await this.runAgentLoop(key, sysPrompt);

    this.running = false;
    document.getElementById('send-btn').disabled = false;
    pill('pill-agent', false, true);
  },

  buildSystem() {
    const treeText = this.getTreeText();
    const lazyNote = document.getElementById('opt-lazy').checked
      ? 'ディレクトリに「▸」が表示されている場合はまだ展開されていません。EXPAND: ディレクトリパス/ でその中身を取得できます。'
      : '';

    return `あなたは高度な自律コーディングアシスタント「LocalNavigator」です。
ユーザーのローカルファイルシステムに安全なブリッジを通じてアクセスできます。

## あなたが使えるコマンド
以下のコマンドを出力すると、ツールが自動的に実行され結果が返ってきます。
複数のコマンドを一度に出力することができます(並列実行されます)。

- READ: <ファイルパス>        — ファイルの内容を読み込む
- SEARCH: <キーワード>        — ファイル名・パスをキーワードで検索する
- EXPAND: <ディレクトリパス/> — ディレクトリの中身を展開して確認する

## 現在のファイル構造
フォルダ名: ${this.dirHandle ? this.dirHandle.name : '(未選択)'}

\`\`\`
${treeText}
\`\`\`

${lazyNote}

## 指示
1. ユーザーの要望を理解し、必要なファイルを特定してください。
2. 情報が不足している場合は、コマンドを出力して収集してください。
3. すべての情報が揃ったら、詳細で正確な回答を提供してください。
4. 最終回答では、コマンドを出力しないでください。
5. 日本語で回答してください。`;
  },

  buildUserContent(msg) {
    return msg;
  },

  // ── AGENT LOOP ────────────────────────────────────────
  async runAgentLoop(key, sysPrompt) {
    let iter = 0;

    while (iter < MAX_ITER) {
      iter++;
      const thinkEl = this.addThinking(iter);

      let assistantText = '';
      try {
        assistantText = await this.callAPI(key, sysPrompt, this.conversation);
      } catch(e) {
        thinkEl.remove();
        this.addSystemMsg('APIエラー: ' + e.message);
        break;
      }

      thinkEl.remove();
      this.conversation.push({ role: 'assistant', content: assistantText });

      // parse commands
      const commands = this.parseCommands(assistantText);

      // display assistant message
      this.addAssistantMsg(assistantText, commands, iter);

      if (commands.length === 0) {
        // 最終回答
        break;
      }

      // execute commands
      this.addLoopDivider(iter);
      const toolResults = await this.executeCommands(commands);

      // feed results back
      const resultText = toolResults.map(r => r.text).join('\n\n');
      this.conversation.push({ role: 'user', content: '[TOOL RESULTS]\n\n' + resultText + '\n\n[指示] 上記の情報を元に回答するか、さらに必要な情報があればコマンドを出力してください。' });

      // context compression
      if (document.getElementById('opt-summary').checked && this.conversation.length > 12) {
        await this.compressContext(key, sysPrompt);
      }

      scrollDown();
    }

    if (iter >= MAX_ITER) {
      this.addSystemMsg(`最大反復回数(${MAX_ITER})に達しました。`);
    }
  },

  parseCommands(text) {
    const cmds = [];
    const readRe = /^READ:\s*(.+)$/gm;
    const searchRe = /^SEARCH:\s*(.+)$/gm;
    const expandRe = /^EXPAND:\s*(.+)$/gm;

    let m;
    while ((m = readRe.exec(text)) !== null) cmds.push({ type: 'READ', arg: m[1].trim().replace(/[`'"]/g,'') });
    while ((m = searchRe.exec(text)) !== null) cmds.push({ type: 'SEARCH', arg: m[1].trim().replace(/[`'"]/g,'') });
    while ((m = expandRe.exec(text)) !== null) cmds.push({ type: 'EXPAND', arg: m[1].trim().replace(/[`'"]/g,'') });
    return cmds;
  },

  async executeCommands(commands) {
    const results = await Promise.all(commands.map(cmd => this.executeOne(cmd)));
    return results;
  },

  async executeOne(cmd) {
    if (cmd.type === 'READ') {
      const content = await this.readFile(cmd.arg);
      if (content === null) {
        // try fuzzy
        const fuzzy = Object.keys(this.fileMap).find(p => p.includes(cmd.arg) || cmd.arg.includes(p.split('/').pop()));
        if (fuzzy) {
          const c2 = await this.readFile(fuzzy);
          return { cmd, text: `=== FILE: ${fuzzy} ===\n${c2}\n=== END ===` };
        }
        return { cmd, text: `[エラー] ファイルが見つかりません: ${cmd.arg}` };
      }
      return { cmd, text: `=== FILE: ${cmd.arg} ===\n${content}\n=== END ===` };
    }

    if (cmd.type === 'SEARCH') {
      const q = cmd.arg.toLowerCase();
      const hits = Object.keys(this.fileMap).filter(p => p.toLowerCase().includes(q)).slice(0, 10);
      if (hits.length === 0) return { cmd, text: `[SEARCH: ${cmd.arg}] 該当ファイルなし` };
      return { cmd, text: `[SEARCH: ${cmd.arg}] 結果:\n` + hits.map(h => '- ' + h).join('\n') };
    }

    if (cmd.type === 'EXPAND') {
      const dirPath = cmd.arg.endsWith('/') ? cmd.arg : cmd.arg + '/';
      const before = Object.keys(this.fileMap).length;
      await this.expandDir(dirPath);
      const after = Object.keys(this.fileMap).length;
      const newFiles = Object.keys(this.fileMap).filter(p => p.startsWith(dirPath));
      return {
        cmd,
        text: `[EXPAND: ${dirPath}] ${after - before}個の新規ファイルが見つかりました:\n` + newFiles.map(f => '- ' + f).join('\n')
      };
    }

    return { cmd, text: '[不明なコマンド]' };
  },

  // ── CONTEXT COMPRESSION ───────────────────────────────
  async compressContext(key, sysPrompt) {
    try {
      const summaryPrompt = [
        ...this.conversation.slice(0, -4),
        { role: 'user', content: '上記の会話をコンパクトに要約してください。重要な発見(ファイル内容・コード構造など)は保持してください。' }
      ];
      const summary = await this.callAPI(key, 'あなたは会話要約アシスタントです。', summaryPrompt, 600);
      const preserved = this.conversation.slice(-4);
      this.conversation = [
        { role: 'user', content: '[以前の調査の要約]\n' + summary },
        { role: 'assistant', content: '了解しました。要約を踏まえて続けます。' },
        ...preserved
      ];
      this.sysLog('コンテキストを圧縮しました');
    } catch(e) {
      // silent fail
    }
  },

  // ── API ───────────────────────────────────────────────
  async callAPI(key, system, messages, maxTokens = 4096) {
    const res = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': key,
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true'
      },
      body: JSON.stringify({
        model: 'claude-opus-4-6',
        max_tokens: maxTokens,
        system,
        messages
      })
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(err.error?.message || `HTTP ${res.status}`);
    }

    const data = await res.json();
    return data.content.map(b => b.text || '').join('');
  },

  // ── UI HELPERS ────────────────────────────────────────
  addUserMsg(text) {
    const chat = document.getElementById('chat-area');
    const g = document.createElement('div');
    g.className = 'msg-group';
    g.innerHTML = `<div class="msg-role user-role">YOU</div><div class="msg-content" style="color:var(--accent2);">${esc(text)}</div>`;
    chat.appendChild(g);
    scrollDown();
  },

  addAssistantMsg(text, commands, iter) {
    const chat = document.getElementById('chat-area');
    const g = document.createElement('div');
    g.className = 'msg-group';

    // strip command lines for display
    const displayText = text.replace(/^(READ|SEARCH|EXPAND):.+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();

    let html = `<div class="msg-role agent-role">NAVIGATOR <span class="iter-badge">loop ${iter}</span></div>`;

    if (displayText) {
      html += `<div class="msg-content">${formatContent(displayText)}</div>`;
    }

    // tool call blocks
    for (const cmd of commands) {
      const cls = cmd.type === 'READ' ? 'read-h' : cmd.type === 'SEARCH' ? 'search-h' : 'expand-h';
      const id = 'tool-' + Math.random().toString(36).slice(2);
      html += `
      <div class="tool-block">
        <div class="tool-header ${cls}" onclick="toggleTool('${id}')">
          <span class="tool-status"><div class="spin"></div> ${cmd.type}</span>
          <code style="font-size:11px; flex:1; margin-left:10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${esc(cmd.arg)}</code>
          <span class="tool-toggle" id="toggle-${id}">▾</span>
        </div>
        <div class="tool-body" id="${id}">実行中...</div>
      </div>`;
    }

    g.innerHTML = html;
    g.dataset.cmdCount = commands.length;
    chat.appendChild(g);
    scrollDown();
    return g;
  },

  updateToolBlock(msgGroup, cmdIndex, content, success) {
    const blocks = msgGroup.querySelectorAll('.tool-block');
    if (!blocks[cmdIndex]) return;
    const header = blocks[cmdIndex].querySelector('.tool-header');
    const body = blocks[cmdIndex].querySelector('.tool-body');
    const spin = header.querySelector('.spin');

    if (spin) {
      const dot = document.createElement('div');
      dot.style.cssText = `width:8px;height:8px;border-radius:50%;background:${success ? 'var(--green)' : 'var(--red)'};flex-shrink:0;`;
      spin.replaceWith(dot);
    }
    if (body) {
      body.textContent = content;
      body.classList.add('open');
    }
  },

  addThinking(iter) {
    const chat = document.getElementById('chat-area');
    const div = document.createElement('div');
    div.className = 'thinking-block';
    div.innerHTML = `<span style="color:var(--text3); font-size:10px; letter-spacing:1px;">LOOP ${iter}</span>
      <div class="thinking-dots"><span></span><span></span><span></span></div>
      <span style="font-size:10px; color:var(--text3);">思考中...</span>`;
    chat.appendChild(div);
    scrollDown();
    return div;
  },

  addLoopDivider(iter) {
    const chat = document.getElementById('chat-area');
    const div = document.createElement('div');
    div.className = 'loop-divider';
    div.textContent = `TOOL EXECUTION — LOOP ${iter}`;
    chat.appendChild(div);
    scrollDown();
  },

  addSystemMsg(text) {
    const chat = document.getElementById('chat-area');
    const div = document.createElement('div');
    div.className = 'sys-line';
    div.style.cssText = 'padding:6px 0; color:var(--amber);';
    div.textContent = '⚠ ' + text;
    chat.appendChild(div);
    scrollDown();
  },

  sysLog(text) {
    console.log('[NAV]', text);
    // also show in top bar briefly
    const pill = document.getElementById('pill-fs');
    if (pill) pill.title = text;
  }
};

// ── EXECUTE COMMANDS with UI update ──────────────────────
const origRunAgentLoop = nav.runAgentLoop.bind(nav);
nav.runAgentLoop = async function(key, sysPrompt) {
  let iter = 0;

  while (iter < MAX_ITER) {
    iter++;
    const thinkEl = this.addThinking(iter);

    let assistantText = '';
    try {
      assistantText = await this.callAPI(key, sysPrompt, this.conversation);
    } catch(e) {
      thinkEl.remove();
      this.addSystemMsg('APIエラー: ' + e.message);
      break;
    }

    thinkEl.remove();
    this.conversation.push({ role: 'assistant', content: assistantText });

    const commands = this.parseCommands(assistantText);
    const msgGroup = this.addAssistantMsg(assistantText, commands, iter);

    if (commands.length === 0) break;

    this.addLoopDivider(iter);

    // Execute with UI updates
    const results = await Promise.all(commands.map(async (cmd, idx) => {
      const result = await this.executeOne(cmd);
      // update tool block
      this.updateToolBlock(msgGroup, idx, result.text, !result.text.startsWith('[エラー]'));
      return result;
    }));

    const resultText = results.map(r => r.text).join('\n\n');
    this.conversation.push({ role: 'user', content: '[TOOL RESULTS]\n\n' + resultText + '\n\n[指示] 上記の情報を元に回答するか、さらに必要な情報があればコマンドを出力してください。' });

    if (document.getElementById('opt-summary').checked && this.conversation.length > 12) {
      await this.compressContext(key, sysPrompt);
    }

    scrollDown();
  }

  if (iter >= MAX_ITER) this.addSystemMsg(`最大反復回数(${MAX_ITER})に達しました。`);
};

// ── HELPERS ──────────────────────────────────────────────
function pill(id, active, hide = false) {
  const el = document.getElementById(id);
  if (!el) return;
  if (hide) {
    el.style.display = active ? 'flex' : 'none';
    return;
  }
  el.className = 'pill' + (active ? ' active' : '');
}

function scrollDown() {
  const el = document.getElementById('chat-area');
  el.scrollTop = el.scrollHeight;
}

function esc(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function formatContent(text) {
  // basic markdown-ish: code blocks, inline code, bold
  return esc(text)
    .replace(/```([\s\S]*?)```/g, '<pre style="background:var(--surface2);border:1px solid var(--border2);padding:10px;border-radius:4px;overflow-x:auto;margin:8px 0;font-size:11px;color:var(--accent2);">$1</pre>')
    .replace(/`([^`]+)`/g, '<code style="background:var(--surface2);padding:1px 5px;border-radius:3px;color:var(--accent2);">$1</code>')
    .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
    .replace(/\n/g, '<br>');
}

function toggleTool(id) {
  const body = document.getElementById(id);
  const toggle = document.getElementById('toggle-' + id);
  if (body) body.classList.toggle('open');
  if (toggle) toggle.textContent = body.classList.contains('open') ? '▴' : '▾';
}

window.nav = nav;
window.toggleTool = toggleTool;
</script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
ローカルファイルをAIに分析させる「Local Navigator」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

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