diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index 236cd77..5cd1cd0 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -370,14 +370,12 @@ def thinking(item: MessageHistoryItem) -> rx.Component: rx.text( item.title, font_size="var(--prismui-font-size-1)", - color="var(--prismui-color-3)", class_name=rx.cond(item.is_running, "is_running", None), ), rx.icon( "chevron-right", width="14px", height="14px", - color="var(--prismui-color-3)", transform=rx.cond(item.is_shown, "rotate(90deg)", "rotate(0deg)"), transition="transform 0.18s ease-in-out", ), @@ -442,57 +440,102 @@ def tool_call(item: MessageHistoryItem) -> rx.Component: margin_bottom="8px", line_height="22px", ), - # 内容 - rx.markdown( - item.content, - width="100%", - margin="4px 0", - line_height="1.6", - font_size="var(--prismui-font-size-1)", - ), - # 审批 - rx.cond( - item.has_approval, - rx.hstack( - rx.text_area( - placeholder="请输入", - # 值绑定审批用户提示词 - value=item.approval_user_prompt, - # 值变化事件:设置审批用户提示词 - on_change=lambda user_prompt: ConversationState.set_approval_user_prompt( - item.id, user_prompt - ), - flex=1, - height="100%", - padding="4px", - border_radius="var(--prismui-border-radius-4)", - line_height="16px", - border="none !important", + rx.hstack( + # 分割线 + rx.box( + width="1px", + margin="12px 7px", + background_color="var(--prismui-background-color-2)", + ), + rx.vstack( + # 内容 + rx.markdown( + item.content, + width="100%", + margin="4px 0", + line_height="1.6", + font_size="var(--prismui-font-size-1)", ), - rx.button( - "发送", - # 点击事件:审批 - on_click=lambda: ConversationState.approve(item.id, True), - # 是否禁用绑定验证码发送禁用状态 - margin="0 8px", - background_color="var(--prismui-background-color-1)", - line_height="16px", - # 若验证码发送禁用状态为是则颜色设置为 color-4,否则设置为 color-5(主题色) - color=rx.cond( - AuthState.is_captcha_code_sending_button_disabled, - "var(--prismui-color-4)", - "var(--prismui-color-5)", + # 审批 + rx.cond( + item.has_approval, + rx.vstack( + rx.text_area( + # 值绑定审批用户提示词 + value=item.approval_user_prompt, + # 值变化事件:设置审批用户提示词 + on_change=lambda user_prompt: ConversationState.set_approval_user_prompt( + item.id, user_prompt + ), + disabled=item.is_approval_user_prompt_input_disabled, + placeholder="请输入", + vertical_align="middle", + width="100%", + height="auto", + padding="4px 0", + outline="none !important", + box_shadow="none !important", + style={ + ".rt-TextAreaInput": { + "font-size": "var(--prismui-font-size-1) !important", + "border": "none !important", + "overflow-y": "hidden !important", + }, + ".rt-TextAreaInput:disabled": { + "background-color": "var(--prismui-background-color-1) !important", + "font-size": "var(--prismui-font-size-1) !important", + "cursor": "not-allowed !important", + }, + }, + ), + rx.hstack( + rx.button( + rx.cond( + item.is_approval_user_prompt_input_disabled, + "已提交", + "提交", + ), + # 点击事件:审批 + on_click=lambda: ConversationState.approve(item.id), + disabled=item.is_approval_user_prompt_input_disabled, + padding="0 12px", + # 是否禁用绑定审批用户提示词不可输出状态 + background_color=rx.cond( + item.is_approval_user_prompt_input_disabled, + "var(--prismui-background-color-8)", + "var(--prismui-background-color-7)", + ), + border_radius="var(--prismui-border-radius-3)", + line_height="18px", + font_size="var(--prismui-font-size-1)", + color="var(--prismui-color-9)", + cursor=rx.cond( + item.is_approval_user_prompt_input_disabled, + "not-allowed", + "pointer", + ), + ), + justify_content="flex-end", + width="100%", + ), + width="100%", + padding="12px 16px", + background_color="var(--prismui-background-color-1)", + border="var(--prismui-border-1)", + border_radius="var(--prismui-border-radius-4)", ), + rx.fragment(), ), width="100%", - height="45px", - align_items="center", - border="var(--prismui-border-1)", - border_radius="var(--prismui-border-radius-4)", + gap="4px", ), - rx.fragment(), + align_items="stretch", + width="100%", + margin_bottom="8px", + gap="4px", ), width="100%", + margin_bottom="8px", ) @@ -828,17 +871,16 @@ def user_prompt_sending() -> rx.Component: inline_height="1.5", font_size="var(--prismui-font-size-2)", white_space="nowrap", - overflow="hidden", cursor=rx.cond( ConversationState.is_user_prompt_sending_button_disabled, "not-allowed", "pointer", ), ), - width="100%", justify_content="flex-end", align_items="center", height="32px", + width="100%", ), width="100%", padding="12px 16px", diff --git a/agent/application/states/conversation.py b/agent/application/states/conversation.py index 75cd840..2da1660 100644 --- a/agent/application/states/conversation.py +++ b/agent/application/states/conversation.py @@ -3,14 +3,9 @@ 会话状态 """ from datetime import datetime -from typing import AsyncGenerator, cast +from typing import AsyncGenerator -from pydantic_ai import ( - DeferredToolRequests, - DeferredToolResults, - ToolApproved, - ToolDenied, -) +from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved from pydantic_ai.messages import ( FunctionToolResultEvent, PartDeltaEvent, @@ -110,6 +105,7 @@ class ConversationState(rx.State): ) # 将最后一个会话的唯一标识作为激活会话唯一标识 self.actived_conversation_id = next(reversed(self.conversations.keys())) + print(self.conversations[self.actived_conversation_id]) @rx.event def toggle_conversation_history_shown(self) -> None: @@ -193,6 +189,11 @@ class ConversationState(rx.State): **message.model_dump(), has_approval=isinstance(approval := message.approval, Approval), approval_user_prompt=getattr(approval, "user_prompt", ""), + is_approval_user_prompt_input_disabled=( + False + if (isinstance(approval, Approval) and approval.result is None) + else True + ), ) ) return message_history @@ -273,10 +274,6 @@ class ConversationState(rx.State): if not (user_prompt := approval.user_prompt): return - # 将等待流式输出设置为是 - conversation.awaiting_stream = True - yield # 通知前端更新渲染 - else: # 若用户提示词为空则直接返回 if not (user_prompt := conversation.user_prompt): @@ -368,19 +365,23 @@ class ConversationState(rx.State): case ToolCallPart( tool_call_id=tool_call_id, tool_name=tool_name ): - # 构建消息实例 - message = Message( - type=MessageType.TOOL_CALL, - title=f"正在{tool_name}", - is_running=True, - ) - # 添加至消息字典 - conversation.messages[message.id] = message - # 将消息实例唯一标识与工具调用唯一标识映射 - conversation.workflow.tool_call_id_map_to_message_id[ + if ( tool_call_id - ] = message.id - yield # 通知前端更新渲染 + not in conversation.workflow.tool_call_id_map_to_message_id + ): + # 构建消息实例 + message = Message( + type=MessageType.TOOL_CALL, + title=f"正在{tool_name}", + is_running=True, + ) + # 添加至消息字典 + conversation.messages[message.id] = message + # 将消息实例唯一标识与工具调用唯一标识映射 + conversation.workflow.tool_call_id_map_to_message_id[ + tool_call_id + ] = message.id + yield # 通知前端更新渲染 # 文本分片 case TextPart(content=content): @@ -458,8 +459,7 @@ class ConversationState(rx.State): ] message.is_running = False message.title = f"{tool_name}已完成" - # 若内容非空字符串则更新 - if content := message.content: + if isinstance(content, str) and content: message.content = content yield # 通知前端更新渲染 messages.append(message) @@ -510,6 +510,7 @@ class ConversationState(rx.State): conversation_id=conversation.id, new_messages=result.new_messages(), ) + # 批量创建消息记录 await db_state.create_message_records( conversation_id=conversation.id, @@ -569,11 +570,10 @@ class ConversationState(rx.State): message.approval.user_prompt = user_prompt @rx.event - async def approve(self, item_id: str, result: bool) -> None: + async def approve(self, item_id: str) -> AsyncGenerator[None, None]: """ 审批消息历史项 :param item_id: 消息历史项唯一标识 - :param result: 审批结果(True 表示审批通过,False 表示审批拒绝) """ # 当前会话 conversation = self.conversations.get(self.actived_conversation_id) @@ -585,16 +585,19 @@ class ConversationState(rx.State): ): return - # 更新审批结果 - approval.result = result + # 若会话未正在运行则将正在运行设置为是 + if not conversation.is_running: + conversation.is_running = True + # 将等待流式输出设置为是 + conversation.awaiting_stream = True + # 更新审批结果为审批通过 + approval.result = True + yield # 通知前端更新渲染 + # 运行 async for _ in self.run( approval, - DeferredToolResults( - approvals={ - approval.tool_call_id: ToolApproved() if result else ToolDenied() - } - ), + DeferredToolResults(approvals={approval.tool_call_id: ToolApproved()}), ): ... diff --git a/agent/application/states/models.py b/agent/application/states/models.py index 3b1fb33..ea9ced2 100644 --- a/agent/application/states/models.py +++ b/agent/application/states/models.py @@ -120,6 +120,9 @@ class MessageHistoryItem(Message): has_approval: bool = Field(default=False, description="包含审批实例") approval_user_prompt: str = Field(default="", description="审批用户提示词") + is_approval_user_prompt_input_disabled: bool = Field( + default=False, description="审批用户提示词不可输出状态" + ) # 工作流适配器 diff --git a/agent/application/workshop/book_flight.py b/agent/application/workshop/book_flight.py index d7280ae..5d3a4d6 100644 --- a/agent/application/workshop/book_flight.py +++ b/agent/application/workshop/book_flight.py @@ -8,6 +8,7 @@ from typing import Any, AsyncGenerator, cast from pydantic import BaseModel, Field, field_validator from pydantic_ai import ( Agent, + AgentRunResult, ApprovalRequired, DeferredToolRequests, DeferredToolResults, @@ -16,11 +17,8 @@ from pydantic_ai import ( RunContext, RunUsage, UsageLimits, - AgentRunResult, ) from pydantic_ai.messages import ( - PartDeltaEvent, - TextPart, AgentStreamEvent, FunctionToolResultEvent, PartStartEvent, @@ -29,12 +27,11 @@ from pydantic_ai.messages import ( ) from pydantic_ai.run import AgentRunResultEvent -from application.states.models import NoResult +from application.workshop.events import AgentPromptEvent from application.workshop.models import ( DEEPSEEK_V4_FLASH_MODEL, DEEPSEEK_V4_FLASH_MODEL_SETTINGS, ) -from application.workshop.events import AgentPromptEvent class Flight(BaseModel): @@ -76,15 +73,15 @@ class Deps(BaseModel): # 主智能体 -agent = Agent[Deps, Flight | NoResult | DeferredToolRequests]( +agent = Agent[Deps, Flight | DeferredToolRequests]( model=DEEPSEEK_V4_FLASH_MODEL, model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS, deps_type=Deps, - output_type=Flight | NoResult | DeferredToolRequests, + output_type=Flight | DeferredToolRequests, system_prompt=( "你的任务是帮助用户查询并预定航班,**必须按照下述步骤执行**:", "1. 使用 search_flights 查询满足用户需求的航班;", - "2. 使用 book_flight 预定航班。若未选择到航班则返回 NoSelectedFlight;", + "2. 使用 book_flight 预定航班。当返回航班信息(Flight)时表示预定航班完成。", ), ) @@ -209,11 +206,11 @@ async def search_flights(ctx: RunContext[Deps]) -> list[Flight]: @agent.tool(name="预定航班") -async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: +async def book_flight(ctx: RunContext[Deps]) -> Flight: """ 预定航班 """ - if ctx.deps.booked_flight: + if ctx.deps.booked_flight is not None: return ctx.deps.booked_flight if (searched_flights := ctx.deps.searched_flights) is None: @@ -232,12 +229,16 @@ async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: ) ).output if not extracted_flight_number: - return NoResult() + raise ApprovalRequired( + metadata={ + "content": f"未提取到航班号。请确认后重新输入。", + } + ) if extracted_flight_number not in flight_numbers: raise ApprovalRequired( metadata={ - "content": f"提取到的航班号 {extracted_flight_number} 不在查询到的航班中。请确认后重新输入。" + "content": f"提取到的航班号 {extracted_flight_number} 不在查询到的航班中。请确认后重新输入。", } ) # 预定到的航班 @@ -252,12 +253,12 @@ async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: @agent.output_validator async def validate_output( ctx: RunContext[Deps], - output: Flight | NoResult | DeferredToolRequests, -) -> Flight | NoResult | DeferredToolRequests: + output: Flight | DeferredToolRequests, +) -> Flight | DeferredToolRequests: """ 输出校验 """ - if isinstance(output, (NoResult, DeferredToolRequests)): + if isinstance(output, DeferredToolRequests): return output errors = [] @@ -291,13 +292,14 @@ async def run_stream_events( usage_limits: UsageLimits | None = None, deferred_tool_results: DeferredToolResults | None = None, ) -> AsyncGenerator[ - AgentStreamEvent - | AgentRunResultEvent[Flight | NoResult | DeferredToolRequests] - | None, + AgentStreamEvent | AgentRunResultEvent[Flight | DeferredToolRequests] | None, ]: """ 运行并流式输出事件 """ + if not isinstance(deps, Deps): + deps = Deps.model_validate(deps) + async with agent.run_stream_events( user_prompt=user_prompt, deps=deps, @@ -330,12 +332,14 @@ async def run_stream_events( searched_flights = cast(list[Flight], content) event.part.content = f"已查询到 **{len(searched_flights)}** 班航班:\n\n" for searched_flight in searched_flights: - event.part.content += f"{searched_flight.number} {searched_flight.airfare}$ 于 {searched_flight.date.strftime('%Y-%m-%d')} 从 {searched_flight.origin_airport_code} 到 {searched_flight.destination_airport_code}\n\n" + event.part.content += f"**{searched_flight.number}** | 💰 {searched_flight.airfare} | {searched_flight.date.strftime('%Y-%m-%d')} | {searched_flight.origin_airport_code} ✈️ {searched_flight.destination_airport_code}\n\n" yield event case "预定航班": if isinstance(deps.booked_flight, Flight): - event.part.content = "" + event.part.content = ( + "请输入航班号:\n\n" # 成功预定内容维持提示 + ) else: event.part.content = ( "未提取到航班号,请检查后重试\n\n" @@ -348,10 +352,6 @@ async def run_stream_events( match output: case Flight(): yield AgentPromptEvent( - content=f"已成功预定:\n\n{output.number} {output.airfare}$ 于 {output.date.strftime('%Y-%m-%d')} 从 {output.origin_airport_code} 到 {output.destination_airport_code}\n\n本次会话结束。" - ) - case NoResult(): - yield AgentPromptEvent( - content="预定失败\n\n本次会话结束。" + content=f"---\n\n✅ 预定机票成功:\n\n{output.number} | 💰 {output.airfare} | {output.date.strftime('%Y-%m-%d')} | {output.origin_airport_code} ✈️ {output.destination_airport_code}\n\n本次会话结束。" ) yield event diff --git a/agent/assets/styles.css b/agent/assets/styles.css index 54e044a..b542093 100644 --- a/agent/assets/styles.css +++ b/agent/assets/styles.css @@ -6,6 +6,7 @@ --prismui-background-color-5: #ffffff33; --prismui-background-color-6: #f2f2f3; --prismui-background-color-7: #5e7ce0; + --prismui-background-color-8: #beccfa; --prismui-background-1: linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%); --prismui-background-2: linear-gradient(180deg, #fffffff2, #f8fafff2 99%); --prismui-background-3: linear-gradient(to right, #f3efff, #f3efff33, #e2f1fd33, #e2f1fd); @@ -90,11 +91,11 @@ pre, pre code { /* 使用主题变量:普通文字色 --prismui-color-3,高光使用主题蓝色 --prismui-color-5 */ background: linear-gradient( 90deg, - var(--prismui-color-3) 0%, - var(--prismui-color-3) 35%, - var(--prismui-color-7) 50%, - var(--prismui-color-3) 65%, - var(--prismui-color-3) 100% + var(--prismui-color-1) 0%, + var(--prismui-color-1) 35%, + var(--prismui-color-3) 50%, + var(--prismui-color-1) 65%, + var(--prismui-color-1) 100% ); background-size: 200% 100%; background-clip: text; diff --git a/agent/database.db b/agent/database.db index a2ea2dd..94864f0 100644 Binary files a/agent/database.db and b/agent/database.db differ