Browse Source

feat(knowledge): 知识库管理完整功能 — 索引构建/Wiki编译/搜索QA/分块浏览

## 后端 (agentpaas/api/v1/knowledge.py)
- 新增完整知识库 CRUD + 文件管理 + 目录忽略配置
- BM25/Wiki 索引构建任务,Popen 流式日志实时写入 DB
- _find_scripts_dir() 自动定位脚本目录,_write_instance_yml() 覆盖硬编码路径
- _resolve_llm() 支持所有 providers(Ollama/DashScope/DeepSeek/OpenAI/Anthropic)
- asyncio.Queue + run_in_executor 无阻塞 LLM 流式推送
- GET /{kb_id}/search/stream 接受 provider_id + model 参数,emit chunks + 流式 token
- GET /{kb_id}/chunks 分块浏览,支持关键词/来源过滤 + 分页
- mtime 缓存 rag_index.json (17.9 MB)

## 前端 (webui/src/pages/KnowledgeDetail.tsx)
- 文件管理:目录树 + 忽略配置
- 索引构建:IndexStats 卡片 + 实时日志终端 + 自动轮询
- Wiki 编译:实时进度日志 + 全量/增量模式
- ChunkBrowser:关键词/来源过滤 + 翻页 + 展开卡片
- SearchTest:fetch+ReadableStream 替代 EventSource(支持 Authorization header)
  - ProviderModelSelect 下拉选择已配置的 LLM 提供商 + 模型
  - A/B 对比模式:两列独立提供商/模型/检索模式
  - QAPanel 流式答案 + 可折叠来源 chunk

