260624更新

This commit is contained in:
liubiren 2026-06-24 20:07:17 +08:00
parent 2f51a9552c
commit d0eb2932dc
8 changed files with 351 additions and 298 deletions

View File

@ -1,21 +1,21 @@
# -*- coding: utf-8 -*-
"""
聊天相关组件
聊天页面相关组件
"""
import reflex as rx
from application.models import Type_, Message, Dialog
from application.models import PartType, Part, Dialog
from application.state import ChatState
def render_input_bubble(input_: str) -> rx.Component:
def render_user_prompt(user_prompt: str) -> rx.Component:
"""
渲染输入气泡
:param content: 输入内容
渲染用户提示词
:param user_prompt: 用户提示词
:return: Component
"""
return rx.markdown(
input_,
user_prompt,
color=rx.color("gray", 12), # 文字颜色
background_color=rx.color("gray", 2), # 背景颜色
display="inline-block", # 布局模式:自适应文本宽度
@ -28,77 +28,20 @@ def render_input_bubble(input_: str) -> rx.Component:
)
def render_collapse_panel(dialog_id: str, message: Message) -> rx.Component:
def render_part(dialog_id: str, part_id: str, part: Part):
"""
渲染折叠面板
渲染片段
:param dialog_id: 对话唯一标识
:param message: 消息实例
:param part_id: 片段唯一标识
:param part: 片段实例
:return: Component
"""
return rx.box(
rx.cond(
message.is_open,
rx.hstack(
rx.text(
message.content,
font_size="0.90rem",
bold=True,
color=rx.color("gray", 10),
),
rx.spacer(),
rx.icon("chevron_up", size=16, color=rx.color("gray", 6)),
width="100%",
cursor="pointer",
on_click=ChatState.toggle_collapse_panel(dialog_id, message.id),
padding_x="1em",
padding_y="0.6em",
background_color=rx.color("gray", 2),
border_radius="8px",
), # 折叠面板打开
rx.hstack(
rx.hstack(
rx.text(
loading_prefix,
font_size="0.90rem",
bold=True,
color=rx.color("gray", 10),
),
loading_dot_group(message.is_streaming),
spacing="8", # 文字和圆点拉开一点间距,更美观
align_items="center",
),
rx.spacer(),
rx.icon("chevron_down", size=16, color=rx.color("gray", 6)),
width="100%",
cursor="pointer",
on_click=ChatState.toggle_collapse_panel(dialog_id, message.id),
padding_x="1em",
padding_y="0.6em",
background_color=rx.color("gray", 2),
border_radius="8px",
), # 折叠面板关闭
),
rx.cond(message.is_open, detail_box, rx.fragment()),
width="100%",
max_width="85%",
margin_bottom="10px",
key=message.id,
cursor="pointer",
)
def render_output_container(dialog_id: str, message: Message) -> rx.Component:
"""
渲染输出容器
:param dialog_id: 对话唯一标识
:param message: 消息实例
:return: Component
"""
return rx.cond(
message.type_ == Type_.TEXT,
return rx.match(
part.part_type,
(
PartType.TEXT,
rx.markdown(
message.content,
part.content,
font_size="0.90rem",
color=rx.color("gray", 12), # 字体颜色
background_color="transparent", # 背景颜色:设置为透明以继承父元素背景颜色
@ -107,21 +50,72 @@ def render_output_container(dialog_id: str, message: Message) -> rx.Component:
padding="0", # 内边距
margin_right="auto", # 右侧外边距自动调整
margin_bottom="12px", # 底部外边距
key=message.id,
key=part_id,
),
), # 文本片段
(PartType.FINISHED, rx.fragment(key=part_id)), # 结束片段
rx.box(
rx.cond(
part.is_open,
rx.hstack(
rx.text(
part.content,
font_size="0.90rem",
bold=True,
color=rx.color("gray", 10),
),
rx.spacer(),
rx.icon("chevron_up", size=16, color=rx.color("gray", 6)),
width="100%",
cursor="pointer",
padding_x="1em",
padding_y="0.6em",
background_color=rx.color("gray", 2),
border_radius="8px",
on_click=ChatState.toggle_collapse_panel(dialog_id, part_id),
), # 折叠面板打开时标题栏
rx.hstack(
rx.hstack(
rx.text(
loading_prefix,
font_size="0.90rem",
bold=True,
color=rx.color("gray", 10),
),
loading_dot_group(part.is_streaming),
spacing="8", # 间距
align_items="center",
),
rx.spacer(),
rx.icon("chevron_down", size=16, color=rx.color("gray", 6)),
width="100%",
cursor="pointer",
on_click=ChatState.toggle_collapse_panel(dialog_id, part_id),
padding_x="1em",
padding_y="0.6em",
background_color=rx.color("gray", 2),
border_radius="8px",
), # 折叠面板关闭时标题栏
),
rx.cond(part.is_open, detail_box, rx.fragment()),
width="100%",
max_width="85%",
margin_bottom="10px",
cursor="pointer",
key=part_id,
),
render_collapse_panel(dialog_id=dialog_id, message=message),
)
def render_dialog_container(dialog: Dialog) -> rx.Component:
def render_dialog_item(dialog_id: str, dialog: Dialog) -> rx.Component:
"""
渲染对话容器
渲染对话
:param dialog: 对话实例
:return: Component
"""
return rx.box(
rx.box(
render_input_bubble(input_=dialog.input_),
render_user_prompt(user_prompt=dialog.user_prompt),
text_align="right",
width="100%",
margin_bottom="8px",
@ -129,8 +123,8 @@ def render_dialog_container(dialog: Dialog) -> rx.Component:
rx.box(
rx.foreach(
dialog.output,
lambda message: render_output_container(
dialog_id=dialog.id, message=message
lambda part_id, part: render_part(
dialog_id=dialog_id, part_id=part_id, part=part
),
),
text_align="left",
@ -139,33 +133,38 @@ def render_dialog_container(dialog: Dialog) -> rx.Component:
),
width="min(100%, 50em)", # 最大宽度父级元素最大宽度和50em中较小值
margin_x="auto", # 水平外边距:自动调整
key=dialog.id,
key=dialog_id,
)
def dialog_list_component() -> rx.Component:
def render_dialog_list() -> rx.Component:
"""
对话列表组件
渲染对话列表
:return: Component
"""
return rx.auto_scroll(
rx.foreach(ChatState.get_current_chat_dialogs, dialog_item_component),
rx.foreach(
ChatState.get_dialogs,
lambda dialog_id, dialog: render_dialog_item(
dialog_id=dialog_id, dialog=dialog
),
),
flex="1",
padding="8px",
overflow_y="auto",
)
def input_component() -> rx.Component:
def render_input_bar() -> rx.Component:
"""
输入组件
渲染输入栏
"""
return rx.center(
rx.vstack(
rx.form(
rx.hstack(
rx.input(
name="input",
name="user_prompt",
placeholder="请输入...",
flex="auto",
),

View File

@ -1,53 +1,25 @@
# -*- coding: utf-8 -*-
"""
导航栏组件
导航栏相关组件
"""
import reflex as rx
from application.state.chat import ChatState
from application.state.create_chat_modal import CreateChatModalState
from application.state.create_chat import CreateChatState
def create_chat_modal_component(trigger) -> rx.Component:
def render_chat_item(chat_id: str, chat_description: str) -> rx.Component:
"""
新建聊天模态窗组件
"""
return rx.dialog.root(
rx.dialog.trigger(trigger),
rx.dialog.content(
rx.form(
rx.hstack(
rx.input(
name="chat_description",
placeholder="聊天描述",
flex="auto",
min_width="20ch",
),
rx.button("新建聊天"),
spacing="2",
wrap="wrap",
width="100%",
),
on_submit=ChatState.create_chat,
),
background_color=rx.color("mauve", 1),
),
open=CreateChatModalState.is_open,
on_open_change=CreateChatModalState.toggle,
)
def chat_item_component(chat_id: str) -> rx.Component:
"""
聊天项组件
渲染聊天项
:param chat_id: 聊天唯一标识
:param description: 聊天描述
:return: Component
"""
return rx.drawer.close(
rx.hstack(
rx.button(
ChatState.get_chat_descriptions[chat_id],
chat_description,
on_click=lambda: ChatState.switch_chat(chat_id), # 点击按钮将切换会话
width="80%",
variant="surface",
@ -68,9 +40,9 @@ def chat_item_component(chat_id: str) -> rx.Component:
)
def chat_list_component(trigger) -> rx.Component:
def render_chat_list(trigger) -> rx.Component:
"""
聊天列表组件
渲染聊天列表
"""
return rx.drawer.root(
rx.drawer.trigger(trigger),
@ -81,9 +53,10 @@ def chat_list_component(trigger) -> rx.Component:
rx.heading("聊天列表", color=rx.color("mauve", 11)),
rx.divider(),
rx.foreach(
ChatState.get_chat_ids, # 获取所有聊天唯一标识
lambda chat_id: chat_item_component(
chat_id=chat_id
ChatState.get_chats, # 获取聊天列表
lambda chat_id, chat: render_chat_item(
chat_id=chat_id,
chat_description=chat.description,
), # 创建聊天组件
),
align_items="stretch",
@ -102,21 +75,50 @@ def chat_list_component(trigger) -> rx.Component:
)
def navbar_component() -> rx.Component:
def render_create_chat_modal(trigger) -> rx.Component:
"""
导航栏组件
渲染新建聊天模态窗
"""
return rx.dialog.root(
rx.dialog.trigger(trigger),
rx.dialog.content(
rx.form(
rx.hstack(
rx.input(
name="chat_description",
placeholder="请输入聊天描述(可选)",
flex="auto",
min_width="20ch",
),
rx.button("新建"),
spacing="2",
wrap="wrap",
width="100%",
),
on_submit=ChatState.create_chat,
),
background_color=rx.color("mauve", 1),
), # 模态窗的内容容器
open=CreateChatState.is_open,
on_open_change=CreateChatState.toggle,
)
def render_navbar() -> rx.Component:
"""
渲染导航栏
"""
return rx.hstack(
rx.badge(
ChatState.get_chat_descriptions[ChatState.current_chat_id],
ChatState.get_current_chat_description,
size="3",
variant="soft",
margin_inline_end="auto",
),
create_chat_modal_component(
render_create_chat_modal(
rx.box(rx.tooltip(rx.icon("message-square-plus"), content="新建聊天"))
),
chat_list_component(
render_chat_list(
rx.box(
rx.tooltip(
rx.icon("messages-square"),

View File

@ -4,7 +4,7 @@
"""
from enum import StrEnum
from time import time_ns
from typing import List
from typing import List, Dict
from uuid import uuid4
from pydantic import BaseModel, Field
@ -47,26 +47,25 @@ class MessageHistory(SQLModel, table=True):
class PartType(StrEnum):
"""片段类"""
"""片段类"""
THINKING = "thinking"
TEXT = "text"
CALL = "call"
TOOL_ARGS = "tool_args"
TOOL_RETURN = "tool_return"
RESULT = "result"
FINISHED = "finished"
ERROR = "error"
# 动态生成前缀和消息类型映射表
# 动态生成前缀和片段类型映射表
PREFIX_MAPING = {f"{i:02d}:": t for i, t in enumerate(PartType)}
class Part(BaseModel):
"""片段类(仅就输出消息)"""
id: str = Field(default_factory=lambda: uuid4().hex, description="片段唯一标识")
part_type: PartType = Field(..., alias="type", description="片段类型")
part_type: PartType = Field(..., description="片段类型")
content: str = Field(default="", description="片段内容")
is_streaming: bool = Field(
@ -83,17 +82,20 @@ class Part(BaseModel):
class Dialog(BaseModel):
"""对话类"""
id: str = Field(default_factory=lambda: uuid4().hex, description="对话唯一标识")
user_prompt: str = Field(..., description="用户提示词")
output: List[Part] = Field(default_factory=list, description="输出")
output: Dict[str, Part] = Field(
default_factory=dict, description="输出,键为片段唯一标识,值为片段对象"
)
class Chat(BaseModel):
"""聊天类"""
description: str = Field(default="新聊天", description="描述")
description: str = Field(default="新聊天", description="聊天描述")
is_streaming: bool = Field(
default=False,
description="流式输出状态True 表示正在流式输出False 表示非正在流式输出",
)
dialogs: List[Dialog] = Field(default_factory=list, description="对话列表")
dialogs: Dict[str, Dialog] = Field(
default_factory=dict, description="对话列表,键为对话唯一标识,值为对话对象"
)

View File

@ -5,14 +5,14 @@
import reflex as rx
from ..components.navbar import navbar_component
from ..components.navbar import render_navbar
from ..components.chat import dialog_list_component, input_component
def index_page() -> rx.Component:
"""根页面"""
"""根页面(聊天页面)"""
return rx.vstack(
navbar_component(), # 导航栏组件
render_navbar(), # 导航栏组件
dialog_list_component(), # 对话列表组件
input_component(), # 输入组件
color=rx.color(color="mauve", shade=12),

View File

@ -2,18 +2,18 @@
"""
聊天页面状态
"""
from typing import Any, AsyncGenerator, Dict, List
from typing import Any, AsyncGenerator, Dict, List, Tuple
from uuid import uuid4
import reflex as rx
from application.utils.agent import Agent
from application.models import Chat, Dialog, Message, PREFIX_MAPING
from application.state.create_chat_modal import CreateChatModalState
from application.state.global_ import GlobalState
from application.models import Chat, Dialog, Part, PartType, PREFIX_MAPING
from application.state.create_chat import CreateChatState
from application.state.database import DatabaseState
# 因 rx.state 需在进程与 WebSocket 间交互,非序列化对象会传输或储存失败,故需将智能体实例单独存储
# 绑定的智能体列表(键为聊天唯一标识,值为智能体实例)
# 因 rx.state 需在 Python 进程与 WebSocket 之间传输和存储等操作,非序列化对象会失败,故需将智能体实例单独存储
agents: Dict[str, Agent] = {}
@ -22,57 +22,61 @@ def get_agent(chat_id: str) -> Agent:
获取绑定的智能体
:return: 绑定的智能体
"""
# 若智能体唯一标识不存在则新建智能体实例
if chat_id not in agents:
agents[chat_id] = Agent(
chat_id=chat_id,
instructions="You are a friendly chatbot", # 智能体指令
)
) # 使用默认指令
return agents[chat_id]
class ChatState(rx.State):
"""
聊天页面状态
聊天状态
"""
# 当前聊天唯一标识
# 初始化当前聊天唯一标识
current_chat_id: str = uuid4().hex
# 聊天列表
chats: Dict[str, Chat] = {current_chat_id: Chat()}
@rx.var
def get_chat_ids(self) -> List[str]:
def get_chats(self) -> Dict[str, Chat]:
"""
获取聊天唯一标识列表
:return: 聊天唯一标识列表
获取聊天列表用于前端渲染聊天列表
:return: 聊天列表
"""
return list(self.chats)
return self.chats
@rx.var
def get_chat_descriptions(self) -> Dict[str, str]:
def get_current_chat_description(self) -> str:
"""
获取聊天描述列表
:return: 聊天描述列表
获取当前聊天描述用于前端渲染导航栏中当前聊天描述
:return: 当前聊天描述
"""
return {chat_id: chat.description for chat_id, chat in self.chats.items()}
# 当前聊天
current_chat = self.chats.get(self.current_chat_id)
return current_chat.description if current_chat else "新聊天"
@rx.var
def get_current_chat_status(self) -> bool:
"""
获取当前聊天流式输出状态
:return: 当前聊天流式输出状态True 表示正在流式输出False 表示非正在流式输出
获取当前聊天状态
:return: 当前聊天状态True 表示正在流式输出False 表示非正在流式输出
"""
chat = self.chats.get(self.current_chat_id)
return chat.is_streaming if chat else False
# 当前聊天
current_chat = self.chats.get(self.current_chat_id)
return current_chat.is_streaming if current_chat else False
@rx.var
def get_current_chat_dialogs(self) -> List[Dialog]:
def get_dialogs(self) -> Dict[str, Dialog]:
"""
获取当前聊天对话列表
:return: 当前聊天对话列表
获取对话列表用于前端渲染对话列表
:return: 对话列表
"""
chat = self.chats.get(self.current_chat_id)
return chat.dialogs if chat else []
# 当前聊天
current_chat = self.chats.get(self.current_chat_id)
return current_chat.dialogs if current_chat else {}
@rx.event
def switch_chat(self, chat_id: str) -> None:
@ -99,8 +103,8 @@ class ChatState(rx.State):
)
# 跨状态读取新建聊天模态窗状态(异步事件)
create_chat_modal_state = await self.get_state(CreateChatModalState)
create_chat_modal_state.is_open = False
create_chat_state = await self.get_state(CreateChatState)
create_chat_state.is_open = False
@rx.event
def delete_chat(self, chat_id: str) -> None:
@ -126,8 +130,9 @@ class ChatState(rx.State):
:param form_data: 表单数据
:return: AsyncGenerator
"""
input_ = form_data["input"].strip()
if not input_:
# 获取用户提示词
user_prompt = form_data["user_prompt"].strip()
if not user_prompt:
return
# 当前聊天
@ -135,64 +140,82 @@ class ChatState(rx.State):
if not current_chat:
return
# 生成新增对话唯一标识
current_dialog_id = uuid4().hex
# 新增对话
current_chat.dialogs.append(Dialog(input_=input_))
current_chat.dialogs[current_dialog_id] = Dialog(user_prompt=user_prompt)
# 当前对话
current_dialog = current_chat.dialogs[-1]
current_dialog = current_chat.dialogs[current_dialog_id]
# 设置当前对话流式输出状态为正在流式输出
# 设置当前聊天流式输出状态为正在流式输出
current_chat.is_streaming = True
yield # 通知前端渲染输入消息
# 全局状态
global_state = await self.get_state(GlobalState)
# 获取当前聊天绑定的智能体
agent = get_agent(chat_id=self.current_chat_id)
# 数据库状态
database_state = await self.get_state(DatabaseState)
# 获取当前聊天消息历史
message_history = await global_state.get_message_history(
message_history = await database_state.get_message_history(
chat_id=self.current_chat_id
)
# 流式输出消息事件
async for event in agent.stream_messages_events(
user_prompt=input_, message_history=message_history
# 流式输出模型响应事件
async for event in agent.stream_events(
user_prompt=user_prompt, message_history=message_history
):
# 若消息事件为空则跳过
# 若模型响应事件为空则跳过
if not event:
continue
# 匹配前缀
# 根据前缀和片段类型映射表匹配前缀
prefix = next((p for p in PREFIX_MAPING if event.startswith(p)), None)
# 若未匹配到前缀则跳过
if not prefix:
continue
# 根据前缀匹配片段类型
part_type = PREFIX_MAPING[prefix]
# 根据前缀匹配消息类型
type_ = PREFIX_MAPING[prefix]
# 若当前对话输出消息为空或当前消息类型与新消息类型不相同则新增消息
if not current_dialog.output or current_dialog.output[-1].type_ != type_:
current_dialog.output.append(Message(type_=type_))
# 获取当前片段
current_part = (
next(iter(reversed(current_dialog.output.values())))
if current_dialog.output
else None
)
# 若当前片段为空或其片段类型与当前事件的片段类型不相同则新增片段
if not current_part or current_part.part_type != part_type:
# 若当前片段非空则设置片段流式输出状态非正在流式输出
if current_part:
current_part.is_streaming = False
new_part = Part(part_type=part_type, is_streaming=True)
current_dialog.output[uuid4().hex] = new_part
current_part = new_part
# 追加消息内容
current_dialog.output[-1].content += event.removeprefix(prefix)
yield # 通知前端渲染输出消息
# 追加片段内容
current_part.content += event.removeprefix(prefix)
yield # 通知前端渲染片段
# 重置所有片段流式输出状态
for part in current_dialog.output.values():
part.is_streaming = False
# 保存本轮对话消息
await global_state.save_new_message(
await database_state.save_new_message(
chat_id=self.current_chat_id, new_message=agent.new_messages
)
# 设置当前对话流式输出状态非正在流式输出
# 设置当前聊天流式输出状态非正在流式输出
current_chat.is_streaming = False
@rx.event
async def toggle_collapse_panel(self, dialog_id: str, message_id: str) -> None:
async def toggle_collapse_panel(self, dialog_id: str, part_id: str) -> None:
"""
打开/关闭折叠面板
:return: None
"""
# 当前聊天
current_chat = self.chats[self.current_chat_id]
if not current_chat:
# 当前片段
current_part = self.chats.get(self.current_chat_id, {}).dialogs.get(dialog_id, {}).get(part_id, None)
if not current_part:
return
# 目标对话
target_dialog = next(

View File

@ -5,9 +5,9 @@
import reflex as rx
class CreateChatModalState(rx.State):
class CreateChatState(rx.State):
"""
新建聊天模态窗状态
新建聊天状态
"""
# 新建聊天模态窗打开状态True表示打开False表示关闭

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
全局共享状态
数据库状态
"""
from typing import List
@ -15,8 +15,8 @@ from application.models import (
)
class GlobalState(rx.State):
"""全局状态"""
class DatabaseState(rx.State):
"""数据库状态"""
async def get_message_history(self, chat_id: str) -> List[ModelMessage]:
"""

View File

@ -39,52 +39,56 @@ from pydantic_ai.run import AgentRunResultEvent
class PartBuffer(BaseModel):
"""
片段缓冲类
模型响应片段缓冲类
"""
part_type: Literal["thinking", "text"] = Field(..., description="片段类型")
content_delta: str = Field(default="", description="片段内容增量")
task: Optional[Task] = Field(default=None, description="延迟刷新异步协程任务")
part_type: Literal["thinking", "text"] = Field(
..., description="片段类型,仅支持思考和文本片段"
)
part_content_delta: str = Field(default="", description="片段内容增量")
flush_task: Optional[Task] = Field(
default=None, description="片段绑定的延迟刷新任务"
)
model_config = {"arbitrary_types_allowed": True} # 允许任意类型
class Debouncer:
"""
防抖器
用于处理 run_stream_events 返回的模型消息事件
模型响应事件防抖器
用于平滑 run_stream_events 返回的高频模型响应事件降低前端渲染频率
"""
def __init__(self, delay: float = 0.25):
def __init__(self, flush_delay: float):
"""
初始化
:param delay: 延迟时长单位为秒停顿超该时长则刷新
:param flush_delay: 刷新延迟时长单位为秒
"""
self.delay = delay
self.flush_delay = flush_delay
# 片段缓冲字典(键为片段索引,值为片段缓冲实例)
self.part_buffers: Dict[int, PartBuffer] = {}
# 待刷新异步队列
self.pending_flush_queue = Queue()
# 待推送刷新队列
self.flush_queue = Queue()
async def handle_event(
self, event: Union[AgentStreamEvent, AgentRunResultEvent]
) -> AsyncGenerator[Union[AgentStreamEvent, AgentRunResultEvent]]:
"""
处理模型消息事件
Pydantic-AI 中模型流式输出包含若干类型片段例如思考片段文本片段等每类型片段包含若干模型消息事件例如片段开始事件片段增量事件片段结束事件等
:param event: 模型消息事件
处理模型响应事件
Pydantic-AI 中模型流式输出包含若干类型片段例如思考片段文本片段等每类型片段包含若干模型响应事件例如片段开始事件片段增量事件片段结束事件等
:param event: 模型响应事件
:yield: AsyncGenerator
"""
# 返回待刷新异步队列中模型消息事件
# 优先推送队列中积压的刷新任务
while True:
try:
yield self.pending_flush_queue.get_nowait()
yield self.flush_queue.get_nowait()
except QueueEmpty:
break
# 若为思考或文本的片段开始事件则先缓存片段再返回,其它事件直接返回
# 若为思考或文本的片段开始事件则初始化片段缓冲,其它事件透传
if isinstance(event, PartStartEvent):
index, part = event.index, event.part # 片段索引、片段
part_index, part = event.index, event.part # 片段索引、片段
match part:
case ThinkingPart():
part_type = "thinking"
@ -93,113 +97,133 @@ class Debouncer:
case _:
yield event
return
# 新增片段缓存
self.buffers[index] = Buffer(
# 初始化片段缓冲
self.part_buffers[part_index] = PartBuffer(
part_type=part_type,
)
yield event
return
"""
原理
收到思考或文本片段增量事件时取消未完成的延迟刷新任务追加增量并重设若模型持续输出则先缓存停顿超过阈值再返回实现防抖
"""
# 若为思考或文本片段增量事件则先取消延迟刷新任务,更新片段缓冲中的片段内容增量并重新绑定延迟刷新任务,其它事件透传
if isinstance(event, PartDeltaEvent):
index, delta = event.index, event.delta
buffer = self.buffers.get(index)
if buffer and isinstance(delta, (ThinkingPartDelta, TextPartDelta)):
# 追加增量
buffer.content_delta += delta.content_delta or ""
# 取消上一轮未完成的延迟刷新任务
self._cancel_task(index=index)
# 创建延迟刷新任务
buffer.task = create_task(coro=self._delay_flush(index=index))
part_index, delta = event.index, event.delta
part_buffer = self.part_buffers.get(part_index)
if part_buffer and isinstance(delta, (ThinkingPartDelta, TextPartDelta)):
# 取消延迟刷新任务
self._cancel_task(part_index=part_index)
# 更新片段缓冲中的片段内容增量
part_buffer.part_content_delta += delta.content_delta or ""
# 重新绑定延迟刷新任务
part_buffer.flush_task = create_task(
coro=self._flush_after_delay(part_index=part_index)
)
else:
yield event
return
# 若为其它事件则先批量刷新片段增量事件再返回该事件
async for event_ in self._batch_flush():
yield event_
# 若为片段结束事件则删除该片段缓存
# 若为其它事件则先刷新所有片段缓冲再透传当前事件
async for flush_event in self._flush_all():
yield flush_event
# 若为片段结束事件则删除相应片段缓冲
if isinstance(event, PartEndEvent):
self.buffers.pop(event.index, None)
self.part_buffers.pop(event.index, None)
yield event
def _cancel_task(self, index: int) -> None:
def _cancel_task(self, part_index: int) -> None:
"""
取消延迟刷新异步协程任务
:param index: 片段索引
取消延迟刷新任务
:param part_index: 片段索引
:return: None
"""
buffer = self.buffers.get(index)
if not buffer or not buffer.task:
part_buffer = self.part_buffers.get(part_index)
if not part_buffer or not part_buffer.flush_task:
return
if not buffer.task.done():
buffer.task.cancel()
buffer.task = None
async def _delay_flush(self, index: int) -> None:
# 若延迟刷新任务未推送则取消
if not part_buffer.flush_task.done():
part_buffer.flush_task.cancel()
part_buffer.flush_task = None
async def _flush_after_delay(self, part_index: int) -> None:
"""
延迟刷新
:param index: 片段索引
:param part_index: 片段索引
:return: None
"""
await sleep(delay=self.delay)
await sleep(delay=self.flush_delay)
# 刷新片段增量事件
event = await self._flush(index=index)
if event:
await self.pending_flush_queue.put(item=event)
# 刷新单个片段缓冲
flush_event = await self._flush(part_index=part_index)
if flush_event:
await self.flush_queue.put(item=flush_event)
async def _flush(self, index: int) -> Optional[PartDeltaEvent]:
async def _flush(self, part_index: int) -> Optional[PartDeltaEvent]:
"""
刷新片段增量事件
:param index: 片段索引
刷新单个片段缓冲
:param part_index: 片段索引
:return: Optional[PartDeltaEvent]
"""
buffer = self.buffers.get(index)
if not buffer or not buffer.content_delta:
part_buffer = self.part_buffers.get(part_index)
if not part_buffer or not part_buffer.part_content_delta:
return
# 构建片段增量事件中增量部分
match buffer.part_type:
# 构建片段增量事件中增量
match part_buffer.part_type:
case "thinking":
delta = ThinkingPartDelta(content_delta=buffer.content_delta)
delta = ThinkingPartDelta(content_delta=part_buffer.part_content_delta)
case "text":
delta = TextPartDelta(content_delta=buffer.content_delta)
delta = TextPartDelta(content_delta=part_buffer.part_content_delta)
buffer.content_delta = ""
buffer.task = None
return PartDeltaEvent(index=index, delta=delta)
# 重置片段缓冲中片段内容增量
part_buffer.part_content_delta = ""
# 重置片段缓冲中延迟刷新任务
part_buffer.flush_task = None
return PartDeltaEvent(index=part_index, delta=delta)
async def _batch_flush(self) -> AsyncGenerator[PartDeltaEvent, None]:
async def _flush_all(self) -> AsyncGenerator[PartDeltaEvent]:
"""
批量刷新片段增量事件
刷新所有片段缓冲
:yield: AsyncGenerator
"""
for index in list(self.buffers.keys()):
event = await self._flush(index=index)
if event:
yield event
for part_index in list(self.part_buffers.keys()):
flush_event = await self._flush(part_index=part_index)
if flush_event:
yield flush_event
DEFAULT_INSTRUCTIONS: str = """
# 角色
专业友好AI助手结构化解答各类问题
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹
4. 重点内容标注 **粗体**/*斜体*
5. 思考工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染
# 行文要求
语言通俗逻辑完整简洁无多余废话
"""
class Agent:
"""
Pydantic AI 智能体
基于 Pydantic AI 封装的智能体支持
1流式输出模型响应事件
"""
def __init__(
self,
chat_id: str,
instructions: str,
instructions: Optional[str] = None,
output_type: OutputSpec = str,
capabilities: Optional[List[AgentCapability]] = None,
retries: int = 1,
):
"""
初始化智能体
初始化
:param chat_id: 聊天唯一标识
:param instructions: 指令
:param capabilities: 技能列表默认为不使用技能
@ -210,11 +234,16 @@ class Agent:
# 聊天唯一标识
self.chat_id = chat_id
# 本轮对话新增消息
# 一次聊天chat包含若干论对话dialog每一轮对话由输入消息message和输出消息message组成
# 一次聊天chat包含若干论对话dialog每一轮对话由用户提示词user_prompt和输出output组成两者统称为消息message
# 本轮对话新增消息列表
self.new_messages: List[ModelMessage] = []
# 实例智能体
# 若指令为空则使用默认指令
if not instructions:
instructions = DEFAULT_INSTRUCTIONS
# 初始化智能体
self.agent = PydanticAIAgent(
model=OpenAIChatModel(
model_name="deepseek-v4-flash",
@ -229,27 +258,27 @@ class Agent:
retries=retries,
)
async def stream_messages_events(
async def stream_events(
self,
user_prompt: str | List[str],
message_history: Optional[List[ModelMessage]] = None,
delay: float = 0.2,
flush_delay: float = 0.15,
) -> AsyncGenerator[str, None]:
"""
流式输出消息事件
:param user_prompt: 用户提示词用户输入消息
:param delay: 延迟时长单位为秒停顿超该时长则刷新
流式输出模型响应事件
:param user_prompt: 用户提示词用户提示词
:param flush_delay: 刷新延迟时长单位为秒
:yield: AsyncGenerator
"""
# 实例防抖器
debouncer = Debouncer(delay=delay)
# 初始化防抖器
debouncer = Debouncer(flush_delay=flush_delay)
async with self.agent.run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
) as events:
async for event in events:
# 处理模型消息事件
# 处理模型响应事件
async for event in debouncer.handle_event(event):
match event:
# 片段开始事件
@ -266,11 +295,11 @@ class Agent:
| ToolCallPart(tool_name=tool_name)
| LoadCapabilityCallPart(tool_name=tool_name)
):
yield f"02:正在使用技能:{tool_name}"
yield f"02:{tool_name}"
case NativeToolReturnPart(
content=content
) | ToolReturnPart(content=content):
yield f"04:技能返回:{content}"
yield f"04:{content}"
case _:
yield f"06:未知片段类型{type(part).__name__}"
# 片段增量事件
@ -281,9 +310,7 @@ class Agent:
case TextPartDelta(content_delta=content_delta):
yield f"01:{content_delta}"
case ToolCallPartDelta(args_delta=args_delta):
yield f"03:技能参数:{args_delta}"
case _:
yield f"06:未知片段类型{type(delta).__name__}"
yield f"03:{args_delta}"
# 片段结束事件、最终结果事件无需处理
case PartEndEvent():
continue
@ -291,6 +318,6 @@ class Agent:
continue
case AgentRunResultEvent(result=result):
self.new_messages = result.new_messages()
yield "05:FinalResultEvent"
yield "05:"
case _:
yield f"06:未知事件类型{type(event).__name__}"