《AgentScope 2.0实战指南》11 - 完整实战:RAG 问答助手(带 UI 界面)

简介: 《AgentScope 2.0实战指南》第11节打造了一个结构完整、可运行的RAG问答助手Web应用:FastAPI后端+原生HTML/CSS/JS前端,集成RAG检索、ReMe跨会话长期记忆与AnySearch联网搜索,支持文档上传、流式响应、多会话隔离及Markdown安全渲染,代码开源,直面生产落地差距。

《AgentScope 2.0实战指南》11 - 完整实战:RAG 问答助手(带 UI 界面)

前面十节都是在命令行里跑脚本。这一节把前面学过的东西组装成一个能在浏览器里用的 Web 应用:FastAPI 做后端,原生 HTML/CSS/JS 做前端,Agent 负责问答,挂上 RAG 知识库、ReMe 长期记忆和 AnySearch 联网搜索三个工具。

先说清楚一件事:这是一个结构完整、可以实际运行的全栈案例,但还不是可以直接上线的商业产品。它和生产环境之间还差什么,放在最后一节讲,不回避。

最终代码在 codes/ai_assistant/。

11.1 功能清单与最终效果

要实现的功能:

  • FastAPI 封装接口,回答用 SSE(Server-Sent Events)流式返回,前端一个字一个字往外蹦;

  • 左侧栏:新建对话、对话列表、知识库入口;右侧:聊天区加底部输入框;

  • 多会话:每个会话一个独立 Agent,会话之间互不串上下文;

  • 上传文档(Markdown / TXT / PDF / Word),自动解析、切块、向量化入库;

  • RAG 问答:回答前先检索上传的文档,答案标注来源;

  • 跨会话长期记忆(ReMe):在 A 会话告诉它的信息,开一个全新的 B 会话也能回忆起来;

  • AnySearch 联网搜索:知识库里没有的实时信息,模型自己决定去搜;

  • 用户 ID 写死为 1(单用户演示,接入登录后替换)。

打开后的界面:

aia_01_home.png

11.2 整体架构

请求从浏览器到模型经过的环节:

浏览器 (static/ 三件套)

   │  fetch + ReadableStream 解析 SSE

   ▼

FastAPI (app/main.py)

   │  会话 CRUD / 文档上传 / SSE 流式问答 / 托管静态文件

   ▼

AssistantEngine 单例 (app/engine.py,lifespan 启动时装配一次)

   │

   ├── Agent × N(每个会话一个,session_id = user1-<会话id>)

   │      └── Toolkit = RAG 工具 + ReMe 工具 + AnySearch 工具

   │

   ├── RAGMiddleware ── KnowledgeBase ── QdrantStore(data/qdrant,持久化)

   │

   ├── ReMeMiddleware ── 工作区 data/reme(记忆卡片 + 索引,持久化)

   │

   └── AnySearchTool ── 联网搜索 API

有一个关键设计:会话是多个,知识库和记忆是共享的单例。

  • 每个会话 new 一个 Agent,带自己的 session_id,对话上下文互相隔离;

  • 所有会话共用同一个 KnowledgeBase(所以任何会话都能检索到你上传的文档);

  • 所有会话共用同一个 ReMe 工作区,而 ReMe 的 session_id 都以 user1- 开头、属于同一个用户,所以 A 会话写入的记忆,B 会话能检索到 —— 这就是跨会话记忆的实现方式。

目录结构:

codes/ai_assistant/

├── app/

│   ├── __init__.py

│   ├── main.py          # FastAPI 入口、路由、SSE

│   ├── engine.py        # 核心引擎:装配 / 会话 / 上传 / 流式

│   └── search_tool.py   # AnySearch 工具(第 5 节同款)

├── static/

│   ├── index.html       # 页面结构

│   ├── style.css        # 样式

│   ├── app.js           # 会话、SSE、上传、渲染

│   └── vendor/          # marked、DOMPurify 本地化,不依赖外网 CDN

│       ├── marked.min.js

│       └── purify.min.js

├── sample_docs/

│   └── product-faq.md   # 演示用文档

├── data/                # 运行时自动生成:uploads / qdrant / reme

├── requirements.txt

└── README.md

模型名称、接口地址和密钥复用上级目录的 codes/config.py,不在这里重复配置。

11.3 依赖

requirements.txt:

agentscope[rag,vdb-qdrant,reme]==2.0.8

fastapi>=0.115

uvicorn[standard]>=0.30

python-multipart>=0.0.9

httpx>=0.27
  • python-multipart 是 FastAPI 处理文件上传表单必须的;

  • vdb-qdrant、rag、reme 三个 extras 分别提供向量库、RAG 和长期记忆能力。

前端的 Markdown 渲染用 marked,HTML 清洗用 DOMPurify。这两个库我下载到了 static/vendor/ 本地引用,而不是在页面里写 jsdelivr 的 CDN 地址 —— 后端服务部署到内网或客户网络不通外网时,CDN 会直接挂掉,本地引用没有这个问题。

11.4 联网搜索工具

app/search_tool.py 就是第 5 节实现的 AnySearch 工具,原样搬过来,只读、免授权、允许并发:

# app/search_tool.py

# -*- coding: utf-8 -*-

"""AnySearch 联网搜索工具(与第 5 节实现一致,只读、免授权、可并发)。

这里单独抽出,供 Web 助手的 Agent 挂载。接口密钥统一从 codes/config.py 读取。

"""

import sys

import pathlib

import httpx

from agentscope.message import TextBlock

from agentscope.permission import (

    PermissionBehavior,

    PermissionContext,

    PermissionDecision,

)

from agentscope.tool import ToolBase, ToolChunk

# codes/ 目录加入 import 路径,复用同一份 config.py

_CODES_DIR = pathlib.Path(__file__).resolve().parents[2]

if str(_CODES_DIR) not in sys.path:

    sys.path.insert(0, str(_CODES_DIR))

from config import ANYSEARCH_KEY, ANYSEARCH_URL  # noqa: E402

async def anysearch_search(

    query: str,

    max_results: int = 5,

    zone: str = "cn",

    language: str = "zh-CN",

) -> str:

    """调用 AnySearch 统一搜索接口,返回可读的标题/链接/摘要文本。"""

    payload = {

        "query": query,

        "max_results": max_results,

        "zone": zone,

        "language": language,

    }

    async with httpx.AsyncClient(timeout=30) as client:

        resp = await client.post(

            ANYSEARCH_URL,

            headers={

                "Authorization": f"Bearer {ANYSEARCH_KEY}",

                "Content-Type": "application/json",

            },

            json=payload,

        )

        resp.raise_for_status()

        data = resp.json()

    if data.get("code") != 0:

        return f"搜索失败:{data.get('message')}"

    lines = []

    for i, item in enumerate(data["data"]["results"], 1):

        lines.append(

            f"{i}. {item.get('title', '')}\n"

            f"   URL: {item.get('url', '')}\n"

            f"   摘要: {item.get('snippet', item.get('content', ''))[:300]}"

        )

    return "\n".join(lines)

