This commit is contained in:
liubiren 2026-06-30 20:11:50 +08:00
parent bc19eae6ea
commit 1493f9d1fe
30 changed files with 786 additions and 970 deletions

View File

@ -1,149 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -1 +0,0 @@
Generic single-database configuration.

View File

@ -1,78 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -1,46 +0,0 @@
"""empty message
Revision ID: e05e08cbaa82
Revises:
Create Date: 2026-06-23 10:49:22.696176
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'e05e08cbaa82'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('messagehistory',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('chat_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('new_message', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('create_at', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('messagehistory', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_messagehistory_chat_id'), ['chat_id'], unique=False)
batch_op.create_index(batch_op.f('ix_messagehistory_create_at'), ['create_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('messagehistory', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_messagehistory_create_at'))
batch_op.drop_index(batch_op.f('ix_messagehistory_chat_id'))
op.drop_table('messagehistory')
# ### end Alembic commands ###

View File

@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
"""
数据模型
"""
from enum import StrEnum
from time import time_ns
from typing import List, Dict
from uuid import uuid4
from pydantic import BaseModel, Field
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
from sqlmodel import Field as SqlField, SQLModel
# 消息历史数据表模型
# 需使用 reflex db init 初始化数据库表,若重新初始化需手动删除 alembic 相关配置和文件夹
class MessageHistory(SQLModel, table=True):
id: str = SqlField(
default_factory=lambda: uuid4().hex,
primary_key=True,
description="消息唯一标识",
)
chat_id: str = SqlField(index=True, description="聊天唯一标识")
new_message: str = SqlField(description="新消息")
create_at: int = SqlField(
index=True,
description="创建时间戳(毫秒级)",
)
@staticmethod
def adapt(chat_id: str, new_message: List[ModelMessage]) -> "MessageHistory":
return MessageHistory(
chat_id=chat_id,
new_message=ModelMessagesTypeAdapter.dump_json(new_message).decode(
"utf-8"
), # 序列化为 JSON 字符串
create_at=time_ns() // 1_000_000, # 毫秒级时间戳
)
"""
聊天对话和消息关系
一次聊天包含若干轮对话每轮对话包含用户提示词user_prompt和输出output
其中
输出包含若干片段part
"""
class PartType(StrEnum):
"""片段类型(适配前端渲染)"""
THINKING = "thinking"
TEXT = "text"
TOOL_NAME = "tool_name"
TOOL_ARGS = "tool_args"
TOOL_RETURN = "tool_return"
FINISHED = "finished"
ERROR = "error"
# 动态生成前缀和片段类型映射表
PREFIX_MAPING = {f"{i:02d}": t for i, t in enumerate(PartType)}
class Part(BaseModel):
"""片段类(仅就输出消息)"""
part_type: PartType = Field(..., description="片段类型")
content: str = Field(default="", description="片段内容")
is_streaming: bool = Field(
default=False,
description="流式输出状态True 表示正在流式输出False 表示非正在流式输出",
)
is_open: bool = Field(
default=False,
description="折叠面板打开状态True表示打开False表示关闭",
)
class Dialog(BaseModel):
"""对话类"""
user_prompt: str = Field(..., description="用户提示词")
output: Dict[str, Part] = Field(
default_factory=dict, description="输出,键为片段唯一标识,值为片段对象"
)
class Chat(BaseModel):
"""聊天类"""
description: str = Field(default="新聊天", description="聊天描述")
is_streaming: bool = Field(
default=False,
description="流式输出状态True 表示正在流式输出False 表示非正在流式输出",
)
dialogs: Dict[str, Dialog] = Field(
default_factory=dict, description="对话列表,键为对话唯一标识,值为对话对象"
)

View File

@ -1,5 +0,0 @@
from application.state.chat import ChatState
from application.state.create_chat_modal import CreateChatModalState
from application.state.global_ import GlobalState
__all__ = ["GlobalState", "ChatState", "CreateChatModalState"]

View File

@ -1,237 +0,0 @@
# -*- coding: utf-8 -*-
"""
聊天页面状态
"""
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, Part, PartType, PREFIX_MAPING
from application.state.create_chat import CreateChatState
from application.state.database import DatabaseState
# 因 rx.state 需在进程与 WebSocket 间交互,非序列化对象会传输或储存失败,故需将智能体实例单独存储
# 绑定的智能体列表(键为聊天唯一标识,值为智能体实例)
agents: Dict[str, Agent] = {}
def get_agent(chat_id: str) -> Agent:
"""
获取绑定的智能体
:return: 绑定的智能体
"""
# 若智能体唯一标识不存在则新建智能体实例
if chat_id not in agents:
agents[chat_id] = Agent(
chat_id=chat_id,
) # 使用默认指令
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_chats(self) -> Dict[str, Chat]:
"""
获取聊天列表用于前端渲染聊天列表
:return: 聊天列表
"""
return self.chats
@rx.var
def get_current_chat_description(self) -> str:
"""
获取当前聊天描述用于前端渲染导航栏中当前聊天描述
:return: 当前聊天描述
"""
# 当前聊天
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 表示非正在流式输出
"""
# 当前聊天
current_chat = self.chats.get(self.current_chat_id)
return current_chat.is_streaming if current_chat else False
@rx.var
def get_dialogs(self) -> Dict[str, Dialog]:
"""
获取对话列表用于前端渲染对话列表
:return: 对话列表
"""
# 当前聊天
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:
"""
切换聊天
:param chat_id: 聊天唯一标识
:return: None
"""
if chat_id not in self.chats:
return
self.current_chat_id = chat_id
@rx.event
async def create_chat(self, form_data: Dict[str, Any]) -> None:
"""
新建聊天
:param form_data: 表单数据
:return: None
"""
# 生成聊天唯一标识
self.current_chat_id = uuid4().hex
self.chats[self.current_chat_id] = Chat(
description=form_data["chat_description"].strip() or "新聊天"
)
# 跨状态读取新建聊天模态窗状态(异步事件)
create_chat_state = await self.get_state(CreateChatState)
create_chat_state.is_open = False
@rx.event
def delete_chat(self, chat_id: str) -> None:
"""
删除聊天
:param chat_id: 聊天唯一标识
:return: None
"""
if chat_id not in self.chats:
return
del self.chats[chat_id]
# 删除聊天后若聊天列表为空,则新建聊天
if not self.chats:
self.chats[uuid4().hex] = Chat()
# 删除聊天后若当前聊天唯一标识不存在,则切换到第一个聊天
if self.current_chat_id not in self.chats:
self.current_chat_id = next(iter(self.chats))
@rx.event
async def run(self, form_data: dict[str, Any]) -> AsyncGenerator:
"""
运行
:param form_data: 表单数据
:return: AsyncGenerator
"""
# 获取用户提示词
user_prompt = form_data["user_prompt"].strip()
if not user_prompt:
return
# 当前聊天
current_chat = self.chats[self.current_chat_id]
if not current_chat:
return
# 生成新增对话唯一标识
current_dialog_id = uuid4().hex
# 新增对话
current_chat.dialogs[current_dialog_id] = Dialog(user_prompt=user_prompt)
# 当前对话
current_dialog = current_chat.dialogs[current_dialog_id]
# 设置当前聊天流式输出状态为正在流式输出
current_chat.is_streaming = True
yield # 通知前端渲染输入消息
# 获取当前聊天绑定的智能体
agent = get_agent(chat_id=self.current_chat_id)
# 数据库状态
database_state = await self.get_state(DatabaseState)
# 获取当前聊天消息历史
message_history = await database_state.get_message_history(
chat_id=self.current_chat_id
)
# 流式输出模型响应事件
async for event in agent.run(
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]
# 获取当前片段
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
# 新增片段
current_dialog.output[uuid4().hex] = (
new_part := Part(part_type=part_type, is_streaming=True)
)
current_part = new_part
# 追加片段内容
current_part.content += event.removeprefix(prefix)
yield # 通知前端渲染片段
# 重置所有片段流式输出状态
for part in current_dialog.output.values():
part.is_streaming = False
# 保存本轮对话消息
await database_state.save_new_message(
chat_id=self.current_chat_id, new_message=agent.new_messages
)
# 设置当前聊天流式输出状态非正在流式输出
current_chat.is_streaming = False
@rx.event
def toggle_part_collapse(self, dialog_id: str, part_id: str) -> None:
"""
打开/关闭片段折叠面板
:param dialog_id: 对话唯一标识
:param part_id: 片段唯一标识
:return: None
"""
# 当前聊天
current_chat = self.chats.get(self.current_chat_id)
if not current_chat:
return
# 当前对话
current_dialog = current_chat.dialogs.get(dialog_id)
if not current_dialog:
return
# 当前片段
current_part = current_dialog.output.get(part_id)
if not current_part:
return
# 切换折叠面板打开状态
current_part.is_open = not current_part.is_open

View File

@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
"""
数据库状态
"""
from typing import List
from sqlmodel import select as sql_select
import reflex as rx
import sqlalchemy as sa
from application.models import (
MessageHistory,
ModelMessage,
ModelMessagesTypeAdapter,
)
class DatabaseState(rx.State):
"""数据库状态"""
async def get_message_history(self, chat_id: str) -> List[ModelMessage]:
"""
获取聊天消息历史
:param chat_id: 聊天唯一标识
:return: 聊天消息历史
"""
message_history = []
async with rx.asession() as session:
records = await session.exec(
sql_select(MessageHistory)
.where(MessageHistory.chat_id == chat_id)
.order_by(sa.desc("create_at"))
)
for record in records.all():
message_history.extend(
ModelMessagesTypeAdapter.validate_json(record.new_message)
)
return message_history
async def save_new_message(
self, chat_id: str, new_message: List[ModelMessage]
) -> None:
"""
保存一轮对话消息
:param chat_id: 聊天唯一标识
:param new_message: 一轮对话消息
:return: None
"""
async with rx.asession() as session:
record = MessageHistory.adapt(chat_id=chat_id, new_message=new_message)
session.add(record)
await session.commit()

View File

@ -1,3 +0,0 @@
from application.utils.agent import Agent, Buffer, Debouncer
__all__ = ["Agent", "Debouncer", "Buffer"]

