虽然上一阶段搞定了“打字机”效果,让 AI 看起来反应很快,但我发现了一个新问题:光有文字,看房体验还是很累。所以,这一阶段的目标很明确,正如上一篇博客提到的下一阶段目标:我要把 AI 的回复从“纯文本”升级成“富媒体卡片”。

1. 遇到的难题:流式传输 vs 结构化数据

这其实是本项目最难的一个技术点。

之前我们用 st.write_stream 来显示打字机效果,但它有个限制:它只能接收字符串。
而房源卡片需要的是结构化的数据(比如:{title: "阳光小区", price: "3500", img: "..."})。如果直接把 JSON 数据混在文字流里吐出来,页面上就会显示一堆乱码代码,用户体验极差。

2. 核心解法:神奇的“旁路传输”

经过一番调研,我设计了一个“旁路传输”的方案。

既然 yield 只能传文字给前端,我于是在 Python 后端搞一个“容器”(List)。

  1. 主路(文字):AI 继续 yield 文本,保证页面上的字是一个个蹦出来的。

  2. 旁路(数据):AI 在后台解析数据时,如果发现有房源信息,就把它 append 进那个“容器”里。

这样,等文字流结束了,我的“共享容器”里也装满了数据,直接拿去渲染卡片就行

3.网络层的封装 

为了配合这种机制,我优化了 app_lib/agent_client.py

这里的核心是 post_sse_line_iter 函数。它不再直接返回解析好的文本,而是返回原始的 SSE 行数据。这样做的好处是解耦——具体的解析逻辑(判断哪一行是文本,哪一行是房源 JSON)被下放到了具体的业务逻辑中,而不是写死在网络请求库里。

def post_sse_line_iter(
    url: str,
    json_body: Mapping[str, Any] | None = None,
    *,
    headers: Mapping[str, str] | None = None,
    connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
    read_timeout: float = DEFAULT_READ_TIMEOUT,
) -> Iterator[str]:
    timeout = httpx.Timeout(
        connect=connect_timeout,
        read=read_timeout,
        write=30.0,
        pool=30.0,
    )
    client = httpx.Client(timeout=timeout)
    try:
        with client.stream(
            "POST",
            url,
            json=dict(json_body) if json_body is not None else {},
            headers={
                "Accept": "text/event-stream",
                **(dict(headers) if headers else {}),
            },
        ) as response:
            response.raise_for_status()
            for line in response.iter_lines():
                if line is not None and line != "":
                    yield line
    finally:
        client.close()

同时,build_chat_payload 负责把前端的对话历史包装成后端需要的格式。这里我处理了 history,只保留 user 和 assistant 的角色,防止系统指令污染上下文

4. UI 组件化:打造房源栅格系统 

有了数据,接下来就是怎么展示。我把所有与 UI 相关的逻辑都抽离了 app_lib/listing_cards.py

_render_one_card中,我处理了大量的边界情况。比如:

缺失数据处理:如果房源没有价格或面积,不能直接报错,而是显示“—(数据待补充)”。

链接去重:_listing_links函数会检查多个链接(链家、贝壳、外链),自动去重,并动态生成st.link_button

图片懒加载:虽然Streamlit的st.image很简单,但我还是加了判断,如果没有thumb_url,则显示占位提示。

def _render_one_card(li: Listing, *, key_prefix: str) -> None:
    title = (li.get("title") or "").strip() or "(标题待补充)"
    st.markdown(f"**{title}**")
    price = li.get("price")
    area = li.get("area_sqm")
    col_price, col_area = st.columns(2)
    with col_price:
        if isinstance(price, (int, float)):
            st.caption(_fmt_line("总价", f"{price:.0f} 万"))
        else:
            st.caption(_fmt_line("总价", None))
    with col_area:
        if isinstance(area, (int, float)):
            st.caption(_fmt_line("面积", f"{area:.1f} ㎡"))
        else:
            st.caption(_fmt_line("面积", None))

    st.caption(_fmt_line("户型", li.get("rooms")))
    st.caption(_fmt_line("楼层", li.get("floor")))
    st.caption(_fmt_line("朝向", li.get("orientation")))
    bits = []
    if comm := li.get("community"):
        bits.append(_fmt_line("小区", comm).replace("小区:", "").strip())
    if dist := li.get("district"):
        bits.append(_fmt_line("区域", dist).replace("区域:", "").strip())
    if bits:
        st.caption(" · ".join(bits))
    else:
        st.caption("小区 / 区域:—(数据待补充)")

    score = li.get("score")
    if isinstance(score, (int, float)):
        sf = float(score)
        st.progress(min(1.0, max(0.0, sf / 10.0)))
        st.caption(f"参考分 {sf:.1f}/10(条形占位,详细图表见第 6 段)")
    else:
        st.caption("评分:—(待第 6 段图表接入)")
    thumb = li.get("thumb_url")
    if thumb:
        st.image(str(thumb), use_container_width=True)
    else:
        st.caption("缩略图:—(大图懒加载占位)")

    with st.expander("详情与外链", expanded=False):
        dec = li.get("decoration")
        st.write(_fmt_line("装修", dec if isinstance(dec, str) else None))
        _listing_links(li)
        fid = str(li.get("listing_id", ""))
        bk = _safe_key(key_prefix, "fav", fid)
        ck = _safe_key(key_prefix, "cmp", fid)
        if st.button("收藏", key=bk, use_container_width=False):
            fav = st.session_state.setdefault("favorite_listing_ids", set())
            if fid in fav:
                fav.discard(fid)
                st.toast("已取消收藏")
            else:
                fav.add(fid)
                st.toast("已加入收藏")
        if st.button("加入对比", key=ck, use_container_width=False):
            cmp_ids = st.session_state.setdefault("compare_listing_ids", [])
            if fid and fid not in cmp_ids:
                cmp_ids.append(fid)
            st.toast("已加入对比(入口预留)")

栅格布局与滚动优化
为了模拟真实App的列表感,我使用了st.columns(3)来实现三列栅格。

render_listing_grid中实现了滚动区。为了避免页面无限拉长,我通过st.markdown注入了一段HTML/CSS,限制了房源列表的最大高度(max-height: 720px)并开启overflow-y: auto。这样,当推荐房源过多时,卡片区域会出现独立的滚动条,而不会影响整个聊天界面的布局。

ncols = 3
    st.markdown(
        f'<div style="max-height:{scroll_max_height_px}px;overflow-y:auto;padding-right:6px;">',
        unsafe_allow_html=True,
    )
    idx = 0
    while idx < len(visible):
        cols = st.columns(ncols)
        for c in range(ncols):
            if idx >= len(visible):
                break
            with cols[c]:
                with st.container(border=True):
                    _render_one_card(visible[idx], key_prefix=_safe_key(grid_key, str(idx)))
            idx += 1
    st.markdown("</div>", unsafe_allow_html=True)

通过这种方式,我实现了“字是一个个打出来的,卡片是随后整齐排列的”这一交互。

5. 总结与展望

现在,我们的智能体不仅能“陪聊”,还能“办事”了(虽然都是前端的模拟流式输出)。代码结构上,agent_client 负责路,listing_cards 负责车,app.py 负责调度,分层非常清晰。

随着房源越看越多,我意识到现在的对话历史存在一个致命弱点:刷新即焚。一旦用户不小心刷新了浏览器,之前辛苦筛选的房源和聊过的需求就会全部丢失,所以在下一阶段我准备引入会话历史持久化与「50+ 条」策略深化这一阶段内容。

更多推荐