)
Mesop 实战在 DuoChat 中扩展状态管理与实现模型选择对话框Codelab Part 3【免费下载链接】mesopRapidly build AI apps in Python项目地址: https://gitcode.com/GitHub_Trending/me/mesop本篇文章是 Mesop DuoChat Codelab 系列第三部分的核心内容在已有聊天 UI 的基础上扩展应用的状态管理模型并实现一个用于「选择 AI 模型 填写 API Key」的自定义对话框。读完本文你将掌握 Mesop 的me.stateclass多状态类组织方式、通过me.content_component与me.slot()自定义可复用对话框组件的完整套路以及复选框、输入框、按钮等事件处理在真实聊天应用中的组合用法可直接照搬到自己的 Mesop 项目中。系列回顾与本篇目标DuoChat 是一个「同时与多个 AI 模型对话」的示例应用。在 Codelab 第一部分docs/codelab/index.md中我们搭建了环境并创建了main.py与requirements.txt依赖mesop、gunicorn、anthropic、google-generativeai在第二部分docs/codelab/2.md中完成了页面头部、居中的聊天输入区与基础交互State.input与on_blur、send_prompt事件处理。本部分要解决两个核心问题状态扩展单字段状态已经不够用需要同时管理多个会话、选中的模型、API Key、输入内容等因此引入独立的数据模型文件data_model.py。交互对话框实现一个自绘的模型选择对话框modal让用户勾选要对话的模型、填入 Gemini / Claude 的 API Key并支持「未填 Key 的模型置灰不可选」的联动逻辑。扩展状态管理构建 data_model.py首先创建一个data_model.py用结构化的方式承载整个应用的状态from dataclasses import dataclass, field from typing import Literal from enum import Enum import mesop as me Role Literal[user, model] dataclass(kw_onlyTrue) class ChatMessage: role: Role user content: str in_progress: bool False class Models(Enum): GEMINI_1_5_FLASH Gemini 1.5 Flash GEMINI_1_5_PRO Gemini 1.5 Pro CLAUDE_3_5_SONNET Claude 3.5 Sonnet dataclass class Conversation: model: str messages: list[ChatMessage] field(default_factorylist) me.stateclass class State: is_model_picker_dialog_open: bool False input: str conversations: list[Conversation] field(default_factorylist) models: list[str] field(default_factorylist) gemini_api_key: str claude_api_key: str me.stateclass class ModelDialogState: selected_models: list[str] field(default_factorylist)这份数据模型把「应用级状态」与「对话框临时状态」拆成了两个独立的 state class各字段的作用如下数据类 / 状态类字段说明ChatMessagerole消息角色user或model用Literal限定ChatMessagecontent消息正文ChatMessagein_progress标记该消息是否正在流式生成后续 Part 4 用于显示加载态Models—可用的模型枚举值为界面展示名如Gemini 1.5 FlashConversationmodel该会话绑定的模型名Conversationmessages该会话的消息列表使用field(default_factorylist)保证可变默认值安全Stateis_model_picker_dialog_open控制对话框开合Stateinput当前输入框内容Stateconversations多个模型的会话列表为多模型并排对话做准备Statemodels用户最终确认选中的模型列表Stategemini_api_key/claude_api_key两个模型的 API KeyModelDialogStateselected_models对话框内的临时勾选结果确认时才提交给State.models从源码理解 me.stateclassme.stateclass并不是一个魔法黑盒。查看 mesop/init.py 的实现可知def stateclass(cls: type[_T] | None, **kw_args: Any) - type[_T]: def wrapper(cls: type[_T]) - type[_T]: dataclass_cls dataclass_with_defaults(cls, **kw_args) runtime().register_state_class(dataclass_cls) return dataclass_cls ...它做的事情有两件先用dataclass_with_defaults把普通类转成 dataclass并自动填充缺省默认值再通过runtime().register_state_class()注册到 Mesop 运行时。注册后的 state class其字段在serialize_state()/diff_state()见 mesop/runtime/context.py中被序列化并做差异对比Mesop 前端据此增量更新 DOM——这也是为什么你修改状态后界面会自动刷新。而读取状态统一走me.state(State)其实现为def state(state: type[_T]) - _T: return runtime().context().state(state)在 mesop/runtime/context.py 中Context.state()直接从当前上下文即当前用户会话的self._states字典里取出该 state class 的实例如果类没有经过stateclass装饰会抛出MesopDeveloperException提示 Did you forget to decorate your state class with stateclass?。实践要点一个应用可以注册多个 state classMesop 会为每个用户会话各自维护一份实例。把「对话框临时态」与「应用全局态」拆成两个类是为了让确认/取消语义更清晰——取消时只需清掉ModelDialogState不会污染State。实现模型选择对话框dialog.py接下来创建dialog.py基于 demo 画廊中的 对话框模式仓库内的完整示例见 demo/dialog.py封装一个可复用的自定义对话框import mesop as me me.content_component def dialog(is_open: bool): with me.box( styleme.Style( backgroundrgba(0,0,0,0.4), displayblock if is_open else none, height100%, overflow_xauto, overflow_yauto, positionfixed, width100%, z_index1000, ) ): with me.box( styleme.Style( align_itemscenter, displaygrid, height100vh, justify_itemscenter, ) ): with me.box( styleme.Style( background#fff, border_radius20, box_sizingcontent-box, box_shadow( 0 3px 1px -2px #0003, 0 2px 2px #00000024, 0 1px 5px #0000001f ), marginme.Margin.symmetric(vertical0, horizontalauto), paddingme.Padding.all(20), ) ): me.slot() me.content_component def dialog_actions(): with me.box( styleme.Style( displayflex, justify_contentend, marginme.Margin(top20) ) ): me.slot()这段代码用三层嵌套me.box搭出经典 modal 结构遮罩层positionfixedwidth/height100%铺满全屏backgroundrgba(0,0,0,0.4)半透明压暗背景z_index1000保证浮在最上层is_open为 False 时用displaynone直接隐藏。居中容器displaygridheight100vhalign_items/justify_itemscenter实现水平垂直双居中。内容卡片白底、border_radius20圆角、Material Design 风格的box_shadow、paddingme.Padding.all(20)中间通过me.slot()接收调用方注入的内容。content_component 与 slot 的原理me.content_component和me.slot()是 Mesop 组合composite组件的两大支柱定义在 mesop/component_helpers/helper.pyslot(name)的文档注释明确指出它用于在定义 content component 时标记组件树中一个「由子组件提供内容」的位置不传name就是匿名槽unnamed slot传入name则为具名槽named slot同一组件内多个槽必须用唯一名字区分可通过me.NamedSlot类型配合me.slotclass使用。其内部实现是runtime().context().save_current_node_as_slot(name)即把当前节点位置记录下来作为插槽书签供运行时把调用方with dialog(...)块里的内容挂载到这个位置。因此dialog组件自身只管「外壳与布局」内部的表单、复选框、按钮全部由调用方通过with dialog(...)注入——这就是它能在 DuoChat 里被复用而不必关心具体弹窗内容的原因。dialog_actions同理只是把操作按钮右对齐justify_contentend。更新 main.py接入对话框与完整交互现在把main.py整体替换为以下代码将数据模型、对话框与上一节已有的 UI 串起来# Update the imports: import mesop as me from data_model import State, Models, ModelDialogState from dialog import dialog, dialog_actions def change_model_option(e: me.CheckboxChangeEvent): s me.state(ModelDialogState) if e.checked: s.selected_models.append(e.key) else: s.selected_models.remove(e.key) def set_gemini_api_key(e: me.InputBlurEvent): me.state(State).gemini_api_key e.value def set_claude_api_key(e: me.InputBlurEvent): me.state(State).claude_api_key e.value def model_picker_dialog(): state me.state(State) with dialog(state.is_model_picker_dialog_open): with me.box(styleme.Style(displayflex, flex_directioncolumn, gap12)): me.text(API keys) me.input( labelGemini API Key, valuestate.gemini_api_key, on_blurset_gemini_api_key, ) me.input( labelClaude API Key, valuestate.claude_api_key, on_blurset_claude_api_key, ) me.text(Pick a model) for model in Models: if model.name.startswith(GEMINI): disabled not state.gemini_api_key elif model.name.startswith(CLAUDE): disabled not state.claude_api_key else: disabled False me.checkbox( keymodel.value, labelmodel.value, checkedmodel.value in state.models, disableddisabled, on_changechange_model_option, styleme.Style( displayflex, flex_directioncolumn, gap4, paddingme.Padding(top12), ), ) with dialog_actions(): me.button(Cancel, on_clickclose_model_picker_dialog) me.button(Confirm, on_clickconfirm_model_picker_dialog) def close_model_picker_dialog(e: me.ClickEvent): state me.state(State) state.is_model_picker_dialog_open False def confirm_model_picker_dialog(e: me.ClickEvent): dialog_state me.state(ModelDialogState) state me.state(State) state.is_model_picker_dialog_open False state.models dialog_state.selected_models ROOT_BOX_STYLE me.Style( background#e7f2ff, height100%, font_familyInter, displayflex, flex_directioncolumn, ) me.page( path/, stylesheets[ https://fonts.googleapis.com/css2?familyInter:wght100..900displayswap ], ) def page(): model_picker_dialog() with me.box(styleROOT_BOX_STYLE): header() with me.box( styleme.Style( widthmin(680px, 100%), marginme.Margin.symmetric(horizontalauto, vertical36), ) ): me.text( Chat with multiple models at once, styleme.Style(font_size20, marginme.Margin(bottom24)), ) chat_input() def header(): with me.box( styleme.Style( paddingme.Padding.all(16), ), ): me.text( DuoChat, styleme.Style( font_weight500, font_size24, color#3D3929, letter_spacing0.3px, ), ) def switch_model(e: me.ClickEvent): state me.state(State) state.is_model_picker_dialog_open True dialog_state me.state(ModelDialogState) dialog_state.selected_models state.models[:] def chat_input(): state me.state(State) with me.box( styleme.Style( border_radius16, paddingme.Padding.all(8), backgroundwhite, displayflex, width100%, ) ): with me.box(styleme.Style(flex_grow1)): me.native_textarea( valuestate.input, placeholderEnter a prompt, on_bluron_blur, styleme.Style( paddingme.Padding(top16, left16), outlinenone, width100%, borderme.Border.all(me.BorderSide(stylenone)), ), ) with me.box( styleme.Style( displayflex, paddingme.Padding(left12, bottom12), cursorpointer, ), on_clickswitch_model, ): me.text( Model:, styleme.Style(font_weight500, paddingme.Padding(right6)), ) if state.models: me.text(, .join(state.models)) else: me.text((no model selected)) with me.content_button( typeicon, on_clicksend_prompt, disablednot state.models ): me.icon(send) def on_blur(e: me.InputBlurEvent): state me.state(State) state.input e.value def send_prompt(e: me.ClickEvent): state me.state(State) print(fSending prompt: {state.input}) print(fSelected models: {state.models}) state.input 逐块拆解新增逻辑1. 复选框勾选与临时态change_model_optiondef change_model_option(e: me.CheckboxChangeEvent): s me.state(ModelDialogState) if e.checked: s.selected_models.append(e.key) else: s.selected_models.remove(e.key)me.checkbox的key参数在这里起到关键作用每个复选框用keymodel.value即枚举值如 Gemini 1.5 Flash来标识自己事件回调通过e.key得知「哪个复选框变了」再结合e.checked决定是追加还是移除。这正是 mesop/components/checkbox/checkbox.py 中CheckboxChangeEvent的设计checked字段携带勾选状态而key由组件实例注入事件对象。2. 模型与 API Key 联动禁用model_picker_dialog遍历Models枚举时通过model.name前缀判断模型属于哪家厂商if model.name.startswith(GEMINI): disabled not state.gemini_api_key elif model.name.startswith(CLAUDE): disabled not state.claude_api_key else: disabled False即「没有填 Gemini Key 时Gemini 系模型复选框自动置灰Claude 同理」。me.checkbox的完整参数label、checked、disabled、on_change、key、style等可以对照 mesop/components/checkbox/checkbox.py 查看其中checked默认False、disabled默认False。3. 打开 / 关闭 / 确认对话框switch_model把State.is_model_picker_dialog_open置为True打开对话框并把已确认的state.models快照到ModelDialogState.selected_models用state.models[:]复制一份避免引用同一列表导致后续操作互相影响。close_model_picker_dialog只关对话框不做任何提交。confirm_model_picker_dialog关对话框并把ModelDialogState.selected_models提交给State.models——这就是「临时态 / 全局态分离」的价值。4. 发送按钮的可用性联动with me.content_button( typeicon, on_clicksend_prompt, disablednot state.models ): me.icon(send)没有选中任何模型时发送按钮保持禁用从 UI 层面避免无意义的发送。chat_input底部还加了可点击的模型切换区on_clickswitch_model未选择时显示 (no model selected)已选择时用, .join(state.models)展示当前模型列表。5. 页面入口page()在page()里model_picker_dialog()放在最外层遮罩层positionfixed全屏覆盖位置先后不影响布局其余保持 Part 2 的结构。me.page的stylesheets参数用于加载 Inter 字体对应源码 mesop/features/page.py 中的定义页面配置还包括path、title、security_policy、on_load等可选参数。运行验证在项目根目录执行mesop main.py浏览器访问http://localhost:32123你应该看到聊天输入区下方出现 Model: (no model selected) 的模型切换入口点击后弹出半透明遮罩的模型选择对话框在对话框中填写 Gemini / Claude API Key未填 Key 的模型复选框自动禁用勾选若干模型后点 Confirm回到主界面模型切换区显示所选模型发送按钮变为可用。说明32123是 Mesop 开发服务器的默认端口如需修改端口可参考 docs/getting-started/installing.md 中的命令行启动方式查看相关参数。常见问题排查提示 Tried to get the state instance for ... but its not a state class检查data_model.py中的State/ModelDialogState是否都加上了me.stateclass装饰器me.state(...)只能访问已注册的 state class对应 mesop/runtime/context.py 中的校验逻辑。对话框点击开关无效确认dialog()的display参数正确接入了is_open且switch_model/close_model_picker_dialog/confirm_model_picker_dialog三个事件函数都绑定到了对应的on_click/on_change上。复选框勾选状态错乱检查每个me.checkbox的key是否唯一且与model.value一致change_model_option依赖e.key定位目标模型。模型始终置灰确认在me.input上绑定了on_blurset_gemini_api_key/set_claude_api_key并且事件类型是me.InputBlurEvent。界面与预期不符可与仓库内的参考实现对照例如 demo/dialog.py对话框组件模式以及 demo/chat.py、mesop/examples/playground.py 中的多模型聊天与输入处理写法。小结与下一步本部分完成了两件事一是用data_model.py把应用状态组织成「会话列表 模型列表 API Key 对话框临时态」的多状态类结构二是基于me.content_componentme.slot()封装出可复用的dialog/dialog_actions组件并实现了模型勾选、Key 联动禁用、确认/取消的完整交互闭环。这些能力完全来自 Mesop 的运行时状态管理与组合组件机制可以在任意 Mesop 应用中直接复用。在下一部分docs/codelab/4.md中我们将为 Gemini 与 Claude 建立真实的 API 连接实现按模型分发的聊天函数并用 Python 生成器把流式响应实时渲染到界面上——届时Conversation.messages、in_progress字段和每个模型的api_key就会派上用场。【免费下载链接】mesopRapidly build AI apps in Python项目地址: https://gitcode.com/GitHub_Trending/me/mesop创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考