Explorar o código

feat: Agent↔KB 完整联动 — DB绑定 + RAG注入 + UI选择器

DB (models.py):
- agents 表新增 kb_ids TEXT DEFAULT '[]' 和 kb_search_mode TEXT DEFAULT 'bm25' 两列
  via _migrate() 存量库自动升级

Backend (agents.py):
- CreateAgentRequest / UpdateAgentRequest 增加 kb_ids / kb_search_mode 字段
- UpdateKBsRequest 新模型
- create_agent / update_agent / get_agent / list_agents 同步读写 kb_ids JSON
- GET /agents/{id}/knowledge — 返回绑定的 KB 列表及详情
- PUT /agents/{id}/knowledge — 替换绑定列表,校验 kb_id 归属
- _build_kb_context() — 执行前检索 KB,优先 pageindex,回退 BM25 子进程
- run_agent / run_agent_stream 两个端点均在执行前注入检索上下文

Frontend (agents.ts):
- Agent 接口增加 kb_ids / kb_search_mode
- AgentKnowledge 接口,getKBs() / updateKBs() API 方法

Frontend (AgentCreate / AgentEdit):
- 新增"知识库"标签页,多选复选框 + 索引状态小徽章 + 检索模式下拉
- AgentEdit KB 变更单独保存,不影响 config 版本号

Frontend (Chat):
- 请求 getKBs(),在对话头部显示绑定 KB 的橙紫徽章

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kenny67nju hai 3 meses
pai
achega
332fe99331

+ 178 - 9
agentpaas/api/v1/agents.py

@@ -40,11 +40,19 @@ class CreateAgentRequest(BaseModel):
     agent_dir: str = Field(default="", description="Agent template directory path")
     agent_template: str = Field(default="", description="Agent template name (e.g. qaagent67wiki)")
     instance_dir: str = Field(default="", description="Instance data directory for this agent")
+    kb_ids: list = Field(default_factory=list, description="Linked knowledge base IDs")
+    kb_search_mode: str = Field(default="bm25", description="bm25 | pageindex | fusion")
 
 class UpdateAgentRequest(BaseModel):
     config: Dict[str, Any]
     changelog: str = ""
     agent_dir: str = ""
+    kb_ids: Optional[list] = None
+    kb_search_mode: Optional[str] = None
+
+class UpdateKBsRequest(BaseModel):
+    kb_ids: list = Field(default_factory=list)
+    kb_search_mode: str = Field(default="bm25")
 
 class RunRequest(BaseModel):
     input: str = Field(..., max_length=102400)  # 100KB max