## wiki_compile.py 修复
- 以 wiki/sources/*.md 实际文件为 done 基准,避免 progress 文件污染
- error 不再加入 done 列表(失败可重试)
- WIKI_REBUILD=1 清空旧进度重建

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kenny67nju 3 months ago
parent
commit
e6f7c7847d

+ 64 - 24
agentexample/qaagent67lambda/scripts/wiki_compile.py

@@ -26,37 +26,64 @@ PROCESSED_DIR = cfg.processed_dir
 WIKI_DIR = cfg.wiki_dir
 PROGRESS_FILE = WIKI_DIR / '.compile_progress.json'
 
-# vLLM API
-VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"
-# Fallback to Ollama if vLLM not available
-OLLAMA_URL = "http://127.0.0.1:8000/v1/chat/completions"
+# LLM API endpoints — read from env vars so the job runner can override them.
+# Priority: VLLM_URL env (set by knowledge.py job runner) → Ollama default → vLLM fallback
+# NOTE: never default to port 8000 — that's the AgentPaaS FastAPI server,
+#       which returns index.html (SPA catch-all) with HTTP 200, fooling detect_api().
+VLLM_URL  = os.environ.get('VLLM_URL',  'http://127.0.0.1:8001/v1/chat/completions')
+OLLAMA_URL = os.environ.get('OLLAMA_URL', 'http://127.0.0.1:11434/v1/chat/completions')
 
 API_URL = None  # auto-detect
 
+def _is_llm_endpoint(url: str, timeout: int = 10) -> bool:
+    """Return True only if `url` responds like an OpenAI-compatible LLM.
+    We send a minimal chat request and verify the response contains 'choices'
+    or a known Ollama/vLLM error (model not found etc.) — NOT HTML.
+    """
+    try:
+        body = json.dumps({
+            "model": os.environ.get('LLM_MODEL', 'qwen2.5:7b'),
+            "messages": [{"role": "user", "content": "hi"}],
+            "max_tokens": 3,
+        }).encode('utf-8')
+        req = urllib.request.Request(
+            url, data=body,
+            headers={"Content-Type": "application/json"}, method="POST"
+        )
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read()
+            # Must be valid JSON and look like an LLM response (not HTML)
+            if not raw or raw[:1] in (b'<', b'!'):
+                return False
+            data = json.loads(raw)
+            # OpenAI format: {"choices": [...]} or Ollama error: {"error": "..."}
+            return "choices" in data or "error" in data or "message" in data
+    except urllib.error.HTTPError as e:
+        # 4xx from a real LLM (model not loaded, bad request) — server IS there
+        if e.code in (400, 404, 422, 503):
+            return True
+        return False
+    except Exception:
+        return False
+
+
 def detect_api():
     global API_URL
-    for url in [VLLM_URL, OLLAMA_URL]:
-        try:
-            test_body = json.dumps({
-                "model": "qwen2.5-32b" if "8000" in url else "qwen2.5-32b",
-                "messages": [{"role": "user", "content": "hi"}],
-                "max_tokens": 5,
-            }).encode('utf-8')
-            req = urllib.request.Request(url, data=test_body,
-                                        headers={"Content-Type": "application/json"}, method="POST")
-            with urllib.request.urlopen(req, timeout=30) as resp:
-                API_URL = url
-                engine = "vLLM NPU" if "8000" in url else "Ollama CPU"
-                print(f"Using: {engine} ({url})")
-                return
-        except:
-            continue
+    candidates = list(dict.fromkeys([VLLM_URL, OLLAMA_URL]))  # dedup, preserve order
+    for url in candidates:
+        if _is_llm_endpoint(url):
+            API_URL = url
+            label = "Ollama" if "11434" in url or "11435" in url else "vLLM/API"
+            print(f"Using: {label} ({url})")
+            return
     print("ERROR: No LLM API available!")
+    print(f"  Tried: {candidates}")
+    print("  → Please start Ollama: brew install ollama && ollama serve && ollama pull qwen2.5:7b")
     sys.exit(1)
 
 
 def llm_chat(prompt, max_tokens=2048, temperature=0.2):
-    model = "qwen2.5-32b" if "8000" in API_URL else "qwen2.5-32b"
+    model = os.environ.get('LLM_MODEL', 'qwen2.5:7b')
     body = json.dumps({
         "model": model,
         "messages": [{"role": "user", "content": prompt}],
@@ -443,9 +470,22 @@ def main():
     for d in ['sources', 'entities', 'topics', 'analyses']:
         (WIKI_DIR / d).mkdir(parents=True, exist_ok=True)
 
+    # WIKI_REBUILD=1 → clear progress file and all generated pages for a clean rebuild
+    if os.environ.get('WIKI_REBUILD') == '1':
+        if PROGRESS_FILE.exists():
+            PROGRESS_FILE.unlink()
+            print("[Rebuild] 已清除进度文件")
+        for d in ['sources', 'entities', 'topics']:
+            for f in (WIKI_DIR / d).glob('*.md'):
+                f.unlink()
+        print("[Rebuild] 已清除旧 wiki 页面,开始全量重建")
+
     # Load progress
     progress = load_progress()
-    done_set = set(progress['done'])
+    # Separate truly-done from errored: only skip docs with generated source pages
+    compiled_pages = {f.stem for f in (WIKI_DIR / 'sources').glob('*.md')}
+    done_set = compiled_pages  # use actual output files as ground truth, not the progress file
+    progress['done'] = [d for d in progress['done'] if d in compiled_pages]
 
     # Get all text files
     all_docs = sorted(PROCESSED_DIR.glob('*.txt'))
@@ -499,8 +539,8 @@ def main():
         except Exception as e:
             errors += 1
             print(f"  ERROR: {e}")
-            # Still mark as done to avoid infinite retry
-            progress['done'].append(doc_name)
+            # Do NOT add to done — failed docs will be retried on next incremental run
+            # (only successfully compiled docs with a source page count as done)
 
         # Save progress and update index every 20 docs
         if (idx + 1) % 20 == 0 or idx == len(remaining) - 1:

+ 2 - 1
agentpaas/api/app.py

@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, feishu, analyze, setup as setup_router, providers_api, templates_api
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, feishu, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router
 from agentpaas.observability.logging import logger
 
 app = FastAPI(
@@ -125,6 +125,7 @@ app.include_router(analyze.router, prefix="/api/v1")
 app.include_router(setup_router.router, prefix="/api/v1")
 app.include_router(providers_api.router, prefix="/api/v1")
 app.include_router(templates_api.router, prefix="/api/v1")
+app.include_router(knowledge_router.router, prefix="/api/v1")
 
 @app.get("/health")
 async def health():

+ 1334 - 0
agentpaas/api/v1/knowledge.py

@@ -0,0 +1,1334 @@
+"""
+api.v1.knowledge — Knowledge Base management endpoints.
+
+GET    /api/v1/knowledge                          List all KBs
+POST   /api/v1/knowledge                          Create KB (name, root_dir)
+DELETE /api/v1/knowledge/{kb_id}                  Delete KB
+GET    /api/v1/knowledge/{kb_id}/files            List root_dir file tree
+POST   /api/v1/knowledge/{kb_id}/files            Add files to KB
+DELETE /api/v1/knowledge/{kb_id}/files/{file_id}  Remove file from KB
+POST   /api/v1/knowledge/{kb_id}/index            Trigger index build job
+POST   /api/v1/knowledge/{kb_id}/wiki             Trigger wiki compile job
+PUT    /api/v1/knowledge/{kb_id}/wiki/pause       Pause/resume wiki compile
+GET    /api/v1/knowledge/{kb_id}/wiki/tree        Get wiki file tree
+GET    /api/v1/knowledge/{kb_id}/wiki/{page_path} Get wiki page content
+POST   /api/v1/knowledge/{kb_id}/search           Search KB
+GET    /api/v1/knowledge/{kb_id}/search/stream    SSE streaming QA
+GET    /api/v1/knowledge/{kb_id}/jobs             List jobs for KB
+GET    /api/v1/knowledge/{kb_id}/jobs/{job_id}    Get job status + log
+"""
+from __future__ import annotations
+import asyncio
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import time
+import threading
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+
+from agentpaas.api.deps import get_tenant, get_database
+from agentpaas.api.errors import api_error
+from agentpaas.api.middleware.auth import TenantContext
+from agentpaas.db.models import Database, gen_id, now_utc
+
+router = APIRouter(prefix="/knowledge", tags=["knowledge"])
+
+# ─── Supported file extensions ───
+SUPPORTED_EXTS = {".pdf", ".txt", ".docx", ".md", ".html", ".htm", ".csv"}
+
+
+# ─── Pydantic models ───
+
+class CreateKBRequest(BaseModel):
+    name: str = Field(..., max_length=256)
+    root_dir: str = Field(..., description="Absolute path to knowledge base directory")
+    description: str = Field(default="", max_length=2048)
+
+
+class AddFilesRequest(BaseModel):
+    file_paths: List[str] = Field(..., description="Absolute paths of files to add")
+
+
+class IndexBuildRequest(BaseModel):
+    index_type: str = Field(default="bm25", description="bm25 | vector | graph | all")
+    mode: str = Field(default="full", description="full | incremental")
+
+
+class WikiBuildRequest(BaseModel):
+    mode: str = Field(default="incremental", description="incremental | rebuild")
+    concurrency: int = Field(default=2, ge=1, le=8)
+
+
+class SearchRequest(BaseModel):
+    query: str = Field(..., max_length=4096)
+    mode: str = Field(default="lambda", description="lambda | bm25 | vector | wiki")
+    top_k: int = Field(default=5, ge=1, le=20)
+    weights: Optional[Dict[str, float]] = None  # bm25/vector/graph/wiki overrides
+
+
+class UpdateIgnoreDirsRequest(BaseModel):
+    ignored_dirs: List[str] = Field(default_factory=list, description="List of relative dir paths to ignore")
+
+
+# ─── Helper: walk root_dir ───
+
+def _walk_dir(root_dir: str, added_paths: set, ignored_dirs: Optional[Set[str]] = None) -> List[Dict]:
+    """
+    Recursively list files under root_dir, skipping ignored directories.
+    ignored_dirs: set of relative paths (e.g. {'processed', 'raw', 'wiki'}).
+      Both bare names ('raw') and nested paths ('data/raw') are matched.
+    Returns up to 2000 file entries.
+    """
+    ignored = ignored_dirs or set()
+    items: List[Dict] = []
+    root = Path(root_dir)
+    if not root.is_dir():
+        return items
+
+    for dirpath, dirnames, filenames in os.walk(root_dir, topdown=True):
+        rel_dir = Path(dirpath).relative_to(root)
+        parts = rel_dir.parts  # () for root itself
+
+        # If any ancestor or the current dir matches the ignore list, skip entire subtree
+        if parts:
+            ignored_hit = any(
+                parts[i] in ignored or "/".join(parts[:i + 1]) in ignored
+                for i in range(len(parts))
+            )
+            if ignored_hit:
+                dirnames.clear()   # prune: don't recurse further
+                continue
+
+        # Prune child dirs that match ignore list before recursing
+        dirnames[:] = sorted(
+            d for d in dirnames
+            if d not in ignored and (
+                "/".join((*parts, d)) if parts else d
+            ) not in ignored
+        )
+
+        for fname in sorted(filenames):
+            p = Path(dirpath) / fname
+            if p.suffix.lower() not in SUPPORTED_EXTS:
+                continue
+            rel_file = str(p.relative_to(root))
+            try:
+                stat = p.stat()
+            except OSError:
+                continue
+            items.append({
+                "path": str(p),
+                "relative_path": rel_file,
+                "name": fname,
+                "type": p.suffix.lower().lstrip("."),
+                "size": stat.st_size,
+                "modified": stat.st_mtime,
+                "added": str(p) in added_paths,
+            })
+            if len(items) >= 2000:
+                return items
+
+    return items
+
+
+def _list_dir_tree(root_dir: str, ignored: Set[str], max_depth: int = 8) -> List[Dict]:
+    """
+    Recursively list all subdirectories as a nested tree.
+    Each node: {name, relative_path, ignored, children}.
+    `ignored` is True only if THIS node's own path is in the ignore set —
+    the frontend handles ancestor propagation for inherited-ignore display.
+    """
+    root = Path(root_dir)
+
+    def _scan(path: Path, rel: str, depth: int) -> List[Dict]:
+        if depth > max_depth:
+            return []
+        result = []
+        try:
+            for child in sorted(path.iterdir()):
+                if not child.is_dir():
+                    continue
+                child_rel = f"{rel}/{child.name}" if rel else child.name
+                self_ignored = child.name in ignored or child_rel in ignored
+                children = _scan(child, child_rel, depth + 1)
+                result.append({
+                    "name": child.name,
+                    "relative_path": child_rel,
+                    "ignored": self_ignored,
+                    "children": children,
+                })
+        except OSError:
+            pass
+        return result
+
+    return _scan(root, "", 0)
+
+
+# ─── Helper: run index build in background ───
+
+def _write_instance_yml(kb_root: str) -> str:
+    """Write a temporary instance.yml that pins all config paths to kb_root.
+
+    config.py loads agent-config.yml first (which may contain hardcoded remote
+    paths like /home/67/knowledge/...) and then overlays instance.yml via deep
+    merge.  By setting INSTANCE_CONFIG to this temp file we override those paths
+    with the actual local kb_root before any script reads cfg.base_dir.
+
+    Returns the temp file path; caller is responsible for os.unlink() cleanup.
+    """
+    kb = Path(kb_root)
+    # Single-quote YAML scalars — safe for paths without single quotes
+    lines = [
+        "knowledge:",
+        f"  baseDir: '{kb_root}'",
+        f"  rawDir: '{kb_root}'",
+        f"  processedDir: '{kb / 'processed'}'",
+        f"  indexFile: '{kb / 'rag_index.json'}'",
+        f"  keywordFile: '{kb / 'rag_index.keywords.json'}'",
+        f"  vectorIndex: '{kb / 'rag_vectors_v2.npy'}'",
+        f"  vectorMeta: '{kb / 'rag_vectors_meta_v2.pkl'}'",
+        "wiki:",
+        f"  dir: '{kb / 'wiki'}'",
+    ]
+    with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
+        f.write("\n".join(lines) + "\n")
+        return f.name
+
+
+def _make_script_env(kb_root: str, scripts_dir: Path, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
+    """Build subprocess env: inherit os.environ + KNOWLEDGE_DIR + PYTHONPATH.
+
+    Does NOT write the instance.yml here (caller manages temp file lifecycle).
+    Pass INSTANCE_CONFIG via `extra` if needed.
+    """
+    env = dict(os.environ)
+    env["KNOWLEDGE_DIR"] = kb_root
+    # Ensure scripts_dir is in PYTHONPATH so `from config import cfg` works
+    existing_py = env.get("PYTHONPATH", "")
+    env["PYTHONPATH"] = str(scripts_dir) + (os.pathsep + existing_py if existing_py else "")
+    if extra:
+        env.update(extra)
+    return env
+
+
+def _popen_stream(
+    script: Path,
+    env: Dict[str, str],
+    cwd: str,
+    log_buf: List[str],
+    db: Database,
+    job_id: str,
+    flush_every: int = 8,
+    timeout: int = 3600,
+) -> int:
+    """Run script with Popen, stream stdout to log_buf, flush to DB every N lines.
+    Returns process returncode.  stderr is appended to log_buf on exit.
+    """
+    proc = subprocess.Popen(
+        [sys.executable, str(script)],
+        cwd=cwd, env=env,
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+        text=True, bufsize=1,
+    )
+    flush_count = 0
+    try:
+        for line in proc.stdout:  # type: ignore[union-attr]
+            log_buf.append(line.rstrip("\n"))
+            flush_count += 1
+            if flush_count % flush_every == 0:
+                try:
+                    db.execute(
+                        "UPDATE kb_jobs SET log=? WHERE id=?",
+                        ("\n".join(log_buf)[-8000:], job_id),
+                    )
+                    db.commit()
+                except Exception:
+                    pass
+        proc.wait(timeout=timeout)
+    except subprocess.TimeoutExpired:
+        proc.kill()
+        log_buf.append("[超时] 进程已终止")
+    stderr_out = (proc.stderr.read() or "").strip()  # type: ignore[union-attr]
+    if stderr_out:
+        log_buf.append("--- stderr ---")
+        log_buf.extend(stderr_out[-2000:].splitlines())
+    return proc.returncode
+
+
+def _run_index_job(db: Database, kb_id: str, job_id: str, kb_root: str, index_type: str, mode: str):
+    """Background thread — extract text then build index, streaming log to DB."""
+
+    def _flush(status: str, log_buf: List[str]):
+        try:
+            db.execute(
+                "UPDATE kb_jobs SET status=?, log=? WHERE id=?",
+                (status, "\n".join(log_buf)[-8000:], job_id),
+            )
+            db.commit()
+        except Exception:
+            pass
+
+    kb_path = Path(kb_root)
+    scripts_dir = _find_scripts_dir(kb_path)
+    instance_yml = _write_instance_yml(kb_root) if scripts_dir else None
+    log_buf: List[str] = []
+    errors: List[str] = []
+
+    _flush("running", log_buf)
+    try:
+        if index_type in ("bm25", "all"):
+            if not scripts_dir:
+                log_buf.append("[BM25] 脚本目录未找到 — 已跳过")
+            else:
+                env = _make_script_env(kb_root, scripts_dir, {"INSTANCE_CONFIG": instance_yml})
+                processed_dir = kb_path / "processed"
+
+                # ── Step 1: 文本提取 ──
+                extract_script = scripts_dir / "extract_docs.py"
+                if extract_script.exists():
+                    existing_txts = list(processed_dir.glob("*.txt")) if processed_dir.is_dir() else []
+                    if not existing_txts or mode == "full":
+                        log_buf.append("[提取] 正在提取原始文档为文本...")
+                        _flush("running", log_buf)
+                        rc = _popen_stream(extract_script, env, kb_root, log_buf, db, job_id, flush_every=10)
+                        if rc != 0:
+                            errors.append("文本提取失败(returncode={rc})")
+                    else:
+                        log_buf.append(f"[提取] 已有 {len(existing_txts)} 个文本文件,跳过(全量模式可强制重新提取)")
+                else:
+                    log_buf.append("[提取] extract_docs.py 未找到,跳过(需手动准备 processed/ 目录)")
+
+                # ── Step 2: 构建 BM25 索引 ──
+                bm25_script = next(
+                    (scripts_dir / n for n in ("rebuild_index.py", "build_index.py") if (scripts_dir / n).exists()),
+                    None,
+                )
+                if bm25_script:
+                    log_buf.append(f"[BM25] 正在构建索引({bm25_script.name})...")
+                    _flush("running", log_buf)
+                    rc = _popen_stream(bm25_script, env, kb_root, log_buf, db, job_id, flush_every=5)
+                    if rc != 0:
+                        errors.append(f"BM25 构建失败(returncode={rc})")
+                else:
+                    log_buf.append("[BM25] rebuild_index.py / build_index.py 均未找到 — 已跳过")
+
+        if index_type in ("vector", "all"):
+            if not scripts_dir:
+                log_buf.append("[Vector] 脚本目录未找到 — 已跳过")
+            else:
+                env = _make_script_env(kb_root, scripts_dir, {"INSTANCE_CONFIG": instance_yml})
+                vec_script = scripts_dir / "build_vector_index.py"
+                if vec_script.exists():
+                    log_buf.append("[Vector] 正在构建向量索引...")
+                    _flush("running", log_buf)
+                    rc = _popen_stream(vec_script, env, kb_root, log_buf, db, job_id, flush_every=5, timeout=3600)
+                    if rc != 0:
+                        errors.append(f"向量索引失败(returncode={rc})")
+                else:
+                    log_buf.append("[Vector] build_vector_index.py 未找到 — 已跳过")
+
+        if index_type in ("graph", "all"):
+            log_buf.append("[Graph] 图谱由 wiki_compile.py 生成 wiki/relations.json — 请先运行 Wiki 编译")
+
+        if errors:
+            log_buf.append("\n❌ ERRORS:\n" + "\n".join(errors))
+        _flush("failed" if errors else "completed", log_buf)
+        db.execute("UPDATE kb_jobs SET completed_at=? WHERE id=?", (now_utc(), job_id))
+        db.commit()
+
+    except Exception as exc:
+        log_buf.append(f"\n❌ 异常: {exc}")
+        _flush("failed", log_buf)
+        db.execute(
+            "UPDATE kb_jobs SET error=?, completed_at=? WHERE id=?",
+            (str(exc)[:500], now_utc(), job_id),
+        )
+        db.commit()
+    finally:
+        if instance_yml:
+            try:
+                os.unlink(instance_yml)
+            except Exception:
+                pass
+
+
+def _detect_local_llm_url() -> str:
+    """Probe common local LLM endpoints and return the first that responds.
+
+    Priority: VLLM_URL env var → Ollama :11434 → Ollama :11435 → vLLM :8001
+    Falls back to the env default so we never block on a missing service here.
+    """
+    import urllib.request as _ur
+    # If explicitly set by operator/user, trust it
+    if os.environ.get("VLLM_URL"):
+        return os.environ["VLLM_URL"]
+
+    candidates = [
+        "http://127.0.0.1:11434/v1/chat/completions",  # Ollama default
+        "http://127.0.0.1:11435/v1/chat/completions",  # Ollama alt port
+        "http://127.0.0.1:8001/v1/chat/completions",   # vLLM on non-conflicting port
+    ]
+    for url in candidates:
+        try:
+            req = _ur.Request(url, data=b'{}', headers={"Content-Type": "application/json"}, method="POST")
+            _ur.urlopen(req, timeout=2)
+        except Exception as exc:
+            # Connection refused / timeout → not available; 4xx means server IS there
+            msg = str(exc)
+            if "400" in msg or "422" in msg or "405" in msg or "404" in msg:
+                return url
+        else:
+            return url  # 2xx on empty payload — unlikely but accept
+    # Nothing found; return Ollama default and let wiki_compile.py fail with a clear message
+    return "http://127.0.0.1:11434/v1/chat/completions"
+
+
+def _run_wiki_job(db: Database, kb_id: str, job_id: str, kb_root: str, mode: str, concurrency: int):
+    """Background thread: run wiki_compile.py with live log streaming to DB."""
+
+    def _flush(status: str, log_buf: List[str]):
+        try:
+            db.execute(
+                "UPDATE kb_jobs SET status=?, log=? WHERE id=?",
+                (status, "\n".join(log_buf)[-8000:], job_id),
+            )
+            db.commit()
+        except Exception:
+            pass
+
+    kb_path = Path(kb_root)
+    scripts_dir = _find_scripts_dir(kb_path)
+    script = scripts_dir / "wiki_compile.py" if scripts_dir else None
+    instance_yml = _write_instance_yml(kb_root) if scripts_dir else None
+    log_buf: List[str] = []
+
+    _flush("running", log_buf)
+    try:
+        if not script or not script.exists():
+            db.execute(
+                "UPDATE kb_jobs SET status='failed', error=?, completed_at=? WHERE id=?",
+                ("wiki_compile.py 未在脚本目录中找到", now_utc(), job_id),
+            )
+            db.commit()
+            return
+
+        llm_url = _detect_local_llm_url()
+        log_buf.append(f"[Wiki] LLM 端点: {llm_url}")
+        _flush("running", log_buf)
+
+        env = _make_script_env(kb_root, scripts_dir, {
+            "INSTANCE_CONFIG": instance_yml,
+            "WIKI_DIR": str(kb_path / "wiki"),
+            "VLLM_URL": llm_url,
+        })
+        if mode == "rebuild":
+            env["WIKI_REBUILD"] = "1"
+        env["WIKI_CONCURRENCY"] = str(concurrency)
+
+        rc = _popen_stream(script, env, kb_root, log_buf, db, job_id, flush_every=3, timeout=7200)
+
+        _flush("failed" if rc != 0 else "completed", log_buf)
+        db.execute("UPDATE kb_jobs SET completed_at=? WHERE id=?", (now_utc(), job_id))
+        db.commit()
+
+    except Exception as exc:
+        log_buf.append(f"\n❌ 异常: {exc}")
+        _flush("failed", log_buf)
+        db.execute(
+            "UPDATE kb_jobs SET error=?, completed_at=? WHERE id=?",
+            (str(exc)[:500], now_utc(), job_id),
+        )
+        db.commit()
+    finally:
+        if instance_yml:
+            try:
+                os.unlink(instance_yml)
+            except Exception:
+                pass
+
+
+def _find_scripts_dir(kb_path: Path) -> Optional[Path]:
+    """Locate the scripts dir containing rebuild_index.py / wiki_compile.py etc.
+    Checks kb_path itself (if scripts live alongside data) and the canonical
+    project path agentexample/qaagent67lambda/scripts/.
+    Uses rebuild_index.py or wiki_compile.py as the presence marker
+    (build_index.py is the old hardcoded variant and no longer required).
+    """
+    _markers = ("rebuild_index.py", "wiki_compile.py", "build_index.py")
+
+    # Check if scripts live inside kb_path or its parent
+    for cand in [kb_path, kb_path.parent]:
+        if any((cand / m).exists() for m in _markers):
+            return cand
+
+    # Walk up from this source file looking for the canonical scripts dir
+    here = Path(__file__).resolve()
+    for _ in range(8):
+        here = here.parent
+        cand = here / "agentexample" / "qaagent67lambda" / "scripts"
+        if cand.is_dir() and any((cand / m).exists() for m in _markers):
+            return cand
+
+    return None
+
+
+def _get_index_status(kb_root: str) -> Dict[str, Any]:
+    """Read index file presence/stats from kb_root."""
+    root = Path(kb_root)
+    bm25_file = root / "rag_index.json"
+    vector_file = root / "rag_vectors_v2.npy"
+    wiki_dir = root / "wiki"
+
+    def _fstat(p: Path) -> Optional[Dict]:
+        if p.exists():
+            s = p.stat()
+            return {"exists": True, "size": s.st_size, "mtime": s.st_mtime}
+        return {"exists": False}
+
+    bm25 = _fstat(bm25_file)
+    if bm25["exists"]:
+        try:
+            import json as _json
+            with open(bm25_file) as f:
+                idx = _json.load(f)
+            bm25["total_chunks"] = idx.get("total_chunks", 0)
+            bm25["total_files"] = idx.get("total_files", 0)
+        except Exception:
+            pass
+
+    vector = _fstat(vector_file)
+    wiki = {"exists": wiki_dir.is_dir()}
+    if wiki["exists"]:
+        subdirs = {}
+        for sub in ["sources", "entities", "topics", "analyses"]:
+            d = wiki_dir / sub
+            subdirs[sub] = len(list(d.glob("*.md"))) if d.is_dir() else 0
+        wiki["subdirs"] = subdirs
+        wiki["total_pages"] = sum(subdirs.values())
+
+    return {"bm25": bm25, "vector": vector, "wiki": wiki}
+
+
+# ─── Chunk index cache ───
+# Avoid re-reading a 17 MB JSON on every request; invalidate when file mtime changes.
+_chunk_cache: Dict[str, Any] = {}   # kb_id → {"mtime": float, "chunks": list, "total": int}
+
+
+def _load_chunks(kb_root: str, kb_id: str) -> Optional[List[Dict]]:
+    """Load chunks from rag_index.json with mtime-based in-process cache."""
+    index_file = Path(kb_root) / "rag_index.json"
+    if not index_file.exists():
+        return None
+    mtime = index_file.stat().st_mtime
+    cached = _chunk_cache.get(kb_id)
+    if cached and cached["mtime"] == mtime:
+        return cached["chunks"]
+    try:
+        with open(index_file, encoding="utf-8") as f:
+            data = json.load(f)
+        chunks = data.get("chunks", [])
+        _chunk_cache[kb_id] = {"mtime": mtime, "chunks": chunks, "total": len(chunks)}
+        return chunks
+    except Exception:
+        return None
+
+
+# ─── CRUD Endpoints ───
+
+@router.get("")
+async def list_kbs(
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    rows = db.fetchall(
+        "SELECT * FROM knowledge_bases WHERE tenant_id=? ORDER BY created_at DESC",
+        (tenant.tenant_id,)
+    )
+    result = []
+    for r in rows:
+        status = _get_index_status(r["root_dir"])
+        result.append({**r, "index_status": status})
+    return {"items": result}
+
+
+@router.post("", status_code=201)
+async def create_kb(
+    req: CreateKBRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    root = Path(req.root_dir)
+    if not root.is_dir():
+        raise HTTPException(status_code=400, detail=f"Directory not found: {req.root_dir}")
+
+    kb_id = gen_id("kb_")
+    db.execute(
+        "INSERT INTO knowledge_bases (id, tenant_id, name, root_dir, description, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, ?, ?, ?)",
+        (kb_id, tenant.tenant_id, req.name, str(root.resolve()), req.description, now_utc(), now_utc())
+    )
+    db.commit()
+    return db.fetchone("SELECT * FROM knowledge_bases WHERE id=?", (kb_id,))
+
+
+@router.get("/{kb_id}")
+async def get_kb(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+    status = _get_index_status(kb["root_dir"])
+    return {**kb, "index_status": status}
+
+
+@router.delete("/{kb_id}", status_code=204)
+async def delete_kb(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+    db.execute("DELETE FROM kb_files WHERE kb_id=?", (kb_id,))
+    db.execute("DELETE FROM kb_jobs WHERE kb_id=?", (kb_id,))
+    db.execute("DELETE FROM knowledge_bases WHERE id=?", (kb_id,))
+    db.commit()
+    return None
+
+
+# ─── Chunk Browser ───
+
+@router.get("/{kb_id}/chunks")
+async def list_chunks(
+    kb_id: str,
+    q: str = Query(default="", description="Keyword filter (case-insensitive substring)"),
+    source: str = Query(default="", description="Filter by source file name"),
+    page: int = Query(default=1, ge=1),
+    limit: int = Query(default=20, ge=1, le=100),
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    chunks = _load_chunks(kb["root_dir"], kb_id)
+    if chunks is None:
+        raise HTTPException(status_code=404, detail="BM25 index not built yet (rag_index.json not found)")
+
+    # Filter
+    q_lower = q.strip().lower()
+    src_lower = source.strip().lower()
+    filtered = chunks
+    if q_lower:
+        filtered = [c for c in filtered if q_lower in c.get("text", "").lower()]
+    if src_lower:
+        filtered = [c for c in filtered if src_lower in c.get("source", "").lower()]
+
+    total = len(filtered)
+    offset = (page - 1) * limit
+    page_chunks = filtered[offset: offset + limit]
+
+    # Return trimmed chunks (avoid huge payloads)
+    return {
+        "total": total,
+        "total_all": len(chunks),
+        "page": page,
+        "limit": limit,
+        "pages": max(1, (total + limit - 1) // limit),
+        "chunks": [
+            {
+                "id": c.get("id", ""),
+                "source": c.get("source", ""),
+                "chunk_id": c.get("chunk_id", 0),
+                "text": c.get("text", ""),
+                "length": c.get("length", 0),
+            }
+            for c in page_chunks
+        ],
+    }
+
+
+# ─── File Management ───
+
+@router.get("/{kb_id}/files")
+async def list_files(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    # Load saved ignore list
+    raw_ignored = kb.get("ignore_dirs") or "[]"
+    try:
+        ignored: Set[str] = set(json.loads(raw_ignored))
+    except Exception:
+        ignored = set()
+
+    added = db.fetchall("SELECT file_path FROM kb_files WHERE kb_id=?", (kb_id,))
+    added_paths = {r["file_path"] for r in added}
+    files = _walk_dir(kb["root_dir"], added_paths, ignored)
+    dirs = _list_dir_tree(kb["root_dir"], ignored)
+
+    return {
+        "kb_id": kb_id,
+        "root_dir": kb["root_dir"],
+        "files": files,
+        "dirs": dirs,
+        "ignored_dirs": sorted(ignored),
+    }
+
+
+@router.get("/{kb_id}/ignore-dirs")
+async def get_ignore_dirs(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+    raw = kb.get("ignore_dirs") or "[]"
+    try:
+        ignored = sorted(set(json.loads(raw)))
+    except Exception:
+        ignored = []
+    return {"ignored_dirs": ignored}
+
+
+@router.put("/{kb_id}/ignore-dirs")
+async def update_ignore_dirs(
+    kb_id: str,
+    req: UpdateIgnoreDirsRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    # Normalise: strip slashes, dedup, sort
+    clean = sorted({p.strip("/").strip() for p in req.ignored_dirs if p.strip()})
+    db.execute(
+        "UPDATE knowledge_bases SET ignore_dirs=?, updated_at=? WHERE id=?",
+        (json.dumps(clean), now_utc(), kb_id)
+    )
+    db.commit()
+    return {"ignored_dirs": clean}
+
+
+@router.post("/{kb_id}/files", status_code=201)
+async def add_files(
+    kb_id: str,
+    req: AddFilesRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    added = []
+    for fp in req.file_paths:
+        p = Path(fp)
+        if not p.is_file():
+            continue
+        existing = db.fetchone("SELECT id FROM kb_files WHERE kb_id=? AND file_path=?", (kb_id, str(p)))
+        if existing:
+            continue
+        fid = gen_id("kbf_")
+        db.execute(
+            "INSERT INTO kb_files (id, kb_id, file_path, file_name, file_type, size, status, added_at) "
+            "VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)",
+            (fid, kb_id, str(p), p.name, p.suffix.lower().lstrip("."), p.stat().st_size, now_utc())
+        )
+        added.append(fid)
+    db.commit()
+    return {"added": len(added)}
+
+
+@router.delete("/{kb_id}/files/{file_id}", status_code=204)
+async def remove_file(
+    kb_id: str,
+    file_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+    db.execute("DELETE FROM kb_files WHERE id=? AND kb_id=?", (file_id, kb_id))
+    db.commit()
+    return None
+
+
+# ─── Index Build Jobs ───
+
+@router.post("/{kb_id}/index", status_code=202)
+async def trigger_index(
+    kb_id: str,
+    req: IndexBuildRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    job_id = gen_id("kbj_")
+    db.execute(
+        "INSERT INTO kb_jobs (id, kb_id, tenant_id, job_type, status, created_at) VALUES (?,?,?,?,?,?)",
+        (job_id, kb_id, tenant.tenant_id, f"index:{req.index_type}", "pending", now_utc())
+    )
+    db.commit()
+
+    t = threading.Thread(
+        target=_run_index_job,
+        args=(db, kb_id, job_id, kb["root_dir"], req.index_type, req.mode),
+        daemon=True,
+    )
+    t.start()
+    return {"job_id": job_id, "status": "pending"}
+
+
+# ─── Wiki Compile Jobs ───
+
+@router.post("/{kb_id}/wiki", status_code=202)
+async def trigger_wiki(
+    kb_id: str,
+    req: WikiBuildRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    job_id = gen_id("kbj_")
+    db.execute(
+        "INSERT INTO kb_jobs (id, kb_id, tenant_id, job_type, status, created_at) VALUES (?,?,?,?,?,?)",
+        (job_id, kb_id, tenant.tenant_id, "wiki", "pending", now_utc())
+    )
+    db.commit()
+
+    t = threading.Thread(
+        target=_run_wiki_job,
+        args=(db, kb_id, job_id, kb["root_dir"], req.mode, req.concurrency),
+        daemon=True,
+    )
+    t.start()
+    return {"job_id": job_id, "status": "pending"}
+
+
+@router.put("/{kb_id}/wiki/pause", status_code=200)
+async def pause_wiki(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    # Write a stop flag that wiki_compile.py checks
+    stop_file = Path(kb["root_dir"]) / ".wiki_stop"
+    if stop_file.exists():
+        stop_file.unlink()
+        return {"action": "resumed"}
+    else:
+        stop_file.touch()
+        return {"action": "paused"}
+
+
+# ─── Wiki Content ───
+
+@router.get("/{kb_id}/wiki/tree")
+async def wiki_tree(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    wiki_path = Path(kb["root_dir"]) / "wiki"
+    result: Dict[str, Any] = {}
+    for sub in ["sources", "entities", "topics", "analyses"]:
+        d = wiki_path / sub
+        pages = []
+        if d.is_dir():
+            for f in sorted(d.glob("*.md")):
+                s = f.stat()
+                pages.append({
+                    "name": f.stem,
+                    "path": f"{sub}/{f.name}",
+                    "size": s.st_size,
+                    "modified": s.st_mtime,
+                })
+        result[sub] = pages
+
+    idx = wiki_path / "index.md"
+    result["index_content"] = idx.read_text(encoding="utf-8") if idx.exists() else ""
+    return result
+
+
+@router.get("/{kb_id}/wiki/{page_path:path}")
+async def wiki_page(
+    kb_id: str,
+    page_path: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    wiki_path = Path(kb["root_dir"]) / "wiki"
+    full = (wiki_path / page_path).resolve()
+    # Path traversal check
+    if not str(full).startswith(str(wiki_path.resolve())):
+        raise HTTPException(status_code=403, detail="Forbidden")
+    if not full.exists():
+        raise HTTPException(status_code=404, detail="Page not found")
+    return {"path": page_path, "content": full.read_text(encoding="utf-8"), "size": full.stat().st_size}
+
+
+@router.delete("/{kb_id}/wiki/analyses/{name}", status_code=204)
+async def delete_analysis(
+    kb_id: str,
+    name: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    p = Path(kb["root_dir"]) / "wiki" / "analyses" / f"{name}.md"
+    if p.exists() and str(p.resolve()).startswith(str((Path(kb["root_dir"]) / "wiki").resolve())):
+        p.unlink()
+    return None
+
+
+# ─── Search helpers ───
+
+def _search_subprocess(kb_root: str, scripts_dir: Path, mode: str, query: str, top_k: int) -> List[Dict]:
+    """Run search in an isolated subprocess to avoid config singleton / module cache issues.
+
+    search_engine.py uses `from config import cfg` at module level.  Once imported
+    in-process the singleton is frozen to the first kb_root it saw.  Running as a
+    subprocess gives each search a fresh Python interpreter with the correct
+    INSTANCE_CONFIG / KNOWLEDGE_DIR env vars.
+    """
+    instance_yml = _write_instance_yml(kb_root)
+    try:
+        env = _make_script_env(kb_root, scripts_dir, {"INSTANCE_CONFIG": instance_yml})
+
+        # Build the inline runner script — use json.dumps for safe string embedding
+        import_stmt = (
+            "from search_engine import search"
+            if mode == "bm25"
+            else (
+                "try:\n    from search_unified import search\n"
+                "except ImportError:\n    from search_engine import search"
+            )
+        )
+        runner = (
+            f"import sys, os, json\n"
+            f"sys.path.insert(0, {json.dumps(str(scripts_dir))})\n"
+            f"os.environ['INSTANCE_CONFIG'] = {json.dumps(instance_yml)}\n"
+            f"os.environ['KNOWLEDGE_DIR'] = {json.dumps(kb_root)}\n"
+            f"{import_stmt}\n"
+            f"results = search({json.dumps(query)}, top_k={top_k})\n"
+            f"print(json.dumps(results, ensure_ascii=False))\n"
+        )
+        r = subprocess.run(
+            [sys.executable, "-c", runner],
+            capture_output=True, text=True, timeout=30, env=env,
+        )
+        if r.returncode != 0:
+            raise RuntimeError(r.stderr[-800:] or "搜索子进程异常退出")
+        # Last line of stdout is the JSON result (earlier lines may be progress logs)
+        lines = [l for l in r.stdout.strip().splitlines() if l.strip()]
+        if not lines:
+            return []
+        return json.loads(lines[-1])
+    finally:
+        try:
+            os.unlink(instance_yml)
+        except Exception:
+            pass
+
+
+# ─── Search ───
+
+@router.post("/{kb_id}/search")
+async def search_kb(
+    kb_id: str,
+    req: SearchRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    kb_root = kb["root_dir"]
+    scripts_dir = _find_scripts_dir(Path(kb_root))
+
+    if not scripts_dir:
+        raise HTTPException(status_code=503, detail="搜索脚本未找到,请先构建 BM25 索引。")
+
+    # Check index exists
+    index_file = Path(kb_root) / "rag_index.json"
+    if not index_file.exists():
+        raise HTTPException(status_code=503, detail="BM25 索引不存在,请先在「索引构建」标签中触发构建。")
+
+    try:
+        results = await asyncio.get_event_loop().run_in_executor(
+            None,
+            lambda: _search_subprocess(kb_root, scripts_dir, req.mode, req.query, req.top_k)
+        )
+        return {
+            "query": req.query,
+            "mode": req.mode,
+            "results": results[:req.top_k],
+        }
+    except RuntimeError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+def _detect_ollama_model() -> tuple[str, str]:
+    """Return (base_url, model_name) for the best available local LLM.
+    Probes Ollama at 11434/11435, picks qwen2.5:7b if available, else first model found.
+    """
+    import urllib.request as _ur
+    preferred = ["qwen2.5:7b", "qwen2.5:14b", "qwen2.5:32b", "qwen2.5-coder:7b"]
+    for port in (11434, 11435):
+        base = f"http://127.0.0.1:{port}"
+        try:
+            with _ur.urlopen(f"{base}/api/tags", timeout=2) as r:
+                data = json.loads(r.read())
+            models = [m["name"] for m in data.get("models", [])]
+            if not models:
+                continue
+            chosen = next((m for m in preferred if m in models), models[0])
+            return base, chosen
+        except Exception:
+            continue
+    return "http://127.0.0.1:11434", "qwen2.5:7b"
+
+
+def _resolve_llm(provider_id: str, model: str) -> tuple[str, dict, str, str]:
+    """Resolve LLM call parameters from a configured provider.
+
+    Returns (chat_url, headers, model_name, display_label).
+    Falls back to local Ollama if provider_id is empty or "ollama".
+    """
+    from agentpaas.api.v1.providers_api import DEFAULT_PROVIDERS, _load_providers
+
+    # ── Ollama fallback ──────────────────────────────────────────
+    if not provider_id or provider_id == "ollama":
+        base, auto_model = _detect_ollama_model()
+        chosen = model or auto_model
+        chat_url = f"{base}/v1/chat/completions"
+        return chat_url, {"Content-Type": "application/json"}, chosen, f"Ollama / {chosen}"
+
+    pdef = DEFAULT_PROVIDERS.get(provider_id)
+    if not pdef:
+        raise ValueError(f"未知提供商: {provider_id}")
+
+    saved = _load_providers()
+    entry = saved.get(provider_id, {})
+    base_url = (entry.get("base_url") or pdef.get("base_url", "")).rstrip("/")
+    api_key = entry.get("api_key") or os.getenv(pdef.get("env_key", ""), "")
+    chosen = model or (pdef["models"][0] if pdef.get("models") else "")
+
+    ptype = pdef.get("type", "openai_compatible")
+
+    # ── Ollama type ──────────────────────────────────────────────
+    if ptype == "ollama":
+        # Detect actual running port from saved/default base_url
+        # but try to also auto-probe in case port changed
+        try:
+            auto_base, auto_model = _detect_ollama_model()
+        except Exception:
+            auto_base = base_url
+            auto_model = chosen
+        final_base = entry.get("base_url") or auto_base
+        chosen = model or auto_model
+        chat_url = f"{final_base.rstrip('/')}/v1/chat/completions"
+        return chat_url, {"Content-Type": "application/json"}, chosen, f"Ollama / {chosen}"
+
+    # ── claude-code type (not streamable via HTTP) ────────────────
+    if ptype == "claude-code":
+        raise ValueError("claude-code 提供商不支持 HTTP 流式调用,请选择其他提供商")
+
+    # ── Anthropic native API ──────────────────────────────────────
+    if ptype == "anthropic":
+        if not api_key:
+            raise ValueError("Anthropic API Key 未配置")
+        chat_url = f"{base_url}/v1/messages"
+        headers = {
+            "Content-Type": "application/json",
+            "x-api-key": api_key,
+            "anthropic-version": "2023-06-01",
+        }
+        return chat_url, headers, chosen, f"Anthropic / {chosen}"
+
+    # ── OpenAI / OpenAI-compatible ────────────────────────────────
+    if not api_key:
+        raise ValueError(f"提供商 {pdef['name']} 未配置 API Key")
+    chat_url = f"{base_url}/chat/completions"
+    headers = {
+        "Content-Type": "application/json",
+        "Authorization": f"Bearer {api_key}",
+    }
+    return chat_url, headers, chosen, f"{pdef['name']} / {chosen}"
+
+
+def _stream_openai_compat(
+    chat_url: str, headers: dict, model: str, messages: list, temperature: float = 0.3
+):
+    """Generator that yields text delta strings from an OpenAI-compatible streaming endpoint."""
+    import urllib.request as _ur
+    body = json.dumps({
+        "model": model,
+        "messages": messages,
+        "stream": True,
+        "temperature": temperature,
+    }, ensure_ascii=False).encode("utf-8")
+    req = _ur.Request(chat_url, data=body, headers=headers, method="POST")
+    with _ur.urlopen(req, timeout=180) as resp:
+        for raw_line in resp:
+            line = raw_line.decode("utf-8").strip()
+            if not line.startswith("data:"):
+                continue
+            payload = line[5:].strip()
+            if payload == "[DONE]":
+                break
+            try:
+                d = json.loads(payload)
+                delta = d["choices"][0]["delta"].get("content", "")
+                if delta:
+                    yield delta
+            except Exception:
+                continue
+
+
+def _stream_anthropic(
+    chat_url: str, headers: dict, model: str, messages: list, temperature: float = 0.3
+):
+    """Generator that yields text delta strings from Anthropic Messages streaming API."""
+    import urllib.request as _ur
+    # Convert OpenAI-style messages to Anthropic format
+    system_parts = [m["content"] for m in messages if m["role"] == "system"]
+    user_msgs = [m for m in messages if m["role"] != "system"]
+    body_dict: dict = {
+        "model": model,
+        "messages": user_msgs,
+        "max_tokens": 2048,
+        "stream": True,
+        "temperature": temperature,
+    }
+    if system_parts:
+        body_dict["system"] = "\n\n".join(system_parts)
+    body = json.dumps(body_dict, ensure_ascii=False).encode("utf-8")
+    req = _ur.Request(chat_url, data=body, headers=headers, method="POST")
+    with _ur.urlopen(req, timeout=180) as resp:
+        for raw_line in resp:
+            line = raw_line.decode("utf-8").strip()
+            if not line.startswith("data:"):
+                continue
+            payload = line[5:].strip()
+            try:
+                d = json.loads(payload)
+                if d.get("type") == "content_block_delta":
+                    delta = d.get("delta", {}).get("text", "")
+                    if delta:
+                        yield delta
+            except Exception:
+                continue
+
+
+@router.get("/{kb_id}/search/stream")
+async def search_stream(
+    kb_id: str,
+    q: str = Query(..., description="Question to answer"),
+    mode: str = Query(default="bm25"),
+    top_k: int = Query(default=5, ge=1, le=20),
+    provider_id: str = Query(default="", description="Provider ID from providers config"),
+    model: str = Query(default="", description="Model name override"),
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """SSE streaming QA: retrieve → build prompt → stream LLM answer token by token."""
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    kb_root = kb["root_dir"]
+    scripts_dir = _find_scripts_dir(Path(kb_root))
+
+    async def event_stream():
+        # ── Step 1: retrieve ──
+        yield f"data: {json.dumps({'type': 'status', 'text': '正在检索相关文档...'})}\n\n"
+        await asyncio.sleep(0)
+
+        chunks: List[Dict] = []
+        if scripts_dir and (Path(kb_root) / "rag_index.json").exists():
+            try:
+                chunks = await asyncio.get_event_loop().run_in_executor(
+                    None,
+                    lambda: _search_subprocess(kb_root, scripts_dir, mode, q, top_k)
+                )
+            except Exception as e:
+                yield f"data: {json.dumps({'type': 'status', 'text': f'检索出错: {e}'})}\n\n"
+        else:
+            yield f"data: {json.dumps({'type': 'status', 'text': 'BM25 索引不存在,请先构建索引'})}\n\n"
+
+        # Emit retrieved chunks so the UI can show sources panel
+        yield f"data: {json.dumps({'type': 'chunks', 'chunks': chunks[:top_k]})}\n\n"
+        await asyncio.sleep(0)
+
+        if not chunks:
+            yield f"data: {json.dumps({'type': 'chunk', 'text': '未检索到相关文档,无法生成回答。'})}\n\n"
+            yield f"data: {json.dumps({'type': 'done'})}\n\n"
+            return
+
+        # ── Step 2: build prompt ──
+        context_parts = []
+        for i, c in enumerate(chunks[:top_k]):
+            context_parts.append(f"[来源{i+1}: {c.get('source', '?')}]\n{c.get('text', '')}")
+        context = "\n\n---\n\n".join(context_parts)[:6000]
+
+        messages = [
+            {
+                "role": "system",
+                "content": (
+                    "你是一个知识库问答助手。请严格基于以下检索到的参考资料回答问题,"
+                    "若资料中无相关信息请如实说明,回答时用「[来源N]」标注依据。"
+                ),
+            },
+            {
+                "role": "user",
+                "content": f"## 参考资料\n{context}\n\n## 问题\n{q}\n\n## 回答",
+            },
+        ]
+
+        # ── Step 3: resolve provider + stream ──
+        yield f"data: {json.dumps({'type': 'status', 'text': '正在生成回答...'})}\n\n"
+        await asyncio.sleep(0)
+
+        try:
+            chat_url, llm_headers, model_name, display_label = _resolve_llm(provider_id, model)
+        except Exception as e:
+            err_msg = f"提供商配置错误: {e}"
+            yield f"data: {json.dumps({'type': 'chunk', 'text': err_msg})}\n\n"
+            yield f"data: {json.dumps({'type': 'done'})}\n\n"
+            return
+
+        yield f"data: {json.dumps({'type': 'status', 'text': f'模型: {display_label}'})}\n\n"
+        await asyncio.sleep(0)
+
+        # Determine stream generator based on provider type
+        from agentpaas.api.v1.providers_api import DEFAULT_PROVIDERS
+        pdef = DEFAULT_PROVIDERS.get(provider_id, {})
+        is_anthropic = pdef.get("type") == "anthropic"
+
+        def _do_stream():
+            if is_anthropic:
+                yield from _stream_anthropic(chat_url, llm_headers, model_name, messages)
+            else:
+                yield from _stream_openai_compat(chat_url, llm_headers, model_name, messages)
+
+        try:
+            loop = asyncio.get_event_loop()
+            # Use asyncio.Queue so the thread can push tokens without blocking the event loop
+            aio_q: asyncio.Queue = asyncio.Queue()
+
+            def _fill_q():
+                """Runs in a thread — pushes (kind, val) into the asyncio queue."""
+                try:
+                    for delta in _do_stream():
+                        loop.call_soon_threadsafe(aio_q.put_nowait, ("chunk", delta))
+                    loop.call_soon_threadsafe(aio_q.put_nowait, ("done", None))
+                except Exception as exc:
+                    loop.call_soon_threadsafe(aio_q.put_nowait, ("error", str(exc)))
+
+            # Start the blocking stream in a thread
+            stream_fut = loop.run_in_executor(None, _fill_q)
+
+            # Drain the async queue in the event loop
+            while True:
+                kind, val = await aio_q.get()
+                if kind == "chunk":
+                    yield f"data: {json.dumps({'type': 'chunk', 'text': val})}\n\n"
+                elif kind == "error":
+                    err_msg = f"\n\n(LLM 错误: {val})"
+                    yield f"data: {json.dumps({'type': 'chunk', 'text': err_msg})}\n\n"
+                    break
+                else:  # done
+                    break
+
+            await stream_fut  # surface thread exceptions
+        except Exception as e:
+            err_msg = f"\n\n(LLM 错误: {e})"
+            yield f"data: {json.dumps({'type': 'chunk', 'text': err_msg})}\n\n"
+
+        yield f"data: {json.dumps({'type': 'done'})}\n\n"
+
+    return StreamingResponse(
+        event_stream(),
+        media_type="text/event-stream",
+        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+    )
+
+
+# ─── Jobs ───
+
+@router.get("/{kb_id}/jobs")
+async def list_jobs(
+    kb_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    jobs = db.fetchall(
+        "SELECT * FROM kb_jobs WHERE kb_id=? ORDER BY created_at DESC LIMIT 20",
+        (kb_id,)
+    )
+    return {"jobs": jobs}
+
+
+@router.get("/{kb_id}/jobs/{job_id}")
+async def get_job(
+    kb_id: str,
+    job_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    job = db.fetchone("SELECT * FROM kb_jobs WHERE id=? AND kb_id=?", (job_id, kb_id))
+    if not job:
+        raise HTTPException(status_code=404, detail="Job not found")
+    return job

+ 50 - 0
agentpaas/db/models.py

@@ -178,6 +178,13 @@ class Database:
         """)
         self.conn.commit()
 
+    def _ensure_table(self, name: str, ddl: str):
+        try:
+            self.conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
+        except sqlite3.OperationalError:
+            self.conn.executescript(ddl)
+            self.conn.commit()
+
     def _migrate(self):
         """Add columns to existing tables if missing (safe for fresh DBs too)."""
         migrations = [
@@ -186,6 +193,7 @@ class Database:
             ("agents", "instance_dir", "ALTER TABLE agents ADD COLUMN instance_dir TEXT"),
             ("runs", "workspace_path", "ALTER TABLE runs ADD COLUMN workspace_path TEXT"),
             ("runs", "idempotency_key", "ALTER TABLE runs ADD COLUMN idempotency_key TEXT"),
+            ("knowledge_bases", "ignore_dirs", "ALTER TABLE knowledge_bases ADD COLUMN ignore_dirs TEXT DEFAULT '[]'"),
         ]
         for table, column, sql in migrations:
             try:
@@ -194,6 +202,48 @@ class Database:
                 self.conn.execute(sql)
         self.conn.commit()
 
+        # Knowledge base tables (added 2026-05-27)
+        self._ensure_table("knowledge_bases", """
+            CREATE TABLE IF NOT EXISTS knowledge_bases (
+                id TEXT PRIMARY KEY,
+                tenant_id TEXT REFERENCES tenants(id),
+                name TEXT NOT NULL,
+                root_dir TEXT NOT NULL,
+                description TEXT DEFAULT '',
+                created_at TEXT,
+                updated_at TEXT
+            )
+        """)
+        self._ensure_table("kb_files", """
+            CREATE TABLE IF NOT EXISTS kb_files (
+                id TEXT PRIMARY KEY,
+                kb_id TEXT REFERENCES knowledge_bases(id),
+                file_path TEXT NOT NULL,
+                file_name TEXT NOT NULL,
+                file_type TEXT,
+                size INTEGER DEFAULT 0,
+                status TEXT DEFAULT 'pending',
+                added_at TEXT
+            )
+        """)
+        self._ensure_table("kb_jobs", """
+            CREATE TABLE IF NOT EXISTS kb_jobs (
+                id TEXT PRIMARY KEY,
+                kb_id TEXT REFERENCES knowledge_bases(id),
+                tenant_id TEXT,
+                job_type TEXT NOT NULL,
+                status TEXT DEFAULT 'pending',
+                progress INTEGER DEFAULT 0,
+                total INTEGER DEFAULT 0,
+                current_file TEXT DEFAULT '',
+                error TEXT DEFAULT '',
+                log TEXT DEFAULT '',
+                started_at TEXT,
+                completed_at TEXT,
+                created_at TEXT
+            )
+        """)
+
     def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
         return self.conn.execute(sql, params)
 

+ 124 - 40
webui/WEB_UI_REQUIREMENTS.md

@@ -394,17 +394,68 @@ LambdAgent PaaS 目前的使用方式以命令行(CLI)和 YAML 文件为主
 
 **路由:** `/knowledge`、`/knowledge/:kbId`  
 **对应后端:** `GET/POST/PUT/DELETE /api/v1/knowledge`(新增)  
-**关联脚本:** `agentexample/qaagent67/scripts/`(extract_pdfs.py、build_index.py、wiki_compile.py、search_unified.py 等)
+**关联脚本:** `agentexample/qaagent67lambda/scripts/`(app.py、build_index.py、wiki_compile.py、search_unified.py、graph_engine.py 等)
 
 #### 背景
 
-现有 QA 智能体(qaagent67、qaagent67wiki、qaagent67lite)已有完整的离线处理流水线:
-- `extract_pdfs.py` — 批量提取 PDF 为纯文本
+现有 QA 智能体(qaagent67lambda、qaagent67、qaagent67lite)已有完整的离线处理流水线:
+- `extract_pdfs.py` / `extract_docs.py` — 批量提取 PDF / DOCX 为纯文本
 - `build_index.py` / `build_vector_index.py` — 构建 BM25 / 向量检索索引
 - `wiki_compile.py` — LLM 逐文档理解 → 生成 sources/、entities/、topics/ 三级 Wiki 页面
-- `search_unified.py` / `search_engine.py` — 跨索引统一搜索
+- `graph_engine.py` — 从 Wiki relations.json 构建实体关系图谱(BFS N跳遍历)
+- `search_unified.py` — LambdaRAG v3 四路检索融合(BM25 + 向量 + 图谱 + Wiki)
 
-这些脚本的路径(`/home/67/knowledge/maritime/`)目前**硬编码**,缺乏管理界面。本需求将其封装为可通过 Web UI 配置和触发的「知识体」。
+这些脚本的路径(`/home/67/knowledge/maritime/`)目前通过 `instance.yml` 配置,但**缺乏 Web 管理界面**。本需求将其封装为可通过 Web UI 配置和触发的「知识体」。
+
+---
+
+#### 4.12.0 参考实现分析(现有服务器部署)
+
+> 以下分析基于对 `http://1.95.125.250:8080`(海事法规)和 `http://1.95.125.250:8084`(投标文件)两个在线服务的调研,以及对本地源码的全面阅读(2026-05-27)。
+
+**服务器代码位置(`1.95.125.250`):**
+
+| 文件 | 说明 |
+|------|------|
+| `/home/67/qademo/app.py` | 主应用(本地源:`agentexample/qaagent67lambda/scripts/app.py`) |
+| `/home/67/qademo/deploy_domain.sh` | 一键部署新领域的 shell 脚本 |
+| `/home/67/qademo/instance.yml.template` | 新领域配置模板 |
+| `/home/67/knowledge/maritime/` | 海事 KB(8080):1326 文档,BM25+向量+图谱+Wiki |
+| `/home/67/knowledge/clean/` | 投标 KB(8084):49 文档,1527 chunks |
+| `/home/67/knowledge/{domain}/instance.yml` | 各领域配置覆盖层 |
+
+运行方式:每个领域独立 Docker 容器(`maritime-qa`、`clean-qa` 等),`deploy_domain.sh {domain}` 一键启动。
+
+**架构优势(Web UI 需保留并集成):**
+
+| 优势 | 实现位置 | 说明 |
+|------|---------|------|
+| **LambdaRAG 四路检索融合** | `search_unified.py` | 意图分类(6类:fact/process/person/compare/temporal/data)→ 实体提取 → BM25+向量+图谱+Wiki 并行检索 → RRF 融合 → 槽位填充 → 富文本上下文 |
+| **意图自适应权重** | `config.py` weight_profiles | 按检测到的意图类型自动切换融合权重(如 temporal 查询 BM25 权重=1.0,compare 查询向量权重=0.8) |
+| **Corrective RAG** | `search_unified.py` P1-1 | 检索质量评分不足时自动改写查询并重试,减少无关结果 |
+| **实体关系图谱** | `graph_engine.py` | 从 wiki/relations.json 构建邻接表,BFS N跳遍历扩展实体上下文 |
+| **三层 Wiki 体系** | `wiki_compile.py` | sources/(逐文档摘要)/ entities/(命名实体)/ topics/(主题聚合)/ analyses/(即席编译) |
+| **A/B 对比面板** | `app.py` HTML | 5 种检索策略同屏对比(Lite / LambdaRAG / Wiki Only / BM25 Baseline / Direct LLM) |
+| **Wiki/RAG 浏览器** | `/wiki`、`/rag` 路由 | 内置知识库内容检视页,可浏览 wiki 页面和搜索原始 chunks |
+| **反馈日志** | `feedback/query_log.jsonl` | 自动记录每次检索的意图、实体、策略,以及 missing_queries 检索盲区 |
+| **实例配置体系** | `instance.yml` + `config.py` | 无需改代码即可通过 YAML 新增领域(路径/模型/权重/UI文案全部可配) |
+
+**现有实现的缺口(Web UI 需弥补):**
+
+| # | 缺口 | 问题描述 | 对应 UI 需求 |
+|---|------|---------|-------------|
+| G1 | 无文件摄入 UI | 建库需 SSH 上传文件 + CLI 运行脚本 | 4.12.1 目录浏览器 + 一键加入 |
+| G2 | 无索引健康监控 | 不知索引是否最新、有多少文档失败 | 4.12.2 状态指示器 + 失败文件列表 |
+| G3 | Wiki 编译无可视化 | `wiki_compile.py` 进度只有终端日志 | 4.12.3 实时进度 + 断点续传控制 |
+| G4 | Wiki 内部检索质量差 | 用字符频率(`sum(1 for c in q_chars ...)`)代替 BM25/向量匹配 | 4.12.2 补充:wiki 搜索改用 BM25 全文检索 |
+| G5 | 5路并行浪费算力 | 每次查询触发 5 个完整 LLM 调用,即使用户只看 2 个面板 | 4.12.4 搜索测试:懒加载/按需触发各模式 |
+| G6 | 无流式输出 | 答案完整生成后才推送(2s 轮询),体验延迟高 | 4.12.4 搜索测试:接入 SSE 流式输出 |
+| G7 | 对话历史是 JSON 文件 | 无搜索、无用户隔离、重启丢失、线程写入竞争 | 4.12.4 历史:写入 agentpaas.db |
+| G8 | 无检索权重可视化调优 | instance.yml 手写权重,无法实时观测效果 | 4.12.4 新增:权重滑块 + 实时效果对比 |
+| G9 | 无增量文档更新 | 新增文档需全量重建索引(BM25 重扫全库)| 4.12.2 增量更新 Job |
+| G10 | 无评测集成 | `generate_questions.py`/`analyze_results.py` 存在但未接入 UI | Phase 4 评估面板 |
+| G11 | Analyses 目录无管理 | 即席编译的 wiki analyses/ 无限积累,无去重/归档 | 4.12.3 Wiki 目录 → analyses 管理子面板 |
+| G12 | 领域创建需手工 | 新领域需 SSH + 编辑 instance.yml + 运行脚本 | 4.12.1 新建知识体向导(GUI 版 deploy_domain.sh) |
 
 ---
 
@@ -478,10 +529,11 @@ LambdAgent PaaS 目前的使用方式以命令行(CLI)和 YAML 文件为主
 
 ##### 标签 3 — Wiki 目录
 
-**Wiki 是什么:** 由 LLM 逐文档阅读后自动生成的结构化知识页面,分三层
+**Wiki 是什么:** 由 LLM 逐文档阅读后自动生成的结构化知识页面,分四层(`wiki_compile.py` 输出)
 - `sources/` — 每个原始文档的摘要页(1文档 = 1 页)
-- `entities/` — 抽取出的实体(人名、公司、术语、地名等)
+- `entities/` — 抽取出的实体(人名、公司、术语、地名等)及其关系
 - `topics/` — 主题聚合页(将多篇文档的相关内容汇总)
+- `analyses/` — 即席编译生成的临时知识页(查询时按需生成,参见现有 `wiki_answer()` 逻辑)
 
 **Wiki 构建面板:**
 
@@ -491,7 +543,7 @@ LambdAgent PaaS 目前的使用方式以命令行(CLI)和 YAML 文件为主
 | LLM 提供商 | 选择用于 Wiki 编译的模型(建议 32B+ 或 Claude) |
 | 编译模式 | 增量(仅处理未编译文档)/ 全量重建 |
 | 并发数 | 同时处理的文档数(1–8) |
-| 断点续传 | 默认开启,中断后从上次进度继续 |
+| 断点续传 | 默认开启,中断后从 `.compile_progress.json` 继续(`wiki_compile.py` 内置支持) |
 
 操作:
 - **开始编译** — 触发后台 Job,对应 `wiki_compile.py` 逻辑
@@ -499,29 +551,55 @@ LambdAgent PaaS 目前的使用方式以命令行(CLI)和 YAML 文件为主
 - 进度条:`已完成 / 总文档数`,预计剩余时间
 
 **Wiki 内容浏览器:**
-- 左侧树:`index.md` / `sources/` / `entities/` / `topics/`
+- 左侧树:`index.md` / `sources/` / `entities/` / `topics/`(显示每类页面数量统计)
 - 右侧:Markdown 渲染预览
-- 搜索框:在 wiki 页面标题/内容中检索
+- **搜索框(修复 G4)**:对 wiki 页面内容进行 BM25 全文检索(不再使用字符频率匹配),结果按相关度排序
 - 每个页面显示"来源文档"反向链接
 
+**Analyses 管理子面板(修复 G11):**
+- 列出 `analyses/` 下所有即席编译页,显示:文件名(来源查询)、大小、创建时间
+- 操作:**归档**(移入 topics/)、**删除**、**批量清理**(删除 30天前的条目)
+- 显示"去重提示":内容相似度 >80% 的 analyses 页高亮,建议合并
+
 ---
 
 ##### 标签 4 — 搜索测试
 
-提供交互式搜索界面,用于验证知识体质量:
+提供交互式搜索界面,用于验证知识体质量(参考现有 `app.py` 的 A/B 对比面板,弥补其 G5/G6/G8 缺口)
 
 **搜索配置:**
-- 检索模式单选:BM25 / 向量 / 混合(BM25 + 向量 Rerank)/ Wiki 全文
+- 检索策略:下拉单选(BM25 / 向量语义 / 混合 Rerank / Wiki 全文 / **LambdaRAG 四路融合**)
+  - 选择 LambdaRAG 时,显示**意图分类结果**(检测到的 query type)和每路检索命中数
 - Top-K 结果数(1–20)
 - 相关性阈值滑块
 
+**A/B 对比模式(参考现有 demo,修复 G5):**
+- 切换"对比模式":页面分为左右两列,各自独立选择检索策略
+- **按需加载**:只触发用户选中面板的检索,不后台并行运行所有 5 路(修复 G5 算力浪费)
+- 结果并排展示,便于直观对比不同策略的答案质量
+
+**检索权重可视化调优(修复 G8):**
+- LambdaRAG 模式下展开"权重配置"折叠面板:
+  - 4 个滑块:BM25 / 向量 / 图谱 / Wiki(各 0.0–1.0)
+  - 预设方案快速切换:按意图类型(fact / compare / temporal 等)加载推荐权重
+  - 修改权重后实时重新排序当前结果(前端 RRF 重算,无需再次检索)
+- "保存为默认":将当前权重写入知识体配置(更新 instance.yml 等效配置)
+
+**流式问答测试(修复 G6):**
+- 输入问题 → 点击"问答测试" → SSE 流式展示 LLM 答案(逐字输出,无需等待完整生成)
+- 答案下方展示引用的 chunks 列表(来源文件 + 相关度分数)
+
 **搜索结果展示:**
-每条结果显示:
+每条 chunk 结果显示:
 - 文档来源(文件名 + 页码/段落)
-- 相关度分数
-- 匹配片段高亮
+- 相关度分数 + 所属检索路径(BM25/向量/图谱/Wiki 哪路命中)
+- 匹配片段,关键词高亮
 - 展开查看原文按钮
 
+**历史记录(修复 G7):**
+- 搜索历史写入 `agentpaas.db`,按知识体隔离,支持搜索和导出
+- 显示最近 100 条(带意图类型、检索耗时、命中文档数)
+
 **与智能体联动:**
 - "在智能体中测试"按钮 — 将当前知识体绑定到选定的 QA 智能体,跳转至对话界面
 
@@ -540,42 +618,48 @@ LambdAgent PaaS 目前的使用方式以命令行(CLI)和 YAML 文件为主
 
 | 方法 | 路径 | 说明 |
 |------|------|------|
-| `GET` | `/api/v1/knowledge` | 列出所有知识体 |
+| `GET` | `/api/v1/knowledge` | 列出所有知识体(含索引状态摘要) |
 | `POST` | `/api/v1/knowledge` | 创建知识体(name, root_dir) |
+| `PUT` | `/api/v1/knowledge/:kbId` | 更新知识体配置(含检索权重) |
 | `DELETE` | `/api/v1/knowledge/:kbId` | 删除知识体 |
-| `GET` | `/api/v1/knowledge/:kbId/files` | 列出根目录文件树 |
+| `GET` | `/api/v1/knowledge/:kbId/files` | 列出根目录文件树(含加入状态) |
 | `POST` | `/api/v1/knowledge/:kbId/files` | 加入文件(触发文本提取) |
-| `POST` | `/api/v1/knowledge/:kbId/index` | 触发索引构建 Job(type: bm25/vector/graph) |
-| `POST` | `/api/v1/knowledge/:kbId/wiki` | 触发 Wiki 编译 Job |
-| `GET` | `/api/v1/knowledge/:kbId/wiki/tree` | 获取 Wiki 文件树 |
-| `GET` | `/api/v1/knowledge/:kbId/wiki/:path` | 获取单个 Wiki 页面内容 |
-| `POST` | `/api/v1/knowledge/:kbId/search` | 在线搜索测试 |
-| `GET` | `/api/v1/knowledge/:kbId/jobs` | 查询 Job 状态(索引/Wiki 构建进度) |
-
-Job 进度通过 SSE(`/api/v1/jobs/{job_id}/stream`)实时推送。
+| `DELETE` | `/api/v1/knowledge/:kbId/files/:fileId` | 从知识体移除文件(不删原文) |
+| `POST` | `/api/v1/knowledge/:kbId/index` | 触发索引构建 Job(type: bm25/vector/graph,mode: full/incremental) |
+| `POST` | `/api/v1/knowledge/:kbId/wiki` | 触发 Wiki 编译 Job(mode: incremental/rebuild) |
+| `PUT` | `/api/v1/knowledge/:kbId/wiki/pause` | 暂停/恢复 Wiki 编译(写 `.compile_progress.json` 停止标志) |
+| `GET` | `/api/v1/knowledge/:kbId/wiki/tree` | 获取 Wiki 文件树(sources/entities/topics/analyses 分组) |
+| `GET` | `/api/v1/knowledge/:kbId/wiki/:path` | 获取单个 Wiki 页面内容(Markdown) |
+| `DELETE` | `/api/v1/knowledge/:kbId/wiki/analyses/:name` | 删除 analyses/ 下指定即席编译页 |
+| `POST` | `/api/v1/knowledge/:kbId/search` | 在线搜索测试(返回 chunks + 意图分类 + 各路命中数) |
+| `GET` | `/api/v1/knowledge/:kbId/search/stream` | **SSE 流式**问答测试(LLM 逐字输出,修复 G6) |
+| `GET` | `/api/v1/knowledge/:kbId/jobs` | 查询 Job 状态(索引/Wiki 构建进度 + 失败文件列表) |
+| `GET` | `/api/v1/knowledge/:kbId/feedback` | 获取反馈日志(query_log + missing_queries,修复 G10 前期准备) |
+
+Job 进度通过 SSE(`/api/v1/jobs/{job_id}/stream`)实时推送,格式:`data: {"progress": 128, "total": 1326, "current_file": "xxx.pdf", "errors": []}`。
 
 ---
 
 #### 4.12.5 QA 智能体脚本整合说明
 
-`agentexample/qaagent67/scripts/` 中各脚本的对应关系:
+`agentexample/qaagent67lambda/scripts/` 中各脚本的对应关系:
 
 | 脚本 | 对应 UI 功能 | 整合方式 |
 |------|------------|---------|
-| `extract_pdfs.py` | 文件管理 → 加入知识体 | 封装为 `kb_service.extract_file(path)` |
-| `extract_pdfs_v2.py` | 同上(改进版,优先使用) | 同上 |
-| `build_index.py` | 索引构建 → BM25 | 封装为 `kb_service.build_bm25(kb_id)` |
-| `build_vector_index.py` | 索引构建 → 向量 | 封装为 `kb_service.build_vector(kb_id)` |
-| `graph_engine.py` | 索引构建 → 图谱 | 封装为 `kb_service.build_graph(kb_id)` |
-| `wiki_compile.py` | Wiki 目录 → 编译 | 封装为 `kb_service.compile_wiki(kb_id, cfg)` |
-| `search_unified.py` | 搜索测试 | 封装为 `kb_service.search(kb_id, query, mode)` |
-| `search_engine.py` | 同上(BM25 backend) | 被 `search_unified` 调用 |
-| `search_engine_v2.py` | 同上(改进版) | 被 `search_unified` 调用 |
-| `generate_questions.py` | (未来)自动生成测试问题集 | Phase 4 |
-| `analyze_results.py` | (未来)评估检索质量报告 | Phase 4 |
-| `rebuild_index.py` | 索引构建 → 重建 | 封装为 `kb_service.rebuild(kb_id)` |
-
-**硬编码路径修复:** 所有脚本中的硬编码路径(`/home/67/knowledge/maritime/`、`/data/knowledge/`)统一替换为从知识体配置动态读取的 `root_dir`,通过 `--kb-dir` CLI 参数或环境变量传入
+| `extract_docs.py` | 文件管理 → 加入知识体(PDF/DOCX/HTML) | 封装为 `kb_service.extract_file(path)` |
+| `build_index.py` | 索引构建 → BM25(全量/增量) | 封装为 `kb_service.build_bm25(kb_id, mode)` |
+| `build_vector_index.py` | 索引构建 → 向量(bge-m3) | 封装为 `kb_service.build_vector(kb_id, mode)` |
+| `graph_engine.py` | 索引构建 → 图谱(entity relation graph) | 封装为 `kb_service.build_graph(kb_id)` |
+| `wiki_compile.py` | Wiki 目录 → 编译(断点续传) | 封装为 `kb_service.compile_wiki(kb_id, cfg)` |
+| `search_unified.py` | 搜索测试 → LambdaRAG 四路融合 | 封装为 `kb_service.search_lambda(kb_id, query, weights)` |
+| `search_engine.py` | 搜索测试 → BM25 Baseline | 被 `search_unified` 调用,也可独立调用 |
+| `search_engine_v2.py` | 搜索测试 → 向量语义 | 被 `search_unified` 调用 |
+| `config.py` | 知识体配置读取 | 适配器:Web UI 传入 `kb_id`,`config.py` 从 DB 加载路径 |
+| `rebuild_index.py` | 索引构建 → 全量重建 | 封装为 `kb_service.rebuild(kb_id)` |
+| `generate_questions.py` | Phase 4:自动生成测试问题集 | `kb_service.generate_eval_set(kb_id)` |
+| `analyze_results.py` | Phase 4:评估检索质量报告 | `kb_service.analyze_quality(kb_id, eval_set)` |
+
+**路径动态化(弥补硬编码缺口):** 现有脚本通过 `config.py` 的 `Config` 类统一读取路径,Web UI 后端只需在调用前设置 `INSTANCE_CONFIG` 环境变量(或直接传参),即可让所有脚本使用 DB 中记录的知识体路径,无需修改脚本本身
 
 ---
 

+ 3 - 0
webui/package.json

@@ -2,6 +2,9 @@
   "name": "lambdagent-webui",
   "version": "0.1.0",
   "type": "module",
+  "engines": {
+    "node": ">=18"
+  },
   "scripts": {
     "dev": "vite",
     "build": "tsc && vite build",

+ 4 - 0
webui/src/App.tsx

@@ -9,6 +9,8 @@ import AgentImport from './pages/AgentImport'
 import AgentEdit from './pages/AgentEdit'
 import Chat from './pages/Chat'
 import Providers from './pages/Providers'
+import Knowledge from './pages/Knowledge'
+import KnowledgeDetail from './pages/KnowledgeDetail'
 
 function RequireAuth({ children }: { children: React.ReactNode }) {
   const apiKey = useAppStore(s => s.apiKey)
@@ -37,6 +39,8 @@ export default function App() {
           <Route path="agents/:agentId/edit" element={<AgentEdit />} />
           <Route path="chat/:agentId" element={<Chat />} />
           <Route path="settings/providers" element={<Providers />} />
+          <Route path="knowledge" element={<Knowledge />} />
+          <Route path="knowledge/:kbId" element={<KnowledgeDetail />} />
         </Route>
         <Route path="*" element={<Navigate to="/" replace />} />
       </Routes>

+ 133 - 0
webui/src/api/knowledge.ts

@@ -0,0 +1,133 @@
+import { api } from './client'
+
+export interface KnowledgeBase {
+  id: string
+  name: string
+  root_dir: string
+  description: string
+  created_at: string
+  updated_at: string
+  index_status: {
+    bm25: { exists: boolean; total_chunks?: number; total_files?: number }
+    vector: { exists: boolean }
+    wiki: { exists: boolean; total_pages?: number; subdirs?: Record<string, number> }
+  }
+}
+
+export interface KBFile {
+  path: string
+  relative_path: string
+  name: string
+  type: string
+  size: number
+  modified: number
+  added: boolean
+}
+
+export interface KBDirNode {
+  name: string
+  relative_path: string
+  ignored: boolean          // true if this specific path is in the ignore list
+  children: KBDirNode[]    // nested subdirectories
+}
+
+export interface KBJob {
+  id: string
+  kb_id: string
+  job_type: string
+  status: 'pending' | 'running' | 'completed' | 'failed'
+  progress: number
+  total: number
+  current_file: string
+  error: string
+  log: string
+  started_at: string
+  completed_at: string
+  created_at: string
+}
+
+export interface KBChunk {
+  id: string
+  source: string
+  chunk_id: number
+  text: string
+  length: number
+}
+
+export interface KBChunkPage {
+  total: number
+  total_all: number
+  page: number
+  limit: number
+  pages: number
+  chunks: KBChunk[]
+}
+
+export interface WikiPage {
+  name: string
+  path: string
+  size: number
+  modified: number
+}
+
+export interface WikiTree {
+  sources: WikiPage[]
+  entities: WikiPage[]
+  topics: WikiPage[]
+  analyses: WikiPage[]
+  index_content: string
+}
+
+export const knowledgeApi = {
+  list: () => api.get<{ items: KnowledgeBase[] }>('/knowledge'),
+  get: (id: string) => api.get<KnowledgeBase>(`/knowledge/${id}`),
+  create: (data: { name: string; root_dir: string; description?: string }) =>
+    api.post<KnowledgeBase>('/knowledge', data),
+  delete: (id: string) => api.delete(`/knowledge/${id}`),
+
+  listFiles: (kbId: string) =>
+    api.get<{ kb_id: string; root_dir: string; files: KBFile[]; dirs: KBDirNode[]; ignored_dirs: string[] }>(`/knowledge/${kbId}/files`),
+
+  getIgnoreDirs: (kbId: string) =>
+    api.get<{ ignored_dirs: string[] }>(`/knowledge/${kbId}/ignore-dirs`),
+  updateIgnoreDirs: (kbId: string, ignored_dirs: string[]) =>
+    api.put<{ ignored_dirs: string[] }>(`/knowledge/${kbId}/ignore-dirs`, { ignored_dirs }),
+  addFiles: (kbId: string, file_paths: string[]) =>
+    api.post<{ added: number }>(`/knowledge/${kbId}/files`, { file_paths }),
+  removeFile: (kbId: string, fileId: string) =>
+    api.delete(`/knowledge/${kbId}/files/${fileId}`),
+
+  buildIndex: (kbId: string, index_type = 'bm25', mode = 'full') =>
+    api.post<{ job_id: string }>(`/knowledge/${kbId}/index`, { index_type, mode }),
+  buildWiki: (kbId: string, mode = 'incremental', concurrency = 2) =>
+    api.post<{ job_id: string }>(`/knowledge/${kbId}/wiki`, { mode, concurrency }),
+  pauseWiki: (kbId: string) =>
+    api.put<{ action: string }>(`/knowledge/${kbId}/wiki/pause`),
+
+  wikiTree: (kbId: string) =>
+    api.get<WikiTree>(`/knowledge/${kbId}/wiki/tree`),
+  wikiPage: (kbId: string, path: string) =>
+    api.get<{ path: string; content: string }>(`/knowledge/${kbId}/wiki/${path}`),
+  deleteAnalysis: (kbId: string, name: string) =>
+    api.delete(`/knowledge/${kbId}/wiki/analyses/${name}`),
+
+  search: (kbId: string, query: string, mode = 'lambda', top_k = 5) =>
+    api.post<{ query: string; mode: string; results: any[] }>(`/knowledge/${kbId}/search`, {
+      query, mode, top_k,
+    }),
+
+  listJobs: (kbId: string) =>
+    api.get<{ jobs: KBJob[] }>(`/knowledge/${kbId}/jobs`),
+  getJob: (kbId: string, jobId: string) =>
+    api.get<KBJob>(`/knowledge/${kbId}/jobs/${jobId}`),
+
+  listChunks: (kbId: string, params?: { q?: string; source?: string; page?: number; limit?: number }) => {
+    const qs = new URLSearchParams()
+    if (params?.q)      qs.set('q', params.q)
+    if (params?.source) qs.set('source', params.source)
+    if (params?.page)   qs.set('page', String(params.page))
+    if (params?.limit)  qs.set('limit', String(params.limit))
+    const query = qs.toString()
+    return api.get<KBChunkPage>(`/knowledge/${kbId}/chunks${query ? '?' + query : ''}`)
+  },
+}

+ 2 - 1
webui/src/components/Sidebar.tsx

@@ -1,11 +1,12 @@
 import { NavLink, useNavigate } from 'react-router-dom'
-import { LayoutDashboard, Bot, Cpu, LogOut, Zap } from 'lucide-react'
+import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig } from 'lucide-react'
 import { useAppStore } from '../store/app'
 import { clsx } from '../lib/clsx'
 
 const nav = [
   { to: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
   { to: '/agents', label: '智能体', icon: Bot },
+  { to: '/knowledge', label: '知识体', icon: LibraryBig },
   { to: '/settings/providers', label: '模型提供商', icon: Cpu },
 ]
 

+ 164 - 0
webui/src/pages/Knowledge.tsx

@@ -0,0 +1,164 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import { FolderOpen, Plus, Trash2, ChevronRight, Database, FileText, BookOpen, RefreshCw } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { knowledgeApi, type KnowledgeBase } from '../api/knowledge'
+import { PageHeader, Spinner, EmptyState, Button, Badge, Card } from '../components/ui'
+
+function IndexStatusChips({ status }: { status: KnowledgeBase['index_status'] }) {
+  return (
+    <div className="flex flex-wrap gap-1.5 mt-2">
+      <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${status.bm25.exists ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
+        <Database size={10} />
+        BM25 {status.bm25.exists ? `(${status.bm25.total_chunks ?? '?'}chunks)` : '未建'}
+      </span>
+      <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${status.vector.exists ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
+        <FileText size={10} />
+        向量 {status.vector.exists ? '就绪' : '未建'}
+      </span>
+      <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${status.wiki.exists ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-500'}`}>
+        <BookOpen size={10} />
+        Wiki {status.wiki.exists ? `(${status.wiki.total_pages ?? 0}页)` : '未建'}
+      </span>
+    </div>
+  )
+}
+
+function CreateModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
+  const [name, setName] = useState('')
+  const [rootDir, setRootDir] = useState('')
+  const [desc, setDesc] = useState('')
+  const mut = useMutation({
+    mutationFn: () => knowledgeApi.create({ name, root_dir: rootDir, description: desc }),
+    onSuccess: () => { toast.success('知识体创建成功'); onCreated() },
+    onError: (e: any) => toast.error(e.message ?? '创建失败'),
+  })
+  return (
+    <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
+      <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
+        <h3 className="text-base font-semibold mb-4">新建知识体</h3>
+        <div className="space-y-3">
+          <div>
+            <label className="block text-xs font-medium text-gray-700 mb-1">名称 *</label>
+            <input value={name} onChange={e => setName(e.target.value)}
+              placeholder="如:海事法规" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none" />
+          </div>
+          <div>
+            <label className="block text-xs font-medium text-gray-700 mb-1">根目录(绝对路径)*</label>
+            <input value={rootDir} onChange={e => setRootDir(e.target.value)}
+              placeholder="/data/maritime" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none" />
+          </div>
+          <div>
+            <label className="block text-xs font-medium text-gray-700 mb-1">描述(可选)</label>
+            <textarea value={desc} onChange={e => setDesc(e.target.value)} rows={2}
+              className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none" />
+          </div>
+        </div>
+        <div className="flex justify-end gap-2 mt-5">
+          <button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">取消</button>
+          <button
+            onClick={() => mut.mutate()}
+            disabled={!name || !rootDir || mut.isPending}
+            className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
+          >
+            {mut.isPending && <RefreshCw size={14} className="animate-spin" />}
+            创建
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default function Knowledge() {
+  const navigate = useNavigate()
+  const qc = useQueryClient()
+  const [showCreate, setShowCreate] = useState(false)
+
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['knowledge'],
+    queryFn: () => knowledgeApi.list(),
+  })
+
+  const deleteMut = useMutation({
+    mutationFn: (id: string) => knowledgeApi.delete(id),
+    onSuccess: () => { toast.success('已删除'); qc.invalidateQueries({ queryKey: ['knowledge'] }) },
+    onError: (e: any) => toast.error(e.message ?? '删除失败'),
+  })
+
+  const kbs = data?.items ?? []
+
+  return (
+    <div className="p-6">
+      <PageHeader
+        title="知识体管理"
+        description="管理知识库文件、构建索引、编译 Wiki、在线搜索测试"
+        action={
+          <div className="flex gap-2">
+            <Button variant="secondary" size="sm" icon={<RefreshCw size={14} />} onClick={() => refetch()}>刷新</Button>
+            <Button size="sm" icon={<Plus size={14} />} onClick={() => setShowCreate(true)}>新建知识体</Button>
+          </div>
+        }
+      />
+
+      {isLoading && (
+        <div className="flex items-center justify-center h-40"><Spinner /></div>
+      )}
+
+      {!isLoading && kbs.length === 0 && (
+        <EmptyState
+          icon={<FolderOpen size={40} className="text-gray-300" />}
+          title="暂无知识体"
+          description='点击"新建知识体",填写名称和根目录路径即可开始'
+        />
+      )}
+
+      {!isLoading && kbs.length > 0 && (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
+          {kbs.map(kb => (
+            <Card
+              key={kb.id}
+              className="cursor-pointer hover:shadow-md transition-shadow"
+              onClick={() => navigate(`/knowledge/${kb.id}`)}
+            >
+              <div className="flex items-start justify-between">
+                <div className="flex items-center gap-3 min-w-0">
+                  <div className="p-2 bg-indigo-50 rounded-lg shrink-0">
+                    <FolderOpen size={18} className="text-indigo-600" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-sm font-semibold text-gray-900 truncate">{kb.name}</p>
+                    <p className="text-xs text-gray-400 font-mono truncate">{kb.root_dir}</p>
+                  </div>
+                </div>
+                <div className="flex items-center gap-1 shrink-0 ml-2">
+                  <button
+                    onClick={e => { e.stopPropagation(); if (confirm('确认删除此知识体?')) deleteMut.mutate(kb.id) }}
+                    className="text-gray-300 hover:text-red-500 transition-colors p-1"
+                  >
+                    <Trash2 size={14} />
+                  </button>
+                  <ChevronRight size={14} className="text-gray-300" />
+                </div>
+              </div>
+
+              {kb.description && (
+                <p className="text-xs text-gray-500 mt-2 line-clamp-2">{kb.description}</p>
+              )}
+
+              <IndexStatusChips status={kb.index_status} />
+            </Card>
+          ))}
+        </div>
+      )}
+
+      {showCreate && (
+        <CreateModal
+          onClose={() => setShowCreate(false)}
+          onCreated={() => { setShowCreate(false); qc.invalidateQueries({ queryKey: ['knowledge'] }) }}
+        />
+      )}
+    </div>
+  )
+}

+ 1401 - 0
webui/src/pages/KnowledgeDetail.tsx

@@ -0,0 +1,1401 @@
+import { useState, useRef, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import {
+  ArrowLeft, FolderOpen, Database, BookOpen, Search,
+  CheckSquare, Square, RefreshCw, Play, Pause, Trash2,
+  ChevronRight, ChevronDown, FileText, File, AlertCircle,
+  Zap, Layers, Network, CheckCircle, XCircle, Clock,
+  Eye, EyeOff, Folder, Settings2
+} from 'lucide-react'
+import toast from 'react-hot-toast'
+import { knowledgeApi, type KBFile, type KBJob, type WikiPage, type KBDirNode, type KBChunk } from '../api/knowledge'
+import { providersApi, type Provider } from '../api/providers'
+import { Spinner, Button } from '../components/ui'
+import { useAppStore } from '../store/app'
+
+// ─── Job Status Badge ──────────────────────────────────────────
+
+function JobStatusBadge({ status }: { status: KBJob['status'] | string }) {
+  const cfg: Record<string, { icon: React.ReactNode; label: string; cls: string }> = {
+    pending: { icon: <Clock size={12}/>, label: '等待中', cls: 'text-yellow-700 bg-yellow-100' },
+    running: { icon: <RefreshCw size={12} className="animate-spin"/>, label: '运行中', cls: 'text-blue-700 bg-blue-100' },
+    completed: { icon: <CheckCircle size={12}/>, label: '完成', cls: 'text-green-700 bg-green-100' },
+    failed: { icon: <XCircle size={12}/>, label: '失败', cls: 'text-red-700 bg-red-100' },
+  }
+  const c = cfg[status as string] ?? { icon: null, label: status, cls: 'text-gray-600 bg-gray-100' }
+
+  return (
+    <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${c.cls}`}>
+      {c.icon}{c.label}
+    </span>
+  )
+}
+
+// ─── Tab 1: File Manager ───────────────────────────────────────
+
+// Recursive tree node for a single directory
+function DirTreeNode({
+  node,
+  ignoredSet,
+  ancestorIgnored,
+  depth,
+  onToggle,
+  saving,
+}: {
+  node: KBDirNode
+  ignoredSet: Set<string>
+  ancestorIgnored: boolean
+  depth: number
+  onToggle: (path: string, shouldIgnore: boolean) => void
+  saving: boolean
+}) {
+  const [expanded, setExpanded] = useState(depth === 0)
+  const selfIgnored = ignoredSet.has(node.relative_path)
+  const effectivelyIgnored = selfIgnored || ancestorIgnored
+  const hasChildren = node.children.length > 0
+
+  return (
+    <div>
+      <div
+        className={`flex items-center gap-1.5 py-1.5 pr-3 group
+          ${effectivelyIgnored ? 'bg-orange-50/60' : 'hover:bg-gray-50'}`}
+        style={{ paddingLeft: `${12 + depth * 18}px` }}
+      >
+        {/* Expand / collapse arrow */}
+        <button
+          onClick={() => setExpanded(e => !e)}
+          className={`shrink-0 text-gray-400 hover:text-gray-600 transition-transform ${hasChildren ? '' : 'invisible'}`}
+        >
+          {expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
+        </button>
+
+        {/* Folder icon */}
+        <Folder
+          size={14}
+          className={selfIgnored ? 'text-orange-300 shrink-0' : ancestorIgnored ? 'text-gray-300 shrink-0' : 'text-indigo-400 shrink-0'}
+        />
+
+        {/* Dir name */}
+        <span
+          className={`flex-1 text-sm font-mono truncate
+            ${selfIgnored ? 'line-through text-gray-400'
+              : ancestorIgnored ? 'text-gray-400'
+              : 'text-gray-700'}`}
+        >
+          {node.name}/
+        </span>
+
+        {/* Inherited-ignore tag */}
+        {ancestorIgnored && !selfIgnored && (
+          <span className="text-xs text-gray-400 px-1.5">继承</span>
+        )}
+
+        {/* Eye toggle — only when not hidden by an ancestor */}
+        {!ancestorIgnored && (
+          <button
+            onClick={() => onToggle(node.relative_path, !selfIgnored)}
+            disabled={saving}
+            title={selfIgnored ? '恢复显示此目录' : '隐藏此目录(含子目录)'}
+            className={`shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity
+              ${selfIgnored
+                ? 'text-orange-500 hover:bg-orange-100 !opacity-100'
+                : 'text-gray-400 hover:text-gray-700 hover:bg-gray-200'}`}
+          >
+            {selfIgnored ? <Eye size={14} /> : <EyeOff size={14} />}
+          </button>
+        )}
+      </div>
+
+      {/* Children */}
+      {expanded && hasChildren && node.children.map(child => (
+        <DirTreeNode
+          key={child.relative_path}
+          node={child}
+          ignoredSet={ignoredSet}
+          ancestorIgnored={effectivelyIgnored}
+          depth={depth + 1}
+          onToggle={onToggle}
+          saving={saving}
+        />
+      ))}
+    </div>
+  )
+}
+
+// Count all ignored entries (own + descendants)
+function countIgnored(nodes: KBDirNode[], ignoredSet: Set<string>): number {
+  let n = 0
+  for (const node of nodes) {
+    if (ignoredSet.has(node.relative_path)) n++
+    n += countIgnored(node.children, ignoredSet)
+  }
+  return n
+}
+
+// Collect all ignored paths recursively so "全部恢复" works
+function collectIgnoredPaths(nodes: KBDirNode[], ignoredSet: Set<string>, acc: string[] = []): string[] {
+  for (const node of nodes) {
+    if (ignoredSet.has(node.relative_path)) acc.push(node.relative_path)
+    collectIgnoredPaths(node.children, ignoredSet, acc)
+  }
+  return acc
+}
+
+function DirIgnorePanel({
+  dirs,
+  ignoredDirs,
+  onToggle,
+  onClearAll,
+  saving,
+}: {
+  dirs: KBDirNode[]
+  ignoredDirs: string[]
+  onToggle: (path: string, shouldIgnore: boolean) => void
+  onClearAll: () => void
+  saving: boolean
+}) {
+  const [open, setOpen] = useState(false)
+  const ignoredSet = new Set(ignoredDirs)
+  const ignoredCount = countIgnored(dirs, ignoredSet)
+
+  return (
+    <div className="border border-gray-200 rounded-xl overflow-hidden">
+      {/* Header */}
+      <button
+        onClick={() => setOpen(o => !o)}
+        className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-50 hover:bg-gray-100 transition-colors"
+      >
+        <div className="flex items-center gap-2 text-sm font-medium text-gray-700">
+          <Settings2 size={14} className="text-gray-500" />
+          目录过滤
+          {ignoredCount > 0 && (
+            <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs bg-orange-100 text-orange-700 font-medium">
+              <EyeOff size={10} /> {ignoredCount} 已隐藏
+            </span>
+          )}
+        </div>
+        <ChevronDown size={14} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
+      </button>
+
+      {/* Tree */}
+      {open && (
+        <>
+          {dirs.length === 0 ? (
+            <p className="px-4 py-3 text-xs text-gray-400">根目录下无子文件夹</p>
+          ) : (
+            <div className="py-1 max-h-72 overflow-y-auto">
+              {dirs.map(node => (
+                <DirTreeNode
+                  key={node.relative_path}
+                  node={node}
+                  ignoredSet={ignoredSet}
+                  ancestorIgnored={false}
+                  depth={0}
+                  onToggle={onToggle}
+                  saving={saving}
+                />
+              ))}
+            </div>
+          )}
+
+          {ignoredCount > 0 && (
+            <div className="px-4 py-2 bg-orange-50 border-t border-orange-100 flex items-center justify-between">
+              <p className="text-xs text-orange-600">
+                已隐藏的目录不会出现在文件列表中,也不会被加入知识体。
+              </p>
+              <button
+                onClick={onClearAll}
+                disabled={saving}
+                className="text-xs text-orange-700 hover:underline ml-4 shrink-0 disabled:opacity-50"
+              >
+                全部恢复
+              </button>
+            </div>
+          )}
+        </>
+      )}
+    </div>
+  )
+}
+
+function FileManager({ kbId }: { kbId: string }) {
+  const qc = useQueryClient()
+  const [selected, setSelected] = useState<Set<string>>(new Set())
+
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['kb-files', kbId],
+    queryFn: () => knowledgeApi.listFiles(kbId),
+  })
+
+  const addMut = useMutation({
+    mutationFn: (paths: string[]) => knowledgeApi.addFiles(kbId, paths),
+    onSuccess: (r) => {
+      toast.success(`已加入 ${r.added} 个文件`)
+      setSelected(new Set())
+      qc.invalidateQueries({ queryKey: ['kb-files', kbId] })
+    },
+    onError: (e: any) => toast.error(e.message ?? '加入失败'),
+  })
+
+  const ignoreMut = useMutation({
+    mutationFn: (ignored: string[]) => knowledgeApi.updateIgnoreDirs(kbId, ignored),
+    onSuccess: () => qc.invalidateQueries({ queryKey: ['kb-files', kbId] }),
+    onError: (e: any) => toast.error(e.message ?? '保存失败'),
+  })
+
+  const handleToggleDir = (dirPath: string, shouldIgnore: boolean) => {
+    const current = new Set(data?.ignored_dirs ?? [])
+    if (shouldIgnore) current.add(dirPath)
+    else current.delete(dirPath)
+    ignoreMut.mutate(Array.from(current))
+  }
+
+  const handleClearAll = () => ignoreMut.mutate([])
+
+  const files = data?.files ?? []
+  const dirs = data?.dirs ?? []
+  const ignoredDirs = data?.ignored_dirs ?? []
+  const notAdded = files.filter(f => !f.added)
+  const added = files.filter(f => f.added)
+
+  const toggle = (path: string) => {
+    setSelected(s => {
+      const n = new Set(s)
+      n.has(path) ? n.delete(path) : n.add(path)
+      return n
+    })
+  }
+
+  const toggleAll = () => {
+    if (selected.size === notAdded.length) setSelected(new Set())
+    else setSelected(new Set(notAdded.map(f => f.path)))
+  }
+
+  const extIcon = (type: string) => {
+    if (type === 'pdf') return <FileText size={14} className="text-red-400" />
+    if (type === 'md') return <FileText size={14} className="text-blue-400" />
+    return <File size={14} className="text-gray-400" />
+  }
+
+  const fmtSize = (b: number) => b > 1024*1024 ? `${(b/1024/1024).toFixed(1)}MB` : `${(b/1024).toFixed(0)}KB`
+
+  if (isLoading) return <div className="flex justify-center p-8"><Spinner /></div>
+
+  return (
+    <div className="space-y-4">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <p className="text-sm text-gray-600">
+          根目录: <code className="bg-gray-100 px-1 rounded text-xs">{data?.root_dir}</code>
+           共 <b>{files.length}</b> 个文件(<b>{added.length}</b> 已加入 / <b>{notAdded.length}</b> 待加入)
+        </p>
+        <div className="flex gap-2">
+          <Button variant="secondary" size="sm" icon={<RefreshCw size={13} />} onClick={() => refetch()}>刷新</Button>
+          {selected.size > 0 && (
+            <Button size="sm" icon={<Play size={13} />}
+              onClick={() => addMut.mutate(Array.from(selected))}
+              disabled={addMut.isPending}>
+              加入选中 ({selected.size})
+            </Button>
+          )}
+          {notAdded.length > 0 && (
+            <Button size="sm" variant="secondary"
+              onClick={() => addMut.mutate(notAdded.map(f => f.path))}
+              disabled={addMut.isPending}>
+              一键加入全部
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* Directory ignore panel */}
+      {dirs.length > 0 && (
+        <DirIgnorePanel
+          dirs={dirs}
+          ignoredDirs={ignoredDirs}
+          onToggle={handleToggleDir}
+          onClearAll={handleClearAll}
+          saving={ignoreMut.isPending}
+        />
+      )}
+
+      {/* Already added */}
+      {added.length > 0 && (
+        <div className="bg-green-50 border border-green-200 rounded-lg overflow-hidden">
+          <div className="px-4 py-2 bg-green-100 text-xs font-semibold text-green-700">
+            已加入知识体 ({added.length})
+          </div>
+          <div className="divide-y divide-green-100 max-h-48 overflow-y-auto">
+            {added.map(f => (
+              <div key={f.path} className="flex items-center gap-2 px-4 py-2 text-sm">
+                {extIcon(f.type)}
+                <span className="flex-1 truncate text-gray-700">{f.relative_path}</span>
+                <span className="text-xs text-gray-400">{fmtSize(f.size)}</span>
+                <CheckCircle size={14} className="text-green-500" />
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Not yet added */}
+      {notAdded.length > 0 && (
+        <div className="border border-gray-200 rounded-lg overflow-hidden">
+          <div className="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-600 flex items-center gap-2">
+            <button onClick={toggleAll} className="text-indigo-600 hover:text-indigo-800">
+              {selected.size === notAdded.length ? <CheckSquare size={14}/> : <Square size={14}/>}
+            </button>
+            待加入 ({notAdded.length})
+          </div>
+          <div className="divide-y divide-gray-100 max-h-80 overflow-y-auto">
+            {notAdded.map(f => (
+              <div
+                key={f.path}
+                onClick={() => toggle(f.path)}
+                className={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer hover:bg-gray-50 ${selected.has(f.path) ? 'bg-indigo-50' : ''}`}
+              >
+                <button onClick={e => { e.stopPropagation(); toggle(f.path) }}>
+                  {selected.has(f.path) ? <CheckSquare size={14} className="text-indigo-500"/> : <Square size={14} className="text-gray-300"/>}
+                </button>
+                {extIcon(f.type)}
+                <span className="flex-1 truncate text-gray-700">{f.relative_path}</span>
+                <span className="text-xs text-gray-400">{fmtSize(f.size)}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {files.length === 0 && !isLoading && (
+        <div className="text-center py-10 text-gray-400">
+          <FolderOpen size={32} className="mx-auto mb-2" />
+          <p className="text-sm">
+            {ignoredDirs.length > 0
+              ? `根目录中未找到文件(${ignoredDirs.length} 个目录已隐藏,展开"目录过滤"可恢复)`
+              : '根目录中没有找到支持的文件(PDF/TXT/DOCX/MD/HTML/CSV)'}
+          </p>
+        </div>
+      )}
+    </div>
+  )
+}
+
+// ─── Chunk Browser ────────────────────────────────────────────
+
+function ChunkBrowser({ kbId }: { kbId: string }) {
+  const [open, setOpen] = useState(false)
+  const [q, setQ] = useState('')
+  const [srcFilter, setSrcFilter] = useState('')
+  const [page, setPage] = useState(1)
+  const [expandedId, setExpandedId] = useState<string | null>(null)
+  const [inputQ, setInputQ] = useState('')
+  const [inputSrc, setInputSrc] = useState('')
+
+  const { data, isLoading, isFetching } = useQuery({
+    queryKey: ['kb-chunks', kbId, q, srcFilter, page],
+    queryFn: () => knowledgeApi.listChunks(kbId, { q, source: srcFilter, page, limit: 20 }),
+    enabled: open,
+    keepPreviousData: true,
+  } as any)
+
+  const result = data as any
+
+  const doSearch = () => {
+    setQ(inputQ)
+    setSrcFilter(inputSrc)
+    setPage(1)
+    setExpandedId(null)
+  }
+
+  const srcColor = (src: string) => {
+    // Deterministic pastel color from source name hash
+    let h = 0
+    for (let i = 0; i < src.length; i++) h = (h * 31 + src.charCodeAt(i)) & 0xffff
+    const hue = h % 360
+    return `hsl(${hue},55%,88%)`
+  }
+
+  return (
+    <div className="border border-gray-200 rounded-xl overflow-hidden">
+      {/* Header toggle */}
+      <button
+        onClick={() => setOpen(o => !o)}
+        className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-50 hover:bg-gray-100 transition-colors"
+      >
+        <div className="flex items-center gap-2 text-sm font-medium text-gray-700">
+          <Search size={14} className="text-gray-500" />
+          分块浏览
+          {result?.total_all !== undefined && (
+            <span className="text-xs text-gray-400 font-normal">共 {result.total_all.toLocaleString()} 个 chunk</span>
+          )}
+        </div>
+        <ChevronDown size={14} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
+      </button>
+
+      {open && (
+        <div className="p-4 space-y-3">
+          {/* Search bar */}
+          <div className="flex gap-2">
+            <input
+              value={inputQ}
+              onChange={e => setInputQ(e.target.value)}
+              onKeyDown={e => e.key === 'Enter' && doSearch()}
+              placeholder="关键词过滤文本内容..."
+              className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:ring-2 focus:ring-indigo-400 outline-none"
+            />
+            <input
+              value={inputSrc}
+              onChange={e => setInputSrc(e.target.value)}
+              onKeyDown={e => e.key === 'Enter' && doSearch()}
+              placeholder="文件名..."
+              className="w-32 border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:ring-2 focus:ring-indigo-400 outline-none"
+            />
+            <Button size="sm" onClick={doSearch} icon={<Search size={12}/>}>搜索</Button>
+            {(q || srcFilter) && (
+              <button onClick={() => { setQ(''); setSrcFilter(''); setInputQ(''); setInputSrc(''); setPage(1) }}
+                className="text-xs text-gray-400 hover:text-gray-700 px-2">清除</button>
+            )}
+          </div>
+
+          {/* Result count */}
+          {result && (q || srcFilter) && (
+            <p className="text-xs text-gray-500">
+              过滤结果:<b>{result.total.toLocaleString()}</b> 个 chunk(共 {result.total_all.toLocaleString()})
+            </p>
+          )}
+
+          {/* Chunk list */}
+          {isLoading ? (
+            <div className="flex justify-center py-6"><Spinner /></div>
+          ) : (
+            <div className="space-y-2">
+              {result?.chunks?.map((c: KBChunk) => {
+                const isExpanded = expandedId === c.id
+                return (
+                  <div key={c.id} className="border border-gray-100 rounded-lg overflow-hidden hover:border-gray-300 transition-colors">
+                    {/* Chunk header */}
+                    <button
+                      onClick={() => setExpandedId(isExpanded ? null : c.id)}
+                      className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50"
+                    >
+                      <span
+                        className="shrink-0 px-1.5 py-0.5 rounded text-xs font-mono font-medium"
+                        style={{ backgroundColor: srcColor(c.source), color: '#374151' }}
+                      >
+                        {c.source.length > 20 ? '…' + c.source.slice(-20) : c.source}
+                      </span>
+                      <span className="text-xs text-gray-400 shrink-0">#{c.chunk_id}</span>
+                      <span className="text-xs text-gray-400 shrink-0">{c.length}字</span>
+                      <span className="flex-1 text-xs text-gray-600 truncate">
+                        {c.text.slice(0, 100)}
+                      </span>
+                      {isExpanded
+                        ? <ChevronDown size={12} className="text-gray-400 shrink-0"/>
+                        : <ChevronRight size={12} className="text-gray-400 shrink-0"/>}
+                    </button>
+                    {/* Expanded full text */}
+                    {isExpanded && (
+                      <div className="px-3 pb-3 pt-1 bg-gray-50 border-t border-gray-100">
+                        <pre className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed font-sans">{c.text}</pre>
+                      </div>
+                    )}
+                  </div>
+                )
+              })}
+              {result?.chunks?.length === 0 && (
+                <p className="text-center py-6 text-xs text-gray-400">未找到匹配的 chunk</p>
+              )}
+            </div>
+          )}
+
+          {/* Pagination */}
+          {result && result.pages > 1 && (
+            <div className="flex items-center justify-center gap-2 pt-1">
+              <button disabled={page === 1} onClick={() => setPage(p => p - 1)}
+                className="px-2 py-1 text-xs border border-gray-200 rounded disabled:opacity-40 hover:bg-gray-50">
+                ← 上一页
+              </button>
+              <span className="text-xs text-gray-500">{page} / {result.pages}</span>
+              <button disabled={page >= result.pages} onClick={() => setPage(p => p + 1)}
+                className="px-2 py-1 text-xs border border-gray-200 rounded disabled:opacity-40 hover:bg-gray-50">
+                下一页 →
+              </button>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+// ─── Shared: Log Terminal ──────────────────────────────────────
+
+function LogTerminal({ log, running, maxH = 'max-h-72' }: { log: string; running: boolean; maxH?: string }) {
+  const bottomRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    if (running) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+  }, [log, running])
+
+  if (!log) return null
+  return (
+    <div className={`relative bg-gray-950 rounded-lg overflow-auto ${maxH}`}>
+      <pre className="text-green-400 text-xs p-3 whitespace-pre-wrap font-mono leading-relaxed">{log}</pre>
+      {running && (
+        <span className="absolute bottom-2 right-3 text-green-500 text-xs animate-pulse">● 运行中</span>
+      )}
+      <div ref={bottomRef} />
+    </div>
+  )
+}
+
+// ─── Tab 2: Index Builder ──────────────────────────────────────
+
+function IndexStats({ kbId }: { kbId: string }) {
+  const { data: kb, refetch } = useQuery({
+    queryKey: ['kb-detail', kbId],
+    queryFn: () => knowledgeApi.get(kbId),
+    refetchInterval: 5000,
+  })
+  const s = kb?.index_status
+  if (!s) return null
+
+  const fmtSize = (bytes?: number) =>
+    bytes ? bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB` : ''
+
+  return (
+    <div className="grid grid-cols-3 gap-3 mb-4">
+      {/* BM25 */}
+      <div className={`rounded-xl border p-3 ${s.bm25.exists ? 'border-blue-200 bg-blue-50' : 'border-gray-200 bg-gray-50'}`}>
+        <div className="flex items-center gap-1.5 mb-1.5">
+          <Database size={13} className={s.bm25.exists ? 'text-blue-500' : 'text-gray-400'} />
+          <span className="text-xs font-semibold text-gray-700">BM25 关键词</span>
+          {s.bm25.exists
+            ? <CheckCircle size={11} className="text-green-500 ml-auto" />
+            : <XCircle size={11} className="text-gray-300 ml-auto" />}
+        </div>
+        {s.bm25.exists ? (
+          <div className="space-y-0.5">
+            <p className="text-sm font-bold text-blue-700">{s.bm25.total_chunks?.toLocaleString() ?? '—'} chunks</p>
+            <p className="text-xs text-gray-500">{s.bm25.total_files?.toLocaleString()} 个文件</p>
+          </div>
+        ) : (
+          <p className="text-xs text-gray-400">未构建</p>
+        )}
+      </div>
+
+      {/* Vector */}
+      <div className={`rounded-xl border p-3 ${s.vector.exists ? 'border-purple-200 bg-purple-50' : 'border-gray-200 bg-gray-50'}`}>
+        <div className="flex items-center gap-1.5 mb-1.5">
+          <Layers size={13} className={s.vector.exists ? 'text-purple-500' : 'text-gray-400'} />
+          <span className="text-xs font-semibold text-gray-700">向量语义</span>
+          {s.vector.exists
+            ? <CheckCircle size={11} className="text-green-500 ml-auto" />
+            : <XCircle size={11} className="text-gray-300 ml-auto" />}
+        </div>
+        {s.vector.exists
+          ? <p className="text-xs text-gray-500">已构建</p>
+          : <p className="text-xs text-gray-400">未构建</p>}
+      </div>
+
+      {/* Wiki */}
+      <div className={`rounded-xl border p-3 ${s.wiki.exists ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'}`}>
+        <div className="flex items-center gap-1.5 mb-1.5">
+          <Network size={13} className={s.wiki.exists ? 'text-green-500' : 'text-gray-400'} />
+          <span className="text-xs font-semibold text-gray-700">Wiki 图谱</span>
+          {s.wiki.exists
+            ? <CheckCircle size={11} className="text-green-500 ml-auto" />
+            : <XCircle size={11} className="text-gray-300 ml-auto" />}
+        </div>
+        {s.wiki.exists ? (
+          <div className="space-y-0.5">
+            <p className="text-sm font-bold text-green-700">{s.wiki.total_pages} 页</p>
+            {s.wiki.subdirs && (
+              <p className="text-xs text-gray-500">
+                {Object.entries(s.wiki.subdirs).filter(([,v]) => v > 0).map(([k,v]) => `${k}:${v}`).join(' · ')}
+              </p>
+            )}
+          </div>
+        ) : (
+          <p className="text-xs text-gray-400">未编译</p>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function IndexBuilder({ kbId }: { kbId: string }) {
+  const [activeLog, setActiveLog] = useState<string | null>(null)
+  const qc = useQueryClient()
+
+  const { data: jobsData, refetch: refetchJobs } = useQuery({
+    queryKey: ['kb-jobs', kbId],
+    queryFn: () => knowledgeApi.listJobs(kbId),
+    refetchInterval: 2000,
+  })
+
+  // Poll active job for live log
+  const jobs = jobsData?.jobs ?? []
+  const runningJob = jobs.find(j => j.status === 'running')
+
+  const { data: liveJob } = useQuery({
+    queryKey: ['kb-job-live', kbId, runningJob?.id],
+    queryFn: () => knowledgeApi.getJob(kbId, runningJob!.id),
+    enabled: !!runningJob,
+    refetchInterval: 1500,
+  })
+
+  const buildMut = useMutation({
+    mutationFn: ({ type, mode }: { type: string; mode: string }) =>
+      knowledgeApi.buildIndex(kbId, type, mode),
+    onSuccess: (r) => {
+      toast.success(`已触发构建 (Job: ${r.job_id})`)
+      setActiveLog(r.job_id)
+      qc.invalidateQueries({ queryKey: ['kb-jobs', kbId] })
+      qc.invalidateQueries({ queryKey: ['kb-detail', kbId] })
+    },
+    onError: (e: any) => toast.error(e.message ?? '触发失败'),
+  })
+
+  const latestByType = (type: string) =>
+    jobs.find(j => j.job_type.startsWith(`index:${type}`) || j.job_type === type)
+
+  const indexTypes = [
+    { key: 'bm25', label: 'BM25 关键词索引', desc: '轻量、无需 GPU,适合精确关键词检索', icon: <Database size={18} className="text-blue-500"/> },
+    { key: 'vector', label: '向量语义索引', desc: '需要 bge-m3 嵌入服务,支持语义搜索', icon: <Layers size={18} className="text-purple-500"/> },
+    { key: 'graph', label: '图谱索引', desc: '从 Wiki 编译结果构建实体关系图', icon: <Network size={18} className="text-green-500"/> },
+  ]
+
+  // Determine which log to show for a given job id
+  const getDisplayLog = (jobId: string) => {
+    if (liveJob?.id === jobId) return liveJob.log
+    return jobs.find(j => j.id === jobId)?.log ?? ''
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Index status cards */}
+      <IndexStats kbId={kbId} />
+
+      <div className="flex justify-end">
+        <Button variant="secondary" size="sm" icon={<RefreshCw size={13}/>}
+          onClick={() => { refetchJobs(); qc.invalidateQueries({ queryKey: ['kb-detail', kbId] }) }}>
+          刷新状态
+        </Button>
+      </div>
+
+      {indexTypes.map(({ key, label, desc, icon }) => {
+        const latest = latestByType(key)
+        const isRunning = latest?.status === 'running'
+        const logVisible = activeLog === latest?.id
+        const displayLog = latest ? getDisplayLog(latest.id) : ''
+
+        return (
+          <div key={key} className="border border-gray-200 rounded-xl p-4">
+            <div className="flex items-start justify-between gap-4">
+              <div className="flex items-start gap-3">
+                <div className="mt-0.5">{icon}</div>
+                <div>
+                  <p className="text-sm font-semibold text-gray-800">{label}</p>
+                  <p className="text-xs text-gray-500 mt-0.5">{desc}</p>
+                  {latest && (
+                    <div className="flex items-center gap-2 mt-1.5">
+                      <JobStatusBadge status={latest.status}/>
+                      {latest.created_at && (
+                        <span className="text-xs text-gray-400">{latest.created_at.slice(0, 16)}</span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              </div>
+              <div className="flex gap-2 shrink-0">
+                <Button size="sm" variant="secondary"
+                  onClick={() => buildMut.mutate({ type: key, mode: 'incremental' })}
+                  disabled={buildMut.isPending || isRunning}>
+                  增量更新
+                </Button>
+                <Button size="sm"
+                  onClick={() => buildMut.mutate({ type: key, mode: 'full' })}
+                  disabled={buildMut.isPending || isRunning}
+                  icon={<Play size={12}/>}>
+                  全量构建
+                </Button>
+              </div>
+            </div>
+
+            {latest && (
+              <div className="mt-3 space-y-2">
+                {/* Log toggle */}
+                {displayLog && (
+                  <button
+                    onClick={() => setActiveLog(logVisible ? null : latest.id)}
+                    className="text-xs text-indigo-600 hover:underline flex items-center gap-1"
+                  >
+                    {logVisible ? <ChevronDown size={12}/> : <ChevronRight size={12}/>}
+                    {isRunning ? '查看实时日志' : '查看日志'}
+                    {isRunning && <RefreshCw size={10} className="text-indigo-400 animate-spin ml-1"/>}
+                  </button>
+                )}
+                {logVisible && <LogTerminal log={displayLog} running={isRunning} />}
+
+                {latest.error && (
+                  <div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 rounded p-2">
+                    <AlertCircle size={12}/> {latest.error}
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+        )
+      })}
+
+      {/* Chunk browser — only show when BM25 index exists */}
+      <ChunkBrowser kbId={kbId} />
+    </div>
+  )
+}
+
+// ─── Tab 3: Wiki Browser ───────────────────────────────────────
+
+function WikiBrowser({ kbId }: { kbId: string }) {
+  const qc = useQueryClient()
+  const [selectedPage, setSelectedPage] = useState<string | null>(null)
+  const [pageContent, setPageContent] = useState('')
+  const [pageLoading, setPageLoading] = useState(false)
+  const [concurrency, setConcurrency] = useState(2)
+  const [wikiMode, setWikiMode] = useState<'incremental' | 'rebuild'>('incremental')
+
+  const { data: tree, isLoading: treeLoading, refetch: refetchTree } = useQuery({
+    queryKey: ['kb-wiki-tree', kbId],
+    queryFn: () => knowledgeApi.wikiTree(kbId),
+  })
+
+  const [showLog, setShowLog] = useState(false)
+
+  const { data: jobsData } = useQuery({
+    queryKey: ['kb-jobs', kbId],
+    queryFn: () => knowledgeApi.listJobs(kbId),
+    refetchInterval: 2000,
+  })
+
+  const wikiJob = jobsData?.jobs.find(j => j.job_type === 'wiki')
+  const isRunning = wikiJob?.status === 'running'
+
+  // Poll live job log when running
+  const { data: liveWikiJob } = useQuery({
+    queryKey: ['kb-wiki-live', kbId, wikiJob?.id],
+    queryFn: () => knowledgeApi.getJob(kbId, wikiJob!.id),
+    enabled: !!wikiJob && isRunning,
+    refetchInterval: 1500,
+  })
+
+  const displayLog = (liveWikiJob ?? wikiJob)?.log ?? ''
+
+  // Auto-show log when job starts
+  useEffect(() => {
+    if (isRunning) setShowLog(true)
+  }, [isRunning])
+
+  const compileMut = useMutation({
+    mutationFn: () => knowledgeApi.buildWiki(kbId, wikiMode, concurrency),
+    onSuccess: () => {
+      toast.success('Wiki 编译已启动')
+      setShowLog(true)
+      qc.invalidateQueries({ queryKey: ['kb-jobs', kbId] })
+    },
+    onError: (e: any) => toast.error(e.message ?? '启动失败'),
+  })
+
+  const pauseMut = useMutation({
+    mutationFn: () => knowledgeApi.pauseWiki(kbId),
+    onSuccess: (r) => toast.success(r.action === 'paused' ? '已暂停' : '已恢复'),
+  })
+
+  const delAnalysisMut = useMutation({
+    mutationFn: (name: string) => knowledgeApi.deleteAnalysis(kbId, name),
+    onSuccess: () => { toast.success('已删除'); refetchTree() },
+  })
+
+  const loadPage = async (path: string) => {
+    setSelectedPage(path)
+    setPageLoading(true)
+    try {
+      const r = await knowledgeApi.wikiPage(kbId, path)
+      setPageContent(r.content)
+    } catch {
+      setPageContent('页面加载失败')
+    } finally {
+      setPageLoading(false)
+    }
+  }
+
+  const fmtSize = (b: number) => b > 1024 ? `${(b/1024).toFixed(1)}K` : `${b}B`
+
+  const PageList = ({ pages, cat }: { pages: WikiPage[]; cat: string }) => (
+    <div className="mb-3">
+      <p className="text-xs font-semibold text-gray-500 uppercase px-2 mb-1">{cat} ({pages.length})</p>
+      <div className="space-y-0.5">
+        {pages.map(p => (
+          <button key={p.path}
+            onClick={() => loadPage(p.path)}
+            className={`w-full text-left flex items-center justify-between px-2 py-1.5 rounded text-xs hover:bg-gray-100 ${selectedPage === p.path ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-700'}`}
+          >
+            <span className="truncate">{p.name}</span>
+            <div className="flex items-center gap-1 shrink-0">
+              <span className="text-gray-400">{fmtSize(p.size)}</span>
+              {cat === 'analyses' && (
+                <button onClick={e => { e.stopPropagation(); delAnalysisMut.mutate(p.name) }}
+                  className="text-gray-300 hover:text-red-500 ml-1"><Trash2 size={11}/></button>
+              )}
+            </div>
+          </button>
+        ))}
+      </div>
+    </div>
+  )
+
+  return (
+    <div className="space-y-4">
+      {/* Compile panel */}
+      <div className="border border-gray-200 rounded-xl p-4">
+        <div className="flex items-center justify-between mb-3">
+          <div>
+            <p className="text-sm font-semibold text-gray-800">Wiki 编译控制</p>
+            <p className="text-xs text-gray-500 mt-0.5">LLM 逐文档阅读生成 sources/entities/topics 三层知识页面</p>
+          </div>
+          {wikiJob && <JobStatusBadge status={wikiJob.status}/>}
+        </div>
+        <div className="flex items-center gap-4 mb-3">
+          <div className="flex items-center gap-2">
+            <label className="text-xs text-gray-600">模式:</label>
+            <select value={wikiMode} onChange={e => setWikiMode(e.target.value as 'incremental' | 'rebuild')}
+              className="text-xs border border-gray-300 rounded px-2 py-1">
+              <option value="incremental">增量(仅新文档)</option>
+              <option value="rebuild">全量重建</option>
+            </select>
+          </div>
+          <div className="flex items-center gap-2">
+            <label className="text-xs text-gray-600">并发数:</label>
+            <input type="number" min={1} max={8} value={concurrency}
+              onChange={e => setConcurrency(Number(e.target.value))}
+              className="w-16 text-xs border border-gray-300 rounded px-2 py-1" />
+          </div>
+        </div>
+        <div className="flex gap-2 flex-wrap">
+          <Button size="sm" icon={<Play size={12}/>}
+            onClick={() => compileMut.mutate()} disabled={compileMut.isPending || isRunning}>
+            开始编译
+          </Button>
+          <Button size="sm" variant="secondary" icon={<Pause size={12}/>}
+            onClick={() => pauseMut.mutate()}>
+            暂停/恢复
+          </Button>
+          <Button size="sm" variant="secondary" icon={<RefreshCw size={13}/>}
+            onClick={() => { refetchTree(); qc.invalidateQueries({ queryKey: ['kb-jobs', kbId] }) }}>
+            刷新
+          </Button>
+          {displayLog && (
+            <button
+              onClick={() => setShowLog(v => !v)}
+              className="text-xs text-indigo-600 hover:underline flex items-center gap-1 ml-auto"
+            >
+              {showLog ? <ChevronDown size={12}/> : <ChevronRight size={12}/>}
+              {isRunning ? '实时日志' : '查看日志'}
+              {isRunning && <RefreshCw size={10} className="animate-spin text-indigo-400 ml-1"/>}
+            </button>
+          )}
+        </div>
+
+        {/* Live / historical log terminal */}
+        {showLog && displayLog && (
+          <div className="mt-3">
+            <LogTerminal log={displayLog} running={isRunning} maxH="max-h-64" />
+          </div>
+        )}
+
+        {wikiJob?.error && (
+          <div className="mt-2 flex items-center gap-2 text-xs text-red-600 bg-red-50 rounded p-2">
+            <AlertCircle size={12}/> {wikiJob.error}
+          </div>
+        )}
+      </div>
+
+      {/* Wiki browser */}
+      {treeLoading ? (
+        <div className="flex justify-center p-4"><Spinner /></div>
+      ) : !tree?.sources.length && !tree?.entities.length && !tree?.topics.length ? (
+        <div className="text-center py-8 text-gray-400">
+          <BookOpen size={28} className="mx-auto mb-2"/>
+          <p className="text-sm">Wiki 尚未生成。点击"开始编译"后,LLM 将为每个文档生成知识页面。</p>
+        </div>
+      ) : (
+        <div className="flex gap-4 min-h-[400px]">
+          {/* Sidebar */}
+          <div className="w-56 shrink-0 border border-gray-200 rounded-xl overflow-y-auto p-2">
+            {tree?.index_content && (
+              <button
+                onClick={() => setPageContent(tree.index_content)}
+                className={`w-full text-left px-2 py-1.5 rounded text-xs mb-2 hover:bg-gray-100 ${!selectedPage ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-700'}`}
+              >
+                📋 index.md
+              </button>
+            )}
+            {tree?.sources && <PageList pages={tree.sources} cat="sources" />}
+            {tree?.entities && <PageList pages={tree.entities} cat="entities" />}
+            {tree?.topics && <PageList pages={tree.topics} cat="topics" />}
+            {tree?.analyses && tree.analyses.length > 0 && <PageList pages={tree.analyses} cat="analyses" />}
+          </div>
+          {/* Content */}
+          <div className="flex-1 border border-gray-200 rounded-xl p-4 overflow-y-auto">
+            {pageLoading ? (
+              <div className="flex justify-center p-8"><Spinner /></div>
+            ) : pageContent ? (
+              <pre className="text-sm text-gray-700 whitespace-pre-wrap font-sans leading-relaxed">{pageContent}</pre>
+            ) : (
+              <div className="text-center py-8 text-gray-400 text-sm">←  选择左侧页面查看内容</div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+// ─── Tab 4: Search Test ────────────────────────────────────────
+
+interface QAState {
+  answer: string
+  chunks: any[]
+  status: string
+  streaming: boolean
+  showSources: boolean
+}
+
+function emptyQA(): QAState {
+  return { answer: '', chunks: [], status: '', streaming: false, showSources: false }
+}
+
+function QAPanel({
+  state, onToggleSources, mode,
+}: {
+  state: QAState
+  onToggleSources: () => void
+  mode: string
+}) {
+  const answerRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    if (state.streaming) answerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
+  }, [state.answer, state.streaming])
+
+  const modeLabel: Record<string, string> = {
+    bm25: 'BM25', lambda: 'LambdaRAG', vector: '向量', wiki: 'Wiki',
+  }
+
+  return (
+    <div className="space-y-3">
+      {/* Status line */}
+      {state.status && !state.answer && (
+        <p className="text-xs text-gray-400 flex items-center gap-1">
+          {state.streaming && <RefreshCw size={10} className="animate-spin"/>}
+          {state.status}
+        </p>
+      )}
+
+      {/* Answer box */}
+      {(state.answer || state.streaming) && (
+        <div className="border border-indigo-200 bg-indigo-50 rounded-xl p-4">
+          <div className="flex items-center gap-2 mb-2">
+            <Zap size={13} className="text-indigo-500 shrink-0"/>
+            <span className="text-xs font-semibold text-indigo-700">{modeLabel[mode] ?? mode} 回答</span>
+            {state.streaming && <RefreshCw size={11} className="text-indigo-400 animate-spin"/>}
+          </div>
+          <div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap" ref={answerRef}>
+            {state.answer}{state.streaming && <span className="animate-pulse">▌</span>}
+          </div>
+        </div>
+      )}
+
+      {/* Source chunks — collapsible */}
+      {state.chunks.length > 0 && (
+        <div className="border border-gray-200 rounded-xl overflow-hidden">
+          <button
+            onClick={onToggleSources}
+            className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 text-xs font-medium text-gray-600"
+          >
+            <span className="flex items-center gap-1.5">
+              <FileText size={12}/> 参考来源({state.chunks.length} 个 chunk)
+            </span>
+            {state.showSources ? <ChevronDown size={12}/> : <ChevronRight size={12}/>}
+          </button>
+          {state.showSources && (
+            <div className="divide-y divide-gray-100 max-h-80 overflow-y-auto">
+              {state.chunks.map((c: any, i: number) => (
+                <div key={i} className="px-3 py-2.5">
+                  <div className="flex items-center gap-2 mb-1">
+                    <span className="text-xs font-semibold text-indigo-600 truncate">[来源{i+1}] {c.source}</span>
+                    {c.score !== undefined && (
+                      <span className="text-xs bg-gray-100 text-gray-500 px-1.5 rounded ml-auto shrink-0">
+                        {typeof c.score === 'number' ? c.score.toFixed(3) : c.score}
+                      </span>
+                    )}
+                  </div>
+                  <p className="text-xs text-gray-600 leading-relaxed line-clamp-3">{c.text}</p>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Empty state */}
+      {!state.answer && !state.streaming && state.chunks.length === 0 && !state.status && (
+        <div className="text-center py-10 text-gray-400 text-sm">输入问题后点击搜索</div>
+      )}
+    </div>
+  )
+}
+
+// ── Provider + Model Selector ──────────────────────────────────
+
+interface LLMSelector {
+  providerId: string
+  model: string
+}
+
+function ProviderModelSelect({
+  value,
+  onChange,
+  providers,
+  compact,
+}: {
+  value: LLMSelector
+  onChange: (v: LLMSelector) => void
+  providers: Provider[]
+  compact?: boolean
+}) {
+  const pdef = providers.find(p => p.id === value.providerId)
+  const models = pdef?.models ?? []
+
+  return (
+    <div className={`flex items-center gap-1.5 ${compact ? '' : 'flex-wrap'}`}>
+      <label className="text-xs text-gray-500 whitespace-nowrap">LLM:</label>
+      <select
+        value={value.providerId}
+        onChange={e => {
+          const np = providers.find(p => p.id === e.target.value)
+          onChange({ providerId: e.target.value, model: np?.models[0] ?? '' })
+        }}
+        className="text-xs border border-gray-300 rounded px-2 py-1 bg-white max-w-[160px]"
+      >
+        {providers.filter(p => p.configured).map(p => (
+          <option key={p.id} value={p.id}>{p.name}</option>
+        ))}
+        {providers.filter(p => p.configured).length === 0 && (
+          <option value="">— 未配置提供商 —</option>
+        )}
+      </select>
+      {models.length > 0 && (
+        <select
+          value={value.model}
+          onChange={e => onChange({ ...value, model: e.target.value })}
+          className="text-xs border border-gray-300 rounded px-2 py-1 bg-white max-w-[180px]"
+        >
+          {models.map(m => <option key={m} value={m}>{m}</option>)}
+        </select>
+      )}
+    </div>
+  )
+}
+
+function SearchTest({ kbId }: { kbId: string }) {
+  const [query, setQuery] = useState('')
+  const [leftMode, setLeftMode] = useState('bm25')
+  const [rightMode, setRightMode] = useState('bm25')
+  const [topK, setTopK] = useState(5)
+  const [abMode, setAbMode] = useState(false)
+  const [left, setLeft] = useState<QAState>(emptyQA())
+  const [right, setRight] = useState<QAState>(emptyQA())
+  // Use AbortController instead of EventSource for proper auth support
+  const leftAbortRef = useRef<AbortController | null>(null)
+  const rightAbortRef = useRef<AbortController | null>(null)
+
+  // ── Provider / Model state ──
+  const [providers, setProviders] = useState<Provider[]>([])
+  const [leftLLM, setLeftLLM] = useState<LLMSelector>({ providerId: 'ollama', model: '' })
+  const [rightLLM, setRightLLM] = useState<LLMSelector>({ providerId: 'ollama', model: '' })
+
+  const apiKey = useAppStore(s => s.apiKey)
+
+  useEffect(() => {
+    providersApi.list().then(res => {
+      const configured = res.providers.filter(p => p.configured && p.id !== 'claude-code')
+      setProviders(configured)
+      if (configured.length > 0) {
+        const def = configured.find(p => p.id === 'ollama') ?? configured[0]
+        setLeftLLM({ providerId: def.id, model: def.models[0] ?? '' })
+        setRightLLM({ providerId: def.id, model: def.models[0] ?? '' })
+      }
+    }).catch(() => {})
+  }, [])
+
+  const modes = [
+    { value: 'bm25', label: 'BM25 关键词' },
+    { value: 'lambda', label: 'LambdaRAG 四路融合' },
+    { value: 'vector', label: '向量语义' },
+    { value: 'wiki', label: 'Wiki 全文' },
+  ]
+
+  const startQA = (side: 'left' | 'right', mode: string) => {
+    if (!query.trim()) return
+    const abortRef = side === 'left' ? leftAbortRef : rightAbortRef
+    const setState = side === 'left' ? setLeft : setRight
+    const llm = side === 'left' ? leftLLM : rightLLM
+
+    // Cancel previous request
+    abortRef.current?.abort()
+    const ctrl = new AbortController()
+    abortRef.current = ctrl
+
+    setState(emptyQA())
+    setState(s => ({ ...s, streaming: true, status: '正在检索...' }))
+
+    const params = new URLSearchParams({
+      q: query,
+      mode,
+      top_k: String(topK),
+      provider_id: llm.providerId,
+      model: llm.model,
+    })
+    const url = `/api/v1/knowledge/${kbId}/search/stream?${params}`
+
+    // Use fetch instead of EventSource to support Authorization header
+    ;(async () => {
+      try {
+        const res = await fetch(url, {
+          headers: { Authorization: apiKey ? `Bearer ${apiKey}` : '' },
+          signal: ctrl.signal,
+        })
+        if (!res.ok) {
+          setState(s => ({ ...s, streaming: false, status: `错误 ${res.status}` }))
+          return
+        }
+        const reader = res.body!.getReader()
+        const decoder = new TextDecoder()
+        let buf = ''
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+          buf += decoder.decode(value, { stream: true })
+          // Process complete SSE lines
+          const lines = buf.split('\n')
+          buf = lines.pop() ?? ''
+          for (const line of lines) {
+            if (!line.startsWith('data: ')) continue
+            const payload = line.slice(6).trim()
+            if (!payload) continue
+            try {
+              const d = JSON.parse(payload)
+              if (d.type === 'status') {
+                setState(s => ({ ...s, status: d.text }))
+              } else if (d.type === 'chunks') {
+                setState(s => ({ ...s, chunks: d.chunks ?? [], status: '' }))
+              } else if (d.type === 'chunk') {
+                setState(s => ({ ...s, answer: s.answer + d.text, status: '' }))
+              } else if (d.type === 'done') {
+                setState(s => ({ ...s, streaming: false, status: '' }))
+              }
+            } catch { /* ignore malformed */ }
+          }
+        }
+        setState(s => ({ ...s, streaming: false }))
+      } catch (err: unknown) {
+        if ((err as { name?: string })?.name === 'AbortError') return
+        setState(s => ({ ...s, streaming: false, status: '连接错误' }))
+      }
+    })()
+  }
+
+  const stopQA = (side: 'left' | 'right') => {
+    const abortRef = side === 'left' ? leftAbortRef : rightAbortRef
+    const setState = side === 'left' ? setLeft : setRight
+    abortRef.current?.abort()
+    abortRef.current = null
+    setState(s => ({ ...s, streaming: false }))
+  }
+
+  const doSearch = () => {
+    startQA('left', leftMode)
+    if (abMode) startQA('right', rightMode)
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Search bar */}
+      <div className="border border-gray-200 rounded-xl p-4">
+        <div className="flex gap-2 mb-3">
+          <input
+            value={query}
+            onChange={e => setQuery(e.target.value)}
+            onKeyDown={e => e.key === 'Enter' && !e.shiftKey && doSearch()}
+            placeholder="输入问题,自动检索并生成回答..."
+            className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
+          />
+          <Button
+            onClick={left.streaming ? () => stopQA('left') : doSearch}
+            icon={left.streaming ? <Pause size={14}/> : <Search size={14}/>}
+          >
+            {left.streaming ? '停止' : '搜索'}
+          </Button>
+        </div>
+        <div className="flex items-center gap-4 flex-wrap">
+          {/* Retrieval mode (only shown in non-AB mode) */}
+          {!abMode && (
+            <div className="flex items-center gap-2">
+              <label className="text-xs text-gray-600">检索:</label>
+              <select value={leftMode} onChange={e => setLeftMode(e.target.value)}
+                className="text-xs border border-gray-300 rounded px-2 py-1">
+                {modes.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
+              </select>
+            </div>
+          )}
+          <div className="flex items-center gap-2">
+            <label className="text-xs text-gray-600">Top-K:</label>
+            <input type="number" min={1} max={20} value={topK}
+              onChange={e => setTopK(Number(e.target.value))}
+              className="w-14 text-xs border border-gray-300 rounded px-2 py-1" />
+          </div>
+          {/* LLM selector (non-AB: single; AB: shown per-side below) */}
+          {!abMode && (
+            <ProviderModelSelect
+              value={leftLLM}
+              onChange={setLeftLLM}
+              providers={providers}
+            />
+          )}
+          <label className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer ml-auto">
+            <input type="checkbox" checked={abMode} onChange={e => setAbMode(e.target.checked)} className="rounded"/>
+            A/B 对比
+          </label>
+        </div>
+      </div>
+
+      {/* Results */}
+      {abMode ? (
+        <div className="grid grid-cols-2 gap-4">
+          {/* Left column */}
+          <div>
+            <div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
+              <select value={leftMode} onChange={e => setLeftMode(e.target.value)}
+                className="text-xs border border-gray-300 rounded px-2 py-1">
+                {modes.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
+              </select>
+              <ProviderModelSelect
+                value={leftLLM}
+                onChange={setLeftLLM}
+                providers={providers}
+                compact
+              />
+              <Button size="sm" onClick={left.streaming ? () => stopQA('left') : () => startQA('left', leftMode)}
+                icon={left.streaming ? <Pause size={11}/> : <Search size={11}/>}>
+                {left.streaming ? '停止' : '搜索'}
+              </Button>
+            </div>
+            <QAPanel state={left} mode={leftMode}
+              onToggleSources={() => setLeft(s => ({ ...s, showSources: !s.showSources }))} />
+          </div>
+          {/* Right column */}
+          <div>
+            <div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
+              <select value={rightMode} onChange={e => setRightMode(e.target.value)}
+                className="text-xs border border-gray-300 rounded px-2 py-1">
+                {modes.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
+              </select>
+              <ProviderModelSelect
+                value={rightLLM}
+                onChange={setRightLLM}
+                providers={providers}
+                compact
+              />
+              <Button size="sm" onClick={right.streaming ? () => stopQA('right') : () => startQA('right', rightMode)}
+                icon={right.streaming ? <Pause size={11}/> : <Search size={11}/>}>
+                {right.streaming ? '停止' : '搜索'}
+              </Button>
+            </div>
+            <QAPanel state={right} mode={rightMode}
+              onToggleSources={() => setRight(s => ({ ...s, showSources: !s.showSources }))} />
+          </div>
+        </div>
+      ) : (
+        <QAPanel state={left} mode={leftMode}
+          onToggleSources={() => setLeft(s => ({ ...s, showSources: !s.showSources }))} />
+      )}
+    </div>
+  )
+}
+
+// ─── Main Detail Page ──────────────────────────────────────────
+
+const TABS = [
+  { id: 'files', label: '文件管理', icon: <FolderOpen size={14}/> },
+  { id: 'index', label: '索引构建', icon: <Database size={14}/> },
+  { id: 'wiki', label: 'Wiki目录', icon: <BookOpen size={14}/> },
+  { id: 'search', label: '搜索测试', icon: <Search size={14}/> },
+]
+
+export default function KnowledgeDetail() {
+  const { kbId } = useParams<{ kbId: string }>()
+  const navigate = useNavigate()
+  const [tab, setTab] = useState('files')
+
+  const { data: kb, isLoading } = useQuery({
+    queryKey: ['knowledge', kbId],
+    queryFn: async () => {
+      const r = await knowledgeApi.list()
+      return r.items.find(k => k.id === kbId) ?? null
+    },
+    enabled: !!kbId,
+  })
+
+  if (isLoading) return <div className="flex items-center justify-center h-40"><Spinner /></div>
+  if (!kb) return <div className="p-6 text-red-600">知识体不存在</div>
+
+  return (
+    <div className="p-6">
+      {/* Header */}
+      <div className="flex items-center gap-3 mb-6">
+        <button onClick={() => navigate('/knowledge')}
+          className="text-gray-400 hover:text-gray-700 transition-colors p-1">
+          <ArrowLeft size={18}/>
+        </button>
+        <div>
+          <h1 className="text-lg font-bold text-gray-900">{kb.name}</h1>
+          <p className="text-xs text-gray-400 font-mono mt-0.5">{kb.root_dir}</p>
+        </div>
+      </div>
+
+      {/* Tabs */}
+      <div className="flex border-b border-gray-200 mb-6">
+        {TABS.map(t => (
+          <button
+            key={t.id}
+            onClick={() => setTab(t.id)}
+            className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
+              tab === t.id
+                ? 'border-indigo-500 text-indigo-600'
+                : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+            }`}
+          >
+            {t.icon}
+            {t.label}
+          </button>
+        ))}
+      </div>
+
+      {/* Tab content */}
+      {tab === 'files' && <FileManager kbId={kbId!}/>}
+      {tab === 'index' && <IndexBuilder kbId={kbId!}/>}
+      {tab === 'wiki' && <WikiBrowser kbId={kbId!}/>}
+      {tab === 'search' && <SearchTest kbId={kbId!}/>}
+    </div>
+  )
+}