第五单元——第十六课:给书店助手加上网页界面

这一课把终端聊天变成网页聊天,继续使用已有的:

  • 知识库检索、库存查询、会员报价。
  • 人工审批与模拟订单保存。
  • 耗时日志和 Token 记录。

不重写业务工具,只增加一个网页入口。 本课先做仅在你电脑上使用的演示版,不涉及公网部署或真实登录。

一、先理解网页和终端的区别

终端程序通过 while True 等待输入。

Streamlit 网页在用户提交消息、点击按钮时,会重新执行页面脚本。因此,不能每次重新创建图,也不能在页面加载时无条件调用模型。

我们使用 st.session_state,保存当前网页会话的图、聊天记录和待审批请求。官方说明

这里要区分:

状态 保存什么
st.session_state 网页显示记录、图对象、审批状态
LangGraph 检查点 图中的消息和执行状态
SQLite 数据库 已保存的模拟订单

网页会话状态不是数据库,浏览器刷新、连接重建或服务器重启可能丢失它。

二、安装网页组件

在 CMD 中执行:

复制代码
cd /d C:\Users\Administrator\langchain-study
.venv\Scripts\activate
python -m pip install streamlit

不需要升级已有的 LangChain 依赖。

三、新建网页入口

在项目根目录新建:

langchain-study\streamlit_app.py

注意:这个文件放在项目根目录,不是放进 bookstore_app 文件夹。

写入:

复制代码
from uuid import uuid4

import streamlit as st

from langchain_core.messages import AIMessage, HumanMessage
from langgraph.types import Command

from bookstore_app.config import USER_ID, RECURSION_LIMIT
from bookstore_app.graph import build_graph
from bookstore_app.tools import UserContext


st.set_page_config(
    page_title="星河书店助手",
    page_icon="📚",
    layout="centered",
)

st.title("📚 星河书店助手")
st.caption("本地练习版:支持规则查询、库存、报价与模拟下单。")
st.warning("这里只保存模拟订单,不扣款、不扣库存。")


def create_session():
    """为当前网页会话创建独立的图和状态。"""
    return {
        "graph": build_graph(),
        "context": UserContext(user_id=USER_ID),
        "config": {
            "configurable": {
                "thread_id": (
                    f"web:{USER_ID}:{uuid4().hex}"
                ),
            },
            "recursion_limit": RECURSION_LIMIT,
        },
        "history": [],
        "pending": [],
        "busy": False,
        "error": None,
    }


# 只在当前网页会话首次打开时创建
if "bookstore_session" not in st.session_state:
    try:
        with st.spinner("正在初始化书店助手......"):
            st.session_state["bookstore_session"] = (
                create_session()
            )
    except Exception as error:
        st.error(
            f"初始化失败:{type(error).__name__}。"
            "请检查模型配置和环境变量。"
        )
        st.stop()


app = st.session_state["bookstore_session"]


def run_graph(payload):
    """只在提交问题或审批决定时调用。"""
    app["busy"] = True

    try:
        with st.spinner(
            "正在处理,请稍候。首次查询知识库可能较慢......"
        ):
            result = app["graph"].invoke(
                payload,
                config=app["config"],
                context=app["context"],
            )

        app["pending"] = list(
            result.get("__interrupt__", ())
        )

        if not app["pending"]:
            last_message = result["messages"][-1]

            if (
                not isinstance(last_message, AIMessage)
                or last_message.tool_calls
                or not last_message.content
            ):
                raise RuntimeError("未获得完整最终回答")

            app["history"].append({
                "role": "assistant",
                "content": last_message.content,
            })

    except Exception as error:
        # 不自动重试,避免在结果不确定时再次执行操作
        app["error"] = type(error).__name__

    # 正常返回或捕获异常后结束忙碌状态。
    # 如果脚本被外部中断,没有走到这里,
    # busy 会保留为 True,避免直接再次提交。
    app["busy"] = False


st.sidebar.header("当前会话")
st.sidebar.write("演示用户:", app["context"].user_id)
st.sidebar.caption("A 为会员,B 为普通用户;这不是正式登录系统。")

locked = (
    bool(app["pending"])
    or app["busy"]
    or app["error"] is not None
)

if st.sidebar.button("开启新对话", disabled=locked):
    del st.session_state["bookstore_session"]
    st.rerun()


# 只展示历史,不在这里调用模型
for item in app["history"]:
    with st.chat_message(item["role"]):
        st.markdown(item["content"])


if app["error"] is not None:
    st.error(
        f"本次处理失败:{app['error']}。"
        "当前会话已停止提交,请检查终端和日志。"
    )
    st.warning(
        "如果错误发生在批准模拟下单之后,"
        "记录可能已经保存,请先核对数据库,"
        "不要直接重复下单。"
    )
    st.stop()


if app["busy"]:
    st.warning(
        "上一操作尚未确认完成,暂不接受新提交。"
        "请先检查终端和日志;不要反复点击或刷新重试。"
    )
    st.stop()


# 图暂停时,在网页上展示审批信息
if app["pending"]:
    st.subheader("待确认的模拟订单")

    for number, pending in enumerate(
        app["pending"], start=1
    ):
        details = pending.value

        with st.container(border=True):
            st.write(f"申请 {number}")
            st.write("用户:", details["user_id"])
            st.write("图书:", details["book_name"])
            st.write("数量:", details["quantity"])
            st.write(
                "模拟金额:",
                details["total_yuan"],
                "元",
            )

    st.caption(
        "以下按钮作用于上面列出的全部申请。"
        "未点击同意前,不会批准这些申请。"
    )

    # 每批审批使用自己的按钮标识
    approval_key = ":".join(
        pending.id for pending in app["pending"]
    )

    left, right = st.columns(2)

    reject = left.button(
        "拒绝以上全部申请",
        key=f"reject:{approval_key}",
    )

    approve = right.button(
        "同意以上全部模拟申请",
        key=f"approve:{approval_key}",
    )

    if reject or approve:
        approved = bool(approve and not reject)

        decisions = {
            pending.id: approved
            for pending in app["pending"]
        }

        # 先消费当前页面的审批请求,避免再次使用旧按钮
        app["pending"] = []

        app["history"].append({
            "role": "assistant",
            "content": (
                "你已同意以上模拟申请,正在处理。"
                if approved
                else "你已拒绝以上模拟申请,正在处理。"
            ),
        })

        run_graph(Command(resume=decisions))
        st.rerun()

    # 等待审批期间,不接收新的聊天问题
    st.chat_input(
        "请先处理上方的模拟订单申请",
        disabled=True,
    )
    st.stop()


question = st.chat_input(
    "例如:周六几点关门?或两本三体多少钱?"
)

if question and question.strip():
    question = question.strip()

    app["history"].append({
        "role": "user",
        "content": question,
    })

    run_graph({
        "messages": [HumanMessage(content=question)],
    })

    st.rerun()

本课不需要修改 graph.py、tools.py 或 knowledge.py。

四、启动网页

在同一个已激活虚拟环境、已配置密钥的 CMD 中运行:

复制代码
python -m streamlit run streamlit_app.py --server.address 127.0.0.1

这条命令从项目根目录执行。

浏览器通常会自动打开;如果没有,点击终端显示的 Local URL ,默认通常是 http://localhost:8501。

终端不要关闭,它是网页服务的运行进程。停止服务时,在终端按 Ctrl+C。

五、三个关键设计

1.为什么保存整个图对象?
复制代码
if "bookstore_session" not in st.session_state:
    ...

保证普通页面重跑时复用已有图,而不是重新创建 InMemorySaver,把对话记忆清掉。

原来的知识库缓存也继续生效。不过网页服务是一个新进程,第一次知识库查询仍需加载模型;它不会继承之前 CMD 聊天进程的内存缓存。

2.为什么不是每次都调用 graph.invoke()?

代码只在两个事件里执行它:

复制代码
if question:
    ...

以及:

复制代码
if reject or approve:
    ...

展示历史聊天时只渲染内容,不重新发送旧问题。

st.chat_input 用于接收本次提交的消息,配合 st.chat_message 展示聊天内容。官方说明

3.为什么异常后不自动重试?

假设:

  1. 用户批准模拟订单。
  2. 数据库保存成功。
  3. 模型组织最终回答时超时。

此时用户没看到成功回答,不代表订单记录没保存。

所以页面会停止继续提交,提示核对数据库,而不是自动再执行一次。原有幂等机制继续保留,但不能据此宣称已解决所有跨会话重复操作。

六、按顺序验收

当前配置是用户 B,先进行这些检查:

操作 预期
问"周六几点关门?" 回答晚上十点,并显示来源
问"三体还有几本?" 回答库存 8 本
接着问"那买两本多少钱?" 沿用三体,报价 90 元
问"帮我模拟下单两本三体" 显示书名、数量、金额和审批按钮
点击拒绝 返回取消结果,不新增本次订单记录
再明确发起一次申请并同意 保存模拟记录,显示结果
完成后点击"开启新对话" 清空页面聊天历史,开始新的图会话

"开启新对话"不会删除之前保存的模拟订单。

第十五课的耗时和 Token 日志仍然输出到终端和原来的日志文件中,本课暂时不把日志面板搬进网页。

七、当前版本的边界

  • 仅供本机演示,不是有真实登录和权限控制的线上系统。
  • 页面普通重跑与浏览器刷新不是一回事;刷新或重启不能保证恢复聊天和待审批状态。
  • 模拟订单保存在 SQLite 中,和网页临时状态不同。
  • 处理请求、等待审批时,不要刷新页面或编辑代码触发重载。
  • 本课减少正常页面重跑带来的重复执行,不承诺网络中断、崩溃等情况下的"绝不重复"。

本课目标:你能在网页上完成一轮知识库问答,以及一次拒绝、一次批准的模拟下单。

完成后进入最后一课:第十七课,整理启动说明、回归检查和项目验收。

运行截图:

星河书店助手效果演示

相关推荐
小许学java3 小时前
AI通识与基础
人工智能·langchain
代码方舟3 小时前
零信任架构实战:基于天远企业法人四要素高级版构建自动化电子印章授权与签约准入网关
人工智能·架构·自动化·区块链
专注仿真3 小时前
梯度提升类(XFBoost)算法详解与代码实例
开发语言·人工智能·python·算法·大模型训练·控制算法
外收内放3 小时前
Python基础语法练习题(75)
开发语言·python·学习
IT_陈寒3 小时前
JavaScript的this坑人实录,这次我真的记下来了
前端·人工智能·后端
一只游鱼3 小时前
LangGraph快速入门 —— Agent 是怎么转起来的
人工智能·ai·agent·langgraph
Rocky Ding*4 小时前
深入浅出GroundingPI技术解析:精确空间感知如何迁移到机器人动作
论文阅读·人工智能·深度学习·机器学习·aigc·ai-native·具身智能
starzy19904 小时前
Scala循环与判断:一切皆表达式的控制流
开发语言·人工智能·scala
QYR_114 小时前
全球新能源汽车热管理零部件市场2032年预计突破1854亿元,CAGR达8.2%
人工智能