class AnySearchTool(ToolBase):

    """面向 Agent 的网页搜索工具:只读、允许并发、权限直接放行。"""

    name = "AnySearch"

    description = (

        "Search the web for real-time information via AnySearch. "

        "Use it when the user asks about latest news, current events, "

        "or facts not covered by the uploaded knowledge base."

    )

    input_schema = {

        "type": "object",

        "properties": {

            "query": {"type": "string", "description": "The search query."},

            "max_results": {

                "type": "integer",

                "description": "Number of results, 1-10.",

                "default": 5,

            },

        },

        "required": ["query"],

    }

    is_concurrency_safe = True

    is_read_only = True

    async def check_permissions(

        self,

        tool_input: dict,

        context: PermissionContext,

    ) -> PermissionDecision:

        return PermissionDecision(

            behavior=PermissionBehavior.ALLOW,

            message="Web search is read-only.",

        )

    async def call(self, query: str, max_results: int = 5) -> ToolChunk:

        results = await anysearch_search(query, max_results=max_results)

        return ToolChunk(content=[TextBlock(text=results)])

11.5 核心引擎

app/engine.py 负责装配所有组件、管理会话、处理上传、把 Agent 的事件流转成统一格式。

完整代码如下,后面分段解释。

# app/engine.py

# -*- coding: utf-8 -*-

"""RAG 问答助手的核心引擎。

职责:

- 全局装配:聊天/嵌入模型、持久化 Qdrant 向量库、KnowledgeBase、

  RAGMiddleware(知识库问答)、ReMeMiddleware(跨会话长期记忆)、AnySearch 工具;

- 多会话:每个会话一个绑定 session_id 的 Agent,共享同一份知识库与记忆工作区;

- 文档上传:按扩展名选解析器,切块后增量写入知识库;

- 流式对话:把 Agent 的 reply_stream 事件转成前端可消费的结构。

设计上 user_id 固定为 "1"(单用户演示):

- 知识库按用户共享,所有会话都能检索到上传的文档;

- ReMe 工作区按用户共享,不同 session_id 之间可以互相召回记忆。

"""

from __future__ import annotations

import asyncio

import inspect

import logging

import sys

import time

import uuid

import pathlib

from dataclasses import dataclass, field

from agentscope.agent import Agent

from agentscope.credential import OpenAICredential

from agentscope.embedding import OpenAIEmbeddingModel

from agentscope.event import TextBlockDeltaEvent, ToolCallStartEvent

from agentscope.message import UserMsg

from agentscope.middleware import RAGMiddleware, ReMeMiddleware

from agentscope.model import OpenAIChatModel

from agentscope.rag import (

    ApproxTokenChunker,

    KnowledgeBase,

    PDFParser,

    QdrantStore,

    TextParser,

    WordParser,

)

from agentscope.state import AgentState

from agentscope.tool import Toolkit

# 复用 codes/config.py

_CODES_DIR = pathlib.Path(__file__).resolve().parents[2]

if str(_CODES_DIR) not in sys.path:

    sys.path.insert(0, str(_CODES_DIR))

from config import (  # noqa: E402

    API_KEY,

    BASE_URL,

    CHAT_MODEL,

    EMBED_DIM,

    EMBED_MODEL,

)

from app.search_tool import AnySearchTool  # noqa: E402

USER_ID = "1"

BASE_DIR = pathlib.Path(__file__).resolve().parents[1]

DATA_DIR = BASE_DIR / "data"

UPLOAD_DIR = DATA_DIR / "uploads"

QDRANT_DIR = DATA_DIR / "qdrant"

REME_DIR = DATA_DIR / "reme"

COLLECTION = "user1_kb"

logging.getLogger("reme").setLevel(logging.ERROR)

# ----------------------------------------------------------------------

# ReMe 兼容补丁:当前 PyPI 的 reme 包缺少 dream_topics_step 组件,而

# ReMeMiddleware 默认的「每日摘要」流水线引用了它,会导致中间件启动失败。

# 运行时把该步骤移除,不影响 auto_memory 写回与 memory_search 检索。

# ----------------------------------------------------------------------

from agentscope.middleware._longterm_memory._reme import _config as _reme_cfg  # noqa: E402

_orig_dream_steps = _reme_cfg._dream_steps

def _patched_dream_steps() -> list[dict]:

    return [s for s in _orig_dream_steps() if s["backend"] != "dream_topics_step"]

_reme_cfg._dream_steps = _patched_dream_steps

SYSTEM_PROMPT = (

    "你是用户的 RAG 问答助手,按以下规则工作:\n"

    "1. 回答与上传资料相关的问题前,先调用 search_knowledge 检索知识库,"

    "并严格依据检索到的内容回答,可在结尾注明来源文档;\n"

    "2. 需要最新资讯、实时信息而知识库没有时,调用 AnySearch 联网搜索;\n"

    "3. 涉及用户的偏好、历史决定或过去对话时,调用 memory_search 回忆;\n"

    "4. 用简洁中文回答,列表、对比与代码用 Markdown 组织,不要编造资料里没有的内容。"

)

@dataclass

class Session:

    sid: str

    agent: Agent

    title: str = "新对话"

    created_at: float = field(default_factory=time.time)

    messages: list[dict] = field(default_factory=list)

    lock: asyncio.Lock = field(default_factory=asyncio.Lock)

