This commit is contained in:
liubiren 2026-09-10 20:54:14 +08:00
parent 606ad01352
commit 31828a7be3
6 changed files with 305 additions and 139 deletions

View File

@ -1,4 +1,5 @@
{ {
"python.languageServer": "None", "python.languageServer": "None",
"python.analysis.extraPaths": ["${workspaceFolder}"] "python.analysis.extraPaths": ["${workspaceFolder}"],
"python.REPL.enableREPLSmartSend": false
} }

View File

@ -24,7 +24,7 @@ def conversation_history_item(
# 高亮 # 高亮
is_highlight = ( is_highlight = (
item.id == ConversationState.is_conversation_history_item_popover_shown item.id == ConversationState.popover_shown_conversation_history_item_id
) | (item.id == ConversationState.actived_conversation_id) ) | (item.id == ConversationState.actived_conversation_id)
return rx.list.item( return rx.list.item(
@ -425,7 +425,7 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
:return: Component :return: Component
""" """
return rx.vstack( return rx.vstack(
# 工具调用的标题栏 # 标题栏
rx.hstack( rx.hstack(
rx.icon( rx.icon(
"square-function", "square-function",
@ -444,7 +444,7 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
margin_bottom="8px", margin_bottom="8px",
line_height="22px", line_height="22px",
), ),
# 工具调用的内容 # 内容
rx.markdown( rx.markdown(
item.content, item.content,
width="100%", width="100%",
@ -453,6 +453,54 @@ def tool_call(item: MessageHistoryItem) -> rx.Component:
font_size="var(--prismui-font-size-1)", font_size="var(--prismui-font-size-1)",
color="var(--prismui-color-3)", color="var(--prismui-color-3)",
), ),
# 审批
rx.cond(
item.has_approval,
rx.hstack(
rx.input(
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.button(
"发送",
# 点击事件:审批
on_click=lambda: ConversationState.approve(item.id),
# 是否禁用绑定验证码发送禁用状态
disabled=AuthState.is_captcha_code_sending_button_disabled,
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)",
),
cursor=rx.cond(
AuthState.is_captcha_code_sending_button_disabled,
"not-allowed",
"pointer",
),
),
width="100%",
height="45px",
align_items="center",
border="var(--prismui-border-1)",
border_radius="var(--prismui-border-radius-4)",
),
rx.fragment(),
),
) )
@ -772,7 +820,7 @@ def user_prompt_sending() -> rx.Component:
# 不可点击状态:绑定用户提示词发送按钮不可点击状态 # 不可点击状态:绑定用户提示词发送按钮不可点击状态
disabled=ConversationState.is_user_prompt_sending_button_disabled, disabled=ConversationState.is_user_prompt_sending_button_disabled,
# 点击事件:运行 # 点击事件:运行
on_click=ConversationState.run, on_click=lambda: ConversationState.run(),
display="inline-flex", display="inline-flex",
position="relative", position="relative",
justify_content="center", justify_content="center",

View File

@ -4,7 +4,12 @@
""" """
from datetime import datetime from datetime import datetime
from typing import AsyncGenerator, cast from typing import AsyncGenerator, cast
from pydantic_ai import DeferredToolRequests from pydantic_ai import (
DeferredToolRequests,
DeferredToolResults,
ToolApproved,
ToolDenied,
)
from pydantic_ai.messages import ( from pydantic_ai.messages import (
FunctionToolCallEvent, FunctionToolCallEvent,
@ -67,13 +72,13 @@ class ConversationState(rx.State):
# 当前用户的会话字典 # 当前用户的会话字典
conversations: dict[str, Conversation] = {} # 键为会话唯一标识,值为会话实例 conversations: dict[str, Conversation] = {} # 键为会话唯一标识,值为会话实例
# 激活会话唯一标识 # 激活的会话唯一标识
actived_conversation_id: str = "" actived_conversation_id: str = ""
# 显示会话历史,True 表示显示,False 表示隐藏 # 显示会话历史,True 表示显示,False 表示隐藏
is_conversation_history_shown: bool = False is_conversation_history_shown: bool = False
# 会话历史项显示悬停气泡 # 悬停气泡显示的会话历史项唯一标识(一个会话只允许存在一个悬停气泡会话历史项)
is_conversation_history_item_popover_shown: str = "" popover_shown_conversation_history_item_id: str = ""
# 当前数据库状态(私有变量) # 当前数据库状态(私有变量)
# 私有变量:reflex 约定以 _ 开头的变量为私有变量,后端不序列化,前端不可使用 # 私有变量:reflex 约定以 _ 开头的变量为私有变量,后端不序列化,前端不可使用
@ -101,8 +106,8 @@ class ConversationState(rx.State):
# 获取当前数据库状态 # 获取当前数据库状态
db_state = await self.get_db_state() db_state = await self.get_db_state()
# 获取当前用户的会话字典 # 查询当前用户的会话字典
self.conversations = await db_state.get_conversations(self.user_id) self.conversations = await db_state.read_conversations(self.user_id)
# 若当前用户的会话字典为空则先创建会话记录再添加会话实例 # 若当前用户的会话字典为空则先创建会话记录再添加会话实例
if not self.conversations: if not self.conversations:
self.conversations.update( self.conversations.update(
@ -136,15 +141,13 @@ class ConversationState(rx.State):
@rx.event @rx.event
def set_conversation_history_item_popover_shown( def set_conversation_history_item_popover_shown(
self, conversation_id: str, is_shown: bool self, item_id: str, is_shown: bool
) -> None: ) -> None:
""" """
设置会话历史项悬停气泡显示 / 隐藏 设置会话历史项悬停气泡显示 / 隐藏
:return: None :return: None
""" """
self.is_conversation_history_item_popover_shown = ( self.popover_shown_conversation_history_item_id = item_id if is_shown else ""
conversation_id if is_shown else ""
)
@rx.event @rx.event
async def delete_conversation(self, conversation_id: str) -> None: async def delete_conversation(self, conversation_id: str) -> None:
@ -188,10 +191,22 @@ class ConversationState(rx.State):
conversation = self.conversations.get(self.actived_conversation_id) conversation = self.conversations.get(self.actived_conversation_id)
if not conversation: if not conversation:
return [] return []
return [ message_history: list[MessageHistoryItem] = []
MessageHistoryItem(**message.model_dump()) has_approval = False
for message in list(conversation.messages.values()) approval_user_prompt = ""
] for message in conversation.messages.values():
approval = message.approval
if isinstance(approval, Approval):
has_approval = True
approval_user_prompt = approval.user_prompt
message_history.append(
MessageHistoryItem(
**message.model_dump(),
has_approval=has_approval,
approval_user_prompt=approval_user_prompt,
)
)
return message_history
@rx.event @rx.event
async def create_conversation(self) -> None: async def create_conversation(self) -> None:
@ -245,34 +260,16 @@ class ConversationState(rx.State):
return True return True
return conversation.is_running or not conversation.user_prompt return conversation.is_running or not conversation.user_prompt
@rx.var
def is_running(self) -> bool:
"""
当前会话正在运行
:return: 当前会话正在运行(True 表示正在运行,False 表示运行完成)
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return False
return conversation.is_running
@rx.var
def awaiting_stream(self) -> bool:
"""
当前会话正在等待流式输出
:return: 当前会话正在等待流式输出(True 表示等待流式输出,False 表示已开始流式输出或已完成)
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return False
return conversation.awaiting_stream
@rx.event @rx.event
async def run(self) -> AsyncGenerator[None]: async def run(
self,
approval: Approval | None = None,
deferred_tool_results: DeferredToolResults | None = None,
) -> AsyncGenerator[None]:
""" """
运行 运行
:param approval: 审批实例
:param deferred_tool_results: 延迟工具调用结果
:return: AsyncGenerator :return: AsyncGenerator
""" """
# 当前会话 # 当前会话
@ -280,6 +277,11 @@ class ConversationState(rx.State):
if not conversation: if not conversation:
return return
# 获取数据库状态
db_state = await self.get_db_state()
# 无审批实例
if not approval:
# 若用户提示词为空则直接返回 # 若用户提示词为空则直接返回
if not conversation.user_prompt: if not conversation.user_prompt:
return return
@ -290,13 +292,6 @@ class ConversationState(rx.State):
conversation.awaiting_stream = True conversation.awaiting_stream = True
yield # 通知前端更新渲染 yield # 通知前端更新渲染
# 获取数据库状态
db_state = await self.get_db_state()
# 更新会话记录
await db_state.update_conversation_record(
conversation.id,
workflow=conversation.workflow,
)
# 先创建消息记录再添加消息实例 # 先创建消息记录再添加消息实例
conversation.messages.update( conversation.messages.update(
await db_state.create_message_record( await db_state.create_message_record(
@ -311,6 +306,15 @@ class ConversationState(rx.State):
conversation.user_prompt = "" conversation.user_prompt = ""
yield # 通知前端更新渲染 yield # 通知前端更新渲染
# 有审批实例
else:
if not (user_prompt := approval.user_prompt):
return
# 将等待流式输出设置为是
conversation.awaiting_stream = True
yield # 通知前端更新渲染
# 初始化片段索引映射为消息实例唯一标识字典 # 初始化片段索引映射为消息实例唯一标识字典
index_map_to_message_id: dict[int, str] = {} index_map_to_message_id: dict[int, str] = {}
# 初始化工具调用片段工具名称映射为消息实例唯一标识字典 # 初始化工具调用片段工具名称映射为消息实例唯一标识字典
@ -328,10 +332,11 @@ class ConversationState(rx.State):
stream_events = run_stream_events( stream_events = run_stream_events(
deps=conversation.workflow.deps, deps=conversation.workflow.deps,
user_prompt=user_prompt, user_prompt=user_prompt,
message_history=await db_state.get_message_history( message_history=await db_state.read_message_history(
conversation_id=self.actived_conversation_id conversation_id=self.actived_conversation_id
), ),
usage=conversation.usage, usage=conversation.usage,
deferred_tool_results=deferred_tool_results,
) )
else: else:
from application.workshop.talk import ( from application.workshop.talk import (
@ -341,7 +346,7 @@ class ConversationState(rx.State):
# 运行并流式输出事件 # 运行并流式输出事件
stream_events = run_stream_events( stream_events = run_stream_events(
user_prompt=user_prompt, user_prompt=user_prompt,
message_history=await db_state.get_message_history( message_history=await db_state.read_message_history(
conversation_id=self.actived_conversation_id conversation_id=self.actived_conversation_id
), ),
usage=conversation.usage, usage=conversation.usage,
@ -469,15 +474,17 @@ class ConversationState(rx.State):
# ========== 智能体运行结果事件 ========== # ========== 智能体运行结果事件 ==========
case AgentRunResultEvent(result=result): case AgentRunResultEvent(result=result):
if isinstance(result.output, DeferredToolRequests): if isinstance(output := result.output, DeferredToolRequests):
for approval in result.output.approvals: for approval_ in output.approvals:
# 获取消息实例 # 获取消息实例
message = conversation.messages[ message = conversation.messages[
tool_name_map_to_message_id[approval.tool_name] tool_name_map_to_message_id[approval_.tool_name]
] ]
message.content = output.metadata[
tool_call_id := approval_.tool_call_id
]["content"]
message.approval = Approval( message.approval = Approval(
tool_call_id=approval.tool_call_id, tool_call_id=tool_call_id,
interaction_type=approval.interaction_type,
) )
messages.append(message) messages.append(message)
else: else:
@ -499,7 +506,7 @@ class ConversationState(rx.State):
) )
self.conversations[self.actived_conversation_id] = conversation self.conversations[self.actived_conversation_id] = conversation
yield yield # 通知前端更新渲染
# 批量创建消息记录 # 批量创建消息记录
await db_state.create_message_records( await db_state.create_message_records(
@ -507,6 +514,88 @@ class ConversationState(rx.State):
messages=messages, messages=messages,
) )
@rx.var
def is_running(self) -> bool:
"""
当前会话正在运行
:return: 当前会话正在运行(True 表示正在运行,False 表示运行完成)
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return False
return conversation.is_running
@rx.var
def awaiting_stream(self) -> bool:
"""
当前会话正在等待流式输出
:return: 当前会话正在等待流式输出(True 表示等待流式输出,False 表示已开始流式输出或已完成)
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return False
return conversation.awaiting_stream
@rx.event
def toggle_message_history_item_shown(self, item_id: str) -> None:
"""
展示 / 隐藏消息历史项
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return
conversation.messages[item_id].is_shown ^= True
@rx.event
def set_approval_user_prompt(self, item_id: str, user_prompt: str) -> None:
"""
设置审批用户提示词
:param item_id: 消息历史项唯一标识
:param user_prompt: 用户提示词
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return
# 获取消息实例
message = conversation.messages.get(item_id)
if not message or not isinstance(message.approval, Approval):
return
message.approval.user_prompt = user_prompt
@rx.event
async def approve(self, item_id: str, result: bool) -> None:
"""
审批消息历史项
:param item_id: 消息历史项唯一标识
:param result: 审批结果(True 表示审批通过,False 表示审批拒绝)
"""
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return
# 获取审批实例
if not (message := conversation.messages.get(item_id)) or not isinstance(
approval := message.approval, Approval
):
return
# 更新审批结果
approval.result = result
# 运行
await self.run(
approval,
DeferredToolResults(
approvals={
approval.tool_call_id: ToolApproved() if result else ToolDenied()
}
),
)
@rx.event @rx.event
async def set_workflow(self, type: WorkflowType) -> None: async def set_workflow(self, type: WorkflowType) -> None:
""" """
@ -529,14 +618,10 @@ class ConversationState(rx.State):
) )
conversation.workflow = workflow conversation.workflow = workflow
# 获取数据库状态
@rx.event db_state = await self.get_db_state()
def toggle_message_history_item_shown(self, message_id: str) -> None: # 更新会话记录
""" await db_state.update_conversation_record(
展示 / 隐藏消息历史项 conversation.id,
""" workflow=conversation.workflow,
# 当前会话 )
conversation = self.conversations.get(self.actived_conversation_id)
if not conversation:
return
conversation.messages[message_id].is_shown ^= True

View File

@ -19,6 +19,8 @@ from application.states.models import (
usage_validate_json, usage_validate_json,
workflow_dump_json, workflow_dump_json,
workflow_validate_json, workflow_validate_json,
approval_dump_json,
approval_validate_json,
) )
@ -101,7 +103,7 @@ class RunRecord(SQLModel, table=True):
description="运行唯一标识", description="运行唯一标识",
) )
conversation_id: str = Field(..., index=True, description="会话唯一标识") conversation_id: str = Field(..., index=True, description="会话唯一标识")
new_messages: str = Field(default="", sa_type=JSON, description="新增消息") new_messages: str = Field(..., description="新增消息")
class MessageRecord(SQLModel, table=True): class MessageRecord(SQLModel, table=True):
@ -118,6 +120,9 @@ class MessageRecord(SQLModel, table=True):
type: MessageType = Field(..., description="消息类型") type: MessageType = Field(..., description="消息类型")
title: str = Field(default="", description="消息标题") title: str = Field(default="", description="消息标题")
content: str = Field(default="", description="消息内容") content: str = Field(default="", description="消息内容")
approval: str = Field(
default="", description="审批"
) # 原则上嵌套列表或字典若不涉及查询则以字符串储存
class DatabaseState(rx.State): class DatabaseState(rx.State):
@ -197,9 +202,31 @@ class DatabaseState(rx.State):
await session.refresh(record) await session.refresh(record)
return record.id return record.id
async def get_conversations(self, user_id: str) -> dict[str, Conversation]: async def create_conversation_record(self, user_id: str) -> dict[str, Conversation]:
""" """
获取当前用户的会话字典 创建会话记录
:param user_id: 用户唯一标识
:return: 会话实例
"""
async with rx.asession() as session:
record = ConversationRecord(user_id=user_id)
session.add(record)
await session.commit()
await session.refresh(record)
return {
record.id: Conversation(
id=record.id,
description=record.description,
usage=usage_validate_json(
record.usage
), # 将 record.usage 由 JSON 字符串反序列化为 RunUsage
created_at=record.created_at,
)
}
async def read_conversations(self, user_id: str) -> dict[str, Conversation]:
"""
查询当前用户的会话字典
:param user_id: 用户唯一标识 :param user_id: 用户唯一标识
:return: 当前用户的会话字典 :return: 当前用户的会话字典
""" """
@ -241,33 +268,11 @@ class DatabaseState(rx.State):
type=message_record.type, type=message_record.type,
title=message_record.title, title=message_record.title,
content=message_record.content, content=message_record.content,
approval=approval_validate_json(message_record.approval),
), ),
) )
return conversations return conversations
async def create_conversation_record(self, user_id: str) -> dict[str, Conversation]:
"""
创建会话记录
:param user_id: 用户唯一标识
:return: 会话实例
"""
async with rx.asession() as session:
record = ConversationRecord(user_id=user_id)
session.add(record)
await session.commit()
await session.refresh(record)
return {
record.id: Conversation(
id=record.id,
description=record.description,
usage=usage_validate_json(
record.usage
), # 将 record.usage 由 JSON 字符串反序列化为 RunUsage
messages={}, # 新会话默认消息字典为空
created_at=record.created_at,
)
}
async def update_conversation_record( async def update_conversation_record(
self, self,
conversation_id: str, conversation_id: str,
@ -321,6 +326,7 @@ class DatabaseState(rx.State):
type=message.type, type=message.type,
title=message.title, title=message.title,
content=message.content, content=message.content,
approval=approval_dump_json(message.approval),
) )
session.add(record) session.add(record)
await session.commit() await session.commit()
@ -335,8 +341,8 @@ class DatabaseState(rx.State):
""" """
创建消息记录 创建消息记录
:param conversation_id: 会话唯一标识 :param conversation_id: 会话唯一标识
:param message: 消息实例 :param messages: 消息实例列表
:return: 消息实例 :return: None
""" """
async with rx.asession() as session: async with rx.asession() as session:
for message in messages: for message in messages:
@ -346,13 +352,14 @@ class DatabaseState(rx.State):
type=message.type, type=message.type,
title=message.title, title=message.title,
content=message.content, content=message.content,
approval=approval_dump_json(message.approval),
) )
session.add(record) session.add(record)
await session.commit() await session.commit()
async def get_message_history(self, conversation_id: str) -> list[ModelMessage]: async def read_message_history(self, conversation_id: str) -> list[ModelMessage]:
""" """
获取消息历史列表 查询消息历史列表
:param conversation_id: 会话唯一标识 :param conversation_id: 会话唯一标识
:return: 消息历史 :return: 消息历史
""" """

View File

@ -19,23 +19,6 @@ class NoResult(BaseModel):
... ...
class InteractionType(StrEnum):
"""
交互类型枚举
"""
INPUT = "input"
class Approval(BaseModel):
"""
审批类
"""
tool_call_id: str = Field(..., description="工具调用唯一标识")
interaction_type: InteractionType = Field(..., description="交互类型")
class MessageType(StrEnum): class MessageType(StrEnum):
""" """
消息类型枚举 消息类型枚举
@ -47,6 +30,19 @@ class MessageType(StrEnum):
TEXT = "text" TEXT = "text"
class Approval(BaseModel):
"""
审批类
"""
tool_call_id: str = Field(..., description="工具调用唯一标识")
user_prompt: str = Field(default="", description="用户提示词")
result: bool | None = Field(
default=None,
description="审批结果,True 表示审批通过,False 表示审批拒绝,None 表示待审批",
)
class Message(BaseModel): class Message(BaseModel):
""" """
消息类 消息类
@ -118,6 +114,9 @@ class MessageHistoryItem(Message):
消息历史项类 消息历史项类
""" """
has_approval: bool = Field(default=False, description="包含审批实例")
approval_user_prompt: str = Field(default="", description="审批用户提示词")
# 工作流适配器 # 工作流适配器
WorkflowAdapter = TypeAdapter(Any) WorkflowAdapter = TypeAdapter(Any)
@ -161,3 +160,27 @@ def usage_dump_json(usage: RunUsage) -> str:
:return: 字符串 :return: 字符串
""" """
return UsageAdapter.dump_json(usage).decode("utf-8") return UsageAdapter.dump_json(usage).decode("utf-8")
# 审批适配器
ApprovalAdapter = TypeAdapter(Approval)
def approval_validate_json(approval: str) -> Approval | None:
"""
将审批反序列化
"""
if not approval:
return None
return ApprovalAdapter.validate_json(approval)
def approval_dump_json(approval: Approval | None) -> str:
"""
将审批序列化
:param approval: 审批
:return: 字符串
"""
if approval is None:
return ""
return ApprovalAdapter.dump_json(approval).decode("utf-8")

View File

@ -28,7 +28,6 @@ from pydantic_ai.run import AgentRunResult, AgentRunResultEvent
from application.states.models import ( from application.states.models import (
NoResult, NoResult,
InteractionType,
Approval, Approval,
) )
from application.workshop.models import ( from application.workshop.models import (
@ -207,7 +206,6 @@ async def search_flights(ctx: RunContext[Deps]) -> list[Flight]:
@agent.tool( @agent.tool(
name="预定航班", name="预定航班",
requires_approval=True,
) )
async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult:
""" """
@ -216,6 +214,9 @@ async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult:
if (searched_flights := ctx.deps.searched_flights) is None: if (searched_flights := ctx.deps.searched_flights) is None:
raise ModelRetry("必须先使用 search_flights 查询航班") raise ModelRetry("必须先使用 search_flights 查询航班")
if not ctx.tool_call_approved:
raise ApprovalRequired(metadata={"content": "您要预定哪班航班?"})
# 提取到的航班号 # 提取到的航班号
extracted_flight_number = ( extracted_flight_number = (
await extraction_flight_number_agent.run( await extraction_flight_number_agent.run(
@ -298,6 +299,7 @@ async def run_stream_events(
deferred_tool_results=deferred_tool_results, deferred_tool_results=deferred_tool_results,
) as events: ) as events:
async for event in events: async for event in events:
print(event)
match event: match event:
case PartStartEvent( case PartStartEvent(
part=part, part=part,