This commit is contained in:
liubiren 2026-07-17 19:59:15 +08:00
parent 6eb839fd9d
commit b74fa0ca34
14 changed files with 527 additions and 229 deletions

View File

@ -0,0 +1,67 @@
"""empty message
Revision ID: 1595751865c1
Revises: 51f049b860bb
Create Date: 2026-07-17 16:28:58.025029
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1595751865c1'
down_revision: Union[str, Sequence[str], None] = '51f049b860bb'
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('captchas',
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('captcha', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_valid', sa.Boolean(), nullable=False),
sa.Column('is_unused', sa.Boolean(), nullable=False),
sa.Column('expired_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('email', 'expired_at')
)
with op.batch_alter_table('captchas', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_captchas_captcha'), ['captcha'], unique=False)
batch_op.create_index(batch_op.f('ix_captchas_is_unused'), ['is_unused'], unique=False)
batch_op.create_index(batch_op.f('ix_captchas_is_valid'), ['is_valid'], unique=False)
op.create_table('users',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False)
with op.batch_alter_table('conversations', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_conversations_is_deleted'), ['is_deleted'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('conversations', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_conversations_is_deleted'))
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users')
with op.batch_alter_table('captchas', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_captchas_is_valid'))
batch_op.drop_index(batch_op.f('ix_captchas_is_unused'))
batch_op.drop_index(batch_op.f('ix_captchas_captcha'))
op.drop_table('captchas')
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 32268fce88c5
Revises: 1595751865c1
Create Date: 2026-07-17 16:31:01.794850
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '32268fce88c5'
down_revision: Union[str, Sequence[str], None] = '1595751865c1'
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! ###
with op.batch_alter_table('captchas', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_captchas_captcha'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('captchas', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_captchas_captcha'), ['captcha'], unique=False)
# ### end Alembic commands ###

View File

@ -20,16 +20,16 @@ app = rx.App(
"*::before, *::after": {
"box_sizing": "border-box",
},
"--prismui-background-1": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)",
"--prismui-background-color-1": "#ffffff", # 纯白背景色
"--prismui-background-color-2": "rgb(235, 235, 235)",
"--prismui-background-color-2": "#ebebeb",
"--prismui-background-color-3": "#f6f6f8",
"--prismui-background-color-4": "#babbc0",
"--prismui-background-1": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)",
"--mc-float-block-shadow": "rgba(213, 213, 213, 0.25)",
"--mc-float-block-shadow": "#d5d5d5",
"--devui-global-bg": "#f6f6f8",
"--mc-global-bg": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)",
"--mc-box-shadow": "rgba(25, 25, 25, .06)",
"--devui-shadow-length-connected-overlay": "0px 0px 12px 0px rgba(0, 0, 0, 0.1)",
"--mc-box-shadow": "#191919",
"--devui-shadow-length-connected-overlay": "0px 0px 12px 0px #000000",
"--prismui-box-shadow-1": "0 4px 12px #0000001a", # 导航按钮阴影
"--devui-icon-fill-weak": "#babbc0",
"--prismui-background-color-weak": "#babbc0",

View File

@ -1,4 +1 @@
# -*- coding: utf-8 -*-
from application.components.sidebar import sidebar
__all__ = ["sidebar"]

View File

@ -1,13 +1,21 @@
# -*- coding: utf-8 -*-
from application.models.datatables import Conversations, Dialogs, RunResults
from application.models.tables import (
Captchas,
Conversations,
Dialogs,
RunResults,
Users,
)
from application.models.memories import Conversation, Dialog, ThoughtNode
__all__ = [
"Captchas",
"Conversation",
"Conversations",
"Dialog",
"Dialogs",
"RunResults",
"ThoughtNode",
"Users",
]

View File

@ -1,14 +1,47 @@
# -*- coding: utf-8 -*-
"""
数据表模型
表
"""
from datetime import datetime
from datetime import datetime, timedelta
from pydantic_ai._uuid import uuid7
from sqlmodel import Field, JSON, SQLModel
from random import choices
# 会话数据表
# 验证码表
class Captchas(SQLModel, table=True):
email: str = Field(..., primary_key=True, description="邮箱")
captcha: str = Field(
default_factory=lambda: "".join(choices("0123456789", k=6)),
description="验证码",
)
is_valid: bool = Field(
default=True, index=True, description="是否有效,True 表示有效,False 表示无效"
)
is_unused: bool = Field(
default=True,
index=True,
description="是否未使用,True 表示未使用,False 表示已使用。",
)
expired_at: datetime = Field(
default_factory=lambda: datetime.now() + timedelta(minutes=30),
primary_key=True,
description="过期时间",
)
# 用户表
class Users(SQLModel, table=True):
id: str = Field(
default_factory=lambda: str(uuid7()),
primary_key=True,
description="用户唯一标识",
)
email: str = Field(..., index=True, description="邮箱")
# 会话表
class Conversations(SQLModel, table=True):
id: str = Field(
default_factory=lambda: str(uuid7()),
@ -17,18 +50,18 @@ class Conversations(SQLModel, table=True):
)
user_id: str = Field(..., index=True, description="用户唯一标识")
description: str = Field(..., description="会话描述")
is_deleted: bool = Field(default=False, description="是否删除")
is_deleted: bool = Field(default=False, index=True, description="是否删除")
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
# 思考节点数据表(仅用于定义)
# 思考节点表(仅定义)
class ThoughtNodes(SQLModel):
kind: str = Field(description="思考节点类型")
content: str = Field(default="", description="思考节点内容")
# 对话数据表
# 对话表
class Dialogs(SQLModel, table=True):
id: str = Field(
default_factory=lambda: str(uuid7()),
@ -43,7 +76,7 @@ class Dialogs(SQLModel, table=True):
answer: str = Field(default="", description="回答")
# 运行结果数据表
# 运行结果表
class RunResults(SQLModel, table=True):
conversation_id: str = Field(primary_key=True, description="会话唯一标识")
dialog_id: str = Field(primary_key=True, description="对话唯一标识")

View File

@ -5,11 +5,7 @@
import reflex as rx
from application.models import Conversation, Dialog, ThoughtNode
from application.states import (
ConversationHistoryCollapseState,
ConversationState,
CreateConversationState,
)
from application.states import ConversationState
def render_conversation_history_item(
@ -602,8 +598,8 @@ def render_create_conversation_button() -> rx.Component:
),
background_color=rx.color("mauve", 1),
),
open=CreateConversationState.is_modal_open,
on_open_change=CreateConversationState.toggle_modal,
open=ConversationState.is_creating_conversation,
on_open_change=ConversationState.toggle_creating_conversation,
),
display="flex",
width="100%",
@ -781,7 +777,7 @@ def render_conversation_history_collapse_button(
transition="all 0.3s ease-in-out",
cursor="pointer",
# 点击事件:切换会话历史折叠面板展开状态
on_click=ConversationHistoryCollapseState.toggle_expanded,
on_click=ConversationState.toggle_conversation_history_shown,
)
@ -790,7 +786,7 @@ def render_conversation() -> rx.Component:
渲染会话页面:布局参考 MetaChat,左侧为会话历史,右侧为对话历史和输入框
"""
# 会话历史折叠面板的展开状态
is_expanded = ConversationHistoryCollapseState.is_expanded
is_expanded = ConversationState.is_conversation_history_shown
return rx.box(
# 渲染折叠面板

View File

@ -6,7 +6,7 @@ import reflex as rx
from application.pages import render_conversation, render_library
from application.pages.conversation import render_conversation
from application.states import AuthState, SidebarState
from application.states import AuthState
# 导航按钮字典
@ -30,7 +30,7 @@ def render_nav_button(
:return : Component
"""
# 导航按钮激活状态
is_actived = nav_button == SidebarState.activated_nav_button
is_actived = nav_button == AuthState.activated_nav_button
return rx.vstack(
# 渲染图标
@ -56,8 +56,8 @@ def render_nav_button(
align_items="center",
gap="4px",
cursor="pointer",
# 点击事件:将指定导航按钮设置为侧边栏中激活的导航按钮
on_click=SidebarState.switch_nav_button(nav_button),
# 点击事件:将指定导航按钮设置为激活的导航按钮
on_click=AuthState.switch_activated_nav_button(nav_button),
)
@ -116,9 +116,9 @@ def render_sidebar() -> rx.Component:
)
def render_login_modal() -> rx.Component:
def render_login_window() -> rx.Component:
"""
渲染登录模态窗
渲染登录窗
:return: Component
"""
@ -145,23 +145,24 @@ def render_login_modal() -> rx.Component:
flex=1,
),
rx.button(
rx.cond(
AuthState.captcha_sending_countdown > 0,
f"{AuthState.captcha_sending_countdown} s111111",
"获取验证码",
),
variant="outline",
width="120px",
on_click=AuthState.send_email_code,
on_click=AuthState.send_captcha,
disabled=AuthState.captcha_sending_countdown > 0,
),
gap="8px",
width="100%",
),
rx.cond(
AuthState.email == "",
rx.text("请输入邮箱地址", color="red", font_size="12px"),
rx.box(height="16px"),
),
rx.text(AuthState.notification, color="red", font_size="12px"),
rx.hstack(
rx.checkbox(
checked=AuthState.agree_policy,
on_change=AuthState.toggle_agree,
checked=AuthState.is_policies_agreed,
on_change=AuthState.toggle_policies_agreed,
),
rx.text("我已阅读并同意 ", font_size="13px"),
rx.link("用户协议、隐私政策", font_size="13px", href="#"),
@ -173,7 +174,9 @@ def render_login_modal() -> rx.Component:
bg="#0a0e29",
color="white",
margin_top="12px",
on_click=AuthState.login_by_email_code,
on_click=AuthState.login,
loading=AuthState.is_logging_in,
disabled=AuthState.is_logging_in,
),
rx.text(
"登录有问题?去反馈",
@ -222,7 +225,7 @@ def render_login_modal() -> rx.Component:
def render_index() -> rx.Component:
"""
首页
渲染首页
"""
return rx.fragment(
@ -231,7 +234,7 @@ def render_index() -> rx.Component:
render_sidebar(),
# 根据侧边栏状态中激活的导航按钮渲染相应页面(本项目采用卡片布局)
rx.match(
SidebarState.activated_nav_button,
AuthState.activated_nav_button,
# 渲染知识库页面
("library", render_library()),
# 渲染会话页面
@ -245,9 +248,10 @@ def render_index() -> rx.Component:
# 挂载事件:
on_mount=AuthState.check,
),
# 若用户唯一标识为空则渲染登录窗
rx.cond(
AuthState.user_id == "",
render_login_modal(),
render_login_window(),
rx.fragment(),
),
)

View File

@ -2,12 +2,20 @@
"""
认证状态
"""
from asyncio import sleep, create_task
from email.header import Header
from email.mime.text import MIMEText
import re
from typing import Any, AsyncGenerator, Coroutine, cast
from aiosmtplib import SMTP, SMTPException
import reflex as rx
import re
from sqlmodel import select
from application.models.user import EmailVerifyCode
from application.database.base import get_async_db
from application.states.database import DatabaseState
# 验证码HTML模板
CAPTCHA_HTML_TEMPLATE = """
<p>验证码:<strong>{captcha}</strong></p>
"""
class AuthState(rx.State):
@ -15,17 +23,34 @@ class AuthState(rx.State):
认证状态
"""
# 当前登录的用户唯一标识(通过本地存储同步)
# 用户唯一标识(通过本地存储同步)
user_id: str = rx.LocalStorage("user_id", sync=True)
# 邮箱
email: str = ""
# 验证码
captcha: str = ""
# 协议同意状态
is_policies_agreed: bool = False
# 提示信息
notification: str = ""
# 验证码发送倒计时
captcha_sending_countdown: int = 0
# 验证码发送中状态
is_captcha_sending: bool = False
# 登录中状态
is_logging_in: bool = False
# 激活的导航按钮
activated_nav_button: str = "conversation"
@rx.event
async def check(self) -> None:
"""
检查认证状态
检查
"""
self.user_id = ""
@ -56,94 +81,142 @@ class AuthState(rx.State):
"""
self.is_policies_agreed = not self.is_policies_agreed
@rx.event
async def send_email_code(self):
"""生成并存储邮箱验证码(异步数据库,不阻塞事件循环)"""
email_rule = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.fullmatch(email_rule, self.email):
return rx.toast("请输入合法邮箱地址", color="red")
# 异步数据库会话
async for db in get_async_db():
# 清理该邮箱旧验证码(await 异步查询)
old_codes = await db.exec(
select(EmailVerifyCode).where(EmailVerifyCode.email == self.email)
)
old_list = old_codes.all()
for item in old_list:
await db.delete(item)
# 6位验证码,5分钟有效期
import random
from datetime import datetime, timedelta
code_str = "".join(random.choices("0123456789", k=6))
expire_time = datetime.utcnow() + timedelta(minutes=5)
db.add(
EmailVerifyCode(email=self.email, code=code_str, expire_at=expire_time)
)
await db.commit()
# 本地调试打印验证码
print(f"邮箱 {self.email} 测试验证码:{code_str}")
return rx.toast("验证码已发送(控制台查看测试码)", color="green")
@rx.event
async def login_by_email_code(self):
"""邮箱验证码登录,不存在用户自动注册"""
def validate_email(self) -> bool:
"""
验证邮箱
:return: 验证是否通过,True 表示校验通过,False 表示校验不通过
"""
self.notification = ""
# 非空校验
if not self.email:
return rx.toast("请填写邮箱", color="red")
if not self.code:
return rx.toast("请填写验证码", color="red")
if not self.agree_policy:
return rx.toast("请勾选同意用户协议与隐私政策", color="red")
self.notification = "请输入邮箱"
return False
# 格式校验
if not re.fullmatch(
r"^[a-zA-Z0-9]+([_\-+.][a-zA-Z0-9_\-+]+)*@[a-zA-Z0-9]+(\-[a-zA-Z0-9\-]+)*(\.[a-zA-Z0-9]+(\-[a-zA-Z0-9\-]+)*)*\.[a-zA-Z0-9]{2,}$",
self.email,
):
self.notification = "邮箱格式不正确,请重新输入"
return False
return True
db = get_db_session()
# 查询有效验证码
code_record = db.exec(
select(EmailVerifyCode).where(
EmailVerifyCode.email == self.email, EmailVerifyCode.code == self.code
def validate_captcha(self) -> bool:
"""
验证验证码
:return: 验证是否通过,True 表示校验通过,False 表示校验不通过
"""
self.notification = ""
# 非空校验
if not self.captcha:
self.notification = "请输入验证码"
return False
# 格式校验
if not re.fullmatch(r"^\d{6}$", self.captcha):
self.notification = "验证码格式不正确,请重新输入"
return False
return True
@rx.event(background=True)
async def resend_captcha_countdown(self) -> None:
"""
重新发送验证码倒计时
:return: None
"""
async with self:
self.captcha_sending_countdown = 59
while self.captcha_sending_countdown > 0:
await sleep(1)
async with self:
self.captcha_sending_countdown -= 1
@rx.event(background=True)
async def send_captcha_(self) -> None:
"""
发送验证码
:return: None
"""
async with self:
self.notification = "验证码已发送"
# 创建验证码记录并赋值
database_state = await self.get_state(DatabaseState)
captcha = await database_state.create_captchas_record(email=self.email)
# 构建邮件
message = MIMEText(
CAPTCHA_HTML_TEMPLATE.format(captcha=captcha),
"html",
"utf-8",
)
).first()
if not code_record or code_record.is_expired():
return rx.toast("验证码错误或已过期", color="red")
message["Subject"] = Header("验证码", "utf-8").encode()
message["From"] = "office@liubiren.cloud"
message["To"] = self.email
try:
async with SMTP(
hostname="smtp.feishu.cn",
port=465,
username="office@liubiren.cloud",
password="cx1dM5tBcbyWftpS",
use_tls=True,
local_hostname="localhost",
) as smtp:
await smtp.send_message(message)
# 一次性验证码使用后删除
db.delete(code_record)
# 查找用户,无则新建
user = db.exec(select(User).where(User.email == self.email)).first()
if not user:
user = User(email=self.email, nickname=self.email.split("@")[0])
db.add(user)
user.last_login_at = datetime.utcnow()
db.commit()
db.refresh(user)
# 登录成功,弹窗自动消失
self.user_id = str(user.id)
self.email = ""
self.code = ""
self.agree_policy = False
return rx.toast("登录成功", color="green")
@rx.var
async def storage_key(self) -> str:
"""
LocalStorage 键
"""
# 获取认证状态
auth = await self.get_state(AuthState)
# 获取用户唯一标识
user_id = auth.user_id or "default"
return f"sidebar_{user_id}"
# 激活的导航按钮
activated_nav_button: str = rx.LocalStorage(storage_key, sync=True)
except SMTPException:
async with self:
self.notification = f"发送失败,请稍后重试"
self.captcha_sending_countdown = 0
@rx.event
def switch_nav_button(self, button: str):
async def send_captcha(self) -> AsyncGenerator:
"""
发送验证码
:return: None
"""
self.notification = ""
# 验证邮箱
if not self.validate_email():
return
# 将重新发送验证码倒计时和发送验证码事件添加至后台任务队列
yield type(self).resend_captcha_countdown
yield type(self).send_captcha_
@rx.event
async def login(self) -> None:
"""
登录
:return: None
"""
self.is_logging_in = True
# 验证邮箱
if not self.validate_email():
return
# 验证验证码
if not self.validate_captcha():
return
# 创建验证码记录并赋值验证码
database_state = await self.get_state(DatabaseState)
if not await database_state.update_captcha(
email=self.email, captcha=self.captcha
):
self.notification = "验证码错误"
return
# 创建用户记录
user_id = await database_state.create_users_record(email=self.email)
self.user_id = user_id
self.notification = "登录成功"
self.is_logging_in = False
@rx.event
def switch_activated_nav_button(self, button: str):
"""
将指定导航按钮设置为激活的导航按钮
"""

View File

@ -65,12 +65,12 @@ class ConversationState(rx.State):
会话状态
"""
# 用户唯一标识
# 当前用户唯一标识
user_id: str = ""
# 会话字典
conversations: Dict[str, Conversation] = {}
# 会话唯一标识
# 当前会话唯一标识
conversation_id: str = ""
# 会话历史展示状态,True表示展示,False表示隐藏
@ -84,7 +84,7 @@ class ConversationState(rx.State):
"""
页面挂载时初始化会话列表和会话唯一标识
"""
# 获取登录用户唯一标识
# 获取当前用户唯一标识
auth_state = await self.get_state(AuthState)
self.user_id = auth_state.user_id
if not self.user_id:
@ -93,21 +93,28 @@ class ConversationState(rx.State):
# 获取数据库状态
database_state = await self.get_state(DatabaseState)
# 获取会话字典
self.conversations = await database_state.get_conversations(
# 检索会话字典并赋值
self.conversations = await database_state.retrieve_conversations(
user_id=self.user_id
)
# 若会话字典为空则新建会话
# 若会话字典为空则先创建会话记录再在会话字典中添加默认会话
if not self.conversations:
# 先在数据库新建会话,再在会话字典新建会话
conversation_id, created_at = await database_state.create_conversation(
user_id=self.user_id
conversation_id, created_at = (
await database_state.create_conversations_record(user_id=self.user_id)
)
self.conversations[conversation_id] = Conversation(created_at=created_at)
# 默认将最后一个会话的唯一标识设置为当前会话唯一标识
# 将最后一个会话的唯一标识设置为当前会话唯一标识
self.conversation_id = next(reversed(self.conversations.keys()))
@rx.event
def toggle_conversation_history_shown(self) -> None:
"""
切换会话历史展示状态
:return: None
"""
self.is_conversation_history_shown = not self.is_conversation_history_shown
@rx.var
def conversation_history(self) -> Dict[str, Conversation]:
"""
@ -126,15 +133,16 @@ class ConversationState(rx.State):
# 获取会话描述
description = form_data["description"].strip() or "新会话"
# 先在数据库中创建会话记录,再在会话字典中创建会话记录
# 先创建会话记录再在会话字典中添加会话
database_state = await self.get_state(DatabaseState)
conversation_id, created_at = await database_state.create_conversation(
conversation_id, created_at = await database_state.create_conversations_record(
user_id=self.user_id, description=description
)
self.conversations[conversation_id] = Conversation(
description=description, created_at=created_at
)
# 将新建会话的唯一标识设置为当前会话唯一标识
# 将所添加的会话唯一标识设置为当前会话唯一标识
self.conversation_id = conversation_id
# 将正在新建会话状态设置为已完成
self.is_creating_conversation = False
@ -149,15 +157,15 @@ class ConversationState(rx.State):
if conversation_id not in self.conversations:
return
# 先在数据库删除会话,再在会话字典删除会话
# 先逻辑删除会话记录再在会话字典中删除会话
database_state = await self.get_state(DatabaseState)
await database_state.delete_conversation(conversation_id=conversation_id)
await database_state.delete_conversations_record(conversation_id=conversation_id)
del self.conversations[conversation_id]
# 删除后,若会话字典为空则先在数据库新建会话,再在会话字典新建会话
# 删除后,若会话字典为空则先创建会话记录再在会话字典中添加默认会话
if not self.conversations:
conversation_id, created_at = await database_state.create_conversation(
user_id=self.user_id
conversation_id, created_at = (
await database_state.create_conversations_record(user_id=self.user_id)
)
self.conversations[conversation_id] = Conversation(created_at=created_at)
@ -168,7 +176,7 @@ class ConversationState(rx.State):
@rx.event
def switch_conversation(self, conversation_id: str) -> None:
"""
将指定会话唯一标识设置为当前会话唯一标识
将指定会话的唯一标识设置为当前会话唯一标识
:param conversation_id: 指定会话唯一标识
:return: None
"""
@ -176,6 +184,13 @@ class ConversationState(rx.State):
return
self.conversation_id = conversation_id
@rx.event
def toggle_creating_conversation(self) -> None:
"""
切换正在新建会话状态
"""
self.is_creating_conversation = not self.is_creating_conversation
@rx.var
def dialog_history(self) -> Dict[str, Dialog]:
"""
@ -221,13 +236,13 @@ class ConversationState(rx.State):
# 获取数据库状态
database_state = await self.get_state(DatabaseState)
# 获取消息历史
message_history = await database_state.get_message_history(
# 检索运行结果记录并作为消息历史
message_history = await database_state.retrieve_message_history(
conversation_id=self.conversation_id
)
# 先在数据库新建对话,再在当前会话的对话字典新增对话
dialog_id = await database_state.create_dialog(
# 先创建对话记录再在当前对话字典中新增对话
dialog_id = await database_state.create_dialogs_record(
conversation_id=self.conversation_id, question=question
)
dialog = conversation.dialogs.setdefault(dialog_id, Dialog(question=question))
@ -383,14 +398,14 @@ class ConversationState(rx.State):
# ========== 智能体运行结果事件 ==========
case AgentRunResultEvent(result=result):
# 完成对话
await database_state.complete_dialog(
# 更新对话记录
await database_state.update_dialog_record(
dialog_id=dialog_id,
thought_nodes=dialog.thought_nodes,
answer=dialog.answer,
)
# 保存新增消息
await database_state.save_new_messages(
# 创建运行结果记录
await database_state.create_run_result_record(
conversation_id=self.conversation_id,
dialog_id=dialog_id,
new_messages=result.new_messages(),
@ -407,18 +422,3 @@ class ConversationState(rx.State):
# 指定运行
dialog = self.conversations[self.conversation_id].dialogs[dialog_id]
dialog.is_collapse_expanded = not dialog.is_collapse_expanded
@rx.event
def toggle_conversation_history_shown(self) -> None:
"""
切换会话历史展示状态
:return: None
"""
self.is_conversation_history_shown = not self.is_conversation_history_shown
@rx.event
def toggle_creating_conversation(self) -> None:
"""
切换正在新建会话状态
"""
self.is_creating_conversation = not self.is_creating_conversation

View File

@ -9,15 +9,17 @@ from pydantic import TypeAdapter
from pydantic_ai import ModelMessage, ModelMessagesTypeAdapter
import reflex as rx
from sqlalchemy import ColumnElement, desc
from sqlmodel import select
from sqlmodel import select, update
from application.models import (
Captchas,
Conversation,
Conversations,
Dialog,
Dialogs,
RunResults,
ThoughtNode,
Users,
)
@ -30,13 +32,88 @@ class DatabaseState(rx.State):
数据库状态
"""
async def get_conversations(self, user_id: str) -> Dict[str, Conversation]:
async def create_captchas_record(self, email: str) -> str:
"""
创建验证码记录
:param email: 邮箱
:return: 验证码
"""
async with rx.asession() as session:
# 前置处理:将该邮箱有效且未使用的验证码记录的是否有效设置为无效
await session.exec(
update(Captchas)
.where(
cast(ColumnElement[bool], Captchas.email == email),
cast(
ColumnElement[bool],
Captchas.is_valid == True,
),
cast(
ColumnElement[bool],
Captchas.is_unused == True,
),
)
.values(is_valid=False)
)
await session.flush()
# 创建验证码记录
record = Captchas(
email=email,
)
session.add(record)
await session.commit()
await session.refresh(record)
return record.captcha
async def update_captcha(self, email: str, captcha: str) -> bool:
"""
更新验证码
:param email: 邮箱
:param captcha: 验证码
:return: 是否更新成功,True 表示更新成功,False 表示更新失败
"""
async with rx.asession() as session:
result = await session.exec(
select(Captchas).where(
Captchas.email == email,
Captchas.captcha == captcha,
Captchas.is_valid == True,
Captchas.is_unused == True,
Captchas.expired_at > datetime.now(),
)
)
record = result.first()
if not record:
return False
record.is_unused = False
await session.commit()
await session.refresh(record)
return True
async def create_users_record(self, email: str) -> str:
"""
创建用户记录
:param email: 邮箱
:return: 创建用户记录的唯一标识
"""
async with rx.asession() as session:
result = await session.exec(select(Users).where(Users.email == email))
record = result.first()
if record:
return record.id
new_user = Users(email=email)
session.add(new_user)
await session.commit()
await session.refresh(new_user)
return new_user.id
async def retrieve_conversations(self, user_id: str) -> Dict[str, Conversation]:
"""
获取会话字典
:param user_id: 用户唯一标识
:return: 会话字典
"""
conversations: Dict[str, Conversation] = {}
records: Dict[str, Conversation] = {}
async with rx.asession() as session:
result = await session.exec(
select(Conversations, Dialogs)
@ -54,121 +131,101 @@ class DatabaseState(rx.State):
.order_by(Conversations.id, Dialogs.id)
)
for conversation, dialog in result.all():
conversation_memory = conversations.setdefault(
record = records.setdefault(
conversation.id,
Conversation(
description=conversation.description,
created_at=conversation.created_at,
),
)
if not dialog:
continue
conversation_memory.dialogs[dialog.id] = Dialog(
record.dialogs[dialog.id] = Dialog(
question=dialog.question,
thought_nodes=ThoughtNodeTypeAdapter.validate_python(
dialog.thought_nodes
), # 反序列化为思考节点内存实例字典
),
answer=dialog.answer,
)
return conversations
return records
async def create_conversation(
async def create_conversations_record(
self, user_id: str, description: str = "新会话"
) -> tuple[str, datetime]:
"""
新建会话
创建会话记录
:param user_id: 用户唯一标识
:param description: 会话描述
:return: 新建会话唯一标识、创建时间
:return: 创建会话记录的唯一标识和创建时间
"""
async with rx.asession() as session:
conversation = Conversations(user_id=user_id, description=description)
session.add(conversation)
record = Conversations(user_id=user_id, description=description)
session.add(record)
await session.commit()
await session.refresh(conversation)
return conversation.id, conversation.created_at
await session.refresh(record)
return record.id, record.created_at
async def delete_conversation(self, conversation_id: str) -> None:
async def delete_conversations_record(self, conversation_id: str) -> None:
"""
删除指定会话(逻辑删除)
:param conversation_id: 指定会话唯一标识
逻辑删除会话记录
:param conversation_id: 指定会话记录唯一标识
:return: None
"""
async with rx.asession() as session:
conversation = await session.get(Conversations, conversation_id)
if not conversation:
record = await session.get(Conversations, conversation_id)
if not record:
return
conversation.is_deleted = True
record.is_deleted = True
await session.commit()
async def create_dialog(self, conversation_id: str, question: str) -> str:
async def create_dialogs_record(self, conversation_id: str, question: str) -> str:
"""
新建对话
创建对话记录
:param conversation_id: 会话唯一标识
:param question: 提问
:return: 新建对话唯一标识
:param question: 问题
:return: 创建对话记录的唯一标识
"""
async with rx.asession() as session:
dialog = Dialogs(
record = Dialogs(
conversation_id=conversation_id,
question=question,
)
session.add(dialog)
session.add(record)
await session.commit()
await session.refresh(dialog)
return dialog.id
await session.refresh(record)
return record.id
async def complete_dialog(
async def update_dialog_record(
self,
dialog_id: str,
thought_nodes: dict[int, ThoughtNode],
answer: str,
) -> None:
"""
完成对话
更新对话记录
:param dialog_id: 对话唯一标识
:param thought_nodes: 思考节点列表
:param answer: 回答
:return: None
"""
async with rx.asession() as session:
dialog = await session.get(Dialogs, dialog_id)
if not dialog:
record = await session.get(Dialogs, dialog_id)
if not record:
return
dialog.thought_nodes = ThoughtNodeTypeAdapter.dump_python(
record.thought_nodes = ThoughtNodeTypeAdapter.dump_python(
{k: v for k, v in thought_nodes.items()}
)
dialog.answer = answer
record.answer = answer
await session.commit()
async def get_message_history(self, conversation_id: str) -> List[ModelMessage]:
"""
获取消息历史
:param conversation_id: 会话唯一标识
:return: 消息历史
"""
message_history: List[ModelMessage] = []
async with rx.asession() as session:
result = await session.exec(
select(RunResults)
.where(RunResults.conversation_id == conversation_id)
.order_by(desc(RunResults.dialog_id))
)
for record in result.all():
message_history.extend(
ModelMessagesTypeAdapter.validate_json(record.new_messages)
)
return message_history
async def save_new_messages(
async def create_run_result_record(
self,
conversation_id: str,
dialog_id: str,
new_messages: List[ModelMessage],
) -> None:
"""
保存新增消息
创建运行结果记录
:param conversation_id: 会话唯一标识
:param dialog_id: 对话唯一标识
:param new_messages: 新增消息
@ -187,3 +244,24 @@ class DatabaseState(rx.State):
)
)
await session.commit()
async def retrieve_message_history(
self, conversation_id: str
) -> List[ModelMessage]:
"""
获取消息历史
:param conversation_id: 会话唯一标识
:return: 消息历史
"""
records: List[ModelMessage] = []
async with rx.asession() as session:
result = await session.exec(
select(RunResults)
.where(RunResults.conversation_id == conversation_id)
.order_by(desc(RunResults.dialog_id))
)
for record in result.all():
records.extend(
ModelMessagesTypeAdapter.validate_json(record.new_messages)
)
return records

Binary file not shown.

View File

@ -11,6 +11,7 @@
"isbot": "5.1.40",
"lucide-react": "1.14.0",
"react": "19.2.6",
"react-debounce-input": "3.3.0",
"react-dom": "19.2.6",
"react-error-boundary": "6.1.1",
"react-helmet": "6.1.0",
@ -660,6 +661,8 @@
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@ -814,6 +817,8 @@
"react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-debounce-input": ["react-debounce-input@3.3.0", "https://registry.npmmirror.com/react-debounce-input/-/react-debounce-input-3.3.0.tgz", { "dependencies": { "lodash.debounce": "^4", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^15.3.0 || 16 || 17 || 18" } }, "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA=="],
"react-dom": ["react-dom@19.2.6", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react-error-boundary": ["react-error-boundary@6.1.1", "https://registry.npmmirror.com/react-error-boundary/-/react-error-boundary-6.1.1.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],

View File

@ -12,6 +12,7 @@
"isbot": "5.1.40",
"lucide-react": "1.14.0",
"react": "19.2.6",
"react-debounce-input": "3.3.0",
"react-dom": "19.2.6",
"react-error-boundary": "6.1.1",
"react-helmet": "6.1.0",