class AssistantEngine:

    """全局单例引擎,FastAPI lifespan 中 startup/shutdown。"""

    def __init__(self) -> None:

        self.credential: OpenAICredential | None = None

        self.chat_model: OpenAIChatModel | None = None

        self.embedding_model: OpenAIEmbeddingModel | None = None

        self.store: QdrantStore | None = None

        self.knowledge: KnowledgeBase | None = None

        self.rag_mw: RAGMiddleware | None = None

        self.reme_mw: ReMeMiddleware | None = None

        self.tools: list = []

        self.chunker = ApproxTokenChunker(

            parameters=ApproxTokenChunker.Parameters(chunk_size=256, overlap=32),

        )

        self.sessions: dict[str, Session] = {}

    # ---------------- 启动 / 关闭 ----------------

    async def startup(self) -> None:

        for d in (UPLOAD_DIR, QDRANT_DIR, REME_DIR):

            d.mkdir(parents=True, exist_ok=True)

        self.credential = OpenAICredential(api_key=API_KEY, base_url=BASE_URL)

        self.chat_model = OpenAIChatModel(

            credential=self.credential,

            model=CHAT_MODEL,

            stream=True,

            context_size=128000,

        )

        self.embedding_model = OpenAIEmbeddingModel(

            credential=self.credential,

            model=EMBED_MODEL,

            dimensions=EMBED_DIM,

        )

        # 持久化向量库:重启后已上传文档仍在

        self.store = QdrantStore(path=str(QDRANT_DIR))

        await self.store.__aenter__()

        self.knowledge = KnowledgeBase(

            name="assistant-kb",

            description="用户通过网页上传的文档知识库。",

            embedding_model=self.embedding_model,

            vector_store=self.store,

            collection=COLLECTION,

        )

        ensured = self.knowledge.ensure_collection()

        if inspect.isawaitable(ensured):

            await ensured

        self.rag_mw = RAGMiddleware(

            knowledge_bases=[self.knowledge],

            parameters=RAGMiddleware.Parameters(mode="agentic", top_k=3),

        )

        self.reme_mw = ReMeMiddleware(

            workspace_dir=str(REME_DIR),

            parameters=ReMeMiddleware.Parameters(

                chat_model=self.chat_model,

                embedding_model=self.embedding_model,

                mode="both",

                top_k=5,

            ),

        )

        # 三类工具:知识库检索 + 长期记忆检索 + 联网搜索

        self.tools = [

            *(await self.rag_mw.list_tools()),

            *(await self.reme_mw.list_tools()),

            AnySearchTool(),

        ]

        print(

            f"[启动] 知识库集合 {COLLECTION} 已就绪;"

            f"ReMe 工作区 {REME_DIR.relative_to(BASE_DIR)}",

            flush=True,

        )

    async def shutdown(self) -> None:

        try:

            await self.reme_mw.close()

        except Exception:

            pass

        try:

            await self.store.__aexit__(None, None, None)

        except Exception:

            pass

    # ---------------- 会话管理 ----------------

    def create_session(self) -> Session:

        sid = uuid.uuid4().hex[:12]

        agent = Agent(

            name="assistant",

            system_prompt=SYSTEM_PROMPT,

            model=self.chat_model,

            toolkit=Toolkit(tools=list(self.tools)),

            middlewares=[self.rag_mw, self.reme_mw],

            state=AgentState(session_id=f"user{USER_ID}-{sid}"),

        )

        sess = Session(sid=sid, agent=agent)

        self.sessions[sid] = sess

        return sess

    def get_session(self, sid: str) -> Session | None:

        return self.sessions.get(sid)

    def list_sessions(self) -> list[dict]:

        items = [

            {

                "sid": s.sid,

                "title": s.title,

                "created_at": s.created_at,

                "message_count": len(s.messages),

            }

            for s in self.sessions.values()

        ]

        return sorted(items, key=lambda x: x["created_at"], reverse=True)

    def delete_session(self, sid: str) -> bool:

        return self.sessions.pop(sid, None) is not None

    # ---------------- 知识库 / 上传 ----------------

    @staticmethod

    def _pick_parser(filename: str):

        name = filename.lower()

        if name.endswith(".pdf"):

            return PDFParser()

        if name.endswith(".docx"):

            return WordParser()

        if name.endswith((".md", ".markdown", ".txt", ".text")):

            return TextParser()

        raise ValueError("仅支持 .md / .txt / .pdf / .docx 文件")

    async def add_document(self, filename: str, raw: bytes) -> dict:

        safe = filename.replace("/", "_").replace("\\\\", "_")

        path = UPLOAD_DIR / safe

        path.write_bytes(raw)

        parser = self._pick_parser(safe)

        sections = await parser.parse(file=str(path), filename=safe)

        chunks = await self.chunker.chunk(sections)

        doc_id = await self.knowledge.insert_document(

            chunks,

            document_metadata={"filename": safe},

        )

        return {"filename": safe, "doc_id": doc_id, "chunks": len(chunks)}

    async def list_documents(self) -> list[dict]:

        out = []

        for s in await self.knowledge.list_documents():

            out.append(

                {

                    "doc_id": s.document_id,

                    "filename": (s.source or {}).get("filename")

                    if isinstance(s.source, dict)

                    else getattr(s, "source", None),

                    "chunks": s.chunk_count,

                }

            )

        return out

    # ---------------- 流式对话 ----------------

    async def chat_stream(self, sess: Session, text: str):

        """产出 (event, data) 元组:tool / token / done / error。"""

        async with sess.lock:

            sess.messages.append({"role": "user", "content": text})

            if sess.title == "新对话":

                sess.title = text.strip()[:20] or "新对话"

            full = ""

            try:

                async for ev in sess.agent.reply_stream(

                    inputs=UserMsg(name="user", content=text),

                ):

                    if isinstance(ev, ToolCallStartEvent):

                        yield ("tool", {"name": ev.tool_call_name})

                    elif isinstance(ev, TextBlockDeltaEvent):

                        full += ev.delta

                        yield ("token", {"delta": ev.delta})

            except Exception as exc:  # 把异常转成一帧错误,避免前端挂起

                yield ("error", {"message": f"模型调用失败:{exc}"})

                return

            sess.messages.append({"role": "assistant", "content": full})

            # 让本轮新写入的 ReMe 记忆立即可被检索(生产环境由后台任务完成)

            try:

                # pylint: disable=protected-access

                await self.reme_mw._run_job("reindex")

            except Exception:

                pass

            yield ("done", {"title": sess.title})

# 模块级单例

engine = AssistantEngine()

几个需要单独说明的点:

1)向量库要用持久化路径,不能用内存模式。

QdrantStore(path=str(QDRANT_DIR)) 把数据写到本地磁盘,服务重启后之前上传的文档还在。如果写成 QdrantStore(location=":memory:"),每次重启知识库都是空的。注意本地文件模式的 Qdrant 有单进程文件锁,不允许两个进程同时打开同一个目录,所以不要在服务运行时另开脚本去访问同一个 data/qdrant。

2)重启后要 ensure_collection()。

知识库集合在第一次上传时创建,服务重启后集合已存在于磁盘,需要调用一次 ensure_collection() 让客户端和已有集合对齐。这个方法可能返回协程也可能直接返回,所以用 inspect.isawaitable 判断后再 await。

3)只挂中间件还不够,要把工具加进 Toolkit。

ReMe 和 RAG 在 agentic / both 模式下,除了在 middlewares=[...] 里挂上,还必须把 mw.list_tools() 返回的工具放进 Toolkit,模型才看得到 search_knowledge、memory_search 这两个可调用的工具。这一步漏掉的现象是:中间件加载了,但模型从不调用工具。

4)ReMe 的 dream_topics 补丁。

当前 PyPI 上的 reme 包缺一个叫 dream_topics_step 的组件,而 ReMeMiddleware 默认的每日摘要流水线引用了它,不打补丁会在启动时报错。补丁的做法是运行时把这个步骤从流水线里剔除,只影响每日主题摘要,不影响记忆写入和检索。这是个版本兼容问题,后续 reme 包补齐后这段可以删掉。

5)每轮结束后手动 reindex。

ReMe 写入记忆卡片后,要重建索引才能被 memory_search 检到。常驻服务里这轮写完、下一轮立刻就要能搜到,所以在每轮对话结束后显式跑一次 reindex。生产部署里这通常交给后台定时任务,不必让请求等它。

6)会话加锁。

每个 Session 带一把 asyncio.Lock,同一时刻只处理该会话的一轮问答,避免一个会话还在流式输出时又进来一条消息把上下文搅乱。不同会话用不同的锁,互不阻塞。

11.6 FastAPI 入口与 SSE

app/main.py 负责路由和静态文件托管。流式问答是重点:用 StreamingResponse 把引擎产出的事件按 SSE 格式(event: xxx\ndata: xxx\n\n)推给前端。

