添加长期存储,流式检查
Some checks failed
构建并部署 AI Agent 服务 / deploy (push) Has been cancelled

This commit is contained in:
2026-04-17 01:26:05 +08:00
parent 602d551fd1
commit 404efde282
37 changed files with 794 additions and 2095 deletions

View File

@@ -1,246 +0,0 @@
# ✨ 前端模块化重构总结
## 📊 重构成果
### 文件结构对比
#### 重构前
```
frontend/
└── frontend.py # 280+ 行单体文件
```
#### 重构后
```
frontend/
├── __init__.py # 包初始化
├── frontend.py # 主入口48 行)
├── config.py # 配置管理62 行)
├── state.py # 状态管理120 行)
├── api_client.py # API 客户端164 行)
├── utils.py # 工具函数56 行)
├── components/
│ ├── __init__.py
│ ├── sidebar.py # 左侧栏156 行)
│ ├── chat_area.py # 中间栏156 行)
│ └── info_panel.py # 右侧栏63 行)
└── REFACTOR.md # 重构文档
```
---
## 🎯 核心改进
### 1. **代码量优化**
| 模块 | 行数 | 说明 |
|------|------|------|
| [frontend.py](file:///home/huang/Study/AIProject/Agent1/frontend/frontend.py) | 48 行 | ✅ -83%(原 280+ 行) |
| [config.py](file:///home/huang/Study/AIProject/Agent1/frontend/config.py) | 62 行 | 新增配置管理 |
| [state.py](file:///home/huang/Study/AIProject/Agent1/frontend/state.py) | 120 行 | 新增状态管理 |
| [api_client.py](file:///home/huang/Study/AIProject/Agent1/frontend/api_client.py) | 164 行 | 新增 API 客户端 |
| [components/sidebar.py](file:///home/huang/Study/AIProject/Agent1/frontend/components/sidebar.py) | 156 行 | 左侧栏组件 |
| [components/chat_area.py](file:///home/huang/Study/AIProject/Agent1/frontend/components/chat_area.py) | 156 行 | 中间聊天区 |
| [components/info_panel.py](file:///home/huang/Study/AIProject/Agent1/frontend/components/info_panel.py) | 63 行 | 右侧信息面板 |
**总计**769 行(模块化后),平均每个文件 < 110
---
### 2. **架构设计**
#### 分层架构
```
┌─────────────────────────────────────┐
│ 表现层 (Components) │ ← UI 渲染
│ sidebar, chat_area, info_panel │
├─────────────────────────────────────┤
│ 业务层 (State) │ ← 状态管理
│ AppState 类 │
├─────────────────────────────────────┤
│ 数据层 (API Client) │ ← 后端通信
│ APIClient 类 │
├─────────────────────────────────────┤
│ 配置层 (Config) │ ← 配置管理
│ FrontendConfig 数据类 │
└─────────────────────────────────────┘
```
#### 依赖关系
```
Components → State → API Client → Config
↑ ↓
└──────── 全局单例 ────────┘
```
---
### 3. **设计模式应用**
| 模式 | 应用场景 | 优势 |
|------|---------|------|
| **单例模式** | `config`, `api_client` 全局实例 | 避免重复初始化 |
| **外观模式** | [AppState](file:///home/huang/Study/AIProject/Agent1/frontend/state.py#L11-L117) 封装 Session State | 统一状态操作接口 |
| **模块模式** | `components/` 独立组件 | 职责单一易于维护 |
| **数据类** | [FrontendConfig](file:///home/huang/Study/AIProject/Agent1/frontend/config.py#L13-L66) 配置管理 | 类型安全IDE 友好 |
---
## 🚀 使用方式
### 本地开发
```bash
# 启动前后端
./scripts/start.sh both
# 访问前端
open http://localhost:8501
```
### Docker 部署
```bash
# 配置环境变量
cp .env.docker .env
# 编辑 .env 填入 API Key
# 启动服务
cd docker
docker compose up -d
```
---
## 📝 扩展示例
### 示例 1添加对话导出功能
只需修改 [components/sidebar.py](file:///home/huang/Study/AIProject/Agent1/frontend/components/sidebar.py)
```python
def _render_history_actions():
"""渲染历史操作按钮"""
if st.button("🔄 刷新列表", use_container_width=True):
_refresh_threads()
if st.button(" 新对话", type="primary", use_container_width=True):
AppState.start_new_thread()
st.rerun()
# 新增:导出按钮
if st.button("📤 导出对话", use_container_width=True):
_export_conversation()
def _export_conversation():
"""导出当前对话"""
messages = AppState.get_messages()
content = "\n\n".join([
f"**{m['role'].upper()}**: {m['content']}"
for m in messages
])
st.download_button(
label="下载 Markdown",
data=content,
file_name="conversation.md",
mime="text/markdown"
)
```
**影响范围**仅修改 `sidebar.py`不影响其他模块
---
### 示例 2添加暗色主题
修改 [config.py](file:///home/huang/Study/AIProject/Agent1/frontend/config.py)
```python
@dataclass
class FrontendConfig:
# ... 现有配置 ...
theme: str = "light" # 新增主题配置
# 在 frontend.py 中应用
if config.theme == "dark":
st.markdown("""
<style>
.stApp { background-color: #0e1117; }
</style>
""", unsafe_allow_html=True)
```
---
### 示例 3添加消息统计图表
修改 [components/info_panel.py](file:///home/huang/Study/AIProject/Agent1/frontend/components/info_panel.py)
```python
def _render_message_stats():
"""渲染消息统计"""
st.subheader("消息统计")
stats = AppState.get_message_stats()
# 新增:柱状图
import pandas as pd
df = pd.DataFrame({
'角色': ['用户', 'AI'],
'数量': [stats['user'], stats['assistant']]
})
st.bar_chart(df.set_index('角色'))
```
---
## ✅ 重构优势
### 1. **可维护性** ⭐⭐⭐⭐⭐
- 每个文件职责单一平均 < 110
- 修改功能只需改对应模块
- 代码结构清晰易于理解
### 2. **可扩展性** ⭐⭐⭐⭐⭐
- 新增功能不影响现有代码
- 组件独立可自由组合
- 支持插件化开发
### 3. **可测试性** ⭐⭐⭐⭐⭐
- 各模块独立便于 Mock
- 状态管理统一易于验证
- API 客户端可独立测试
### 4. **代码质量** ⭐⭐⭐⭐⭐
- 遵循 SOLID 原则
- 类型提示完整
- 符合 Clean Architecture
### 5. **团队协作** ⭐⭐⭐⭐⭐
- 多人并行开发不同组件
- 减少代码冲突
- 降低 Review 难度
---
## 📚 文档资源
| 文档 | 说明 |
|------|------|
| [frontend/REFACTOR.md](file:///home/huang/Study/AIProject/Agent1/frontend/REFACTOR.md) | 详细重构说明和架构设计 |
| [FEATURES.md](file:///home/huang/Study/AIProject/Agent1/FEATURES.md) | 功能使用说明 |
| [README.md](file:///home/huang/Study/AIProject/Agent1/README.md) | 项目总体说明 |
---
## 🎉 总结
本次重构将前端从 **280+ 行单体文件** 改造为 **模块化分层架构**实现了
**代码精简**主文件从 280+ 行降至 48 -83%
**模块化**拆分为 7 个独立模块平均 < 110
**分层架构**表现层 业务层 数据层 配置层
**类型安全**使用 dataclass 和类型提示
**易于扩展**新增功能只需修改对应模块
**易于测试**各模块独立便于 Mock 和单元测试
**团队协作**减少代码冲突降低 Review 难度
**前端架构已与后端保持一致的优雅设计!** 🎊

View File

@@ -1,289 +0,0 @@
# 🏗️ 前端重构说明
## 重构目标
将原来的单体 `frontend.py`280+ 行)拆分为模块化、可维护的架构,参考后端的分层设计模式。
---
## 📁 新架构
```
frontend/
├── __init__.py # 包初始化
├── frontend.py # 主入口50 行,仅负责组装)
├── config.py # 配置管理(数据类 + 环境变量)
├── state.py # 状态管理(统一 Session State 操作)
├── api_client.py # API 客户端(封装所有后端通信)
├── utils.py # 工具函数(通用辅助函数)
└── components/ # UI 组件
├── __init__.py
├── sidebar.py # 左侧栏:用户登录 + 历史列表
├── chat_area.py # 中间栏:聊天区域 + 流式响应
└── info_panel.py # 右侧栏:信息面板
```
---
## 🎯 核心模块说明
### 1. **配置管理** (`config.py`)
**设计理念**:使用 Python `dataclass` 集中管理所有配置,支持环境变量覆盖。
```python
@dataclass
class FrontendConfig:
api_base: str = ""
page_title: str = "AI 个人助手"
default_model: str = "zhipu"
history_limit: int = 50
# ... 其他配置
# 全局配置实例
config = FrontendConfig()
```
**优势**
- ✅ 类型安全dataclass 自动类型检查)
- ✅ 集中管理(所有配置在一处)
- ✅ 易于测试(可轻松 mock 配置)
- ✅ 环境变量支持(`__post_init__` 中加载)
---
### 2. **状态管理** (`state.py`)
**设计理念**:封装所有 `st.session_state` 操作,提供统一的 API。
```python
class AppState:
@staticmethod
def init():
"""初始化所有状态"""
if "user_id" not in st.session_state:
st.session_state.user_id = config.default_user_id
# ...
@staticmethod
def login(username: str):
"""用户登录"""
st.session_state.user_id = username.strip()
st.session_state.logged_in = True
@staticmethod
def get_messages() -> List[Dict[str, str]]:
"""获取消息列表"""
return st.session_state.messages
```
**优势**
- ✅ 统一接口(所有状态操作通过 AppState
- ✅ 类型提示IDE 自动补全)
- ✅ 易于维护(状态逻辑集中)
- ✅ 避免魔法字符串(不再直接使用 `st.session_state["xxx"]`
---
### 3. **API 客户端** (`api_client.py`)
**设计理念**:封装所有与后端的通信,支持流式响应。
```python
class APIClient:
def get_user_threads(self, user_id: str, limit: int) -> List[Dict]:
"""获取用户历史列表"""
resp = requests.get(f"{self.base_url}/threads", ...)
return resp.json().get("threads", [])
def chat_stream(self, message: str, ...) -> AsyncGenerator[Dict, None]:
"""流式对话"""
with requests.post(..., stream=True) as response:
for line in response.iter_lines():
yield json.loads(line)
```
**优势**
- ✅ 职责单一(仅负责 API 通信)
- ✅ 错误处理集中(统一的异常捕获)
- ✅ 易于测试(可 mock APIClient
- ✅ 流式支持Generator 逐行 yield
---
### 4. **UI 组件** (`components/`)
**设计理念**:每个组件独立渲染,通过 State 和 API Client 交互。
#### `sidebar.py` - 左侧栏
```python
def render_sidebar():
"""渲染左侧栏"""
with st.sidebar:
_render_user_section() # 用户登录
_render_history_section() # 历史列表
```
#### `chat_area.py` - 中间聊天区
```python
def render_chat_area():
"""渲染中间聊天区域"""
_render_model_selector() # 模型选择
_render_chat_container() # 消息显示
_render_input_box() # 输入框 + 流式响应
```
#### `info_panel.py` - 右侧信息面板
```python
def render_info_panel():
"""渲染右侧信息面板"""
_render_thread_info() # 当前线程
_render_message_stats() # 消息统计
_render_tips() # 使用提示
```
**优势**
- ✅ 组件独立(每个文件 < 150
- 职责清晰一个组件一个文件
- 易于复用可在其他页面复用组件
- 易于测试可独立测试每个组件
---
### 5. **主入口** (`frontend.py`)
**设计理念**仅负责组装各模块代码量 < 50
```python
from .config import config
from .state import AppState
from .components.sidebar import render_sidebar
from .components.chat_area import render_chat_area
from .components.info_panel import render_info_panel
st.set_page_config(...)
AppState.init()
def main():
st.title("🤖 个人生活与数据分析助手")
col_sidebar, col_chat, col_info = st.columns([1, 3, 1])
with col_sidebar:
render_sidebar()
with col_chat:
render_chat_area()
with col_info:
render_info_panel()
if __name__ == "__main__":
main()
```
**优势**
- 极简主义< 50
- 清晰结构一眼看懂整体架构
- 易于维护修改功能只需改对应组件
---
## 重构对比
| 指标 | 重构前 | 重构后 | 改进 |
|------|--------|--------|------|
| **主文件行数** | 280+ | 48 | -83% |
| **代码结构** | 单体文件 | 模块化架构 | 分层清晰 |
| **组件独立性** | 耦合严重 | 独立组件 | 可复用 |
| **测试友好性** | 难以测试 | 易于 Mock | 可测试 |
| **维护成本** | 改一处影响全局 | 改组件不影响其他 | 易维护 |
| **代码可读性** | 滚动查找 | 模块化 | 易读 |
---
## 🎨 架构设计模式
### 1. **分层架构**
```
┌─────────────────────────────────────┐
│ 表现层 (Components) │
│ sidebar.py, chat_area.py, ... │
├─────────────────────────────────────┤
│ 业务层 (State) │
│ state.py - 状态管理 │
├─────────────────────────────────────┤
│ 数据层 (API Client) │
│ api_client.py - 后端通信 │
├─────────────────────────────────────┤
│ 配置层 (Config) │
│ config.py - 配置管理 │
└─────────────────────────────────────┘
```
### 2. **依赖方向**
```
Components → State → API Client → Config
↑ ↓
└────────────────────────┘
(全局单例实例)
```
**规则**
- 上层依赖下层
- 禁止循环依赖
- 配置和客户端为全局单例
---
## 🚀 使用示例
### 扩展新功能:添加对话导出按钮
只需修改 `components/sidebar.py`
```python
def _render_history_actions():
"""渲染历史操作按钮"""
if st.button("🔄 刷新列表", use_container_width=True):
_refresh_threads()
if st.button(" 新对话", type="primary", use_container_width=True):
AppState.start_new_thread()
st.rerun()
# 新增:导出对话按钮
if st.button("📤 导出对话", use_container_width=True):
_export_current_thread()
def _export_current_thread():
"""导出当前对话为 Markdown"""
messages = AppState.get_messages()
content = "\n\n".join([f"**{m['role']}**: {m['content']}" for m in messages])
st.download_button("下载", content, "conversation.md")
```
**优势**修改仅影响 `sidebar.py`不影响其他模块
---
## ✅ 重构优势总结
1. **模块化**每个文件职责单一易于理解和维护
2. **可扩展**添加新功能只需修改对应模块
3. **可测试**各模块独立便于编写单元测试
4. **可复用**组件可在其他项目中复用
5. **类型安全**使用 dataclass 和类型提示
6. **代码质量**遵循 SOLID 原则和 Clean Architecture
---
## 📝 后续优化建议
1. **添加单元测试** `state.py` `api_client.py` 编写测试
2. **错误边界**在组件中添加 try-except避免单个组件崩溃影响全局
3. **性能优化**使用 `st.cache_data` 缓存 API 响应
4. **国际化**提取所有文本到 `i18n.py`支持多语言
5. **主题支持**添加暗色/亮色主题切换
---
**🎉 前端重构完成代码结构更清晰维护成本大幅降低**

View File

@@ -3,6 +3,7 @@
包含模型选择、消息显示和输入框
"""
import re
import streamlit as st
# 使用绝对导入
@@ -13,28 +14,30 @@ from frontend.config import config
def render_chat_area():
"""渲染中间聊天区域"""
# 模型选择器
# 顶部:极简模型选择器(可选放在顶部中间)
_render_model_selector()
st.divider()
# 使用空白占位符或者不需要 divider 让界面更干净
st.write("")
# 聊天容器
_render_chat_container()
# 渲染历史消息
_render_chat_history()
# 输入框
_render_input_box()
# 输入框和流式响应处理
_render_input_and_response()
def _render_model_selector():
"""渲染模型选择器"""
col_model, col_empty = st.columns([2, 3])
"""渲染模型选择器,极简风格"""
col_empty1, col_model, col_empty2 = st.columns([1, 2, 1])
with col_model:
selected_model = st.selectbox(
"🧠 选择模型",
"选择模型",
options=list(config.model_options.keys()),
format_func=lambda x: config.model_options[x],
index=_get_model_index()
index=_get_model_index(),
label_visibility="collapsed" # 隐藏标签,只显示下拉框,更加现代
)
AppState.set_selected_model(selected_model)
@@ -51,45 +54,71 @@ def _get_model_index() -> int:
return model_keys.index(current_model) if current_model in model_keys else 0
def _render_chat_container():
"""渲染聊天消息容器"""
chat_container = st.container(height=500)
with chat_container:
messages = AppState.get_messages()
for msg in messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
def _render_chat_history():
"""渲染历史聊天消息"""
messages = AppState.get_messages()
for msg in messages:
with st.chat_message(msg["role"]):
content = msg["content"]
# 1. 尝试解析我们在前端流式结束后存入的 ```thought 格式
if "```thought\n" in content:
parts = content.split("```thought\n")
if parts[0].strip():
st.markdown(parts[0])
for part in parts[1:]:
if "\n```\n" in part:
thought, rest = part.split("\n```\n", 1)
with st.expander("🤔 思考过程", expanded=False):
st.markdown(thought)
if rest.strip():
st.markdown(rest)
else:
st.markdown("```thought\n" + part)
# 2. 尝试解析从后端原始加载的历史记录中包含的 <think> 标签
elif "<think>" in content and "</think>" in content:
# 提取思考内容和剩余正文
thought_match = re.search(r'<think>(.*?)</think>', content, re.DOTALL)
if thought_match:
thought = thought_match.group(1).strip()
rest = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
with st.expander("🤔 思考过程", expanded=False):
st.markdown(thought)
if rest:
st.markdown(rest)
else:
st.markdown(content)
else:
st.markdown(content)
def _render_input_box():
"""渲染输入框和流式响应处理"""
def _render_input_and_response():
"""渲染输入框并处理用户输入与AI响应"""
if prompt := st.chat_input("请输入您的问题...", key="chat_input"):
_handle_user_message(prompt)
def _handle_user_message(prompt: str):
"""
处理用户消息
Args:
prompt: 用户输入的消息
"""
# 显示用户消息
with st.chat_message("user"):
st.markdown(prompt)
AppState.add_message("user", prompt)
# 流式调用 AI 回复
_handle_ai_response()
# 显示用户消息
with st.chat_message("user"):
st.markdown(prompt)
AppState.add_message("user", prompt)
# 流式调用 AI 回复
_handle_ai_response()
def _handle_ai_response():
"""处理 AI 流式响应"""
"""处理 AI 流式响应 (适配 LangGraph v2 事件格式)"""
with st.chat_message("assistant"):
# 用于容纳思考过程的占位符(只有在使用 DeepSeek reasoner 时才显示)
thought_placeholder = st.empty()
message_placeholder = st.empty()
tool_status_placeholder = st.empty()
full_response = ""
raw_text = ""
api_thought = ""
display_text = ""
display_thought = ""
# 调用流式 API
stream = api_client.chat_stream(
@@ -99,38 +128,163 @@ def _handle_ai_response():
user_id=AppState.get_user_id()
)
# 消费流式响应
# 消费流式响应 (v2 格式)
for event in stream:
event_type = event.get("type")
if event_type == "token":
# 逐字输出
full_response += event.get("content", "")
message_placeholder.markdown(full_response + "")
# [DEBUG] 可以在前端终端看到接收到的事件
import logging
if event_type == "llm_token":
logging.debug(f"[Frontend Stream] token: {repr(event.get('token'))}, reasoning: {repr(event.get('reasoning_token'))}")
elif event_type == "tool_start":
# 工具调用开始
tool_name = event.get("tool", "")
tool_status_placeholder.info(f"🔧 调用工具: {tool_name}...")
# 1. 处理 LLM Token 流 (打字机效果)
if event_type == "llm_token":
# 确保只处理来自 LLM 的 token避免将工具的输出作为 token 显示
if event.get("node") == "llm_call":
token = str(event.get("token", ""))
reasoning_token = str(event.get("reasoning_token", ""))
if reasoning_token:
api_thought += reasoning_token
if token:
raw_text += token
display_thought = api_thought
display_text = raw_text
is_thinking = False
# 1. 原生 API 推理模式 (如 DeepSeek-Reasoner)
if api_thought:
is_thinking = not bool(raw_text.strip())
# 2. 本地模型 <think> 标签模式 (如 Gemma, 本地 DeepSeek)
if "<think>" in raw_text:
think_match = re.search(r'<think>(.*?)(</think>|$)', raw_text, re.DOTALL)
if think_match:
display_thought = think_match.group(1).strip()
is_thinking = "</think>" not in raw_text
# 正文部分应该是除去了整个 <think>...</think> 块后的剩余内容
# 注意:流式输出时可能 </think> 还没出来,此时也要把 <think> 到末尾的部分剔除,只显示正文
if is_thinking:
display_text = re.sub(r'<think>.*$', '', raw_text, flags=re.DOTALL).strip()
else:
display_text = re.sub(r'<think>.*?</think>', '', raw_text, flags=re.DOTALL).strip()
elif "<" in raw_text and "think" in raw_text and not raw_text.startswith("<think>"):
# 处理一种特殊情况:模型正在输出 <think> 标签的过程中(例如刚输出了 "<thin"
# 此时正则表达式匹配不到完整的 "<think>",会导致残缺的标签显示在正文中
# 我们做个简单拦截:如果在开头发现了不完整的标签,暂时不显示它
if re.match(r'^<t[hink>]*$', raw_text):
display_text = ""
is_thinking = True
# 渲染思考过程
if display_thought:
# 使用 st.empty 的特殊方式来避免闪烁和嵌套
# Streamlit 无法在流式中动态切换 expander 的 expanded 状态
# 最好的方法是直接写一个 markdown 组件,使用 info 的样式来模拟
if is_thinking:
# 正在思考时,直接显示内容,不要用 expander
thought_placeholder.info(f"**🤔 思考过程 (正在思考...)**\n\n{display_thought}")
else:
# 思考完毕后,将 placeholder 替换为空,等待最终替换为折叠面板
thought_placeholder.info(f"**🤔 思考过程**\n\n{display_thought}")
# 渲染正式回复
if display_text or not is_thinking:
cursor = "" if not is_thinking else ""
message_placeholder.markdown(display_text + cursor)
elif event_type == "tool_end":
# 工具调用完成
tool_name = event.get("tool", "")
tool_status_placeholder.success(f"✅ 工具 {tool_name} 完成")
tool_status_placeholder.empty()
# 2. 处理状态更新 (节点完成、工具结果等)
elif event_type == "state_update":
# state_update 可能包含多种数据,常见的是 messages 更新
data = event.get("data", {})
messages_update = event.get("messages", [])
if not messages_update and isinstance(data, dict):
for node_name, node_data in data.items():
if isinstance(node_data, dict) and "messages" in node_data:
messages_update = node_data["messages"]
# 如果更新中包含 messages说明某个节点输出了完整消息
# 但我们已经在用 token 流构建回复,这里可以用来检测工具调用结果
if messages_update:
# 检查最后一条消息是否来自工具
last_msg = messages_update[-1] if messages_update else {}
if isinstance(last_msg, dict) and last_msg.get("role") == "tool":
tool_name = last_msg.get("name", "unknown")
tool_status_placeholder.success(f"✅ 工具 {tool_name} 执行完成")
# 短暂显示后清除,保持界面清爽
import time
time.sleep(0.5)
tool_status_placeholder.empty()
elif event_type == "done":
# 对话完成
_show_completion_stats(event)
# 3. 处理自定义事件 (你在后端通过 get_stream_writer 发送的)
elif event_type == "custom":
custom_data = event.get("data", {})
# 检查是否是完成事件
if custom_data.get("type") == "done":
_show_completion_stats(custom_data)
# 其他自定义事件,比如工具调用状态
elif "type" in custom_data:
custom_type = custom_data["type"]
if custom_type == "tool_start":
tool_name = custom_data.get("tool", "unknown")
tool_status_placeholder.info(f"🔧 调用工具: {tool_name}...")
elif custom_type == "tool_end":
tool_name = custom_data.get("tool", "unknown")
tool_status_placeholder.success(f"✅ 工具 {tool_name} 完成")
tool_status_placeholder.empty()
elif "status" in custom_data:
status_msg = custom_data.get("status", "")
tool_status_placeholder.info(f"🔧 {status_msg}")
# 4. 处理错误
elif event_type == "error":
# 错误处理
st.error(f"❌ 错误: {event.get('message', '未知错误')}")
break # 发生错误时停止处理
# 注意v2 格式中没有固定的 "done" 事件,流结束即代表完成
# 统计信息 (token_usage, elapsed_time) 通常会在最后的 state_update 中携带
# 如果后端在最终状态里返回了这些信息,可以在此处理
# 显示完整响应
message_placeholder.markdown(full_response)
AppState.add_message("assistant", full_response)
# 流结束后,移除光标并保存完整回复
display_text = raw_text
display_thought = api_thought
# 最后的标签清理,以防未闭合
if "<think>" in raw_text:
think_match = re.search(r'<think>(.*?)(</think>|$)', raw_text, re.DOTALL)
if think_match:
display_thought = think_match.group(1).strip()
display_text = re.sub(r'<think>.*?(</think>|$)', '', raw_text, flags=re.DOTALL).strip()
if display_thought:
# 只有在最终结束时,才把它放进折叠面板
with thought_placeholder.container():
with st.expander("🤔 思考过程", expanded=False):
st.markdown(display_thought)
else:
thought_placeholder.empty()
# 移除光标
message_placeholder.markdown(display_text)
# 拼装包含思考过程的完整内容,以便后续在历史中正确渲染
final_content = display_text
if display_thought:
final_content = f"```thought\n{display_thought}\n```\n\n" + display_text
AppState.add_message("assistant", final_content)
tool_status_placeholder.empty()
# 消息发送完毕后,静默刷新历史记录列表
# (因为可能生成了新对话,或者旧对话摘要已更新)
from frontend.components.sidebar import _refresh_threads
_refresh_threads()
# 强制重绘页面,使侧边栏立即显示最新记录
st.rerun()
def _show_completion_stats(event: dict):

View File

@@ -10,50 +10,30 @@ from frontend.state import AppState
def render_info_panel():
"""渲染右侧信息面板"""
st.header("📊 会话信息")
# 当前线程信息
_render_thread_info()
st.divider()
"""渲染右侧信息面板(现改为侧边栏底部)"""
st.caption("📊 会话信息")
# 消息统计
_render_message_stats()
st.divider()
# 使用提示
_render_tips()
def _render_thread_info():
"""渲染当前线程信息"""
st.subheader("当前对话")
thread_id = AppState.get_current_thread_id()
st.code(thread_id[:8] + "...", language=None)
def _render_message_stats():
"""渲染消息统计"""
st.subheader("消息统计")
stats = AppState.get_message_stats()
col1, col2 = st.columns(2)
with col1:
st.metric("用户消息", stats["user"])
with col2:
st.metric("AI 回复", stats["assistant"])
st.markdown(f"<span style='font-size:0.8em;color:#666;'>共 {stats['user']} 问 / {stats['assistant']} 答</span>", unsafe_allow_html=True)
def _render_tips():
"""渲染使用提示"""
st.subheader("💡 使用提示")
st.markdown("""
- 左侧可切换历史对话
- 点击"新对话"开始新话题
- 登录后对话历史隔离
- 支持流式实时响应
- 模型可随时切换
""")
with st.expander("💡 使用提示", expanded=False):
st.markdown("""
<div style='font-size:0.85em;color:#555;'>
- 左侧可切换历史对话<br>
- 点击"新对话"开始新话题<br>
- 登录后对话历史隔离<br>
- 模型可随时切换
</div>
""", unsafe_allow_html=True)

View File

@@ -14,14 +14,22 @@ from frontend.config import config
def render_sidebar():
"""渲染左侧栏"""
_render_user_section()
# 顶部放置新对话按钮,像 ChatGPT/DeepSeek 一样显眼
_render_history_actions()
st.divider()
# 历史列表
_render_history_section()
# 底部放用户部分
st.divider()
_render_user_section()
def _render_user_section():
"""渲染用户登录区域"""
st.header("👤 用户")
# st.header("👤 用户") # 移除显眼的标题,改用更柔和的 caption
st.caption("👤 用户管理")
if not AppState.is_logged_in():
_render_login_form()
@@ -32,58 +40,62 @@ def _render_user_section():
def _render_login_form():
"""渲染登录表单"""
username = st.text_input(
"输入用户名(可选)",
"用户名",
key="login_input",
placeholder="留空使用默认用户",
help="未登录将使用 default_user可能导致对话污染"
placeholder="输入用户名...",
help="未登录将使用 default_user可能导致对话污染",
label_visibility="collapsed"
)
if st.button("进入", type="primary", use_container_width=True):
if st.button("进入", type="secondary", use_container_width=True):
AppState.login(username)
_refresh_threads()
st.rerun()
st.info("💡 建议登录以隔离对话历史")
# st.info("💡 建议登录以隔离对话历史") # 移除多余色块
def _render_user_info():
"""渲染用户信息"""
st.success(f"当前用户: `{AppState.get_user_id()}`")
st.markdown(f"**当前用户**: `{AppState.get_user_id()}`")
if st.button("🔄 切换用户", use_container_width=True):
if st.button("切换用户", type="secondary", use_container_width=True):
AppState.logout()
_refresh_threads()
st.rerun()
def _render_history_section():
"""渲染历史对话列表"""
st.header("📚 对话历史")
col1, col2 = st.columns([3, 1])
with col1:
st.caption("📚 对话历史")
with col2:
if st.button("🔄", help="刷新列表", key="refresh_history_btn"):
_refresh_threads()
# 操作按钮
_render_history_actions()
st.divider()
# 历史列表
_render_thread_list()
def _render_history_actions():
"""渲染历史操作按钮"""
if st.button("🔄 刷新列表", use_container_width=True):
_refresh_threads()
if st.button(" 新对话", type="primary", use_container_width=True):
# 移除了 type="primary",让它变成普通的线框按钮,不再是大红块
if st.button(" 新对话", use_container_width=True):
AppState.start_new_thread()
st.rerun()
def _render_thread_list():
"""渲染线程列表"""
# 仅在初次加载时拉取,或由外部主动调用 _refresh_threads() 更新
if "threads_loaded" not in st.session_state:
_refresh_threads()
st.session_state.threads_loaded = True
threads = AppState.get_threads()
if not threads:
st.info("暂无对话历史")
st.caption("暂无对话历史")
return
for thread in threads:
@@ -98,28 +110,23 @@ def _render_thread_item(thread: dict):
thread: 线程信息字典
"""
thread_id = thread["thread_id"]
summary = thread.get("summary", "对话")
message_count = thread.get("message_count", 0)
last_updated = thread.get("last_updated", "")
# 格式化时间
time_str = _format_time(last_updated)
summary = thread.get("summary", "对话")
# 判断是否为当前线程
is_current = thread_id == AppState.get_current_thread_id()
button_type = "primary" if is_current else "secondary"
# 截断摘要
summary_display = summary[:config.summary_max_length]
if len(summary) > config.summary_max_length:
summary_display += "..."
# 根据是否当前线程改变按钮样式
btn_type = "primary" if is_current else "tertiary"
# 为了避免内容过长,截断摘要
display_text = summary[:15] + "..." if len(summary) > 15 else summary
# 渲染按钮
if st.button(
f"💬 {summary_display}\n\n🕐 {time_str} | {message_count}",
display_text,
key=f"thread_{thread_id}",
help=f"完整摘要: {summary}",
use_container_width=True,
type=button_type
type=btn_type
):
_load_thread(thread_id)

View File

@@ -24,7 +24,7 @@ class FrontendConfig:
layout: str = "wide"
# ==================== 模型配置 ====================
default_model: str = "zhipu"
default_model: str = "local" # 更改为local作为默认模型
model_options: dict = None
# ==================== 用户配置 ====================
@@ -41,9 +41,9 @@ class FrontendConfig:
"""初始化后处理 - 设置默认值和加载环境变量"""
if self.model_options is None:
self.model_options = {
"zhipu": "智谱 GLM-4.7-Flash在线",
"deepseek": "DeepSeek V3.2(在线)",
"local": "本地 llama.cppGemma-4"
"local": "本地 llama.cppGemma-4", # 本地模型作为第一个
"deepseek": "DeepSeek V3.2(在线)", # DeepSeek 作为中间
"zhipu": "智谱 GLM-4.7-Flash在线" # GLM-4.7 作为最后一个
}
# 从环境变量加载配置
@@ -53,9 +53,9 @@ class FrontendConfig:
"""从环境变量加载配置(优先级最高)"""
# API 地址(移除 /chat 后缀)
# 优先级:环境变量 API_URL > 默认值
api_url = os.getenv("API_URL", "http://localhost:8083")
api_url = os.getenv("API_URL", "http://127.0.0.1:8083")
self.api_base = api_url.replace("/chat", "").rstrip("/")
# 全局配置实例(单例模式)
config = FrontendConfig()
config = FrontendConfig()

View File

@@ -1,409 +0,0 @@
"""
右侧栏组件:工具状态和统计信息
"""
import streamlit as st
def render_info_panel():
st.header("📊 会话信息")
# 当前线程信息
st.subheader("当前对话")
st.code(st.session_state.current_thread_id[:8] + "...", language=None)
st.divider()
# 消息统计
st.subheader("消息统计")
user_msgs = len([m for m in st.session_state.messages if m["role"] == "user"])
assistant_msgs = len([m for m in st.session_state.messages if m["role"] == "assistant"])
st.metric("用户消息", user_msgs)
st.metric("AI 回复", assistant_msgs)
st.divider()
# 使用提示
st.subheader("💡 使用提示")
st.markdown("""
- 左侧可切换历史对话
- 点击"新对话"开始新话题
- 登录后对话历史隔离
- 支持流式实时响应
- 模型可随时切换
""")
"""
中间栏组件:聊天区域
"""
import streamlit as st
from ..config import config
from ..api_client import stream_chat
def render_chat_area():
# 模型选择器
col_model, col_empty = st.columns([2, 3])
with col_model:
selected_model_key = st.selectbox(
"🧠 选择模型",
options=list(config.model_options.keys()),
format_func=lambda x: config.model_options[x],
index=list(config.model_options.keys()).index(st.session_state.selected_model) if st.session_state.selected_model in config.model_options else 0
)
st.session_state.selected_model = selected_model_key
st.divider()
# 显示消息历史
chat_container = st.container(height=500)
with chat_container:
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# 输入框
if prompt := st.chat_input("请输入您的问题...", key="chat_input"):
# 显示用户消息
with st.chat_message("user"):
st.markdown(prompt)
st.session_state.messages.append({"role": "user", "content": prompt})
# 流式调用后端
with st.chat_message("assistant"):
message_placeholder = st.empty()
tool_status_placeholder = st.empty()
full_response = ""
stream_gen = stream_chat(
message=prompt,
thread_id=st.session_state.current_thread_id,
model=st.session_state.selected_model,
user_id=st.session_state.user_id
)
if stream_gen:
for data in stream_gen:
if data["type"] == "token":
full_response += data["content"]
message_placeholder.markdown(full_response + "")
elif data["type"] == "tool_start":
tool_status_placeholder.info(f"🔧 调用工具: {data['tool']}...")
elif data["type"] == "tool_end":
tool_status_placeholder.success(f"✅ 工具 {data['tool']} 完成")
tool_status_placeholder.empty()
elif data["type"] == "done":
# 最终响应
token_usage = data.get("token_usage", {})
elapsed = data.get("elapsed_time", 0)
if token_usage:
st.caption(f"📊 消耗 {token_usage.get('total_tokens', 0)} tokens | ⏱️ {elapsed:.2f}s")
elif data["type"] == "error":
st.error(f"❌ 错误: {data['message']}")
# 显示完整响应
message_placeholder.markdown(full_response)
st.session_state.messages.append({"role": "assistant", "content": full_response})
tool_status_placeholder.empty()
"""
左侧栏组件:用户登录 + 历史对话列表
"""
from datetime import datetime
import streamlit as st
from ..state import AppState
from ..api_client import refresh_threads, load_thread_history
def render_sidebar():
st.header("👤 用户")
# 用户登录区域
if not st.session_state.logged_in:
username = st.text_input(
"输入用户名(可选)",
key="login_input",
placeholder="留空使用默认用户",
help="未登录将使用 default_user可能导致对话污染"
)
if st.button("✅ 进入", type="primary", use_container_width=True):
AppState.login(username)
refresh_threads(st.session_state.user_id)
st.info("💡 建议登录以隔离对话历史")
else:
st.success(f"✅ 当前用户: `{st.session_state.user_id}`")
if st.button("🔄 切换用户", use_container_width=True):
AppState.reset_login()
st.divider()
# 历史对话列表
st.header("📚 对话历史")
# 刷新按钮
if st.button("🔄 刷新列表", use_container_width=True):
refresh_threads(st.session_state.user_id)
# 新对话按钮
if st.button(" 新对话", type="primary", use_container_width=True):
AppState.start_new_thread()
st.divider()
# 显示历史列表
if st.session_state.threads:
for thread in st.session_state.threads:
thread_id = thread["thread_id"]
summary = thread.get("summary", "空对话")
message_count = thread.get("message_count", 0)
last_updated = thread.get("last_updated", "")
# 格式化时间
if last_updated:
try:
dt = datetime.fromisoformat(last_updated.replace("Z", "+00:00"))
time_str = dt.strftime("%m-%d %H:%M")
except:
time_str = last_updated[:10]
else:
time_str = "未知"
# 按钮样式
is_current = thread_id == st.session_state.current_thread_id
button_type = "primary" if is_current else "secondary"
if st.button(
f"💬 {summary[:30]}{'...' if len(summary) > 30 else ''}\n\n🕐 {time_str} | {message_count}",
key=f"thread_{thread_id}",
use_container_width=True,
type=button_type
):
load_thread_history(thread_id, st.session_state.user_id)
else:
st.info("暂无对话历史")
# Components package
"""
后端 API 客户端封装
"""
import json
import requests
import streamlit as st
from .config import config
def refresh_threads(user_id: str):
"""刷新用户的历史对话列表"""
try:
resp = requests.get(
f"{config.api_base}/threads",
params={"user_id": user_id, "limit": 50},
timeout=10
)
if resp.status_code == 200:
st.session_state.threads = resp.json()["threads"]
else:
st.error(f"加载历史列表失败: HTTP {resp.status_code}")
except Exception as e:
st.error(f"加载历史列表失败: {e}")
def load_thread_history(thread_id: str, user_id: str):
"""加载指定线程的完整消息历史"""
try:
resp = requests.get(
f"{config.api_base}/thread/{thread_id}/messages",
params={"user_id": user_id},
timeout=10
)
if resp.status_code == 200:
st.session_state.messages = resp.json()["messages"]
st.session_state.current_thread_id = thread_id
st.rerun()
else:
st.error(f"加载对话失败: HTTP {resp.status_code}")
except Exception as e:
st.error(f"加载对话失败: {e}")
def stream_chat(message: str, thread_id: str, model: str, user_id: str):
"""流式调用后端聊天接口"""
payload = {
"message": message,
"thread_id": thread_id,
"model": model,
"user_id": user_id
}
try:
with requests.post(
f"{config.api_base}/chat/stream",
json=payload,
stream=True,
timeout=120
) as response:
if response.status_code != 200:
st.error(f"请求失败: HTTP {response.status_code}")
return None
full_response = ""
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
yield data
except json.JSONDecodeError:
pass
return full_response
except Exception as e:
st.error(f"请求失败: {e}")
return None
"""
Session State 管理
"""
import uuid
import streamlit as st
class AppState:
"""管理 Streamlit Session State"""
@staticmethod
def init():
"""初始化必要的 session state 变量"""
if "user_id" not in st.session_state:
st.session_state.user_id = "default_user"
if "logged_in" not in st.session_state:
st.session_state.logged_in = False
if "threads" not in st.session_state:
st.session_state.threads = []
if "current_thread_id" not in st.session_state:
st.session_state.current_thread_id = str(uuid.uuid4())
if "messages" not in st.session_state:
st.session_state.messages = []
if "selected_model" not in st.session_state:
st.session_state.selected_model = "zhipu"
if "loading_history" not in st.session_state:
st.session_state.loading_history = False
@staticmethod
def reset_login():
"""重置登录状态"""
st.session_state.logged_in = False
st.session_state.user_id = "default_user"
st.session_state.threads = []
st.rerun()
@staticmethod
def login(username: str):
"""执行登录"""
st.session_state.user_id = username.strip() if username.strip() else "default_user"
st.session_state.logged_in = True
st.rerun()
@staticmethod
def start_new_thread():
"""开始新对话"""
st.session_state.current_thread_id = str(uuid.uuid4())
st.session_state.messages = []
st.rerun()
"""
应用配置
"""
import os
from dataclasses import dataclass
@dataclass
class AppConfig:
page_title: str = "AI 个人助手"
page_icon: str = "🤖"
layout: str = "wide"
# 后端 API 地址配置
# 优先级:环境变量 API_URL > Docker 内部服务名 > 本地开发地址
api_base: str = os.getenv("API_URL", "http://localhost:8001").replace("/chat", "")
model_options: dict = None
def __post_init__(self):
if self.model_options is None:
self.model_options = {
"zhipu": "智谱 GLM-4.7-Flash在线",
"deepseek": "DeepSeek V3.2(在线)",
"local": "本地 vLLMGemma-4"
}
config = AppConfig()
"""
AI Agent 前端主入口
采用模块化架构,仅负责组装各组件
"""
import sys
import os
# 添加项目根目录到 Python 路径,支持绝对导入
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import streamlit as st
# 使用绝对导入
from frontend.config import config
from frontend.state import AppState
from frontend.components.sidebar import render_sidebar
from frontend.components.chat_area import render_chat_area
from frontend.components.info_panel import render_info_panel
# =============================================================================
# 页面配置
# =============================================================================
st.set_page_config(
page_title=config.page_title,
page_icon=config.page_icon,
layout=config.layout
)
# =============================================================================
# 初始化状态
# =============================================================================
AppState.init()
# =============================================================================
# 主界面
# =============================================================================
def main():
"""主界面渲染 - 三栏布局"""
# 标题
st.title("🤖 个人生活与数据分析助手")
# 三栏布局:左侧栏(1) + 中间栏(3) + 右侧栏(1)
col_sidebar, col_chat, col_info = st.columns([1, 3, 1])
# 左侧栏:用户登录 + 历史对话
with col_sidebar:
render_sidebar()
# 中间栏:模型选择 + 聊天区域 + 输入框
with col_chat:
render_chat_area()
# 右侧栏:会话信息 + 统计 + 使用提示
with col_info:
render_info_panel()
if __name__ == "__main__":
main()

125
frontend/frontend_main.py Normal file
View File

@@ -0,0 +1,125 @@
"""
AI Agent 前端主入口
采用模块化架构,仅负责组装各组件
"""
import sys
import os
# 添加项目根目录到 Python 路径,支持绝对导入
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import streamlit as st
# 使用绝对导入
from frontend.config import config
from frontend.state import AppState
from frontend.components.sidebar import render_sidebar
from frontend.components.chat_area import render_chat_area
from frontend.components.info_panel import render_info_panel
# =============================================================================
# 页面配置
# =============================================================================
st.set_page_config(
page_title=config.page_title,
page_icon=config.page_icon,
layout=config.layout
)
# =============================================================================
# 初始化状态
# =============================================================================
AppState.init()
def apply_custom_css():
"""应用自定义CSS样式实现极简风格"""
st.markdown("""
<style>
/* 移除顶部默认空白 */
.block-container {
padding-top: 2rem !important;
padding-bottom: 2rem !important;
}
/* 侧边栏样式优化:降低背景色对比度,稍微暗一点提高区分度 */
[data-testid="stSidebar"] {
background-color: #f0f2f5 !important;
border-right: 1px solid #e1e4e8;
}
/* 隐藏标题和头像边框的粗重线条 */
hr {
margin: 1em 0;
border-color: #eee;
}
/* 自定义按钮样式:去除强烈的背景色,使用浅色线框或扁平风 */
.stButton>button {
border-radius: 8px;
font-weight: 500;
}
/* 覆盖 Primary 按钮默认的刺眼大红色,改为柔和的深色高亮 */
.stButton>button[kind="primary"] {
background-color: #e5e7eb !important;
color: #1f2937 !important;
border: 1px solid #d1d5db !important;
}
/* 覆盖 Primary 按钮悬停效果 */
.stButton>button[kind="primary"]:hover {
background-color: #d1d5db !important;
border-color: #9ca3af !important;
color: #111827 !important;
}
/* 普通按钮悬停效果 */
.stButton>button:hover {
border-color: #9ca3af;
color: #1f2937;
background-color: #f9fafb;
}
/* 聊天输入框美化 */
[data-testid="stChatInput"] {
border-radius: 12px;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 10px rgba(0,0,0,0.03);
}
/* 用户和 AI 的头像调整 */
.stChatMessage {
padding: 1rem 0;
border-bottom: 1px solid #f8f8f8;
}
</style>
""", unsafe_allow_html=True)
# =============================================================================
# 主界面
# =============================================================================
def main():
"""主界面渲染 - 极简宽屏布局"""
# 应用 CSS
apply_custom_css()
# 顶部标题(可选,也可以不放,让界面更像对话框)
st.markdown("<h3 style='text-align: center; font-weight: 400; color: #555; margin-bottom: 2rem;'>个人助手</h3>", unsafe_allow_html=True)
# 左侧边栏:合并用户登录、模型选择和历史对话
with st.sidebar:
render_sidebar()
# 将原本右侧的信息面板简化并移入侧边栏底部
st.divider()
render_info_panel()
# 中间主区域:全宽的聊天区域
render_chat_area()
if __name__ == "__main__":
main()

View File

@@ -60,6 +60,8 @@ class AppState:
"""
st.session_state.user_id = username.strip() if username.strip() else config.default_user_id
st.session_state.logged_in = True
# 登录后必须开启一个干净的新对话
AppState.start_new_thread()
@staticmethod
def logout():
@@ -67,6 +69,8 @@ class AppState:
st.session_state.logged_in = False
st.session_state.user_id = config.default_user_id
st.session_state.threads = []
# 登出后必须开启一个干净的新对话
AppState.start_new_thread()
# ==================== 线程相关 ====================