View File

@ -1,265 +0,0 @@
# -*- coding: utf-8 -*-
"""
Pydantic AI 聊天智能体和相关模块
"""
# 列举导入模块
from enum import StrEnum
from typing import AsyncGenerator, List, Optional, Union
from uuid import uuid4
from pydantic_ai import Agent
from pydantic_ai.capabilities import AgentCapability
from pydantic_ai.messages import (
AgentStreamEvent,
ModelMessage,
PartStartEvent,
ThinkingPart,
ToolSearchCallPart,
ThinkingPartDelta,
ToolCallPart,
LoadCapabilityCallPart,
TextPartDelta,
ToolCallPartDelta,
FunctionToolResultEvent,
PartDeltaEvent,
TextPart,
PartEndEvent,
)
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.output import OutputSpec
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.run import AgentRunResultEvent
from pydantic import BaseModel, Field
DEFAULT_INSTRUCTIONS: str = """
# 角色
专业友好AI助手结构化解答各类问题
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹
4. 重点内容标注 **粗体**/*斜体*
5. 思考工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染
# 行文要求
语言通俗逻辑完整简洁无多余废话
"""
class Event(BaseModel):
"""
事件类
"""
event_kind: str = Field(..., description="事件种类")
content: Optional[str] = Field(default=None, description="事件内容")
part_index: Optional[int] = Field(default=None, description="分片索引")
part_kind: str = Field(..., description="分片种类")
tool_name: Optional[str] = Field(default=None, description="工具名称")
class AIAgent:
"""
基于 Pydantic AI 封装的智能体
"""
def __init__(
self,
chat_id: str,
instructions: Optional[str] = None,
output_type: OutputSpec = str,
capabilities: Optional[List[AgentCapability]] = None,
retries: int = 1,
):
"""
初始化
:param chat_id: 聊天唯一标识
:param instructions: 指令
:param capabilities: 技能列表默认为不使用技能
:param output_type: 输出类型
:param retries: 重试次数默认为1次
:return: 智能体实例
"""
# 聊天唯一标识
self.chat_id = chat_id
# 一次聊天chat包含若干论对话dialog每轮对话包含用户提示词user_prompt和输出output。其中输出包含若干分片Part
# 本轮对话工具调用唯一标识与片段索引映射表
self.tool_call_ids: dict[str, int] = {}
# 本轮对话新增消息列表
self.new_messages: List[ModelMessage] = []
# 若指令为空则使用默认指令
if not instructions:
instructions = DEFAULT_INSTRUCTIONS
# 初始化智能体
self.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=capabilities,
output_type=output_type,
retries=retries,
)
async def run(
self,
user_prompt: str | List[str],
message_history: Optional[List[ModelMessage]] = None,
) -> AsyncGenerator[Event]:
"""
运行
:param user_prompt: 用户提示词用户提示词
:param flush_delay: 刷新延迟时长单位为秒
:yield: AsyncGenerator[Event]
"""
async with self.agent.run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
) as events:
async for event in events:
match event:
# ========== 分片开始事件 ==========
case PartStartEvent(event_kind=event_kind, index=index, part=part):
match part:
# 思考分片开始事件
case ThinkingPart(part_kind=part_kind, content=content):
yield Event(
event_kind=event_kind,
content=content,
part_index=index,
part_kind=part_kind,
)
# 检索工具分片开始事件
case ToolSearchCallPart(
part_kind=part_kind, tool_call_id=tool_call_id, tool_name=tool_name
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 加载能力分片开始事件
case LoadCapabilityCallPart(
part_kind=part_kind, tool_call_id=tool_call_id, tool_name=tool_name
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 调用工具分片开始事件
case ToolCallPart(
part_kind=part_kind,
tool_call_id=tool_call_id,
tool_name=tool_name,
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 文本分片开始事件
case TextPart(part_kind=part_kind, content=content):
yield Event(
event_kind=event_kind,
content=content,
part_index=index,
part_kind=part_kind,
)
# ========== 分片增量事件(前端仅就文本片段实现打字机效果) ==========
case PartDeltaEvent(
event_kind=event_kind, index=index, delta=delta
):
match delta:
# 文本分片增量事件
case TextPartDelta(
part_delta_kind=part_delta_kind,
content_delta=content_delta,
):
yield Event(
event_kind=event_kind,
content=content_delta, # 增量
part_index=index,
part_kind=part_delta_kind,
)
# ========== 分片结束事件 ==========
case PartEndEvent(event_kind=event_kind, index=index, part=part):
match part:
# 思考分片结束事件
case ThinkingPart(part_kind=part_kind, content=content):
yield Event(
event_kind=event_kind,
content=content,
part_index=index,
part_kind=part_kind,
)
# 检索工具分片结束事件
case ToolSearchCallPart(part_kind=part_kind, tool_name=tool_name):
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 加载能力分片结束事件
case LoadCapabilityCallPart(part_kind=part_kind, tool_name=tool_name):
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 调用工具分片结束事件
case ToolCallPart(part_kind=part_kind, tool_name=tool_name):
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)
# 文本分片结束事件
case TextPart(part_kind=part_kind):
yield Event(
event_kind=event_kind,
part_index=index,
part_kind=part_kind,
)
# ========== 函数工具结果回调事件 ==========
case FunctionToolResultEvent(
event_kind=event_kind,
content=content,
part=part,
):
yield Event(
event_kind=event_kind,
content=content if isinstance(content, str) else None, #
part_index=index,
part_kind=part_kind,
tool_name=tool_name,
)

View File

@ -98,7 +98,7 @@ def render_create_chat_modal(trigger) -> rx.Component:
on_submit=ChatState.create_chat, on_submit=ChatState.create_chat,
), ),
background_color=rx.color("mauve", 1), background_color=rx.color("mauve", 1),
), # 模态窗内容容器 ), # 模态窗内容容器
open=CreateChatState.is_open, open=CreateChatState.is_open,
on_open_change=CreateChatState.toggle, on_open_change=CreateChatState.toggle,
) )

