260622
This commit is contained in:
parent
9f03de61ab
commit
696d91be47
|
|
@ -0,0 +1,149 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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()
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""${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"}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 97c7b4855ab2
|
||||
Revises:
|
||||
Create Date: 2026-06-22 19:56:54.150570
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '97c7b4855ab2'
|
||||
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('historymessage',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('chat_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('new_message', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('timestamp', sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('historymessage')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
主模块
|
||||
应用主入口
|
||||
"""
|
||||
import reflex as rx
|
||||
from application.pages.index import index_page
|
||||
|
||||
import reflex
|
||||
from .page.index import index
|
||||
|
||||
app = reflex.App()
|
||||
app.add_page(index)
|
||||
app = rx.App()
|
||||
# 注册首页路由
|
||||
app.add_page(index_page, route="/")
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
应用状态管理模块
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
from typing import Any, AsyncGenerator, Dict, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
import reflex
|
||||
|
||||
from sys import path
|
||||
from pathlib import Path
|
||||
|
||||
path.append(Path(__file__).parent.parent.parent.parent.as_posix())
|
||||
from utils.agent import Agent
|
||||
|
||||
# 所有会话绑定的智能体
|
||||
agents: Dict[str, Agent] = {}
|
||||
|
||||
|
||||
def get_current_session_agent(state) -> Agent:
|
||||
"""
|
||||
获取当前会话绑定的智能体
|
||||
:return: 当前会话绑定的智能体
|
||||
"""
|
||||
current_session_name = state.current_session_name
|
||||
if current_session_name not in agents:
|
||||
agents[current_session_name] = Agent(
|
||||
session_id=uuid4().hex,
|
||||
instructions="You are a friendly chatbot",
|
||||
)
|
||||
return agents[current_session_name]
|
||||
|
||||
|
||||
class MessageType(StrEnum):
|
||||
"""消息类型类"""
|
||||
|
||||
THINKING = "thinking"
|
||||
TEXT = "text"
|
||||
CALL = "call"
|
||||
TOOL_ARGS = "tool_args"
|
||||
TOOL_RETURN = "tool_return"
|
||||
RESULT = "result"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# 消息类型前缀映射
|
||||
MESSAGE_TYPE_PREFIX_MAP = {f"{i:02d}:": mt for i, mt in enumerate(MessageType)}
|
||||
|
||||
|
||||
# 会话、对话、消息关系:一次会话包含若干轮对话,每轮对话包含若干条消息
|
||||
class Message(BaseModel):
|
||||
"""消息类"""
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, description="消息唯一标识")
|
||||
type_: MessageType = Field(..., description="类型")
|
||||
content: str = Field(default="", description="内容")
|
||||
|
||||
|
||||
class Turn(BaseModel):
|
||||
"""对话类"""
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, description="对话唯一标识")
|
||||
input_: str = Field(..., description="输入消息")
|
||||
output: List[Message] = Field(default_factory=list, description="输出消息")
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
"""会话类"""
|
||||
|
||||
is_processing: bool = Field(
|
||||
default=False,
|
||||
description="会话状态:True 表示正在处理中,False 表示未处理或处理完成",
|
||||
)
|
||||
turns: List[Turn] = Field(default_factory=list, description="对话列表")
|
||||
|
||||
|
||||
class State(reflex.State):
|
||||
"""统一管理应用数据与功能状态,作为前后端交互枢纽,借助响应式特性实现页面自动更新"""
|
||||
|
||||
# 当前会话名称
|
||||
current_session_name: str = "新会话"
|
||||
# 会话列表(会话名称作为会话对象的唯一标识,不允许重复)
|
||||
sessions: Dict[str, Session] = {current_session_name: Session()}
|
||||
|
||||
# 新建会话模态窗是否打开
|
||||
create_session_modal_is_open: bool = False
|
||||
|
||||
@reflex.var
|
||||
def get_session_names(self) -> List[str]:
|
||||
"""
|
||||
获取会话名称列表
|
||||
:return: 会话名称列表
|
||||
"""
|
||||
return list(self.sessions)
|
||||
|
||||
@reflex.event
|
||||
def switch_session(self, session_name: str) -> None:
|
||||
"""
|
||||
切换会话
|
||||
:param session_name: 会话名称
|
||||
:return: None
|
||||
"""
|
||||
self.current_session_name = session_name
|
||||
|
||||
@reflex.var
|
||||
def get_current_session_status(self) -> bool:
|
||||
"""
|
||||
获取当前会话状态
|
||||
:return: 当前会话状态
|
||||
"""
|
||||
if self.current_session_name not in self.sessions:
|
||||
return False
|
||||
return self.sessions[self.current_session_name].is_processing
|
||||
|
||||
@reflex.var
|
||||
def get_current_session_turns(self) -> List[Turn]:
|
||||
"""
|
||||
获取当前会话对话列表
|
||||
:return: 对话列表
|
||||
"""
|
||||
if self.current_session_name not in self.sessions:
|
||||
return []
|
||||
return self.sessions[self.current_session_name].turns
|
||||
|
||||
@reflex.event
|
||||
def create_session(self, form_data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
新建会话
|
||||
:param form_data: 新建会话表单数据
|
||||
:return: None
|
||||
"""
|
||||
session_name = form_data["session_name"].strip()
|
||||
|
||||
# 若新建会话名称为空则默认使用"新会话"作为会话名称
|
||||
if not session_name:
|
||||
session_name = "新会话"
|
||||
|
||||
original_session_name = session_name
|
||||
counter = 1
|
||||
# 若会话名称重复则在会话名称后面添加标号至不重复
|
||||
while session_name in self.sessions:
|
||||
session_name = f"{original_session_name}({counter})"
|
||||
counter += 1
|
||||
|
||||
self.current_session_name = session_name
|
||||
self.sessions[session_name] = Session()
|
||||
|
||||
# 关闭新建会话模态窗
|
||||
self.create_session_modal_is_open = False
|
||||
|
||||
@reflex.event
|
||||
def delete_session(self, session_name: str) -> None:
|
||||
"""
|
||||
删除会话
|
||||
:param session_name: 会话名称
|
||||
:return: None
|
||||
"""
|
||||
if session_name not in self.sessions:
|
||||
return
|
||||
del self.sessions[session_name]
|
||||
|
||||
# 若会话列表为空则新建会话(区别于 create_session 方法,此处为后台新建会话)
|
||||
if not self.sessions:
|
||||
self.sessions["新会话"] = Session()
|
||||
|
||||
# 若当前会话名称不存在则默认使用第一个会话名称
|
||||
if self.current_session_name not in self.sessions:
|
||||
self.current_session_name = next(iter(self.sessions))
|
||||
|
||||
@reflex.event
|
||||
def toggle_create_session_modal(self, is_open: bool) -> None:
|
||||
"""
|
||||
打开 / 关闭新建会话模态窗
|
||||
:param is_open: 打开或关闭新建会话模态窗
|
||||
:return: None
|
||||
"""
|
||||
self.create_session_modal_is_open = is_open
|
||||
|
||||
@reflex.event
|
||||
async def adapt_input(self, form_data: dict[str, Any]) -> AsyncGenerator:
|
||||
"""
|
||||
适配输入
|
||||
:param form_data: 输入栏组件的表单数据
|
||||
:return: AsyncGenerator
|
||||
"""
|
||||
input_ = form_data["input"].strip()
|
||||
if not input_:
|
||||
return
|
||||
|
||||
# 当前会话
|
||||
current_session = self.sessions[self.current_session_name]
|
||||
# 当前会话正在处理
|
||||
current_session.is_processing = True
|
||||
# 将输入添加到当前会话对话列表
|
||||
current_session.turns.append(
|
||||
Turn(
|
||||
input_=input_,
|
||||
)
|
||||
)
|
||||
yield # 通知前端渲染输入消息
|
||||
|
||||
# 当前对话
|
||||
current_turn = current_session.turns[-1]
|
||||
|
||||
# 获取当前会话绑定的智能体
|
||||
agent = get_current_session_agent(self)
|
||||
async for event in agent.stream_messages_events(user_prompt=input_):
|
||||
# 跳过空事件
|
||||
if not event:
|
||||
continue
|
||||
|
||||
# 匹配消息类型
|
||||
prefix = next(
|
||||
(t for t in MESSAGE_TYPE_PREFIX_MAP if event.startswith(t)), None
|
||||
)
|
||||
# 跳过未匹配事件
|
||||
if not prefix:
|
||||
continue
|
||||
|
||||
# 消息类型
|
||||
type_ = MESSAGE_TYPE_PREFIX_MAP[prefix]
|
||||
# 若当前对话输出为空或当前消息类型和上一个消息类型不一致则创建消息
|
||||
if not current_turn.output or current_turn.output[-1].type_ != type_:
|
||||
current_turn.output.append(Message(type_=type_))
|
||||
current_turn.output[-1].content += event.removeprefix(prefix)
|
||||
|
||||
yield # 通知前端渲染输出消息
|
||||
|
||||
# 当前会话处理完成
|
||||
current_session.is_processing = False
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
会话相关组件
|
||||
"""
|
||||
import reflex as rx
|
||||
from reflex.constants.colors import ColorType
|
||||
|
||||
from application.models import Type_, Message, Dialog
|
||||
from application.state import ChatState
|
||||
|
||||
|
||||
def input_message_component(message: str, color: ColorType) -> rx.Component:
|
||||
"""
|
||||
输入消息组件
|
||||
:param message: 消息
|
||||
:param color: 颜色
|
||||
:return: Component
|
||||
"""
|
||||
return rx.markdown(
|
||||
message,
|
||||
color=rx.color(color=color, shade=12),
|
||||
background_color=rx.color(color=color, shade=4),
|
||||
display="inline-block",
|
||||
padding_inline="1em",
|
||||
border_radius="8px",
|
||||
)
|
||||
|
||||
|
||||
def output_message_component(message: Message) -> rx.Component:
|
||||
"""
|
||||
输出消息组件
|
||||
:param message: 消息
|
||||
:return: 气泡组件
|
||||
"""
|
||||
color = rx.cond(
|
||||
message.type_ == Type_.TEXT,
|
||||
"accent",
|
||||
rx.cond(
|
||||
message.type_ == Type_.THINKING,
|
||||
"iris",
|
||||
rx.cond(
|
||||
message.type_ == Type_.CALL,
|
||||
"orange",
|
||||
rx.cond(
|
||||
message.type_ == Type_.TOOL_RETURN,
|
||||
"teal",
|
||||
rx.cond(
|
||||
message.type_ == Type_.ERROR,
|
||||
"red",
|
||||
"mauve", # 兜底
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return rx.markdown(
|
||||
message.content,
|
||||
color=rx.color(color=color, shade=12),
|
||||
background_color=rx.color(color=color, shade=4),
|
||||
display="inline-block",
|
||||
padding_inline="1em",
|
||||
padding_block="0.5em",
|
||||
border_radius="8px",
|
||||
margin_bottom="4px",
|
||||
key=message.id,
|
||||
)
|
||||
|
||||
|
||||
def dialog_item_component(dialog: Dialog) -> rx.Component:
|
||||
"""
|
||||
对话项组件
|
||||
:param dialog: 对话
|
||||
:return: Component
|
||||
"""
|
||||
return rx.box(
|
||||
rx.box(
|
||||
input_message_component(message=dialog.input_, color="mauve"),
|
||||
text_align="right",
|
||||
margin_bottom="8px",
|
||||
),
|
||||
rx.box(
|
||||
rx.foreach(dialog.output, output_message_component),
|
||||
text_align="left",
|
||||
margin_bottom="8px",
|
||||
),
|
||||
max_width="50em",
|
||||
margin_inline="auto",
|
||||
key=dialog.id,
|
||||
)
|
||||
|
||||
|
||||
def dialog_list_component() -> rx.Component:
|
||||
"""
|
||||
对话列表组件
|
||||
:return: Component
|
||||
"""
|
||||
return rx.auto_scroll(
|
||||
rx.foreach(ChatState.get_current_chat_dialogs, dialog_item_component),
|
||||
flex="1",
|
||||
padding="8px",
|
||||
overflow_y="auto",
|
||||
)
|
||||
|
||||
|
||||
def input_component() -> rx.Component:
|
||||
"""
|
||||
输入组件
|
||||
"""
|
||||
return rx.center(
|
||||
rx.vstack(
|
||||
rx.form(
|
||||
rx.hstack(
|
||||
rx.input(
|
||||
name="input",
|
||||
placeholder="请输入...",
|
||||
flex="auto",
|
||||
),
|
||||
rx.button(
|
||||
"发送",
|
||||
type="submit",
|
||||
loading=ChatState.get_current_chat_status, # 正在流式输出时按钮显示为 loading
|
||||
disabled=ChatState.get_current_chat_status, # 正在流式输出时按钮禁用
|
||||
),
|
||||
max_width="50em",
|
||||
margin="0 auto",
|
||||
align_items="center",
|
||||
),
|
||||
on_submit=ChatState.process, # 处理输入,返回流式输出
|
||||
reset_on_submit=True, # 提交后清空输入框
|
||||
),
|
||||
rx.text(
|
||||
"抹茶兔兔工作室",
|
||||
text_align="center",
|
||||
font_size=".75em",
|
||||
color=rx.color("mauve", 10),
|
||||
),
|
||||
width="100%",
|
||||
padding_x="16px",
|
||||
align="stretch",
|
||||
),
|
||||
position="sticky",
|
||||
bottom="0",
|
||||
left="0",
|
||||
padding_y="16px",
|
||||
backdrop_filter="auto",
|
||||
backdrop_blur="lg",
|
||||
border_top=f"1px solid {rx.color('mauve', 3)}",
|
||||
background_color=rx.color("mauve", 2),
|
||||
align="stretch",
|
||||
width="100%",
|
||||
) # rx.center 等价 rx.box(display="flex", align_items="center", justify_content="center")
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
导航栏组件
|
||||
"""
|
||||
|
||||
import reflex as rx
|
||||
|
||||
from application.state.chat import ChatState
|
||||
from application.state.create_chat_modal import CreateChatModalState
|
||||
|
||||
|
||||
def create_chat_modal_component(trigger) -> rx.Component:
|
||||
"""
|
||||
新建聊天模态窗组件
|
||||
"""
|
||||
return rx.dialog.root(
|
||||
rx.dialog.trigger(trigger),
|
||||
rx.dialog.content(
|
||||
rx.form(
|
||||
rx.hstack(
|
||||
rx.input(
|
||||
name="chat_description",
|
||||
placeholder="聊天描述",
|
||||
flex="auto",
|
||||
min_width="20ch",
|
||||
),
|
||||
rx.button("新建聊天"),
|
||||
spacing="2",
|
||||
wrap="wrap",
|
||||
width="100%",
|
||||
),
|
||||
on_submit=ChatState.create_chat,
|
||||
),
|
||||
background_color=rx.color("mauve", 1),
|
||||
),
|
||||
open=CreateChatModalState.is_open,
|
||||
on_open_change=CreateChatModalState.toggle,
|
||||
)
|
||||
|
||||
|
||||
def chat_item_component(chat_id: str) -> rx.Component:
|
||||
"""
|
||||
聊天项组件
|
||||
:param chat_id: 聊天唯一标识
|
||||
:return: Component
|
||||
"""
|
||||
return rx.drawer.close(
|
||||
rx.hstack(
|
||||
rx.button(
|
||||
ChatState.get_chat_descriptions[chat_id],
|
||||
on_click=lambda: ChatState.switch_chat(chat_id), # 点击按钮将切换会话
|
||||
width="80%",
|
||||
variant="surface",
|
||||
), # 点击按钮将切换聊天
|
||||
rx.button(
|
||||
rx.icon(
|
||||
tag="trash",
|
||||
on_click=lambda: ChatState.delete_chat(chat_id), # 点击按钮删除聊天
|
||||
stroke_width=1,
|
||||
),
|
||||
width="20%",
|
||||
variant="surface",
|
||||
color_scheme="red",
|
||||
),
|
||||
width="100%",
|
||||
),
|
||||
key=chat_id, # 使用聊天唯一标识作为键
|
||||
)
|
||||
|
||||
|
||||
def chat_list_component(trigger) -> rx.Component:
|
||||
"""
|
||||
聊天列表组件
|
||||
"""
|
||||
return rx.drawer.root(
|
||||
rx.drawer.trigger(trigger),
|
||||
rx.drawer.overlay(),
|
||||
rx.drawer.portal(
|
||||
rx.drawer.content(
|
||||
rx.vstack(
|
||||
rx.heading("聊天列表", color=rx.color("mauve", 11)),
|
||||
rx.divider(),
|
||||
rx.foreach(
|
||||
ChatState.get_chat_ids, # 获取所有聊天唯一标识
|
||||
lambda chat_id: chat_item_component(
|
||||
chat_id=chat_id
|
||||
), # 创建聊天组件
|
||||
),
|
||||
align_items="stretch",
|
||||
width="100%",
|
||||
),
|
||||
top="auto",
|
||||
right="auto",
|
||||
height="100%",
|
||||
width="20em",
|
||||
padding="2em",
|
||||
background_color=rx.color("mauve", 2),
|
||||
outline="none",
|
||||
)
|
||||
),
|
||||
direction="left",
|
||||
)
|
||||
|
||||
|
||||
def navbar_component() -> rx.Component:
|
||||
"""
|
||||
导航栏组件
|
||||
"""
|
||||
return rx.hstack(
|
||||
rx.badge(
|
||||
ChatState.get_chat_descriptions[ChatState.current_chat_id],
|
||||
size="3",
|
||||
variant="soft",
|
||||
margin_inline_end="auto",
|
||||
),
|
||||
create_chat_modal_component(
|
||||
rx.box(rx.tooltip(rx.icon("message-square-plus"), content="新建聊天"))
|
||||
),
|
||||
chat_list_component(
|
||||
rx.box(
|
||||
rx.tooltip(
|
||||
rx.icon("messages-square"),
|
||||
content="聊天历史",
|
||||
)
|
||||
)
|
||||
),
|
||||
justify_content="space-between",
|
||||
align_items="center",
|
||||
padding="12px",
|
||||
border_bottom=f"1px solid {rx.color('mauve', 3)}",
|
||||
background_color=rx.color("mauve", 2),
|
||||
)
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
导航栏组件
|
||||
"""
|
||||
|
||||
import reflex
|
||||
|
||||
from ..backend.state import State
|
||||
|
||||
|
||||
def create_session_modal(trigger) -> reflex.Component:
|
||||
"""
|
||||
创建会话模态窗组件
|
||||
"""
|
||||
return reflex.dialog.root(
|
||||
reflex.dialog.trigger(trigger),
|
||||
reflex.dialog.content(
|
||||
reflex.form(
|
||||
reflex.hstack(
|
||||
reflex.input(
|
||||
name="session_name",
|
||||
placeholder="会话名称",
|
||||
flex="auto",
|
||||
min_width="20ch",
|
||||
),
|
||||
reflex.button("创建会话"),
|
||||
spacing="2",
|
||||
wrap="wrap",
|
||||
width="100%",
|
||||
),
|
||||
on_submit=State.create_session,
|
||||
),
|
||||
background_color=reflex.color("mauve", 1),
|
||||
),
|
||||
open=State.create_session_modal_is_open,
|
||||
on_open_change=State.toggle_create_session_modal,
|
||||
)
|
||||
|
||||
|
||||
def session_history_item(session_name: str) -> reflex.Component:
|
||||
"""
|
||||
会话历史侧边栏中一次会话组件
|
||||
:param session_name: 会话名称
|
||||
:return: Component
|
||||
"""
|
||||
return reflex.drawer.close(
|
||||
reflex.hstack(
|
||||
reflex.button(
|
||||
session_name,
|
||||
on_click=lambda: State.switch_session(
|
||||
session_name
|
||||
), # 点击按钮将切换会话
|
||||
width="80%",
|
||||
variant="surface",
|
||||
),
|
||||
reflex.button(
|
||||
reflex.icon(
|
||||
tag="trash",
|
||||
on_click=lambda: State.delete_session(
|
||||
session_name
|
||||
), # 点击按钮删除会话
|
||||
stroke_width=1,
|
||||
),
|
||||
width="20%",
|
||||
variant="surface",
|
||||
color_scheme="red",
|
||||
),
|
||||
width="100%",
|
||||
),
|
||||
key=session_name, # 使用会话名称作为唯一标识(会话名称不可重复)
|
||||
)
|
||||
|
||||
|
||||
def session_history(trigger) -> reflex.Component:
|
||||
"""
|
||||
会话历史侧边栏组件
|
||||
"""
|
||||
return reflex.drawer.root(
|
||||
reflex.drawer.trigger(trigger),
|
||||
reflex.drawer.overlay(),
|
||||
reflex.drawer.portal(
|
||||
reflex.drawer.content(
|
||||
reflex.vstack(
|
||||
reflex.heading("会话列表", color=reflex.color("mauve", 11)),
|
||||
reflex.divider(),
|
||||
reflex.foreach(
|
||||
State.get_session_names, # 获取所有会话名称
|
||||
lambda session_name: session_history_item(
|
||||
session_name=session_name
|
||||
), # 创建会话组件
|
||||
),
|
||||
align_items="stretch",
|
||||
width="100%",
|
||||
),
|
||||
top="auto",
|
||||
right="auto",
|
||||
height="100%",
|
||||
width="20em",
|
||||
padding="2em",
|
||||
background_color=reflex.color("mauve", 2),
|
||||
outline="none",
|
||||
)
|
||||
),
|
||||
direction="left",
|
||||
)
|
||||
|
||||
|
||||
def navigation_bar() -> reflex.Component:
|
||||
"""
|
||||
导航栏组件
|
||||
"""
|
||||
return reflex.hstack(
|
||||
reflex.badge(
|
||||
State.current_session_name,
|
||||
size="3",
|
||||
variant="soft",
|
||||
margin_inline_end="auto",
|
||||
),
|
||||
create_session_modal(
|
||||
reflex.box(
|
||||
reflex.tooltip(reflex.icon("message-square-plus"), content="创建会话")
|
||||
)
|
||||
),
|
||||
session_history(
|
||||
reflex.box(
|
||||
reflex.tooltip(
|
||||
reflex.icon("messages-square"),
|
||||
content="会话历史",
|
||||
)
|
||||
)
|
||||
),
|
||||
justify_content="space-between",
|
||||
align_items="center",
|
||||
padding="12px",
|
||||
border_bottom=f"1px solid {reflex.color('mauve', 3)}",
|
||||
background_color=reflex.color("mauve", 2),
|
||||
)
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
会话相关组件
|
||||
"""
|
||||
import reflex
|
||||
from reflex.constants.colors import ColorType
|
||||
|
||||
from ..backend.state import State, MessageType, Message, Turn
|
||||
|
||||
|
||||
def input_bubble(message: str, color: ColorType) -> reflex.Component:
|
||||
"""
|
||||
输入消息气泡组件
|
||||
:param message: 消息
|
||||
:param color: 颜色
|
||||
:return: Component
|
||||
"""
|
||||
return reflex.markdown(
|
||||
message,
|
||||
color=reflex.color(color=color, shade=12),
|
||||
background_color=reflex.color(color=color, shade=4),
|
||||
display="inline-block",
|
||||
padding_inline="1em",
|
||||
border_radius="8px",
|
||||
)
|
||||
|
||||
|
||||
def output_bubble(message: Message) -> reflex.Component:
|
||||
"""
|
||||
输出消息气泡组件
|
||||
:param message: 消息
|
||||
:return: 气泡组件
|
||||
"""
|
||||
color = reflex.cond(
|
||||
message.type_ == MessageType.TEXT,
|
||||
"accent",
|
||||
reflex.cond(
|
||||
message.type_ == MessageType.THINKING,
|
||||
"iris",
|
||||
reflex.cond(
|
||||
message.type_ == MessageType.CALL,
|
||||
"orange",
|
||||
reflex.cond(
|
||||
message.type_ == MessageType.TOOL_RETURN,
|
||||
"teal",
|
||||
reflex.cond(
|
||||
message.type_ == MessageType.ERROR,
|
||||
"red",
|
||||
"mauve", # 兜底
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return reflex.markdown(
|
||||
message.content,
|
||||
color=reflex.color(color=color, shade=12),
|
||||
background_color=reflex.color(color=color, shade=4),
|
||||
display="inline-block",
|
||||
padding_inline="1em",
|
||||
padding_block="0.5em",
|
||||
border_radius="8px",
|
||||
margin_bottom="4px",
|
||||
key=message.id,
|
||||
)
|
||||
|
||||
|
||||
def turn(turn: Turn) -> reflex.Component:
|
||||
"""
|
||||
一轮对话组件
|
||||
:param turn: 对话
|
||||
:return: Component
|
||||
"""
|
||||
return reflex.box(
|
||||
reflex.box(
|
||||
input_bubble(message=turn.input_, color="mauve"),
|
||||
text_align="right",
|
||||
margin_bottom="8px",
|
||||
),
|
||||
reflex.box(
|
||||
reflex.foreach(turn.output, output_bubble),
|
||||
text_align="left",
|
||||
margin_bottom="8px",
|
||||
),
|
||||
max_width="50em",
|
||||
margin_inline="auto",
|
||||
key=turn.id,
|
||||
)
|
||||
|
||||
|
||||
def session_area() -> reflex.Component:
|
||||
"""
|
||||
会话区域组件
|
||||
:return: Component
|
||||
"""
|
||||
return reflex.auto_scroll(
|
||||
reflex.foreach(State.get_current_session_turns, turn),
|
||||
flex="1",
|
||||
padding="8px",
|
||||
overflow_y="auto",
|
||||
)
|
||||
|
||||
|
||||
def input_bar() -> reflex.Component:
|
||||
"""
|
||||
输入栏组件
|
||||
"""
|
||||
return reflex.center(
|
||||
reflex.vstack(
|
||||
reflex.form(
|
||||
reflex.hstack(
|
||||
reflex.input(
|
||||
name="input",
|
||||
placeholder="请输入...",
|
||||
flex="auto",
|
||||
),
|
||||
reflex.button(
|
||||
"发送",
|
||||
type="submit",
|
||||
loading=State.get_current_session_status, # 正在处理中时按钮显示为 loading
|
||||
disabled=State.get_current_session_status, # 正在处理中时按钮禁用
|
||||
),
|
||||
max_width="50em",
|
||||
margin="0 auto",
|
||||
align_items="center",
|
||||
),
|
||||
on_submit=State.adapt_input,
|
||||
reset_on_submit=True, # 提交后清空输入框
|
||||
),
|
||||
reflex.text(
|
||||
"抹茶兔兔工作室",
|
||||
text_align="center",
|
||||
font_size=".75em",
|
||||
color=reflex.color("mauve", 10),
|
||||
),
|
||||
width="100%",
|
||||
padding_x="16px",
|
||||
align="stretch",
|
||||
),
|
||||
position="sticky",
|
||||
bottom="0",
|
||||
left="0",
|
||||
padding_y="16px",
|
||||
backdrop_filter="auto",
|
||||
backdrop_blur="lg",
|
||||
border_top=f"1px solid {reflex.color('mauve', 3)}",
|
||||
background_color=reflex.color("mauve", 2),
|
||||
align="stretch",
|
||||
width="100%",
|
||||
) # reflex.center 等价 reflex.box(display="flex", align_items="center", justify_content="center")
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据模型
|
||||
"""
|
||||
from enum import StrEnum
|
||||
from time import time_ns
|
||||
from typing import List
|
||||
from typing import Annotated
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
|
||||
from sqlmodel import SQLModel, Field as SQLField
|
||||
|
||||
|
||||
# 数据库表模型
|
||||
class HistoryMessage(SQLModel, table=True):
|
||||
id: int = SQLField(default_factory=int, primary_key=True)
|
||||
chat_id: str
|
||||
new_message: str
|
||||
timestamp: int
|
||||
|
||||
@staticmethod
|
||||
def adapt(chat_id: str, new_message: List[ModelMessage]) -> "HistoryMessage":
|
||||
return HistoryMessage(
|
||||
chat_id=chat_id,
|
||||
new_message=ModelMessagesTypeAdapter.dump_json(new_message).decode("utf-8"),
|
||||
timestamp=time_ns() // 1000, # 微秒级时间戳
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
聊天、对话和消息关系:
|
||||
一次聊天包含若干轮对话,每轮对话包含一条输入消息(input_message)和若干条输出消息(output_messages)。其中,输入消息和输出消息合称消息(message)。
|
||||
"""
|
||||
|
||||
|
||||
class Type_(StrEnum):
|
||||
"""消息类型类"""
|
||||
|
||||
THINKING = "thinking"
|
||||
TEXT = "text"
|
||||
CALL = "call"
|
||||
TOOL_ARGS = "tool_args"
|
||||
TOOL_RETURN = "tool_return"
|
||||
RESULT = "result"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# 前缀映射表(动态生成)
|
||||
PREFIX_MAPING = {f"{i:02d}:": t for i, t in enumerate(Type_)}
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""消息类"""
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, description="消息唯一标识")
|
||||
type_: Type_ = Field(..., description="消息类型")
|
||||
content: str = Field(default="", description="消息内容")
|
||||
|
||||
|
||||
class Dialog(BaseModel):
|
||||
"""对话类"""
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, description="对话唯一标识")
|
||||
input_: str = Field(..., description="输入消息")
|
||||
output: List[Message] = Field(default_factory=list, description="输出消息")
|
||||
|
||||
|
||||
class Chat(BaseModel):
|
||||
"""聊天类"""
|
||||
|
||||
description: str = Field(default="新聊天", description="描述")
|
||||
is_streaming: bool = Field(
|
||||
default=False,
|
||||
description="流式输出状态,True 表示正在流式输出,False 表示非正在流式输出",
|
||||
)
|
||||
dialogs: List[Dialog] = Field(default_factory=list, description="对话列表")
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
会话页面
|
||||
"""
|
||||
|
||||
import reflex
|
||||
|
||||
from ..frontend.navigation_bar import navigation_bar
|
||||
from ..frontend.session import session_area, input_bar
|
||||
|
||||
|
||||
def index() -> reflex.Component:
|
||||
"""根页面"""
|
||||
return reflex.vstack(
|
||||
navigation_bar(), # 导航栏
|
||||
session_area(), # 会话区域
|
||||
input_bar(), # 输入栏
|
||||
color=reflex.color(color="mauve", shade=12),
|
||||
background_color=reflex.color(color="mauve", shade=1),
|
||||
height="100dvh",
|
||||
align_items="stretch",
|
||||
spacing="0",
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
会话页面
|
||||
"""
|
||||
|
||||
import reflex as rx
|
||||
|
||||
from ..components.navbar import navbar_component
|
||||
from ..components.chat import dialog_list_component, input_component
|
||||
|
||||
|
||||
def index_page() -> rx.Component:
|
||||
"""根页面"""
|
||||
return rx.vstack(
|
||||
navbar_component(), # 导航栏组件
|
||||
dialog_list_component(), # 对话列表组件
|
||||
input_component(), # 输入组件
|
||||
color=rx.color(color="mauve", shade=12),
|
||||
background_color=rx.color(color="mauve", shade=1),
|
||||
height="100dvh",
|
||||
align_items="stretch",
|
||||
spacing="0",
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from application.state.chat import ChatState
|
||||
from application.state.create_chat_modal import CreateChatModalState
|
||||
from application.state.global_ import GlobalState
|
||||
|
||||
__all__ = ["GlobalState", "ChatState", "CreateChatModalState"]
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
聊天页面状态
|
||||
"""
|
||||
from typing import Any, AsyncGenerator, Dict, List
|
||||
from uuid import uuid4
|
||||
|
||||
import reflex as rx
|
||||
from application.utils.agent import Agent
|
||||
from application.models import Chat, Dialog, Message, PREFIX_MAPING
|
||||
from application.state.create_chat_modal import CreateChatModalState
|
||||
from application.state.global_ import GlobalState
|
||||
|
||||
|
||||
# 绑定的智能体列表(键为聊天唯一标识,值为智能体实例)
|
||||
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,
|
||||
instructions="You are a friendly chatbot",
|
||||
)
|
||||
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_chat_ids(self) -> List[str]:
|
||||
"""
|
||||
获取聊天唯一标识列表
|
||||
:return: 聊天唯一标识列表
|
||||
"""
|
||||
return list(self.chats)
|
||||
|
||||
@rx.var
|
||||
def get_chat_descriptions(self) -> Dict[str, str]:
|
||||
"""
|
||||
获取聊天描述列表
|
||||
:return: 聊天描述列表
|
||||
"""
|
||||
return {chat_id: chat.description for chat_id, chat in self.chats.items()}
|
||||
|
||||
@rx.var
|
||||
def get_current_chat_status(self) -> bool:
|
||||
"""
|
||||
获取当前聊天流式输出状态
|
||||
:return: 当前聊天流式输出状态(True 表示正在流式输出,False 表示非正在流式输出)
|
||||
"""
|
||||
chat = self.chats.get(self.current_chat_id)
|
||||
return chat.is_streaming if chat else False
|
||||
|
||||
@rx.var
|
||||
def get_current_chat_dialogs(self) -> List[Dialog]:
|
||||
"""
|
||||
获取当前聊天对话列表
|
||||
:return: 当前聊天对话列表
|
||||
"""
|
||||
chat = self.chats.get(self.current_chat_id)
|
||||
return chat.dialogs if 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_modal_state = await self.get_state(CreateChatModalState)
|
||||
create_chat_modal_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 process(self, form_data: dict[str, Any]) -> AsyncGenerator:
|
||||
"""
|
||||
处理输入,返回流式输出
|
||||
:param form_data: 表单数据
|
||||
:return: AsyncGenerator
|
||||
"""
|
||||
input_ = form_data["input"].strip()
|
||||
if not input_:
|
||||
return
|
||||
|
||||
# 当前聊天
|
||||
current_chat = self.chats[self.current_chat_id]
|
||||
# 新增对话
|
||||
current_chat.dialogs.append(Dialog(input_=input_))
|
||||
# 当前对话
|
||||
current_dialog = current_chat.dialogs[-1]
|
||||
|
||||
current_chat.is_streaming = True
|
||||
yield # 通知前端渲染输入消息
|
||||
|
||||
# 全局状态
|
||||
global_state = await self.get_state(GlobalState)
|
||||
# 获取当前聊天绑定的智能体
|
||||
agent = get_agent(chat_id=self.current_chat_id)
|
||||
# 获取当前聊天消息历史
|
||||
message_history = await global_state.get_message_history(
|
||||
chat_id=self.current_chat_id
|
||||
)
|
||||
|
||||
# 流式输出消息事件
|
||||
async for event in agent.stream_messages_events(
|
||||
user_prompt=input_, 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
|
||||
|
||||
type_ = PREFIX_MAPING[prefix]
|
||||
# 若当前对话输出消息为空或当前消息类型与新消息类型不相同则新增消息
|
||||
if not current_dialog.output or current_dialog.output[-1].type_ != type_:
|
||||
current_dialog.output.append(Message(type_=type_))
|
||||
|
||||
# 追加消息内容
|
||||
current_dialog.output[-1].content += event.removeprefix(prefix)
|
||||
yield # 通知前端渲染输出消息
|
||||
|
||||
# 保存本轮对话消息
|
||||
await global_state.save_new_message(
|
||||
chat_id=self.current_chat_id, new_message=agent.new_messages
|
||||
)
|
||||
current_chat.is_streaming = False
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
创建聊天模态窗状态
|
||||
"""
|
||||
import reflex as rx
|
||||
|
||||
|
||||
class CreateChatModalState(rx.State):
|
||||
"""
|
||||
新建聊天模态窗状态
|
||||
"""
|
||||
|
||||
# 新建聊天模态窗打开状态,True表示打开,False表示关闭
|
||||
is_open: bool = False
|
||||
|
||||
@rx.event
|
||||
def toggle(self, is_open: bool) -> None:
|
||||
"""
|
||||
打开/关闭模态窗
|
||||
"""
|
||||
self.is_open = is_open
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
全局共享状态
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
from sqlmodel import select as sql_select
|
||||
import reflex as rx
|
||||
import sqlalchemy as sa
|
||||
|
||||
from application.models import (
|
||||
HistoryMessage,
|
||||
ModelMessage,
|
||||
ModelMessagesTypeAdapter,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class GlobalState(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(HistoryMessage)
|
||||
.where(HistoryMessage.chat_id == chat_id)
|
||||
.order_by(sa.desc("timestamp"))
|
||||
)
|
||||
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 = HistoryMessage.adapt(chat_id=chat_id, new_message=new_message)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from application.utils.agent import Agent, Buffer, Debouncer
|
||||
|
||||
__all__ = ["Agent", "Debouncer", "Buffer"]
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pydantic AI 聊天智能体和相关模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
from asyncio import Queue, QueueEmpty, Task, Task, create_task, sleep
|
||||
from typing import AsyncGenerator, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel
|
||||
from pydantic_ai import Agent as PydanticAIAgent, ModelMessage
|
||||
from pydantic_ai.capabilities import AgentCapability
|
||||
from pydantic_ai.messages import (
|
||||
AgentStreamEvent,
|
||||
FinalResultEvent,
|
||||
LoadCapabilityCallPart,
|
||||
ModelMessage,
|
||||
NativeToolCallPart,
|
||||
NativeToolReturnPart,
|
||||
NativeToolSearchCallPart,
|
||||
PartDeltaEvent,
|
||||
PartEndEvent,
|
||||
PartStartEvent,
|
||||
TextPart,
|
||||
TextPartDelta,
|
||||
ThinkingPart,
|
||||
ThinkingPartDelta,
|
||||
ToolCallPart,
|
||||
ToolCallPartDelta,
|
||||
ToolReturnPart,
|
||||
ToolSearchCallPart,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class Buffer(BaseModel):
|
||||
"""
|
||||
模型消息事件缓冲类
|
||||
"""
|
||||
|
||||
part_type: Literal["thinking", "text"] = Field(..., description="片段类型")
|
||||
content_delta: str = Field(default="", description="片段内容增量")
|
||||
task: Optional[Task] = Field(default=None, description="延迟刷新异步协程任务")
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class Debouncer:
|
||||
"""
|
||||
防抖器
|
||||
用于处理 run_stream_events 返回的模型消息事件
|
||||
"""
|
||||
|
||||
def __init__(self, delay: float = 0.25):
|
||||
"""
|
||||
初始化
|
||||
:param delay: 延迟时长(单位为秒),停顿超该时长则刷新
|
||||
"""
|
||||
self.delay = delay
|
||||
|
||||
# 模型消息事件缓冲字典(数据类型为字典,键为片段索引,值为缓冲模型)
|
||||
self.buffers: Dict[int, Buffer] = {}
|
||||
# 待刷新异步队列
|
||||
self.pending_flush_queue = Queue()
|
||||
|
||||
async def handle_event(
|
||||
self, event: Union[AgentStreamEvent, AgentRunResultEvent]
|
||||
) -> AsyncGenerator[Union[AgentStreamEvent, AgentRunResultEvent]]:
|
||||
"""
|
||||
处理模型消息事件
|
||||
在 Pydantic-AI 中模型流式输出包含若干类型片段,例如思考片段、文本片段等。每类型片段包含若干模型消息事件,例如片段开始事件、片段增量事件、片段结束事件等
|
||||
:param event: 模型消息事件
|
||||
:yield: AsyncGenerator
|
||||
"""
|
||||
# 返回待刷新异步队列中模型消息事件
|
||||
while True:
|
||||
try:
|
||||
yield self.pending_flush_queue.get_nowait()
|
||||
except QueueEmpty:
|
||||
break
|
||||
|
||||
# 若为思考或文本的片段开始事件则先缓存片段再返回,其它事件直接返回
|
||||
if isinstance(event, PartStartEvent):
|
||||
index, part = event.index, event.part # 片段索引、片段
|
||||
match part:
|
||||
case ThinkingPart():
|
||||
part_type = "thinking"
|
||||
case TextPart():
|
||||
part_type = "text"
|
||||
case _:
|
||||
yield event
|
||||
return
|
||||
# 新增片段缓存
|
||||
self.buffers[index] = Buffer(
|
||||
part_type=part_type,
|
||||
)
|
||||
yield event
|
||||
return
|
||||
|
||||
"""
|
||||
原理:
|
||||
收到思考或文本片段增量事件时取消未完成的延迟刷新任务、追加增量并重设;若模型持续输出则先缓存,停顿超过阈值再返回,实现防抖
|
||||
"""
|
||||
if isinstance(event, PartDeltaEvent):
|
||||
index, delta = event.index, event.delta
|
||||
buffer = self.buffers.get(index)
|
||||
if buffer and isinstance(delta, (ThinkingPartDelta, TextPartDelta)):
|
||||
# 追加增量
|
||||
buffer.content_delta += delta.content_delta or ""
|
||||
|
||||
# 取消上一轮未完成的延迟刷新任务
|
||||
self._cancel_task(index=index)
|
||||
# 创建延迟刷新任务
|
||||
buffer.task = create_task(coro=self._delay_flush(index=index))
|
||||
else:
|
||||
yield event
|
||||
return
|
||||
|
||||
# 若为其它事件则先批量刷新片段增量事件再返回该事件
|
||||
async for event_ in self._batch_flush():
|
||||
yield event_
|
||||
# 若为片段结束事件则删除该片段缓存
|
||||
if isinstance(event, PartEndEvent):
|
||||
self.buffers.pop(event.index, None)
|
||||
yield event
|
||||
|
||||
def _cancel_task(self, index: int) -> None:
|
||||
"""
|
||||
取消延迟刷新异步协程任务
|
||||
:param index: 片段索引
|
||||
:return: None
|
||||
"""
|
||||
buffer = self.buffers.get(index)
|
||||
if not buffer or not buffer.task:
|
||||
return
|
||||
if not buffer.task.done():
|
||||
buffer.task.cancel()
|
||||
buffer.task = None
|
||||
|
||||
async def _delay_flush(self, index: int) -> None:
|
||||
"""
|
||||
延迟刷新
|
||||
:param index: 片段索引
|
||||
:return: None
|
||||
"""
|
||||
await sleep(delay=self.delay)
|
||||
|
||||
# 刷新片段增量事件
|
||||
event = await self._flush(index=index)
|
||||
if event:
|
||||
await self.pending_flush_queue.put(item=event)
|
||||
|
||||
async def _flush(self, index: int) -> Optional[PartDeltaEvent]:
|
||||
"""
|
||||
刷新片段增量事件
|
||||
:param index: 片段索引
|
||||
:return: Optional[PartDeltaEvent]
|
||||
"""
|
||||
buffer = self.buffers.get(index)
|
||||
if not buffer or not buffer.content_delta:
|
||||
return
|
||||
|
||||
# 构建片段增量事件中增量部分
|
||||
match buffer.part_type:
|
||||
case "thinking":
|
||||
delta = ThinkingPartDelta(content_delta=buffer.content_delta)
|
||||
case "text":
|
||||
delta = TextPartDelta(content_delta=buffer.content_delta)
|
||||
|
||||
buffer.content_delta = ""
|
||||
buffer.task = None
|
||||
return PartDeltaEvent(index=index, delta=delta)
|
||||
|
||||
async def _batch_flush(self) -> AsyncGenerator[PartDeltaEvent, None]:
|
||||
"""
|
||||
批量刷新片段增量事件
|
||||
:yield: AsyncGenerator
|
||||
"""
|
||||
for index in list(self.buffers.keys()):
|
||||
event = await self._flush(index=index)
|
||||
if event:
|
||||
yield event
|
||||
|
||||
|
||||
class Agent:
|
||||
"""
|
||||
Pydantic AI 智能体
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chat_id: str,
|
||||
instructions: str,
|
||||
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),每一轮对话由输入消息(message)和输出消息(message)组成
|
||||
self.new_messages: List[ModelMessage] = []
|
||||
|
||||
# 实例智能体
|
||||
self.agent = PydanticAIAgent(
|
||||
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 stream_messages_events(
|
||||
self,
|
||||
user_prompt: str | List[str],
|
||||
message_history: Optional[List[ModelMessage]] = None,
|
||||
delay: float = 0.2,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
流式输出消息事件
|
||||
:param user_prompt: 用户提示词(用户输入消息)
|
||||
:param delay: 延迟时长(单位为秒),停顿超该时长则刷新
|
||||
:yield: AsyncGenerator
|
||||
"""
|
||||
# 实例防抖器
|
||||
debouncer = Debouncer(delay=delay)
|
||||
|
||||
async with self.agent.run_stream_events(
|
||||
user_prompt=user_prompt,
|
||||
message_history=message_history,
|
||||
) as events:
|
||||
async for event in events:
|
||||
# 处理模型消息事件
|
||||
async for event in debouncer.handle_event(event):
|
||||
match event:
|
||||
# 片段开始事件
|
||||
case PartStartEvent(part=part):
|
||||
match part:
|
||||
case ThinkingPart(content=content):
|
||||
yield f"00:{content}"
|
||||
case TextPart(content=content):
|
||||
yield f"01:{content}"
|
||||
case (
|
||||
NativeToolSearchCallPart(tool_name=tool_name)
|
||||
| NativeToolCallPart(tool_name=tool_name)
|
||||
| ToolSearchCallPart(tool_name=tool_name)
|
||||
| ToolCallPart(tool_name=tool_name)
|
||||
| LoadCapabilityCallPart(tool_name=tool_name)
|
||||
):
|
||||
yield f"02:正在使用技能:{tool_name}"
|
||||
case NativeToolReturnPart(
|
||||
content=content
|
||||
) | ToolReturnPart(content=content):
|
||||
yield f"04:技能返回:{content}"
|
||||
case _:
|
||||
yield f"06:未知片段类型{type(part).__name__}"
|
||||
# 片段增量事件
|
||||
case PartDeltaEvent(delta=delta):
|
||||
match delta:
|
||||
case ThinkingPartDelta(content_delta=content_delta):
|
||||
yield f"00:{content_delta}"
|
||||
case TextPartDelta(content_delta=content_delta):
|
||||
yield f"01:{content_delta}"
|
||||
case ToolCallPartDelta(args_delta=args_delta):
|
||||
yield f"03:技能参数:{args_delta}"
|
||||
case _:
|
||||
yield f"06:未知片段类型{type(delta).__name__}"
|
||||
# 片段结束事件、最终结果事件无需处理
|
||||
case PartEndEvent():
|
||||
continue
|
||||
case FinalResultEvent():
|
||||
continue
|
||||
case AgentRunResultEvent(result=result):
|
||||
self.new_messages = result.new_messages()
|
||||
yield "05:FinalResultEvent"
|
||||
case _:
|
||||
yield f"06:未知事件类型{type(event).__name__}"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
主运行模块
|
||||
"""
|
||||
|
||||
# 列举导入模块
|
||||
import uvicorn
|
||||
|
||||
from utils.agent import BaseAgent
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 实例智能体
|
||||
agent = BaseAgent(
|
||||
instructions="使用提供的技能回答问题。", skill_name="requirements-analysis"
|
||||
)
|
||||
uvicorn.run(app=agent.start_web_service(), host="127.0.0.1", port=7932)
|
||||
|
|
@ -1,13 +1,25 @@
|
|||
import reflex
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
全局配置
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import reflex as rx
|
||||
from reflex.plugins import RadixThemesPlugin
|
||||
from reflex_base.plugins.sitemap import SitemapPlugin
|
||||
|
||||
config = reflex.Config(
|
||||
# 构建数据库路径(使用 SQLite 作为数据库)
|
||||
database_path = Path(__file__).parent / "database.db"
|
||||
|
||||
config = rx.Config(
|
||||
app_name="application",
|
||||
db_url=f"sqlite:///{database_path.as_posix()}",
|
||||
async_db_url=f"sqlite+aiosqlite:///{database_path.as_posix()}",
|
||||
disable_plugins=[SitemapPlugin],
|
||||
plugins=[
|
||||
RadixThemesPlugin(
|
||||
theme=reflex.theme(
|
||||
theme=rx.theme(
|
||||
appearance="dark", accent_color="purple" # 暗黑模式 # 主题色
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue