From 78175a1ec454b4e338e7c1d1382d7073d99ac8b7 Mon Sep 17 00:00:00 2001 From: liubiren Date: Sun, 12 Jul 2026 15:04:11 +0800 Subject: [PATCH] 1 --- .gitignore | 1 + agent/application/application.py | 13 +- agent/application/components/__init__.py | 4 + agent/application/components/frame.py | 115 --------- agent/application/components/sidebar.py | 120 +++++++++ agent/application/models.py | 98 +++----- agent/application/pages/__init__.py | 7 + agent/application/pages/conversation.py | 17 +- agent/application/pages/index.py | 42 ++-- .../pages/{knowledge_base.py => library.py} | 4 +- agent/application/state/__init__.py | 0 .../state/conversation_history_collapse.py | 23 -- .../state/create_conversation_modal.py | 21 -- agent/application/state/database.py | 57 ----- agent/application/state/frame.py | 24 -- agent/application/states/__init__.py | 20 ++ agent/application/states/auth.py | 22 ++ .../{state => states}/conversation.py | 231 +++++++++++------- .../states/conversation_history.py | 22 ++ .../application/states/create_conversation.py | 21 ++ agent/application/states/database.py | 90 +++++++ agent/application/states/sidebar.py | 40 +++ agent/application/template.py | 4 + .../{knowledge_base.svg => library.svg} | 0 agent/reflex.lock/package.json | 44 +--- 25 files changed, 562 insertions(+), 478 deletions(-) delete mode 100644 agent/application/components/frame.py create mode 100644 agent/application/components/sidebar.py rename agent/application/pages/{knowledge_base.py => library.py} (54%) delete mode 100644 agent/application/state/__init__.py delete mode 100644 agent/application/state/conversation_history_collapse.py delete mode 100644 agent/application/state/create_conversation_modal.py delete mode 100644 agent/application/state/database.py delete mode 100644 agent/application/state/frame.py create mode 100644 agent/application/states/__init__.py create mode 100644 agent/application/states/auth.py rename agent/application/{state => states}/conversation.py (60%) create mode 100644 agent/application/states/conversation_history.py create mode 100644 agent/application/states/create_conversation.py create mode 100644 agent/application/states/database.py create mode 100644 agent/application/states/sidebar.py create mode 100644 agent/application/template.py rename agent/assets/{knowledge_base.svg => library.svg} (100%) diff --git a/.gitignore b/.gitignore index 1c3d044..a6cab14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ +**/__pycache__/ .DS_Store .gitignore diff --git a/agent/application/application.py b/agent/application/application.py index f414748..76e644e 100644 --- a/agent/application/application.py +++ b/agent/application/application.py @@ -3,7 +3,7 @@ 应用主入口 """ import reflex as rx -from application.pages.index import render_index_page +from application.pages import index app = rx.App( style={ @@ -20,20 +20,21 @@ app = rx.App( "*::before, *::after": { "box_sizing": "border-box", }, - "--devui-base-bg": "#ffffff", + "--prismui-background-color": "#ffffff", "--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)", "--devui-icon-fill-weak": "#babbc0", + "--prismui-background-color-weak": "#babbc0", "--devui-border-radius-feedback": "4px", - "--devui-border-radius-card": "8px", + "--prismui-border-radius": "8px", "--devui-border-radius-full": "100px", "--font-family": "HuaweiFont,Helvetica,Arial,PingFangSC-Regular,Hiragino Sans GB,Microsoft YaHei,微软雅黑,Microsoft JhengHei", - "--devui-font-size-sm": "12px", + "--prismui-font-size-sm": "12px", "--devui-font-size": "14px", "--devui-font-size-lg": "14px", - "--devui-text": "#252b3a", + "--prismui-color-text": "#252b3a", "--devui-text-weak": "#575d6c", "--devui-light-text": "#ffffff", "--placeholder": "#babbc0", @@ -45,4 +46,4 @@ app = rx.App( }, # 自定义主题颜色 ) # 此处变量名需使用 app ,具体原因目前尚不清楚 # 注册首页路由 -app.add_page(component=render_index_page, route="/") +app.add_page(component=index, route="/") diff --git a/agent/application/components/__init__.py b/agent/application/components/__init__.py index e69de29..a516dbc 100644 --- a/agent/application/components/__init__.py +++ b/agent/application/components/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from application.components.sidebar import sidebar + +__all__ = ["sidebar"] diff --git a/agent/application/components/frame.py b/agent/application/components/frame.py deleted file mode 100644 index f678ead..0000000 --- a/agent/application/components/frame.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -""" -渲染框架相关组件 -""" - -import reflex as rx - -from application.state.frame import FrameState - - -def render_sidebar() -> rx.Component: - """ - 渲染侧边栏 - """ - - # 初始化按钮字典 - BUTTONS = { - "conversation": {"src": "/conversation.svg", "text": "对话"}, - "knowledge_base": {"src": "/knowledge_base.svg", "text": "知识库"}, - } - - def render_button( - button: str, - ) -> rx.Component: - """ - 渲染按钮 - :param button: 按钮名称 - """ - # 指定按钮的激活状态 - is_actived = button == FrameState.sidebar_icon_nav_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(--devui-border-radius-card)", # 圆角 - # 若已激活则渲染图标盒子背景颜色和阴影,否则不渲染 - style={ - "background-color": rx.cond( - is_actived, "var(--devui-base-bg)", "transparent" - ), - "box-shadow": rx.cond(is_actived, "0 4px 12px #0000001a", "none"), - }, - ), - # 渲染标签 - rx.text( - BUTTONS[button]["text"], - color="var(--devui-text)", - font_size="var(--devui-font-size-sm)", - line_height="20px", - ), - display="flex", # 弹性布局 - flex_direction="column", # 垂直方向布局 - align_items="center", # 垂直居中对齐 - gap="4px", - font_size="var(--devui-font-size-sm)", # 字体大小 - cursor="pointer", # 鼠标指针 - ) - - return rx.vstack( - # 渲染顶部 - rx.vstack( - # 渲染品牌 LOGO 和名称 - 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(--devui-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="#babbc0" - ), - # 渲染按钮:对话 - render_button(button="conversation"), - # 渲染按钮:知识库 - render_button(button="knowledge_base"), - margin_top="12px", # 上外边距 - display="flex", # 弹性布局 - flex_direction="column", # 垂直方向布局 - align_items="center", # 子元素水平居中 - width="100%", # 宽度为父容器宽度 - ), - 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/components/sidebar.py b/agent/application/components/sidebar.py new file mode 100644 index 0000000..11a4e96 --- /dev/null +++ b/agent/application/components/sidebar.py @@ -0,0 +1,120 @@ +# -*- 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(--devui-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/models.py b/agent/application/models.py index 91e7be3..7a52909 100644 --- a/agent/application/models.py +++ b/agent/application/models.py @@ -3,90 +3,48 @@ 数据模型 """ from datetime import datetime -from enum import StrEnum -from typing import Dict, List +from typing import Any -from pydantic import BaseModel, Field from pydantic_ai._uuid import uuid7 from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter -from sqlmodel import Field as SqlField, SQLModel +from sqlmodel import Field, SQLModel, JSON -# 消息历史数据表模型 -class MessageHistory(SQLModel, table=True): - id: str = SqlField( +# 会话数据表模型 +class Conversations(SQLModel, table=True): + id: str = Field( default_factory=lambda: str(uuid7()), primary_key=True, - description="消息唯一标识", + description="会话唯一标识", ) - conversation_id: str = SqlField(index=True, description="对话唯一标识") - run_id: str = SqlField(index=True, description="运行唯一标识") - new_message: str = SqlField(description="新增消息") + user_id: str = Field(index=True, description="用户唯一标识") + description: str = Field(description="会话描述") + is_deleted: bool = Field(default=False, description="是否删除") + dialogs: dict[str, dict[str, Any]] = Field(default_factory=dict, sa_type=JSON, description="对话字典") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + + +# 运行结果数据表模型 +class RunResults(SQLModel, table=True): + conversation_id: str = Field(primary_key=True, description="会话唯一标识") + dialog_id: str = Field(primary_key=True, description="对话唯一标识") + new_messages: str = Field(description="新增消息") @staticmethod def adapt( - conversation_id: str, run_id: str, new_message: List[ModelMessage] - ) -> "MessageHistory": + conversation_id: str, dialog_id: str, new_messages: list[ModelMessage] + ) -> "RunResults": """ - 适配新增消息为消息历史记录 - :param conversation_id: 对话唯一标识 - :param run_id: 运行唯一标识 - :param new_message: 新增消息 - :return: 消息历史记录 + 适配为运行结果记录 + :param conversation_id: 会话唯一标识 + :param dialog_id: 对话唯一标识 + :param new_messages: 新增消息 + :return: 运行结果记录 """ - return MessageHistory( + return RunResults( conversation_id=conversation_id, - run_id=run_id, - new_message=ModelMessagesTypeAdapter.dump_json(new_message).decode( + dialog_id=dialog_id, + new_messages=ModelMessagesTypeAdapter.dump_json(new_messages).decode( "utf-8" ), # 序列化为 JSON 字符串 ) - - -class ReasoningKind(StrEnum): - """推理种类""" - - THINKING = "thinking" - TOOL_SEARCH = "tool-search" - CAPABILITY_LOAD = "load-capability" - TOOL_CALL = "tool-call" - TEXT = "text" - TOOL_RETURN = "tool-return" - RETRY_PROMPT = "retry-prompt" - RUN_RETURN = "run-return" - - -class Reasoning(BaseModel): - """推理类""" - - kind: ReasoningKind = Field(..., description="推理种类") - content: str = Field(default="", description="推理内容") - - -class Run(BaseModel): - """运行类""" - - user_prompt: str = Field(..., description="用户提示词") - reasonings: Dict[int, Reasoning] = Field( - default_factory=dict, description="推理字典" - ) - is_reasoning: bool = Field( - default=False, - description="推理状态,True 表示正在推理,False 表示推理完成", - ) - is_reasoning_panel_open: bool = Field( - default=False, - description="推理面板展开状态,True 表示推理面板展开,False 表示推理面板折叠", - ) - assistant_content: str = Field(default="", description="回复正文") - is_running: bool = Field( - default=False, - description="运行状态,True 表示正在运行,False 表示运行完成", - ) - - -class Conversation(BaseModel): - """对话类""" - - description: str = Field(default="新对话", description="描述") - runs: Dict[str, Run] = Field(default_factory=dict, description="运行字典") diff --git a/agent/application/pages/__init__.py b/agent/application/pages/__init__.py index e69de29..6fa28b7 100644 --- a/agent/application/pages/__init__.py +++ b/agent/application/pages/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from application.pages.index import index +from application.pages.conversation import conversation +from application.pages.library import library + + +__all__ = ["index", "conversation", "library"] diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index ad6a369..88e35d5 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -1,27 +1,24 @@ # -*- coding: utf-8 -*- """ -对话页面 +会话页面 """ import reflex as rx -from application.components.conversation import ( +from application.components import ( render_conversation_box, render_conversation_history_item, render_input_box, render_create_conversation_button, ) -from application.state.conversation import ConversationState -from application.state.conversation_history_collapse import ( - ConversationHistoryCollapseState, -) +from application.states import ConversationState, ConversationHistoryState -def render_conversation_page() -> rx.Component: +def conversation() -> rx.Component: """ - 渲染对话页面:左侧为对话历史折叠面板,右侧为对话历史、运行历史、新建和输入等组件 + 会话页面:布局参考 MetaChat,左侧为会话历史,右侧为会话。其中,会话历史为折叠面板。 """ - # 对话历史折叠面板打开状态 - is_open = ConversationHistoryCollapseState.is_open + # 会话历史折叠面板打开状态 + is_open = ConversationHistoryState.is_open return rx.box( rx.hstack( diff --git a/agent/application/pages/index.py b/agent/application/pages/index.py index 3668edb..11adbc3 100644 --- a/agent/application/pages/index.py +++ b/agent/application/pages/index.py @@ -1,36 +1,36 @@ # -*- coding: utf-8 -*- """ -根页面 +首页 """ - import reflex as rx -from application.components.frame import render_sidebar -from application.pages.conversation import render_conversation_page -from application.pages.knowledge_base import render_knowledge_base_page -from application.state.frame import FrameState +from application.components import sidebar +from application.pages import conversation, library +from application.states import AuthState, SidebarState -def render_index_page() -> rx.Component: +def index() -> rx.Component: """ - 渲染根页面 + 首页 """ - + return rx.hstack( # 渲染侧边栏 - render_sidebar(), - # 根据选中侧边栏图标导航按钮渲染相应页面 + sidebar(), + # 根据用户所点中的侧边栏图标导航按钮渲染相应页面 rx.match( - FrameState.sidebar_icon_nav_button, - # 渲染对话页面 - ("conversation", render_conversation_page()), + SidebarState.get_activated_button, # 渲染知识库页面 - ("knowledge_base", render_knowledge_base_page()), + ("library", library()), + # 渲染会话页面 + conversation(), ), - width="100%", # 宽度 - height="100vh", # 高度 - padding="8px 8px 8px 0", # 内边距 - overflow="auto", # 垂直方向拉伸至父元素高度 - box_sizing="border-box", # 盒模型 - background="var(--mc-global-bg)", # 背景颜色 + 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/pages/knowledge_base.py b/agent/application/pages/library.py similarity index 54% rename from agent/application/pages/knowledge_base.py rename to agent/application/pages/library.py index d1104f3..12f5f5e 100644 --- a/agent/application/pages/knowledge_base.py +++ b/agent/application/pages/library.py @@ -5,8 +5,8 @@ import reflex as rx -def render_knowledge_base_page() -> rx.Component: +def library() -> rx.Component: """ - 渲染知识库页面:暂时为空 + 知识库页面 """ return rx.fragment() diff --git a/agent/application/state/__init__.py b/agent/application/state/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/agent/application/state/conversation_history_collapse.py b/agent/application/state/conversation_history_collapse.py deleted file mode 100644 index 266cfa6..0000000 --- a/agent/application/state/conversation_history_collapse.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -""" -对话历史折叠面板状态 -""" -import reflex as rx - - -class ConversationHistoryCollapseState(rx.State): - """ - 对话历史折叠面板状态 - """ - - # 对话历史折叠面板打开状态,True表示打开,False表示关闭 - is_open: bool = False - - @rx.event - def toggle(self) -> None: - """ - 打开/关闭对话历史折叠面板 - :param is_open: 打开状态 - :return: None - """ - self.is_open = not self.is_open diff --git a/agent/application/state/create_conversation_modal.py b/agent/application/state/create_conversation_modal.py deleted file mode 100644 index 136d404..0000000 --- a/agent/application/state/create_conversation_modal.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -""" -创建对话模态窗状态 -""" -import reflex as rx - - -class CreateConversationModalState(rx.State): - """ - 创建对话模态窗状态 - """ - - # 创建对话模态窗打开状态,True表示打开,False表示关闭 - is_open: bool = False - - @rx.event - def toggle(self) -> None: - """ - 打开/关闭创建对话模态窗 - """ - self.is_open = not self.is_open diff --git a/agent/application/state/database.py b/agent/application/state/database.py deleted file mode 100644 index 460359d..0000000 --- a/agent/application/state/database.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -""" -数据库状态 -""" - -from typing import List - -import reflex as rx -import sqlalchemy as sa -from sqlmodel import select as sql_select - -from application.models import MessageHistory, ModelMessage, ModelMessagesTypeAdapter - - -class DatabaseState(rx.State): - """ - 数据库状态 - 重新初始化:先手动删除 alembic 相关配置和文件夹,再使用 reflex db init 初始化数据库表 - """ - - async def get_message_history(self, conversation_id: str) -> List[ModelMessage]: - """ - 获取消息历史 - :param conversation_id: 对话唯一标识 - :return: 消息历史 - """ - message_history = [] - async with rx.asession() as session: - records = await session.exec( - sql_select(MessageHistory) - .where(MessageHistory.conversation_id == conversation_id) - .order_by(sa.desc("run_id")) - ) - for record in records.all(): - message_history.extend( - ModelMessagesTypeAdapter.validate_json(record.new_message) - ) - return message_history - - async def save_new_messages( - self, conversation_id: str, run_id: str, new_messages: List[ModelMessage] - ) -> None: - """ - 保存新增消息 - :param conversation_id: 对话唯一标识 - :param run_id: 运行唯一标识 - :param new_messages: 新增消息 - :return: None - """ - async with rx.asession() as session: - record = MessageHistory.adapt( - conversation_id=conversation_id, - run_id=run_id, - new_message=new_messages, - ) - session.add(record) - await session.commit() diff --git a/agent/application/state/frame.py b/agent/application/state/frame.py deleted file mode 100644 index a636c1f..0000000 --- a/agent/application/state/frame.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -""" -框架状态 -""" -import reflex as rx - - -class FrameState(rx.State): - """ - 框架状态 - """ - - # 选中侧边栏图标导航按钮 - sidebar_icon_nav_button: str = "conversation" - - @rx.var - def get_sidebar_icon_nav_button(self) -> str: - """获取选中侧边栏图标导航按钮""" - return self.sidebar_icon_nav_button - - @rx.event - def switch_active_sidebar_tab(self, sidebar_icon_nav_button: str): - """将指定侧边栏图标导航按钮设置为选中侧边栏图标导航按钮""" - self.sidebar_icon_nav_button = sidebar_icon_nav_button diff --git a/agent/application/states/__init__.py b/agent/application/states/__init__.py new file mode 100644 index 0000000..60e779e --- /dev/null +++ b/agent/application/states/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from application.states.auth import AuthState +from application.states.conversation import ConversationState, Conversation, Dialog, ReasoningPart +from application.states.create_conversation import CreateConversationState +from application.states.conversation_history import ConversationHistoryState +from application.states.database import DatabaseState +from application.states.sidebar import SidebarState + + +__all__ = [ + "AuthState", + "ConversationState", + "ConversationHistoryState", + "CreateConversationState", + "DatabaseState", + "SidebarState", + "Conversation", + "Dialog", + "ReasoningPart", +] diff --git a/agent/application/states/auth.py b/agent/application/states/auth.py new file mode 100644 index 0000000..859d483 --- /dev/null +++ b/agent/application/states/auth.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" +认证状态 +""" +import reflex as rx + + +class AuthState(rx.State): + """ + 认证状态 + """ + # 当前登录的用户唯一标识,绑定 LocalStorage + user_id = rx.LocalStorage("user_id", sync=True) + + @rx.event + async def authenticate(self) -> None: + """ + 认证 + """ + + self.user_id = "1" + diff --git a/agent/application/state/conversation.py b/agent/application/states/conversation.py similarity index 60% rename from agent/application/state/conversation.py rename to agent/application/states/conversation.py index 29c87c6..e8acec5 100644 --- a/agent/application/state/conversation.py +++ b/agent/application/states/conversation.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- """ -对话状态 +智能体状态 """ +from datetime import datetime +from enum import StrEnum from typing import Any, AsyncGenerator, Dict +from typing import Dict, List +from pydantic import BaseModel, Field from pydantic_ai import Agent, ThinkingPartDelta from pydantic_ai._uuid import uuid7 +from pydantic_ai._uuid import uuid7 from pydantic_ai.messages import ( FunctionToolCallEvent, FunctionToolResultEvent, @@ -19,14 +24,71 @@ from pydantic_ai.messages import ( ToolCallPart, ToolSearchCallPart, ) +from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.run import AgentRunResultEvent import reflex as rx +from sqlmodel import Field as SqlField, SQLModel -from application.models import Conversation, Reasoning, ReasoningKind, Run -from application.state.create_conversation_modal import CreateConversationModalState -from application.state.database import DatabaseState +from application.states import CreateConversationState, DatabaseState + + +class ReasoningPartKind(StrEnum): + """ + 推理分片种类 + """ + + THINKING = "thinking" + TOOL_SEARCH = "tool-search" + CAPABILITY_LOAD = "load-capability" + TOOL_CALL = "tool-call" + TEXT = "text" + TOOL_RETURN = "tool-return" + RETRY_PROMPT = "retry-prompt" + RUN_RETURN = "run-return" + + +class ReasoningPart(BaseModel): + """ + 推理分片内存模型 + """ + + kind: ReasoningPartKind = Field(..., description="推理分片种类") + content: str = Field(default="", description="推理分片内容") + + +class Dialog(BaseModel): + """ + 对话内存模型 + """ + + user_prompt: str = Field(..., description="用户提示词") + is_reasoning: bool = Field( + default=False, + description="推理状态,True 表示正在推理,False 表示推理完成", + ) + is_collapse_open: bool = Field( + default=False, + description="推理面板展开状态,True 表示推理面板展开,False 表示推理面板折叠", + ) + reasoning_parts: Dict[int, ReasoningPart] = Field( + default_factory=dict, description="推理分片字典" + ) + assistant_content: str = Field(default="", description="回复正文") + is_running: bool = Field( + default=False, + description="运行状态,True 表示正在运行,False 表示运行完成", + ) + + +class Conversation(BaseModel): + """ + 会话内存模型 + """ + + description: str = Field(default="新会话", description="会话描述") + dialogs: Dict[str, Dialog] = Field(default_factory=dict, description="对话字典") instructions: str = """ @@ -44,8 +106,7 @@ instructions: str = """ # 行文要求 语言通俗,逻辑完整简洁,无多余废话。 """ - -# 初始化智能体 +# 实例化智能体(因无法序列化故剥离出状态管理) agent: Agent = Agent( model=OpenAIChatModel( model_name="deepseek-v4-flash", @@ -63,70 +124,68 @@ agent: Agent = Agent( class ConversationState(rx.State): """ - 对话状态 + 会话状态 """ - # 初始化对话字典 + # 初始化会话字典 conversations: Dict[str, Conversation] = {str(uuid7()): Conversation()} - # 当前对话唯一标识 + # 获取当前会话唯一标识 conversation_id: str = next(reversed(conversations.keys())) @rx.var - def get_conversation_history(self) -> Dict[str, Conversation]: + def get_chats(self) -> Dict[str, Conversation]: """ - 获取对话历史,用于前端渲染对话历史 - :return: 对话历史 + 获取会话字典,用于前端渲染会话历史 + :return: 会话字典 """ return dict(reversed(self.conversations.items())) @rx.event - async def create_conversation(self, form_data: Dict[str, Any]) -> None: + async def create_chat(self, form_data: Dict[str, Any]) -> None: """ - 新建对话 + 新建会话 :param form_data: 表单数据 :return: None """ - # 获取描述 + # 获取会话描述 description = form_data["description"].strip() if not description: - description = "新对话" + description = "新会话" - # 新建对话 + # 新建会话 self.conversations[str(uuid7())] = Conversation(description=description) - # 将末位对话唯一标识设置为当前对话唯一标识 + # 将末位会话唯一标识设置为当前会话唯一标识 self.conversation_id = next(reversed(self.conversations.keys())) - # 获取新建对话模态窗状态 - create_conversation_modal_state = await self.get_state( - CreateConversationModalState - ) - # 关闭新建对话模态窗 - create_conversation_modal_state.is_open = False + # 获取新建会话状态 + create_chat_state = await self.get_state(CreateConversationState) + # 关闭新建会话模态窗 + create_chat_state.is_open = False @rx.event def delete_conversation(self, conversation_id: str) -> None: """ - 删除对话 - :param conversation_id: 对话唯一标识 + 删除会话 + :param conversation_id: 会话唯一标识 :return: None """ if conversation_id not in self.conversations: return del self.conversations[conversation_id] - # 删除后,若对话字典为空则创建对话 + # 删除后,若会话字典为空则创建会话 if not self.conversations: self.conversations[str(uuid7())] = Conversation() - # 删除后,若当前对话唯一标识不存在则将末位对话唯一标识设置为当前对话唯一标识 + # 删除后,若当前会话唯一标识不存在则将末位会话唯一标识设置为当前会话唯一标识 if self.conversation_id not in self.conversations: self.conversation_id = next(reversed(self.conversations.keys())) @rx.event def switch_conversation(self, conversation_id: str) -> None: """ - 将指定对话唯一标识设置为当前对话唯一标识 - :param conversation_id: 指定对话唯一标识 + 将指定会话唯一标识设置为当前会话唯一标识 + :param conversation_id: 指定会话唯一标识 :return: None """ if conversation_id not in self.conversations: @@ -136,22 +195,22 @@ class ConversationState(rx.State): @rx.var def get_conversation_description(self) -> str: """ - 获取当前对话描述,用于前端渲染对话标题 - :return: 当前对话描述 + 获取当前会话描述,用于前端渲染会话标题 + :return: 当前会话描述 """ - # 当前对话 + # 当前会话 conversation = self.conversations.get(self.conversation_id) - return conversation.description if conversation else "新对话" + return conversation.description if conversation else "新会话" @rx.var - def get_runs(self) -> Dict[str, Run]: + def get_dialogs(self) -> Dict[str, Dialog]: """ - 获取运行字典,用于前端渲染运行历史 - :return: 运行字典 + 获取对话字典,用于前端渲染对话历史 + :return: 对话字典 """ - # 当前对话 + # 当前会话 conversation = self.conversations.get(self.conversation_id) - return conversation.runs if conversation else {} + return conversation.dialogs if conversation else {} @rx.var def get_running_status(self) -> bool: @@ -159,14 +218,14 @@ class ConversationState(rx.State): 获取当前运行状态 :return: 当前运行状态(True 表示正在运行,False 表示运行完成) """ - # 当前对话 + # 当前会话 conversation = self.conversations.get(self.conversation_id) - if not conversation or not conversation.runs: + if not conversation or not conversation.dialogs: return False - # 末位运行 - run = next(reversed(conversation.runs.values())) - return run.is_running + # 末位对话 + dialog = next(reversed(conversation.dialogs.values())) + return dialog.is_running @rx.event async def run(self, form_data: dict[str, Any]) -> AsyncGenerator[None]: @@ -175,7 +234,7 @@ class ConversationState(rx.State): :param form_data: 表单数据 :return: AsyncGenerator """ - # 当前对话 + # 当前会话 conversation = self.conversations.get(self.conversation_id) if not conversation: return @@ -195,12 +254,12 @@ class ConversationState(rx.State): # 初始化工具调用唯一标识和片段索引映射字典 tool_call_ids: Dict[str, int] = {} - # 创建运行 - conversation.runs[str(uuid7())] = Run(user_prompt=user_prompt) - # 将末位运行唯一标识、运行设置为当前运行唯一标识、运行 - run_id, run = next(reversed(conversation.runs.items())) + # 创建对话 + conversation.dialogs[str(uuid7())] = Dialog(user_prompt=user_prompt) + # 将末位对话唯一标识、对话设置为当前对话唯一标识、对话 + dialog_id, dialog = next(reversed(conversation.dialogs.items())) # 将运行状态设置为正在运行 - run.is_running = True + dialog.is_running = True yield # 通知前端渲染 async with agent.run_stream_events( @@ -221,11 +280,11 @@ class ConversationState(rx.State): case ThinkingPart(content=content): # 若上一分片种类为空则将推理状态设置为正在推理、推理面板展开状态设置为展开 if not previous_part_kind: - run.is_reasoning = True - run.is_reasoning_panel_open = True + dialog.is_reasoning = True + dialog.is_collapse_open = True - run.reasonings[index] = Reasoning( - kind=ReasoningKind.THINKING, content=content + dialog.reasoning_parts[index] = ReasoningPart( + kind=ReasoningPartKind.THINKING, content=content ) yield @@ -234,8 +293,8 @@ class ConversationState(rx.State): # 创建工具调用唯一标识与片段索引映射 tool_call_ids[tool_call_id] = index - run.reasonings[index] = Reasoning( - kind=ReasoningKind.TOOL_SEARCH, + dialog.reasoning_parts[index] = ReasoningPart( + kind=ReasoningPartKind.TOOL_SEARCH, content="正在生成检索关键词", ) yield @@ -244,8 +303,8 @@ class ConversationState(rx.State): case LoadCapabilityCallPart(tool_call_id=tool_call_id): tool_call_ids[tool_call_id] = index - run.reasonings[index] = Reasoning( - kind=ReasoningKind.CAPABILITY_LOAD, + dialog.reasoning_parts[index] = ReasoningPart( + kind=ReasoningPartKind.CAPABILITY_LOAD, content="正在生成加载参数", ) yield @@ -254,15 +313,15 @@ class ConversationState(rx.State): case ToolCallPart(tool_call_id=tool_call_id): tool_call_ids[tool_call_id] = index - run.reasonings[index] = Reasoning( - kind=ReasoningKind.TOOL_CALL, + dialog.reasoning_parts[index] = ReasoningPart( + kind=ReasoningPartKind.TOOL_CALL, content="正在生成调用参数", ) yield # 文本分片开始事件 case TextPart(content=content): - run.assistant_content = content + dialog.assistant_content = content yield # ========== 增量事件 ========== @@ -271,15 +330,15 @@ class ConversationState(rx.State): # 思考分片增量事件 case ThinkingPartDelta( content_delta=content_delta, - ): - run.reasonings[index].content += content_delta or "" + ): + dialog.reasoning_parts[index].content += content_delta or "" yield # 文本分片增量事件 case TextPartDelta( content_delta=content_delta, ): - run.assistant_content += content_delta + dialog.assistant_content += content_delta yield # ========== 结束事件 ========== @@ -292,31 +351,31 @@ class ConversationState(rx.State): # 思考分片结束事件 case ThinkingPart(part_kind=part_kind, content=content): # 若下一分片种类为文本则将推理状态设置为推理完成、推理面板展开状态设置为折叠 - if next_part_kind == ReasoningKind.TEXT: - run.is_reasoning = False - run.is_reasoning_panel_open = False + if next_part_kind == ReasoningPartKind.TEXT: + dialog.is_reasoning = False + dialog.is_collapse_open = False yield # ========== 函数工具调用事件 ========== case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part): # 获取分片索引 index = tool_call_ids[tool_call_id] - match run.reasonings[index].kind: + match dialog.reasoning_parts[index].kind: # 工具检索 - case ReasoningKind.TOOL_SEARCH: - run.reasonings[index].content = "正在检索" + case ReasoningPartKind.TOOL_SEARCH: + dialog.reasoning_parts[index].content = "正在检索" yield # 能力加载 - case ReasoningKind.CAPABILITY_LOAD: - run.reasonings[index].content = ( + case ReasoningPartKind.CAPABILITY_LOAD: + dialog.reasoning_parts[index].content = ( f"正在加载能力 {part.tool_name}" ) yield # 工具调用 - case ReasoningKind.TOOL_CALL: - run.reasonings[index].content = ( + case ReasoningPartKind.TOOL_CALL: + dialog.reasoning_parts[index].content = ( f"正在调用工具 {part.tool_name}" ) yield @@ -327,22 +386,22 @@ class ConversationState(rx.State): content=content, ): index = tool_call_ids[tool_call_id] - match run.reasonings[index].kind: + match dialog.reasoning_parts[index].kind: # 工具检索 - case ReasoningKind.TOOL_SEARCH: - run.reasonings[index].content = ( + case ReasoningPartKind.TOOL_SEARCH: + dialog.reasoning_parts[index].content = ( content if isinstance(content, str) else "" ) # 暂仅考虑文本内容 yield # 能力加载 - case ReasoningKind.CAPABILITY_LOAD: - run.reasonings[index].content = "已加载" + case ReasoningPartKind.CAPABILITY_LOAD: + dialog.reasoning_parts[index].content = "已加载" yield # 工具调用 - case ReasoningKind.TOOL_CALL: - run.reasonings[index].content = f"已调用" + case ReasoningPartKind.TOOL_CALL: + dialog.reasoning_parts[index].content = f"已调用" yield # ========== 智能体运行结果事件 ========== @@ -350,18 +409,18 @@ class ConversationState(rx.State): # 保存新增消息 await database_state.save_new_messages( conversation_id=self.conversation_id, - run_id=run_id, + dialog_id=dialog_id, new_messages=result.new_messages(), ) # 将运行状态设置为运行完成 - run.is_running = False + dialog.is_running = False yield @rx.event - def toggle_reasoning_panel(self, run_id: str) -> None: + def toggle_reasoning_panel(self, dialog_id: str) -> None: """ 展开/折叠指定运行唯一标识的推理面板 """ # 指定运行 - run = self.conversations[self.conversation_id].runs[run_id] - run.is_reasoning_panel_open = not run.is_reasoning_panel_open + dialog = self.conversations[self.conversation_id].dialogs[dialog_id] + dialog.is_collapse_open = not dialog.is_collapse_open diff --git a/agent/application/states/conversation_history.py b/agent/application/states/conversation_history.py new file mode 100644 index 0000000..ce39be6 --- /dev/null +++ b/agent/application/states/conversation_history.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" +会话历史状态 +""" +import reflex as rx + + +class ConversationHistoryState(rx.State): + """ + 会话历史状态 + """ + + # 会话历史折叠面板打开状态,True表示打开,False表示关闭 + is_collapse_open: bool = False + + @rx.event + def toggle_collapse(self) -> None: + """ + 打开/关闭会话历史折叠面板 + :return: None + """ + self.is_collapse_open = not self.is_collapse_open diff --git a/agent/application/states/create_conversation.py b/agent/application/states/create_conversation.py new file mode 100644 index 0000000..26351dd --- /dev/null +++ b/agent/application/states/create_conversation.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" +新建会话状态 +""" +import reflex as rx + + +class CreateConversationState(rx.State): + """ + 新建会话状态 + """ + + # 新建会话模态窗打开状态,True表示打开,False表示关闭 + is_modal_open: bool = False + + @rx.event + def toggle_modal(self) -> None: + """ + 打开/关闭新建会话模态窗 + """ + self.is_modal_open = not self.is_modal_open diff --git a/agent/application/states/database.py b/agent/application/states/database.py new file mode 100644 index 0000000..f2fa91b --- /dev/null +++ b/agent/application/states/database.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +数据库状态 +""" + +from typing import List, Dict + +import reflex as rx +from sqlalchemy import desc, and_ +from sqlmodel import select + +from application.models import Conversations, RunResults +from pydantic_ai import ModelMessage, ModelMessagesTypeAdapter + + +class DatabaseState(rx.State): + """ + 数据库状态 + """ + + async def get_conversations(self, user_id: str) -> Dict[str, Conversation]: + """ + 获取指定用户的会话字典 + :param user_id: 用户唯一标识 + :return: 指定用户的会话字典 + """ + conversations: Dict[str, Conversation] = {} + async with rx.asession() as session: + result = await session.exec( + select(Conversations, Dialogs, Parts) + .outerjoin(Dialogs, Conversations.id == Dialogs.conversation_id) # type: ignore + .outerjoin(Parts, Dialogs.id == Parts.dialog_id) # type: ignore + .where( + models.Conversation.user_id == user_id, + models.Conversation.is_deleted == False, + ) + .order_by( + models.Conversation.id, models.Dialog.id, models.ReasoningPart.id + ) + ) + records = result.all() + for db_conversation, _, _ in records: + conversations[conversation.id] = models.Conversation( + user_id=user_id, description=conversation.description + ) + + return conversations + + 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( + self, + conversation_id: str, + dialog_id: str, + new_messages: List[ModelMessage], + ) -> None: + """ + 保存新增消息 + :param conversation_id: 会话唯一标识 + :param dialog_id: 对话唯一标识 + :param new_messages: 新增消息 + :return: None + """ + async with rx.asession() as session: + record = RunResults.adapt( + conversation_id=conversation_id, + dialog_id=dialog_id, + new_messages=new_messages, + ) + session.add(record) + await session.commit() diff --git a/agent/application/states/sidebar.py b/agent/application/states/sidebar.py new file mode 100644 index 0000000..3ac10ba --- /dev/null +++ b/agent/application/states/sidebar.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +""" +侧边栏状态 +""" +import reflex as rx + +from application.states import AuthState + + +class SidebarState(rx.State): + """ + 侧边栏状态 + """ + + @rx.var + async def generate_storage_key(self) -> str: + """ + 生成 LocalStorage 键 + """ + auth = await self.get_state(AuthState) + # 获取用户唯一标识 + user_id = auth.user_id or "default" + return f"sidebar_{user_id}" + + # 侧边栏中激活的图标导航按钮 + activated_button = rx.LocalStorage(generate_storage_key, sync=True) + + @rx.var + def get_activated_button(self) -> str: + """ + 获取侧边栏中激活的图标导航按钮 + """ + return self.activated_button + + @rx.event + def switch_activated_button(self, button: str): + """ + 将指定图标导航按钮设置为侧边栏中激活的图标导航按钮 + """ + self.activated_button = button diff --git a/agent/application/template.py b/agent/application/template.py new file mode 100644 index 0000000..0c66f22 --- /dev/null +++ b/agent/application/template.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +页面模板 +""" \ No newline at end of file diff --git a/agent/assets/knowledge_base.svg b/agent/assets/library.svg similarity index 100% rename from agent/assets/knowledge_base.svg rename to agent/assets/library.svg diff --git a/agent/reflex.lock/package.json b/agent/reflex.lock/package.json index 8873239..d15ac2e 100644 --- a/agent/reflex.lock/package.json +++ b/agent/reflex.lock/package.json @@ -1,43 +1 @@ -{ - "name": "reflex", - "type": "module", - "scripts": { - "dev": "react-router dev --host", - "export": "react-router build" - }, - "dependencies": { - "@radix-ui/react-form": "0.1.8", - "@radix-ui/themes": "3.3.0", - "@react-router/node": "7.15.0", - "isbot": "5.1.40", - "lucide-react": "1.14.0", - "react": "19.2.6", - "react-dom": "19.2.6", - "react-error-boundary": "6.1.1", - "react-helmet": "6.1.0", - "react-markdown": "10.1.0", - "react-router": "7.15.0", - "react-router-dom": "7.15.0", - "react-syntax-highlighter": "16.1.1", - "rehype-katex": "7.0.1", - "rehype-raw": "7.0.0", - "rehype-unwrap-images": "1.0.0", - "remark-gfm": "4.0.1", - "remark-math": "6.0.0", - "socket.io-client": "4.8.3", - "sonner": "2.0.7", - "universal-cookie": "7.2.2" - }, - "devDependencies": { - "@emotion/react": "11.14.0", - "@react-router/dev": "7.15.0", - "@react-router/fs-routes": "7.15.0", - "autoprefixer": "10.5.0", - "postcss": "8.5.14", - "postcss-import": "16.1.1", - "vite": "8.0.16" - }, - "overrides": { - "cookie": "1.1.1" - } -} \ No newline at end of file +{"name": "reflex", "type": "module", "scripts": {"dev": "react-router dev --host", "export": "react-router build"}, "dependencies": {"@radix-ui/react-form": "0.1.8", "@radix-ui/themes": "3.3.0", "@react-router/node": "7.15.0", "isbot": "5.1.40", "lucide-react": "1.14.0", "react": "19.2.6", "react-dom": "19.2.6", "react-error-boundary": "6.1.1", "react-helmet": "6.1.0", "react-markdown": "10.1.0", "react-router": "7.15.0", "react-router-dom": "7.15.0", "react-syntax-highlighter": "16.1.1", "rehype-katex": "7.0.1", "rehype-raw": "7.0.0", "rehype-unwrap-images": "1.0.0", "remark-gfm": "4.0.1", "remark-math": "6.0.0", "socket.io-client": "4.8.3", "sonner": "2.0.7", "universal-cookie": "7.2.2"}, "devDependencies": {"@emotion/react": "11.14.0", "@react-router/dev": "7.15.0", "@react-router/fs-routes": "7.15.0", "autoprefixer": "10.5.0", "postcss": "8.5.14", "postcss-import": "16.1.1", "vite": "8.0.16"}, "overrides": {"cookie": "1.1.1"}} \ No newline at end of file