This commit is contained in:
liubiren 2026-09-15 20:21:37 +08:00
parent 2502d2f663
commit a8dc684c20
6 changed files with 161 additions and 112 deletions

View File

@ -370,14 +370,12 @@ def thinking(item: MessageHistoryItem) -> rx.Component:
rx.text( rx.text(
item.title, item.title,
font_size="var(--prismui-font-size-1)", font_size="var(--prismui-font-size-1)",
color="var(--prismui-color-3)",
class_name=rx.cond(item.is_running, "is_running", None), class_name=rx.cond(item.is_running, "is_running", None),
), ),
rx.icon( rx.icon(
"chevron-right", "chevron-right",
width="14px", width="14px",
height="14px", height="14px",
color="var(--prismui-color-3)",
transform=rx.cond(item.is_shown, "rotate(90deg)", "rotate(0deg)"), transform=rx.cond(item.is_shown, "rotate(90deg)", "rotate(0deg)"),
transition="transform 0.18s ease-in-out", transition="transform 0.18s ease-in-out",
), ),
@ -442,57 +440,102 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
margin_bottom="8px", margin_bottom="8px",
line_height="22px", line_height="22px",
), ),
# 内容 rx.hstack(
rx.markdown( # 分割线
item.content, rx.box(
width="100%", width="1px",
margin="4px 0", margin="12px 7px",
line_height="1.6", background_color="var(--prismui-background-color-2)",
font_size="var(--prismui-font-size-1)", ),
), rx.vstack(
# 审批 # 内容
rx.cond( rx.markdown(
item.has_approval, item.content,
rx.hstack( width="100%",
rx.text_area( margin="4px 0",
placeholder="请输入", line_height="1.6",
# 值绑定审批用户提示词 font_size="var(--prismui-font-size-1)",
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.button( # 审批
"发送", rx.cond(
# 点击事件:审批 item.has_approval,
on_click=lambda: ConversationState.approve(item.id, True), rx.vstack(
# 是否禁用绑定验证码发送禁用状态 rx.text_area(
margin="0 8px", # 值绑定审批用户提示词
background_color="var(--prismui-background-color-1)", value=item.approval_user_prompt,
line_height="16px", # 值变化事件:设置审批用户提示词
# 若验证码发送禁用状态为是则颜色设置为 color-4,否则设置为 color-5(主题色) on_change=lambda user_prompt: ConversationState.set_approval_user_prompt(
color=rx.cond( item.id, user_prompt
AuthState.is_captcha_code_sending_button_disabled, ),
"var(--prismui-color-4)", disabled=item.is_approval_user_prompt_input_disabled,
"var(--prismui-color-5)", 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%", width="100%",
height="45px", gap="4px",
align_items="center",
border="var(--prismui-border-1)",
border_radius="var(--prismui-border-radius-4)",
), ),
rx.fragment(), align_items="stretch",
width="100%",
margin_bottom="8px",
gap="4px",
), ),
width="100%", width="100%",
margin_bottom="8px",
) )
@ -828,17 +871,16 @@ def user_prompt_sending() -> rx.Component:
inline_height="1.5", inline_height="1.5",
font_size="var(--prismui-font-size-2)", font_size="var(--prismui-font-size-2)",
white_space="nowrap", white_space="nowrap",
overflow="hidden",
cursor=rx.cond( cursor=rx.cond(
ConversationState.is_user_prompt_sending_button_disabled, ConversationState.is_user_prompt_sending_button_disabled,
"not-allowed", "not-allowed",
"pointer", "pointer",
), ),
), ),
width="100%",
justify_content="flex-end", justify_content="flex-end",
align_items="center", align_items="center",
height="32px", height="32px",
width="100%",
), ),
width="100%", width="100%",
padding="12px 16px", padding="12px 16px",

View File

@ -3,14 +3,9 @@
会话状态 会话状态
""" """
from datetime import datetime from datetime import datetime
from typing import AsyncGenerator, cast from typing import AsyncGenerator
from pydantic_ai import ( from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
DeferredToolRequests,
DeferredToolResults,
ToolApproved,
ToolDenied,
)
from pydantic_ai.messages import ( from pydantic_ai.messages import (
FunctionToolResultEvent, FunctionToolResultEvent,
PartDeltaEvent, PartDeltaEvent,
@ -110,6 +105,7 @@ class ConversationState(rx.State):
) )
# 将最后一个会话的唯一标识作为激活会话唯一标识 # 将最后一个会话的唯一标识作为激活会话唯一标识
self.actived_conversation_id = next(reversed(self.conversations.keys())) self.actived_conversation_id = next(reversed(self.conversations.keys()))
print(self.conversations[self.actived_conversation_id])
@rx.event @rx.event
def toggle_conversation_history_shown(self) -> None: def toggle_conversation_history_shown(self) -> None:
@ -193,6 +189,11 @@ class ConversationState(rx.State):
**message.model_dump(), **message.model_dump(),
has_approval=isinstance(approval := message.approval, Approval), has_approval=isinstance(approval := message.approval, Approval),
approval_user_prompt=getattr(approval, "user_prompt", ""), 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 return message_history
@ -273,10 +274,6 @@ class ConversationState(rx.State):
if not (user_prompt := approval.user_prompt): if not (user_prompt := approval.user_prompt):
return return
# 将等待流式输出设置为是
conversation.awaiting_stream = True
yield # 通知前端更新渲染
else: else:
# 若用户提示词为空则直接返回 # 若用户提示词为空则直接返回
if not (user_prompt := conversation.user_prompt): if not (user_prompt := conversation.user_prompt):
@ -368,19 +365,23 @@ class ConversationState(rx.State):
case ToolCallPart( case ToolCallPart(
tool_call_id=tool_call_id, tool_name=tool_name tool_call_id=tool_call_id, tool_name=tool_name
): ):
# 构建消息实例 if (
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 tool_call_id
] = message.id not in conversation.workflow.tool_call_id_map_to_message_id
yield # 通知前端更新渲染 ):
# 构建消息实例
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): case TextPart(content=content):
@ -458,8 +459,7 @@ class ConversationState(rx.State):
] ]
message.is_running = False message.is_running = False
message.title = f"{tool_name}已完成" message.title = f"{tool_name}已完成"
# 若内容非空字符串则更新 if isinstance(content, str) and content:
if content := message.content:
message.content = content message.content = content
yield # 通知前端更新渲染 yield # 通知前端更新渲染
messages.append(message) messages.append(message)
@ -510,6 +510,7 @@ class ConversationState(rx.State):
conversation_id=conversation.id, conversation_id=conversation.id,
new_messages=result.new_messages(), new_messages=result.new_messages(),
) )
# 批量创建消息记录 # 批量创建消息记录
await db_state.create_message_records( await db_state.create_message_records(
conversation_id=conversation.id, conversation_id=conversation.id,
@ -569,11 +570,10 @@ class ConversationState(rx.State):
message.approval.user_prompt = user_prompt message.approval.user_prompt = user_prompt
@rx.event @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 item_id: 消息历史项唯一标识
:param result: 审批结果(True 表示审批通过,False 表示审批拒绝)
""" """
# 当前会话 # 当前会话
conversation = self.conversations.get(self.actived_conversation_id) conversation = self.conversations.get(self.actived_conversation_id)
@ -585,16 +585,19 @@ class ConversationState(rx.State):
): ):
return 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( async for _ in self.run(
approval, approval,
DeferredToolResults( DeferredToolResults(approvals={approval.tool_call_id: ToolApproved()}),
approvals={
approval.tool_call_id: ToolApproved() if result else ToolDenied()
}
),
): ):
... ...

View File

@ -120,6 +120,9 @@ class MessageHistoryItem(Message):
has_approval: bool = Field(default=False, description="包含审批实例") has_approval: bool = Field(default=False, description="包含审批实例")
approval_user_prompt: str = Field(default="", description="审批用户提示词") approval_user_prompt: str = Field(default="", description="审批用户提示词")
is_approval_user_prompt_input_disabled: bool = Field(
default=False, description="审批用户提示词不可输出状态"
)
# 工作流适配器 # 工作流适配器

View File

