返回

Streamlit text_input回调坑:为何输入没保存及解决方法

Ai

解决 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 函数被调用。它做了两件事:

  1. 在侧边栏渲染了一个 st.sidebar.text_input("New chat name") 组件。
  2. 立刻 尝试读取这个刚渲染的 text_input 的值,并赋给 st.session_state.chat_names[...]

问题就出在这儿了!

问题根源:Streamlit 的执行机制

理解这个问题的关键在于明白 Streamlit 的运行方式:

  1. 脚本重跑 (Rerun): 每当用户与页面上的小部件(Widget,比如按钮、输入框、下拉菜单等)交互时,Streamlit 会 从头到尾重新执行整个 Python 脚本
  2. 状态保持 (st.session_state): 为了在重跑之间保存数据,Streamlit 提供了 st.session_state。它像一个字典,可以在脚本的不同次运行之间传递值。
  3. 回调执行时机: 当你给按钮设置 on_click 回调时,这个回调函数会在 按钮被点击那一次 的脚本重跑过程中执行。

那么,在上面的问题代码里发生了什么?

  1. 点击 "Edit Chat Name" 按钮:
    • Streamlit 开始重跑脚本。
    • 执行到 st.button('Edit Chat Name', on_click=edit_chat_name) 时,检测到按钮被点击,于是调用 edit_chat_name 函数。
  2. 执行 edit_chat_name 函数:
    • st.sidebar.text_input("New chat name") 被执行。它的作用是告诉 Streamlit:“嘿,在这个位置给我画一个文本输入框”。
    • 关键点:这次 脚本重跑中,这个 text_input 刚刚被创建,用户还根本没机会输入任何内容!所以,此时 st.sidebar.text_input(...) 返回的是它的默认值 ,也就是一个空字符串 ""
    • 代码 st.session_state.chat_names[...] = "" 被执行,把当前聊天的名称设置成了空字符串。
  3. 脚本继续执行:
    • 脚本跑完,页面重新渲染。现在侧边栏出现了一个空的文本输入框。
    • 下面的 st.radio 组件读取 st.session_state.chat_names,发现当前聊天的名字是 "",所以显示为空白(或者默认的 "Chat xxx" 如果 get 方法的默认值生效)。
  4. 用户输入新名称:
    • 你在文本输入框里输入了 "Awesome Chat"。
    • 输入内容或者按下回车,这又触发了一次新的 脚本重跑。
  5. 第二次脚本重跑:
    • 脚本从头执行。
    • 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 的值。

原理:

  1. 用一个 session_state 变量 (例如 editing_name) 控制编辑状态。
  2. "Edit Chat Name" 按钮只负责切换这个状态变量。
  3. 当处于编辑状态时,显示 text_input,并为其指定一个 key
  4. 用户在 text_input 中输入内容并按回车后,脚本会重跑。
  5. 在脚本重跑时,我们检查 session_state 中与 text_inputkey 关联的值,如果存在且非空,就用它更新 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})

关键改动:

  1. editing_name 状态: 使用 st.session_state.editing_name 布尔值来跟踪是否正在编辑名称。
  2. toggle_edit_mode 函数: "Edit Name" / "Save Name" 按钮现在调用此函数,它只负责切换 editing_name 的状态。
  3. key 参数:st.text_input 添加了一个 key="new_chat_name_input_key"。这样,输入框的值会自动保存在 st.session_state.new_chat_name_input_key 中。
  4. on_change 回调:st.text_input 添加了 on_change=update_chat_name。当用户输入内容后,按回车或鼠标点击别处(失去焦点),update_chat_name 会被调用。
  5. update_chat_name 函数: 这个回调函数从 st.session_state.new_chat_name_input_key 读取输入值,并用它更新 st.session_state.chat_names。更新成功后,它还会将 editing_name 设回 False,隐藏输入框。
  6. 条件渲染: st.text_input 只在 st.session_state.editing_nameTrue 时才渲染。
  7. 按钮文本切换: "Edit Name" 按钮根据 editing_name 的状态显示 "Edit Name" 或 "Save Name"。

进阶技巧:

  • 输入框预填充:text_inputvalue 参数中设置当前聊天名称,让用户能在现有基础上修改。如代码示例中已添加。
  • 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)

# ... (主界面代码不变)

关键改动:

  1. 使用 st.form(key="edit_name_form") 包裹 text_input 和一个提交按钮 st.form_submit_button("💾 Save")
  2. "Edit Name" 按钮 (edit_button) 只负责将 editing_name 设为 True,显示表单。
  3. 提交逻辑写在 if submitted: 代码块内。只有当用户点击 "Save" 按钮后,这个代码块才会执行。此时,new_name 变量会持有用户在 text_input 中输入的值。
  4. 在提交逻辑中更新 st.session_state.chat_names,并将 editing_name 设回 False
  5. st.rerun() 在保存后调用,可以确保界面立即更新(显示新名称、隐藏表单)。有时不加也能工作,但显式调用更可靠。
  6. 添加了取消按钮,用于退出编辑模式。

方案三:简化版 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 是解决这类问题的基础。