Feat:让启动器使用api,修改gui设计

This commit is contained in:
SengokuCola
2025-05-05 13:18:12 +08:00
parent 2ace6cc415
commit 08d07dc3bd
20 changed files with 2899 additions and 361 deletions

5
@flet_new_.mdc Normal file
View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -15,11 +15,13 @@ from src.MaiGoi.ui_views import (
create_main_view,
create_console_view,
create_adapters_view,
create_settings_view,
create_process_output_view,
)
from src.MaiGoi.config_manager import load_config
# --- Import the new settings view --- #
from src.MaiGoi.ui_settings_view import create_settings_view
# --- Global AppState instance --- #
# This holds all the state previously scattered as globals
app_state = AppState()
@@ -109,6 +111,7 @@ def route_change(route: ft.RouteChangeEvent):
adapters_view = create_adapters_view(page, app_state)
page.views.append(adapters_view)
elif target_route == "/settings":
# Call the new settings view function
settings_view = create_settings_view(page, app_state)
page.views.append(settings_view)
@@ -161,6 +164,9 @@ def view_pop(e: ft.ViewPopEvent):
# --- Main Application Setup --- #
def main(page: ft.Page):
# Load initial config and store in state
# 启动时清除/logs/interest/interest_history.log
if os.path.exists("logs/interest/interest_history.log"):
os.remove("logs/interest/interest_history.log")
loaded_config = load_config()
app_state.gui_config = loaded_config
app_state.adapter_paths = loaded_config.get("adapters", []).copy() # Get adapter paths
@@ -179,12 +185,21 @@ def main(page: ft.Page):
print("[Main] FilePicker created and added to page overlay.")
page.title = "MaiBot 启动器"
page.window_width = 500
page.window_height = 650 # Increased height slightly for monitor
page.window.width = 1400
page.window.height = 1000 # Increased height slightly for monitor
page.vertical_alignment = ft.MainAxisAlignment.START
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
# --- Apply Theme from Config --- #
saved_theme = app_state.gui_config.get("theme", "System").upper()
try:
page.theme_mode = ft.ThemeMode[saved_theme]
print(f"[Main] Applied theme from config: {page.theme_mode}")
except KeyError:
print(f"[Main] Warning: Invalid theme '{saved_theme}' in config. Falling back to System.")
page.theme_mode = ft.ThemeMode.SYSTEM
page.padding = 10 # Reduced padding slightly
page.padding = 0 # <-- 将页面 padding 设置为 0
# --- Create the main 'Start Bot' button and store in state --- #
# This button needs to exist before the first route_change call
@@ -210,6 +225,10 @@ def main(page: ft.Page):
# Prevent immediate close to allow cleanup
page.window_prevent_close = True
# --- Hide Native Title Bar --- #
# page.window_title_bar_hidden = True
# page.window.frameless = True
# --- Initial Navigation --- #
# Trigger the initial route change to build the first view
page.go(page.route if page.route else "/")

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

BIN
src/MaiGoi/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

BIN
src/MaiGoi/assets/lihui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -1,72 +1,99 @@
import os
import toml
# Use tomlkit for dumping to preserve comments/formatting if needed,
# but stick to `toml` for loading unless specific features are required.
import tomlkit
from pathlib import Path
from typing import Dict, Any
from typing import Dict, Any, Optional
CONFIG_DIR = "config"
CONFIG_FILE = "gui_config.toml"
DEFAULT_CONFIG = {"adapters": []}
CONFIG_DIR = Path("config")
# Define default filenames for different config types
CONFIG_FILES = {"gui": "gui_config.toml", "lpmm": "lpmm_config.toml", "bot": "bot_config.toml"}
DEFAULT_GUI_CONFIG = {"adapters": [], "theme": "System"} # Add default theme
def get_config_path() -> Path:
"""Gets the full path to the config file."""
# Assume script_dir is the project root for simplicity here
# A more robust solution might involve finding the project root explicitly
script_dir = Path(os.path.dirname(os.path.abspath(__file__))).parent.parent
config_path = script_dir / CONFIG_DIR / CONFIG_FILE
def get_config_path(config_type: str = "gui") -> Optional[Path]:
"""Gets the full path to the specified config file type."""
filename = CONFIG_FILES.get(config_type)
if not filename:
print(f"[Config] Error: Unknown config type '{config_type}'")
return None
# Determine the base directory relative to this file
# Assumes config_manager.py is in src/MaiGoi/
try:
script_dir = Path(__file__).parent.parent.parent # Project Root (MaiBot-Core/)
config_path = script_dir / CONFIG_DIR / filename
return config_path
except Exception as e:
print(f"[Config] Error determining config path for type '{config_type}': {e}")
return None
def load_config() -> Dict[str, Any]:
"""Loads the configuration from the TOML file."""
config_path = get_config_path()
print(f"[Config] Loading config from: {config_path}")
def load_config(config_type: str = "gui") -> Dict[str, Any]:
"""Loads the configuration from the specified TOML file type."""
config_path = get_config_path(config_type)
if not config_path:
return {} # Return empty dict if path is invalid
print(f"[Config] Loading {config_type} config from: {config_path}")
default_config_to_use = DEFAULT_GUI_CONFIG if config_type == "gui" else {}
try:
config_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
if config_path.is_file():
with open(config_path, "r", encoding="utf-8") as f:
# Use standard toml for loading, it's generally more robust
config_data = toml.load(f)
# Ensure essential keys exist, merge with defaults if necessary
# For now, just check for 'adapters'
print(f"[Config] {config_type} config loaded successfully.")
# Basic check for GUI config default keys
if config_type == "gui":
if "adapters" not in config_data:
config_data["adapters"] = DEFAULT_CONFIG["adapters"]
print("[Config] Config loaded successfully.")
config_data["adapters"] = DEFAULT_GUI_CONFIG["adapters"]
if "theme" not in config_data:
config_data["theme"] = DEFAULT_GUI_CONFIG["theme"]
return config_data
else:
print("[Config] Config file not found, using default config.")
# Save default config if file doesn't exist
save_config(DEFAULT_CONFIG)
return DEFAULT_CONFIG.copy() # Return a copy
print(f"[Config] {config_type} config file not found, using default.")
# Save default config only if it's the GUI config
if config_type == "gui":
save_config(default_config_to_use, config_type=config_type)
return default_config_to_use.copy() # Return a copy
except FileNotFoundError:
print("[Config] Config file not found (FileNotFoundError), using default config.")
save_config(DEFAULT_CONFIG) # Attempt to save default
return DEFAULT_CONFIG.copy()
print(f"[Config] {config_type} config file not found (FileNotFoundError), using default.")
if config_type == "gui":
save_config(default_config_to_use, config_type=config_type) # Attempt to save default
return default_config_to_use.copy()
except toml.TomlDecodeError as e:
print(f"[Config] Error decoding TOML file: {e}. Using default config.")
# Optionally: backup the corrupted file here
return DEFAULT_CONFIG.copy()
print(f"[Config] Error decoding {config_type} TOML file: {e}. Using default.")
return default_config_to_use.copy()
except Exception as e:
print(f"[Config] An unexpected error occurred loading config: {e}. Using default config.")
print(f"[Config] An unexpected error occurred loading {config_type} config: {e}.")
import traceback
traceback.print_exc()
return DEFAULT_CONFIG.copy()
return default_config_to_use.copy()
def save_config(config_data: Dict[str, Any]) -> bool:
"""Saves the configuration dictionary to the TOML file."""
config_path = get_config_path()
print(f"[Config] Saving config to: {config_path}")
def save_config(config_data: Dict[str, Any], config_type: str = "gui") -> bool:
"""Saves the configuration dictionary to the specified TOML file type."""
config_path = get_config_path(config_type)
if not config_path:
return False # Cannot save if path is invalid
print(f"[Config] Saving {config_type} config to: {config_path}")
try:
config_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
with open(config_path, "w", encoding="utf-8") as f:
toml.dump(config_data, f)
print("[Config] Config saved successfully.")
# Use tomlkit.dump if preserving format/comments is important
# Otherwise, stick to toml.dump for simplicity
tomlkit.dump(config_data, f) # Using tomlkit here
print(f"[Config] {config_type} config saved successfully.")
return True
except IOError as e:
print(f"[Config] Error writing config file (IOError): {e}")
print(f"[Config] Error writing {config_type} config file (IOError): {e}")
except Exception as e:
print(f"[Config] An unexpected error occurred saving config: {e}")
print(f"[Config] An unexpected error occurred saving {config_type} config: {e}")
import traceback
traceback.print_exc()

View File

