This commit is contained in:
parent
6eb839fd9d
commit
b74fa0ca34
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from application.components.sidebar import sidebar
|
||||
|
||||
__all__ = ["sidebar"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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="对话唯一标识")
|
||||
|
|
@ -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(
|
||||
# 渲染折叠面板
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
将指定导航按钮设置为激活的导航按钮
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue