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
def conversation_history_item_showing(
def conversation_history_item(
item: Tuple[str, Conversation],
) -> rx.Component:
"""
会话历史项展示
会话历史项
:param item: 会话历史项
:return: Component
"""
@ -25,7 +25,8 @@ def conversation_history_item_showing(
# 高亮:若为当前会话或显示更多则高亮
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)
return rx.list.item(
@ -172,12 +173,12 @@ def conversation_history_item_showing(
)
def conversation_history_items_showing(
is_conversation_history_shown: bool,
def conversation_history(
is_shown: bool,
) -> rx.Component:
"""
会话历史列表展示
:param is_conversation_history_shown: 会话历史展示状态
会话历史
:param is_shown: 展示 / 隐藏
:return: Component
"""
return rx.box(
@ -195,8 +196,8 @@ def conversation_history_items_showing(
),
rx.auto_scroll(
rx.foreach(
ConversationState.conversations,
conversation_history_item_showing,
ConversationState.conversation_history,
conversation_history_item,
),
flex="1",
align_items="stretch",
@ -217,9 +218,9 @@ def conversation_history_items_showing(
backdrop_filter="blur(50px)",
gap="12px",
),
width=rx.cond(is_conversation_history_shown, "25%", "0px"),
min_width=rx.cond(is_conversation_history_shown, "240px", "0px"),
max_width=rx.cond(is_conversation_history_shown, "380px", "0px"),
width=rx.cond(is_shown, "25%", "0px"),
min_width=rx.cond(is_shown, "240px", "0px"),
max_width=rx.cond(is_shown, "380px", "0px"),
height="100%",
transition="all 0.18s ease-in-out",
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(
rx.hstack(
# 会话历史列表展示
conversation_history_items_showing(is_conversation_history_shown),
# 会话历史
conversation_history(is_shown),
# 工作区
rx.box(
# 对话历史
rx.vstack(
# 对话列表展示
dialog_items_showing(),
dialog_history(),
width="100%",
height="100%",
gap="8px",
@ -903,7 +905,7 @@ def conversation() -> rx.Component:
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,
position="relative",

View File

@ -28,10 +28,10 @@ from application.states.models import (
Message,
MessageType,
Work,
WorkType,
ConversationHistoryItem,
usage_to_dict,
usage_validate_python,
)
from application.tasks import run_stream_events
def format_conversation_created_at(created_at: datetime) -> str:
@ -69,9 +69,6 @@ class ConversationState(rx.State):
# 会话历史项显示悬停气泡True 表示显示False 表示隐藏
is_conversation_history_item_popover_shown: bool = False
# 当前工作实例
work: Work | None = None
# 当前数据库状态(私有变量)
# 私有变量reflex 约定以 _ 开头的变量为私有变量,后端不序列化,前端不可使用
_db_state: DatabaseState | None = None
@ -117,7 +114,7 @@ class ConversationState(rx.State):
self.is_conversation_history_shown = not self.is_conversation_history_shown
@rx.var
def conversation_history_items(self) -> list[ConversationHistoryItem]:
def conversation_history(self) -> list[ConversationHistoryItem]:
"""
获取会话历史项列表按照会话创建日期时间降序排序
:return: 会话历史项列表
@ -240,182 +237,220 @@ class ConversationState(rx.State):
conversation.is_running = True
# 构建用户提示词消息实例
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 = ""
# 添加至消息字典
user_prompt = conversation.messages.setdefault(message.id, message)
yield # 通知前端更新渲染
# 获取数据库状态
db_state = await self.get_db_state()
# 初始化片段索引映射为消息实例唯一标识字典
index_map_to_message_id: dict[int, str] = {}
# 获取运行流式输出事件
async for event in run_stream_events(
work=self.work,
user_prompt=user_prompt,
message_history=await db_state.get_message_history(
# 初始化工具调用唯一标识集合
tool_call_ids: set[str] = set()
try:
# 获取数据库状态
db_state = await self.get_db_state()
# 获取消息历史列表
message_history = await db_state.get_message_history(
conversation_id=self.actived_conversation_id
),
):
match event:
# ========== 开始事件 ==========
case PartStartEvent(
index=index,
part=part,
previous_part_kind=previous_part_kind,
):
match part:
# 思考分片开始事件
case ThinkingPart(content=content):
message = Message(type=MessageType.THINKING)
# 将消息实例唯一标识与片段索引映射
message_id_map_to_index[index] = message.id
# 若上一分片种类为空则将正在思考设置为是
if not previous_part_kind:
)
usage = usage_validate_python(conversation.usage)
# 匹配工作类型
match conversation.work:
# 预定航班
case WorkType.BOOK_FLIGHT:
from application.workshop.book_flight import run_stream_events
stream_events = run_stream_events(
work=conversation.work,
user_prompt=user_prompt,
message_history=message_history,
usage=usage,
)
# 非结构化对话
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.content = content
message = Message(
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):
# 创建工具调用唯一标识与片段索引映射
tool_call_ids[tool_call_id] = index
# 工具调用分片开始事件
case ToolCallPart(
tool_name=tool_name, tool_call_id=tool_call_id
):
# 构建消息实例
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",
content="正在生成检索关键词",
)
# 文本分片开始事件
case TextPart(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):
tool_call_ids[tool_call_id] = index
# ========== 增量事件 ==========
case PartDeltaEvent(index=index, delta=delta):
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",
content="正在生成加载参数",
)
# 文本分片增量事件
case TextPartDelta(
content_delta=content_delta,
):
# 增量更新
conversation.messages[
index_map_to_message_id[index]
].content += content_delta
# 工具调用分片开始事件
case ToolCallPart(tool_call_id=tool_call_id):
tool_call_ids[tool_call_id] = index
# ========== 结束事件 ==========
case PartEndEvent(
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",
content="正在生成调用参数",
)
# ========== 函数工具调用事件 ==========
case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part):
# 获取分片索引
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):
dialog.result_output = content
# 能力加载
case "capability_load":
dialog.thoughts[index].content = (
f"正在加载能力 {part.tool_name}"
)
# ========== 增量事件 ==========
case PartDeltaEvent(index=index, delta=delta):
match delta:
# 思考分片增量事件
case ThinkingPartDelta(
content_delta=content_delta,
):
dialog.thoughts[index].content += content_delta or ""
# 工具调用
case "tool_call":
dialog.thoughts[index].content = (
f"正在调用工具 {part.tool_name}"
)
# 文本分片增量事件
case TextPartDelta(
content_delta=content_delta,
):
dialog.result_output += content_delta
# ========== 函数工具结果事件 ==========
case FunctionToolResultEvent(
tool_call_id=tool_call_id,
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 ""
) # 暂仅考虑文本内容
# ========== 结束事件 ==========
case PartEndEvent(
index=index,
part=part,
next_part_kind=next_part_kind,
):
match part:
# 思考分片结束事件
case ThinkingPart(content=content):
# 若下一分片种类为文本则将思考状态设置为思考完成
if next_part_kind == "text":
dialog.is_thinking = False
# 能力加载
case "capability_load":
dialog.thoughts[index].content = f"已加载 {content}"
# ========== 函数工具调用事件 ==========
case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part):
# 获取分片索引
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 "tool_call":
dialog.thoughts[index].content = f"已调用 {content}"
# 能力加载
case "capability_load":
dialog.thoughts[index].content = (
f"正在加载能力 {part.tool_name}"
)
case TaskNodeResultEvent(task=task, content=content):
# 更新任务
conversation.task = task
dialog.result_output += content
# 工具调用
case "tool_call":
dialog.thoughts[index].content = (
f"正在调用工具 {part.tool_name}"
)
# ========== 智能体运行结果事件 ==========
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,
_id=dialog.id,
new_messages=result.new_messages(),
)
# ========== 函数工具结果事件 ==========
case FunctionToolResultEvent(
tool_call_id=tool_call_id,
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 ""
) # 暂仅考虑文本内容
# 强制更新会话并推送前端
self.conversations[self.actived_conversation_id] = conversation
yield
# 能力加载
case "capability_load":
dialog.thoughts[index].content = f"已加载 {content}"
except Exception as e:
...
finally:
# 将正在运行设置为否
conversation.is_running = False
# 工具调用
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
self.conversations[self.actived_conversation_id] = conversation
yield
# 将正在运行设置为否
conversation.is_running = False
self.conversations[self.conversation_id] = conversation
yield
@rx.event
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} 的航班"
@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]
dialog.is_expanded = not dialog.is_expanded
# 当前会话
conversation = self.conversations.get(self.actived_conversation_id)
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 (
Conversation,
TaskType,
TaskStatus,
RunStatus,
MessageType,
Task,
Run,
Message,
deps_to_object,
@ -109,7 +107,7 @@ class MessageRecord(SQLModel, table=True, table_name="message"):
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():
message_history.extend(
ModelMessagesTypeAdapter.validate_python(
ModelMessagesTypeAdapter.validate_json(
run.new_messages
) # 将 run.new_messages 由 Python 类型反序列化为 List[ModelMessage]
) # 将 run.new_messages 由 JSON 字符串反序列化为 List[ModelMessage]
)
return message_history
@ -357,9 +355,9 @@ class DatabaseState(rx.State):
RunRecord(
id=id,
conversation_id=conversation_id,
new_messages=ModelMessagesTypeAdapter.dump_python(
new_messages=ModelMessagesTypeAdapter.dump_json(
new_messages
), # 将 messages 由 List[ModelMessage] 序列化为 Python 类型
).decode(), # 将 messages 由 List[ModelMessage] 序列化为 JSON 字符串
)
)
await session.commit()

View File

@ -19,7 +19,9 @@ class MessageType(StrEnum):
USER_PROMPT = "user_prompt"
THINKING = "thinking"
AGENT_RUN_RESULT_OUTPUT = "agent_run_result_output"
TOOL_CALL = "tool_call"
RESULT_OUTPUT = "result_output"
class Message(BaseModel):
@ -31,8 +33,8 @@ class Message(BaseModel):
type: MessageType = Field(..., description="消息类型")
title: str = Field(default="", description="消息标题")
content: str = Field(default="", description="消息内容")
is_thinking: bool = Field(
default=False, description="正在思考True 表示正在思考False 表示思考完成"
is_running: bool = Field(
default=False, description="正在运行True 表示正在运行False 表示运行完成"
)
is_expanded: bool = Field(
default=False, description="展开组件True 表示展开False 表示折叠"
@ -109,11 +111,11 @@ class Conversation(BaseModel):
id: str = Field(..., description="会话唯一标识")
description: str = Field(..., description="会话描述")
work: Work | None = Field(default=None, description="绑定工作实例")
user_prompt: str = Field(default="", description="用户提示词")
usage: dict[str, Any] = Field(..., description="会话使用量")
messages: dict[str, Message] = Field(..., description="消息字典")
created_at: datetime = Field(..., description="会话创建日期时间")
is_running: bool = Field(
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 -*-
"""
预定航班任务
预定航班
"""
import datetime
from typing import AsyncGenerator, Literal
@ -181,9 +181,11 @@ def init_task() -> Task:
async def run_stream_events(
task: Task | None,
usage: RunUsage,
user_prompt: str,
message_history: list[ModelMessage],
work: WorkType | None = None,
) -> AsyncGenerator:
result = None
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