This commit is contained in:
liubiren 2026-06-22 20:07:05 +08:00
parent 9f03de61ab
commit 696d91be47
25 changed files with 1256 additions and 569 deletions

View File

@ -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

View File

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

View File

@ -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()

View File

@ -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"}

View File

@ -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 ###

View File

@ -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="/")

View File

@ -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

View File

@ -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")

View File

@ -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),
)

View File

@ -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),
)

View File

@ -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")

View File

@ -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="对话列表")

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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()

View File

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

View File

@ -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__}"

View File

@ -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)

View File

@ -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" # 暗黑模式 # 主题色
)
)