From 7b89324981154bb1228cd4a1b77e0044fc1d54ed Mon Sep 17 00:00:00 2001 From: liubiren Date: Tue, 28 Jul 2026 20:08:03 +0800 Subject: [PATCH] 1 --- agent/application/domain_models.py | 30 +------ agent/application/pages/conversation.py | 103 ++++++++++++++++------- agent/application/states/conversation.py | 90 ++++---------------- agent/application/states/database.py | 21 ++++- agent/database.db | Bin 151552 -> 204800 bytes 5 files changed, 111 insertions(+), 133 deletions(-) diff --git a/agent/application/domain_models.py b/agent/application/domain_models.py index 1200d28..749bee6 100644 --- a/agent/application/domain_models.py +++ b/agent/application/domain_models.py @@ -46,31 +46,7 @@ class Conversation(BaseModel): default=False, description="正在运行,True 表示正在运行,False 表示未正在运行" ) dialogs: Dict[str, Dialog] = Field(default_factory=dict, description="对话列表") - created_at: datetime = Field(default_factory=datetime.now, description="创建时间") - - -class ConversationHistoryItem(BaseModel): - """ - 会话历史项领域模型 - """ - - id: str = Field(..., description="会话唯一标识") - description: str = Field(..., description="会话描述") - created_at: str = Field(..., description="会话创建时间") - - -class DialogItem(BaseModel): - """ - 对话项领域模型 - """ - - id: str = Field(..., description="对话唯一标识") - user_prompt: str = Field(..., description="用户提示词") - thoughts: List[Thought] = Field(default_factory=list, description="思考列表") - result_output: str = Field(default="", description="结果输出") - is_thinking: bool = Field( - default=False, description="正在思考,True 表示正在思考,False 表示未正在思考" - ) - is_expanded: bool = Field( - default=False, description="思考折叠面板展开状态,True 表示展开,False 表示折叠" + created_at: str = Field( + ..., + description="创建时间", ) diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index a57b242..dd612fd 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -3,37 +3,37 @@ 会话页面 """ import reflex as rx -from typing import Dict +from typing import Tuple from application.domain_models import ( Conversation, - DialogItem, + Dialog, Thought, - ConversationHistoryItem, ) from application.states import ConversationState, AuthState def conversation_history_item_showing( - item: ConversationHistoryItem, + item: Tuple[str, Conversation], ) -> rx.Component: """ 会话历史项展示 - :param conversation: 会话历史项 + :param item: 会话历史项 :return: Component """ + conversation = item[1] # 高亮:若为当前会话或显示更多则高亮 - highlight: bool = (item.id == ConversationState.shown_more_conversation_id) | ( - item.id == ConversationState.conversation_id - ) + highlight: bool = ( + conversation.id == ConversationState.shown_more_conversation_id + ) | (conversation.id == ConversationState.conversation_id) return rx.list.item( rx.vstack( rx.hstack( # 对话描述 rx.text( - item.description, + conversation.description, flex=1, height="22px", line_height="22px", @@ -52,7 +52,7 @@ def conversation_history_item_showing( rx.spacer(), # 创建时间 rx.text( - item.created_at, + conversation.created_at, line_height="20px", font_size="var(--prismui-font-size-1)", color="var(--prismui-color-8)", @@ -64,7 +64,7 @@ def conversation_history_item_showing( margin_bottom="8px", ), # 点击事件:切换会话 - on_click=lambda: ConversationState.switch_conversation(item.id), + on_click=lambda: ConversationState.switch_conversation(conversation.id), width="100%", ), # 更多按钮:点击更多按钮显示气泡卡片,可删除会话 @@ -97,7 +97,7 @@ def conversation_history_item_showing( "删除", # 点击事件:删除对话 on_click=lambda: ConversationState.delete_conversation( - item.id + conversation.id ), display="flex", align_items="center", @@ -130,7 +130,7 @@ def conversation_history_item_showing( overflow="visible", ), on_open_change=lambda is_shown: ConversationState.set_shown_more_conversation_id( - item.id, is_shown + conversation.id, is_shown ), open_delay=0, ), @@ -195,7 +195,7 @@ def conversation_history_items_showing( ), rx.auto_scroll( rx.foreach( - ConversationState.conversation_history_items, + ConversationState.conversations, conversation_history_item_showing, ), flex="1", @@ -331,19 +331,21 @@ def greeting_showing() -> rx.Component: ) -def thought_showing(item: Thought): +def thought_showing(item: Tuple[int, Thought]): """ 思考展示 :param item: 思考实例 :return: Component """ + # 思考实例 + thought = item[1] return rx.vstack( rx.match( - item.type, + thought.type, ( "thinking", rx.text( - item.content, + thought.content, line_height="22px", font_size="var(--prismui-font-size-2)", color="var(--prismui-color-3)", @@ -356,16 +358,18 @@ def thought_showing(item: Thought): ) -def dialog_item_showing(item: DialogItem) -> rx.Component: +def dialog_showing(item: Tuple[str, Dialog]) -> rx.Component: """ - 对话项展示 - :param item: 对话项实例 + 对话展示 + :param item: 对话实例 :return: Component """ + # 对话实例 + dialog = item[1] # 推理状态 - is_thinking = item.is_thinking + is_thinking = dialog.is_thinking # 思考折叠面板展开状态 - is_expanded = item.is_expanded + is_expanded = dialog.is_expanded return rx.vstack( rx.hstack( @@ -373,7 +377,7 @@ def dialog_item_showing(item: DialogItem) -> rx.Component: rx.vstack( # 用户提示词 rx.text( - item.user_prompt, + dialog.user_prompt, max_width="600px", padding="12px 16px", background_color="var(--prismui-background-color-3)", @@ -439,13 +443,13 @@ def dialog_item_showing(item: DialogItem) -> rx.Component: ), cursor="pointer", # 点击事件,展开/折叠思考折叠面板 - on_click=lambda: ConversationState.toggle_collapse(item.id), + on_click=lambda: ConversationState.toggle_collapse(dialog.id), ), rx.box( rx.box( rx.auto_scroll( rx.foreach( - item.thoughts, + dialog.thoughts, thought_showing, ), width="100%", @@ -467,7 +471,7 @@ def dialog_item_showing(item: DialogItem) -> rx.Component: ), # 结果输出 rx.markdown( - item.result_output, + dialog.result_output, component_map={ "p": lambda text: rx.text( text, @@ -545,14 +549,42 @@ def dialog_item_showing(item: DialogItem) -> rx.Component: "hr": lambda _: rx.divider( height="1px", margin="12px 0", - background_color="var(--prismui-background-color-6)", + background_color="transparent", + ), + "table": lambda children: rx.el.table( + children, + style={ + "margin": "12px 0", + "width": "100%", + "border-collapse": "collapse", + }, + ), + "thead": lambda children: rx.el.thead(children), + "tbody": lambda children: rx.el.tbody(children), + "tr": lambda children: rx.el.tr(children), + "th": lambda text: rx.el.th( + text, + style={ + "padding": "10px 12px", + "background": "var(--prismui-background-color-3)", + "border": "1px solid var(--prismui-background-color-6)", + "text-align": "left", + }, + ), + "td": lambda text: rx.el.td( + text, + style={ + "padding": "10px 12px", + "border": "1px solid var(--prismui-background-color-6)", + "vertical-align": "top", + }, ), }, width="100%", ), padding="0 16px", width="100%", - key=item.id, + key=dialog.id, ) @@ -563,21 +595,26 @@ def dialog_items_showing() -> rx.Component: """ # 当前会话的对话列表 - dialog_items = ConversationState.dialog_items + dialogs = ConversationState.dialogs # 若对话列表为空则显示欢迎,否则显示对话列表 return rx.cond( - dialog_items.length() == 0, + dialogs.length() == 0, # 欢迎展示 greeting_showing(), rx.vstack( rx.auto_scroll( rx.foreach( - dialog_items, - dialog_item_showing, + dialogs, + dialog_showing, ), width="100%", padding="0 12px", padding_bottom="180px", + style={ + "&::-webkit-scrollbar": { + "display": "none", + }, + }, ), flex="1", width="100%", @@ -778,12 +815,14 @@ def user_prompt_sending() -> rx.Component: position="absolute", bottom="0", left="0", + right="0", z_index="99", display="flex", flex_direction="column", width="100%", max_width="1200px", padding="0 12px 12px", + margin_x="auto", border="none", ) diff --git a/agent/application/states/conversation.py b/agent/application/states/conversation.py index d994713..ae40dc3 100644 --- a/agent/application/states/conversation.py +++ b/agent/application/states/conversation.py @@ -26,9 +26,8 @@ import reflex as rx from application.states.database import DatabaseState from application.domain_models import ( Conversation, - DialogItem, + Dialog, Thought, - ConversationHistoryItem, ) instructions: str = """ @@ -117,34 +116,6 @@ class ConversationState(rx.State): """ self.is_conversation_history_shown = not self.is_conversation_history_shown - @rx.var - def conversation_history_items(self) -> List[ConversationHistoryItem]: - """ - 获取会话历史项列表,用于前端渲染会话历史 - :return: 会话历史项列表 - """ - items: List[ConversationHistoryItem] = [] - for conversation in reversed( - self.conversations.values() - ): # 按照会话唯一标识倒序排序 - # 格式化会话创建时间 - match (datetime.now().date() - conversation.created_at.date()).days: - case 0: - created_at = f"{conversation.created_at.strftime('%H:%M')}" - case 1: - created_at = f"昨天 {conversation.created_at.strftime('%H:%M')}" - case _: - created_at = conversation.created_at.strftime("%Y-%m-%d %H:%M") - - items.append( - ConversationHistoryItem( - id=conversation.id, - description=conversation.description, - created_at=created_at, - ) - ) - return items - @rx.event def set_shown_more_conversation_id( self, conversation_id: str, is_shown: bool @@ -252,29 +223,16 @@ class ConversationState(rx.State): return conversation.is_running @rx.var - def dialog_items(self) -> List[DialogItem]: + def dialogs(self) -> Dict[str, Dialog]: """ - 当前会话的对话项列表 - :return: 当前会话的对话项列表 + 当前会话的对话字典 + :return: 当前会话的对话字典 """ # 当前会话 conversation = self.conversations.get(self.conversation_id) if not conversation: - return [] - - items: List[DialogItem] = [] - for dialog in conversation.dialogs.values(): - items.append( - DialogItem( - id=dialog.id, - user_prompt=dialog.user_prompt, - thoughts=list(dialog.thoughts.values()), - result_output=dialog.result_output, - is_thinking=dialog.is_thinking, - is_expanded=dialog.is_expanded, - ) - ) - return items + return {} + return conversation.dialogs @rx.event async def handle_user_prompt(self) -> AsyncGenerator[None]: @@ -293,7 +251,6 @@ class ConversationState(rx.State): conversation = self.conversations[self.conversation_id] # 将运行状态设置为正在运行 conversation.is_running = True - yield database_state = await self.get_state(DatabaseState) @@ -303,20 +260,20 @@ class ConversationState(rx.State): conversation_id=self.conversation_id, user_prompt=user_prompt ) ) + # 强制更新会话并推送前端 + self.conversations[self.conversation_id] = conversation + yield + # 将最后一个对话作为当前对话 dialog = next(reversed(conversation.dialogs.values())) - - # 获取消息历史 - message_history = await database_state.get_message_history( - conversation_id=self.conversation_id - ) - # 初始化工具调用唯一标识和片段索引映射字典 tool_call_ids: Dict[str, int] = {} async with agent.run_stream_events( conversation_id=self.conversation_id, user_prompt=user_prompt, - message_history=message_history, + message_history=await database_state.get_message_history( + conversation_id=self.conversation_id + ), ) as events: async for event in events: match event: @@ -336,7 +293,6 @@ class ConversationState(rx.State): dialog.thoughts[index] = Thought( type="thinking", content=content ) - yield # 工具检索分片开始事件 case ToolSearchCallPart(tool_call_id=tool_call_id): @@ -347,7 +303,6 @@ class ConversationState(rx.State): type="tool_search", content="正在生成检索关键词", ) - yield # 能力加载分片开始事件 case LoadCapabilityCallPart(tool_call_id=tool_call_id): @@ -357,7 +312,6 @@ class ConversationState(rx.State): type="capability_load", content="正在生成加载参数", ) - yield # 工具调用分片开始事件 case ToolCallPart(tool_call_id=tool_call_id): @@ -367,12 +321,10 @@ class ConversationState(rx.State): type="tool_call", content="正在生成调用参数", ) - yield # 文本分片开始事件 case TextPart(content=content): dialog.result_output = content - yield # ========== 增量事件 ========== case PartDeltaEvent(index=index, delta=delta): @@ -382,14 +334,12 @@ class ConversationState(rx.State): content_delta=content_delta, ): dialog.thoughts[index].content += content_delta or "" - yield # 文本分片增量事件 case TextPartDelta( content_delta=content_delta, ): dialog.result_output += content_delta - yield # ========== 结束事件 ========== case PartEndEvent( @@ -399,12 +349,11 @@ class ConversationState(rx.State): ): match part: # 思考分片结束事件 - case ThinkingPart(part_kind=part_kind, content=content): + case ThinkingPart(content=content): # 若下一分片种类为文本则将思考状态设置为思考完成、思考面板展开状态设置为折叠 if next_part_kind == "text": dialog.is_thinking = False dialog.is_expanded = False - yield # ========== 函数工具调用事件 ========== case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part): @@ -414,21 +363,18 @@ class ConversationState(rx.State): # 工具检索 case "tool_search": dialog.thoughts[index].content = "正在检索" - yield # 能力加载 case "capability_load": dialog.thoughts[index].content = ( f"正在加载能力 {part.tool_name}" ) - yield # 工具调用 case "tool_call": dialog.thoughts[index].content = ( f"正在调用工具 {part.tool_name}" ) - yield # ========== 函数工具结果事件 ========== case FunctionToolResultEvent( @@ -442,17 +388,14 @@ class ConversationState(rx.State): dialog.thoughts[index].content = ( content if isinstance(content, str) else "" ) # 暂仅考虑文本内容 - yield # 能力加载 case "capability_load": dialog.thoughts[index].content = "已加载" - yield # 工具调用 case "tool_call": dialog.thoughts[index].content = f"已调用" - yield # ========== 智能体运行结果事件 ========== case AgentRunResultEvent(result=result): @@ -470,7 +413,10 @@ class ConversationState(rx.State): ) # 将当前会话的运行状态设置为运行完成 conversation.is_running = False - yield + + # 强制更新会话并推送前端 + self.conversations[self.conversation_id] = conversation + yield @rx.event def toggle_collapse(self, dialog_id: str) -> None: diff --git a/agent/application/states/database.py b/agent/application/states/database.py index 32d63cd..b1b6701 100644 --- a/agent/application/states/database.py +++ b/agent/application/states/database.py @@ -122,6 +122,22 @@ class ResultRecord(SQLModel, table=True): new_messages: str = Field(description="新增消息") +def format_at(at: datetime) -> str: + """ + 格式化日期时间 + :param at: 日期时间 + :return: 格式化后的日期时间字符串 + """ + match (datetime.now().date() - at.date()).days: + case 0: + formatted_at = f"{at.strftime('%H:%M')}" + case 1: + formatted_at = f"昨天 {at.strftime('%H:%M')}" + case _: + formatted_at = at.strftime("%Y-%m-%d %H:%M") + return formatted_at + + # 思考领域模型类型适配器 ThoughtTypeAdapter = TypeAdapter(Dict[int, Thought]) @@ -221,12 +237,13 @@ class DatabaseState(rx.State): .order_by(ConversationRecord.id, DialogRecord.id) ) for conversation_record, dialog_record in result.all(): + record = records.setdefault( conversation_record.id, Conversation( id=conversation_record.id, description=conversation_record.description, - created_at=conversation_record.created_at, + created_at=format_at(conversation_record.created_at), ), ) if not dialog_record: @@ -263,7 +280,7 @@ class DatabaseState(rx.State): record.id: Conversation( id=record.id, description=record.description, - created_at=record.created_at, + created_at=format_at(record.created_at), ) } diff --git a/agent/database.db b/agent/database.db index 3076509dfea48e48a9576076ca4c174d6f593e26..aeb34310d738a5e2de92ad66fc4c971a82c21f39 100644 GIT binary patch delta 27661 zcmd6Qd2k%pnIC$12n0z$Rz!&+MF}!#uMBbEIl6m#=vap(A9CbF_R@~l@OtO8P1*Ej zQz~ayQhERqCvgf#69hm26iM*JAyOnTxa9I5iQ~;WcB)eAKXzrw(>*g@?=sF-oGNd; zmEZTioJeSz=}>ZtSD zUfCT8hS1X!i@P;d4ZFR8u;vT_6W zWq*Z}EAp*$@Dm*eHeY)t)wG>X-9xAT;T`yJ)6qXhNBM!23%!Z5k*l{>QC9-83-K(YC4hytjDE;ln>X zY`pV9*M}ZngF1{~U6bzL`>k(%J2jb1O(#-Q4XK$^W_>c=msda$?{&^F$6@ul^`TL^td$N!O(omx2Nt9mJ@ z#A44q_ZefK=Gwc<8xK{z6!3>VRWB*AK)C89Ef@(J-e;soj6H8lCCW<*ZZcZKu_!)6 z9`sW~J}-I)H1t+|8npy`akTh@_zcJ%)d)Q!-D`aKC(@P=y%Y@47%iw_j2w%^7LU<_ zQ9z``LaOmy%~k$o#qUw5M=XL*=Q`jZm?aj%ngLalF(x8o-k_?``W}MO@9|&_EgS-* zfmpXPa ztNZtt=ZPQ3lc)c86a9Pz-c1bLni(u9aW+`Z_2$`;%-Ge;g@kos*gSnA-8E=6w5R(| zq-JK!_J(wOU23+@96g=weV{hm)tea`vl@Cby{D|x$J3{dn{~D3=#=ruAy+od-U|YN=1ihFJ8&AmrU%bu#_Fu@ajRw2Om^be5_dTuw}z&u zrLo28sn4D{ZoPRTHHm){smV)Lf5L1V%N(1<(wVWATQkk+zENwa9?Mne6}*u3W_7x| z_UDP}%>3!h^)d9sJbt|BQ6WH3%6E#@b96e^eF^s!JfH!mi zx@oW9k z@L;PsS!1?cW=4lI1(fe5^saXkU3>>kpn3_RkurNhx z5;OGUP~+Mz5x)kMWtMCa)4S)otd@(bQzCZuVX>Umpi1 zot-dIrv6eY`C6vBD?Qsm%Qx4W?U%8YY<(LO6wWF=*q82`6NgrB88@6H#<;mP(_o&j zF~{37W7nzM`0Jq9TQiM}GupO(`h;Z+5&vsWnhkY)w1A6gl@@Nhgo8J)o=;5;rIK}- z`QB8rj|mw2NKX%DMu#&a$IZDD_M}Xi4Rz+^F`T_QP;WM4$x~+C&8%^Osn_1>&`qmf zzhaKl{_G3{Aaw^Z_j@1ne$@`)9?gL@@D@9=c(f80VKv@l-b^z9XOMo*4qf^ zn}g|r1b!0}@@m=Ydb43Xo0!hb;a1T(6TodPyhpw;*b6Nukd~S26nCa2edRoekPnh5 zP4F6;Q&6K^34YfE)6lh>)=D!VU9kDQP>AOHf6Q7adGUuz)$@A90V17eNWjR2!nhYy3 zLO7^BN7Oqu0XVQ~y6b|~05WUD#;`Cmht%v4ZUxv*W~^GbPPY0U^L${FE!f_J_udKZ12c#Ud>$!K5m;mPqt<+H3KrM zA6FU_(qIlI)5D2$V*{=}kdprsVnb&ZVf9~PRztw{Ujm~g%n5F9o;hvKBw0#eJoo_) zBsDjX9T);_WshAj+iGwDn6L5u#%64$GJ2Z$5{ay8MhUp8Wt_L!z3$}%uZJwPV z9E?v81MY&soJ4==;ZYp6HBp02=Q#{80ci7o0s;6n zGj%ddnz+K$|S&Wx3z%u_$rVT5NiRF)LfsX51g}-?bMos z9fAjf9Js)-<)9WijyX4)89ydw%)ZuuOP6k(GDlBk&(xWNgG?p%j_d2NCEQ1Qcq*Ap zPfUY%WzT~G8@}+|r#_KGX+~$!XxuQ*^_Wc)#My^Oi4)|+zIE(kdiE3_C*~^w4iiui zIzdQg8|Z3)G!*-Huwsz3IonBUP*cL{AG1#1uvM%8*D(f&wYqD; zA%O7AI0OmtFehmBk2@Cgd$qV1UH~O(4C~S#8{a-9?b9=3XK-euk*;Ez5s34>0MXV{ zpbjNlmuRJo1GAOn5^&1=2LLg50oURVnUUAbL?w9uv>8e8`05MRnY;mao~{aA>&_v(zX2# zsOnWmZ$nqtD{ewdDjjRu*YVD2?6eWvm|RO`*U5$uX>Lq^&mmv~8oZuU;R!US4*z>n;dP2M3>j0?dOzvdy%Eu{2f*1d5r~a9{68VonJ$n zx50jI>bwqpo+l`z&~HqirxRbVMz60^rT<-N^hWF}(w}Ye`2xnD?sGjZsm8>t^evaH zb(a6bp@nFORv8TVWv{RE#eZnso)4V_|H$~>SEb#Z58m3dACW;N=Jkf$0e>v!mLp2Q z9rDK%cUY6X9z_eP!MO3(SEXF^rl7SDy=(J3MVsH*TsP727n={hP=YYX!}%~s!C(}H zK@Qnr5DImQp5;*#J|XSg=A*cQ>XW0! z_kM=($>-zv;q+0dLPo5D0xmu;CXr*BfS-aY9GX!ynpW{q@I~{)5n>RN56P}QC9v&8 z*hN!~Pk%!yH~#sEwDmrFIuX!NVnGBnXwuIAJG;l2`Gxe5@#G~eOo-Fhi@2(nd>$_r zwu2sV4f|L8u{=PkO7S5?KCl7wq)5(cM<>T_&3I!T<5&Mfx>wKPZ7EUm7I_R4E|V$uXJQR{iQE}1;F(Ur z0d4>{gEmHV>7-Pi!A6oU2$M-z(^Tx$Uf2@{x&wh2wiWa!`0m8E8VOOXZ283%RAT=s zZYn}}*z}?$^H+g5yRqjRQmOI3ej)AFu{`0>E8`Q_6T{>fowg0T<51SKfV~X|#Ub2C z=OWn1LU@x}B6N;M&&Q>GNBHoZ;X$5Aj!{6;My47E(FP)6@F2w-0ebU`iAznC#J5b2 z5&ke13n;!2up7`+j6y6_Hpag%?a^(Tl6`bY;vO=txE(kLLD`(o>mdpi_nDwXWYVgk zcp%&u6&2auvk1MI4W}%))f)_o(8HTk^EyRhng+=oJ9PriN#PQ5#I$@5O(m{Y=^3NNc!Tg!tvdf=@jW08VE@G*zog)$UqXFan_<||mGvk#IHM7mq zv&Z4RqUglXj0ni|U$lYIBhJYI*kih{V~90SlbV_UhB*QX2OOmeB-_~g%;3SWpP%}}&pZb|7!N^b7*l3j4`Lqq5QsT-13`}T^l@GV z!5R*{W#($lri(N`j(+-BYHEn_26upUVlb09h7bi>5Y|N>cH~*zy}C8fo;@{}u9?E( zme^bRN>^rdoFbyhH+T(LVDSHC8sD%QN3EGILbRBa;1gdEro!yzD4mlzddBKUIIKE@ z?~@JK43Eus214PIHT#oR?--njBKp`}XEqII#v9CrvouB9>j<3lCJ5^yDg`UZZ0Lgr z7R#F#CQ?)Fn9)Auad^G2(!jc#=_?(Wot8h|k-mDuN}L0l&6zRU^qV(LJ5-Cv z2p@@1UquDbYt~(}2HO!?H*0UEW;@xhx?CChBMzR!XW!A38Eb@Bo%`eJnVpv~oCo3> z;wN~r*sBg_so6iy?s+Kx=@CTqr`VB9$y1V4QY4iOcSywp|9%@%eUdNOsXm3XUPP+T zN(!4);o4g!^i)MYXClz3gHs2QXD#-6?aXlOm>u4I1-FzW;DC~>5<9;oq z27|IM5DVp9*yUe_H+DHrz=Ul7Is(Z^O%OIcdFE@TKyI+Mi7AQT7j7=aF>}F6Tufk| z9J04=O1pQV8#@P>D*}@^CFAG`X|J2`++cuVQi$N}h&crMBPNNl>EkyjEGdG5W?KjP z7=Q9n*Df7qotf;ypMV*B*E}~1AgMnRq53aye=JdWN9J4u2Su4}FYVj_zS{8Av!FiW zsMV(cFTy3(F~s$CBp6US0d~Do_Ro)t~$}^DE1ryKA*P~x* zYLGY=r+#oCR75ne6SF~h^X=HMj$kNpO5hv$5}El9vwp}L7`ARSAisj(%pC$IrGyF* z14X;dX-JY#%np%OirFRW_yo|whOWU7EVkJ?c8vq$yaRUgAqh*TEG)#F5UdAFY#7gv z#1YVrti}WaUbHxlHrbUKJIA7lLLbRKY7~21K3)tVoPs-p+BndWnm&=9Zow$pfsl+} z#X1~+Agm$af-Ur2Pror|)>AwmU4crhA);>6X43_V*o~g01xi1i#sRut?s%Oc5MNU)0~mq~U#FWPe}EfMZwjkTmZ zb+RvAcW+_oo?FJ2@A@(UFW_9agkR{rNW`y0!ZRTH{_^EQo+e$tloy*{LCmiz<;4J7 z%xt6ql z3)2gxnieJ_jSaRHTpPkIz+MS9-kg^Dau*TxG2S{NJ!AasW$FGc!h2!-`m*$&#Mh`H zeNWmWvsY?8i!JzIjGz6bbZ>NpF%Eawa_c$gNFk>Z40*CHmu-kW8cS9>vaXdJ9w>Ij zDRHtroole23?d=VfU1y7Ls++^?=1XQ2liA(oxCLfD&@I~q1A*YRutYzUGA={aK z56Jz^+t*J2jX*rP%#0uX7~Zchd_&qToMqx1-e)@k70?MA;l^9PkoK(RC|iaiRT%OM zD1vz{9AZj1j`^HmI2|PRal|NhI1r=sYo}9^x2gC8KnyTa$fvewfjn@=^BA^_r=d;aJtDB6jXP1|}&RBi;bqUC4_jzYi~FyZQvJ z+1>{v(Lh;-`bZj)NH&53Lv99S*7RS>J0UoOp0%irxeav6xg^7FeQE}J7S=M@kSw5L zELfDnZ9s`{tN0Bzq+wuMHmx`4HqgnNv9jNwkaj`KEmJP?y2S?E2KwrLgTji%9daA! ztambJ7RzK_Wa|sYxV=H<|OjuuhD(pOAJxV7nXo6UY@rBfA5BQY8Ik zqT@eH#r=X@Z7w{Gktj3?zN)P1ZDtQ3%*1q3g>C^l{)$1tofo36^Wxr3( zIRR*L8C7SBFO1SOuh$*Y0%4C&3nE^k=A8ih+_Y(=F$&Atwp()^fF%Y#Fk~EWlpZM~ zdk*&a*%lsU3_Kx4bXdo>g(*z(MaFn;!o2kjn|)CSQRb63DxD5d_C>ID2b6=ytvuEz zG7@2X!XzRuq*xM1K(4o5xEsd77b%}x*n(Ia2DtI>PQdSt&1A;Tr>Eh)CBH4U#5OT@ z>vTo{M=<-z;1{k3{OZI4CNvYhG(>DZzsiD(A%_B(tqWKfn=*Cb`M}yRzzG4>V&?^#xCLA&Em)Q~{C0$FaYBGLHp8DJuk@tI$!1nZGu zX#C@krGHf>MlIOv#@*kN%1cltDr|UP$ml;JJ^Cd=j*Tw1(*hzmA0;aZ=)kr(R&3ix zj>=FD({k{YYe;FA*a<8>q)i_3MhsMdgwynAt=cssPW0&mGjNb1_ zJ9N87SAeo4=Y)(%+pgtsw#Z;vR-TH70B{y6o)5@bTTVK#tu06ssEO87OWc^6oQC>n!Y@7Djr)!0@S7*7G6~ue1ih&FuvuAao!RyVWG#nG5K=>ZSq9#3 z;p?ubFekcEg$eNwB?a+X6zkARCv-BP+`$BL4UjOuTLpnKx0u5Lj zw*MP$a#6359Zn)X3U!Fpa*k+bnraZ#fhLz7o)bklSI=cHokbZ9KnHNpX%Ml*pCUrr zPo*Lve=nEEM}NGs({4iTjJNz1<9mNE-Mu|Di?Qx!j!%OjUKLl zlu6n8ni!+-m>-w9_CB2(W1~_`kC7blV5O4+Q39V_Rti%a?P4NzVevy`MKmBL40Sla zafWtvKmrRpe18s*{FucsYc|UKE38u2LIf$))@4WxVhLklMtb0D__ESjcs&A$JUbL{ zK;Puz96GeKE!eDx{@TPPVk)2vCryGrO)p%#CYk#S8PbC54sry`e$m-8yvqW1q<)WX1;%8{&Xr(hxA~tl1 zK=FDI{*{5?6ml)ljXR2kQn$Q`n}>x?W)bry zM#_epO(Vk8%##NWm9Hoh2qgi$Mm6$FSh|yw-zvsNrck)sWgL0hwO6Gj5!7V+*bbpD z)Y0cFu8{IzU9YBlCJ>%4vRW>HNTFQ3iQkn3(pMKj8cmqF=zPR+K{S1u=w9 zw$S1K&}XiNCdkR5hw|)ru^gg4IYD%Qnd3@4l)9}UiFRVZ?_h!R zdy_X{Z%BFoyCQ9%YswgV&UKIRr(cocn|*%GHWOtnz-FS?<1iB;In>I42l=Ufhna}h zK;BH;bHEdb#1L0f-66c|;zq7_0B?7xAvZ#x0c3B6eQ_jR+R@B4OhDEQ%o5eH_E%bm>4qJes3LeUv0`Jn;7N&=aINexPg^lU;Q*x_FaYalGpg1FIa$JNsV?Ii_^@U(o*+vo6n!ILX zo}EO2I7#lghV12&X6<#t{v`64#&HpD%~XTxQaZAbrbpo5G@CBr6$?t-gq6f~?zwtn zz7Kh63z^BGf7>@LoS+c;FiqgNw0)mgrs*OLL0VqV6oAN{YD2AZKC2JQw|;R)@!=00 z_BMZ@?aTJ@ujz2umUlQ9|Mi>FM~ug*^nfY=j4~Yb-3p$CL!3@zehXVW@pb9myBXmN z@oc007jOvt!t2^$eC;jip-RrJ#*=lht(jERWfruSw}Xvu{t)#EBOjOUxf}0LVL4>* z5!HmD+dU$?4lXH8C;-@I0?erXXHX$$Ix}suPlGd^nJ}FG4dgzP!+|#%;{pMqS`O@E ztoSa2Mr}G1fmfcH$w>=ylzCy4rh6$vK#9il26s@Q2r}ZK6y_G&1VH3f&}2~@AadyO zjuu)(JJBDI;Ny5JC3&yvc@Tvhm>GREu9WcVbUUyE$^;h~-pluGyD;!TA*L2N-|VnZ zmjHn*a7aqn&s6E3a}I$1KW5%v+p$;5=+f~{1w8=4nj@@xQm^~qM>v-m zzrgwZ`Pc`m1KkcxGal6%CqRSeg_b21~&6UG+&; zhj5Q=go?bv^yL#~!?e{nkMus$t%cb|pmF58p~E=)Y1bo^%*VsQ6)>C}Bw|`p1+hOI zPg@ga#&a2@KPQm`Py53u(kmN~kMz_8B0}RBTTy`sAPVd4=$3d?f|PUNSmEFxtjMu0 z6AxH4j==oOj8uON=gE)ZB}2nRfJI4HI+$5kaXP_q12-)>7co=Peh3IUPSuS@LqsSr1o-C32 zn=-!pw5wDHl9z`omm|V z&i#%s=ueQq%`@oS_SVm$?eo90JURykbLpz1P9iVBrgiY?e>pZ?b@U)09mCUs>tfWl z!3q6JB+qX01wCYN9&xFv!{7vG)hNqFQyf*=#D*PJ+6NGM40tlGlSi7sZ<0CsEJ%+!TbHWxh6Ghq1f}NQ2 z!s0Pr&moVuQq_F2n)kvwT7z2N3yb*!o;Y5yR`H6p64u-vJbaCUSTz)nE|lxgi&J(A z3Y(;mk(rxgw=@X{$Gc9M@$=b) zNQIiZVG4f^sU9a=t>g37y~o>FsbGVR1vRtU4I6?v{p8Nrfd@BY2-*}F4Du@hE>&Ry z^!tba$%D?Pcxl|L#Uc?; zG!l|w_S+thHP1M@r8r(x90vtf6@R$x{!+Lqy!El}huKzL+vTy9T^`2x(~@F*)(h88 z&QQH;6N<`>>PylCBEW11jk!Eoi&Cm7hnVf;TX-*w=YJ^eiio;;4xrl6Sw}>cU-?!1 z3hb&?vtPNGeOW29o#h(zhA7!~&#INji}G&^)*r+AgGtAg?TUw7EJ{2`4wX2S4TBft z6KS1EzihI~x~LM)rQT8vHswzXCkG5vJdA*eWJfOUqMU3;uD0zIu`9z^+_l-oQn+Tg zme6FmMhUhS?ky^#$STx@;X}H|=xI{-Z=$C6CX57_0jOo93P=Nw43pk{ic5Pl^X+IO z1Fxk!HQR#9DgM(k&f)P-9w7#^d4SSdp3|O!DroJhSG?e9HtRj9>DSXkc>d9T7%rD| zOILr0-spr;0JEu~ml+4L>%>fy{RpkOuJ^-s!3+1X3pDMAx>zfun$_;!Obw-zB4!5D z4yCZ7N)|JbQ%F3{oJ}-ZCobYA+S>4CydTNm@!Bt<;Q6XrF$cf#$i+quXwqHNPz2M{ z*QuJhZ;s1oX*xWgI@?c{=zN{oGMsL#=WS7bk#3jgifNqhCZrD?{O^?;n>A-JRhg{QY0Dv!KC`=@HAEGu^3_!{lo0)#&*QY5xp&DW&DVD;**_zSR^bKAOXFh|x zg6g!8HTYuITrr#Q^mH{|nYOMrq5zpn*4t@3Dvo&yQ2!|6*01ptx={=DAOCV`I$j6O z)STlEG@mdlU|ix&V9*~=FrCbdj*8J)Y(<2c0RWIl16rry_%Pd#WzSJjvXyAXtNH1R z^H%#+J~hfuoA1k>yuqoICAzq0jtXlAYxPVuc5BcRJG`KHyg6TH4f=L?cX+O5Iy+L* z=Hc_CyaW_UgSNpH;4V3gh?jM90`KDTS&CP^>4D$V?L7JzUcMaU2HKyfM#t;CobpFo z>^KPz65k;^)?HE2^Eo*Bm>1bjQho_lJpPOWH_u+Q#=9^&Q!|`CcY&tq8P6Ki2%5pK zik*Q=Se-4nHPm_RxUlVCqyD33@REVq0$*<T4Vt4zLz%Q3h->~(P*G6Zm3!$k zXRI%YC)mb%?p1Nt!Em3JB;k^uuBnfcTlhsVI~5oIdxKo9Eld%R3~O$DR?bA-)R%-P zv6F^#1-O(Vov1YvGv=Iu$A95-Av0KH`~vf^1!>aS3&?oJk>zuH?c8WlOU;_wMx|BG zMp-Vd$RygZywdXmi-)gIVigux_Dt)NSh^5vLS*H31{-&Y(BcV~NUaSDEuLeU)Y4gq zu3v1~n-k25UzK1s=y>k8fY{3IF)z5_3bvnf;E6XVy7H3C-rmMU7Y6C1TilNDS}MD8 zJ6tZk7{n#wOW&aUvL{(Az&0Yk>UcNr>4nZcK{h zg&2?K%=I;;*nw5V812&zqyK*-%JvozWyZI^B7JkS-vgmTkI!xLb0qzUOBRuIdccE4 zlTzuGk&OM{s-$)@E4fi!Fe~>*)Nml~^P_4f3I{Uc%t*NQh81^Ajne;HN{xkr_Um(N IOR%r~KN9Rvn*aa+ delta 232 zcmZoTz|*jRbAmK$G6MsH>O=*5AVaT9WMj&L^+K%t&w+vrD*V6szwv+Mf5ZQLv!K9E zuFdCn{bHGZ(wIV F2LQmRQ9l3x