This commit is contained in:
liubiren 2026-07-12 15:04:11 +08:00
parent 524290c2b7
commit 78175a1ec4
25 changed files with 562 additions and 478 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
__pycache__/ __pycache__/
**/__pycache__/
.DS_Store .DS_Store
.gitignore .gitignore

View File

@ -3,7 +3,7 @@
应用主入口 应用主入口
""" """
import reflex as rx import reflex as rx
from application.pages.index import render_index_page from application.pages import index
app = rx.App( app = rx.App(
style={ style={
@ -20,20 +20,21 @@ app = rx.App(
"*::before, *::after": { "*::before, *::after": {
"box_sizing": "border-box", "box_sizing": "border-box",
}, },
"--devui-base-bg": "#ffffff", "--prismui-background-color": "#ffffff",
"--devui-global-bg": "#f6f6f8", "--devui-global-bg": "#f6f6f8",
"--mc-global-bg": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)", "--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)", "--mc-box-shadow": "rgba(25, 25, 25, .06)",
"--devui-shadow-length-connected-overlay": "0px 0px 12px 0px rgba(0, 0, 0, 0.1)", "--devui-shadow-length-connected-overlay": "0px 0px 12px 0px rgba(0, 0, 0, 0.1)",
"--devui-icon-fill-weak": "#babbc0", "--devui-icon-fill-weak": "#babbc0",
"--prismui-background-color-weak": "#babbc0",
"--devui-border-radius-feedback": "4px", "--devui-border-radius-feedback": "4px",
"--devui-border-radius-card": "8px", "--prismui-border-radius": "8px",
"--devui-border-radius-full": "100px", "--devui-border-radius-full": "100px",
"--font-family": "HuaweiFont,Helvetica,Arial,PingFangSC-Regular,Hiragino Sans GB,Microsoft YaHei,微软雅黑,Microsoft JhengHei", "--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": "14px",
"--devui-font-size-lg": "14px", "--devui-font-size-lg": "14px",
"--devui-text": "#252b3a", "--prismui-color-text": "#252b3a",
"--devui-text-weak": "#575d6c", "--devui-text-weak": "#575d6c",
"--devui-light-text": "#ffffff", "--devui-light-text": "#ffffff",
"--placeholder": "#babbc0", "--placeholder": "#babbc0",
@ -45,4 +46,4 @@ app = rx.App(
}, # 自定义主题颜色 }, # 自定义主题颜色
) # 此处变量名需使用 app ,具体原因目前尚不清楚 ) # 此处变量名需使用 app ,具体原因目前尚不清楚
# 注册首页路由 # 注册首页路由
app.add_page(component=render_index_page, route="/") app.add_page(component=index, route="/")

View File

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

View File

@ -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", # 盒模型
)

View File

@ -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",
)

View File

