Streamlit text_input回调坑:为何输入没保存及解决方法
2025-03-26 19:22:05
解决 Streamlit text_input
回调难题:为什么你的输入没保存?
用 Streamlit 做交互应用挺方便,但有时会遇到些小坑。比如,想在侧边栏用个按钮触发 text_input
来修改聊天名称,结果输完名字,界面上的名字却没更新,甚至变空了。这是咋回事呢?
看看下面这段有问题的代码片段(来自原问题):
# ... (部分代码省略)
def edit_chat_name():
if st.session_state.current_chat_id:
# 问题就在这行!
st.session_state.chat_names[st.session_state.current_chat_id] = st.sidebar.text_input("New chat name")
# Sidebar layout
with st.sidebar:
# ...
st.button('Edit Chat Name', on_click=edit_chat_name)
# ...
# 显示聊天名称的 Radio 组件
if st.session_state.chat_sessions:
selected_chat = st.radio(
"Select a chat",
options=list(st.session_state.chat_sessions.keys()),
# format_func 会读取 session_state 中的 chat_names 来显示
format_func=lambda x: st.session_state.chat_names.get(x, f"Chat {x[:8]}")
)
select_chat(selected_chat)
# ... (主界面代码)
当你点击 "Edit Chat Name" 按钮时,edit_chat_name
函数被调用。它做了两件事:
- 在侧边栏渲染了一个
st.sidebar.text_input("New chat name")
组件。 - 立刻 尝试读取这个刚渲染的
text_input
的值,并赋给st.session_state.chat_names[...]
。
问题就出在这儿了!
问题根源:Streamlit 的执行机制
理解这个问题的关键在于明白 Streamlit 的运行方式:
- 脚本重跑 (Rerun): 每当用户与页面上的小部件(Widget,比如按钮、输入框、下拉菜单等)交互时,Streamlit 会 从头到尾重新执行整个 Python 脚本 。
- 状态保持 (
st.session_state
): 为了在重跑之间保存数据,Streamlit 提供了st.session_state
。它像一个字典,可以在脚本的不同次运行之间传递值。 - 回调执行时机: 当你给按钮设置
on_click
回调时,这个回调函数会在 按钮被点击那一次 的脚本重跑过程中执行。
那么,在上面的问题代码里发生了什么?
- 点击 "Edit Chat Name" 按钮:
- Streamlit 开始重跑脚本。
- 执行到
st.button('Edit Chat Name', on_click=edit_chat_name)
时,检测到按钮被点击,于是调用edit_chat_name
函数。
- 执行
edit_chat_name
函数:st.sidebar.text_input("New chat name")
被执行。它的作用是告诉 Streamlit:“嘿,在这个位置给我画一个文本输入框”。- 关键点: 在 这次 脚本重跑中,这个
text_input
刚刚被创建,用户还根本没机会输入任何内容!所以,此时st.sidebar.text_input(...)
返回的是它的默认值 ,也就是一个空字符串""
。 - 代码
st.session_state.chat_names[...] = ""
被执行,把当前聊天的名称设置成了空字符串。
- 脚本继续执行:
- 脚本跑完,页面重新渲染。现在侧边栏出现了一个空的文本输入框。
- 下面的
st.radio
组件读取st.session_state.chat_names
,发现当前聊天的名字是""
,所以显示为空白(或者默认的 "Chat xxx" 如果get
方法的默认值生效)。
- 用户输入新名称:
- 你在文本输入框里输入了 "Awesome Chat"。
- 输入内容或者按下回车,这又触发了一次新的 脚本重跑。
- 第二次脚本重跑:
- 脚本从头执行。
edit_chat_name
函数不会 被调用,因为这次重跑是由text_input
的交互触发的,而不是 "Edit Chat Name" 按钮。- 代码执行到
st.sidebar.text_input("New chat name")
时,Streamlit 发现这个输入框已经存在(并且用户输入了内容),它会显示 "Awesome Chat"。但是 ,它返回的值 "Awesome Chat" 没有 被赋给任何变量,也没有用于更新st.session_state.chat_names
! - 执行到
st.radio
时,它仍然读取st.session_state.chat_names
中那个在第一次点击按钮时被错误设置的空字符串 。
结果就是,你输入了名字,但名字并没有被正确保存到 session_state
里。 hardcode "Greetings" 能行,是因为它在按钮点击那次重跑中就直接、无条件地写入了正确的值,不依赖于当时还不存在的用户输入。
解决方案
要解决这个问题,我们需要调整逻辑,确保在用户输入内容 之后 再去更新 st.session_state.chat_names
。以下提供几种可行方案:
方案一:利用 Widget Key 和状态管理
这是比较常见和推荐的做法。我们引入一个状态变量来控制是否显示输入框,并使用 key
来访问 text_input
的值。
原理:
- 用一个
session_state
变量 (例如editing_name
) 控制编辑状态。 - "Edit Chat Name" 按钮只负责切换这个状态变量。
- 当处于编辑状态时,显示
text_input
,并为其指定一个key
。 - 用户在
text_input
中输入内容并按回车后,脚本会重跑。 - 在脚本重跑时,我们检查
session_state
中与text_input
的key
关联的值,如果存在且非空,就用它更新chat_names
,然后退出编辑状态。
代码示例:
from openai import OpenAI
import streamlit as st
import uuid
# 初始化 session_state
if "chat_sessions" not in st.session_state:
st.session_state.chat_sessions = {}
st.session_state.current_chat_id = None
st.session_state.chat_names = {}
st.session_state.editing_name = False # 新增:控制是否处于编辑名称状态
st.session_state.new_chat_name_input = "" # 新增:临时存储输入框的值,防止回车后被清空
def start_new_chat():
new_chat_id = str(uuid.uuid4())
st.session_state.chat_sessions[new_chat_id] = []
st.session_state.current_chat_id = new_chat_id
st.session_state.chat_names[new_chat_id] = f"Chat {new_chat_id[:4]}" # 给个更有区分度的默认名
st.session_state.editing_name = False # 开始新聊天时,不在编辑状态
def select_chat(chat_id):
st.session_state.current_chat_id = chat_id
st.session_state.editing_name = False # 切换聊天时,退出编辑状态
def remove_chat():
if st.session_state.current_chat_id in st.session_state.chat_sessions:
del st.session_state.chat_sessions[st.session_state.current_chat_id]
del st.session_state.chat_names[st.session_state.current_chat_id]
st.session_state.current_chat_id = list(st.session_state.chat_sessions.keys())[0] if st.session_state.chat_sessions else None
st.session_state.editing_name = False # 删除后退出编辑状态
# 注意:edit_chat_name 函数不再直接处理 text_input
def toggle_edit_mode():
# 只切换编辑状态标志
if st.session_state.current_chat_id:
st.session_state.editing_name = not st.session_state.editing_name
# 进入编辑模式时,预填充当前名称
if st.session_state.editing_name:
st.session_state.new_chat_name_input = st.session_state.chat_names.get(st.session_state.current_chat_id, "")
else:
# 如果从编辑模式退出(可能通过按钮或其他方式),检查是否有输入值需要保存
# 但更好的做法是在text_input的on_change中处理保存逻辑
pass
def update_chat_name():
# 这个函数作为 text_input 的 on_change 回调
new_name = st.session_state.get("new_chat_name_input_key", "").strip()
if new_name and st.session_state.current_chat_id:
st.session_state.chat_names[st.session_state.current_chat_id] = new_name
st.session_state.editing_name = False # 更新成功后自动退出编辑模式
def clear_chat_history():
if st.session_state.current_chat_id:
st.session_state.chat_sessions[st.session_state.current_chat_id] = []
st.session_state.editing_name = False # 清空历史时也退出编辑模式
# --- Sidebar ---
with st.sidebar:
st.title('🤖💬 AI Chatbot')
st.button('➕ New Chat', on_click=start_new_chat)
if st.session_state.chat_sessions:
# 使用 st.radio 选择聊天
selected_chat = st.radio(
" Chats",
options=list(st.session_state.chat_sessions.keys()),
format_func=lambda x: st.session_state.chat_names.get(x, f"Chat {x[:8]}"),
key=f"chat_selector_{st.session_state.current_chat_id}", # 加key避免编辑名称时光标跳走
on_change=lambda: setattr(st.session_state, 'editing_name', False) # 切换聊天时退出编辑
)
if selected_chat != st.session_state.current_chat_id:
select_chat(selected_chat)
# 编辑/保存 按钮 和 输入框
col1, col2 = st.columns(2)
with col1:
edit_button_text = "💾 Save Name" if st.session_state.editing_name else "✏️ Edit Name"
st.button(edit_button_text, on_click=toggle_edit_mode, key="edit_save_button")
with col2:
st.button('🗑️ Remove', on_click=remove_chat)
# 条件渲染 text_input
if st.session_state.editing_name and st.session_state.current_chat_id:
st.text_input(
"Enter new name:",
value=st.session_state.chat_names.get(st.session_state.current_chat_id, ""), # 预填充当前名字
key="new_chat_name_input_key", # 给输入框一个唯一的 key
on_change=update_chat_name, # 当输入框内容改变且失去焦点或按回车时触发
placeholder="Type new name & press Enter"
)
# 解释一下:这里的on_change会在用户修改内容并按 Enter 或点击页面其他地方后触发 update_chat_name 函数。
# update_chat_name 函数会读取 key 对应的 session_state 值来更新名字。
# "Save Name" 按钮现在更像一个视觉提示,主要作用是切换编辑状态和触发最后一次检查更新(虽然on_change更常用)
st.button('🧹 Clear History', on_click=clear_chat_history)
# --- Main Area ---
st.title(st.session_state.chat_names.get(st.session_state.current_chat_id, "Chat")) # 标题显示当前聊天名称
# (其余代码基本不变)
client = OpenAI(base_url="http://localhost:1234/v1", api_key="not-needed")
if "openai_model" not in st.session_state:
st.session_state["openai_model"] = "local-model"
if st.session_state.current_chat_id is None and st.session_state.chat_sessions:
st.session_state.current_chat_id = list(st.session_state.chat_sessions.keys())[0]
elif not st.session_state.chat_sessions:
if st.button("Start your first chat!"): # 友好提示
start_new_chat()
else:
st.stop() # 如果没有聊天会话并且用户没有点击开始,就停止执行避免错误
# 获取当前聊天记录
current_chat = st.session_state.chat_sessions[st.session_state.current_chat_id]
# 显示聊天记录
for message in current_chat:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 处理用户输入
if prompt := st.chat_input("Enter prompt"):
current_chat.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
stream = client.chat.completions.create(
model=st.session_state["openai_model"],
messages=[{"role": m["role"], "content": m["content"]} for m in current_chat],
temperature=0.7,
stream=True,
)
response = st.write_stream(stream)
current_chat.append({"role": "assistant", "content": response})
关键改动:
editing_name
状态: 使用st.session_state.editing_name
布尔值来跟踪是否正在编辑名称。toggle_edit_mode
函数: "Edit Name" / "Save Name" 按钮现在调用此函数,它只负责切换editing_name
的状态。key
参数: 给st.text_input
添加了一个key="new_chat_name_input_key"
。这样,输入框的值会自动保存在st.session_state.new_chat_name_input_key
中。on_change
回调: 给st.text_input
添加了on_change=update_chat_name
。当用户输入内容后,按回车或鼠标点击别处(失去焦点),update_chat_name
会被调用。update_chat_name
函数: 这个回调函数从st.session_state.new_chat_name_input_key
读取输入值,并用它更新st.session_state.chat_names
。更新成功后,它还会将editing_name
设回False
,隐藏输入框。- 条件渲染:
st.text_input
只在st.session_state.editing_name
为True
时才渲染。 - 按钮文本切换: "Edit Name" 按钮根据
editing_name
的状态显示 "Edit Name" 或 "Save Name"。
进阶技巧:
- 输入框预填充: 在
text_input
的value
参数中设置当前聊天名称,让用户能在现有基础上修改。如代码示例中已添加。 - Enter 键提交:
text_input
默认支持按 Enter 键触发表单提交(如果在st.form
内)或触发on_change
回调(如果设置了)。这个方案利用了on_change
。
方案二:使用 st.form
묶定输入和提交
如果你希望有一个明确的“保存”按钮来确认修改,可以使用 st.form
。
原理:
st.form
创建一个表单容器。表单内的所有小部件的值只有在点击表单内的 st.form_submit_button
时才会被一起收集并触发一次脚本重跑。这避免了输入过程中不必要的重跑。
代码示例 (侧边栏部分):
# ... (初始化和函数定义部分类似方案一,但可能不需要 on_change)
def set_editing_true():
if st.session_state.current_chat_id:
st.session_state.editing_name = True
def set_editing_false():
st.session_state.editing_name = False
# --- Sidebar ---
with st.sidebar:
st.title('🤖💬 AI Chatbot')
st.button('➕ New Chat', on_click=start_new_chat)
if st.session_state.chat_sessions:
selected_chat = st.radio(
" Chats",
options=list(st.session_state.chat_sessions.keys()),
format_func=lambda x: st.session_state.chat_names.get(x, f"Chat {x[:8]}"),
key=f"chat_selector_{st.session_state.current_chat_id}"
)
if selected_chat != st.session_state.current_chat_id:
select_chat(selected_chat)
col1, col2 = st.columns(2)
with col1:
# "Edit Name" 按钮仅用于进入编辑模式
if not st.session_state.get('editing_name', False):
st.button("✏️ Edit Name", on_click=set_editing_true, key="edit_button")
with col2:
st.button('🗑️ Remove', on_click=remove_chat)
# 如果在编辑模式,显示表单
if st.session_state.get('editing_name', False) and st.session_state.current_chat_id:
with st.form(key="edit_name_form"):
new_name = st.text_input(
"Enter new name:",
value=st.session_state.chat_names.get(st.session_state.current_chat_id, ""),
key="new_name_in_form" # 表单内的 key
)
submitted = st.form_submit_button("💾 Save")
if submitted:
cleaned_name = new_name.strip()
if cleaned_name: # 确保输入不为空
st.session_state.chat_names[st.session_state.current_chat_id] = cleaned_name
st.session_state.editing_name = False # 提交后退出编辑模式
st.rerun() # 强制刷新界面以显示新名称并隐藏表单
# 提供一个取消按钮
if st.button("❌ Cancel", on_click=set_editing_false, key="cancel_edit"):
pass # 点击取消只改变状态,下次重跑时表单不显示
st.button('🧹 Clear History', on_click=clear_chat_history)
# ... (主界面代码不变)
关键改动:
- 使用
st.form(key="edit_name_form")
包裹text_input
和一个提交按钮st.form_submit_button("💾 Save")
。 - "Edit Name" 按钮 (
edit_button
) 只负责将editing_name
设为True
,显示表单。 - 提交逻辑写在
if submitted:
代码块内。只有当用户点击 "Save" 按钮后,这个代码块才会执行。此时,new_name
变量会持有用户在text_input
中输入的值。 - 在提交逻辑中更新
st.session_state.chat_names
,并将editing_name
设回False
。 st.rerun()
在保存后调用,可以确保界面立即更新(显示新名称、隐藏表单)。有时不加也能工作,但显式调用更可靠。- 添加了取消按钮,用于退出编辑模式。
方案三:简化版 on_change
(仅用作说明,方案一更完善)
如果不想用表单,也可以尝试直接在 text_input
上使用 on_change
,但需要确保 key
的使用正确。
原理:
on_change
回调函数执行时,可以通过 st.session_state[key]
来访问触发回调的那个 text_input
的当前值。
代码示例 (核心部分):
# ...
def update_name_from_input():
# 直接从 session_state 读取 input 的值
new_name = st.session_state.get("chat_name_editor_key", "").strip()
if new_name and st.session_state.current_chat_id:
st.session_state.chat_names[st.session_state.current_chat_id] = new_name
# 可选:更新后自动隐藏输入框
# st.session_state.editing_name = False
def toggle_edit_display():
if st.session_state.current_chat_id:
st.session_state.editing_name = not st.session_state.get('editing_name', False)
# ...
# --- Sidebar ---
with st.sidebar:
# ...
st.button("✏️ Edit Name", on_click=toggle_edit_display)
if st.session_state.get('editing_name', False) and st.session_state.current_chat_id:
st.text_input(
"New name:",
value=st.session_state.chat_names.get(st.session_state.current_chat_id, ""),
key="chat_name_editor_key", # 指定 Key
on_change=update_name_from_input # 设置 on_change 回调
)
# ...
注意: 这个简化方案看起来简洁,但在实际应用中可能不如方案一健壮。比如,何时隐藏输入框需要更仔细地处理(可能在 update_name_from_input
内部或者结合按钮状态)。方案一整合了状态切换和更新,逻辑更清晰。
选择哪种方案取决于你希望的用户交互体验。方案一(Widget Key + 状态管理 + on_change)通常灵活性较好,用户体验也比较流畅(输入后按回车即可)。方案二(st.form)在需要明确“提交”步骤或有多个相关输入项时非常有用。理解 Streamlit 的执行模型和 session_state
是解决这类问题的基础。