From b74fa0ca348b1781bfb96950668204d00e6d21b9 Mon Sep 17 00:00:00 2001 From: liubiren Date: Fri, 17 Jul 2026 19:59:15 +0800 Subject: [PATCH] 1 --- agent/alembic/versions/1595751865c1_.py | 67 +++++ agent/alembic/versions/32268fce88c5_.py | 36 +++ agent/application/application.py | 10 +- agent/application/components/__init__.py | 3 - agent/application/models/__init__.py | 10 +- .../models/{datatables.py => tables.py} | 47 +++- agent/application/pages/conversation.py | 14 +- agent/application/pages/index.py | 42 +-- agent/application/states/auth.py | 247 ++++++++++++------ agent/application/states/conversation.py | 84 +++--- agent/application/states/database.py | 190 ++++++++++---- agent/database.db | Bin 81920 -> 118784 bytes agent/reflex.lock/bun.lock | 5 + agent/reflex.lock/package.json | 1 + 14 files changed, 527 insertions(+), 229 deletions(-) create mode 100644 agent/alembic/versions/1595751865c1_.py create mode 100644 agent/alembic/versions/32268fce88c5_.py rename agent/application/models/{datatables.py => tables.py} (54%) diff --git a/agent/alembic/versions/1595751865c1_.py b/agent/alembic/versions/1595751865c1_.py new file mode 100644 index 0000000..041533a --- /dev/null +++ b/agent/alembic/versions/1595751865c1_.py @@ -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 ### diff --git a/agent/alembic/versions/32268fce88c5_.py b/agent/alembic/versions/32268fce88c5_.py new file mode 100644 index 0000000..72cf34c --- /dev/null +++ b/agent/alembic/versions/32268fce88c5_.py @@ -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 ### diff --git a/agent/application/application.py b/agent/application/application.py index db6686c..e62a198 100644 --- a/agent/application/application.py +++ b/agent/application/application.py @@ -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", diff --git a/agent/application/components/__init__.py b/agent/application/components/__init__.py index a516dbc..40a96af 100644 --- a/agent/application/components/__init__.py +++ b/agent/application/components/__init__.py @@ -1,4 +1 @@ # -*- coding: utf-8 -*- -from application.components.sidebar import sidebar - -__all__ = ["sidebar"] diff --git a/agent/application/models/__init__.py b/agent/application/models/__init__.py index 98689f6..03f2501 100644 --- a/agent/application/models/__init__.py +++ b/agent/application/models/__init__.py @@ -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", ] diff --git a/agent/application/models/datatables.py b/agent/application/models/tables.py similarity index 54% rename from agent/application/models/datatables.py rename to agent/application/models/tables.py index f5a3d7b..ccb3f67 100644 --- a/agent/application/models/datatables.py +++ b/agent/application/models/tables.py @@ -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="对话唯一标识") diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index de546dd..8db4d72 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -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( # 渲染折叠面板 diff --git a/agent/application/pages/index.py b/agent/application/pages/index.py index d34bdf8..1c743fc 100644 --- a/agent/application/pages/index.py +++ b/agent/application/pages/index.py @@ -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(), ), ) diff --git a/agent/application/states/auth.py b/agent/application/states/auth.py index d7c4162..6fc7abc 100644 --- a/agent/application/states/auth.py +++ b/agent/application/states/auth.py @@ -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 = """ +

验证码:{captcha}