@@ -110,11 +118,12 @@ async def create_agent(
     now = now_utc()
     db.execute(
         "INSERT INTO agents (id, tenant_id, name, description, current_version, tags, environment, "
-        "agent_dir, agent_template, instance_dir, status, created_at, updated_at) "
-        "VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, 'active', ?, ?)",
+        "agent_dir, agent_template, instance_dir, kb_ids, kb_search_mode, status, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
         (agent_id, tenant.tenant_id, req.name, req.description,
          json.dumps(req.tags), req.environment,
-         agent_dir, req.agent_template, instance_dir, now, now)
+         agent_dir, req.agent_template, instance_dir,
+         json.dumps(req.kb_ids), req.kb_search_mode, now, now)
     )
     db.execute(
         "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
@@ -149,6 +158,7 @@ async def list_agents(
     agents = db.fetchall(sql, tuple(params))
     for a in agents:
         a["tags"] = json.loads(a.get("tags", "[]"))
+        a["kb_ids"] = json.loads(a.get("kb_ids") or "[]")
     return {"agents": agents, "count": len(agents)}
 
 
@@ -171,6 +181,7 @@ async def get_agent(
         (agent_id, agent["current_version"])
     )
     agent["tags"] = json.loads(agent.get("tags", "[]"))
+    agent["kb_ids"] = json.loads(agent.get("kb_ids") or "[]")
     agent["config"] = json.loads(version["config"]) if version else {}
     return agent
 
@@ -216,10 +227,16 @@ async def update_agent(
         "VALUES (?, ?, ?, ?, ?, ?, ?)",
         (agent_id, new_version, config_json, config_hash, req.changelog, tenant.user_id, now)
     )
-    db.execute(
-        "UPDATE agents SET current_version = ?, agent_dir = ?, updated_at = ? WHERE id = ?",
-        (new_version, req.agent_dir, now, agent_id)
-    )
+    update_fields = "current_version = ?, agent_dir = ?, updated_at = ?"
+    update_vals = [new_version, req.agent_dir, now]
+    if req.kb_ids is not None:
+        update_fields += ", kb_ids = ?"
+        update_vals.append(json.dumps(req.kb_ids))
+    if req.kb_search_mode is not None:
+        update_fields += ", kb_search_mode = ?"
+        update_vals.append(req.kb_search_mode)
+    update_vals.append(agent_id)
+    db.execute(f"UPDATE agents SET {update_fields} WHERE id = ?", tuple(update_vals))
     db.commit()
 
     return {"agent_id": agent_id, "version": new_version, "updated_at": now}
@@ -337,11 +354,20 @@ async def run_agent(
     instance_dir = agent.get("instance_dir", "") or ""
     agent_dir = instance_dir or agent.get("agent_dir", "") or ""
 
+    # KB context injection: enrich input with top-K retrieved passages
+    kb_ids = json.loads(agent.get("kb_ids") or "[]")
+    kb_search_mode = agent.get("kb_search_mode") or "bm25"
+    enriched_input = req.input
+    if kb_ids:
+        kb_ctx = _build_kb_context(db, kb_ids, req.input, search_mode=kb_search_mode)
+        if kb_ctx:
+            enriched_input = f"{kb_ctx}\n\n[用户问题]\n{req.input}"
+
     # Execute via lambdagent
     t0 = time.time()
     try:
         result, trace_info = _execute_agent(
-            config, req.input, agent_dir=agent_dir, run_id=run_id,
+            config, enriched_input, agent_dir=agent_dir, run_id=run_id,
         )
         duration_ms = int((time.time() - t0) * 1000)
         workspace_path = trace_info.get("workspace_path", "")
@@ -431,6 +457,15 @@ async def run_agent_stream(
     instance_dir = agent.get("instance_dir", "") or ""
     agent_dir = instance_dir or agent.get("agent_dir", "") or ""
 
+    # KB context injection for streaming run
+    kb_ids = json.loads(agent.get("kb_ids") or "[]")
+    kb_search_mode = agent.get("kb_search_mode") or "bm25"
+    stream_input = req.input
+    if kb_ids:
+        kb_ctx = _build_kb_context(db, kb_ids, req.input, search_mode=kb_search_mode)
+        if kb_ctx:
+            stream_input = f"{kb_ctx}\n\n[用户问题]\n{req.input}"
+
     # Create run record before streaming starts
     run_id = gen_id("run_")
     now = now_utc()
@@ -452,7 +487,7 @@ async def run_agent_stream(
         def _run():
             try:
                 result, trace_info = _execute_agent(
-                    config, req.input, on_step=event_queue.put,
+                    config, stream_input, on_step=event_queue.put,
                     agent_dir=agent_dir, run_id=run_id,
                 )
                 duration_ms = int((time.time() - t0) * 1000)
@@ -695,6 +730,140 @@ async def delete_agent_memory(
     return {"deleted": key}
 
 
+# ── Agent-KB Linking API ──
+
+@router.get("/{agent_id}/knowledge")
+async def get_agent_knowledge(
+    agent_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """List knowledge bases linked to this agent, with their index status."""
+    require_permission(tenant, "agents:read")
+    agent = db.fetchone(
+        "SELECT kb_ids, kb_search_mode FROM agents WHERE id = ? AND tenant_id = ?",
+        (agent_id, tenant.tenant_id)
+    )
+    if not agent:
+        api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
+    kb_ids = json.loads(agent.get("kb_ids") or "[]")
+    kbs = []
+    for kb_id in kb_ids:
+        kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id = ?", (kb_id,))
+        if kb:
+            kbs.append(dict(kb))
+    return {
+        "kb_ids": kb_ids,
+        "kb_search_mode": agent.get("kb_search_mode", "bm25"),
+        "knowledge_bases": kbs,
+    }
+
+
+@router.put("/{agent_id}/knowledge")
+async def update_agent_knowledge(
+    agent_id: str,
+    req: UpdateKBsRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """Replace the list of knowledge bases linked to this agent."""
+    require_permission(tenant, "agents:write")
+    if not db.fetchone(
+        "SELECT id FROM agents WHERE id = ? AND tenant_id = ?",
+        (agent_id, tenant.tenant_id)
+    ):
+        api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
+    # Validate that all kb_ids exist (for this tenant)
+    for kb_id in req.kb_ids:
+        if not db.fetchone("SELECT id FROM knowledge_bases WHERE id = ? AND tenant_id = ?",
+                           (kb_id, tenant.tenant_id)):
+            api_error(404, "KB_NOT_FOUND", f"Knowledge base {kb_id} not found")
+    db.execute(
+        "UPDATE agents SET kb_ids = ?, kb_search_mode = ?, updated_at = ? WHERE id = ?",
+        (json.dumps(req.kb_ids), req.kb_search_mode, now_utc(), agent_id)
+    )
+    db.commit()
+    return {"kb_ids": req.kb_ids, "kb_search_mode": req.kb_search_mode}
+
+
+# ── KB Context Injection (RAG helper for agent execution) ──
+
+def _build_kb_context(
+    db: "Database",
+    kb_ids: list,
+    query: str,
+    search_mode: str = "bm25",
+    top_k: int = 5,
+) -> str:
+    """Search linked KBs and return a formatted context block.
+
+    Tries pageindex first (fast, in-process), then falls back to BM25
+    subprocess.  Returns empty string when nothing useful is found.
+    """
+    if not kb_ids or not query.strip():
+        return ""
+
+    all_results: list = []
+    for kb_id in kb_ids:
+        kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id = ?", (kb_id,))
+        if not kb:
+            continue
+        kb_root = kb.get("root_dir", "")
+        if not kb_root:
+            continue
+        kb_name = kb.get("name", kb_id)
+
+        results: list = []
+        try:
+            from pathlib import Path as _Path
+            from agentpaas.api.v1.knowledge import (
+                _page_index_search,
+                _find_scripts_dir,
+                _search_subprocess,
+            )
+            page_idx_exists = (_Path(kb_root) / "rag_page_index.json").exists()
+
+            if search_mode == "pageindex" and page_idx_exists:
+                results = _page_index_search(kb_root, query, top_k)
+            else:
+                # BM25 subprocess
+                try:
+                    sd = _find_scripts_dir(_Path(kb_root))
+                    if sd:
+                        results = _search_subprocess(kb_root, sd, "bm25", query, top_k)
+                except Exception:
+                    pass
+                # Fallback to pageindex
+                if not results and page_idx_exists:
+                    results = _page_index_search(kb_root, query, top_k)
+        except Exception:
+            continue
+
+        for r in results:
+            r["_kb_name"] = kb_name
+            all_results.append(r)
+
+    if not all_results:
+        return ""
+
+    # Sort by score descending (results may come from multiple KBs)
+    all_results.sort(key=lambda x: -(x.get("score") or 0))
+
+    lines = ["[知识库检索结果]"]
+    for i, r in enumerate(all_results[:top_k], 1):
+        src = r.get("_kb_name", "")
+        doc = r.get("source", "") or r.get("doc_id", "")
+        if doc:
+            src += f" / {doc}"
+        pg = r.get("page")
+        if pg:
+            src += f" 第{pg}页"
+        text = (r.get("text") or r.get("chunk_text") or r.get("content") or "")[:600].strip()
+        lines.append(f"\n[来源{i}: {src}]\n{text}")
+
+    return "\n".join(lines)
+
+
 # ── Persistent Memory API (Layer 1 Core + Layer 2 Recall) ──
 
 def _get_agent_dir(agent_id: str, tenant_id: str, db: "Database") -> str:

+ 3 - 0
agentpaas/db/models.py

@@ -197,6 +197,9 @@ class Database:
             # BUG-06: persist ctx.trace JSON so /traces/{run_id} returns step-level data
             ("runs", "trace_json", "ALTER TABLE runs ADD COLUMN trace_json TEXT DEFAULT ''"),
             ("knowledge_bases", "ignore_dirs", "ALTER TABLE knowledge_bases ADD COLUMN ignore_dirs TEXT DEFAULT '[]'"),
+            # Agent-KB integration (2026-06-02)
+            ("agents", "kb_ids", "ALTER TABLE agents ADD COLUMN kb_ids TEXT DEFAULT '[]'"),
+            ("agents", "kb_search_mode", "ALTER TABLE agents ADD COLUMN kb_search_mode TEXT DEFAULT 'bm25'"),
         ]
         for table, column, sql in migrations:
             try:

+ 30 - 2
webui/src/api/agents.ts

@@ -9,10 +9,23 @@ export interface Agent {
   tags: string[]
   config: Record<string, unknown>
   agent_dir: string
+  kb_ids: string[]
+  kb_search_mode: string
   created_at: string
   updated_at: string
 }
 
+export interface AgentKnowledge {
+  kb_ids: string[]
+  kb_search_mode: string
+  knowledge_bases: Array<{
+    id: string
+    name: string
+    root_dir: string
+    description: string
+  }>
+}
+
 export interface Run {
   id: string
   agent_id: string
@@ -64,9 +77,16 @@ export const agentsApi = {
     description: string
     config: Record<string, unknown>
     tags: string[]
+    kb_ids?: string[]
+    kb_search_mode?: string
   }) => api.post<{ agent_id: string }>('/agents', body),
-  update: (id: string, body: { config: Record<string, unknown>; agent_dir?: string; changelog?: string }) =>
-    api.put(`/agents/${id}`, body),
+  update: (id: string, body: {
+    config: Record<string, unknown>
+    agent_dir?: string
+    changelog?: string
+    kb_ids?: string[]
+    kb_search_mode?: string
+  }) => api.put(`/agents/${id}`, body),
   delete: (id: string) => api.delete(`/agents/${id}`),
   runs: (id: string, limit = 50) =>
     api.get<{ runs: Run[] }>(`/agents/${id}/runs?limit=${limit}`),
@@ -96,4 +116,12 @@ export const agentsApi = {
   // Persistent Memory — Layer 2: Recall Log
   recallMemory: (agentId: string, n = 20) =>
     api.get<{ entries: RecallEntry[]; total: number }>(`/agents/${agentId}/memory/recall?n=${n}`),
+
+  // Agent-KB linking
+  getKBs: (agentId: string) =>
+    api.get<AgentKnowledge>(`/agents/${agentId}/knowledge`),
+  updateKBs: (agentId: string, kb_ids: string[], kb_search_mode: string) =>
+    api.put<{ kb_ids: string[]; kb_search_mode: string }>(
+      `/agents/${agentId}/knowledge`, { kb_ids, kb_search_mode }
+    ),
 }

+ 103 - 1
webui/src/pages/AgentCreate.tsx

@@ -1,10 +1,11 @@
 import { useState } from 'react'
 import { useNavigate } from 'react-router-dom'
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { ArrowLeft, Bot } from 'lucide-react'
+import { ArrowLeft, Bot, Database, CheckSquare, Square } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { agentsApi } from '../api/agents'
 import { providersApi } from '../api/providers'
+import { knowledgeApi } from '../api/knowledge'
 import { Card, PageHeader, Button, Input, Textarea, Select, Tabs } from '../components/ui'
 
 const SYSTEM_PROMPT_TEMPLATES = [
@@ -29,6 +30,7 @@ const tabs = [
   { id: 'basic', label: '基本设置' },
   { id: 'model', label: '模型配置' },
   { id: 'prompt', label: '系统提示词' },
+  { id: 'kb', label: '知识库' },
 ]
 
 export default function AgentCreate() {
@@ -44,6 +46,8 @@ export default function AgentCreate() {
   const [temperature, setTemperature] = useState('0.7')
   const [maxTokens, setMaxTokens] = useState('4096')
   const [systemPrompt, setSystemPrompt] = useState(SYSTEM_PROMPT_TEMPLATES[0].prompt)
+  const [selectedKBs, setSelectedKBs] = useState<string[]>([])
+  const [kbSearchMode, setKbSearchMode] = useState('bm25')
 
   const { data: providerData } = useQuery({
     queryKey: ['providers'],
@@ -52,6 +56,12 @@ export default function AgentCreate() {
 
   const configuredProviders = providerData?.providers?.filter(p => p.configured) ?? []
 
+  const { data: kbData } = useQuery({
+    queryKey: ['knowledge-bases'],
+    queryFn: knowledgeApi.list,
+  })
+  const allKBs = kbData?.items ?? []
+
   function handleProviderChange(p: string) {
     setProvider(p)
     const models = MODEL_MAP[p] ?? []
@@ -80,6 +90,8 @@ export default function AgentCreate() {
           .split(',')
           .map(t => t.trim())
           .filter(Boolean),
+        kb_ids: selectedKBs,
+        kb_search_mode: kbSearchMode,
       }),
     onSuccess: data => {
       qc.invalidateQueries({ queryKey: ['agents'] })
@@ -238,6 +250,96 @@ export default function AgentCreate() {
             </Button>
           </div>
         )}
+
+        {/* ── 知识库 ── */}
+        {activeTab === 'kb' && (
+          <div className="space-y-5">
+            <p className="text-xs text-gray-500">
+              选择知识库后,每次对话前会自动检索相关内容并注入到提示词中(RAG 增强)。
+            </p>
+
+            {/* Search mode */}
+            <div>
+              <label className="block text-xs font-medium text-gray-700 mb-1">检索模式</label>
+              <select
+                value={kbSearchMode}
+                onChange={e => setKbSearchMode(e.target.value)}
+                className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
+              >
+                <option value="bm25">BM25 关键词检索(推荐)</option>
+                <option value="pageindex">PageIndex 页面级检索</option>
+              </select>
+            </div>
+
+            {/* KB multi-select */}
+            <div>
+              <label className="block text-xs font-medium text-gray-700 mb-2">
+                可用知识库
+                {selectedKBs.length > 0 && (
+                  <span className="ml-2 text-indigo-600">(已选 {selectedKBs.length} 个)</span>
+                )}
+              </label>
+              {allKBs.length === 0 ? (
+                <div className="rounded-lg border border-dashed border-gray-200 p-6 text-center text-sm text-gray-400">
+                  <Database size={24} className="mx-auto mb-2 opacity-40" />
+                  暂无知识库,请先在知识库页面创建
+                </div>
+              ) : (
+                <div className="space-y-2">
+                  {allKBs.map(kb => {
+                    const checked = selectedKBs.includes(kb.id)
+                    return (
+                      <button
+                        key={kb.id}
+                        type="button"
+                        onClick={() => setSelectedKBs(prev =>
+                          checked ? prev.filter(id => id !== kb.id) : [...prev, kb.id]
+                        )}
+                        className={`w-full flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
+                          checked
+                            ? 'border-indigo-300 bg-indigo-50'
+                            : 'border-gray-200 bg-white hover:border-gray-300'
+                        }`}
+                      >
+                        {checked
+                          ? <CheckSquare size={15} className="text-indigo-600 shrink-0" />
+                          : <Square size={15} className="text-gray-300 shrink-0" />}
+                        <div className="min-w-0">
+                          <p className="text-sm font-medium text-gray-800 truncate">{kb.name}</p>
+                          {kb.description && (
+                            <p className="text-xs text-gray-500 truncate">{kb.description}</p>
+                          )}
+                        </div>
+                        <div className="ml-auto flex gap-1.5 shrink-0">
+                          {kb.index_status?.bm25?.exists && (
+                            <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">BM25</span>
+                          )}
+                          {(kb.index_status as any)?.pageindex?.exists && (
+                            <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">PageIndex</span>
+                          )}
+                          {kb.index_status?.wiki?.exists && (
+                            <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">Wiki</span>
+                          )}
+                        </div>
+                      </button>
+                    )
+                  })}
+                </div>
+              )}
+            </div>
+
+            <Button
+              onClick={() => mutation.mutate()}
+              loading={mutation.isPending}
+              size="lg"
+              className="w-full"
+              disabled={!canSave}
+              icon={<Bot size={16} />}
+            >
+              {canSave ? '创建智能体' : '请先完善必填项(名称/模型)'}
+            </Button>
+          </div>
+        )}
       </Card>
     </div>
   )

+ 115 - 1
webui/src/pages/AgentEdit.tsx

@@ -1,9 +1,10 @@
 import { useState, useEffect } from 'react'
 import { useNavigate, useParams } from 'react-router-dom'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { ArrowLeft, Save, X } from 'lucide-react'
+import { ArrowLeft, Save, X, Database, CheckSquare, Square } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { agentsApi } from '../api/agents'
+import { knowledgeApi } from '../api/knowledge'
 import { Button, Input, Textarea, Card, PageHeader, Spinner, Tabs } from '../components/ui'
 import { clsx } from '../lib/clsx'
 
@@ -63,6 +64,7 @@ const TABS = [
   { id: 'memory',   label: '记忆 & 运行时' },
   { id: 'tools',    label: '工具列表' },
   { id: 'prompt',   label: '系统提示词' },
+  { id: 'kb',       label: '知识库' },
 ]
 
 // ── Page ──────────────────────────────────────────────────────────
@@ -103,6 +105,10 @@ export default function AgentEdit() {
   const [localTools, setLocalTools]     = useState<string[]>([])
   const [systemPrompt, setSystemPrompt] = useState('')
 
+  // knowledge bases
+  const [linkedKBs, setLinkedKBs]         = useState<string[]>([])
+  const [kbSearchMode, setKbSearchMode]   = useState('bm25')
+
   const { data: agent, isLoading } = useQuery({
     queryKey: ['agents', agentId],
     queryFn: () => agentsApi.get(agentId!),
@@ -143,6 +149,8 @@ export default function AgentEdit() {
 
     setLocalTools((mcp.localTools as string[]) ?? [])
     setSystemPrompt((cfg.systemPrompt as string) ?? '')
+    setLinkedKBs(agent.kb_ids ?? [])
+    setKbSearchMode(agent.kb_search_mode ?? 'bm25')
     setReady(true)
   }, [agent, ready])
 
@@ -194,6 +202,23 @@ export default function AgentEdit() {
     onError: () => toast.error('保存失败,请检查配置'),
   })
 
+  // KB queries & mutation
+  const { data: kbData } = useQuery({
+    queryKey: ['knowledge-bases'],
+    queryFn: knowledgeApi.list,
+  })
+  const allKBs = kbData?.items ?? []
+
+  const kbMutation = useMutation({
+    mutationFn: () => agentsApi.updateKBs(agentId!, linkedKBs, kbSearchMode),
+    onSuccess: () => {
+      toast.success('知识库绑定已更新')
+      qc.invalidateQueries({ queryKey: ['agents', agentId] })
+      qc.invalidateQueries({ queryKey: ['agent-knowledge', agentId] })
+    },
+    onError: () => toast.error('知识库绑定更新失败'),
+  })
+
   if (isLoading) {
     return (
       <div className="flex justify-center items-center h-64">
@@ -343,6 +368,95 @@ export default function AgentEdit() {
           </div>
         )}
 
+        {/* ── 知识库 ── */}
+        {tab === 'kb' && (
+          <div className="space-y-5 max-w-lg">
+            <p className="text-xs text-gray-500">
+              绑定知识库后,每次对话前会自动检索相关内容注入提示词(RAG 增强)。
+              知识库变更立即保存,无需点击"保存并生效"。
+            </p>
+
+            {/* Search mode */}
+            <div>
+              <label className="block text-xs font-medium text-gray-700 mb-1">检索模式</label>
+              <select
+                value={kbSearchMode}
+                onChange={e => setKbSearchMode(e.target.value)}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
+              >
+                <option value="bm25">BM25 关键词检索(推荐)</option>
+                <option value="pageindex">PageIndex 页面级检索</option>
+              </select>
+            </div>
+
+            {/* KB multi-select */}
+            <div>
+              <label className="block text-xs font-medium text-gray-700 mb-2">
+                可用知识库
+                {linkedKBs.length > 0 && (
+                  <span className="ml-2 text-indigo-600">(已绑定 {linkedKBs.length} 个)</span>
+                )}
+              </label>
+              {allKBs.length === 0 ? (
+                <div className="rounded-lg border border-dashed border-gray-200 p-6 text-center text-sm text-gray-400">
+                  <Database size={24} className="mx-auto mb-2 opacity-40" />
+                  暂无知识库,请先在知识库页面创建
+                </div>
+              ) : (
+                <div className="space-y-2">
+                  {allKBs.map(kb => {
+                    const checked = linkedKBs.includes(kb.id)
+                    return (
+                      <button
+                        key={kb.id}
+                        type="button"
+                        onClick={() => setLinkedKBs(prev =>
+                          checked ? prev.filter(id => id !== kb.id) : [...prev, kb.id]
+                        )}
+                        className={clsx(
+                          'w-full flex items-center gap-3 rounded-lg border p-3 text-left transition-colors',
+                          checked
+                            ? 'border-indigo-300 bg-indigo-50'
+                            : 'border-gray-200 bg-white hover:border-gray-300'
+                        )}
+                      >
+                        {checked
+                          ? <CheckSquare size={15} className="text-indigo-600 shrink-0" />
+                          : <Square size={15} className="text-gray-300 shrink-0" />}
+                        <div className="min-w-0">
+                          <p className="text-sm font-medium text-gray-800 truncate">{kb.name}</p>
+                          {kb.description && (
+                            <p className="text-xs text-gray-500 truncate">{kb.description}</p>
+                          )}
+                        </div>
+                        <div className="ml-auto flex gap-1.5 shrink-0">
+                          {kb.index_status?.bm25?.exists && (
+                            <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">BM25</span>
+                          )}
+                          {(kb.index_status as any)?.pageindex?.exists && (
+                            <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">PageIndex</span>
+                          )}
+                          {kb.index_status?.wiki?.exists && (
+                            <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">Wiki</span>
+                          )}
+                        </div>
+                      </button>
+                    )
+                  })}
+                </div>
+              )}
+            </div>
+
+            <Button
+              onClick={() => kbMutation.mutate()}
+              loading={kbMutation.isPending}
+              icon={<Save size={14} />}
+            >
+              保存知识库绑定
+            </Button>
+          </div>
+        )}
+
         <div className="mt-8 pt-6 border-t border-gray-100 flex gap-3">
           <Button onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}
             disabled={!name.trim() || !provider || !modelName}

+ 27 - 6
webui/src/pages/Chat.tsx

@@ -4,9 +4,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import {
   ArrowLeft, Send, Square, ChevronDown, ChevronRight,
   Bot, User, Clock, Brain, Folder, Download, Trash2, X,
-  Save, RotateCcw, History, Key,
+  Save, RotateCcw, History, Key, BookMarked,
 } from 'lucide-react'
 import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type RecallEntry } from '../api/agents'
+import { knowledgeApi } from '../api/knowledge'
 import { useAppStore } from '../store/app'
 import { Spinner } from '../components/ui'
 import { clsx } from '../lib/clsx'
@@ -473,6 +474,13 @@ export default function Chat() {
     enabled: !!agentId,
   })
 
+  // Load linked KB details for the header badge display
+  const { data: agentKBData } = useQuery({
+    queryKey: ['agent-knowledge', agentId],
+    queryFn: () => agentsApi.getKBs(agentId!),
+    enabled: !!agentId && (agent?.kb_ids?.length ?? 0) > 0,
+  })
+
   const { data: runsData } = useQuery({
     queryKey: ['runs', agentId],
     queryFn: () => agentsApi.runs(agentId!),
@@ -642,11 +650,24 @@ export default function Chat() {
         </div>
         <div className="flex-1 min-w-0">
           <p className="text-sm font-semibold text-gray-900">{agent?.name ?? '加载中…'}</p>
-          <p className="text-xs text-gray-500">
-            {(agent?.config?.model as Record<string, unknown>)?.provider as string ?? ''}
-            {' · '}
-            {(agent?.config?.model as Record<string, unknown>)?.name as string ?? ''}
-          </p>
+          <div className="flex items-center gap-2 flex-wrap">
+            <p className="text-xs text-gray-500">
+              {(agent?.config?.model as Record<string, unknown>)?.provider as string ?? ''}
+              {' · '}
+              {(agent?.config?.model as Record<string, unknown>)?.name as string ?? ''}
+            </p>
+            {/* KB badges */}
+            {(agentKBData?.knowledge_bases ?? []).map(kb => (
+              <span
+                key={kb.id}
+                className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-600 border border-indigo-200 px-1.5 py-0.5 rounded-full"
+                title={`知识库: ${kb.name}`}
+              >
+                <BookMarked size={9} />
+                {kb.name}
+              </span>
+            ))}
+          </div>
         </div>
         {/* Memory toggle */}
         <button