@@ -5,23 +5,53 @@ import json
import time
import traceback
import random
import httpx
from datetime import datetime
from collections import deque
# --- 配置 (可以从 launcher.py 传入或在此处定义) ---
LOG_FILE_PATH = os.path.join("logs", "interest", "interest_history.log")
# 移除临时文件路径注释,保留作为备用
GUI_COMMAND_PATH = "temp_command/gui_command.json" # 旧方式,保留作为备用
API_HOST = "localhost" # API主机名
API_PORT = 8000 # API端口默认值
API_BASE_URL = f"http://{API_HOST}:{API_PORT}/api/v1" # API基础URL
# 如果设置了环境变量,则使用环境变量中的配置
if "MAIBOT_API_PORT" in os.environ:
try:
API_PORT = int(os.environ["MAIBOT_API_PORT"])
API_BASE_URL = f"http://{API_HOST}:{API_PORT}/api/v1"
print(f"[配置] 使用环境变量中的API端口: {API_PORT}")
except ValueError:
print(f"[配置] 环境变量MAIBOT_API_PORT值无效: {os.environ['MAIBOT_API_PORT']}")
print(f"[配置] 使用API地址: {API_BASE_URL}")
REFRESH_INTERVAL_SECONDS = 1 # 刷新间隔(秒)
MAX_HISTORY_POINTS = 1000 # 图表数据点 (Tkinter version uses 1000)
MAX_STREAMS_TO_DISPLAY = 15 # 最多显示的流数量 (Tkinter version uses 15)
MAX_QUEUE_SIZE = 30 # 历史想法队列最大长度 (Tkinter version uses 30)
CHART_HEIGHT = 250 # 图表区域高度
DEFAULT_AUTO_SCROLL = True # 默认开启自动滚动
# --- 子流聊天状态枚举 --- #
# 与心流模块中定义保持一致
CHAT_STATES = [
{"key": "ABSENT", "text": "没在看群"},
{"key": "CHAT", "text": "随便水群"},
{"key": "FOCUSED", "text": "认真水群"},
]
# --- 重要: API使用的实际枚举值确保与ChatState一致 --- #
# API需要的是中文描述值而不是英文枚举键
API_CHAT_STATE_VALUES = {"ABSENT": "没在看群", "CHAT": "随便水群", "FOCUSED": "认真水群"}
# --- 辅助函数 ---
def format_timestamp(ts):
"""辅助函数:格式化时间戳,处理 None 或无效值"""
if ts is None:
return "N/A"
return ""
try:
# 假设 ts 是 float 类型的时间戳
dt_object = datetime.fromtimestamp(float(ts))
@@ -38,6 +68,33 @@ def get_random_flet_color():
return f"#{r:02x}{g:02x}{b:02x}"
# --- 新增: 发送GUI命令到文件 ---
def send_gui_command(subflow_id, target_state):
"""发送GUI命令到文件用于改变子心流状态"""
try:
# 确保目录存在
command_dir = os.path.dirname(GUI_COMMAND_PATH)
if command_dir: # 如果有目录部分
os.makedirs(command_dir, exist_ok=True)
# 创建命令数据
command_data = {
"subflow_id": subflow_id,
"target_state": target_state, # 不再转为大写,保留原始状态值
}
# 写入文件
with open(GUI_COMMAND_PATH, "w", encoding="utf-8") as f:
json.dump(command_data, f, ensure_ascii=False, indent=2)
print(f"[InterestMonitor] 已发送命令: 将子流 {subflow_id} 设置为 {target_state}")
return True
except Exception as e:
print(f"[InterestMonitor] 发送GUI命令出错: {e}")
traceback.print_exc()
return False
class InterestMonitorDisplay(ft.Column):
"""一个 Flet 控件,用于显示兴趣监控图表和信息。"""
@@ -53,24 +110,19 @@ class InterestMonitorDisplay(ft.Column):
self.stream_colors = {} # {stream_id: color_string}
self.selected_stream_id_for_details = None
self.last_log_read_time = 0 # 上次读取日志的时间戳
self.is_expanded = True # 新增:跟踪是否展开显示
self.stream_details = {} # 存储子流详情
# 新增:监控器切换显示回调函数
self.on_toggle = None # 外部可以设置此回调函数
# --- 新增:存储其他参数 ---
# 顶层信息 (直接使用 Text 控件引用)
self.global_mai_state_text = ft.Text("状态: N/A", size=11)
self.global_main_mind_text = ft.Text(
"想法: N/A", size=11, overflow=ft.TextOverflow.ELLIPSIS, tooltip="完整想法请查看历史记录"
)
self.global_subflow_count_text = ft.Text("子流数: 0", size=11)
self.global_mai_state_text = ft.Text("状态: N/A | 活跃聊天数: 0", size=10, width=300)
# 子流最新状态 (key: stream_id)
self.stream_sub_minds = {}
self.stream_chat_states = {}
self.stream_threshold_status = {}
self.stream_last_active = {}
# self.stream_last_interaction = {} # Tkinter 有但日志似乎没有?
# 新增:历史想法队列
self.main_mind_history = deque(maxlen=MAX_QUEUE_SIZE)
self.last_main_mind_timestamp = 0
# --- UI 控件引用 ---
self.status_text = ft.Text("正在初始化监控器...", size=10, color=ft.colors.SECONDARY)
@@ -79,8 +131,6 @@ class InterestMonitorDisplay(ft.Column):
self.global_info_row = ft.Row(
controls=[
self.global_mai_state_text,
self.global_main_mind_text,
self.global_subflow_count_text,
],
spacing=15,
wrap=False, # 防止换行
@@ -97,36 +147,83 @@ class InterestMonitorDisplay(ft.Column):
)
self.stream_dropdown = ft.Dropdown(
label="选择流查看详情", options=[], width=300, on_change=self.on_stream_selected
label="选择流查看详情",
options=[],
width=200, # 调整宽度以适应并排布局
on_change=self.on_stream_selected,
)
# 创建合并的详情图表
self.detail_chart_combined = ft.LineChart(height=CHART_HEIGHT)
# --- 创建状态控制下拉菜单 ---
self.state_dropdown = ft.Dropdown(
label="选择目标状态",
options=[ft.dropdown.Option(text=state["text"], key=state["key"]) for state in CHAT_STATES],
width=150, # 调整宽度以适应并排布局
)
# --- 创建控制按钮 ---
self.control_button = ft.ElevatedButton(
"设置状态",
icon=ft.icons.SWAP_HORIZ,
on_click=self.handle_control_button_click,
disabled=True, # 初始禁用
)
# --- 控制按钮行 ---
self.control_row = ft.Row(
[
self.state_dropdown,
self.control_button,
],
alignment=ft.MainAxisAlignment.START,
spacing=10,
)
self.detail_chart_interest = ft.LineChart(height=CHART_HEIGHT)
self.detail_chart_probability = ft.LineChart(height=CHART_HEIGHT)
# --- 单个流详情文本控件 (Column) ---
self.detail_texts = ft.Column(
[
# --- 添加新的 Text 控件来显示详情 ---
ft.Text("想法: N/A", size=11, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS), # index 0: sub_mind
ft.Text("状态: N/A", size=11), # index 1: chat_state
ft.Text("阈值以上: N/A", size=11), # index 2: threshold
ft.Text("最后活跃: N/A", size=11), # index 3: last_active
# ft.Text("最后交互: N/A", size=11), # 如果需要的话
# --- 合并所有详情为一行 ---
ft.Text(
"状态: 无 | 最后活跃: 无",
size=20,
no_wrap=True,
overflow=ft.TextOverflow.ELLIPSIS,
tooltip="查看详细状态信息",
),
],
spacing=2,
)
# --- 历史想法 ListView ---
self.mind_history_listview = ft.ListView(expand=True, spacing=5, padding=5)
# --- 新增:切换显示按钮 ---
self.toggle_button = ft.IconButton(
icon=ft.icons.ARROW_UPWARD, tooltip="隐藏兴趣监控", on_click=self.toggle_display
)
# --- 新增:顶部栏包含状态和切换按钮 ---
self.top_bar = ft.Row(
[
self.status_text,
# ft.Spacer(), # Flet没有Spacer组件
ft.Container(expand=True), # 使用可扩展容器代替Spacer
self.toggle_button,
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
)
# --- 构建整体布局 ---
# 创建 Tabs 控件 (现在是 Column 的直接子控件)
# 创建 Tabs 控件
self.tabs_control = ft.Tabs(
selected_index=0,
animation_duration=300,
tabs=[
ft.Tab(
text="所有流兴趣度",
content=ft.Row(
content=ft.Column(
controls=[
self.global_info_row, # 将全局信息行移动到这里
ft.Row(
controls=[
self.main_chart, # 图表在左侧
self.legend_column, # 图例在右侧
@@ -134,49 +231,210 @@ class InterestMonitorDisplay(ft.Column):
vertical_alignment=ft.CrossAxisAlignment.START,
expand=True, # 让 Row 扩展
),
],
),
),
ft.Tab(
text="单个流详情",
content=ft.Column(
[
self.stream_dropdown,
ft.Divider(height=5, color=ft.colors.TRANSPARENT),
self.detail_texts, # 显示文本信息的 Column
ft.Divider(height=10, color=ft.colors.TRANSPARENT),
# 添加顶部间距,防止被标签遮挡
ft.Container(height=10),
# --- 修改:流选择、状态设置和详情文本放在同一行 ---
ft.Row(
[
ft.Column(
[ft.Text("兴趣度", weight=ft.FontWeight.BOLD), self.detail_chart_interest],
expand=1,
),
ft.Column(
[ft.Text("HFC概率", weight=ft.FontWeight.BOLD), self.detail_chart_probability],
expand=1,
),
self.stream_dropdown, # 流选择下拉菜单
ft.Container(width=10), # 添加间距
self.control_row, # 状态控制行
ft.Container(width=15), # 添加间距
self.detail_texts, # 显示文本信息的 Column (现在移到这一行)
],
alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
ft.Divider(height=10, color=ft.colors.TRANSPARENT),
# 合并的图表显示
ft.Column(
[ft.Text("兴趣度和HFC概率", weight=ft.FontWeight.BOLD), self.detail_chart_combined],
expand=1,
),
],
scroll=ft.ScrollMode.ADAPTIVE, # 自适应滚动
),
),
# --- 添加历史想法 Tab ---
ft.Tab(
text="麦麦历史想法",
content=self.mind_history_listview, # 直接使用 ListView
),
],
expand=True, # 让 Tabs 在父 Column 中扩展
)
# 主要内容区域(可隐藏部分)
self.content_area = ft.Column(
[
self.tabs_control, # 标签页
],
expand=True,
)
self.controls = [
self.status_text,
self.global_info_row, # 添加全局信息行
# --- Tabs 直接放在 Column 里 --- #
self.tabs_control,
self.top_bar, # 顶部栏包含状态和切换按钮
self.content_area, # 可隐藏的内容区域
]
print("[InterestMonitor] 初始化完成")
# --- 新增: 状态切换处理函数 ---
async def change_stream_state(self, e):
"""处理状态切换按钮点击"""
if not self.selected_stream_id_for_details or not self.state_dropdown.value:
# 显示错误提示
if self.page:
self.page.snack_bar = ft.SnackBar(content=ft.Text("请先选择子流和目标状态"), show_close_icon=True)
self.page.snack_bar.open = True
self.page.update()
return
subflow_id = self.selected_stream_id_for_details
target_state = self.state_dropdown.value # 这是英文的枚举值如 "ABSENT"
# 获取对应的中文显示文本,用于通知
state_text = next((state["text"] for state in CHAT_STATES if state["key"] == target_state), target_state)
try:
# 使用API切换子心流状态
success, error_msg = await self.change_subheartflow_status(subflow_id, target_state)
if success:
# 命令发送成功
if self.page:
self.page.snack_bar = ft.SnackBar(
content=ft.Text(f"已成功将子流 {subflow_id} 设置为 {state_text}"),
show_close_icon=True,
bgcolor=ft.colors.GREEN_200,
)
self.page.snack_bar.open = True
self.page.update()
else:
# 命令发送失败
if self.page:
self.page.snack_bar = ft.SnackBar(
content=ft.Text(f"命令发送失败: {error_msg}"), show_close_icon=True, bgcolor=ft.colors.RED_200
)
self.page.snack_bar.open = True
self.page.update()
except Exception as ex:
print(f"[调试] 切换子心流状态时出错: {ex}")
traceback.print_exc()
if self.page:
self.page.snack_bar = ft.SnackBar(
content=ft.Text(f"命令发送失败,请查看日志: {str(ex)}"),
show_close_icon=True,
bgcolor=ft.colors.RED_200,
)
self.page.snack_bar.open = True
self.page.update()
async def change_subheartflow_status(self, subflow_id, target_state):
"""通过API改变子心流状态"""
try:
# 验证参数
if not subflow_id:
print("[调试] 错误: subflow_id为空")
return False, "子流ID不能为空"
# 验证状态值是否为有效的枚举
valid_states = [state["key"] for state in CHAT_STATES]
if target_state not in valid_states:
print(f"[调试] 错误: 无效的目标状态 {target_state},有效值: {valid_states}")
return False, f"无效的目标状态: {target_state}"
# 转换状态到API期望的格式
api_state_value = API_CHAT_STATE_VALUES.get(target_state, target_state)
print(f"[调试] 转换状态值: {target_state} -> {api_state_value}")
url = f"{API_BASE_URL}/gui/subheartflow/forced_change_status"
# API需要的是查询参数使用转换后的状态值
params = {"subheartflow_id": subflow_id, "status": api_state_value}
print(f"[调试] 准备发送API请求: URL={url}")
print(f"[调试] URL参数={params}")
async with httpx.AsyncClient(timeout=30.0) as client: # 增加超时时间到30秒
print("[调试] 正在发送API请求...")
try:
response = await client.post(url, params=params)
print(f"[调试] 收到API响应: 状态码={response.status_code}")
print(f"[调试] 响应内容: {response.text}")
except httpx.TimeoutException:
print(f"[调试] API请求超时服务器可能未运行或端口配置错误: {url}")
return False, "API请求超时"
if response.status_code == 200:
try:
result = response.json()
print(f"[调试] 解析响应JSON: {result}")
if result.get("status") == "success":
print(f"[InterestMonitor] API请求成功: 将子流 {subflow_id} 设置为 {target_state}")
return True, None
else:
error_msg = result.get("reason", "未知错误")
print(f"[InterestMonitor] API请求失败: {error_msg}")
return False, error_msg
except json.JSONDecodeError:
print(f"[调试] 响应不是有效的JSON: {response.text}")
return False, "服务器响应不是有效的JSON"
else:
print(f"[InterestMonitor] API请求失败: HTTP状态码 {response.status_code}")
return False, f"HTTP错误: {response.status_code}"
except Exception as e:
print(f"[InterestMonitor] 调用API出错: {e}")
traceback.print_exc()
return False, str(e)
def handle_control_button_click(self, e):
"""处理控制按钮点击,启动异步任务"""
try:
print("[调试] 控制按钮被点击")
if self.page:
print("[调试] 准备启动异步任务")
# 创建一个不需要参数的异步包装函数
async def async_wrapper():
return await self.change_stream_state(e)
# 使用包装函数作为任务
async_task = self.page.run_task(async_wrapper)
print(f"[调试] 异步任务已启动: {async_task}")
else:
print("[调试] 错误: self.page 为 None")
except Exception as ex:
print(f"[调试] 启动任务时出错: {ex}")
traceback.print_exc()
def toggle_display(self, e):
"""切换兴趣监控的显示/隐藏状态"""
self.is_expanded = not self.is_expanded
# 更新按钮图标和提示
if self.is_expanded:
self.toggle_button.icon = ft.icons.ARROW_DOWNWARD
self.toggle_button.tooltip = "隐藏兴趣监控"
else:
self.toggle_button.icon = ft.icons.ARROW_UPWARD
self.toggle_button.tooltip = "显示兴趣监控"
# 切换内容区域的可见性
self.content_area.visible = self.is_expanded
# 调用回调函数通知父容器
if self.on_toggle:
self.on_toggle(self.is_expanded)
# 更新UI
self.update()
def did_mount(self):
print("[InterestMonitor] 控件已挂载,启动日志读取任务")
if self.page:
@@ -196,10 +454,8 @@ class InterestMonitorDisplay(ft.Column):
async def log_reader_loop(self):
while True:
try:
mind_history_updated = await self.load_and_process_log()
await self.load_and_process_log()
await self.update_charts()
if mind_history_updated:
await self.refresh_mind_listview()
except asyncio.CancelledError:
print("[InterestMonitor] 日志读取循环被取消")
break
@@ -211,16 +467,15 @@ class InterestMonitorDisplay(ft.Column):
await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
async def load_and_process_log(self):
"""读取并处理日志文件的新增内容。返回是否有新的 Main Mind 记录。"""
mind_history_updated = False # 跟踪是否有新的 Main Mind
"""读取并处理日志文件的新增内容。"""
if not os.path.exists(LOG_FILE_PATH):
self.update_status("日志文件未找到", ft.colors.WARNING)
return mind_history_updated
self.update_status("日志文件未找到", ft.colors.ORANGE)
return
try:
file_mod_time = os.path.getmtime(LOG_FILE_PATH)
if file_mod_time <= self.last_log_read_time:
return mind_history_updated
return
print(f"[InterestMonitor] 检测到日志文件更新 (修改时间: {file_mod_time}), 正在读取...", flush=True)
@@ -232,11 +487,9 @@ class InterestMonitorDisplay(ft.Column):
self.stream_chat_states.clear()
self.stream_threshold_status.clear()
self.stream_last_active.clear()
# self.stream_last_interaction.clear()
read_count = 0
error_count = 0
# 注意Flet 版本目前没有实现时间过滤,会读取整个文件
with open(LOG_FILE_PATH, "r", encoding="utf-8") as f:
for line in f:
@@ -290,10 +543,11 @@ class InterestMonitorDisplay(ft.Column):
for subflow_entry in subflows:
stream_id = subflow_entry.get("stream_id")
interest = subflow_entry.get("interest")
# 兼容两种字段名
interest = subflow_entry.get("interest", subflow_entry.get("interest_level"))
group_name = subflow_entry.get("group_name", stream_id)
# 新增: 获取子流概率
probability = subflow_entry.get("probability")
# 兼容两种概率字段名
probability = subflow_entry.get("probability", subflow_entry.get("start_hfc_probability"))
if stream_id is None or interest is None:
continue
@@ -331,7 +585,6 @@ class InterestMonitorDisplay(ft.Column):
self.stream_chat_states[stream_id] = subflow_entry.get("sub_chat_state", "N/A")
self.stream_threshold_status[stream_id] = subflow_entry.get("is_above_threshold", False)
self.stream_last_active[stream_id] = subflow_entry.get("chat_state_changed_time")
# self.stream_last_interaction[stream_id] = ... # 如果日志中有
except json.JSONDecodeError:
error_count += 1
@@ -356,28 +609,39 @@ class InterestMonitorDisplay(ft.Column):
# 更新全局信息控件 (如果 page 存在)
if self.page:
# TODO: Update global info based on latest log entry? Or aggregate?
# For now, let's update with the last processed entry's data if available
# 更新全局状态信息
if log_entry: # Check if log_entry was populated
self.global_mai_state_text.value = f"状态: {log_entry.get('mai_state', 'N/A')}"
self.global_main_mind_text.value = f"想法: {log_entry.get('main_mind', 'N/A')}"
self.global_main_mind_text.tooltip = log_entry.get("main_mind", "N/A")
self.global_subflow_count_text.value = f"子流数: {log_entry.get('subflow_count', '0')}"
self.global_info_row.update() # Update the row
mai_state = log_entry.get("mai_state", "N/A")
# subflow_count = log_entry.get('subflow_count', '0')
# --- 处理 Main Mind 历史 ---
if "main_mind" in log_entry and entry_timestamp > self.last_main_mind_timestamp:
self.main_mind_history.append(log_entry)
self.last_main_mind_timestamp = entry_timestamp
mind_history_updated = True
# 获取当前时间和行数信息,格式化为状态信息的一部分
current_time = datetime.now().strftime("%H:%M:%S")
status_info = f"读取于{current_time}"
if error_count > 0:
status_info += f" (跳过 {error_count} 行)"
self.global_info_row.update() # 直接调用 update()
# 将所有信息合并到一行显示
if mai_state != "N/A":
if mai_state == "PEEKING":
mai_state_str = "看一眼手机"
elif mai_state == "NORMAL_CHAT":
mai_state_str = "正常看手机"
elif mai_state == "FOCUSED_CHAT":
mai_state_str = "专心看手机"
elif mai_state == "OFFLINE":
mai_state_str = "不在线"
combined_info = f"{status_info} | 状态: {mai_state_str}"
self.global_mai_state_text.value = combined_info
self.global_info_row.update()
# 更新状态文本的颜色
color = ft.colors.GREEN if error_count == 0 else ft.colors.ORANGE
self.global_mai_state_text.color = color
# 更新下拉列表选项
await self.update_dropdown_options()
return mind_history_updated # 返回是否有新 mind 记录
except IOError as e:
print(f"读取日志文件时发生 IO 错误: {e}")
self.update_status(f"日志 IO 错误: {e}", ft.colors.ERROR)
@@ -386,40 +650,29 @@ class InterestMonitorDisplay(ft.Column):
traceback.print_exc()
self.update_status(f"处理日志时出错: {e}", ft.colors.ERROR)
return mind_history_updated
async def refresh_mind_listview(self):
"""刷新历史想法列表视图。"""
if not self.page:
return # 无法更新
self.mind_history_listview.controls.clear()
for entry in self.main_mind_history:
ts = entry.get("timestamp", 0)
dt_str = format_timestamp(ts)
main_mind = entry.get("main_mind", "")
mai_state = entry.get("mai_state", "")
subflow_count = entry.get("subflow_count", "")
# 使用 Markdown 加粗时间,简化显示
text_content = f"**[{dt_str}]** 状态:{mai_state} 子流:{subflow_count}\n{main_mind}"
self.mind_history_listview.controls.append(
ft.Markdown(text_content, selectable=True, extension_set=ft.MarkdownExtensionSet.COMMON_MARK)
)
# print(f"[InterestMonitor] 刷新历史想法列表,共 {len(self.mind_history_listview.controls)} 条")
self.mind_history_listview.update() # 更新 ListView
# 滚动到底部 (如果需要)
# await asyncio.sleep(0.1) # 短暂延迟确保控件更新
# self.mind_history_listview.scroll_to(offset=-1, duration=300) # 滚动到底部
# 注意scroll_to 可能需要在 page 上下文或特定条件下才有效
async def update_charts(self):
all_series = []
legend_items = [] # 存储图例控件
# 检查是否有足够的数据生成图表
if not self.stream_history:
print("[InterestMonitor] 警告: 没有流历史数据可用于生成图表")
self.update_status("无图表数据可用", ft.colors.ORANGE)
# 清空图表
self.main_chart.data_series = []
self.legend_column.controls = []
self.update()
return
active_streams_sorted = sorted(
self.stream_history.items(), key=lambda item: item[1][-1][1] if item[1] else -1, reverse=True
)[:MAX_STREAMS_TO_DISPLAY]
# 调试信息
print(f"[InterestMonitor] 有 {len(active_streams_sorted)} 个活跃流可用于图表")
for stream_id, history in active_streams_sorted:
print(f"[InterestMonitor] 流 {stream_id}: {len(history)} 个数据点")
min_ts, max_ts = self.get_time_range(self.stream_history)
for stream_id, history in active_streams_sorted:
@@ -431,6 +684,15 @@ class InterestMonitorDisplay(ft.Column):
if not mpl_dates:
continue
# 为颜色分配固定的颜色,如果不存在
if stream_id not in self.stream_colors:
self.stream_colors[stream_id] = get_random_flet_color()
# 获取或设置显示名称
if stream_id not in self.stream_display_names:
group_name = self.stream_details.get(stream_id, {}).get("group_name", stream_id)
self.stream_display_names[stream_id] = group_name
data_points = [ft.LineChartDataPoint(x=ts, y=interest) for ts, interest in zip(mpl_dates, interests)]
all_series.append(
ft.LineChartData(
@@ -454,6 +716,7 @@ class InterestMonitorDisplay(ft.Column):
)
except Exception as plot_err:
print(f"绘制主图表/图例时跳过 Stream {stream_id}: {plot_err}")
traceback.print_exc() # 添加完整的错误堆栈
continue
# --- 更新主图表 ---
@@ -466,17 +729,32 @@ class InterestMonitorDisplay(ft.Column):
# --- 更新图例 ---
self.legend_column.controls = legend_items
# 只有在选择了流的情况下更新详情图表
if self.selected_stream_id_for_details:
await self.update_detail_charts(self.selected_stream_id_for_details)
else:
print("[InterestMonitor] 未选择流,跳过详情图表更新")
if self.page:
# 更新整个控件,包含图表和图例的更新
self.update()
else:
print("[InterestMonitor] 警告: self.page 为 None无法更新图表 UI")
async def update_detail_charts(self, stream_id):
interest_series = []
probability_series = []
combined_series = []
min_ts_detail, max_ts_detail = None, None
# --- 增加检查如果没有选择流ID或流ID不在历史记录中则直接返回
if not stream_id or stream_id not in self.stream_history:
print(f"[InterestMonitor] 没有找到流ID或未选择流ID: {stream_id}")
# 清空图表
self.detail_chart_combined.data_series = []
# 确保更新详情文本,清空信息
await self.update_detail_texts(None)
return
# --- 兴趣度图 ---
if stream_id and stream_id in self.stream_history and self.stream_history[stream_id]:
min_ts_detail, max_ts_detail = self.get_time_range({stream_id: self.stream_history[stream_id]})
@@ -487,7 +765,7 @@ class InterestMonitorDisplay(ft.Column):
interest_data_points = [
ft.LineChartDataPoint(x=ts, y=interest) for ts, interest in zip(mpl_dates, interests)
]
interest_series.append(
combined_series.append(
ft.LineChartData(
data_points=interest_data_points,
color=self.stream_colors.get(stream_id, ft.colors.BLUE),
@@ -516,30 +794,29 @@ class InterestMonitorDisplay(ft.Column):
if max_prob_ts is not None:
max_ts_detail = max(max_ts_detail, max_prob_ts)
# 调整HFC概率值到兴趣度的比例范围便于在一个图表中显示
# 兴趣度范围0-10将概率值x10
scaled_probabilities = [prob * 10 for prob in probabilities]
probability_data_points = [
ft.LineChartDataPoint(x=ts, y=prob) for ts, prob in zip(prob_dates, probabilities)
ft.LineChartDataPoint(x=ts, y=prob) for ts, prob in zip(prob_dates, scaled_probabilities)
]
probability_series.append(
combined_series.append(
ft.LineChartData(
data_points=probability_data_points,
color=self.stream_colors.get(stream_id, ft.colors.GREEN),
color=ft.colors.GREEN,
stroke_width=2,
)
)
except Exception as plot_err:
print(f"绘制详情概率图时出错 Stream {stream_id}: {plot_err}")
self.detail_chart_interest.data_series = interest_series
self.detail_chart_interest.min_y = 0
self.detail_chart_interest.max_y = 10
self.detail_chart_interest.min_x = min_ts_detail
self.detail_chart_interest.max_x = max_ts_detail
self.detail_chart_probability.data_series = probability_series
self.detail_chart_probability.min_y = 0
self.detail_chart_probability.max_y = 1.05
self.detail_chart_probability.min_x = min_ts_detail
self.detail_chart_probability.max_x = max_ts_detail
# 更新合并图表
self.detail_chart_combined.data_series = combined_series
self.detail_chart_combined.min_y = 0
self.detail_chart_combined.max_y = 10
self.detail_chart_combined.min_x = min_ts_detail
self.detail_chart_combined.max_x = max_ts_detail
await self.update_detail_texts(stream_id)
@@ -547,61 +824,86 @@ class InterestMonitorDisplay(ft.Column):
current_value = self.stream_dropdown.value
options = []
valid_stream_ids = set()
sorted_items = sorted(self.stream_display_names.items(), key=lambda item: item[1])
# 调试信息
print(f"[InterestMonitor] 更新流下拉列表,当前有 {len(self.stream_history)} 个流")
# 确保所有流都有显示名称
for stream_id in self.stream_history.keys():
if stream_id not in self.stream_display_names:
# 如果没有显示名称使用group_name或stream_id
group_name = self.stream_details.get(stream_id, {}).get("group_name", stream_id)
self.stream_display_names[stream_id] = group_name
print(f"[InterestMonitor] 为流 {stream_id} 设置显示名称: {group_name}")
# 排序所有流数据用于下拉列表
sorted_items = sorted(
[
(stream_id, self.stream_display_names.get(stream_id, stream_id))
for stream_id in self.stream_history.keys()
],
key=lambda item: item[1], # 按显示名称排序
)
for stream_id, display_name in sorted_items:
if stream_id in self.stream_history and self.stream_history[stream_id]:
# 使用 f"DisplayName (StreamID)" 格式?
option_text = f"{display_name}"
options.append(ft.dropdown.Option(key=stream_id, text=option_text))
valid_stream_ids.add(stream_id)
print(f"[InterestMonitor] 添加流选项: {stream_id} ({display_name})")
self.stream_dropdown.options = options
# Flet Dropdown 的 value 似乎是用 key (stream_id) 来匹配的
if current_value not in valid_stream_ids:
# 如果之前的 stream_id 不再有效,尝试选择第一个,否则清空
# 如果当前值无效,选择第一个选项或清空
if not current_value or current_value not in valid_stream_ids:
new_value = options[0].key if options else None
if self.stream_dropdown.value != new_value: # 避免不必要的更新循环
if self.stream_dropdown.value != new_value:
print(f"[InterestMonitor] 设置新的选中流: {new_value}")
self.stream_dropdown.value = new_value
self.selected_stream_id_for_details = new_value
await self.update_detail_charts(new_value)
if self.page and self.stream_dropdown.page: # 确保控件已挂载再更新
# 确保按钮状态正确
self.control_button.disabled = not self.stream_dropdown.value
if self.page and self.stream_dropdown.page:
self.stream_dropdown.update()
self.control_button.update()
async def on_stream_selected(self, e):
selected_id = e.control.value # value 应该是 stream_id (key)
print(f"[InterestMonitor] 选择了 Stream ID: {selected_id}")
if self.selected_stream_id_for_details != selected_id:
self.selected_stream_id_for_details = selected_id
# 启用控制按钮
self.control_button.disabled = selected_id is None
await self.update_detail_charts(selected_id)
# Dropdown 更新是自动的,但图表和文本需要手动触发父容器更新
if self.page:
self.update()
async def update_detail_texts(self, stream_id):
if not self.detail_texts or not hasattr(self.detail_texts, "controls") or len(self.detail_texts.controls) < 4:
if not self.detail_texts or not hasattr(self.detail_texts, "controls") or len(self.detail_texts.controls) < 1:
print("[InterestMonitor] 错误detail_texts 未正确初始化或控件不足")
return
if stream_id:
if stream_id and stream_id in self.stream_history:
sub_mind = self.stream_sub_minds.get(stream_id, "N/A")
chat_state = self.stream_chat_states.get(stream_id, "N/A")
threshold = self.stream_threshold_status.get(stream_id, False)
last_active_ts = self.stream_last_active.get(stream_id)
last_active_str = format_timestamp(last_active_ts)
self.detail_texts.controls[0].value = f"想法: {sub_mind}"
self.detail_texts.controls[0].tooltip = sub_mind # 添加 tooltip
self.detail_texts.controls[1].value = f"状态: {chat_state}"
self.detail_texts.controls[2].value = f"阈值以上: {'' if threshold else ''}"
self.detail_texts.controls[3].value = f"最后活跃: {format_timestamp(last_active_ts)}"
# self.detail_texts.controls[4].value = ... # 如果有更多详情
# 合并详情为一行
detail_text = f"状态: {chat_state} | 最后活跃: {last_active_str}"
if sub_mind and sub_mind != "N/A" and sub_mind.strip():
detail_text = f"想法: {sub_mind} | {detail_text}"
self.detail_texts.controls[0].value = detail_text
self.detail_texts.controls[0].tooltip = detail_text # 完整文本作为tooltip
else:
self.detail_texts.controls[0].value = "想法: N/A"
self.detail_texts.controls[0].tooltip = "N/A"
self.detail_texts.controls[1].value = "状态: N/A"
self.detail_texts.controls[2].value = "阈值以上: N/A"
self.detail_texts.controls[3].value = "最后活跃: N/A"
# self.detail_texts.controls[4].value = "N/A"
# 默认显示
self.detail_texts.controls[0].value = "状态: 无 | 最后活跃: 无"
self.detail_texts.controls[0].tooltip = "暂无详细信息"
if self.page and self.detail_texts.page: # 确保控件已挂载再更新
self.detail_texts.update()
@@ -609,14 +911,24 @@ class InterestMonitorDisplay(ft.Column):
def update_status(self, message: str, color: str = ft.colors.SECONDARY):
max_len = 150
display_message = (message[:max_len] + "...") if len(message) > max_len else message
# 保留当前状态信息的一部分(如果存在)
if "|" in self.global_mai_state_text.value:
status_part = self.global_mai_state_text.value.split("|")[1].strip()
self.status_text.value = f"{display_message} | {status_part}"
else:
self.status_text.value = display_message
self.status_text.color = color
if self.page and self.status_text.page:
self.status_text.update()
def get_time_range(self, history_dict, is_prob=False):
"""获取所有数据点的时间范围,确保即使没有数据也能返回有效的时间范围"""
all_ts = []
target_history_key = self.probability_history if is_prob else self.stream_history
try:
for stream_id, _history in history_dict.items():
# 使用正确的历史记录字典
actual_history = target_history_key.get(stream_id)
@@ -624,13 +936,68 @@ class InterestMonitorDisplay(ft.Column):
all_ts.extend([ts for ts, _ in actual_history])
if not all_ts:
# 如果没有时间戳,返回当前时间前后一小时的范围
now = time.time()
return now - 3600, now
print(f"[InterestMonitor] 警告: 没有找到时间戳数据,使用当前时间: {now}")
return now - 3600, now + 60
# 确保时间戳是有效的数字
valid_ts = [ts for ts in all_ts if isinstance(ts, (int, float))]
if not valid_ts:
now = time.time()
print(f"[InterestMonitor] 警告: 没有有效的时间戳数据,使用当前时间: {now}")
return now - 3600, now + 60
min_ts = min(valid_ts)
max_ts = max(valid_ts)
# 确保时间范围不为零(避免图表问题)
if min_ts == max_ts:
print(f"[InterestMonitor] 警告: 最小和最大时间戳相同: {min_ts}")
padding = 60 # 如果只有一个点前后加1分钟
else:
padding = (max_ts - min_ts) * 0.05 # 正常情况下添加5%的填充
min_ts = min(all_ts)
max_ts = max(all_ts)
padding = (max_ts - min_ts) * 0.05 if max_ts > min_ts else 10
return min_ts - padding, max_ts + padding
except Exception as e:
# 出现任何错误都返回当前时间范围
now = time.time()
print(f"[InterestMonitor] 获取时间范围时出错: {e}")
traceback.print_exc()
return now - 3600, now + 60
def send_gui_command_file(self, subflow_id, target_state):
"""使用文件方式发送命令(备用方法)"""
try:
# 确保目录存在
command_dir = os.path.dirname(GUI_COMMAND_PATH)
if command_dir: # 如果有目录部分
os.makedirs(command_dir, exist_ok=True)
# 创建命令数据
command_data = {
"subflow_id": subflow_id,
"target_state": target_state, # 不转为大写,保留原始状态值
}
# 写入文件
with open(GUI_COMMAND_PATH, "w", encoding="utf-8") as f:
json.dump(command_data, f, ensure_ascii=False, indent=2)
print(f"[InterestMonitor] 已通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}")
if self.page:
self.page.snack_bar = ft.SnackBar(
content=ft.Text(f"通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}"),
show_close_icon=True,
bgcolor=ft.colors.ORANGE_200, # 使用不同颜色表示使用了备用方式
)
self.page.snack_bar.open = True
self.page.update()
return True
except Exception as e:
print(f"[InterestMonitor] 发送GUI命令文件出错: {e}")
traceback.print_exc()
return False
# --- 测试部分保持不变 ---

159
src/MaiGoi/flet_rules.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Flet UI开发的规则和最佳实践
这个文件记录了在使用Flet开发UI界面时发现的重要规则和最佳实践
可以帮助避免常见错误并提高代码质量。
"""
# ===== Container相关规则 =====
"""
规则1: Container没有controls属性
Container只有content属性不能直接访问controls。必须通过container.content访问内容。
错误示例:
container.controls.append(...) # 错误! Container没有controls属性
正确示例:
container.content = ft.Column([]) # 先设置content为一个有controls属性的控件
container.content.controls.append(...) # 然后通过content访问controls
"""
"""
规则2: Card没有padding属性
Card控件不直接支持padding必须用Container包装来添加padding。
错误示例:
ft.Card(padding=10, content=...) # 错误! Card没有padding属性
正确示例:
ft.Card(
content=ft.Container(
content=...,
padding=10
)
)
"""
# ===== UI更新规则 =====
"""
规则3: 控件必须先添加到页面才能调用update()
调用控件的update()方法前,确保该控件已经添加到页面中,否则会报错。
错误示例:
new_column = ft.Column([])
new_column.update() # 错误! 控件还未添加到页面
正确示例:
# 区分初始加载和用户交互
def add_item(e=None, is_initial=False):
# 创建新控件...
items_column.controls.append(new_control)
# 只在用户交互时更新UI
if not is_initial and e is not None:
items_column.update()
"""
"""
规则4: 嵌套结构展开/折叠时的更新策略
处理嵌套数据结构(如字典)的展开/折叠时要小心控制update()的调用时机。
最佳实践:
1. 在生成UI结构时不要调用update()
2. 在用户交互(如点击展开按钮)后再调用update()
3. 始终从父容器调用update(),而不是每个子控件都调用
4. 添加异常处理防止动态生成控件时的错误导致整个UI崩溃
"""
# ===== 数据类型处理规则 =====
"""
规则5: 特殊处理集合类型(set)
Python中的set类型在UI表示时需要特殊处理将其转换为可编辑的表单控件。
最佳实践:
1. 为set类型实现专门的UI控件(如_create_set_control)
2. 添加错误处理,即使创建控件失败也要提供备选显示方式
3. 小心处理类型转换确保UI中的数据变更能正确应用到set类型
示例:
if isinstance(value, set):
try:
return create_set_control(value)
except Exception:
return ft.Text(f"{value} (不可编辑)", italic=True)
"""
"""
规则6: 动态UI组件的初始化与更新分离
创建动态UI组件时将初始化和更新逻辑分开处理。
最佳实践:
1. 初始化时只创建控件不调用update()
2. 使用标志(如is_initial)区分初始加载和用户交互
3. 只在用户交互时调用update()
4. 更新数据模型和更新UI分开处理
示例:
# 添加现有项目使用is_initial=True标记为初始化
for item in values:
add_item(item, is_initial=True)
# 用户添加新项目时不使用is_initial参数
add_button.on_click = lambda e: add_item(new_value)
"""
# ===== 其他实用规则 =====
"""
规则7: 始终使用正确的padding格式
Flet中padding必须使用正确的格式不能直接传入数字。
错误示例:
ft.Padding(padding=10, content=...) # 错误
正确示例:
ft.Padding(padding=ft.padding.all(10), content=...)
ft.Container(padding=ft.padding.all(10), content=...)
"""
"""
规则8: 控件引用路径注意层级关系
访问嵌套控件时注意层级关系特别是当使用Container包装其他控件时。
错误示例:
# 如果card的内容是Container且Container的内容是Column
button = card.controls[-1] # 错误! Card没有controls属性
正确示例:
# 正确的访问路径
button = card.content.content.controls[-1]
"""
# ===== 自定义控件规则 (Flet v0.21.0+) =====
"""
规则9: 弃用 UserControl直接继承基础控件
Flet v0.21.0 及更高版本已弃用 `ft.UserControl`。
创建自定义控件时,应直接继承自 Flet 的基础控件,如 `ft.Column`, `ft.Row`, `ft.Card`, `ft.Text` 等。
修改步骤:
1. 更改类定义: `class MyControl(ft.Column):` 替换 `class MyControl(ft.UserControl):`
2. 将 `build()` 方法中的 UI 构建逻辑移至 `__init__` 方法。
3. 在 `__init__` 中调用 `super().__init__(...)` 并传递基础控件所需的参数。
4. 在 `__init__` 中直接将子控件添加到 `self.controls`。
5. 移除 `build()` 方法。
错误示例 (已弃用):
class OldCustom(ft.UserControl):
def build(self):
return ft.Text("Old way")
正确示例 (继承 ft.Column):
class NewCustom(ft.Column):
def __init__(self):
super().__init__(spacing=5)
self.controls.append(ft.Text("New way"))
"""

View File

@@ -53,7 +53,7 @@ def update_buttons_state(page: Optional[ft.Page], app_state: "AppState", is_runn
console_button.on_click = new_onclick
needs_update = True
else:
new_text = "MaiCore 主控室"
new_text = "启动 MaiCore"
new_color = ft.colors.with_opacity(0.6, ft.colors.GREEN_ACCENT_100)
new_onclick = _start_action # Use def
if (
@@ -317,9 +317,43 @@ async def output_processor_loop(
break
if output_lv:
# 如果在手动观看模式(自动滚动关闭),记录首个元素索引
if process_id == "bot.py" and hasattr(app_state, "manual_viewing") and app_state.manual_viewing:
# 只有在自动滚动关闭时才保存视图位置
if not getattr(output_lv, "auto_scroll", True):
# 记录当前第一个可见元素的索引
first_visible_idx = 0
if hasattr(output_lv, "first_visible") and output_lv.first_visible is not None:
first_visible_idx = output_lv.first_visible
# 添加新行
output_lv.controls.extend(lines_to_add)
# 移除过多的行
removal_count = 0
while len(output_lv.controls) > 1000:
output_lv.controls.pop(0)
removal_count += 1
# 如果移除了行,需要调整首个可见元素的索引
if removal_count > 0 and first_visible_idx > removal_count:
new_idx = max(0, first_visible_idx - removal_count)
# 设置滚动位置到调整后的索引
output_lv.first_visible = new_idx
else:
# 保持当前滚动位置
output_lv.first_visible = first_visible_idx
else:
# 自动滚动开启时,正常添加
output_lv.controls.extend(lines_to_add)
while len(output_lv.controls) > 1000:
output_lv.controls.pop(0) # Limit lines
else:
# 对于非主控制台输出,或没有手动观看模式,正常处理
output_lv.controls.extend(lines_to_add)
while len(output_lv.controls) > 1000:
output_lv.controls.pop(0) # Limit lines
if output_lv.visible and page:
try:
await update_page_safe(page)
@@ -447,7 +481,7 @@ def start_managed_process(
output_lv = app_state.output_list_view # Use the main console view
else:
# Create and store a new ListView for this specific process
output_lv = ft.ListView(expand=True, spacing=2, padding=5, auto_scroll=True) # Default auto_scroll on
output_lv = ft.ListView(expand=True, spacing=2, padding=5, auto_scroll=True) # 始终默认开启自动滚动
new_process_state.output_list_view = output_lv
# Add starting message to the determined ListView
@@ -460,16 +494,87 @@ def start_managed_process(
print(f"[Start Managed - {process_id}] Starting subprocess: {full_path}", flush=True)
sub_env = os.environ.copy()
# Set env vars if needed (e.g., for colorization)
sub_env["LOGURU_COLORIZE"] = "True" # Tell Loguru (inside bot.py) to colorize
sub_env["FORCE_COLOR"] = "1" # Force libraries that check this to use color
sub_env["SIMPLE_OUTPUT"] = "True" # Custom flag for bot.py to use simple format
sub_env["LOGURU_COLORIZE"] = "True"
sub_env["FORCE_COLOR"] = "1"
sub_env["SIMPLE_OUTPUT"] = "True"
print(
f"[Start Managed - {process_id}] Subprocess environment set: COLORIZE={sub_env.get('LOGURU_COLORIZE')}, FORCE_COLOR={sub_env.get('FORCE_COLOR')}, SIMPLE_OUTPUT={sub_env.get('SIMPLE_OUTPUT')}",
flush=True,
)
# --- 修改启动命令 ---
cmd_list = []
executable_path = "" # 用于日志记录
if getattr(sys, "frozen", False):
# 打包后运行
executable_dir = os.path.dirname(sys.executable)
# 修改逻辑:这次我们直接指定 _internal 目录下的 Python 解释器
# 而不是尝试其他选项
try:
# _internal 目录是 PyInstaller 默认放置 Python 解释器的位置
internal_dir = os.path.join(executable_dir, "_internal")
if os.path.exists(internal_dir):
print(f"[Start Managed - {process_id}] 找到 _internal 目录: {internal_dir}")
# 在 _internal 目录中查找 python.exe
python_exe = None
python_paths = []
# 首先尝试直接查找
direct_python = os.path.join(internal_dir, "python.exe")
if os.path.exists(direct_python):
python_exe = direct_python
python_paths.append(direct_python)
# 如果没找到,进行递归搜索
if not python_exe:
for root, _, files in os.walk(internal_dir):
if "python.exe" in files:
path = os.path.join(root, "python.exe")
python_paths.append(path)
if not python_exe: # 只取第一个找到的
python_exe = path
# 记录所有找到的路径
if python_paths:
print(f"[Start Managed - {process_id}] 在 _internal 中找到的所有 Python.exe: {python_paths}")
if python_exe:
# 找到 Python 解释器,使用它来运行脚本
cmd_list = [python_exe, "-u", full_path]
executable_path = python_exe
print(f"[Start Managed - {process_id}] 使用打包内部的 Python: {executable_path}")
else:
# 如果找不到,只能使用脚本文件直接执行
print(f"[Start Managed - {process_id}] 无法在 _internal 目录中找到 python.exe")
cmd_list = [full_path]
executable_path = full_path
print(f"[Start Managed - {process_id}] 直接执行脚本: {executable_path}")
else:
# _internal 目录不存在,尝试直接执行脚本
print(f"[Start Managed - {process_id}] _internal 目录不存在: {internal_dir}")
cmd_list = [full_path]
executable_path = full_path
print(f"[Start Managed - {process_id}] 直接执行脚本: {executable_path}")
except Exception as path_err:
print(f"[Start Managed - {process_id}] 查找 Python 路径时出错: {path_err}")
# 如果出现异常,尝试直接执行脚本
cmd_list = [full_path]
executable_path = full_path
print(f"[Start Managed - {process_id}] 出错回退:直接执行脚本 {executable_path}")
else:
# 源码运行,使用当前的 Python 解释器
cmd_list = [sys.executable, "-u", full_path]
executable_path = sys.executable
print(f"[Start Managed - {process_id}] 源码模式:使用当前 Python ({executable_path})")
print(f"[Start Managed - {process_id}] 最终命令列表: {cmd_list}")
process = subprocess.Popen(
[sys.executable, "-u", full_path], # -u for unbuffered output
cmd_list, # 使用构建好的命令列表
cwd=app_state.script_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,

View File

@@ -40,7 +40,8 @@ class AppState:
self.output_list_view: Optional[ft.ListView] = None
self.start_bot_button: Optional[ft.FilledButton] = None
self.console_action_button: Optional[ft.ElevatedButton] = None
self.is_auto_scroll_enabled: bool = False
self.is_auto_scroll_enabled: bool = True # 默认启用自动滚动
self.manual_viewing: bool = False # 手动观看模式标识,用于修复自动滚动关闭时的位移问题
self.interest_monitor_control: Optional[InterestMonitorDisplay] = None
# Script directory (useful for paths)

View File

@@ -0,0 +1,916 @@
import flet as ft
import tomlkit
from typing import Dict, Any, List, Optional, Union
from pathlib import Path
def load_template_with_comments(template_filename: str = "bot_config_template.toml"):
"""
加载指定的模板文件,保留所有注释。
Args:
template_filename: 要加载的模板文件名 (相对于 template/ 目录)。
Returns:
包含注释的TOML文档对象如果失败则返回空文档。
"""
try:
# 首先尝试从相对路径加载 (相对于项目根目录)
# 假设此脚本位于 src/MaiGoi/
base_path = Path(__file__).parent.parent.parent
template_path = base_path / "template" / template_filename
if template_path.exists():
print(f"找到模板文件: {template_path}")
with open(template_path, "r", encoding="utf-8") as f:
return tomlkit.parse(f.read())
else:
print(f"警告: 模板文件不存在: {template_path}")
return tomlkit.document()
except Exception as e:
print(f"加载模板文件 '{template_filename}' 出错: {e}")
return tomlkit.document()
def get_comment_for_key(template_doc, key_path: str) -> str:
"""
获取指定键路径的注释 (修正版)
Args:
template_doc: 包含注释的TOML文档
key_path: 点分隔的键路径,例如 "bot.qq"
Returns:
该键对应的注释字符串,如果没有则返回空字符串
"""
if not template_doc:
return ""
try:
parts = key_path.split(".")
current_item = template_doc
# 逐级导航到目标项或其父表
for i, part in enumerate(parts):
if part not in current_item:
print(f"警告: 路径部分 '{part}'{'.'.join(parts[:i])} 中未找到")
return "" # 路径不存在
# 如果是最后一个部分,我们找到了目标项
if i == len(parts) - 1:
target_item = current_item[part]
# --- 尝试从 trivia 获取注释 ---
if hasattr(target_item, "trivia") and hasattr(target_item.trivia, "comment"):
comment_lines = target_item.trivia.comment.split("\n")
# 去除每行的 '#' 和首尾空格
cleaned_comment = "\n".join([line.strip().lstrip("#").strip() for line in comment_lines])
if cleaned_comment:
return cleaned_comment
# --- 如果是顶级表,也检查容器自身的 trivia ---
# (tomlkit 对于顶级表的注释存储方式可能略有不同)
if isinstance(target_item, (tomlkit.items.Table, tomlkit.container.Container)) and len(parts) == 1:
if hasattr(target_item, "trivia") and hasattr(target_item.trivia, "comment"):
comment_lines = target_item.trivia.comment.split("\n")
cleaned_comment = "\n".join([line.strip().lstrip("#").strip() for line in comment_lines])
if cleaned_comment:
return cleaned_comment
# 如果 trivia 中没有,尝试一些旧版或不常用的属性 (风险较高)
# if hasattr(target_item, '_comment'): # 不推荐
# return str(target_item._comment).strip(" #")
# 如果以上都找不到,返回空
return ""
# 继续导航到下一级
current_item = current_item[part]
# 如果中间路径不是表/字典,则无法继续
if not isinstance(current_item, (dict, tomlkit.items.Table, tomlkit.container.Container)):
print(f"警告: 路径部分 '{part}' 指向的不是表结构,无法继续导航")
return ""
return "" # 理论上不应执行到这里,除非 key_path 为空
except Exception as e:
# 打印更详细的错误信息,包括路径和异常类型
print(f"获取注释时发生意外错误 (路径: {key_path}): {type(e).__name__} - {e}")
# print(traceback.format_exc()) # 可选:打印完整堆栈跟踪
return ""
class TomlFormGenerator:
"""用于将TOML配置生成Flet表单控件的类。"""
def __init__(
self,
page: ft.Page,
config_data: Dict[str, Any],
parent_container: ft.Column,
template_filename: str = "bot_config_template.toml",
):
"""
初始化表单生成器。
Args:
page: Flet Page 对象 (用于强制刷新)
config_data: TOML配置数据嵌套字典
parent_container: 要添加控件的父容器
template_filename: 要使用的模板文件名 (相对于 template/ 目录)
"""
self.page = page # <-- 保存 Page 对象
self.config_data = config_data # 保存对原始数据的引用(重要!)
self.parent_container = parent_container
self.controls_map = {} # 映射 full_path 到 Flet 控件
self.expanded_sections = set() # 记录展开的部分
# 加载指定的模板文档
self.template_doc = load_template_with_comments(template_filename)
if not self.template_doc.value:
print(f"警告:加载的模板 '{template_filename}' 为空,注释功能将不可用。")
def build_form(self):
"""构建整个表单。"""
self.parent_container.controls.clear()
self.controls_map.clear() # 清空控件映射
# 使用 self.config_data 构建表单
self._process_toml_section(self.config_data, self.parent_container)
def _get_comment(self, key_path: str) -> str:
"""获取指定键路径的注释,并确保结果是字符串"""
try:
comment = get_comment_for_key(self.template_doc, key_path)
# 确保返回值是字符串
if comment and isinstance(comment, str):
return comment
except Exception as e:
print(f"获取注释出错: {key_path}, {e}")
return "" # 如果出现任何问题,返回空字符串
def _process_toml_section(
self,
section_data: Dict[str, Any],
container: Union[ft.Column, ft.Container],
section_path: str = "",
indent: int = 0,
):
"""
递归处理TOML配置的一个部分。
Args:
section_data: 要处理的配置部分
container: 放置控件的容器可以是Column或Container
section_path: 当前部分的路径(用于跟踪嵌套层级)
indent: 当前缩进级别
"""
# 确保container是有controls属性的对象
if isinstance(container, ft.Container):
if container.content and hasattr(container.content, "controls"):
container = container.content
else:
# 如果Container没有有效的content创建一个Column
container.content = ft.Column([])
container = container.content
if not hasattr(container, "controls"):
raise ValueError(f"传递给_process_toml_section的容器必须有controls属性got: {type(container)}")
# 先处理所有子部分(嵌套表)
subsections = {}
simple_items = {}
# 分离子部分和简单值
for key, value in section_data.items():
if isinstance(value, (dict, tomlkit.items.Table)):
subsections[key] = value
else:
simple_items[key] = value
# 处理简单值
for key, value in simple_items.items():
full_path = f"{section_path}.{key}" if section_path else key
control = self._create_control_for_value(key, value, full_path)
if control:
if indent > 0: # 添加缩进
row = ft.Row(
[
ft.Container(width=indent * 20), # 每级缩进20像素
control,
],
alignment=ft.MainAxisAlignment.START,
)
container.controls.append(row)
else:
container.controls.append(control)
# 处理子部分
for key, value in subsections.items():
full_path = f"{section_path}.{key}" if section_path else key
# 创建一个可展开/折叠的部分
is_expanded = full_path in self.expanded_sections
# 获取此部分的注释(安全获取)
section_comment = self._get_comment(full_path)
# 创建子部分的标题行
section_title_elems = [
ft.Container(width=indent * 20) if indent > 0 else ft.Container(width=0),
ft.IconButton(
icon=ft.icons.ARROW_DROP_DOWN if is_expanded else ft.icons.ARROW_RIGHT,
on_click=lambda e, path=full_path: self._toggle_section(e, path),
),
ft.Text(key, weight=ft.FontWeight.BOLD, size=16),
]
# 如果有注释添加一个Info图标并设置tooltip
if section_comment and len(section_comment) > 0:
try:
section_title_elems.append(
ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=section_comment, icon_size=16)
)
except Exception as e:
print(f"创建信息图标时出错: {full_path}, {e}")
section_title = ft.Row(
section_title_elems,
alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
container.controls.append(section_title)
# 创建子部分的容器
subsection_column = ft.Column([])
subsection_container = ft.Container(content=subsection_column, visible=is_expanded)
container.controls.append(subsection_container)
# 递归处理子部分
if is_expanded:
self._process_toml_section(value, subsection_column, full_path, indent + 1)
def _toggle_section(self, e, section_path):
"""切换部分的展开/折叠状态。"""
# 使用一个简化和更稳定的方法来处理toggle
print(f"切换部分: {section_path}")
# 在点击的行的下一个容器中查找
parent_row = e.control.parent
if not parent_row or not isinstance(parent_row, ft.Row):
print(f"错误: 无法找到父行: {e.control.parent}")
return
parent_container = parent_row.parent
if not parent_container or not hasattr(parent_container, "controls"):
print(f"错误: 无法找到父容器: {parent_row.parent}")
return
# 找到当前行在父容器中的索引
try:
row_index = parent_container.controls.index(parent_row)
except ValueError:
print(f"错误: 在父容器中找不到行: {parent_row}")
return
# 检查下一个控件是否是子部分容器
if row_index + 1 >= len(parent_container.controls):
print(f"错误: 行索引超出范围: {row_index + 1} >= {len(parent_container.controls)}")
return
subsection_container = parent_container.controls[row_index + 1]
print(f"找到子部分容器: {type(subsection_container).__name__}")
# 切换展开/折叠状态
if section_path in self.expanded_sections:
# 折叠
e.control.icon = ft.icons.ARROW_RIGHT
self.expanded_sections.remove(section_path)
subsection_container.visible = False
# parent_container.update() # <-- 改为 page.update()
else:
# 展开
e.control.icon = ft.icons.ARROW_DROP_DOWN
self.expanded_sections.add(section_path)
subsection_container.visible = True
# 如果容器刚刚变为可见,且内容为空,则加载内容
if subsection_container.visible:
# 获取子部分的内容列
subsection_content = None
if isinstance(subsection_container, ft.Container) and subsection_container.content:
subsection_content = subsection_container.content
else:
subsection_content = subsection_container
# 如果内容是Column且为空则加载内容
if isinstance(subsection_content, ft.Column) and len(subsection_content.controls) == 0:
# 获取配置数据
parts = section_path.split(".")
current = self.config_data
for part in parts:
if part and part in current:
current = current[part]
else:
print(f"警告: 配置路径不存在: {part} in {section_path}")
# parent_container.update() # <-- 改为 page.update()
self.page.update() # <-- 在这里也强制页面更新
return
# 递归处理子部分
if isinstance(current, (dict, tomlkit.items.Table)):
indent = len(parts) # 使用路径部分数量作为缩进级别
try:
# 处理内容但不立即更新UI
self._process_toml_section(current, subsection_content, section_path, indent)
# 只在完成内容处理后更新一次UI
# parent_container.update() # <-- 改为 page.update()
except Exception as ex:
print(f"处理子部分时出错: {ex}")
else:
print(f"警告: 配置数据不是字典类型: {type(current).__name__}")
# parent_container.update() # <-- 改为 page.update()
# else:
# 如果只是切换可见性,简单更新父容器
# parent_container.update() # <-- 改为 page.update()
# 强制更新整个页面
if self.page:
try:
self.page.update() # <-- 在函数末尾强制页面更新
except Exception as page_update_e:
print(f"强制页面更新失败: {page_update_e}")
else:
print("警告: _toggle_section 中无法访问 Page 对象进行更新")
def _create_control_for_value(self, key: str, value: Any, full_path: str) -> Optional[ft.Control]:
"""
根据值的类型创建适当的控件。
Args:
key: 配置键
value: 配置值
full_path: 配置项的完整路径
Returns:
对应类型的Flet控件
"""
# 获取注释(安全获取)
comment = self._get_comment(full_path)
comment_valid = isinstance(comment, str) and len(comment) > 0
# 根据类型创建不同的控件
if isinstance(value, bool):
return self._create_boolean_control(key, value, full_path, comment if comment_valid else "")
elif isinstance(value, (int, float)):
return self._create_number_control(key, value, full_path, comment if comment_valid else "")
elif isinstance(value, str):
return self._create_string_control(key, value, full_path, comment if comment_valid else "")
elif isinstance(value, list):
return self._create_list_control(key, value, full_path, comment if comment_valid else "")
elif isinstance(value, set):
# 特殊处理集合类型groups部分经常使用
print(f"处理集合类型: {key} = {value}")
try:
return self._create_set_control(key, value, full_path, comment if comment_valid else "")
except Exception as e:
print(f"创建集合控件时出错: {e}")
# 如果创建失败,返回只读文本
return ft.Text(f"{key}: {value} (集合类型,处理失败)", italic=True)
else:
# 其他类型默认显示为只读文本
control = ft.Text(f"{key}: {value} (类型不支持编辑: {type(value).__name__})", italic=True)
# 如果有有效的注释,添加图标
if comment_valid:
try:
# 在只读文本旁加上注释图标
return ft.Row([control, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
except Exception:
pass # 如果添加图标失败,仍返回原始控件
return control
def _update_config_value(self, path: str, new_value: Any):
"""递归地更新 self.config_data 中嵌套字典的值。"""
keys = path.split(".")
d = self.config_data
try:
for key in keys[:-1]:
d = d[key]
# 确保最后一个键存在并且可以赋值
if keys[-1] in d:
# 类型转换 (尝试)
original_value = d[keys[-1]]
try:
if isinstance(original_value, bool):
new_value = str(new_value).lower() in ("true", "1", "yes")
elif isinstance(original_value, int):
new_value = int(new_value)
elif isinstance(original_value, float):
new_value = float(new_value)
# Add other type checks if needed (e.g., list, set)
except (ValueError, TypeError) as e:
print(
f"类型转换错误 ({path}): 输入 '{new_value}' ({type(new_value)}), 期望类型 {type(original_value)}. 错误: {e}"
)
# 保留原始类型或回退?暂时保留新值,让用户修正
# new_value = original_value # 或者可以选择回退
pass # Keep new_value as is for now
d[keys[-1]] = new_value
print(f"配置已更新: {path} = {new_value}")
else:
print(f"警告: 尝试更新不存在的键: {path}")
except KeyError:
print(f"错误: 更新配置时找不到路径: {path}")
except TypeError:
print(f"错误: 尝试在非字典对象中更新键: {path}")
except Exception as e:
print(f"更新配置时发生未知错误 ({path}): {e}")
# 注意:这里不需要调用 page.update(),因为这是内部数据更新
# 调用保存按钮时,会使用更新后的 self.config_data
def _create_boolean_control(self, key: str, value: bool, path: str, comment: str = "") -> ft.Control:
"""创建布尔值的开关控件。"""
def on_change(e):
self._update_config_value(path, e.control.value)
switch = ft.Switch(label=key, value=value, on_change=on_change)
# 如果有注释添加一个Info图标
if comment and len(comment) > 0:
try:
return ft.Row([switch, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
except Exception as e:
print(f"创建布尔控件的注释图标时出错: {path}, {e}")
return switch
def _create_number_control(self, key: str, value: Union[int, float], path: str, comment: str = "") -> ft.Control:
"""创建数字输入控件。"""
def on_change(e):
try:
# 尝试转换为原始类型
if isinstance(value, int):
converted = int(e.control.value)
else:
converted = float(e.control.value)
self._update_config_value(path, converted)
except (ValueError, TypeError):
pass # 忽略无效输入
text_field = ft.TextField(
label=key,
value=str(value),
input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9.-]"),
on_change=on_change,
)
# 如果有注释,添加一个信息图标
if comment and len(comment) > 0:
try:
return ft.Row([text_field, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
except Exception as e:
print(f"创建数字控件的注释图标时出错: {path}, {e}")
return text_field
def _create_string_control(self, key: str, value: str, path: str, comment: str = "") -> ft.Control:
"""创建字符串输入控件。"""
def on_change(e):
self._update_config_value(path, e.control.value)
# 若字符串较长,使用多行文本
multiline = len(value) > 30 or "\n" in value
text_field = ft.TextField(
label=key,
value=value,
multiline=multiline,
min_lines=1,
max_lines=5 if multiline else 1,
on_change=on_change,
)
# 如果有注释添加一个Info图标
if comment and len(comment) > 0:
try:
return ft.Row([text_field, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
except Exception as e:
print(f"创建字符串控件的注释图标时出错: {path}, {e}")
return text_field
def _create_list_control(self, key: str, value: List[Any], path: str, comment: str = "") -> ft.Control:
"""创建列表控件。"""
# 创建一个可编辑的列表控件
# 首先创建一个Column存放列表项目和控制按钮
title_row = ft.Row(
[
ft.Text(f"{key}:", weight=ft.FontWeight.BOLD),
]
)
# 如果有注释添加一个Info图标
if comment and len(comment) > 0:
try:
title_row.controls.append(ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16))
except Exception as e:
print(f"创建列表控件的注释图标时出错: {path}, {e}")
column = ft.Column([title_row])
# 创建一个内部Column用于存放列表项
items_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.AUTO)
# 创建添加新项目的函数
def add_item(e=None, default_value=None, is_initial=False):
# 确定新项目的类型(基于现有项目或默认为字符串)
item_type = str
if value and len(value) > 0:
if isinstance(value[0], int):
item_type = int
elif isinstance(value[0], float):
item_type = float
elif isinstance(value[0], bool):
item_type = bool
# 创建新项目的默认值
if default_value is None:
if item_type is int:
default_value = 0
elif item_type is float:
default_value = 0.0
elif item_type is bool:
default_value = False
else:
default_value = ""
# 创建当前索引
index = len(items_column.controls)
# 创建删除项目的函数
def delete_item(e):
# 删除此项目
items_column.controls.remove(item_row)
# 更新列表中的值
update_list_value()
# 确保UI更新
items_column.update()
# 更新整个表单
column.update()
# 创建项目控件(根据类型)
if item_type is bool:
item_control = ft.Switch(value=default_value)
elif item_type in (int, float):
item_control = ft.TextField(
value=str(default_value),
input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9.-]"),
width=200,
)
else: # 字符串
item_control = ft.TextField(value=default_value, width=200)
# 添加控件的更改事件
def on_item_change(e):
# 获取新值
new_val = e.control.value
# 转换类型
if item_type is int:
try:
new_val = int(new_val)
except ValueError:
new_val = 0
elif item_type is float:
try:
new_val = float(new_val)
except ValueError:
new_val = 0.0
elif item_type is bool:
new_val = bool(new_val)
# 更新列表中的值
update_list_value()
# 添加更改事件
if item_type is bool:
item_control.on_change = on_item_change
else:
item_control.on_change = on_item_change
# 创建行包含项目控件和删除按钮
item_row = ft.Row(
[ft.Text(f"[{index}]"), item_control, ft.IconButton(icon=ft.icons.DELETE, on_click=delete_item)],
alignment=ft.MainAxisAlignment.START,
)
# 将行添加到列表中
items_column.controls.append(item_row)
# 只有在用户交互时更新UI初始加载时不更新
if not is_initial and e is not None:
# 更新UI - 确保整个控件都更新
try:
items_column.update()
column.update()
except Exception as update_e:
print(f"更新列表控件时出错: {path}, {update_e}")
return item_control
# 创建更新列表值的函数
def update_list_value():
new_list = []
for item_row in items_column.controls:
if len(item_row.controls) < 2:
continue # 跳过格式不正确的行
item_control = item_row.controls[1] # 获取TextField或Switch
# 根据控件类型获取值
if isinstance(item_control, ft.Switch):
new_list.append(item_control.value)
elif isinstance(item_control, ft.TextField):
# 根据原始列表中的类型转换值
if value and len(value) > 0:
if isinstance(value[0], int):
try:
new_list.append(int(item_control.value))
except ValueError:
new_list.append(0)
elif isinstance(value[0], float):
try:
new_list.append(float(item_control.value))
except ValueError:
new_list.append(0.0)
else:
new_list.append(item_control.value)
else:
new_list.append(item_control.value)
# 更新TOML配置
try:
self._update_config_value(path, new_list)
except Exception as e:
print(f"更新列表值时出错: {path}, {e}")
# 添加现有项目使用is_initial=True标记为初始化
for item in value:
add_item(default_value=item, is_initial=True)
# 添加按钮行
button_row = ft.Row(
[ft.ElevatedButton("添加项目", icon=ft.icons.ADD, on_click=add_item)], alignment=ft.MainAxisAlignment.START
)
# 将组件添加到主Column
column.controls.append(items_column)
column.controls.append(button_row)
# 将整个列表控件包装在一个Card中让它看起来更独立
# Card不支持padding参数使用Container包裹
return ft.Card(content=ft.Container(content=column, padding=10))
def _create_set_control(self, key: str, value: set, path: str, comment: str = "") -> ft.Control:
"""创建集合控件。"""
# 创建一个可编辑的列表控件
# 首先创建一个Column存放列表项目和控制按钮
title_row = ft.Row(
[
ft.Text(f"{key} (集合):", weight=ft.FontWeight.BOLD),
]
)
# 如果有注释添加一个Info图标
if comment and len(comment) > 0:
try:
title_row.controls.append(ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16))
except Exception as e:
print(f"创建集合控件的注释图标时出错: {path}, {e}")
column = ft.Column([title_row])
# 创建一个内部Column用于存放集合项
items_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.AUTO)
# 创建一个用于输入的文本框
new_item_field = ft.TextField(label="添加新项目", hint_text="输入值后按Enter添加", width=300)
# 创建一个列表存储当前集合值
current_values = list(value)
# 创建添加新项目的函数
def add_item(e=None, item_value=None, is_initial=False):
if e and hasattr(e, "control") and e.control == new_item_field:
# 从文本框获取值
item_value = new_item_field.value.strip()
if not item_value:
return
new_item_field.value = "" # 清空输入框
if not is_initial: # 只有在用户交互时更新
try:
new_item_field.update()
except Exception as update_e:
print(f"更新文本框时出错: {path}, {update_e}")
if item_value is None or item_value == "":
return
# 判断值的类型(假设集合中所有元素类型一致)
item_type = str
if current_values and len(current_values) > 0:
if isinstance(current_values[0], int):
item_type = int
elif isinstance(current_values[0], float):
item_type = float
elif isinstance(current_values[0], bool):
item_type = bool
# 转换类型
if item_type is int:
try:
item_value = int(item_value)
except ValueError:
return # 如果无法转换则忽略
elif item_type is float:
try:
item_value = float(item_value)
except ValueError:
return # 如果无法转换则忽略
elif item_type is bool:
if item_value.lower() in ("true", "yes", "1", "y"):
item_value = True
elif item_value.lower() in ("false", "no", "0", "n"):
item_value = False
else:
return # 无效的布尔值
# 检查是否已存在(集合特性)
if item_value in current_values:
return # 如果已存在则忽略
# 添加到当前值列表
current_values.append(item_value)
# 创建删除项目的函数
def delete_item(e):
# 删除此项目
current_values.remove(item_value)
items_column.controls.remove(item_row)
# 更新集合中的值
update_set_value()
# 确保UI更新
try:
items_column.update()
column.update() # 更新整个表单
except Exception as update_e:
print(f"更新集合UI时出错: {path}, {update_e}")
# 创建行包含项目文本和删除按钮
item_row = ft.Row(
[ft.Text(str(item_value)), ft.IconButton(icon=ft.icons.DELETE, on_click=delete_item)],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
)
# 将行添加到列表中
items_column.controls.append(item_row)
# 只有在用户交互时更新UI初始加载时不更新
if not is_initial and e is not None:
# 更新UI
try:
items_column.update()
column.update() # 确保整个表单都更新
except Exception as update_e:
print(f"更新集合UI时出错: {path}, {update_e}")
# 更新集合值
update_set_value()
# 创建更新集合值的函数
def update_set_value():
# 从current_values创建一个新集合
try:
new_set = set(current_values)
# 更新TOML配置
self._update_config_value(path, new_set)
except Exception as e:
print(f"更新集合值时出错: {path}, {e}")
# 添加键盘事件处理
def on_key_press(e):
if e.key == "Enter":
add_item(e)
new_item_field.on_submit = add_item
# 添加现有项目使用is_initial=True标记为初始化
for item in value:
add_item(item_value=item, is_initial=True)
# 添加输入框
input_row = ft.Row(
[
new_item_field,
ft.IconButton(
icon=ft.icons.ADD, on_click=lambda e: add_item(e, item_value=new_item_field.value.strip())
),
],
alignment=ft.MainAxisAlignment.START,
)
# 将组件添加到主Column
column.controls.append(items_column)
column.controls.append(input_row)
# 将整个集合控件包装在一个Card中让它看起来更独立
# Card不支持padding参数使用Container包裹
return ft.Card(content=ft.Container(content=column, padding=10))
def load_bot_config_template(app_state) -> Dict[str, Any]:
"""
加载bot_config_template.toml文件作为参考。
Returns:
带有注释的TOML文档
"""
template_path = Path(app_state.script_dir) / "template/bot_config_template.toml"
if template_path.exists():
try:
with open(template_path, "r", encoding="utf-8") as f:
return tomlkit.parse(f.read()) # 使用parse而不是load以保留注释
except Exception as e:
print(f"加载模板配置文件失败: {e}")
return tomlkit.document()
def get_bot_config_path(app_state) -> Path:
"""
获取配置文件路径
"""
config_path = Path(app_state.script_dir) / "config/bot_config.toml"
return config_path
def load_bot_config(app_state) -> Dict[str, Any]:
"""
加载bot_config.toml文件
如果文件不存在,会尝试从模板创建
"""
config_path = get_bot_config_path(app_state)
# 如果配置文件不存在,尝试从模板创建
if not config_path.exists():
template_config = load_bot_config_template(app_state)
if template_config:
print(f"配置文件不存在,尝试从模板创建: {config_path}")
try:
# 确保目录存在
config_path.parent.mkdir(parents=True, exist_ok=True)
# 保存模板内容到配置文件
with open(config_path, "w", encoding="utf-8") as f:
tomlkit.dump(template_config, f)
print(f"成功从模板创建配置文件: {config_path}")
return template_config
except Exception as e:
print(f"从模板创建配置文件失败: {e}")
return {}
return {}
# 加载配置文件
try:
with open(config_path, "r", encoding="utf-8") as f:
return tomlkit.load(f)
except Exception as e:
print(f"加载配置文件失败: {e}")
return {}
def create_toml_form(
page: ft.Page,
config_data: Dict[str, Any],
container: ft.Column,
template_filename: str = "bot_config_template.toml",
):
"""
创建并构建TOML表单。
Args:
page: Flet Page 对象
config_data: TOML配置数据
container: 放置表单的父容器
template_filename: 要使用的模板文件名
Returns:
创建的 TomlFormGenerator 实例
"""
generator = TomlFormGenerator(page, config_data, container, template_filename)
generator.build_form()
return generator # Return the generator instance

265
src/MaiGoi/ui_env_editor.py Normal file
View File

@@ -0,0 +1,265 @@
import flet as ft
from pathlib import Path
from typing import List, Tuple
# --- .env File Handling Logic ---
def load_env_data(env_path: Path) -> List[Tuple[str, str]]:
"""Loads key-value pairs from a .env file, skipping comments and empty lines."""
variables = []
if not env_path.exists():
print(f"[Env Editor] .env file not found at {env_path}")
return variables
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
# Basic handling for quotes (remove if present at ends)
if len(value) >= 2 and value.startswith(("'", '"')) and value.endswith(("'", '"')):
value = value[1:-1]
variables.append((key, value))
# else: Handle lines without '='? Maybe ignore them.
except Exception as e:
print(f"[Env Editor] Error loading .env file {env_path}: {e}")
return variables
def save_env_data(env_path: Path, variables: List[Tuple[str, str]]):
"""Saves key-value pairs back to the .env file, overwriting existing content."""
try:
with open(env_path, "w", encoding="utf-8") as f:
for key, value in variables:
# Basic quoting if value contains spaces or special chars?
# For simplicity, just write key=value for now.
# Advanced quoting logic can be added if needed.
f.write(f"{key}={value}\n")
print(f"[Env Editor] Successfully saved data to {env_path}")
except Exception as e:
print(f"[Env Editor] Error saving .env file {env_path}: {e}")
# Optionally raise or show error to user
# --- Flet UI Component ---
# Inherit directly from ft.Column instead of ft.UserControl
class EnvEditor(ft.Column):
"""A Flet Column containing controls for editing .env file variables."""
def __init__(self, app_state):
# Initialize the Column base class
# Pass Column properties like spacing, scroll, expand here
super().__init__(spacing=5, scroll=ft.ScrollMode.ADAPTIVE, expand=True)
self.app_state = app_state
self.env_path = Path(self.app_state.script_dir) / ".env"
self.variables = load_env_data(self.env_path)
# UI Controls - Define them as instance attributes
self.variable_rows_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.ADAPTIVE)
self.add_key_field = ft.TextField(label="New Key", width=150)
self.add_value_field = ft.TextField(label="New Value", expand=True)
self.save_button = ft.ElevatedButton("Save Changes", icon=ft.icons.SAVE, on_click=self._save_changes)
self.status_text = ft.Text("") # For showing save status/errors
# --- Build the UI directly within __init__ ---
self._populate_rows() # Populate rows initially
add_row = ft.Row(
[
self.add_key_field,
self.add_value_field,
ft.IconButton(
icon=ft.icons.ADD_CIRCLE_OUTLINE,
tooltip="Add Variable",
on_click=self._add_variable_row_interactive,
),
],
alignment=ft.MainAxisAlignment.START,
)
# Add controls directly to self (the Column)
self.controls.extend(
[
ft.Text(".env File Editor", style=ft.TextThemeStyle.HEADLINE_SMALL),
ft.Text(f"Editing: {self.env_path.name} (in {self.env_path.parent})"),
ft.Divider(),
self.variable_rows_column, # Add the column that holds the variable rows
ft.Divider(),
ft.Text("Add New Variable:", style=ft.TextThemeStyle.LABEL_LARGE),
add_row,
ft.Divider(),
ft.Row([self.save_button, self.status_text], alignment=ft.MainAxisAlignment.START),
]
)
# No need to return anything from __init__
def _populate_rows(self):
"""Clears and refills the variable rows column based on self.variables."""
self.variable_rows_column.controls.clear()
for index, (key, value) in enumerate(self.variables):
self.variable_rows_column.controls.append(self._create_variable_row(index, key, value))
# No need to update here, usually called during init or after add/delete
def _create_variable_row(self, index: int, key: str, value: str) -> ft.Row:
"""Creates a Row control for a single key-value pair."""
key_field = ft.TextField(value=key, expand=2, data=index)
value_field = ft.TextField(value=value, expand=5, data=index)
# Update self.variables when text fields change (optional, safer to update only on save)
# key_field.on_change = self._update_variable_from_ui
# value_field.on_change = self._update_variable_from_ui
return ft.Row(
[
key_field,
value_field,
ft.IconButton(
icon=ft.icons.DELETE_OUTLINE,
tooltip="Delete Variable",
data=index, # Store index to know which one to delete
on_click=self._delete_variable_row,
),
],
alignment=ft.MainAxisAlignment.START,
key=str(index), # Assign a key for potential targeted updates
)
def _add_variable_row_interactive(self, e):
"""Adds a variable row based on the 'Add New' fields and updates the UI."""
new_key = self.add_key_field.value.strip()
new_value = self.add_value_field.value.strip()
if not new_key:
# Access page via self.page if the control is mounted
if self.page:
self.page.show_snack_bar(ft.SnackBar(ft.Text("Key cannot be empty."), open=True))
return
# Check if key already exists? For now, allow duplicates, save will handle last one.
# Add to internal list
self.variables.append((new_key, new_value))
# Add UI row
new_index = len(self.variables) - 1
self.variable_rows_column.controls.append(self._create_variable_row(new_index, new_key, new_value))
# Clear add fields
self.add_key_field.value = ""
self.add_value_field.value = ""
self.update() # Update this Column
# If page exists, update page too (might be redundant if Column update cascades)
# if self.page: self.page.update()
def _delete_variable_row(self, e):
"""Deletes a variable row from the UI and the internal list."""
index_to_delete = e.control.data
if 0 <= index_to_delete < len(self.variables):
# Find the row control to remove
row_to_remove = None
for control in self.variable_rows_column.controls:
# Check the data attribute of the delete button inside the row
if (
isinstance(control, ft.Row)
and len(control.controls) > 2
and isinstance(control.controls[2], ft.IconButton)
and control.controls[2].data == index_to_delete
):
row_to_remove = control
break
# Remove from internal list *first*
if index_to_delete < len(self.variables): # Double check index after finding row
del self.variables[index_to_delete]
else:
print(f"[Env Editor] Error: Index {index_to_delete} out of bounds after finding row.")
return
# Remove from UI column if found
if row_to_remove:
self.variable_rows_column.controls.remove(row_to_remove)
# Need to re-index remaining rows' data attributes
self._reindex_rows()
self.update() # Update this Column
# if self.page: self.page.update()
else:
print(f"[Env Editor] Error: Invalid index to delete: {index_to_delete}")
def _reindex_rows(self):
"""Updates the data attribute (index) of controls in each row after deletion."""
for i, row in enumerate(self.variable_rows_column.controls):
if isinstance(row, ft.Row) and len(row.controls) > 2:
# Update index on key field, value field, and delete button
if isinstance(row.controls[0], ft.TextField):
row.controls[0].data = i
if isinstance(row.controls[1], ft.TextField):
row.controls[1].data = i
if isinstance(row.controls[2], ft.IconButton):
row.controls[2].data = i
def _save_changes(self, e):
"""Collects data from UI rows and saves to the .env file."""
updated_variables = []
has_error = False
keys = set()
for row_index, row in enumerate(self.variable_rows_column.controls):
if isinstance(row, ft.Row) and len(row.controls) >= 2:
key_field = row.controls[0]
value_field = row.controls[1]
if isinstance(key_field, ft.TextField) and isinstance(value_field, ft.TextField):
key = key_field.value.strip()
value = value_field.value # Keep original spacing/quotes for value for now
if not key:
has_error = True
# Use row_index which reflects the current visual order
self.status_text.value = f"Error: Row {row_index + 1} has an empty key."
self.status_text.color = ft.colors.RED
break # Stop processing on first error
if key in keys:
print(f"[Env Editor] Warning: Duplicate key '{key}' found. Last occurrence will be saved.")
# Or show error? Let's allow for now, last wins on save.
keys.add(key)
updated_variables.append((key, value))
else:
has_error = True
self.status_text.value = "Error: Invalid row structure found."
self.status_text.color = ft.colors.RED
break
else: # Handle cases where row might not be what's expected
print(f"[Env Editor] Warning: Skipping unexpected control type in variable column: {type(row)}")
if not has_error:
try:
save_env_data(self.env_path, updated_variables)
self.variables = updated_variables # Update internal state
self.status_text.value = "Changes saved successfully!"
self.status_text.color = ft.colors.GREEN
except Exception as ex:
self.status_text.value = f"Error saving file: {ex}"
self.status_text.color = ft.colors.RED
self.status_text.update()
# --- Function to create the main view containing the editor ---
# This can be called from ui_settings_view.py
def create_env_editor_page_content(page: ft.Page, app_state) -> ft.Control:
"""Creates the EnvEditor control."""
# EnvEditor is now the Column itself
editor = EnvEditor(app_state)
return editor

View File

@@ -0,0 +1,348 @@
import flet as ft
import tomlkit
from .state import AppState
from .utils import show_snackbar # Assuming show_snackbar is in utils
from .toml_form_generator import create_toml_form, load_bot_config, get_bot_config_path
from .config_manager import load_config, save_config
from .ui_env_editor import create_env_editor_page_content
def save_bot_config(page: ft.Page, app_state: AppState, new_config_data: dict):
"""将修改后的 Bot 配置保存回文件。"""
config_path = get_bot_config_path(app_state)
try:
with open(config_path, "w", encoding="utf-8") as f:
# Use tomlkit.dumps to preserve formatting/comments as much as possible
# It might need refinement based on how UI controls update the dict
tomlkit.dump(new_config_data, f)
show_snackbar(page, "Bot 配置已保存!")
# Optionally reload config into app_state if needed immediately elsewhere
# app_state.bot_config = new_config_data # Or reload using a dedicated function
except Exception as e:
print(f"Error saving bot config: {e}")
show_snackbar(page, f"保存 Bot 配置失败: {e}", error=True)
def save_bot_config_changes(page: ft.Page, config_to_save: dict):
"""Handles saving changes for bot_config.toml"""
print("[Settings] Saving Bot Config (TOML) changes...")
# Assuming save_config needs path, let's build it or adapt save_config
# For now, let's assume save_config can handle type='bot'
# config_path = get_bot_config_path(app_state) # Need app_state if using this
success = save_config(config_to_save, config_type="bot")
if success:
message = "Bot 配置已保存!"
else:
message = "保存 Bot 配置失败。"
show_snackbar(page, message, error=(not success))
def save_lpmm_config_changes(page: ft.Page, config_to_save: dict):
"""Handles saving changes for lpmm_config.toml"""
print("[Settings] Saving LPMM Config (TOML) changes...")
success = save_config(config_to_save, config_type="lpmm") # Use type 'lpmm'
if success:
message = "LPMM 配置已保存!"
else:
message = "保存 LPMM 配置失败。"
show_snackbar(page, message, error=(not success))
def save_gui_config_changes(page: ft.Page, app_state: AppState):
"""Handles saving changes for gui_config.toml (currently just theme)"""
print("[Settings] Saving GUI Config changes...")
# gui_config is directly in app_state, no need to pass config_to_save
success = save_config(app_state.gui_config, config_type="gui")
if success:
message = "GUI 配置已保存!"
else:
message = "保存 GUI 配置失败。"
show_snackbar(page, message, error=(not success))
def create_settings_view(page: ft.Page, app_state: AppState) -> ft.View:
"""Creates the settings view with sections for different config files."""
# --- State for switching between editors ---
content_area = ft.Column([], expand=True, scroll=ft.ScrollMode.ADAPTIVE)
current_config_data = {} # Store loaded data for saving
# --- Function to load Bot config editor (Original TOML editor) ---
def show_bot_config_editor(e=None):
nonlocal current_config_data
print("[Settings] Loading Bot Config Editor")
try:
current_bot_config = load_bot_config(app_state)
if not current_bot_config:
raise ValueError("Bot config could not be loaded.")
current_config_data = current_bot_config
content_area.controls.clear()
# Pass the correct template filename string
form_generator = create_toml_form(
page, current_bot_config, content_area, template_filename="bot_config_template.toml"
)
save_button = ft.ElevatedButton(
"保存 Bot 配置更改",
icon=ft.icons.SAVE,
on_click=lambda _: save_bot_config_changes(
page, form_generator.config_data if hasattr(form_generator, "config_data") else current_config_data
),
)
content_area.controls.append(ft.Divider())
content_area.controls.append(save_button)
except Exception as ex:
content_area.controls.clear()
content_area.controls.append(ft.Text(f"加载 Bot 配置时出错: {ex}", color=ft.colors.ERROR))
if page:
page.update()
# --- Function to load LPMM config editor ---
def show_lpmm_editor(e=None):
nonlocal current_config_data
print("[Settings] Loading LPMM Config Editor")
try:
lpmm_config = load_config(config_type="lpmm")
if not lpmm_config:
raise ValueError("LPMM config could not be loaded.")
current_config_data = lpmm_config
content_area.controls.clear()
# Pass the correct template filename string
form_generator = create_toml_form(
page, lpmm_config, content_area, template_filename="lpmm_config_template.toml"
)
save_button = ft.ElevatedButton(
"保存 LPMM 配置更改",
icon=ft.icons.SAVE,
on_click=lambda _: save_lpmm_config_changes(
page, form_generator.config_data if hasattr(form_generator, "config_data") else current_config_data
),
)
content_area.controls.append(ft.Divider())
content_area.controls.append(save_button)
except Exception as ex:
content_area.controls.clear()
content_area.controls.append(ft.Text(f"加载 LPMM 配置时出错: {ex}", color=ft.colors.ERROR))
if page:
page.update()
# --- Function to load GUI settings editor ---
def show_gui_settings(e=None):
# GUI config is simpler, might not need full form generator
# We'll load it directly from app_state and save app_state.gui_config
print("[Settings] Loading GUI Settings Editor")
content_area.controls.clear()
def change_theme(ev):
selected_theme = ev.control.value.upper()
page.theme_mode = ft.ThemeMode[selected_theme]
app_state.gui_config["theme"] = selected_theme
print(f"Theme changed to: {page.theme_mode}, updating app_state.gui_config")
page.update() # Update theme immediately
# Get current theme from app_state or page
current_theme_val = app_state.gui_config.get("theme", str(page.theme_mode).split(".")[-1]).capitalize()
if current_theme_val not in ["System", "Light", "Dark"]:
current_theme_val = "System" # Default fallback
theme_dropdown = ft.Dropdown(
label="界面主题",
value=current_theme_val,
options=[
ft.dropdown.Option("System"),
ft.dropdown.Option("Light"),
ft.dropdown.Option("Dark"),
],
on_change=change_theme,
# expand=True, # Maybe not expand in this layout
)
save_button = ft.ElevatedButton(
"保存 GUI 设置", icon=ft.icons.SAVE, on_click=lambda _: save_gui_config_changes(page, app_state)
)
content_area.controls.extend(
[
ft.Text("界面设置:", weight=ft.FontWeight.BOLD),
ft.Row([theme_dropdown]),
# Add more GUI controls here if needed in the future
ft.Divider(),
save_button,
]
)
if page:
page.update()
# --- Function to load .env editor ---
def show_env_editor(e=None):
# No config data to manage here, it handles its own save
print("[Settings] Loading .env Editor")
content_area.controls.clear()
env_editor_content = create_env_editor_page_content(page, app_state)
content_area.controls.append(env_editor_content)
if page:
page.update()
# --- Initial View Setup ---
# Load the Bot config editor by default
show_bot_config_editor()
return ft.View(
"/settings",
[
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
ft.Row(
[
ft.ElevatedButton("Bot 配置", icon=ft.icons.SETTINGS_SUGGEST, on_click=show_bot_config_editor),
ft.ElevatedButton("LPMM 配置", icon=ft.icons.MEMORY, on_click=show_lpmm_editor),
ft.ElevatedButton("GUI 设置", icon=ft.icons.BRUSH, on_click=show_gui_settings),
ft.ElevatedButton(".env 配置", icon=ft.icons.EDIT, on_click=show_env_editor),
],
alignment=ft.MainAxisAlignment.CENTER,
wrap=True, # Allow buttons to wrap on smaller widths
),
ft.Divider(),
content_area, # This holds the currently selected editor
],
scroll=ft.ScrollMode.ADAPTIVE,
)
# Note: Assumes save_config function exists and can handle saving
# the bot_config dictionary back to its TOML file. You might need to
# adjust the save_bot_config_changes function based on how saving is implemented.
# Also assumes load_bot_config loads the data correctly for the TOML editor.
def create_settings_view_old(page: ft.Page, app_state: AppState) -> ft.View:
"""创建设置页面视图。"""
# --- GUI Settings ---
def change_theme(e):
selected_theme = e.control.value.upper()
page.theme_mode = ft.ThemeMode[selected_theme]
# Persist theme choice? Maybe in gui_config?
app_state.gui_config["theme"] = selected_theme # Example persistence
# Need a way to save gui_config too (similar to bot_config?)
print(f"Theme changed to: {page.theme_mode}")
page.update()
theme_dropdown = ft.Dropdown(
label="界面主题",
value=str(page.theme_mode).split(".")[-1].capitalize()
if page.theme_mode
else "System", # Handle None theme_mode
options=[
ft.dropdown.Option("System"),
ft.dropdown.Option("Light"),
ft.dropdown.Option("Dark"),
],
on_change=change_theme,
expand=True,
)
gui_settings_card = ft.Card(
content=ft.Container(
content=ft.Column(
[
ft.ListTile(title=ft.Text("GUI 设置")),
ft.Row([theme_dropdown], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
# Add more GUI settings here
]
),
padding=10,
)
)
# --- Bot Settings (Placeholder) ---
# TODO: Load bot_config.toml and dynamically generate controls
config_path = get_bot_config_path(app_state)
bot_config_content_area = ft.Column(expand=True, scroll=ft.ScrollMode.ADAPTIVE)
bot_settings_card = ft.Card(
content=ft.Container(
content=ft.Column(
[
ft.ListTile(title=ft.Text("Bot 配置 (bot_config.toml)")),
ft.Text(f"配置文件路径: {config_path}", italic=True, size=10),
ft.Divider(),
# Placeholder - Controls will be added dynamically
bot_config_content_area,
ft.Divider(),
ft.Row(
[
ft.ElevatedButton(
"重新加载", icon=ft.icons.REFRESH, on_click=lambda _: print("Reload TBD")
), # Placeholder action
ft.ElevatedButton(
"保存 Bot 配置", icon=ft.icons.SAVE, on_click=lambda _: print("Save TBD")
), # Placeholder action
],
alignment=ft.MainAxisAlignment.END,
),
]
),
padding=10,
)
)
# --- Load and Display Bot Config ---
# This needs error handling and dynamic UI generation
try:
# 使用新的加载方法
loaded_bot_config = load_bot_config(app_state)
if loaded_bot_config:
# 使用新的表单生成器创建动态表单
create_toml_form(page, loaded_bot_config, bot_config_content_area, app_state)
# Update the save button's action
save_button = bot_settings_card.content.content.controls[-1].controls[1] # Find the save button
save_button.on_click = lambda _: save_bot_config(
page, app_state, loaded_bot_config
) # Pass the loaded config dict
# Add reload logic here
reload_button = bot_settings_card.content.content.controls[-1].controls[0] # Find the reload button
def reload_action(_):
bot_config_content_area.controls.clear()
try:
reloaded_config = load_bot_config(app_state)
if reloaded_config:
# 重新创建表单
create_toml_form(page, reloaded_config, bot_config_content_area, app_state)
# Update save button reference
save_button.on_click = lambda _: save_bot_config(page, app_state, reloaded_config)
show_snackbar(page, "Bot 配置已重新加载。")
# 确保UI完全更新
bot_config_content_area.update()
bot_settings_card.update()
else:
bot_config_content_area.controls.append(
ft.Text("重新加载失败: 无法加载配置文件", color=ft.colors.ERROR)
)
bot_config_content_area.update()
except Exception as reload_e:
bot_config_content_area.controls.append(ft.Text(f"重新加载失败: {reload_e}", color=ft.colors.ERROR))
bot_config_content_area.update()
page.update()
reload_button.on_click = reload_action
else:
bot_config_content_area.controls.append(
ft.Text(f"错误: 无法加载配置文件 {config_path}", color=ft.colors.ERROR)
)
except Exception as e:
bot_config_content_area.controls.append(ft.Text(f"加载配置文件出错: {e}", color=ft.colors.ERROR))
return ft.View(
"/settings",
[
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
gui_settings_card,
bot_settings_card, # Add the bot settings card
# Add more settings sections/cards as needed
],
scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling for the whole view
padding=10,
)

View File

@@ -2,6 +2,7 @@ import flet as ft
from typing import Optional, TYPE_CHECKING
import psutil
import os
import sys
# Import components and state
from .flet_interest_monitor import InterestMonitorDisplay
@@ -10,8 +11,67 @@ if TYPE_CHECKING:
from .state import AppState
# --- 添加资源路径处理函数 ---
def get_asset_path(relative_path: str) -> str:
"""
获取资源文件的正确路径,在打包环境和源码环境下都能正常工作。
Args:
relative_path: 相对于项目根目录的资源路径,例如 "src/MaiGoi/assets/image.png"
Returns:
str: 资源文件的绝对路径
"""
# 检查是否在打包环境中运行
if getattr(sys, "frozen", False):
# 打包环境
# 获取应用程序所在目录
base_dir = os.path.dirname(sys.executable)
# 尝试多种可能的路径
possible_paths = [
# 1. 直接在根目录下
os.path.join(base_dir, os.path.basename(relative_path)),
# 2. 保持原始相对路径结构
# os.path.join(base_dir, relative_path),
# 3. 在 _internal 目录下保持原始路径结构
os.path.join(base_dir, "_internal", relative_path),
# 4. 从路径中去掉 "src/" 部分
# os.path.join(base_dir, relative_path.replace("src/", "", 1)),
# 5. 只使用最后的文件名
# os.path.join(base_dir, os.path.basename(relative_path)),
]
# 尝试所有可能的路径
for path in possible_paths:
if os.path.exists(path):
print(f"[AssetPath] 打包环境: 找到资源 '{relative_path}' 位置: {path}")
return path
# 如果找不到任何匹配的路径,记录错误并返回原始路径
print(f"[AssetPath] 警告: 在打包环境中找不到资源 '{relative_path}'")
return os.path.join(base_dir, relative_path) # 返回可能的路径,以便更容易识别错误
else:
# 源码环境,直接使用相对路径
# 假设 cwd 是项目根目录
root_dir = os.getcwd()
path = os.path.join(root_dir, relative_path)
# 验证路径是否存在
if os.path.exists(path):
return path
else:
print(f"[AssetPath] 警告: 在源码环境中找不到资源 '{relative_path}'")
return relative_path # 返回原始路径,方便调试
def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
"""Creates the main view ('/') of the application."""
# --- Set Page Padding to Zero --- #
page.padding = 0
# page.update() # Update the page to apply the padding change - 移除这行,避免闪烁
# ------------------------------ #
# Get the main button from state (should be created in launcher.py main)
start_button = app_state.start_bot_button
if not start_button:
@@ -32,45 +92,156 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
card_radius = ft.border_radius.all(4) # Slightly softer edges for glass
# card_bgcolor = ft.colors.with_opacity(0.05, ft.colors.BLUE_GREY_50) # Subtle background
# Use a semi-transparent primary color for the frosted glass effect
card_bgcolor = ft.colors.with_opacity(0.65, ft.colors.PRIMARY_CONTAINER) # Example: using theme container color
_card_bgcolor = ft.colors.with_opacity(0.65, ft.colors.PRIMARY_CONTAINER) # Example: using theme container color
# --- Card Creation Function --- #
def create_action_card(icon: str, text: str, on_click_handler, tooltip: str = None):
def create_action_card(
page: ft.Page,
icon: str,
subtitle: str,
text: str,
on_click_handler,
tooltip: str = None,
width: int = 450,
height: int = 150,
):
# Removed icon parameter usage
return ft.Container(
content=ft.Row(
subtitle_text = subtitle
# darker_bgcolor ='#ffffff' # Default Light mode background
# --- Determine colors based on theme ---
# is_dark = page.theme_mode == ft.ThemeMode.DARK
# card_bgcolor_actual = ft.colors.BLACK if is_dark else '#ffffff' # Use BLACK for dark, white for light
# main_text_color = ft.colors.GREY_200 if is_dark else ft.colors.BLACK # Light grey for dark, black for light
# subtitle_color = ft.colors.GREY_500 if is_dark else ft.colors.with_opacity(0.7, ft.colors.GREY_500) # Darker grey for dark, lighter grey for light
# --- Use Theme Colors Instead ---
# Let Flet handle the color adaptation based on theme
# card_bgcolor_theme = ft.colors.SURFACE_VARIANT # Or PRIMARY_CONTAINER, SURFACE etc.
# main_text_color_theme = ft.colors.ON_SURFACE_VARIANT
# subtitle_color_theme = ft.colors.with_opacity(0.8, ft.colors.ON_SURFACE_VARIANT) # Slightly transparent
card_bgcolor_theme = ft.colors.SURFACE # Use SURFACE for a generally whiter/lighter background
main_text_color_theme = ft.colors.ON_SURFACE # Corresponding text color
subtitle_color_theme = ft.colors.with_opacity(0.7, ft.colors.ON_SURFACE) # Slightly more transparent ON_SURFACE
# --- 使用辅助函数获取Emoji图片路径 --- #
emoji_image_path = get_asset_path("src/MaiGoi/assets/button_shape.png") # 使用辅助函数获取正确路径
# --- Create Text Content --- #
text_content_column = ft.Column(
[
# ft.Icon(name=icon, color=ft.colors.PRIMARY, size=20), # Icon Removed
ft.Text(
# --- Main Title Text ---
ft.Container(
content=ft.Text(
text,
weight=ft.FontWeight.BOLD, # Bolder text
size=20, # Even larger font size
expand=True, # Allow text to take available space
text_align=ft.TextAlign.CENTER, # Center text within the row
weight=ft.FontWeight.W_800,
size=50,
text_align=ft.TextAlign.LEFT,
font_family="SimSun",
# color=ft.colors.BLACK,
color=main_text_color_theme, # Use theme color
),
margin=ft.margin.only(top=-5),
),
# --- Subtitle Text (Wrapped in Container for Margin) ---
ft.Container(
content=ft.Text(
subtitle_text,
weight=ft.FontWeight.BOLD,
size=20,
# color=ft.colors.with_opacity(0.7, ft.colors.GREY_500),
color=subtitle_color_theme, # Use theme color
text_align=ft.TextAlign.LEFT,
font_family="SimHei",
),
margin=ft.margin.only(top=-20, left=10),
),
],
# alignment=ft.MainAxisAlignment.START, # Row alignment doesn't matter much with only text
vertical_alignment=ft.CrossAxisAlignment.CENTER,
# spacing=15, # Spacing removed as icon is gone
spacing=0,
alignment=ft.MainAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.START,
)
# --- Create Emoji Image Layer --- #
emoji_image_layer = ft.Container(
content=ft.Image(
src=emoji_image_path,
fit=ft.ImageFit.COVER, # <-- Change fit to COVER for zoom/fill effect
),
width=300, # *** Explicitly set a fixed width for the card ***
# min_height=80, # Increase minimum height (Container doesn't have min_height)
alignment=ft.alignment.center, # Center the image within the container
# Position the container itself to overlap the right side
right=-100, # <-- Allow container to extend beyond the right edge slightly
top=10, # <-- Allow container to extend beyond the top edge slightly
# bottom=5, # Remove bottom constraint
width=300, # <-- Increase width of the image container area
height=300, # <-- Give it a height too, slightly larger than card text area
opacity=0.3, # <-- Set back to semi-transparent
# expand=True # Optionally expand if needed
rotate=ft.transform.Rotate(angle=0.2),
# transform=ft.transform.Scale(scale_x=-1), # <-- Remove transform from container
)
# --- Hover effect shadow --- #
hover_shadow = ft.BoxShadow(
spread_radius=2,
blur_radius=15, # Slightly more blur on hover
color=ft.colors.with_opacity(0.3, ft.colors.BLACK87), # Slightly darker shadow
offset=ft.Offset(2, 4),
)
# --- on_hover handler --- #
def handle_hover(e):
if e.data == "true": # Mouse enters
e.control.scale = ft.transform.Scale(1.03)
e.control.shadow = hover_shadow
else: # Mouse exits
e.control.scale = ft.transform.Scale(1.0)
e.control.shadow = card_shadow # Restore original shadow
e.control.update()
return ft.Container(
# Use Stack to layer text and image
content=ft.Stack(
[
# Layer 1: Text Content (aligned left implicitly by parent Row settings)
# Need to wrap the column in a Row again if we removed the original one,
# but let's try putting the column directly first if Stack handles alignment
# We need padding inside the stack for the text
ft.Container(
content=text_content_column,
padding=ft.padding.only(top=8, left=15, bottom=15, right=20), # Apply padding here
),
# Layer 2: Emoji Image
emoji_image_layer,
]
),
height=height,
width=width,
border_radius=card_radius,
# border=card_border, # Border removed
bgcolor=card_bgcolor,
padding=ft.padding.symmetric(vertical=25, horizontal=20), # Further increase vertical padding for height
margin=ft.margin.only(bottom=20), # Increased bottom margin for more spacing
# bgcolor=darker_bgcolor,
bgcolor=card_bgcolor_theme, # Use theme color
# Padding is now applied to the inner container for text
padding=0,
margin=ft.margin.only(bottom=20), # Margin applied outside the hover effect
shadow=card_shadow,
on_click=on_click_handler,
tooltip=tooltip,
ink=True, # Add ripple effect on click
ink=True,
# rotate=ft.transform.Rotate(angle=0.1), # Remove rotate as it might conflict
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, # Clip overflowing image within card bounds
# rotate=ft.transform.Rotate(angle=0.1), # Apply rotation outside hover if needed
scale=ft.transform.Scale(1.0), # Initial scale
animate_scale=ft.animation.Animation(200, "easeOutCubic"), # Animate scale changes
on_hover=handle_hover, # Attach hover handler
)
# --- Main Button Action --- #
# Need process_manager for the main button action
start_bot_card = create_action_card(
page=page, # Pass page object
icon=ft.icons.SMART_TOY_OUTLINED,
text="启动麦麦Core",
text="主控室",
subtitle="在此启动 Bot",
on_click_handler=lambda _: page.go("/console"),
tooltip="打开 Bot 控制台视图 (在此启动 Bot)",
)
@@ -93,61 +264,98 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
]
# --- Create "More..." Card Separately for Stack --- #
# more_options_card = create_action_card(
# page=page,
# icon=ft.icons.MORE_HORIZ_OUTLINED,
# text="更多...",
# subtitle="其他工具",
# on_click_handler=None, # 这里不设置点击动作,因为我们会覆盖内容
# tooltip="选择要运行的脚本",
# width=300,
# height=100,
# )
# 创建一个包含 more_options_card 和 PopupMenuButton 的 Stack
more_options_card_stack = ft.Container(
content=ft.Row(
content=ft.Stack(
[
ft.Text(
"更多...", # Renamed text
weight=ft.FontWeight.BOLD,
size=14, # Smaller size for less emphasis
# expand=True,
text_align=ft.TextAlign.LEFT,
# more_options_card, # 作为背景卡片
# 将 PopupMenuButton 放在卡片上层
ft.Container(
content=ft.PopupMenuButton(
items=menu_items,
icon=ft.icons.MORE_VERT,
icon_size=50,
icon_color=ft.colors.ORANGE,
tooltip="选择要运行的脚本",
),
ft.PopupMenuButton(items=menu_items, icon=ft.icons.MORE_VERT, tooltip="选择要运行的脚本"),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
spacing=5, # Reduced spacing
alignment=ft.MainAxisAlignment.END, # Align content to the end (right) of the row
right=50, # 右侧距离
top=20, # 顶部距离
),
# width=150, # Reduced width
border_radius=card_radius,
bgcolor=card_bgcolor,
padding=ft.padding.symmetric(vertical=10, horizontal=15), # Reduced padding
# margin=ft.margin.only(bottom=20), # Margin handled by Stack positioning
shadow=card_shadow,
]
),
height=150, # 与普通卡片相同高度
width=450, # 与普通卡片相同宽度
# 不需要设置 bgcolor 和 border_radius因为 more_options_card 已包含这些样式
rotate=ft.transform.Rotate(angle=0.12), # 与其他卡片使用相同的旋转角度
)
# --- Main Column of Cards --- #
main_cards_column = ft.Column(
controls=[
ft.Container(height=15), # Top spacing
# start_button, # Removed direct button
start_bot_card, # Use the card
# Wrap start_bot_card
ft.Container(
content=start_bot_card,
margin=ft.margin.only(top=20, right=10),
rotate=ft.transform.Rotate(angle=0.12),
),
# --- Move Adapters Card Up --- #
create_action_card(
# Wrap Adapters card
ft.Container(
content=create_action_card(
page=page, # Pass page object
icon=ft.icons.EXTENSION_OUTLINED, # Example icon
text="启动适配器...",
text="适配器",
subtitle="管理适配器脚本",
on_click_handler=lambda _: page.go("/adapters"),
tooltip="管理和运行适配器脚本",
),
margin=ft.margin.only(top=20, right=45),
rotate=ft.transform.Rotate(angle=0.12),
),
# Re-add the LPMM script card
create_action_card(
# Wrap LPMM card
ft.Container(
content=create_action_card(
page=page, # Pass page object
icon=ft.icons.MODEL_TRAINING_OUTLINED, # Icon is not used visually but kept for consistency maybe
text="麦麦学习",
text="学习",
subtitle="使用LPMM知识库",
on_click_handler=lambda _: run_script("start_lpmm.bat", page, app_state),
tooltip="运行学习脚本 (start_lpmm.bat)",
),
margin=ft.margin.only(top=20, right=15),
rotate=ft.transform.Rotate(angle=0.12),
),
# more_options_card, # Add the new card with the popup menu (Moved to Stack)
# --- Add Adapters and Settings Cards --- #
create_action_card(
# Wrap Settings card
ft.Container(
content=create_action_card(
page=page, # Pass page object
icon=ft.icons.SETTINGS_OUTLINED, # Example icon
text="设置",
subtitle="配置所有选项",
on_click_handler=lambda _: page.go("/settings"),
tooltip="配置启动器选项",
),
margin=ft.margin.only(top=20, right=60),
rotate=ft.transform.Rotate(angle=0.12),
),
],
# alignment=ft.MainAxisAlignment.START, # Default vertical alignment is START
horizontal_alignment=ft.CrossAxisAlignment.START, # Align cards to the START (left)
horizontal_alignment=ft.CrossAxisAlignment.END, # Align cards to the END (right)
spacing=0, # Let card margin handle spacing
# expand=True, # Remove expand from the inner column if using Stack
)
@@ -155,71 +363,101 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
return ft.View(
"/", # Main view route
[
ft.AppBar(
# title=ft.Text("MaiBot 工具箱", size=18, weight=ft.FontWeight.W_600), # Larger, bolder title
# Use leading for custom title layout with a line
leading=ft.Row(
[
ft.Container(width=4, height=28, bgcolor=ft.colors.PRIMARY, border_radius=2), # Vertical line
ft.Container(width=5), # Use a Container for simple horizontal spacing
ft.Text("MaiBot 工具箱", size=22, weight=ft.FontWeight.BOLD), # Larger title
],
spacing=5, # Spacing within the row
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
leading_width=300, # Adjust width to fit the custom leading widget
center_title=False, # Left-align title
),
# --- Use Stack for Layout --- #
ft.Stack(
[
# Main column of cards (aligned top-left implicitly)
main_cards_column,
# --- Giant Orange Stripe (Background) --- #
ft.Container(
bgcolor=ft.colors.with_opacity(1, ft.colors.ORANGE_ACCENT_200), # Orange with opacity
width=3000, # Make it very wide
height=1000, # Give it substantial height
rotate=ft.transform.Rotate(0.12), # Apply rotation (adjust angle as needed)
# alignment=ft.alignment.center, # Center it in the stack
# Position it manually to better control placement with rotation
left=-200,
top=-500,
opacity=1, # Overall opacity for the stripe
),
ft.Container(
content=ft.Image(
src=get_asset_path("src/MaiGoi/assets/button_shape.png"), # 使用辅助函数获取正确路径
fit=ft.ImageFit.CONTAIN,
),
width=900,
height=1800,
left=35, # 距离左侧
top=-420, # 距离顶部
border_radius=ft.border_radius.all(10),
rotate=ft.transform.Rotate(-1.2),
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, # Helps with rounded corners
),
ft.Container(
bgcolor=ft.colors.with_opacity(1, ft.colors.ORANGE_ACCENT_200), # Orange with opacity
width=1000, # Make it very wide
height=1000, # Give it substantial height
rotate=ft.transform.Rotate(0.12), # Apply rotation (adjust angle as needed)
# alignment=ft.alignment.center, # Center it in the stack
# Position it manually to better control placement with rotation
left=280,
top=-561.6,
opacity=1, # Overall opacity for the stripe
),
# --- End Giant Orange Stripe ---
ft.Container(
bgcolor=ft.colors.with_opacity(1, ft.colors.PURPLE_200), # Orange with opacity
width=800, # Make it very wide
height=3000, # Give it substantial height
rotate=ft.transform.Rotate(0.6), # Apply rotation (adjust angle as needed)
# alignment=ft.alignment.center, # Center it in the stack
# Position it manually to better control placement with rotation
left=-500,
top=-1600,
opacity=1, # Overall opacity for the stripe
),
ft.Container(
content=main_cards_column,
top=20, # 距离顶部
right=20, # 距离右侧
),
# --- End positioned Container ---
# "More..." card aligned bottom-right
ft.Container(
content=more_options_card_stack,
# Use Stack positioning properties instead of alignment
right=10, # Distance from right edge
bottom=10, # Distance from bottom edge
# 重新定位"更多..."按钮
right=10, # 距离右侧
bottom=15, # 距离底部
),
# --- Add Large Text to Bottom Left ---
ft.Container(
content=ft.Text(
"MAI",
size=50,
font_family="Microsoft YaHei",
weight=ft.FontWeight.W_700,
color=ft.colors.with_opacity(1, ft.colors.WHITE10),
),
left=32,
top=30,
rotate=ft.transform.Rotate(-0.98),
),
ft.Container(
content=ft.Text(
"工具箱",
size=80,
font_family="Microsoft YaHei", # 使用相同的锐利字体
weight=ft.FontWeight.W_700, # 加粗
color=ft.colors.with_opacity(1, ft.colors.WHITE10),
),
left=-10,
top=78,
rotate=ft.transform.Rotate(-0.98),
),
# --- End Add Large Text ---
],
expand=True, # Make Stack fill the available space
),
# ft.Column(
# [
# ft.Container(height=15), # Top spacing
# # start_button, # Removed direct button
# start_bot_card, # Use the card
# # Re-add the LPMM script card
# create_action_card(
# icon=ft.icons.MODEL_TRAINING_OUTLINED, # Icon is not used visually but kept for consistency maybe
# text="麦麦学习",
# on_click_handler=lambda _: run_script("start_lpmm.bat", page, app_state),
# tooltip="运行学习脚本 (start_lpmm.bat)"
# ),
# # more_options_card, # Add the new card with the popup menu (Moved to Stack)
# # --- Add Adapters and Settings Cards --- #
# create_action_card(
# icon=ft.icons.EXTENSION_OUTLINED, # Example icon
# text="适配器...",
# on_click_handler=lambda _: page.go("/adapters"),
# tooltip="管理和运行适配器脚本"
# ),
# create_action_card(
# icon=ft.icons.SETTINGS_OUTLINED, # Example icon
# text="设置",
# on_click_handler=lambda _: page.go("/settings"),
# tooltip="配置启动器选项"
# ),
# ],
# # alignment=ft.MainAxisAlignment.START, # Default vertical alignment is START
# horizontal_alignment=ft.CrossAxisAlignment.START, # Align cards to the START (left)
# spacing=0, # Let card margin handle spacing
# expand=True,
# )
],
padding=ft.padding.symmetric(horizontal=15), # Add horizontal padding to the view
scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows
# padding=ft.padding.symmetric(horizontal=20), # <-- 移除水平 padding
# scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows
)
@@ -227,9 +465,11 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
"""Creates the console output view ('/console'), including the interest monitor."""
# Get UI elements from state
output_list_view = app_state.output_list_view
# start_button = app_state.start_bot_button # Variable is assigned but never used
from .process_manager import update_buttons_state # Dynamic import
# 默认开启自动滚动
app_state.is_auto_scroll_enabled = True
# Create ListView if it doesn't exist (as a fallback, should be created by start_bot)
if not output_list_view:
output_list_view = ft.ListView(expand=True, spacing=2, auto_scroll=app_state.is_auto_scroll_enabled, padding=5)
@@ -248,7 +488,35 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
interest_monitor = app_state.interest_monitor_control
# --- Process Manager Functions (Import for button actions) ---
# --- 为控制台输出和兴趣监控创建容器,以便动态调整大小 --- #
output_container = ft.Container(
content=output_list_view,
expand=4, # 在左侧 Column 内部分配比例
border=ft.border.only(bottom=ft.border.BorderSide(1, ft.colors.OUTLINE)),
)
monitor_container = ft.Container(
content=interest_monitor,
expand=4, # 在左侧 Column 内部分配比例
)
# --- 设置兴趣监控的切换回调函数 --- #
def on_monitor_toggle(is_expanded):
if is_expanded:
# 监控器展开时,恢复原比例
output_container.expand = 4
monitor_container.expand = 4
else:
# 监控器隐藏时,让输出区占据更多空间
output_container.expand = 9
monitor_container.expand = 0
# 更新容器以应用新布局
output_container.update()
monitor_container.update()
# 为监控器设置回调函数
interest_monitor.on_toggle = on_monitor_toggle
# --- Auto-scroll toggle button callback (remains separate) --- #
def toggle_auto_scroll(e):
@@ -256,6 +524,15 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
lv = app_state.output_list_view # Get potentially updated list view
if lv:
lv.auto_scroll = app_state.is_auto_scroll_enabled
# 当关闭自动滚动时,记录当前滚动位置
if not app_state.is_auto_scroll_enabled:
# 标记视图正在手动观看模式,以便在更新时保持位置
app_state.manual_viewing = True
else:
# 开启自动滚动时,关闭手动观看模式
app_state.manual_viewing = False
# Update button appearance (assuming button reference is available)
# e.control is the Container now
# We need to update the Text control stored in its data attribute
@@ -376,20 +653,9 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
ft.Column(
controls=[
# 1. Console Output Area
ft.Container(
content=output_list_view, # From state
expand=5, # 在左侧 Column 内部分配比例
border=ft.border.only(bottom=ft.border.BorderSide(1, ft.colors.OUTLINE)),
),
output_container, # 使用容器替代直接引用
# 2. Interest Monitor Area
ft.Container(
content=interest_monitor, # From state
expand=4, # 在左侧 Column 内部分配比例
# border=ft.border.all(1, ft.colors.OUTLINE), # 可以去掉这里的边框
# border_radius=ft.border_radius.all(5),
# padding=10, # 可以调整或去掉
# margin=ft.margin.only(top=10),
),
monitor_container, # 使用容器替代直接引用
],
expand=True, # 让左侧 Column 占据 Row 的大部分空间
),
@@ -649,20 +915,14 @@ def create_adapters_view(page: ft.Page, app_state: "AppState") -> ft.View:
# --- Settings View --- #
def create_settings_view(page: ft.Page, app_state: "AppState") -> ft.View:
"""Creates the settings view (/settings)."""
"""Placeholder for settings view."""
# This function is now implemented in ui_settings_view.py
# This placeholder can be removed if no longer referenced anywhere else.
# For safety, let's keep it but make it clear it's deprecated/moved.
print("Warning: Deprecated create_settings_view called in ui_views.py. Should use ui_settings_view.py version.")
return ft.View(
"/settings",
[
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
# Pass padding value positionally, use content keyword argument
# ft.Padding(ft.padding.all(20), content=ft.Text("设置选项将在此处显示...")),
# Use a Container with the padding property instead
ft.Container(
padding=ft.padding.all(20), # Set padding property on the Container
content=ft.Text("设置选项将在此处显示..."), # Place the original content inside
),
# Add settings controls here later
],
"/settings_deprecated",
[ft.AppBar(title=ft.Text("Settings (Deprecated)")), ft.Text("This view has moved to ui_settings_view.py")],
)

View File

@@ -916,6 +916,7 @@ INTEREST_CHAT_STYLE_CONFIG = (
INTEREST_CHAT_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else INTEREST_CHAT_STYLE_CONFIG["advanced"]
)
def is_registered_module(record: dict) -> bool:
"""检查是否为已注册的模块"""
return record["extra"].get("module") in _handler_registry

View File

@@ -78,6 +78,7 @@ class BackgroundTaskManager:
self._into_focus_task: Optional[asyncio.Task] = None
self._private_chat_activation_task: Optional[asyncio.Task] = None # 新增私聊激活任务引用
self._tasks: List[Optional[asyncio.Task]] = [] # Keep track of all tasks
self._detect_command_from_gui_task: Optional[asyncio.Task] = None # 新增GUI命令检测任务引用
async def start_tasks(self):
"""启动所有后台任务
@@ -135,6 +136,13 @@ class BackgroundTaskManager:
f"私聊激活检查任务已启动 间隔:{PRIVATE_CHAT_ACTIVATION_CHECK_INTERVAL_SECONDS}s",
"_private_chat_activation_task",
),
# 新增GUI命令检测任务配置
# (
# lambda: self._run_detect_command_from_gui_cycle(3),
# "debug",
# f"GUI命令检测任务已启动 间隔:{3}s",
# "_detect_command_from_gui_task",
# ),
]
# 统一启动所有任务
@@ -296,3 +304,11 @@ class BackgroundTaskManager:
interval=interval,
task_func=self.subheartflow_manager.sbhf_absent_private_into_focus,
)
# # 有api之后删除
# async def _run_detect_command_from_gui_cycle(self, interval: int):
# await _run_periodic_loop(
# task_name="Detect Command from GUI",
# interval=interval,
# task_func=self.subheartflow_manager.detect_command_from_gui,
# )

View File

@@ -101,7 +101,7 @@ class InterestLogger:
try:
current_timestamp = time.time()
main_mind = self.heartflow.current_mind
# main_mind = self.heartflow.current_mind
# 获取 Mai 状态名称
mai_state_name = self.heartflow.current_state.get_current_state().name
@@ -109,7 +109,7 @@ class InterestLogger:
log_entry_base = {
"timestamp": round(current_timestamp, 2),
"main_mind": main_mind,
# "main_mind": main_mind,
"mai_state": mai_state_name,
"subflow_count": len(all_subflow_states),
"subflows": [],
@@ -144,7 +144,7 @@ class InterestLogger:
"sub_chat_state": state.get("chat_state", "未知"),
"interest_level": interest_state.get("interest_level", 0.0),
"start_hfc_probability": interest_state.get("start_hfc_probability", 0.0),
"is_above_threshold": interest_state.get("is_above_threshold", False),
# "is_above_threshold": interest_state.get("is_above_threshold", False),
}
subflow_details.append(subflow_entry)

View File

@@ -852,3 +852,52 @@ class SubHeartflowManager:
# --- 结束新增 --- #
# --- 结束新增:处理来自 HeartFChatting 的状态转换请求 --- #
# 临时函数用于GUI切换有api后删除
# async def detect_command_from_gui(self):
# """检测来自GUI的命令"""
# command_file = Path("temp_command/gui_command.json")
# if not command_file.exists():
# return
# try:
# # 读取并解析命令文件
# command_data = json.loads(command_file.read_text())
# subflow_id = command_data.get("subflow_id")
# target_state = command_data.get("target_state")
# if not subflow_id or not target_state:
# logger.warning("GUI命令文件格式不正确缺少必要字段")
# return
# # 尝试转换为ChatState枚举
# try:
# target_state_enum = ChatState[target_state.upper()]
# except KeyError:
# logger.warning(f"无效的目标状态: {target_state}")
# command_file.unlink()
# return
# # 执行状态转换
# await self.force_change_by_gui(subflow_id, target_state_enum)
# # 转换成功后删除文件
# command_file.unlink()
# logger.debug(f"已处理GUI命令并删除命令文件: {command_file}")
# except json.JSONDecodeError:
# logger.warning("GUI命令文件不是有效的JSON格式")
# except Exception as e:
# logger.error(f"处理GUI命令时发生错误: {e}", exc_info=True)
# async def force_change_by_gui(self, subflow_id: Any, target_state: ChatState):
# """强制改变指定子心流的状态"""
# async with self._lock:
# subflow = self.subheartflows.get(subflow_id)
# if not subflow:
# logger.warning(f"[强制状态转换] 尝试转换不存在的子心流 {subflow_id} 到 {target_state.value}")
# return
# await subflow.change_chat_state(target_state)
# logger.info(f"[强制状态转换] 成功将 {subflow_id} 的状态转换为 {target_state.value}")
# --- 结束新增 --- #