# app/main.py

# -*- coding: utf-8 -*-

"""FastAPI 入口:会话管理、文档上传、RAG 流式问答、静态前端托管。

启动(在 codes/ai_assistant 目录下):

    uvicorn app.main:app --reload --host 127.0.0.1 --port 8000

然后浏览器打开 http://127.0.0.1:8000

"""

import json

from contextlib import asynccontextmanager

from pathlib import Path

from fastapi import FastAPI, File, HTTPException, UploadFile

from fastapi.middleware.cors import CORSMiddleware

from fastapi.responses import StreamingResponse

from fastapi.staticfiles import StaticFiles

from pydantic import BaseModel

from app.engine import engine

STATIC_DIR = Path(__file__).resolve().parents[1] / "static"

@asynccontextmanager

async def lifespan(app: FastAPI):

    await engine.startup()

    yield

    await engine.shutdown()

app = FastAPI(title="AgentScope RAG 问答助手", lifespan=lifespan)

app.add_middleware(

    CORSMiddleware,

    allow_origins=["*"],

    allow_methods=["*"],

    allow_headers=["*"],

)

class ChatIn(BaseModel):

    message: str

def sse(event: str, data: dict) -> bytes:

    payload = json.dumps(data, ensure_ascii=False)

    return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")

# ---------------- 会话 ----------------

@app.post("/api/sessions")

async def create_session():

    sess = engine.create_session()

    return {"sid": sess.sid, "title": sess.title, "created_at": sess.created_at}

@app.get("/api/sessions")

async def list_sessions():

    return {"sessions": engine.list_sessions()}

@app.get("/api/sessions/{sid}/messages")

async def get_messages(sid: str):

    sess = engine.get_session(sid)

    if sess is None:

        raise HTTPException(404, "会话不存在")

    return {"sid": sid, "title": sess.title, "messages": sess.messages}

@app.delete("/api/sessions/{sid}")

async def delete_session(sid: str):

    ok = engine.delete_session(sid)

    if not ok:

        raise HTTPException(404, "会话不存在")

    return {"ok": True}

# ---------------- 流式问答(SSE) ----------------

@app.post("/api/sessions/{sid}/chat")

async def chat(sid: str, body: ChatIn):

    sess = engine.get_session(sid)

    if sess is None:

        raise HTTPException(404, "会话不存在")

    message = body.message.strip()

    if not message:

        raise HTTPException(400, "消息为空")

    async def gen():

        try:

            async for event, data in engine.chat_stream(sess, message):

                yield sse(event, data)

        except Exception as exc:  # 兜底:任何异常都以一帧 error 结束

            yield sse("error", {"message": str(exc)})

    return StreamingResponse(

        gen(),

        media_type="text/event-stream",

        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},

    )

# ---------------- 知识库 / 上传 ----------------

@app.post("/api/documents/upload")

async def upload_documents(files: list[UploadFile] = File(...)):

    results = []

    for f in files:

        raw = await f.read()

        try:

            info = await engine.add_document(f.filename, raw)

        except ValueError as exc:

            raise HTTPException(415, str(exc)) from exc

        results.append(info)

    return {"documents": results}

@app.get("/api/documents")

async def list_documents():

    return {"documents": await engine.list_documents()}

@app.get("/api/health")

async def health():

    return {"ok": True}

# 静态前端放最后,避免吞掉 /api 路由

app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")

接口一览:

方法 路径 作用
POST /api/sessions 新建会话
GET /api/sessions 会话列表
GET /api/sessions/{sid}/messages 取历史消息
DELETE /api/sessions/{sid} 删除会话
POST /api/sessions/{sid}/chat 提问,SSE 流式返回
POST /api/documents/upload 上传文档,表单字段 files,支持多文件
GET /api/documents 知识库文档列表
GET /api/health 健康检查

两个细节:

  • SSE 响应要带 X-Accel-Buffering: no,否则放在 Nginx 反代后面时,Nginx 会缓冲响应,前端看到的就不是逐字流式,而是憋到最后一次性出现。

  • app.mount("/", StaticFiles(...)) 必须放在所有 /api 路由之后注册,否则根路径的通配挂载会把 /api 请求也吞掉。

11.7 前端界面

前端不引入框架,就三个文件。

页面结构 static/index.html:左侧栏和右侧聊天区,Markdown 渲染库从本地 vendor/ 加载。

<!-- static/index.html -->

<!DOCTYPE html>

<html lang="zh-CN">

<head>

  <meta charset="UTF-8" />

  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <title>RAG 问答助手 · AgentScope 2.0</title>

  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />

  <link rel="stylesheet" href="/style.css?v=4" />

  <script src="/vendor/marked.min.js"></script>

  <script src="/vendor/purify.min.js"></script>

</head>

<body>

<div class="app">

  <!-- 左侧:新对话 + 会话列表 -->

  <aside class="sidebar">

    <div class="brand">

      <div class="brand-title">RAG 问答助手</div>

      <div class="brand-sub">AgentScope 2.0</div>

    </div>

    <button id="btn-new" class="btn-new">+ 新建对话</button>

    <div class="side-label">对话列表</div>

    <nav id="session-list" class="session-list"></nav>

    <div class="side-foot">

      <button id="btn-docs" class="btn-ghost">

        📄 知识库 <span id="doc-count" class="badge">0</span>

      </button>

      <div id="doc-panel" class="doc-panel hidden"></div>

    </div>

  </aside>

  <!-- 右侧:聊天区 -->

  <main class="main">

    <header class="topbar">

      <div id="chat-title" class="chat-title">新对话</div>

      <div class="top-actions">

        <input id="file-input" type="file" multiple

               accept=".md,.markdown,.txt,.pdf,.docx" hidden />

        <button id="btn-upload" class="btn-outline">⬆ 上传文档</button>

      </div>

    </header>

    <section id="messages" class="messages">

      <div class="empty-tip" id="empty-tip">

        <div class="empty-title">开始提问</div>

        <div class="empty-desc">上传文档后可基于资料问答;也能联网搜索、跨会话记住你的偏好。</div>

        <div class="empty-examples">

          <button class="example" data-q="根据我上传的文档,总结一下主要内容">📄 总结上传的文档</button>

          <button class="example" data-q="帮我搜索一下 AgentScope 2.0 的最新动态">🔎 联网搜索最新动态</button>

          <button class="example" data-q="记住我偏好简洁的中文回答">🧠 记住我的回答偏好</button>

        </div>

      </div>

    </section>

    <footer class="composer">

      <div id="tool-status" class="tool-status hidden"></div>

      <div class="input-row">

        <textarea id="input" rows="1"

          placeholder="输入问题,Enter 发送,Shift + Enter 换行"></textarea>

        <button id="btn-send" class="btn-send">发送</button>

      </div>

    </footer>

  </main>

</div>

<script src="/app.js?v=4"></script>

</body>

</html>

样式 static/style.css:参考 Linear、Claude 这类海外产品的浅色风格 —— 浅灰侧栏配白色聊天区,用户消息靠右显示成浅灰气泡,助手消息去掉卡片边框、直接以纯文本铺在留白里,工具调用是浅灰小标签并放在回答上方(先调用工具、再给回答),底部输入框做成浮起的圆角卡片,整体留白和字号都更松。链接里的 ?v=4 是资源版本号,改了静态文件后把它加一,可强制浏览器加载新版本,避免被缓存卡住。

/* static/style.css */

:root {

  /* 海外 SaaS 浅色风:克制、低饱和、大留白 */

  --bg: #ffffff;

  --bg-soft: #fafafa;

  --sidebar-bg: #f7f7f8;

  --sidebar-hover: #ececee;

  --sidebar-active: #ffffff;

  --panel: #ffffff;

  --border: #ececf1;

  --border-strong: #d9dce3;

  --text: #1a1a20;

  --text-soft: #3f3f46;

  --muted: #8e8ea0;

  --muted-2: #b4b4bd;

  --accent: #2563eb;

  --ink: #1f2430;          /* 主按钮深色,比高饱和蓝更克制 */

  --user-bubble: #f0f2f5;  /* 用户消息:浅灰气泡,深色文字 */

  --tool-bg: #f2f2f4;

  --tool-text: #71717a;

  --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);

  --shadow-md: 0 4px 20px rgba(16, 24, 40, 0.08);

  --shadow-lg: 0 8px 32px rgba(16, 24, 40, 0.10);

  --radius: 16px;

}

* { box-sizing: border-box; }

html, body { height: 100%; margin: 0; }

body {

  font-family: -apple-system, BlinkMacSystemFont, "Inter", "SF Pro Text",

    "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;

  color: var(--text);

  background: var(--bg);

  -webkit-font-smoothing: antialiased;

  text-rendering: optimizeLegibility;

}

.app { display: flex; height: 100vh; overflow: hidden; }

/* 细滚动条 */

::-webkit-scrollbar { width: 8px; height: 8px; }

::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.12); border-radius: 8px; }

::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.22); }

::-webkit-scrollbar-track { background: transparent; }

/* ---------- 左侧栏 ---------- */

.sidebar {

  width: 280px;

  flex-shrink: 0;

  background: var(--sidebar-bg);

  color: var(--text-soft);

  display: flex;

  flex-direction: column;

  padding: 22px 16px 16px;

  border-right: 1px solid var(--border);

}

.brand { padding: 6px 10px 22px; }

.brand-title {

  font-size: 16.5px;

  font-weight: 650;

  color: var(--text);

  letter-spacing: -0.01em;

}

.brand-sub {

  font-size: 12px;

  color: var(--muted);

  margin-top: 3px;

  font-weight: 450;

  letter-spacing: 0.02em;

}

.btn-new {

  width: 100%;

  padding: 11px 14px;

  border: 1px solid var(--border-strong);

  background: var(--panel);

  color: var(--text);

  border-radius: 12px;

  font-size: 14px;

  font-weight: 500;

  cursor: pointer;

  box-shadow: var(--shadow-sm);

  transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;

}

.btn-new:hover { background: #f4f4f6; border-color: #cfd2da; }

.btn-new:active { transform: translateY(0.5px); }

.side-label {

  font-size: 11.5px;

  color: var(--muted);

  margin: 24px 10px 9px;

  font-weight: 550;

  letter-spacing: 0.06em;

  text-transform: uppercase;

}

.session-list { flex: 1; overflow-y: auto; margin: 0 -6px; padding: 0 6px; }

.session-item {

  padding: 10px 12px;

  border-radius: 10px;

  font-size: 13.5px;

  font-weight: 450;

  color: var(--text-soft);

  cursor: pointer;

  margin-bottom: 2px;

  white-space: nowrap;

  overflow: hidden;

  text-overflow: ellipsis;

  position: relative;

  transition: background 0.12s ease;

}

.session-item:hover { background: var(--sidebar-hover); }

.session-item.active {

  background: var(--sidebar-active);

  color: var(--text);

  font-weight: 520;

  box-shadow: var(--shadow-sm);

}

.session-item .del {

  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);

  opacity: 0; color: var(--muted); font-size: 12px;

  width: 22px; height: 22px; line-height: 22px; text-align: center;

  border-radius: 6px; transition: opacity 0.12s, background 0.12s, color 0.12s;

}

.session-item:hover .del { opacity: 1; }

.session-item .del:hover { background: #e4e4e8; color: #ef4444; }

.side-foot { position: relative; border-top: 1px solid var(--border); padding-top: 14px; }

.btn-ghost {

  width: 100%;

  background: transparent;

  border: 1px solid transparent;

  color: var(--text-soft);

  padding: 9px 12px;

  border-radius: 10px;

  font-size: 13px;

  font-weight: 480;

  cursor: pointer;

  transition: background 0.12s ease;

}

.btn-ghost:hover { background: var(--sidebar-hover); }

.badge {

  display: inline-block;

  min-width: 19px;

  padding: 1px 6px;

  margin-left: 5px;

  background: var(--ink);

  color: #fff;

  border-radius: 10px;

  font-size: 11px;

  font-weight: 550;

  vertical-align: 1px;

}

.doc-panel {

  position: absolute;

  bottom: 52px; left: 0; right: 0;

  background: var(--panel);

  border: 1px solid var(--border);

  border-radius: 12px;

  padding: 10px 12px;

  max-height: 240px;

  overflow-y: auto;

  font-size: 12.5px;

  box-shadow: var(--shadow-lg);

}

.doc-panel .doc-line {

  padding: 7px 2px;

  border-bottom: 1px solid var(--border);

  color: var(--text-soft);

}

.doc-panel .doc-line:last-child { border-bottom: none; }

.doc-panel .doc-empty { color: var(--muted); padding: 4px 2px; }

.hidden { display: none !important; }

/* ---------- 右侧主区 ---------- */

.main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }

.topbar {

  height: 64px;

  flex-shrink: 0;

  background: rgba(255, 255, 255, 0.85);

  backdrop-filter: saturate(180%) blur(10px);

  border-bottom: 1px solid var(--border);

  display: flex;

  align-items: center;

  justify-content: space-between;

  padding: 0 28px;

}

.chat-title {

  font-size: 14.5px;

  font-weight: 550;

  color: var(--text-soft);

  max-width: 60%;

  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;

}

.btn-outline {

  border: 1px solid var(--border-strong);

  background: var(--panel);

  padding: 8px 15px;

  border-radius: 10px;

  font-size: 13px;

  font-weight: 480;

  cursor: pointer;

  color: var(--text-soft);

  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;

}

