diff --git a/agent/application/application.py b/agent/application/application.py index 8f8794f..db6686c 100644 --- a/agent/application/application.py +++ b/agent/application/application.py @@ -20,18 +20,22 @@ app = rx.App( "*::before, *::after": { "box_sizing": "border-box", }, - "--prismui-background-color-1": "#ffffff", + "--prismui-background-color-1": "#ffffff", # 纯白背景色 "--prismui-background-color-2": "rgb(235, 235, 235)", "--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)", "--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)", + "--prismui-box-shadow-1": "0 4px 12px #0000001a", # 导航按钮阴影 "--devui-icon-fill-weak": "#babbc0", "--prismui-background-color-weak": "#babbc0", "--devui-border-radius-feedback": "4px", "--prismui-border-radius": "8px", + "--prismui-border-radius-1": "8px", "--devui-border-radius-full": "100px", "--font-family": "HuaweiFont,Helvetica,Arial,PingFangSC-Regular,Hiragino Sans GB,Microsoft YaHei,微软雅黑,Microsoft JhengHei", "--prismui-font-size": "14px", @@ -43,7 +47,7 @@ app = rx.App( "--devui-font-size-lg": "14px", "--prismui-color-text": "#252b3a", "--prismui-color-text-light": "#babbc0", - "--prismui-color-1": "#252b3a", + "--prismui-color-1": "#252b3a", # 文本颜色 "--prismui-color-2": "#575d6c", "--prismui-color-3": "#babbc0", "--devui-text-weak": "#575d6c", diff --git a/agent/application/components/sidebar.py b/agent/application/components/sidebar.py deleted file mode 100644 index b44a8d6..0000000 --- a/agent/application/components/sidebar.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- -""" -渲染框架相关组件 -""" - -import reflex as rx - -from application.states import SidebarState - -# 侧边栏图标导航按钮字典 -buttons = { - "conversation": {"src": "/conversation.svg", "text": "会话"}, - "library": {"src": "/library.svg", "text": "知识库"}, -} - - -def sidebar() -> rx.Component: - """ - 侧边栏 - """ - - def button( - button: str, - ) -> rx.Component: - """ - 侧边栏图标导航按钮 - :param button: 指定侧边栏图标导航按钮 - """ - # 指定按钮的激活状态 - is_actived = button == SidebarState.get_activated_button - - return rx.box( - # 渲染图标容器 - rx.box( - # 渲染图标 - rx.image( - src=buttons[button]["src"], - width="24px", - height="24px", - object_fit="contain", - ), - display="flex", - justify_content="center", - align_items="center", - width="36px", - height="36px", - border_radius="var(--prismui-border-radius)", - # 若激活则渲染背景颜色和阴影,否则不渲染 - style={ - "background-color": rx.cond( - is_actived, "var(--prismui-background-color)", "transparent" - ), - "box-shadow": rx.cond(is_actived, "0 4px 12px #0000001a", "none"), - }, - ), - # 渲染标签 - rx.text( - buttons[button]["text"], - color="var(--prismui-color-text)", - font_size="var(--prismui-font-size-sm)", - line_height="20px", - ), - display="flex", - flex_direction="column", - align_items="center", - gap="4px", - cursor="pointer", - # 点击事件:将指定图标导航按钮设置为侧边栏中激活的图标导航按钮 - on_click=SidebarState.switch_activated_button(button), - ) - - return rx.vstack( - rx.vstack( - rx.vstack( - # 品牌图标 - rx.image( - src="/logo.png", - width="34px", - height="34px", - object_fit="contain", - ), - # 品牌名称 - rx.text( - "棱镜球", - line_height="20px", - font_size="11px", - font_weight="700", - color="var(--prismui-color-text)", - ), - display="flex", - flex_direction="column", - justify_content="center", - align_items="center", - gap="4px", - ), - # 渲染分隔线 - rx.divider( - width="32px", - height="1px", - margin="16px 0", - background_color="var(--prismui-background-color-weak)", - ), - # 渲染侧边栏中会话图标导航按钮 - button("conversation"), - # 渲染侧边栏中知识库图标导航按钮 - button("library"), - display="flex", - flex_direction="column", - align_items="center", - width="100%", - margin_top="12px", - ), - display="flex", - flex_direction="column", - justify_content="space-between", - align_items="center", - width="60px", - height="100%", - box_sizing="border-box", - ) diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index 84545bd..de546dd 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -6,7 +6,7 @@ import reflex as rx from application.models import Conversation, Dialog, ThoughtNode from application.states import ( - ConversationHistoryState, + ConversationHistoryCollapseState, ConversationState, CreateConversationState, ) @@ -323,6 +323,7 @@ def render_thought_node(thought_node_id: int, thought_node: ThoughtNode): "thinking", rx.text( thought_node.content, + line_height="22px", font_size="var(--prismui-font-size-2)", color="var(--prismui-color-3)", ), @@ -426,27 +427,28 @@ def render_dialog_history_item(dialog_id: str, dialog: Dialog) -> rx.Component: on_click=lambda: ConversationState.toggle_collapse(dialog_id), ), rx.box( - rx.auto_scroll( - rx.foreach( - thought_nodes, - lambda i, _: render_thought_node(i[0], i[1]), + rx.box( + rx.auto_scroll( + rx.foreach( + thought_nodes, + lambda i, _: render_thought_node(i[0], i[1]), + ), + width="100%", + margin_top="8px", + align_self="start", ), - width="100%", - margin_top="8px", - align_self="start", + style={"overflow": "hidden", "min-height": "0"}, ), width="100%", - overflow="hidden", style={ - "max-height": rx.cond(is_collapse_expanded, "1200px", "20px"), + "display": "grid", + "overflow": "hidden", + "grid_template_rows": rx.cond(is_collapse_expanded, "1fr", "0fr"), "opacity": rx.cond(is_collapse_expanded, "1", "0"), - "transition": "max-height 0.2s ease-out, opacity 0.2s ease-out", + # 双属性同步过渡,和千问一致 + "transition": "grid-template-rows 0.3s ease-out, opacity 0.3s ease-out", }, ), - width="100%", - margin_top="8px", - text_align="left", - overflow="visible", ), # 渲染回答 rx.markdown( @@ -744,16 +746,16 @@ def render_conversation_workplace() -> rx.Component: def render_conversation_history_collapse_button( - is_collapse_expanded: bool, + is_expanded: bool, ) -> rx.Component: """ 渲染会话历史折叠面板的打开/关闭按钮 - :param is_collapse_expanded: 会话历史折叠面板展开状态 + :param is_expanded: 会话历史折叠面板展开状态 :return: Component """ return rx.button( rx.cond( - is_collapse_expanded, + is_expanded, rx.icon( "chevron-left", width="16px", @@ -769,17 +771,17 @@ def render_conversation_history_collapse_button( ), position="absolute", top="50%", - left=rx.cond(is_collapse_expanded, "calc(clamp(240px, 25%, 380px) - 8px)", "0"), + left=rx.cond(is_expanded, "calc(clamp(240px, 25%, 380px) - 8px)", "0"), z_index=99, width="16px", height="40px", background="var(--prismui-background-color-1)", box_shadow="2px 0 4px 0 var(--mc-float-block-shadow)", - border_radius=rx.cond(is_collapse_expanded, "6px", "0 6px 6px 0"), + border_radius=rx.cond(is_expanded, "6px", "0 6px 6px 0"), transition="all 0.3s ease-in-out", cursor="pointer", - # 点击事件:打开/关闭对话历史折叠面板 - on_click=ConversationHistoryState.toggle_collapse, + # 点击事件:切换会话历史折叠面板展开状态 + on_click=ConversationHistoryCollapseState.toggle_expanded, ) @@ -787,14 +789,14 @@ def render_conversation() -> rx.Component: """ 渲染会话页面:布局参考 MetaChat,左侧为会话历史,右侧为对话历史和输入框 """ - # 会话历史折叠面板展开状态 - is_collapse_expanded = ConversationHistoryState.is_collapse_expanded + # 会话历史折叠面板的展开状态 + is_expanded = ConversationHistoryCollapseState.is_expanded return rx.box( - # 渲染对话历史折叠面板 + # 渲染折叠面板 rx.hstack( - # 渲染对话历史折叠面板 - render_conversation_history_collapse(is_collapse_expanded), + # 渲染折叠面板 + render_conversation_history_collapse(is_expanded), # 渲染会话工作区 render_conversation_workplace(), position="relative", @@ -806,7 +808,7 @@ def render_conversation() -> rx.Component: transition="all 0.3s ease-in-out", ), # 渲染对话历史折叠面板打开/关闭按钮 - render_conversation_history_collapse_button(is_collapse_expanded), + render_conversation_history_collapse_button(is_expanded), position="relative", width="100%", height="100%", diff --git a/agent/application/pages/index.py b/agent/application/pages/index.py index fb95435..d34bdf8 100644 --- a/agent/application/pages/index.py +++ b/agent/application/pages/index.py @@ -4,34 +4,250 @@ """ import reflex as rx -from application.components import sidebar from application.pages import render_conversation, render_library from application.pages.conversation import render_conversation from application.states import AuthState, SidebarState +# 导航按钮字典 +nav_buttons: dict = { + "conversation": {"src": "/conversation.svg", "text": "会话"}, + "library": {"src": "/library.svg", "text": "知识库"}, +} + +""" +约定: +1 若组件仅用于当前页面则无需抽象为通用组件(application.components.*) +""" + + +def render_nav_button( + nav_button: str, +) -> rx.Component: + """ + 渲染导航按钮 + :param nav_button: 指定的导航按钮 + :return : Component + """ + # 导航按钮激活状态 + is_actived = nav_button == SidebarState.activated_nav_button + + return rx.vstack( + # 渲染图标 + rx.image( + nav_buttons[nav_button]["src"], + width="36px", + height="36px", + object_fit="contain", + padding="6px", + background_color=rx.cond( + is_actived, "var(--prismui-background-color-1)", "transparent" + ), + border_radius="var(--prismui-border-radius-1)", + box_shadow=rx.cond(is_actived, "var(--prismui-box-shadow-1)", "none"), + ), + # 渲染标签 + rx.text( + nav_buttons[nav_button]["text"], + line_height="20px", + color="var(--prismui-color-1)", + font_size="var(--prismui-font-size-2)", + ), + align_items="center", + gap="4px", + cursor="pointer", + # 点击事件:将指定导航按钮设置为侧边栏中激活的导航按钮 + on_click=SidebarState.switch_nav_button(nav_button), + ) + + +def render_sidebar() -> rx.Component: + """ + 渲染侧边栏 + :return: Component + """ + + return rx.vstack( + rx.vstack( + rx.vstack( + # 品牌图标 + rx.image( + src="/logo.png", + width="34px", + height="34px", + object_fit="contain", + ), + # 品牌名称 + rx.text( + "棱镜球", + line_height="20px", + font_size="11px", + font_weight="var(--prismui-font-weight-1)", + color="var(--prismui-color-1)", + ), + align_items="center", + gap="4px", + ), + # 渲染分隔线 + rx.divider( + width="32px", + height="1px", + margin="16px 0", + background_color="var(--prismui-background-color-4)", + ), + rx.vstack( + # 渲染会话导航按钮 + render_nav_button("conversation"), + # 渲染知识库导航按钮 + render_nav_button("library"), + gap="8px", + ), + align_items="center", + width="100%", + margin_top="12px", + ), + flex_shrink="0", + justify_content="space-between", + align_items="center", + width="60px", + min_width="60px", + max_width="60px", + height="100%", + ) + + +def render_login_modal() -> rx.Component: + """ + 渲染登录模态窗 + :return: Component + """ + + return rx.center( + rx.hstack( + rx.vstack( + rx.text( + "登录", + font_size="20px", + font_weight="600", + margin_bottom="24px", + ), + rx.input( + placeholder="请输入邮箱地址", + value=AuthState.email, + on_change=AuthState.set_email, + width="100%", + ), + rx.hstack( + rx.input( + placeholder="请输入验证码", + value=AuthState.captcha, + on_change=AuthState.set_captcha, + flex=1, + ), + rx.button( + "获取验证码", + variant="outline", + width="120px", + on_click=AuthState.send_email_code, + ), + gap="8px", + width="100%", + ), + rx.cond( + AuthState.email == "", + rx.text("请输入邮箱地址", color="red", font_size="12px"), + rx.box(height="16px"), + ), + rx.hstack( + rx.checkbox( + checked=AuthState.agree_policy, + on_change=AuthState.toggle_agree, + ), + rx.text("我已阅读并同意 ", font_size="13px"), + rx.link("用户协议、隐私政策", font_size="13px", href="#"), + align_items="center", + ), + rx.button( + "登录", + width="100%", + bg="#0a0e29", + color="white", + margin_top="12px", + on_click=AuthState.login_by_email_code, + ), + rx.text( + "登录有问题?去反馈", + font_size="12px", + color="#666", + margin_top="8px", + ), + width="360px", + gap="16px", + padding="40px 32px", + align_items="start", + ), + rx.divider(orientation="vertical"), + rx.vstack( + rx.image( + src="/qrcode.png", + width="160px", + height="160px", + padding="8px", + border="1px solid #eee", + border_radius="8px", + bg="white", + ), + rx.text( + "添加微信申请使用", + font_size="18px", + font_weight="600", + margin_top="16px", + ), + align_items="center", + width="320px", + padding="40px 32px", + style={"background": "#f8f9fc"}, + ), + ), + position="fixed", + top="0", + left="0", + width="100vw", + height="100vh", + bg="rgba(255, 255, 255, 0.85)", + z_index="9999", + overflow="hidden", + ) + + def render_index() -> rx.Component: """ 首页 """ - return rx.hstack( - # 渲染侧边栏 - sidebar(), - # 根据用户所点中的侧边栏图标导航按钮渲染相应页面 - rx.match( - SidebarState.get_activated_button, - # 渲染知识库页面 - ("library", render_library()), - # 渲染会话页面 - render_conversation(), + return rx.fragment( + rx.hstack( + # 渲染侧边栏 + render_sidebar(), + # 根据侧边栏状态中激活的导航按钮渲染相应页面(本项目采用卡片布局) + rx.match( + SidebarState.activated_nav_button, + # 渲染知识库页面 + ("library", render_library()), + # 渲染会话页面 + render_conversation(), + ), + width="100%", + height="100vh", + overflow="auto", + padding="8px 8px 8px 0", + background="var(--prismui-background-1)", + # 挂载事件: + on_mount=AuthState.check, + ), + rx.cond( + AuthState.user_id == "", + render_login_modal(), + rx.fragment(), ), - width="100%", - height="100vh", - padding="8px 8px 8px 0", - overflow="auto", - box_sizing="border-box", - background="var(--mc-global-bg)", - # 挂载事件:因测试需模拟已认证 - on_mount=AuthState.authenticate, ) diff --git a/agent/application/states/__init__.py b/agent/application/states/__init__.py index 01e9582..24e4ca3 100644 --- a/agent/application/states/__init__.py +++ b/agent/application/states/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from application.states.auth import AuthState from application.states.conversation import ConversationState -from application.states.conversation_history import ConversationHistoryState +from application.states.conversation_historycollapse import ConversationHistoryCollapseState from application.states.create_conversation import CreateConversationState from application.states.database import DatabaseState from application.states.sidebar import SidebarState @@ -9,7 +9,7 @@ from application.states.sidebar import SidebarState __all__ = [ "AuthState", - "ConversationHistoryState", + "ConversationHistoryCollapseState", "ConversationState", "CreateConversationState", "DatabaseState", diff --git a/agent/application/states/auth.py b/agent/application/states/auth.py index 0e31683..7e32691 100644 --- a/agent/application/states/auth.py +++ b/agent/application/states/auth.py @@ -4,6 +4,11 @@ """ import reflex as rx +import re +from sqlmodel import select +from application.models.user import EmailVerifyCode +from application.database.base import get_async_db + class AuthState(rx.State): """ @@ -13,10 +18,112 @@ class AuthState(rx.State): # 当前登录的用户唯一标识(通过本地存储同步) user_id: str = rx.LocalStorage("user_id", sync=True) + email: str = "" + captcha: str = "" + is_policies_agreed: bool = False + @rx.event - async def authenticate(self) -> None: + async def check(self) -> None: """ - 认证 + 检查认证状态 """ - self.user_id = "1" + self.user_id = "" + + @rx.event + def set_email(self, email: str) -> None: + """ + 设置邮箱 + :param email: 邮箱 + :return: None + """ + self.email = email.strip() + + @rx.event + def set_captcha(self, captcha: str) -> None: + """ + 设置验证码 + :param captcha: 验证码 + :return: None + """ + self.captcha = captcha.strip() + + @rx.event + def toggle_policies_agreed(self) -> None: + """ + 切换协议同意状态 + :return: None + """ + 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): + """邮箱验证码登录,不存在用户自动注册""" + 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") + + 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") diff --git a/agent/application/states/conversation_history.py b/agent/application/states/conversation_history.py deleted file mode 100644 index 7ecbaf6..0000000 --- a/agent/application/states/conversation_history.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -""" -会话历史状态 -""" -import reflex as rx - - -class ConversationHistoryState(rx.State): - """ - 会话历史状态 - """ - - # 会话历史折叠面板展开状态,True表示展开,False表示收起 - is_collapse_expanded: bool = False - - @rx.event - def toggle_collapse(self) -> None: - """ - 打开/关闭会话历史折叠面板 - :return: None - """ - self.is_collapse_expanded = not self.is_collapse_expanded diff --git a/agent/application/states/conversation_historycollapse.py b/agent/application/states/conversation_historycollapse.py new file mode 100644 index 0000000..dbd889c --- /dev/null +++ b/agent/application/states/conversation_historycollapse.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" +会话历史折叠面板状态 +""" +import reflex as rx + + +class ConversationHistoryCollapseState(rx.State): + """ + 会话历史折叠面板状态 + """ + + # 展开状态,True表示展开,False表示折叠 + is_expanded: bool = False + + @rx.event + def toggle_expanded(self) -> None: + """ + 切换展开状态 + :return: None + """ + self.is_expanded = not self.is_expanded diff --git a/agent/application/states/sidebar.py b/agent/application/states/sidebar.py index e0b3dce..b6052e8 100644 --- a/agent/application/states/sidebar.py +++ b/agent/application/states/sidebar.py @@ -13,9 +13,9 @@ class SidebarState(rx.State): """ @rx.var - async def generate_storage_key(self) -> str: + async def storage_key(self) -> str: """ - 生成 LocalStorage 键 + LocalStorage 键 """ # 获取认证状态 auth = await self.get_state(AuthState) @@ -23,19 +23,12 @@ class SidebarState(rx.State): user_id = auth.user_id or "default" return f"sidebar_{user_id}" - # 侧边栏中激活的图标导航按钮 - activated_button: str = rx.LocalStorage(generate_storage_key, sync=True) - - @rx.var - def get_activated_button(self) -> str: - """ - 获取侧边栏中激活的图标导航按钮 - """ - return self.activated_button + # 激活的导航按钮 + activated_nav_button: str = rx.LocalStorage(storage_key, sync=True) @rx.event - def switch_activated_button(self, button: str): + def switch_nav_button(self, button: str): """ - 将指定图标导航按钮设置为侧边栏中激活的图标导航按钮 + 将指定导航按钮设置为激活的导航按钮 """ - self.activated_button = button + self.activated_nav_button = button diff --git a/agent/database.db b/agent/database.db index f58dbf1..98a5162 100644 Binary files a/agent/database.db and b/agent/database.db differ