This commit is contained in:
liubiren 2026-08-10 17:41:44 +08:00
parent 3dde59d8be
commit 8c8fda3da6
9 changed files with 289 additions and 256 deletions

View File

@ -13,11 +13,11 @@ from application.states.models import (
from application.states import ConversationState, AuthState from application.states import ConversationState, AuthState
def conversation_history_item_showing( def conversation_history_item(
item: Tuple[str, Conversation], item: Tuple[str, Conversation],
) -> rx.Component: ) -> rx.Component:
""" """
会话历史项展示 会话历史项
:param item: 会话历史项 :param item: 会话历史项
:return: Component :return: Component
""" """
@ -25,7 +25,8 @@ def conversation_history_item_showing(
# 高亮:若为当前会话或显示更多则高亮 # 高亮:若为当前会话或显示更多则高亮
highlight: bool = ( highlight: bool = (
conversation.id == ConversationState.is_conversation_history_item_more_button_shown conversation.id
== ConversationState.is_conversation_history_item_more_button_shown
) | (conversation.id == ConversationState.conversation_id) ) | (conversation.id == ConversationState.conversation_id)
return rx.list.item( return rx.list.item(
@ -172,12 +173,12 @@ def conversation_history_item_showing(
) )
def conversation_history_items_showing( def conversation_history(
is_conversation_history_shown: bool, is_shown: bool,
) -> rx.Component: ) -> rx.Component:
""" """
会话历史列表展示 会话历史
:param is_conversation_history_shown: 会话历史展示状态 :param is_shown: 展示 / 隐藏
:return: Component :return: Component
""" """
return rx.box( return rx.box(
@ -195,8 +196,8 @@ def conversation_history_items_showing(
), ),
rx.auto_scroll( rx.auto_scroll(
rx.foreach( rx.foreach(
ConversationState.conversations, ConversationState.conversation_history,
conversation_history_item_showing, conversation_history_item,
), ),
flex="1", flex="1",
align_items="stretch", align_items="stretch",
@ -217,9 +218,9 @@ def conversation_history_items_showing(
backdrop_filter="blur(50px)", backdrop_filter="blur(50px)",
gap="12px", gap="12px",
), ),
width=rx.cond(is_conversation_history_shown, "25%", "0px"), width=rx.cond(is_shown, "25%", "0px"),
min_width=rx.cond(is_conversation_history_shown, "240px", "0px"), min_width=rx.cond(is_shown, "240px", "0px"),
max_width=rx.cond(is_conversation_history_shown, "380px", "0px"), max_width=rx.cond(is_shown, "380px", "0px"),
height="100%", height="100%",
transition="all 0.18s ease-in-out", transition="all 0.18s ease-in-out",
overflow="hidden", overflow="hidden",
@ -864,21 +865,22 @@ def conversation_history_collapse_button(
) )
def conversation() -> rx.Component: def conversation_page() -> rx.Component:
""" """
会话页面布局参考 MetaChat左侧为折叠面板右侧为工作区其中工作区包含展示区和操作区若对话历史为空则展示欢迎文案否则展示对话历史 会话页面布局参考 MetaChat从左到右分别为会话历史和工作区其中工作区从上到下分别为对话历史和用户提示词输入框
""" """
# 会话历史展示状态 # 会话历史展示状态
is_conversation_history_shown = ConversationState.is_conversation_history_shown is_shown = ConversationState.is_conversation_history_shown
return rx.box( return rx.box(
rx.hstack( rx.hstack(
# 会话历史列表展示 # 会话历史
conversation_history_items_showing(is_conversation_history_shown), conversation_history(is_shown),
# 工作区
rx.box( rx.box(
# 对话历史
rx.vstack( rx.vstack(
# 对话列表展示 dialog_history(),
dialog_items_showing(),
width="100%", width="100%",
height="100%", height="100%",
gap="8px", gap="8px",
@ -903,7 +905,7 @@ def conversation() -> rx.Component:
transition="all 0.18s ease-in-out", transition="all 0.18s ease-in-out",
), ),
# 会话历史折叠面板按钮 # 会话历史折叠面板按钮
conversation_history_collapse_button(is_conversation_history_shown), conversation_history_collapse_button(is_shown),
# 挂载事件:恢复会话状态 # 挂载事件:恢复会话状态
on_mount=AuthState.resume_conversation_state, on_mount=AuthState.resume_conversation_state,
position="relative", position="relative",

View File

@ -28,10 +28,10 @@ from application.states.models import (
Message, Message,
MessageType, MessageType,
Work, Work,
WorkType,
ConversationHistoryItem, ConversationHistoryItem,
usage_to_dict, usage_validate_python,
) )
from application.tasks import run_stream_events
def format_conversation_created_at(created_at: datetime) -> str: def format_conversation_created_at(created_at: datetime) -> str:
@ -69,9 +69,6 @@ class ConversationState(rx.State):
# 会话历史项显示悬停气泡True 表示显示False 表示隐藏 # 会话历史项显示悬停气泡True 表示显示False 表示隐藏
is_conversation_history_item_popover_shown: bool = False is_conversation_history_item_popover_shown: bool = False
# 当前工作实例
work: Work | None = None
# 当前数据库状态(私有变量) # 当前数据库状态(私有变量)
# 私有变量reflex 约定以 _ 开头的变量为私有变量,后端不序列化,前端不可使用 # 私有变量reflex 约定以 _ 开头的变量为私有变量,后端不序列化,前端不可使用
_db_state: DatabaseState | None = None _db_state: DatabaseState | None = None
@ -117,7 +114,7 @@ class ConversationState(rx.State):
self.is_conversation_history_shown = not self.is_conversation_history_shown self.is_conversation_history_shown = not self.is_conversation_history_shown
@rx.var @rx.var
def conversation_history_items(self) -> list[ConversationHistoryItem]: def conversation_history(self) -> list[ConversationHistoryItem]:
""" """
获取会话历史项列表按照会话创建日期时间降序排序 获取会话历史项列表按照会话创建日期时间降序排序
:return: 会话历史项列表 :return: 会话历史项列表
@ -240,182 +237,220 @@ class ConversationState(rx.State):
conversation.is_running = True conversation.is_running = True
# 构建用户提示词消息实例 # 构建用户提示词消息实例
message = Message( message = Message(
type=MessageType.USER_PROMPT, content=conversation.user_prompt type=MessageType.USER_PROMPT,
content=(user_prompt := conversation.user_prompt),
) )
# 添加至消息字典
conversation.messages[message.id] = message
# 清空用户提示词 # 清空用户提示词
conversation.user_prompt = "" conversation.user_prompt = ""
# 添加至消息字典
user_prompt = conversation.messages.setdefault(message.id, message)
yield # 通知前端更新渲染 yield # 通知前端更新渲染
# 获取数据库状态
db_state = await self.get_db_state()
# 初始化片段索引映射为消息实例唯一标识字典 # 初始化片段索引映射为消息实例唯一标识字典
index_map_to_message_id: dict[int, str] = {} index_map_to_message_id: dict[int, str] = {}
# 获取运行流式输出事件 # 初始化工具调用唯一标识集合
async for event in run_stream_events( tool_call_ids: set[str] = set()
work=self.work, try:
user_prompt=user_prompt, # 获取数据库状态
message_history=await db_state.get_message_history( db_state = await self.get_db_state()
# 获取消息历史列表
message_history = await db_state.get_message_history(
conversation_id=self.actived_conversation_id conversation_id=self.actived_conversation_id
), )
): usage = usage_validate_python(conversation.usage)
match event: # 匹配工作类型
# ========== 开始事件 ========== match conversation.work:
case PartStartEvent( # 预定航班
index=index, case WorkType.BOOK_FLIGHT:
part=part, from application.workshop.book_flight import run_stream_events
previous_part_kind=previous_part_kind,
): stream_events = run_stream_events(
match part: work=conversation.work,
# 思考分片开始事件 user_prompt=user_prompt,
case ThinkingPart(content=content): message_history=message_history,
message = Message(type=MessageType.THINKING) usage=usage,
# 将消息实例唯一标识与片段索引映射 )
message_id_map_to_index[index] = message.id
# 若上一分片种类为空则将正在思考设置为是 # 非结构化对话
if not previous_part_kind: case _:
from application.workshop.unstructured_dialogue import (
run_stream_events,
)
stream_events = run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
usage=usage,
)
# 获取运行流式输出事件
async for event in stream_events:
match event:
# ========== 开始事件 ==========
case PartStartEvent(
index=index,
part=part,
):
match part:
# 思考分片开始事件
case ThinkingPart(content=content):
# 构建消息实例 # 构建消息实例
message.is_thinking = True message = Message(
message.content = content type=MessageType.THINKING,
title="正在思考",
content=content,
is_running=True,
)
# 添加至消息字典
conversation.messages[message.id] = message
# 将消息实例唯一标识与片段索引映射
index_map_to_message_id[index] = message.id
# 工具检索分片开始事件 # 工具调用分片开始事件
case ToolSearchCallPart(tool_call_id=tool_call_id): case ToolCallPart(
# 创建工具调用唯一标识与片段索引映射 tool_name=tool_name, tool_call_id=tool_call_id
tool_call_ids[tool_call_id] = index ):
# 构建消息实例
message = Message(
type=MessageType.TOOL_CALL,
title=tool_name,
content=",",
is_running=True,
)
tool_call_ids.add(tool_call_id)
# 添加至消息字典
dialog.thoughts[index] = Thought(
type="tool_call",
content="正在生成调用参数",
)
dialog.thoughts[index] = Thought( # 文本分片开始事件
type="tool_search", case TextPart(content=content):
content="正在生成检索关键词", # 构建消息实例
) message = Message(type=MessageType.RESULT_OUTPUT)
message.content = content
# 添加至消息字典
conversation.messages[message.id] = message
# 将消息实例唯一标识与片段索引映射
index_map_to_message_id[index] = message.id
# 能力加载分片开始事件 # ========== 增量事件 ==========
case LoadCapabilityCallPart(tool_call_id=tool_call_id): case PartDeltaEvent(index=index, delta=delta):
tool_call_ids[tool_call_id] = index match delta:
# 思考分片增量事件
case ThinkingPartDelta(
content_delta=content_delta,
):
# 若内容增量不为空则增量更新
if content_delta:
conversation.messages[
index_map_to_message_id[index]
].content += content_delta
dialog.thoughts[index] = Thought( # 文本分片增量事件
type="capability_load", case TextPartDelta(
content="正在生成加载参数", content_delta=content_delta,
) ):
# 增量更新
conversation.messages[
index_map_to_message_id[index]
].content += content_delta
# 工具调用分片开始事件 # ========== 结束事件 ==========
case ToolCallPart(tool_call_id=tool_call_id): case PartEndEvent(
tool_call_ids[tool_call_id] = index index=index,
part=part,
):
match part:
# 思考分片结束事件
case ThinkingPart(content=content):
# 获取消息实例
message = conversation.messages[
index_map_to_message_id[index]
]
message.is_running = False
message.title = "思考完成"
dialog.thoughts[index] = Thought( # ========== 函数工具调用事件 ==========
type="tool_call", case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part):
content="正在生成调用参数", # 获取分片索引
) index = tool_call_ids[tool_call_id]
match dialog.thoughts[index].type:
# 工具检索
case "tool_search":
dialog.thoughts[index].content = (
f"正在检索 {part.args_as_json_str()}"
)
# 文本分片开始事件 # 能力加载
case TextPart(content=content): case "capability_load":
dialog.result_output = content dialog.thoughts[index].content = (
f"正在加载能力 {part.tool_name}"
)
# ========== 增量事件 ========== # 工具调用
case PartDeltaEvent(index=index, delta=delta): case "tool_call":
match delta: dialog.thoughts[index].content = (
# 思考分片增量事件 f"正在调用工具 {part.tool_name}"
case ThinkingPartDelta( )
content_delta=content_delta,
):
dialog.thoughts[index].content += content_delta or ""
# 文本分片增量事件 # ========== 函数工具结果事件 ==========
case TextPartDelta( case FunctionToolResultEvent(
content_delta=content_delta, tool_call_id=tool_call_id,
): content=content,
dialog.result_output += content_delta ):
index = tool_call_ids[tool_call_id]
match dialog.thoughts[index].type:
# 工具检索
case "tool_search":
dialog.thoughts[index].content = (
content if isinstance(content, str) else ""
) # 暂仅考虑文本内容
# ========== 结束事件 ========== # 能力加载
case PartEndEvent( case "capability_load":
index=index, dialog.thoughts[index].content = f"已加载 {content}"
part=part,
next_part_kind=next_part_kind,
):
match part:
# 思考分片结束事件
case ThinkingPart(content=content):
# 若下一分片种类为文本则将思考状态设置为思考完成
if next_part_kind == "text":
dialog.is_thinking = False
# ========== 函数工具调用事件 ========== # 工具调用
case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part): case "tool_call":
# 获取分片索引 dialog.thoughts[index].content = f"已调用 {content}"
index = tool_call_ids[tool_call_id]
match dialog.thoughts[index].type:
# 工具检索
case "tool_search":
dialog.thoughts[index].content = (
f"正在检索 {part.args_as_json_str()}"
)
# 能力加载 case TaskNodeResultEvent(task=task, content=content):
case "capability_load": # 更新任务
dialog.thoughts[index].content = ( conversation.task = task
f"正在加载能力 {part.tool_name}" dialog.result_output += content
)
# 工具调用 # ========== 智能体运行结果事件 ==========
case "tool_call": case AgentRunResultEvent(result=result):
dialog.thoughts[index].content = ( # 创建对话记录
f"正在调用工具 {part.tool_name}" await db_state.create_dialog_record(
) conversation_id=self.conversation_id,
id=dialog.id,
user_prompt=dialog.user_prompt,
thoughts=thoughts_to_dict(dialog.thoughts),
result_output=dialog.result_output,
usage=usage_to_dict(result.usage),
)
# 创建结果记录
await db_state.create_result_record(
conversation_id=self.conversation_id,
_id=dialog.id,
new_messages=result.new_messages(),
)
# ========== 函数工具结果事件 ========== # 强制更新会话并推送前端
case FunctionToolResultEvent( self.conversations[self.actived_conversation_id] = conversation
tool_call_id=tool_call_id, yield
content=content,
):
index = tool_call_ids[tool_call_id]
match dialog.thoughts[index].type:
# 工具检索
case "tool_search":
dialog.thoughts[index].content = (
content if isinstance(content, str) else ""
) # 暂仅考虑文本内容
# 能力加载 except Exception as e:
case "capability_load": ...
dialog.thoughts[index].content = f"已加载 {content}" finally:
# 将正在运行设置为否
conversation.is_running = False
# 工具调用 self.conversations[self.actived_conversation_id] = conversation
case "tool_call":
dialog.thoughts[index].content = f"已调用 {content}"
case TaskNodeResultEvent(task=task, content=content):
# 更新任务
conversation.task = task
dialog.result_output += content
# ========== 智能体运行结果事件 ==========
case AgentRunResultEvent(result=result):
# 创建对话记录
await db_state.create_dialog_record(
conversation_id=self.conversation_id,
id=dialog.id,
user_prompt=dialog.user_prompt,
thoughts=thoughts_to_dict(dialog.thoughts),
result_output=dialog.result_output,
usage=usage_to_dict(result.usage),
)
# 创建结果记录
await db_state.create_result_record(
conversation_id=self.conversation_id,
dialog_id=dialog.id,
new_messages=result.new_messages(),
)
# 强制更新会话并推送前端
self.conversations[self.conversation_id] = conversation
yield yield
# 将正在运行设置为否
conversation.is_running = False
self.conversations[self.conversation_id] = conversation
yield
@rx.event @rx.event
async def generate_prd(self) -> None: async def generate_prd(self) -> None:
""" """
@ -430,10 +465,12 @@ class ConversationState(rx.State):
self.user_prompt = f"帮我找一班从 {conversation.task.deps.origin}{conversation.task.deps.destination}{conversation.task.deps.date} 的航班" self.user_prompt = f"帮我找一班从 {conversation.task.deps.origin}{conversation.task.deps.destination}{conversation.task.deps.date} 的航班"
@rx.event @rx.event
def toggle_collapse(self, dialog_id: str) -> None: def toggle_message_collapse(self, message_id: str) -> None:
""" """
展开/折叠思考折叠面板 展开/折叠消息组件
""" """
# 指定运行 # 当前会话
dialog = self.conversations[self.conversation_id].dialogs[dialog_id] conversation = self.conversations.get(self.actived_conversation_id)
dialog.is_expanded = not dialog.is_expanded if not conversation:
return
conversation.messages[message_id].is_expanded ^= True

View File

@ -14,11 +14,9 @@ from sqlmodel import Field, JSON, SQLModel, select, update
from application.states.models import ( from application.states.models import (
Conversation, Conversation,
TaskType,
TaskStatus, TaskStatus,
RunStatus, RunStatus,
MessageType, MessageType,
Task,
Run, Run,
Message, Message,
deps_to_object, deps_to_object,
@ -109,7 +107,7 @@ class MessageRecord(SQLModel, table=True, table_name="message"):
content: str = Field(default="", description="消息内容") content: str = Field(default="", description="消息内容")
class RunRecord(SQLModel, table=True, table_name="run_result"): class RunRecord(SQLModel, table=True, table_name="run"):
""" """
运行记录 运行记录
""" """
@ -333,9 +331,9 @@ class DatabaseState(rx.State):
) )
for run in result.all(): for run in result.all():
message_history.extend( message_history.extend(
ModelMessagesTypeAdapter.validate_python( ModelMessagesTypeAdapter.validate_json(
run.new_messages run.new_messages
) # 将 run.new_messages 由 Python 类型反序列化为 List[ModelMessage] ) # 将 run.new_messages 由 JSON 字符串反序列化为 List[ModelMessage]
) )
return message_history return message_history
@ -357,9 +355,9 @@ class DatabaseState(rx.State):
RunRecord( RunRecord(
id=id, id=id,
conversation_id=conversation_id, conversation_id=conversation_id,
new_messages=ModelMessagesTypeAdapter.dump_python( new_messages=ModelMessagesTypeAdapter.dump_json(
new_messages new_messages
), # 将 messages 由 List[ModelMessage] 序列化为 Python 类型 ).decode(), # 将 messages 由 List[ModelMessage] 序列化为 JSON 字符串
) )
) )
await session.commit() await session.commit()

View File

@ -19,7 +19,9 @@ class MessageType(StrEnum):
USER_PROMPT = "user_prompt" USER_PROMPT = "user_prompt"
THINKING = "thinking" THINKING = "thinking"
AGENT_RUN_RESULT_OUTPUT = "agent_run_result_output" TOOL_CALL = "tool_call"
RESULT_OUTPUT = "result_output"
class Message(BaseModel): class Message(BaseModel):
@ -31,8 +33,8 @@ class Message(BaseModel):
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="消息内容")
is_thinking: bool = Field( is_running: bool = Field(
default=False, description="正在思考True 表示正在思考False 表示思考完成" default=False, description="正在运行True 表示正在运行False 表示运行完成"
) )
is_expanded: bool = Field( is_expanded: bool = Field(
default=False, description="展开组件True 表示展开False 表示折叠" default=False, description="展开组件True 表示展开False 表示折叠"
@ -109,11 +111,11 @@ class Conversation(BaseModel):
id: str = Field(..., description="会话唯一标识") id: str = Field(..., description="会话唯一标识")
description: str = Field(..., description="会话描述") description: str = Field(..., description="会话描述")
work: Work | None = Field(default=None, description="绑定工作实例")
user_prompt: str = Field(default="", description="用户提示词") user_prompt: str = Field(default="", description="用户提示词")
usage: dict[str, Any] = Field(..., description="会话使用量") usage: dict[str, Any] = Field(..., description="会话使用量")
messages: dict[str, Message] = Field(..., description="消息字典") messages: dict[str, Message] = Field(..., description="消息字典")
created_at: datetime = Field(..., description="会话创建日期时间") created_at: datetime = Field(..., description="会话创建日期时间")
is_running: bool = Field( is_running: bool = Field(
default=False, description="正在运行True 表示正在运行, False 表示运行结束" default=False, description="正在运行True 表示正在运行, False 表示运行结束"
) )

View File

@ -1,63 +0,0 @@
# -*- coding: utf-8 -*-
"""
任务模块
"""
from typing import AsyncGenerator, List
from pydantic_ai import Agent, ModelMessage
from pydantic_ai.messages import AgentStreamEvent
from pydantic_ai.run import AgentRunResultEvent
from application.states.models import TaskNodeResultEvent, TaskType, Task, Message
from application.tasks.models import DEEPSEEK_V4_FLASH_MODEL
instruction = """
# 角色
专业友好AI助手结构化解答各类问题
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹
4. 重点内容标注 **粗体**/*斜体*
5. 思考工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染
# 行文要求
语言通俗逻辑完整简洁无多余废话
"""
async def run_stream_events(
work: Work | None,
user_prompt: Message,
message_history: List[ModelMessage],
) -> AsyncGenerator[AgentStreamEvent | AgentRunResultEvent | TaskNodeResultEvent, None]:
"""
以流式事件模式运行
"""
if task:
match task.type:
case TaskType.BOOK_FLIGHT:
from application.tasks.book_flight import run_stream_events
async for event in run_stream_events(
task=task,
user_prompt=user_prompt,
message_history=message_history,
):
print(event)
yield event
else:
agent = Agent(
model=DEEPSEEK_V4_FLASH_MODEL,
instructions=instruction,
)
async with agent.run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
) as events:
async for event in events:
yield event

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
预定航班任务 预定航班
""" """
import datetime import datetime
from typing import AsyncGenerator, Literal from typing import AsyncGenerator, Literal
@ -181,9 +181,11 @@ def init_task() -> Task:
async def run_stream_events( async def run_stream_events(
task: Task | None, usage: RunUsage,
user_prompt: str, user_prompt: str,
message_history: list[ModelMessage], message_history: list[ModelMessage],
work: WorkType | None = None,
) -> AsyncGenerator: ) -> AsyncGenerator:
result = None result = None
while True: while True:

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
非结构化对话
"""
from typing import AsyncGenerator
from pydantic_ai import Agent, ModelMessage, RunUsage
from pydantic_ai.messages import AgentStreamEvent
from pydantic_ai.run import AgentRunResultEvent
from application.workshop.models import DEEPSEEK_V4_FLASH_MODEL
instruction = """
# 角色
专业友好AI助手结构化解答各类问题
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹
4. 重点内容标注 **粗体**/*斜体*
5. 思考工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染
# 行文要求
语言通俗逻辑完整简洁无多余废话
"""
agent = Agent(
model=DEEPSEEK_V4_FLASH_MODEL,
instructions=instruction,
)
async def run_stream_events(
user_prompt: str,
message_history: list[ModelMessage],
usage: RunUsage,
) -> AsyncGenerator[AgentStreamEvent | AgentRunResultEvent, None]:
"""
运行并流式输出事件
:param user_prompt: 用户提示词
:param message_history: 消息历史
:param usage: 使用量
:return: AsyncGenerator
"""
async with agent.run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
usage=usage,
) as events:
async for event in events:
yield event