.btn-outline:hover { border-color: #b9bdc8; color: var(--text); background: var(--bg-soft); }

.btn-outline:disabled { opacity: 0.55; cursor: default; }

.messages {

  flex: 1;

  overflow-y: auto;

  padding: 36px 0 20px;

}

.msg {

  max-width: 780px;

  margin: 0 auto;

  padding: 14px 28px;

  display: flex;

}

.msg.user { flex-direction: row-reverse; }

.bubble {

  max-width: 80%;

  padding: 12px 17px;

  border-radius: var(--radius);

  font-size: 15px;

  line-height: 1.78;

  letter-spacing: 0.005em;

  white-space: normal;

  word-break: break-word;

}

/* 用户:浅灰气泡靠右 */

.msg.user .bubble {

  background: var(--user-bubble);

  color: var(--text);

  border-top-right-radius: 5px;

  font-weight: 450;

}

/* 助手:去卡片化,纯文本铺在留白里 */

.msg.assistant .bubble {

  background: transparent;

  border: none;

  padding: 0;

  max-width: 100%;

  color: var(--text);

  border-top-left-radius: 0;

}

.bubble p { margin: 0 0 10px; }

.bubble p:last-child { margin-bottom: 0; }

.bubble pre {

  background: #0f172a; color: #e2e8f0;

  padding: 15px 17px; border-radius: 12px; overflow-x: auto;

  font-size: 13px; line-height: 1.6;

  margin: 12px 0;

}

.bubble code { font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }

.bubble :not(pre) > code {

  background: #f1f2f5; padding: 2px 6px; border-radius: 6px;

  font-size: 13px; color: #b91c1c;

}

.msg.user :not(pre) > code { background: #e3e6eb; color: #b91c1c; }

.bubble ul, .bubble ol { margin: 8px 0; padding-left: 24px; }

.bubble li { margin: 4px 0; }

.bubble table { border-collapse: collapse; margin: 12px 0; font-size: 14px; }

.bubble th, .bubble td {

  border: 1px solid var(--border-strong);

  padding: 8px 13px;

  text-align: left;

}

.bubble th { background: var(--bg-soft); font-weight: 600; }

.bubble a { color: var(--accent); text-decoration: none; }

.bubble a:hover { text-decoration: underline; }

.bubble.thin { white-space: pre-wrap; }

/* 工具调用:克制的浅灰小标签 */

.tool-chip { max-width: 780px; margin: 0 auto; padding: 2px 28px; }

.tool-chip span {

  display: inline-block;

  background: var(--tool-bg);

  color: var(--tool-text);

  font-size: 12px;

  font-weight: 480;

  padding: 4px 11px;

  border-radius: 8px;

  margin: 4px 0;

}

.cursor {

  display: inline-block; width: 7px; height: 16px;

  background: var(--text); margin-left: 2px;

  animation: blink 1.1s steps(2) infinite;

  vertical-align: -2px;

  border-radius: 1px;

}

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

/* 空状态:居中、放大、呼吸感 */

.empty-tip { text-align: center; margin-top: 16vh; padding: 0 32px; }

.empty-title {

  font-size: 26px;

  font-weight: 650;

  color: var(--text);

  letter-spacing: -0.02em;

}

.empty-desc {

  color: var(--muted);

  font-size: 14.5px;

  margin-top: 12px;

  line-height: 1.7;

}

.empty-examples {

  margin-top: 34px;

  display: flex;

  gap: 12px;

  justify-content: center;

  flex-wrap: wrap;

}

.example {

  border: 1px solid var(--border);

  background: var(--panel);

  border-radius: 22px;

  padding: 10px 19px;

  font-size: 13.5px;

  font-weight: 450;

  color: var(--text-soft);

  cursor: pointer;

  box-shadow: var(--shadow-sm);

  transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;

}

.example:hover {

  border-color: var(--border-strong);

  box-shadow: var(--shadow-md);

  color: var(--text);

}

.example:active { transform: translateY(0.5px); }

/* ---------- 底部输入区 ---------- */

.composer {

  flex-shrink: 0;

  background: linear-gradient(to top, var(--bg) 70%, rgba(255, 255, 255, 0));

  padding: 8px 28px 22px;

}

.tool-status { max-width: 780px; margin: 0 auto 8px; min-height: 0; }

.tool-status span {

  display: inline-block; background: var(--tool-bg); color: var(--tool-text);

  font-size: 12px; padding: 4px 11px; border-radius: 8px; margin-right: 6px;

}

.input-row {

  max-width: 780px;

  margin: 0 auto;

  display: flex;

  gap: 12px;

  align-items: flex-end;

  background: var(--panel);

  border: 1px solid var(--border-strong);

  border-radius: 20px;

  padding: 9px 9px 9px 18px;

  box-shadow: var(--shadow-md);

  transition: border-color 0.15s ease, box-shadow 0.15s ease;

}

.input-row:focus-within {

  border-color: #b6bcc9;

  box-shadow: var(--shadow-lg);

}

#input {

  flex: 1;

  resize: none;

  border: none;

  padding: 5px 0;

  font-size: 15px;

  line-height: 1.6;

  font-family: inherit;

  max-height: 180px;

  outline: none;

  background: transparent;

  color: var(--text);

}

#input::placeholder { color: var(--muted-2); }

.btn-send {

  background: var(--ink);

  color: #fff;

  border: none;

  border-radius: 13px;

  padding: 10px 24px;

  font-size: 14px;

  font-weight: 550;

  cursor: pointer;

  transition: background 0.15s ease, transform 0.05s ease;

  flex-shrink: 0;

}

.btn-send:hover:not(:disabled) { background: #343b4c; }

.btn-send:active:not(:disabled) { transform: translateY(0.5px); }

.btn-send:disabled { background: #c9ccd4; cursor: not-allowed; }

交互逻辑 static/app.js:会话的增删切换、用 fetch + ReadableStream 逐帧解析 SSE、流式过程中显示纯文本、结束后再渲染成 Markdown、FormData 上传。

// static/app.js

// ============== 状态 ==============

let sessions = [];

let currentSid = null;

let streaming = false;

const TOOL_LABELS = {

  search_knowledge: "🔍 检索知识库",

  memory_search: "🧠 回忆长期记忆",

  AnySearch: "🌐 联网搜索",

};

const $ = (id) => document.getElementById(id);

const messagesEl = $("messages");

const inputEl = $("input");

// ============== API ==============

async function api(path, options = {}) {

  const res = await fetch(path, options);

  if (!res.ok) {

    let msg = res.statusText;

    try { msg = (await res.json()).detail || msg; } catch (e) {}

    throw new Error(msg);

  }

  return res.json();

}

// ============== 会话 ==============

async function loadSessions(selectSid = null) {

  const data = await api("/api/sessions");

  sessions = data.sessions;

  renderSessionList();

  if (selectSid) {

    switchSession(selectSid);

  }

}

function renderSessionList() {

  const el = $("session-list");

  el.innerHTML = "";

  sessions.forEach((s) => {

    const div = document.createElement("div");

    div.className = "session-item" + (s.sid === currentSid ? " active" : "");

    const title = document.createElement("span");

    title.textContent = s.title;

    const del = document.createElement("span");

    del.className = "del";

    del.textContent = "✕";

    del.onclick = async (e) => {

      e.stopPropagation();

      await api(`/api/sessions/${s.sid}`, { method: "DELETE" });

      if (s.sid === currentSid) currentSid = null;

      const rest = sessions.filter((x) => x.sid !== s.sid);

      if (rest.length) await loadSessions(rest[0].sid);

      else await newSession();

    };

    div.appendChild(title);

    div.appendChild(del);

    div.onclick = () => switchSession(s.sid);

    el.appendChild(div);

  });

}

async function newSession() {

  const s = await api("/api/sessions", { method: "POST" });

  await loadSessions(s.sid);

}

async function switchSession(sid) {

  if (streaming) return;

  currentSid = sid;

  renderSessionList();

  const data = await api(`/api/sessions/${sid}/messages`);

  $("chat-title").textContent = data.title || "新对话";

  renderHistory(data.messages);

}

function renderHistory(messages) {

  messagesEl.innerHTML = "";

  if (!messages.length) { showEmpty(); return; }

  messages.forEach((m) => appendBubble(m.role, m.content, false));

  scrollBottom();

}

function showEmpty() {

  messagesEl.innerHTML =

    '<div class="empty-tip"><div class="empty-title">开始提问</div>' +

    '<div class="empty-desc">上传文档后可基于资料问答;也能联网搜索、跨会话记住你的偏好。</div>' +

    '<div class="empty-examples">' +

    '<button class="example" data-q="根据我上传的文档,总结一下主要内容">📄 总结上传的文档</button>' +

    '<button class="example" data-q="帮我搜索一下 AgentScope 2.0 的最新动态">🔎 联网搜索最新动态</button>' +

    '<button class="example" data-q="记住我偏好简洁的中文回答">🧠 记住我的回答偏好</button>' +

    "</div></div>";

  messagesEl.querySelectorAll(".example").forEach((b) => {

    b.onclick = () => { inputEl.value = b.dataset.q; autoGrow(); send(); };

  });

}

// ============== 消息渲染 ==============

function appendBubble(role, text, streamingNow) {

  document.querySelector(".empty-tip")?.remove();

  const wrap = document.createElement("div");

  wrap.className = `msg ${role}`;

  const bubble = document.createElement("div");

  bubble.className = "bubble";

  if (role === "assistant" && streamingNow) {

    bubble.classList.add("thin");

    bubble.textContent = text;

  } else if (role === "assistant") {

    bubble.innerHTML = DOMPurify.sanitize(marked.parse(text || ""));

  } else {

    bubble.textContent = text;

  }

  wrap.appendChild(bubble);

  messagesEl.appendChild(wrap);

  scrollBottom();

  return bubble;

}

function addToolChip(name, anchor = null) {

  const bar = document.createElement("div");

  bar.className = "tool-chip";

  const span = document.createElement("span");

  span.textContent = TOOL_LABELS[name] || name;

  bar.appendChild(span);

  // 插到当前助手气泡之前,体现「先调用工具、再回答」

  if (anchor) messagesEl.insertBefore(bar, anchor.closest(".msg") || anchor);

  else messagesEl.appendChild(bar);

  scrollBottom();

}

function addNotice(text) {

  const bar = document.createElement("div");

  bar.className = "tool-chip";

  const span = document.createElement("span");

  span.textContent = text;

  bar.appendChild(span);

  messagesEl.appendChild(bar);

  scrollBottom();

}

function scrollBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; }

// ============== 发送 + SSE 流式 ==============

async function send() {

  const text = inputEl.value.trim();

  if (!text || streaming || !currentSid) return;

  streaming = true;

  $("btn-send").disabled = true;

  inputEl.value = "";

  autoGrow();

  appendBubble("user", text, false);

  const bubble = appendBubble("assistant", "", true);

  const cursor = document.createElement("span");

  cursor.className = "cursor";

  bubble.appendChild(cursor);

  let full = "";

  const toolSeen = new Set();

  $("tool-status").innerHTML = "";

  try {

    const res = await fetch(`/api/sessions/${currentSid}/chat`, {

      method: "POST",

      headers: { "Content-Type": "application/json" },

      body: JSON.stringify({ message: text }),

    });

    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const reader = res.body.getReader();

    const decoder = new TextDecoder();

    let buffer = "";

    while (true) {

      const { value, done } = await reader.read();

      if (done) break;

      buffer += decoder.decode(value, { stream: true });

      const frames = buffer.split("\n\n");

      buffer = frames.pop();

      for (const frame of frames) handleFrame(frame);

    }

    function handleFrame(frame) {

      const lines = frame.split("\n");

      let event = "message", data = "";

      lines.forEach((ln) => {

        if (ln.startsWith("event:")) event = ln.slice(6).trim();

        else if (ln.startsWith("data:")) data += ln.slice(5).trim();

      });

      if (!data) return;

      const payload = JSON.parse(data);

      if (event === "tool") {

        if (!toolSeen.has(payload.name)) {

          toolSeen.add(payload.name);

          addToolChip(payload.name, bubble);

          const tag = document.createElement("span");

          tag.textContent = TOOL_LABELS[payload.name] || payload.name;

          $("tool-status").appendChild(tag);

        }

      } else if (event === "token") {

        full += payload.delta;

        bubble.textContent = full;

        bubble.appendChild(cursor);

        scrollBottom();

      } else if (event === "error") {

        full += `\n⚠️ ${payload.message}`;

      } else if (event === "done") {

        if (payload.title) $("chat-title").textContent = payload.title;

      }

    }

    // 结束:渲染 Markdown

    bubble.classList.remove("thin");

    bubble.innerHTML = DOMPurify.sanitize(marked.parse(full || "(无回复)"));

    await loadSessions(currentSid);

  } catch (err) {

    bubble.classList.remove("thin");

    bubble.textContent = "请求失败:" + err.message;

  } finally {

    cursor?.remove();

    streaming = false;

    $("btn-send").disabled = false;

    inputEl.focus();

  }

}

// ============== 上传 / 知识库 ==============

async function refreshDocCount() {

  const data = await api("/api/documents");

  $("doc-count").textContent = data.documents.length;

  const panel = $("doc-panel");

  if (!data.documents.length) {

    panel.innerHTML = '<div class="doc-empty">还没有上传文档</div>';

  } else {

    panel.innerHTML = data.documents

      .map((d) => `<div class="doc-line">📄 ${d.filename} <span style="color:#9aa3b2">· ${d.chunks} 块</span></div>`)

      .join("");

  }

  return data.documents;

}

$("btn-upload").onclick = () => $("file-input").click();

$("file-input").onchange = async (e) => {

  const files = [...e.target.files];

  if (!files.length) return;

  const fd = new FormData();

  files.forEach((f) => fd.append("files", f));

  $("btn-upload").disabled = true;

  try {

    const data = await api("/api/documents/upload", { method: "POST", body: fd });

    const names = data.documents.map((d) => d.filename).join("、");

    addNotice(`📄 已入库 ${data.documents.length} 个文档:${names},现在可以基于它们提问`);

    await refreshDocCount();

  } catch (err) {

    addNotice("⚠️ 上传失败:" + err.message);

  } finally {

    $("btn-upload").disabled = false;

    e.target.value = "";

  }

};

$("btn-docs").onclick = async () => {

  const panel = $("doc-panel");

  panel.classList.toggle("hidden");

  if (!panel.classList.contains("hidden")) await refreshDocCount();

};

// ============== 输入交互 ==============

function autoGrow() {

  inputEl.style.height = "auto";

  inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px";

}

inputEl.addEventListener("input", autoGrow);

inputEl.addEventListener("keydown", (e) => {

  if (e.key === "Enter" && !e.shiftKey) {

    e.preventDefault();

    send();

  }

});

$("btn-send").onclick = send;

$("btn-new").onclick = newSession;

// ============== 启动 ==============

(async function init() {

  await refreshDocCount();

  const data = await api("/api/sessions");

  if (data.sessions.length) await loadSessions(data.sessions[0].sid);

  else await newSession();

})();

前端有三个处理需要留意:

  • SSE 用 fetch 的 ReadableStream 手动解析。EventSource 只支持 GET,而问答接口是 POST(要带消息体),所以用 fetch 拿到响应流,按 \n\n 切分事件帧,半帧留在 buffer 里等下一块,不能假设一次 read 正好拿到完整帧。

  • 流式时显示纯文本,结束后再渲染 Markdown。流式过程中内容是不完整的,随时渲染 Markdown 会出现半截语法、表格错版,所以过程中用 textContent 按 white-space: pre-wrap 显示,收到 done 后再一次性 marked.parse。

  • 模型输出不能直接 innerHTML。回答内容来自模型,属于不可信文本,渲染前先用 DOMPurify.sanitize 清洗,防止模型输出里夹带 <script> 造成存储型 XSS。用户自己的消息则一律用 textContent。

11.8 启动与实测

在 codes/ai_assistant 目录下启动(虚拟环境沿用前面章节装好的):

cd codes/ai_assistant

uvicorn app.main:app --reload --host 127.0.0.1 --port 8000

启动日志,能看到知识库集合和 ReMe 工作区就绪,随后是各接口的访问记录:

aia_00_server.png

浏览器打开 http://127.0.0.1:8000。

第一步,上传文档。 点右上角「上传文档」,选择 sample_docs/product-faq.md(一份产品 FAQ)。上传成功后左下角知识库计数变成 1,聊天区出现入库提示:

aia_02_upload.png

点左下角「知识库」,可以看到当前库里的文档和分块数:

aia_03_kbpanel.png

第二步,基于文档问答。 问「申请退款后多久能到账」。模型先调用 search_knowledge 检索知识库(界面上出现绿色的「检索知识库」标签),然后严格按文档内容回答,结尾标注来源:

aia_04_rag.png

普通订单 3 个工作日、大额订单 5 到 7 个工作日,和上传的 FAQ 内容一致,没有编造。

第三步,联网搜索。 问一个知识库里没有、需要实时信息的问题。模型判断需要联网,调用 AnySearch,界面出现「联网搜索」标签:

aia_05_web.png

第四步,写入长期记忆。 新建一个对话,告诉它个人信息。ReMe 会在后台把这些信息抽取成记忆卡片,助手确认已记住:

aia_06_memory_write.png

第五步,验证跨会话召回。 再新建一个全新的对话(注意左侧已经有三个会话),直接问「你还记得我是谁、在哪个城市、做什么工作吗」。这一轮没有任何上下文,模型调用 memory_search 检索长期记忆,准确回答出上一个会话里告诉它的信息:

aia_07_recall.png

到这里,上传、RAG、联网、跨会话记忆、多会话、流式输出这六项功能全部跑通。

11.9 它离生产环境还差什么

这个案例在架构分层和功能完整度上接近一个真实产品,但直接对外提供服务还有明显缺口。按重要程度列出来,也作为你继续改造的清单:

方面 现状 生产环境需要
用户体系 用户 ID 写死为 1,所有人共用一份知识库和记忆 接入登录鉴权(OAuth/JWT),知识库集合、ReMe 工作区、会话全部按真实用户 ID 隔离
会话与消息存储 存在进程内存的 dict 里,服务重启后对话列表和历史消息丢失(向量库和 ReMe 已持久化) 会话、消息落 PostgreSQL/MySQL,向量库和记忆库也应按用户分集合或加过滤条件
文件上传 只按扩展名判断类型,没有大小限制、数量限制、病毒扫描 限制大小和类型、校验文件头、对象存储(S3/OSS)托管、异步解析任务队列
鉴权与跨域 CORS allow_origins=["*"],接口无任何鉴权 收紧 CORS 来源,接口加认证与授权
限流与配额 无限制,任何人可无限调用模型和搜索接口 按用户限流、模型 token 配额、上传配额
向量库 本地 Qdrant 文件模式,单进程、单点、无并发 部署 Qdrant Server 集群或托管服务,带备份和副本
并发模型 单 uvicorn 进程,--reload 只适合开发 多 worker + Uvicorn/Gunicorn,无状态化(会话状态外置后才能水平扩容)
可观测性 只有 print 和 uvicorn 访问日志 结构化日志、请求追踪(trace id)、指标监控、工具调用和模型耗时统计
错误处理 异常统一转一帧 error,信息较粗 区分可重试 / 不可重试错误,前端分级提示,错误上报
测试 无自动化测试 引擎、接口、SSE 帧解析的单元测试和端到端测试
密钥 明文写在 codes/config.py 走环境变量或密钥管理服务,不进代码仓库
部署 本地手动启动 Docker 镜像、CI/CD、前后端分离部署、Nginx 正确配置 SSE 不缓冲

另外两个功能层面的已知限制:

  • 前端刷新后能拉回历史消息,但历史消息不还原工具调用标签(接口只存了角色和文本,没存这一轮调了哪些工具)。要还原得在消息结构里额外记录工具调用事件。

  • ReMe 的记忆写入依赖一次额外的模型抽取,回答完到记忆可检索之间有短暂延迟;示例里用每轮后手动 reindex 抹平,生产环境应改成后台任务,避免让用户的请求为建索引耗时买单。

所以更准确的说法是:它是一个生产级架构的雏形,不是生产级产品。分层、流式、持久化、工具编排这些骨架是对的,缺的是多租户、安全、运维和测试这些让系统能被陌生人可靠使用的部分。

11.10 小结

这一节把前面零散的能力拼成了一个完整应用:

  • 后端用 FastAPI 把 Agent 包成 HTTP 服务,SSE 把模型的流式输出推给浏览器;

  • 引擎在启动时一次性装配模型、Qdrant 知识库、RAG 和 ReMe 两个中间件、AnySearch 工具,每个会话 new 一个 Agent 共享这些单例;

  • 前端用原生三件套实现多会话、文档上传、流式渲染和工具调用状态;

  • 跨会话记忆的关键,是让同一用户的所有 Agent 共用一个 ReMe 工作区;

  • 最后如实列出了它和生产环境的差距。

整个专栏到这里,你已经走完了从安装、模型配置、工具、技能、上下文压缩、长期记忆、RAG,到一个完整 Web 应用的全过程。剩下的,是挑一个你自己的真实场景,把这套骨架改成你的产品。

源码地址:https://github.com/LarryLi93/agentscope2

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8618 25
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3040 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2110 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
16天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章