@ -8,6 +8,7 @@ from typing import Any, AsyncGenerator, cast
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from pydantic_ai import ( from pydantic_ai import (
Agent, Agent,
AgentRunResult,
ApprovalRequired, ApprovalRequired,
DeferredToolRequests, DeferredToolRequests,
DeferredToolResults, DeferredToolResults,
@ -16,11 +17,8 @@ from pydantic_ai import (
RunContext, RunContext,
RunUsage, RunUsage,
UsageLimits, UsageLimits,
AgentRunResult,
) )
from pydantic_ai.messages import ( from pydantic_ai.messages import (
PartDeltaEvent,
TextPart,
AgentStreamEvent, AgentStreamEvent,
FunctionToolResultEvent, FunctionToolResultEvent,
PartStartEvent, PartStartEvent,
@ -29,12 +27,11 @@ from pydantic_ai.messages import (
) )
from pydantic_ai.run import AgentRunResultEvent from pydantic_ai.run import AgentRunResultEvent
from application.states.models import NoResult from application.workshop.events import AgentPromptEvent
from application.workshop.models import ( from application.workshop.models import (
DEEPSEEK_V4_FLASH_MODEL, DEEPSEEK_V4_FLASH_MODEL,
DEEPSEEK_V4_FLASH_MODEL_SETTINGS, DEEPSEEK_V4_FLASH_MODEL_SETTINGS,
) )
from application.workshop.events import AgentPromptEvent
class Flight(BaseModel): 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=DEEPSEEK_V4_FLASH_MODEL,
model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS, model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS,
deps_type=Deps, deps_type=Deps,
output_type=Flight | NoResult | DeferredToolRequests, output_type=Flight | DeferredToolRequests,
system_prompt=( system_prompt=(
"你的任务是帮助用户查询并预定航班,**必须按照下述步骤执行**:", "你的任务是帮助用户查询并预定航班,**必须按照下述步骤执行**:",
"1. 使用 search_flights 查询满足用户需求的航班;", "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="预定航班") @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 return ctx.deps.booked_flight
if (searched_flights := ctx.deps.searched_flights) is None: if (searched_flights := ctx.deps.searched_flights) is None:
@ -232,12 +229,16 @@ async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult:
) )
).output ).output
if not extracted_flight_number: if not extracted_flight_number:
return NoResult() raise ApprovalRequired(
metadata={
"content": f"未提取到航班号。请确认后重新输入。",
}
)
if extracted_flight_number not in flight_numbers: if extracted_flight_number not in flight_numbers:
raise ApprovalRequired( raise ApprovalRequired(
metadata={ 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 @agent.output_validator
async def validate_output( async def validate_output(
ctx: RunContext[Deps], ctx: RunContext[Deps],
output: Flight | NoResult | DeferredToolRequests, output: Flight | DeferredToolRequests,
) -> Flight | NoResult | DeferredToolRequests: ) -> Flight | DeferredToolRequests:
""" """
输出校验 输出校验
""" """
if isinstance(output, (NoResult, DeferredToolRequests)): if isinstance(output, DeferredToolRequests):
return output return output
errors = [] errors = []
@ -291,13 +292,14 @@ async def run_stream_events(
usage_limits: UsageLimits | None = None, usage_limits: UsageLimits | None = None,
deferred_tool_results: DeferredToolResults | None = None, deferred_tool_results: DeferredToolResults | None = None,
) -> AsyncGenerator[ ) -> AsyncGenerator[
AgentStreamEvent AgentStreamEvent | AgentRunResultEvent[Flight | DeferredToolRequests] | None,
| AgentRunResultEvent[Flight | NoResult | DeferredToolRequests]
| None,
]: ]:
""" """
运行并流式输出事件 运行并流式输出事件
""" """
if not isinstance(deps, Deps):
deps = Deps.model_validate(deps)
async with agent.run_stream_events( async with agent.run_stream_events(
user_prompt=user_prompt, user_prompt=user_prompt,
deps=deps, deps=deps,
@ -330,12 +332,14 @@ async def run_stream_events(
searched_flights = cast(list[Flight], content) searched_flights = cast(list[Flight], content)
event.part.content = f"已查询到 **{len(searched_flights)}** 班航班:\n\n" event.part.content = f"已查询到 **{len(searched_flights)}** 班航班:\n\n"
for searched_flight in searched_flights: 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 yield event
case "预定航班": case "预定航班":
if isinstance(deps.booked_flight, Flight): if isinstance(deps.booked_flight, Flight):
event.part.content = "" event.part.content = (
"请输入航班号:\n\n" # 成功预定内容维持提示
)
else: else:
event.part.content = ( event.part.content = (
"未提取到航班号,请检查后重试\n\n" "未提取到航班号,请检查后重试\n\n"
@ -348,10 +352,6 @@ async def run_stream_events(
match output: match output:
case Flight(): case Flight():
yield AgentPromptEvent( 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本次会话结束。" 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本次会话结束。"
)
case NoResult():
yield AgentPromptEvent(
content="预定失败\n\n本次会话结束。"
) )
yield event yield event

View File

@ -6,6 +6,7 @@
--prismui-background-color-5: #ffffff33; --prismui-background-color-5: #ffffff33;
--prismui-background-color-6: #f2f2f3; --prismui-background-color-6: #f2f2f3;
--prismui-background-color-7: #5e7ce0; --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-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-2: linear-gradient(180deg, #fffffff2, #f8fafff2 99%);
--prismui-background-3: linear-gradient(to right, #f3efff, #f3efff33, #e2f1fd33, #e2f1fd); --prismui-background-3: linear-gradient(to right, #f3efff, #f3efff33, #e2f1fd33, #e2f1fd);
@ -90,11 +91,11 @@ pre, pre code {
/* 使用主题变量:普通文字色 --prismui-color-3,高光使用主题蓝色 --prismui-color-5 */ /* 使用主题变量:普通文字色 --prismui-color-3,高光使用主题蓝色 --prismui-color-5 */
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--prismui-color-3) 0%, var(--prismui-color-1) 0%,
var(--prismui-color-3) 35%, var(--prismui-color-1) 35%,
var(--prismui-color-7) 50%, var(--prismui-color-3) 50%,
var(--prismui-color-3) 65%, var(--prismui-color-1) 65%,
var(--prismui-color-3) 100% var(--prismui-color-1) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
background-clip: text; background-clip: text;

Binary file not shown.