View File

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""
数据模型
"""
from enum import StrEnum
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic_ai._uuid import uuid7
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
from sqlmodel import Field as SqlField, SQLModel
# 消息历史数据表模型
# 需使用 reflex db init 初始化数据库表,若重新初始化需手动删除 alembic 相关配置和文件夹
class MessageHistory(SQLModel, table=True):
id: str = SqlField(
default_factory=lambda: str(uuid7()),
primary_key=True,
description="消息唯一标识",
)
conversation_id: str = SqlField(index=True, description="会话唯一标识")
run_id: str = SqlField(index=True, description="运行唯一标识")
run_new_message: str = SqlField(description="运行新增消息")
@staticmethod
def adapt(
conversation_id: str, run_id: str, run_new_message: List[ModelMessage]
) -> "MessageHistory":
"""
适配运行新增消息为消息历史
:param conversation_id: 会话唯一标识
:param run_id: 运行唯一标识
:param run_new_message: 运行新增消息
:return: 消息历史
"""
return MessageHistory(
conversation_id=conversation_id,
run_id=run_id,
run_new_message=ModelMessagesTypeAdapter.dump_json(run_new_message).decode(
"utf-8"
), # 序列化为 JSON 字符串
)
"""
会话运行和消息关系
一次会话conversation包含若干次运行run每次运行包含用户提示词user_prompt和输出output其中输出包含推理和回答
"""
class EventKind(StrEnum):
"""事件类型枚举"""
PART_START = "part_start"
PART_DELTA = "part_delta"
PART_END = "part_end"
FUNCTION_TOOL_CALL = "function_tool_call"
FUNCTION_TOOL_RESULT = "function_tool_result"
RUN_START = "run_start"
RUN_END = "run_end"
class PartKind(StrEnum):
"""分片类型枚举"""
THINKING = "thinking"
TOOL_SEARCH = "tool-search"
CAPABILITY_LOAD = "capability-load"
TOOL_CALL = "tool-call"
TEXT = "text"
TOOL_RETURN = "tool-return"
RETRY_PROMPT = "retry-prompt"
RUN_RETURN = "run-return"
class Event(BaseModel):
"""
事件类
"""
event_kind: EventKind = Field(..., description="事件类型")
event_content: str = Field(default="", description="事件内容")
part_index: Optional[int] = Field(default=None, description="分片索引")
previous_part_kind: Optional[PartKind] = Field(
default=None, description="上个分片类型"
)
part_kind: Optional[PartKind] = Field(default=None, description="分片类型")
next_part_kind: Optional[PartKind] = Field(default=None, description="下个分片类型")
tool_name: Optional[str] = Field(default=None, description="工具名称")
run_id: str = Field(..., description="运行唯一标识")
run_new_messages: List[ModelMessage] = Field(
default=[], description="运行新增消息列表"
)
class ReasoningKind(StrEnum):
"""推理类型枚举"""
THINKING = "thinking"
TOOL_SEARCH = "tool-search"
CAPABILITY_LOAD = "capability-load"
TOOL_CALL = "tool-call"
TEXT = "text"
TOOL_RETURN = "tool-return"
RETRY_PROMPT = "retry-prompt"
RUN_RETURN = "run-return"
class Reasoning(BaseModel):
"""推理类"""
reasoning_kind: ReasoningKind = Field(..., description="推理类型")
content: str = Field(default="", description="推理内容")
class Run(BaseModel):
"""运行类"""
user_prompt: str = Field(..., description="用户提示词")
reasonings: Dict[int, Reasoning] = Field(
default_factory=dict, description="推理字典"
)
is_reasoning: bool = Field(
default=False,
description="运行推理状态True 表示正在推理False 表示非正在推理",
)
is_expanded: bool = Field(
default=False,
description="推理折叠面板展开状态True 表示展开False 表示折叠",
)
answer: str = Field(default="", description="回答")
is_streaming: bool = Field(
default=False,
description="运行流式输出状态True 表示正在流式输出False 表示非正在流式输出",
)
class Conversation(BaseModel):
"""会话类"""
description: str = Field(default="新会话", description="会话描述")
runs: Dict[str, Run] = Field(default_factory=dict, description="运行字典")

View File

View File

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""
会话页面状态
"""
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, cast
from pydantic_ai._uuid import uuid7
import reflex as rx
from application.models import Conversation, EventKind, PartKind, Run
from application.state.create_conversation import CreateConversationState
from application.state.database import DatabaseState
from application.utils.agent import AIAgent
class ConversationState(rx.State):
"""
会话状态
"""
# 当前会话唯一标识
current_conversation_id: str = str(uuid7())
# 会话字典
conversations: Dict[str, Conversation] = {current_conversation_id: Conversation()}
# 当前运行唯一标识
current_run_id: Optional[str] = None
# 初始化智能体
agent: AIAgent = AIAgent()
@rx.var
def get_conversations(self) -> Dict[str, Conversation]:
"""
获取会话字典用于前端渲染会话列表
:return: 会话字典
"""
return self.conversations
@rx.var
def get_current_conversation_description(self) -> str:
"""
获取当前会话描述用于前端渲染导航栏中当前会话描述
:return: 当前会话描述
"""
# 若当前会话唯一标识不存在则创建会话
if self.current_conversation_id not in self.conversations:
self.conversations[self.current_conversation_id] = Conversation()
# 当前会话
current_conversation = self.conversations[self.current_conversation_id]
return current_conversation.description
@rx.event
def set_current_conversation_id(self, conversation_id: str) -> None:
"""
将指定会话唯一标识设置为当前会话唯一标识
:param conversation_id: 指定会话唯一标识
:return: None
"""
# 若指定会话唯一标识不存在则创建会话
if conversation_id not in self.conversations:
self.conversations[conversation_id] = Conversation()
# 将指定会话唯一标识设置为当前会话唯一标识
self.current_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["conversation_description"].strip()
if not description:
description = "新会话"
# 创建会话唯一标识
self.current_conversation_id = str(uuid7())
self.conversations[self.current_conversation_id] = Conversation(
description=description
)
# 跨状态读取创建会话模态窗状态
create_conversation_state = await self.get_state(CreateConversationState)
create_conversation_state.is_open = False
@rx.event
def delete_conversation(self, conversation_id: str) -> None:
"""
删除会话
:param conversation_id: 会话唯一标识
:return: None
"""
# 若指定会话唯一标识不存在则直接返回
if conversation_id not in self.conversations:
return
del self.conversations[conversation_id]
# 删除后若会话字典为空则后创建会话
if not self.conversations:
self.conversations[str(uuid7())] = Conversation()
# 删除后若当前会话唯一标识不存在则将第一个会话唯一标识设置为当前会话唯一标识
if self.current_conversation_id not in self.conversations:
self.current_conversation_id = next(iter(self.conversations))
@rx.var
def get_runs(self) -> Dict[str, Run]:
"""
获取当前会话运行字典用于前端渲染运行列表
:return: 当前会话运行字典
"""
# 若当前会话唯一标识不存在则创建会话
if self.current_conversation_id not in self.conversations:
self.conversations[self.current_conversation_id] = Conversation()
# 当前会话
current_conversation = self.conversations[self.current_conversation_id]
return current_conversation.runs
@rx.var
def get_current_run_streaming_status(self) -> bool:
"""
获取当前运行流式输出状态
:return: 当前运行流式输出状态True 表示正在流式输出False 表示非正在流式输出
"""
# 若当前会话唯一标识不存在则创建会话
if self.current_conversation_id not in self.conversations:
self.conversations[self.current_conversation_id] = Conversation()
# 当前会话
current_conversation = self.conversations[self.current_conversation_id]
# 若当前运行唯一标识为空或不存在则返回 False
if not self.current_run_id or self.current_run_id not in current_conversation.runs:
return False
# 当前运行
current_run = current_conversation.runs[self.current_run_id]
return current_run.is_streaming
@rx.event
async def run(self, form_data: dict[str, Any]) -> AsyncGenerator[None]:
"""
运行
:param form_data: 表单数据
:return: AsyncGenerator
"""
# 若当前会话唯一标识不存在则创建会话
if self.current_conversation_id not in self.conversations:
self.conversations[self.current_conversation_id] = Conversation()
# 当前会话
current_conversation = self.conversations[self.current_conversation_id]
# 获取用户提示词
user_prompt = form_data["user_prompt"].strip()
if not user_prompt:
return
# 数据库状态
database_state = await self.get_state(DatabaseState)
# 获取当前会话消息历史
message_history = await database_state.get_message_history(
conversation_id=self.current_conversation_id
)
async for event in self.agent.run(
conversation_id=self.current_conversation_id,
user_prompt=user_prompt,
message_history=message_history,
):
# 若为运行开始事件则将事件运行唯一标识设置为当前运行唯一标识
if event.event_kind == EventKind.RUN_START:
self.current_run_id = event.run_id
continue
# 若当前运行唯一标识为空或与事件运行唯一标识不相同则跳过
if not self.current_run_id or self.current_run_id != event.run_id:
continue
# 若当前运行唯一标识不存在则新增运行
if self.current_run_id not in current_conversation.runs:
# 新增运行
current_conversation.runs[self.current_run_id] = Run(user_prompt=user_prompt, is_streaming=True)
yield # 通知前端渲染
continue
# 当前运行
current_run = current_conversation.runs[self.current_run_id]
# ========== 推理 ==========
if event.event_kind == EventKind.PART_START and event.part_kind == PartKind.THINKING:
current_run.thinking += event.event_content
yield # 通知前端渲染
continue
if part_index := event.part_index:
if part_index not in current_run.reasonings:
current_run.reasonings[part_index] += event.event_content
yield # 通知前端渲染
continue
# ========== 回答 ==========
if event.part_kind == PartKind.TEXT:
current_run.answer += event.event_content
yield # 通知前端渲染
continue
# ========== 运行结束 ==========
if event.event_kind == EventKind.RUN_END:
# 保存运行新增消息
await database_state.save_new_message(
conversation_id=self.current_conversation_id, run_id=self.current_run_id, run_new_message=event.run_new_messages
)
# 设置当前运行流式输出状态非正在流式输出
current_run.is_streaming = False
yield # 通知前端渲染
continue

