Python/agent/application/states/conversation.py

458 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
会话状态
"""
from datetime import datetime
from typing import Any, AsyncGenerator, Dict, List
from pydantic_ai import Agent, ThinkingPartDelta
from pydantic_ai.messages import (
FunctionToolCallEvent,
FunctionToolResultEvent,
LoadCapabilityCallPart,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
TextPart,
TextPartDelta,
ThinkingPart,
ToolCallPart,
ToolSearchCallPart,
)
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.run import AgentRunResultEvent
import reflex as rx
from application.states.database import DatabaseState
from application.domain_models import (
Conversation,
Dialog,
Thought,
ConversationHistoryItem,
)
instructions: str = """
# 角色
专业友好AI助手结构化解答各类问题。
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹;
4. 重点内容标注 **粗体**/*斜体*
5. 思考、工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染。
# 行文要求
语言通俗,逻辑完整简洁,无多余废话。
"""
# 实例化智能体(因无法序列化故剥离出状态管理)
agent: Agent = Agent(
model=OpenAIChatModel(
model_name="deepseek-v4-flash",
provider=OpenAIProvider(
base_url="https://tokenhub.tencentmaas.com/v1",
api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq",
),
),
instructions=instructions,
capabilities=None,
output_type=str,
retries=1,
)
class ConversationState(rx.State):
"""
会话状态
"""
# 当前用户唯一标识
user_id: str = ""
# 键为会话唯一标识,值为会话实例的会话字典
conversations: Dict[str, Conversation] = {} # 按照会话唯一标识顺序排序
# 当前会话唯一标识
conversation_id: str = ""
# 会话历史展示状态True表示展示False表示隐藏
is_conversation_history_shown: bool = False
# 显示更多的会话唯一标识
shown_more_conversation_id: str = ""
# 会话创建状态True表示正在创建False表示未正在创建
is_conversation_creating: bool = False
# 用户提示词
user_prompt: str = ""
async def resume(self, user_id: str) -> None:
"""
恢复当前用户会话状态
:param user_id: 用户唯一标识
:return: None
"""
self.user_id = user_id
if not self.user_id:
return
database_state = await self.get_state(DatabaseState)
# 获取会话字典
self.conversations = await database_state.get_conversations(
user_id=self.user_id
)
# 若会话字典为空则先创建会话记录再在会话字典中添加会话实例
if not self.conversations:
self.conversations.update(
await database_state.create_conversations_record(user_id=self.user_id)
)
# 将最后一个会话作为当前会话并更新会话唯一标识
self.conversation_id = next(reversed(self.conversations))
@rx.event
def toggle_conversation_history_shown(self) -> None:
"""
切换会话历史展示状态,用于前端是否渲染会话历史
:return: None
"""
self.is_conversation_history_shown = not self.is_conversation_history_shown
@rx.var
def conversation_history_items(self) -> List[ConversationHistoryItem]:
"""
获取会话历史项列表,用于前端渲染会话历史
:return: 会话历史项列表
"""
items: List[ConversationHistoryItem] = []
for conversation in reversed(
self.conversations.values()
): # 按照会话唯一标识倒序排序
# 格式化会话创建时间
match (datetime.now().date() - conversation.created_at.date()).days:
case 0:
created_at = f"{conversation.created_at.strftime('%H:%M')}"
case 1:
created_at = f"昨天 {conversation.created_at.strftime('%H:%M')}"
case _:
created_at = conversation.created_at.strftime("%Y-%m-%d %H:%M")
items.append(
ConversationHistoryItem(
id=conversation.id,
description=conversation.description,
created_at=created_at,
)
)
return items
@rx.event
def set_shown_more_conversation_id(
self, conversation_id: str, is_shown: bool
) -> None:
"""
设置显示更多的会话唯一标识
:return: None
"""
self.shown_more_conversation_id = conversation_id if is_shown else ""
@rx.event
async def delete_conversation(self, conversation_id: str) -> None:
"""
删除会话
:param conversation_id: 需删除的会话唯一标识
:return: None
"""
# 先设置会话记录为已删除再在会话字典中删除会话实例
database_state = await self.get_state(DatabaseState)
await database_state.set_conversations_record_deleted(
conversation_id=conversation_id
)
del self.conversations[conversation_id]
# 删除后,若会话字典为空则先创建会话记录再添加会话实例
if not self.conversations:
self.conversations.update(
await database_state.create_conversations_record(user_id=self.user_id)
)
# 删除后,若当前会话不存在则将最后一个会话作为当前会话并更新会话唯一标识
if self.conversation_id not in self.conversations:
self.conversation_id = next(reversed(self.conversations))
@rx.event
def switch_conversation(self, conversation_id: str) -> None:
"""
切换会话
:param conversation_id: 需切换的会话唯一标识
:return: None
"""
self.conversation_id = conversation_id
@rx.event
async def create_conversation(self, form_data: Dict[str, Any]) -> None:
"""
创建会话
:param form_data: 表单数据
:return: None
"""
# 解析会话描述
description = form_data["description"].strip() or "新会话"
# 创建会话记录再添加会话实例
database_state = await self.get_state(DatabaseState)
self.conversations.update(
await database_state.create_conversations_record(
user_id=self.user_id, description=description
)
)
# 将最后一个会话作为当前会话并更新会话唯一标识
self.conversation_id = next(reversed(self.conversations))
# 会话创建状态设置为未正在创建
self.is_conversation_creating = False
@rx.event
def toggle_conversation_creating(self) -> None:
"""
切换会话创建状态
"""
self.is_conversation_creating = not self.is_conversation_creating
@rx.event
def set_user_prompt(self, user_prompt: str) -> None:
"""
设置用户提示词
:param user_prompt: 用户提示词
:return: None
"""
self.user_prompt = user_prompt.strip()
@rx.var
def is_user_prompt_sending_disabled(self) -> bool:
"""
用户提示词发送禁用状态
:return: bool
"""
# 当前会话
conversation = self.conversations.get(self.conversation_id)
if not conversation:
return True
return conversation.is_running or not self.user_prompt
@rx.var
def running_status(self) -> bool:
"""
获取当前会话的运行状态
:return: 当前会话的运行状态True 表示正在运行False 表示运行完成)
"""
# 当前会话
conversation = self.conversations.get(self.conversation_id)
if not conversation:
return False
return conversation.is_running
@rx.var
def dialogs(self) -> List[Dialog]:
"""
当前会话的对话列表
:return: 当前会话的对话列表
"""
# 当前会话
conversation = self.conversations.get(self.conversation_id)
if not conversation:
return []
return list(conversation.dialogs.values())
@rx.event
async def handle_user_prompt(self) -> AsyncGenerator[None]:
"""
处理用户提示词
:return: AsyncGenerator[None]
"""
if not self.user_prompt:
return
# 清空前端用户提示词
user_prompt = self.user_prompt
self.user_prompt = ""
# 当前会话
conversation = self.conversations[self.conversation_id]
# 将当前会话的运行状态设置为正在运行
conversation.is_running = True
yield # 通知前端渲染
# 获取数据库状态
database_state = await self.get_state(DatabaseState)
# 获取消息历史
message_history = await database_state.get_message_history(
conversation_id=self.conversation_id
)
# 创建对话记录再添加对话实例
conversation.dialogs.update(
await database_state.create_dialog_record(
conversation_id=self.conversation_id, user_prompt=user_prompt
)
)
# 将最后一个会话作为当前会话
dialog = next(reversed(conversation.dialogs.values()))
# 初始化工具调用唯一标识和片段索引映射字典
tool_call_ids: Dict[str, int] = {}
async with agent.run_stream_events(
conversation_id=self.conversation_id,
user_prompt=user_prompt,
message_history=message_history,
) as events:
async for event in events:
match event:
# ========== 开始事件 ==========
case PartStartEvent(
index=index,
part=part,
previous_part_kind=previous_part_kind,
):
match part:
# 思考分片开始事件
case ThinkingPart(content=content):
# 若上一分片种类为空则将正在思考设置为 True、思考折叠面板展开状态设置为展开
if not previous_part_kind:
dialog.is_thinking = True
dialog.is_expanded = True
dialog.thoughts[index] = Thought(
type="thinking", content=content
)
# 工具检索分片开始事件
case ToolSearchCallPart(tool_call_id=tool_call_id):
# 创建工具调用唯一标识与片段索引映射
tool_call_ids[tool_call_id] = index
dialog.thoughts[index] = Thought(
type="tool_search",
content="正在生成检索关键词",
)
# 能力加载分片开始事件
case LoadCapabilityCallPart(tool_call_id=tool_call_id):
tool_call_ids[tool_call_id] = index
dialog.thoughts[index] = Thought(
type="capability_load",
content="正在生成加载参数",
)
# 工具调用分片开始事件
case ToolCallPart(tool_call_id=tool_call_id):
tool_call_ids[tool_call_id] = index
dialog.thoughts[index] = Thought(
type="tool_call",
content="正在生成调用参数",
)
# 文本分片开始事件
case TextPart(content=content):
dialog.result_output = content
# ========== 增量事件 ==========
case PartDeltaEvent(index=index, delta=delta):
match delta:
# 思考分片增量事件
case ThinkingPartDelta(
content_delta=content_delta,
):
dialog.thoughts[index].content += content_delta or ""
# 文本分片增量事件
case TextPartDelta(
content_delta=content_delta,
):
dialog.result_output += content_delta
# ========== 结束事件 ==========
case PartEndEvent(
index=index,
part=part,
next_part_kind=next_part_kind,
):
match part:
# 思考分片结束事件
case ThinkingPart(part_kind=part_kind, content=content):
# 若下一分片种类为文本则将思考状态设置为思考完成、思考面板展开状态设置为折叠
if next_part_kind == "text":
dialog.is_thinking = False
dialog.is_expanded = False
# ========== 函数工具调用事件 ==========
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 = "正在检索"
# 能力加载
case "capability_load":
dialog.thoughts[index].content = (
f"正在加载能力 {part.tool_name}"
)
# 工具调用
case "tool_call":
dialog.thoughts[index].content = (
f"正在调用工具 {part.tool_name}"
)
# ========== 函数工具结果事件 ==========
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 "capability_load":
dialog.thoughts[index].content = "已加载"
# 工具调用
case "tool_call":
dialog.thoughts[index].content = f"已调用"
# ========== 智能体运行结果事件 ==========
case AgentRunResultEvent(result=result):
# 补全对话记录
await database_state.complete_dialog_record(
id=dialog.id,
thoughts=dialog.thoughts,
result_output=dialog.result_output,
)
# 创建结果记录
await database_state.create_result_record(
conversation_id=self.conversation_id,
dialog_id=dialog.id,
new_messages=result.new_messages(),
)
# 将当前会话的运行状态设置为运行完成
conversation.is_running = False
yield
@rx.event
def toggle_collapse(self, dialog_id: str) -> None:
"""
展开/折叠思考折叠面板
"""
# 指定运行
dialog = self.conversations[self.conversation_id].dialogs[dialog_id]
dialog.is_expanded = not dialog.is_expanded