@ -3,90 +3,48 @@
数据模型 数据模型
""" """
from datetime import datetime from datetime import datetime
from enum import StrEnum from typing import Any
from typing import Dict, List
from pydantic import BaseModel, Field
from pydantic_ai._uuid import uuid7 from pydantic_ai._uuid import uuid7
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter 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): class Conversations(SQLModel, table=True):
id: str = SqlField( id: str = Field(
default_factory=lambda: str(uuid7()), default_factory=lambda: str(uuid7()),
primary_key=True, primary_key=True,
description="消息唯一标识", description="会话唯一标识",
) )
conversation_id: str = SqlField(index=True, description="对话唯一标识") user_id: str = Field(index=True, description="用户唯一标识")
run_id: str = SqlField(index=True, description="运行唯一标识") description: str = Field(description="会话描述")
new_message: str = SqlField(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 @staticmethod
def adapt( def adapt(
conversation_id: str, run_id: str, new_message: List[ModelMessage] conversation_id: str, dialog_id: str, new_messages: list[ModelMessage]
) -> "MessageHistory": ) -> "RunResults":
""" """
适配新增消息为消息历史记录 适配为运行结果记录
:param conversation_id: 对话唯一标识 :param conversation_id: 话唯一标识
:param run_id: 运行唯一标识 :param dialog_id: 对话唯一标识
:param new_message: 新增消息 :param new_messages: 新增消息
:return: 消息历史记录 :return: 运行结果记录
""" """
return MessageHistory( return RunResults(
conversation_id=conversation_id, conversation_id=conversation_id,
run_id=run_id, dialog_id=dialog_id,
new_message=ModelMessagesTypeAdapter.dump_json(new_message).decode( new_messages=ModelMessagesTypeAdapter.dump_json(new_messages).decode(
"utf-8" "utf-8"
), # 序列化为 JSON 字符串 ), # 序列化为 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="运行字典")

View File

@ -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"]

View File

@ -1,27 +1,24 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
话页面 话页面
""" """
import reflex as rx import reflex as rx
from application.components.conversation import ( from application.components import (
render_conversation_box, render_conversation_box,
render_conversation_history_item, render_conversation_history_item,
render_input_box, render_input_box,
render_create_conversation_button, render_create_conversation_button,
) )
from application.state.conversation import ConversationState from application.states import ConversationState, ConversationHistoryState
from application.state.conversation_history_collapse import (
ConversationHistoryCollapseState,
)
def render_conversation_page() -> rx.Component: def conversation() -> rx.Component:
""" """
渲染对话页面左侧为对话历史折叠面板右侧为对话历史运行历史新建和输入等组件 会话页面布局参考 MetaChat左侧为会话历史右侧为会话其中会话历史为折叠面板
""" """
# 话历史折叠面板打开状态 # 话历史折叠面板打开状态
is_open = ConversationHistoryCollapseState.is_open is_open = ConversationHistoryState.is_open
return rx.box( return rx.box(
rx.hstack( rx.hstack(

View File

@ -1,36 +1,36 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
根页面 首页
""" """
import reflex as rx import reflex as rx
from application.components.frame import render_sidebar from application.components import sidebar
from application.pages.conversation import render_conversation_page from application.pages import conversation, library
from application.pages.knowledge_base import render_knowledge_base_page from application.states import AuthState, SidebarState
from application.state.frame import FrameState
def render_index_page() -> rx.Component: def index() -> rx.Component:
""" """
渲染根页面 首页
""" """
return rx.hstack( return rx.hstack(
# 渲染侧边栏 # 渲染侧边栏
render_sidebar(), sidebar(),
# 根据选中侧边栏图标导航按钮渲染相应页面 # 根据用户所点中的侧边栏图标导航按钮渲染相应页面
rx.match( rx.match(
FrameState.sidebar_icon_nav_button, SidebarState.get_activated_button,
# 渲染对话页面
("conversation", render_conversation_page()),
# 渲染知识库页面 # 渲染知识库页面
("knowledge_base", render_knowledge_base_page()), ("library", library()),
# 渲染会话页面
conversation(),
), ),
width="100%", # 宽度 width="100%",
height="100vh", # 高度 height="100vh",
padding="8px 8px 8px 0", # 内边距 padding="8px 8px 8px 0",
overflow="auto", # 垂直方向拉伸至父元素高度 overflow="auto",
box_sizing="border-box", # 盒模型 box_sizing="border-box",
background="var(--mc-global-bg)", # 背景颜色 background="var(--mc-global-bg)",
# 挂载事件:因测试需模拟已认证
on_mount=AuthState.authenticate(),
) )

View File

@ -5,8 +5,8 @@
import reflex as rx import reflex as rx
def render_knowledge_base_page() -> rx.Component: def library() -> rx.Component:
""" """
渲染知识库页面暂时为空 知识库页面
""" """
return rx.fragment() return rx.fragment()

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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",
]

View File

@ -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"

View File

@ -1,11 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
对话状态 智能体状态
""" """
from datetime import datetime
from enum import StrEnum
from typing import Any, AsyncGenerator, Dict 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 import Agent, ThinkingPartDelta
from pydantic_ai._uuid import uuid7 from pydantic_ai._uuid import uuid7
from pydantic_ai._uuid import uuid7
from pydantic_ai.messages import ( from pydantic_ai.messages import (
FunctionToolCallEvent, FunctionToolCallEvent,
FunctionToolResultEvent, FunctionToolResultEvent,
@ -19,14 +24,71 @@ from pydantic_ai.messages import (
ToolCallPart, ToolCallPart,
ToolSearchCallPart, ToolSearchCallPart,
) )
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.run import AgentRunResultEvent from pydantic_ai.run import AgentRunResultEvent
import reflex as rx import reflex as rx
from sqlmodel import Field as SqlField, SQLModel
from application.models import Conversation, Reasoning, ReasoningKind, Run from application.states import CreateConversationState, DatabaseState
from application.state.create_conversation_modal import CreateConversationModalState
from application.state.database import 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 = """ instructions: str = """
@ -44,8 +106,7 @@ instructions: str = """
# 行文要求 # 行文要求
语言通俗逻辑完整简洁无多余废话 语言通俗逻辑完整简洁无多余废话
""" """
# 实例化智能体(因无法序列化故剥离出状态管理)
# 初始化智能体
agent: Agent = Agent( agent: Agent = Agent(
model=OpenAIChatModel( model=OpenAIChatModel(
model_name="deepseek-v4-flash", model_name="deepseek-v4-flash",
@ -63,70 +124,68 @@ agent: Agent = Agent(
class ConversationState(rx.State): class ConversationState(rx.State):
""" """
话状态 话状态
""" """
# 初始化话字典 # 初始化话字典
conversations: Dict[str, Conversation] = {str(uuid7()): Conversation()} conversations: Dict[str, Conversation] = {str(uuid7()): Conversation()}
# 当前对话唯一标识 # 获取当前会话唯一标识
conversation_id: str = next(reversed(conversations.keys())) conversation_id: str = next(reversed(conversations.keys()))
@rx.var @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())) return dict(reversed(self.conversations.items()))
@rx.event @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: 表单数据 :param form_data: 表单数据
:return: None :return: None
""" """
# 获取描述 # 获取会话描述
description = form_data["description"].strip() description = form_data["description"].strip()
if not description: if not description:
description = "" description = ""
# 新建 # 新建
self.conversations[str(uuid7())] = Conversation(description=description) self.conversations[str(uuid7())] = Conversation(description=description)
# 将末位对话唯一标识设置为当前对话唯一标识 # 将末位会话唯一标识设置为当前会话唯一标识
self.conversation_id = next(reversed(self.conversations.keys())) self.conversation_id = next(reversed(self.conversations.keys()))
# 获取新建对话模态窗状态 # 获取新建会话状态
create_conversation_modal_state = await self.get_state( create_chat_state = await self.get_state(CreateConversationState)
CreateConversationModalState # 关闭新建会话模态窗
) create_chat_state.is_open = False
# 关闭新建对话模态窗
create_conversation_modal_state.is_open = False
@rx.event @rx.event
def delete_conversation(self, conversation_id: str) -> None: def delete_conversation(self, conversation_id: str) -> None:
""" """
删除 删除
:param conversation_id: 话唯一标识 :param conversation_id: 话唯一标识
:return: None :return: None
""" """
if conversation_id not in self.conversations: if conversation_id not in self.conversations:
return return
del self.conversations[conversation_id] del self.conversations[conversation_id]
# 删除后,若对话字典为空则创建对 # 删除后,若会话字典为空则创建会
if not self.conversations: if not self.conversations:
self.conversations[str(uuid7())] = Conversation() self.conversations[str(uuid7())] = Conversation()
# 删除后,若当前对话唯一标识不存在则将末位对话唯一标识设置为当前对话唯一标识 # 删除后,若当前会话唯一标识不存在则将末位会话唯一标识设置为当前会话唯一标识
if self.conversation_id not in self.conversations: if self.conversation_id not in self.conversations:
self.conversation_id = next(reversed(self.conversations.keys())) self.conversation_id = next(reversed(self.conversations.keys()))
@rx.event @rx.event
def switch_conversation(self, conversation_id: str) -> None: def switch_conversation(self, conversation_id: str) -> None:
""" """
将指定对话唯一标识设置为当前对话唯一标识 将指定会话唯一标识设置为当前会话唯一标识
:param conversation_id: 指定话唯一标识 :param conversation_id: 指定话唯一标识
:return: None :return: None
""" """
if conversation_id not in self.conversations: if conversation_id not in self.conversations:
@ -136,22 +195,22 @@ class ConversationState(rx.State):
@rx.var @rx.var
def get_conversation_description(self) -> str: def get_conversation_description(self) -> str:
""" """
获取当前对话描述用于前端渲染对话标题 获取当前会话描述用于前端渲染会话标题
:return: 当前话描述 :return: 当前话描述
""" """
# 当前 # 当前
conversation = self.conversations.get(self.conversation_id) conversation = self.conversations.get(self.conversation_id)
return conversation.description if conversation else "" return conversation.description if conversation else ""
@rx.var @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) conversation = self.conversations.get(self.conversation_id)
return conversation.runs if conversation else {} return conversation.dialogs if conversation else {}
@rx.var @rx.var
def get_running_status(self) -> bool: def get_running_status(self) -> bool:
@ -159,14 +218,14 @@ class ConversationState(rx.State):
获取当前运行状态 获取当前运行状态
:return: 当前运行状态True 表示正在运行False 表示运行完成 :return: 当前运行状态True 表示正在运行False 表示运行完成
""" """
# 当前 # 当前
conversation = self.conversations.get(self.conversation_id) conversation = self.conversations.get(self.conversation_id)
if not conversation or not conversation.runs: if not conversation or not conversation.dialogs:
return False return False
# 末位运行 # 末位对话
run = next(reversed(conversation.runs.values())) dialog = next(reversed(conversation.dialogs.values()))
return run.is_running return dialog.is_running
@rx.event @rx.event
async def run(self, form_data: dict[str, Any]) -> AsyncGenerator[None]: async def run(self, form_data: dict[str, Any]) -> AsyncGenerator[None]:
@ -175,7 +234,7 @@ class ConversationState(rx.State):
:param form_data: 表单数据 :param form_data: 表单数据
:return: AsyncGenerator :return: AsyncGenerator
""" """
# 当前 # 当前
conversation = self.conversations.get(self.conversation_id) conversation = self.conversations.get(self.conversation_id)
if not conversation: if not conversation:
return return
@ -195,12 +254,12 @@ class ConversationState(rx.State):
# 初始化工具调用唯一标识和片段索引映射字典 # 初始化工具调用唯一标识和片段索引映射字典
tool_call_ids: Dict[str, int] = {} tool_call_ids: Dict[str, int] = {}
# 创建运行 # 创建对话
conversation.runs[str(uuid7())] = Run(user_prompt=user_prompt) conversation.dialogs[str(uuid7())] = Dialog(user_prompt=user_prompt)
# 将末位运行唯一标识、运行设置为当前运行唯一标识、运行 # 将末位对话唯一标识、对话设置为当前对话唯一标识、对话
run_id, run = next(reversed(conversation.runs.items())) dialog_id, dialog = next(reversed(conversation.dialogs.items()))
# 将运行状态设置为正在运行 # 将运行状态设置为正在运行
run.is_running = True dialog.is_running = True
yield # 通知前端渲染 yield # 通知前端渲染
async with agent.run_stream_events( async with agent.run_stream_events(
@ -221,11 +280,11 @@ class ConversationState(rx.State):
case ThinkingPart(content=content): case ThinkingPart(content=content):
# 若上一分片种类为空则将推理状态设置为正在推理、推理面板展开状态设置为展开 # 若上一分片种类为空则将推理状态设置为正在推理、推理面板展开状态设置为展开
if not previous_part_kind: if not previous_part_kind:
run.is_reasoning = True dialog.is_reasoning = True
run.is_reasoning_panel_open = True dialog.is_collapse_open = True
run.reasonings[index] = Reasoning( dialog.reasoning_parts[index] = ReasoningPart(
kind=ReasoningKind.THINKING, content=content kind=ReasoningPartKind.THINKING, content=content
) )
yield yield
@ -234,8 +293,8 @@ class ConversationState(rx.State):
# 创建工具调用唯一标识与片段索引映射 # 创建工具调用唯一标识与片段索引映射
tool_call_ids[tool_call_id] = index tool_call_ids[tool_call_id] = index
run.reasonings[index] = Reasoning( dialog.reasoning_parts[index] = ReasoningPart(
kind=ReasoningKind.TOOL_SEARCH, kind=ReasoningPartKind.TOOL_SEARCH,
content="正在生成检索关键词", content="正在生成检索关键词",
) )
yield yield
@ -244,8 +303,8 @@ class ConversationState(rx.State):
case LoadCapabilityCallPart(tool_call_id=tool_call_id): case LoadCapabilityCallPart(tool_call_id=tool_call_id):
tool_call_ids[tool_call_id] = index tool_call_ids[tool_call_id] = index
run.reasonings[index] = Reasoning( dialog.reasoning_parts[index] = ReasoningPart(
kind=ReasoningKind.CAPABILITY_LOAD, kind=ReasoningPartKind.CAPABILITY_LOAD,
content="正在生成加载参数", content="正在生成加载参数",
) )
yield yield
@ -254,15 +313,15 @@ class ConversationState(rx.State):
case ToolCallPart(tool_call_id=tool_call_id): case ToolCallPart(tool_call_id=tool_call_id):
tool_call_ids[tool_call_id] = index tool_call_ids[tool_call_id] = index
run.reasonings[index] = Reasoning( dialog.reasoning_parts[index] = ReasoningPart(
kind=ReasoningKind.TOOL_CALL, kind=ReasoningPartKind.TOOL_CALL,
content="正在生成调用参数", content="正在生成调用参数",
) )
yield yield
# 文本分片开始事件 # 文本分片开始事件
case TextPart(content=content): case TextPart(content=content):
run.assistant_content = content dialog.assistant_content = content
yield yield
# ========== 增量事件 ========== # ========== 增量事件 ==========
@ -271,15 +330,15 @@ class ConversationState(rx.State):
# 思考分片增量事件 # 思考分片增量事件
case ThinkingPartDelta( case ThinkingPartDelta(
content_delta=content_delta, content_delta=content_delta,
): ):
run.reasonings[index].content += content_delta or "" dialog.reasoning_parts[index].content += content_delta or ""
yield yield
# 文本分片增量事件 # 文本分片增量事件
case TextPartDelta( case TextPartDelta(
content_delta=content_delta, content_delta=content_delta,
): ):
run.assistant_content += content_delta dialog.assistant_content += content_delta
yield yield
# ========== 结束事件 ========== # ========== 结束事件 ==========
@ -292,31 +351,31 @@ class ConversationState(rx.State):
# 思考分片结束事件 # 思考分片结束事件
case ThinkingPart(part_kind=part_kind, content=content): case ThinkingPart(part_kind=part_kind, content=content):
# 若下一分片种类为文本则将推理状态设置为推理完成、推理面板展开状态设置为折叠 # 若下一分片种类为文本则将推理状态设置为推理完成、推理面板展开状态设置为折叠
if next_part_kind == ReasoningKind.TEXT: if next_part_kind == ReasoningPartKind.TEXT:
run.is_reasoning = False dialog.is_reasoning = False
run.is_reasoning_panel_open = False dialog.is_collapse_open = False
yield yield
# ========== 函数工具调用事件 ========== # ========== 函数工具调用事件 ==========
case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part): case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part):
# 获取分片索引 # 获取分片索引
index = tool_call_ids[tool_call_id] index = tool_call_ids[tool_call_id]
match run.reasonings[index].kind: match dialog.reasoning_parts[index].kind:
# 工具检索 # 工具检索
case ReasoningKind.TOOL_SEARCH: case ReasoningPartKind.TOOL_SEARCH:
run.reasonings[index].content = "正在检索" dialog.reasoning_parts[index].content = "正在检索"
yield yield
# 能力加载 # 能力加载
case ReasoningKind.CAPABILITY_LOAD: case ReasoningPartKind.CAPABILITY_LOAD:
run.reasonings[index].content = ( dialog.reasoning_parts[index].content = (
f"正在加载能力 {part.tool_name}" f"正在加载能力 {part.tool_name}"
) )
yield yield
# 工具调用 # 工具调用
case ReasoningKind.TOOL_CALL: case ReasoningPartKind.TOOL_CALL:
run.reasonings[index].content = ( dialog.reasoning_parts[index].content = (
f"正在调用工具 {part.tool_name}" f"正在调用工具 {part.tool_name}"
) )
yield yield
@ -327,22 +386,22 @@ class ConversationState(rx.State):
content=content, content=content,
): ):
index = tool_call_ids[tool_call_id] index = tool_call_ids[tool_call_id]
match run.reasonings[index].kind: match dialog.reasoning_parts[index].kind:
# 工具检索 # 工具检索
case ReasoningKind.TOOL_SEARCH: case ReasoningPartKind.TOOL_SEARCH:
run.reasonings[index].content = ( dialog.reasoning_parts[index].content = (
content if isinstance(content, str) else "" content if isinstance(content, str) else ""
) # 暂仅考虑文本内容 ) # 暂仅考虑文本内容
yield yield
# 能力加载 # 能力加载
case ReasoningKind.CAPABILITY_LOAD: case ReasoningPartKind.CAPABILITY_LOAD:
run.reasonings[index].content = "已加载" dialog.reasoning_parts[index].content = "已加载"
yield yield
# 工具调用 # 工具调用
case ReasoningKind.TOOL_CALL: case ReasoningPartKind.TOOL_CALL:
run.reasonings[index].content = f"已调用" dialog.reasoning_parts[index].content = f"已调用"
yield yield
# ========== 智能体运行结果事件 ========== # ========== 智能体运行结果事件 ==========
@ -350,18 +409,18 @@ class ConversationState(rx.State):
# 保存新增消息 # 保存新增消息
await database_state.save_new_messages( await database_state.save_new_messages(
conversation_id=self.conversation_id, conversation_id=self.conversation_id,
run_id=run_id, dialog_id=dialog_id,
new_messages=result.new_messages(), new_messages=result.new_messages(),
) )
# 将运行状态设置为运行完成 # 将运行状态设置为运行完成
run.is_running = False dialog.is_running = False
yield yield
@rx.event @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] dialog = self.conversations[self.conversation_id].dialogs[dialog_id]
run.is_reasoning_panel_open = not run.is_reasoning_panel_open dialog.is_collapse_open = not dialog.is_collapse_open

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
"""
页面模板
"""

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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"}}
"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"
}
}