+""" 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 - ) - ).first() - if not code_record or code_record.is_expired(): - return rx.toast("验证码错误或已过期", color="red") - - # 一次性验证码使用后删除 - 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: + def validate_captcha(self) -> bool: """ - LocalStorage 键 + 验证验证码 + :return: 验证是否通过,True 表示校验通过,False 表示校验不通过 """ - # 获取认证状态 - auth = await self.get_state(AuthState) - # 获取用户唯一标识 - user_id = auth.user_id or "default" - return f"sidebar_{user_id}" + 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 - # 激活的导航按钮 - activated_nav_button: str = rx.LocalStorage(storage_key, sync=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", + ) + 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) + + 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): """ 将指定导航按钮设置为激活的导航按钮 """ diff --git a/agent/application/states/conversation.py b/agent/application/states/conversation.py index 3263fae..6710d47 100644 --- a/agent/application/states/conversation.py +++ b/agent/application/states/conversation.py @@ -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 diff --git a/agent/application/states/database.py b/agent/application/states/database.py index 43365b8..2d9150b 100644 --- a/agent/application/states/database.py +++ b/agent/application/states/database.py @@ -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 diff --git a/agent/database.db b/agent/database.db index 98a5162657f36fa77d4e5b27ff80ae51404b13a2..3f0d68e494a3a3b12764b4be8283dae638383c12 100644 GIT binary patch delta 4353 zcmbtXeTW?88K24S?au6cy!V+giPx?c^%k2k7WP&yN3FhG@3sk zAp^Z?d|}TKlwcL%X70Exe(QDHoM-H+v}_S%Cz#=J9%KDf)Wr^r4yR;hExnv)`I|um~Dr0CDE%-{Mq6D4iP4Tb3oS z-MxB8{n%17Iu?c9-Lo97?msxw;}q2PKFu16sO>fFcCFPY6-uR%ob^qs{W`iO?4-ic z$DX4oXYon-iJs7f`l;20hw4!o9<4968oh;G{fc`BM8P6tRFN({Dn_(gYM6P;o0Qk& zZi=uLXFhTQbgU=2WxFID=%y^4*d=&H+OKR zIt!4)#o76T2lo~WDje)ycG}VAnNzLh_H*Xc?kLILVzkg~)at9y@~`VTYkE?&e5|LZ zhI`+3Ubs=1&?zCVzqru!{RRHJ;th0be8GT?mNg%j&@1SudEI>c(#Y_`W_UWAo1g!x zQgv{!=q|cWxj~6n_IQ1TIL5 z{nFe6km7=b{)#T3&(NokqTNlgea&Jiwg+CiQPcnZ^pz~Jv^ypA7xYu~eKci$VqP}S znfIH!jrWb0j5Ef5L)ZVIKdV2iGwuJ{pR}j7?`d~}n2!q~vvQ?fs#-l>UtU={w!H83 zX?x-1aSy8O`mu&Vw+N{a-^MP~E?_%>rS!8nbGZ}rv7pVaO+C*E95%6u6+$wXG0vSN z%?Ti%?J?JNeKx*{2CM)^$0aeAXs!z{+jFVI85>JuQQ|uR+hB!7pgB$j2euPk>VBZfsW~9-e(Vh$2e`7=Y z6$;558zz84L-jP4@8SU37H5UgSW(*{%<(wW(r7ernH!%EkO=e0neB_|z^stQ;)M95 zuZqxXf^Em)Fgr{ImKrL3lTSTLX>34*RWY|h1KT4Mdk$05l0iLO-;G}xu&7AJ2y7(G z@@XuWlh9}V$#8{pn|p+jfaTI?#0fSfgW@|dR?ul0u%R>-hY^69#ES1h%R)ipunrq0 zn?_@>E&N2!1g+u_8`hz|ER)9KE=+i$_+AAQu{o(vFsE!smlY;#2ZqC^UQ#2D>cOBO zFjsO~GMBm}*v!J7a02QD1@T;|U6at?(R1jB=svUuZg#Jm=gkF^?>6(syT%K~6ULlz zoBkjDP5tM3OaF#$XxG3WpVz{2wp5jq<`~o0XN%Tgbj%KS4n#YF*dJXRoY)@TXMf`Z z(Q(%s+X@|a@7GDY8{G=NfsWbS$Y6BL-G&qBuya3(m0b8!%+^o>o{EmSnK=+0cQK=! z4Y%vQ@FBP<_S{P!c=EVwX#>&1t-_vN6$ZeQE~O4e$J|NT8Xb0@)>zDy@>|2Z=(ro@ zZW7%wp`ku>D4s^*-A}qtwhz7;9e16~V06rEcI<_%UsAW+`*33bZ(4U5u#0Qx{8P8N zk=>vtCG-yZ9Q_x4g07O>C*V{WmM&IX1;VYKMIna6c;X|tMK>_rT-fu qcV*_)n>kB|_mPA?>U8}6q1#6?{bM3PS#(|MO#nduiK43XUC$Ro5JjIciDa|8V?ZkflhnTOzIOJb5wWq+fS_OzOm=m_R;*2af)cP0gn-s2 zm`bq7Z?MwNLW1cxAlUdKC}NX=X$J1OXIh=PRx5M9R45Zd7WDp1_F`3*%A|6(Z@~vK zjD&y%57y9rvQErnYa>54lKh}wm_Idc5@XSHc+8X^ddR}?zL>!B--D&8DX0_ZLERqM zlUUDgZl|-mu##J6+>a5%h$xs+p( z@qnp-@rsZRGO0^CzdQ#8^`j$?y2^=YL&Ppc?ujVo+-0HCywZQ5Q{-NtFc5x4qLKpJ z^|f0w1ziHq(1jY*wD%UYYt`K56y3CT+4=r!ibwmdx~D6~1U_L5BN*w57szL3VTMm2 KChsQ)=Ia;BRB*2V diff --git a/agent/reflex.lock/bun.lock b/agent/reflex.lock/bun.lock index 6b697d4..e8e3f43 100644 --- a/agent/reflex.lock/bun.lock +++ b/agent/reflex.lock/bun.lock @@ -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=="], diff --git a/agent/reflex.lock/package.json b/agent/reflex.lock/package.json index 8873239..47cf4cd 100644 --- a/agent/reflex.lock/package.json +++ b/agent/reflex.lock/package.json @@ -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",