山东大学软件学院项目实训-创新实训-大数据租房推荐智能体-前端部分(3)
虽然上一阶段搞定了“打字机”效果,让 AI 看起来反应很快,但我发现了一个新问题:光有文字,看房体验还是很累。所以,这一阶段的目标很明确,正如上一篇博客提到的下一阶段目标:我要把 AI 的回复从“纯文本”升级成“富媒体卡片”。
1. 遇到的难题:流式传输 vs 结构化数据
这其实是本项目最难的一个技术点。
之前我们用 st.write_stream 来显示打字机效果,但它有个限制:它只能接收字符串。
而房源卡片需要的是结构化的数据(比如:{title: "阳光小区", price: "3500", img: "..."})。如果直接把 JSON 数据混在文字流里吐出来,页面上就会显示一堆乱码代码,用户体验极差。
2. 核心解法:神奇的“旁路传输”
经过一番调研,我设计了一个“旁路传输”的方案。
既然 yield 只能传文字给前端,我于是在 Python 后端搞一个“容器”(List)。
-
主路(文字):AI 继续
yield文本,保证页面上的字是一个个蹦出来的。 -
旁路(数据):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+ 条」策略深化这一阶段内容。
更多推荐
所有评论(0)