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(
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,6 +440,14 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
margin_bottom="8px",
line_height="22px",
),
rx.hstack(
# 分割线
rx.box(
width="1px",
margin="12px 7px",
background_color="var(--prismui-background-color-2)",
),
rx.vstack(
# 内容
rx.markdown(
item.content,
@ -453,46 +459,83 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
# 审批
rx.cond(
item.has_approval,
rx.hstack(
rx.vstack(
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",
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(
"发送",
# 点击事件:审批
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.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%",
height="45px",
align_items="center",
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%",
gap="4px",
),
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",

View File

@ -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):
@ -367,6 +364,10 @@ class ConversationState(rx.State):
# 工具调用分片
case ToolCallPart(
tool_call_id=tool_call_id, tool_name=tool_name
):
if (
tool_call_id
not in conversation.workflow.tool_call_id_map_to_message_id
):
# 构建消息实例
message = Message(
@ -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()}),
):
...

View File

@ -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="审批用户提示词不可输出状态"
)
# 工作流适配器

View File

@ -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

View File

@ -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;

Binary file not shown.