This commit is contained in:
parent
bc19eae6ea
commit
1493f9d1fe
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
Generic single-database configuration.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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="对话列表,键为对话唯一标识,值为对话对象"
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from application.utils.agent import Agent, Buffer, Debouncer
|
||||
|
||||
__all__ = ["Agent", "Debouncer", "Buffer"]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ def render_create_chat_modal(trigger) -> rx.Component:
|
|||
on_submit=ChatState.create_chat,
|
||||
),
|
||||
background_color=rx.color("mauve", 1),
|
||||
), # 模态窗的内容容器
|
||||
), # 模态窗内容容器
|
||||
open=CreateChatState.is_open,
|
||||
on_open_change=CreateChatState.toggle,
|
||||
)
|
||||
|
|
@ -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="运行字典")
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
创建聊天模态窗状态
|
||||
创建会话模态窗状态
|
||||
"""
|
||||
import reflex as rx
|
||||
|
||||
|
||||
class CreateChatState(rx.State):
|
||||
class CreateConversationState(rx.State):
|
||||
"""
|
||||
新建聊天状态
|
||||
创建会话状态
|
||||
"""
|
||||
|
||||
# 新建聊天模态窗打开状态,True表示打开,False表示关闭
|
||||
# 创建会话模态窗打开状态,True表示打开,False表示关闭
|
||||
is_open: bool = False
|
||||
|
||||
@rx.event
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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(),
|
||||
)
|
||||
Loading…
Reference in New Issue