View File

@ -1,16 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
创建聊天模态窗状态 创建会话模态窗状态
""" """
import reflex as rx import reflex as rx
class CreateChatState(rx.State): class CreateConversationState(rx.State):
""" """
新建聊天状态 创建会话状态
""" """
# 新建聊天模态窗打开状态True表示打开False表示关闭 # 创建会话模态窗打开状态True表示打开False表示关闭
is_open: bool = False is_open: bool = False
@rx.event @rx.event

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
数据库状态
"""
from typing import List
import reflex as rx
import sqlalchemy as sa
from sqlmodel import select as sql_select
from application.models import MessageHistory, ModelMessage, ModelMessagesTypeAdapter
class DatabaseState(rx.State):
"""数据库状态"""
async def get_message_history(self, conversation_id: str) -> List[ModelMessage]:
"""
获取会话消息历史
:param conversation_id: 会话唯一标识
:return: 会话消息历史
"""
message_history = []
async with rx.asession() as session:
records = await session.exec(
sql_select(MessageHistory)
.where(MessageHistory.conversation_id == conversation_id)
.order_by(sa.desc("run_id"))
)
for record in records.all():
message_history.extend(
ModelMessagesTypeAdapter.validate_json(record.run_new_message)
)
return message_history
async def save_new_message(
self, conversation_id: str, run_id: str, run_new_message: List[ModelMessage]
) -> None:
"""
保存运行新增消息
:param conversation_id: 会话唯一标识
:param run_id: 运行唯一标识
:param run_new_message: 运行新增消息
:return: None
"""
async with rx.asession() as session:
record = MessageHistory.adapt(
conversation_id=conversation_id,
run_id=run_id,
run_new_message=run_new_message,
)
session.add(record)
await session.commit()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
"""
Pydantic AI 智能体和相关模块
"""
# 列举导入模块
from typing import AsyncGenerator, List, Optional
from pydantic_ai import _agent_graph as agent_graph
from pydantic_ai import Agent, ThinkingPartDelta
from pydantic_ai.capabilities import AgentCapability
from pydantic_ai.messages import (
FunctionToolCallEvent,
FunctionToolResultEvent,
LoadCapabilityCallPart,
ModelMessage,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
TextPart,
TextPartDelta,
ThinkingPart,
ToolCallPart,
ToolSearchCallPart,
)
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.output import OutputSpec
from pydantic_ai.providers.openai import OpenAIProvider
from application.models import Event, EventKind, PartKind
DEFAULT_INSTRUCTIONS: str = """
# 角色
专业友好AI助手结构化解答各类问题
# 输出硬性规则
1. 全文强制标准Markdown禁止纯文本不要额外说明排版格式直接输出内容
2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表;
3. 代码块用 ```语言名``` 包裹
4. 重点内容标注 **粗体**/*斜体*
5. 思考工具日志仅输出文本适配前端折叠面板禁止输出HTML标签
6. 内容分点拆分排版整洁适配前端Markdown渲染
# 行文要求
语言通俗逻辑完整简洁无多余废话
"""
class AIAgent:
"""
基于 Pydantic AI 封装的智能体
"""
def __init__(
self,
instructions: Optional[str] = None,
capabilities: Optional[List[AgentCapability]] = None,
output_type: OutputSpec = str,
retries: int = 1,
):
"""
初始化
:param instructions: 指令
:param capabilities: 技能列表默认为不使用技能
:param output_type: 输出类型
:param retries: 重试次数默认为1次
:return: 智能体实例
"""
# 一次会话conversation包含若干次运行run每次运行包含用户提示词user_prompt和输出output。其中输出包含若干片分片Part
# 本次运行工具调用唯一标识与片段索引映射表
self.tool_call_ids: dict[str, int] = {}
# 若指令为空则使用默认指令
if not instructions:
instructions = DEFAULT_INSTRUCTIONS
# 初始化智能体
self.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=capabilities,
output_type=output_type,
retries=retries,
)
async def run(
self,
conversation_id: str,
user_prompt: str | List[str],
message_history: Optional[List[ModelMessage]] = None,
) -> AsyncGenerator[Event]:
"""
运行
:param conversation_id: 会话唯一标识
:param user_prompt: 用户提示词用户提示词
:param message_history: 消息历史默认为None
:yield: AsyncGenerator[Event]
"""
# 重置工具调用唯一标识与片段索引映射表
self.tool_call_ids.clear()
async with self.agent.iter(
conversation_id=conversation_id,
user_prompt=user_prompt,
message_history=message_history,
) as run:
# 当前运行唯一标识
current_run_id = run.ctx.state.run_id
yield Event(
event_kind=EventKind.RUN_START,
run_id=current_run_id,
)
async for node in run:
if self.agent.is_model_request_node(
node=node
) or self.agent.is_call_tools_node(node=node):
async with node.stream(run.ctx) as stream:
# 将原始流转为事件流
stream = run.ctx.deps.root_capability.wrap_run_event_stream(
ctx=agent_graph.build_run_context(ctx=run.ctx),
stream=stream,
)
async for event in stream:
match event:
# ========== 分片开始事件 ==========
case PartStartEvent(
event_kind=event_kind,
index=index,
part=part,
previous_part_kind=previous_part_kind,
):
match part:
# 思考分片开始事件
case ThinkingPart(
part_kind=part_kind, content=content
):
yield Event(
event_kind=EventKind(event_kind),
event_content=content,
part_index=index,
previous_part_kind=PartKind(
previous_part_kind
),
part_kind=PartKind(part_kind),
run_id=current_run_id,
)
# 检索工具分片开始事件
case ToolSearchCallPart(
tool_kind=tool_kind,
tool_call_id=tool_call_id,
tool_name=tool_name,
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
previous_part_kind=PartKind(
previous_part_kind
),
part_kind=PartKind(tool_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# 加载能力分片开始事件
case LoadCapabilityCallPart(
tool_kind=tool_kind,
tool_call_id=tool_call_id,
tool_name=tool_name,
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
previous_part_kind=PartKind(
previous_part_kind
),
part_kind=PartKind(tool_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# 调用工具分片开始事件
case ToolCallPart(
part_kind=part_kind,
tool_call_id=tool_call_id,
tool_name=tool_name,
):
# 记录工具调用唯一标识与片段索引映射
self.tool_call_ids[tool_call_id] = index
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
previous_part_kind=PartKind(
previous_part_kind
),
part_kind=PartKind(part_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# 文本分片开始事件
case TextPart(
part_kind=part_kind, content=content
):
yield Event(
event_kind=EventKind(event_kind),
event_content=content,
part_index=index,
previous_part_kind=PartKind(
previous_part_kind
),
part_kind=PartKind(part_kind),
run_id=current_run_id,
)
# ========== 分片增量事件(前端仅就文本片段实现打字机效果) ==========
case PartDeltaEvent(
event_kind=event_kind, index=index, delta=delta
):
match delta:
# 思考分片增量事件
case ThinkingPartDelta(
part_delta_kind=part_delta_kind,
content_delta=content_delta,
):
yield Event(
event_kind=EventKind(event_kind),
event_content=content_delta or "",
part_index=index,
part_kind=PartKind(part_delta_kind),
run_id=current_run_id,
)
# 文本分片增量事件
case TextPartDelta(
part_delta_kind=part_delta_kind,
content_delta=content_delta,
):
yield Event(
event_kind=EventKind(event_kind),
event_content=content_delta,
part_index=index,
part_kind=PartKind(part_delta_kind),
run_id=current_run_id,
)
# ========== 分片结束事件 ==========
case PartEndEvent(
event_kind=event_kind,
index=index,
part=part,
next_part_kind=next_part_kind,
):
match part:
# 思考分片结束事件
case ThinkingPart(
part_kind=part_kind, content=content
):
yield Event(
event_kind=EventKind(event_kind),
event_content=content,
part_index=index,
part_kind=PartKind(part_kind),
next_part_kind=PartKind(next_part_kind),
run_id=current_run_id,
)
# 检索工具分片结束事件
case ToolSearchCallPart(
tool_kind=tool_kind, tool_name=tool_name
):
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
part_kind=PartKind(tool_kind),
next_part_kind=PartKind(next_part_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# 加载能力分片结束事件
case LoadCapabilityCallPart(
tool_kind=tool_kind, tool_name=tool_name
):
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
part_kind=PartKind(tool_kind),
next_part_kind=PartKind(next_part_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# 调用工具分片结束事件
case ToolCallPart(
part_kind=part_kind, tool_name=tool_name
):
yield Event(
event_kind=EventKind(event_kind),
part_index=index,
part_kind=PartKind(part_kind),
next_part_kind=PartKind(next_part_kind),
tool_name=tool_name,
run_id=current_run_id,
)
# ========== 函数工具调用事件 ==========
case FunctionToolCallEvent(
event_kind=event_kind,
part=part,
):
yield Event(
event_kind=EventKind(event_kind),
part_index=self.tool_call_ids.get(
part.tool_call_id
),
part_kind=PartKind(part.part_kind),
tool_name=part.tool_name,
run_id=current_run_id,
)
# ========== 函数工具回调事件 ==========
case FunctionToolResultEvent(
event_kind=event_kind,
content=content,
part=part,
):
yield Event(
event_kind=EventKind(event_kind),
event_content=(
content if isinstance(content, str) else ""
), # 暂仅处理文本类型,后续处理多模态和缓存
part_index=self.tool_call_ids.get(
part.tool_call_id
),
part_kind=PartKind(part.part_kind),
tool_name=part.tool_name,
run_id=current_run_id,
)
yield Event(
event_kind=EventKind.RUN_END,
run_id=current_run_id,
run_new_messages=run.new_messages(),
)