diff --git a/@flet_new_.mdc b/@flet_new_.mdc new file mode 100644 index 000000000..b93c988bf --- /dev/null +++ b/@flet_new_.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/launcher.py b/launcher.py index 133c219e7..f5b1a4250 100644 --- a/launcher.py +++ b/launcher.py @@ -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 - page.theme_mode = ft.ThemeMode.SYSTEM - page.padding = 10 # Reduced padding slightly + + # --- 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 = 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 "/") diff --git a/requirements.txt b/requirements.txt index 5416220a6..cab258e29 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/MaiGoi/assets/button_shape.png b/src/MaiGoi/assets/button_shape.png new file mode 100644 index 000000000..67f0f9adc Binary files /dev/null and b/src/MaiGoi/assets/button_shape.png differ diff --git a/src/MaiGoi/assets/icon.png b/src/MaiGoi/assets/icon.png new file mode 100644 index 000000000..90db90aca Binary files /dev/null and b/src/MaiGoi/assets/icon.png differ diff --git a/src/MaiGoi/assets/lihui.png b/src/MaiGoi/assets/lihui.png new file mode 100644 index 000000000..05430d69f Binary files /dev/null and b/src/MaiGoi/assets/lihui.png differ diff --git a/src/MaiGoi/assets/lihui_bhl.png b/src/MaiGoi/assets/lihui_bhl.png new file mode 100644 index 000000000..be05982e8 Binary files /dev/null and b/src/MaiGoi/assets/lihui_bhl.png differ diff --git a/src/MaiGoi/config_manager.py b/src/MaiGoi/config_manager.py index 92a4af668..1552bde61 100644 --- a/src/MaiGoi/config_manager.py +++ b/src/MaiGoi/config_manager.py @@ -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 - return config_path +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' - if "adapters" not in config_data: - config_data["adapters"] = DEFAULT_CONFIG["adapters"] - print("[Config] Config loaded successfully.") + 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_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() diff --git a/src/MaiGoi/flet_interest_monitor.py b/src/MaiGoi/flet_interest_monitor.py index 04823b221..255894d39 100644 --- a/src/MaiGoi/flet_interest_monitor.py +++ b/src/MaiGoi/flet_interest_monitor.py @@ -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,86 +147,294 @@ 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.main_chart, # 图表在左侧 - self.legend_column, # 图例在右侧 + self.global_info_row, # 将全局信息行移动到这里 + ft.Row( + controls=[ + self.main_chart, # 图表在左侧 + self.legend_column, # 图例在右侧 + ], + vertical_alignment=ft.CrossAxisAlignment.START, + expand=True, # 让 Row 扩展 + ), ], - 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 - await self.update_detail_charts(self.selected_stream_id_for_details) + # 只有在选择了流的情况下更新详情图表 + 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,28 +911,93 @@ 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 - self.status_text.value = display_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 - for stream_id, _history in history_dict.items(): - # 使用正确的历史记录字典 - actual_history = target_history_key.get(stream_id) - if actual_history: - all_ts.extend([ts for ts, _ in actual_history]) - if not all_ts: + try: + for stream_id, _history in history_dict.items(): + # 使用正确的历史记录字典 + actual_history = target_history_key.get(stream_id) + if actual_history: + all_ts.extend([ts for ts, _ in actual_history]) + + if not all_ts: + # 如果没有时间戳,返回当前时间前后一小时的范围 + now = time.time() + 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%的填充 + + return min_ts - padding, max_ts + padding + except Exception as e: + # 出现任何错误都返回当前时间范围 now = time.time() - return now - 3600, now + print(f"[InterestMonitor] 获取时间范围时出错: {e}") + traceback.print_exc() + return now - 3600, now + 60 - 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 + 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 # --- 测试部分保持不变 --- diff --git a/src/MaiGoi/flet_rules.py b/src/MaiGoi/flet_rules.py new file mode 100644 index 000000000..9570b577c --- /dev/null +++ b/src/MaiGoi/flet_rules.py @@ -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")) +""" diff --git a/src/MaiGoi/process_manager.py b/src/MaiGoi/process_manager.py index 81aa13567..515ac9901 100644 --- a/src/MaiGoi/process_manager.py +++ b/src/MaiGoi/process_manager.py @@ -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: - output_lv.controls.extend(lines_to_add) - while len(output_lv.controls) > 1000: - output_lv.controls.pop(0) # Limit lines + # 如果在手动观看模式(自动滚动关闭),记录首个元素索引 + 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, diff --git a/src/MaiGoi/state.py b/src/MaiGoi/state.py index 2accc735b..bad7260a1 100644 --- a/src/MaiGoi/state.py +++ b/src/MaiGoi/state.py @@ -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) diff --git a/src/MaiGoi/toml_form_generator.py b/src/MaiGoi/toml_form_generator.py new file mode 100644 index 000000000..5ba8ff5f7 --- /dev/null +++ b/src/MaiGoi/toml_form_generator.py @@ -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 diff --git a/src/MaiGoi/ui_env_editor.py b/src/MaiGoi/ui_env_editor.py new file mode 100644 index 000000000..08f3a1da6 --- /dev/null +++ b/src/MaiGoi/ui_env_editor.py @@ -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 diff --git a/src/MaiGoi/ui_settings_view.py b/src/MaiGoi/ui_settings_view.py new file mode 100644 index 000000000..b15b06429 --- /dev/null +++ b/src/MaiGoi/ui_settings_view.py @@ -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, + ) diff --git a/src/MaiGoi/ui_views.py b/src/MaiGoi/ui_views.py index 90bbbdcb0..b1a74e85d 100644 --- a/src/MaiGoi/ui_views.py +++ b/src/MaiGoi/ui_views.py @@ -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( - [ - # ft.Icon(name=icon, color=ft.colors.PRIMARY, size=20), # Icon Removed - ft.Text( + 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( + [ + # --- 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 ), - ], - # 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 + 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), + ), + ], + 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="选择要运行的脚本", + ), + right=50, # 右侧距离 + top=20, # 顶部距离 ), - 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 + ] ), - # 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( - icon=ft.icons.EXTENSION_OUTLINED, # Example icon - text="启动适配器...", - on_click_handler=lambda _: page.go("/adapters"), - tooltip="管理和运行适配器脚本", + # Wrap Adapters card + ft.Container( + content=create_action_card( + page=page, # Pass page object + icon=ft.icons.EXTENSION_OUTLINED, # Example icon + 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( - 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)", + # 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="学习", + 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( - icon=ft.icons.SETTINGS_OUTLINED, # Example icon - text="设置", - on_click_handler=lambda _: page.go("/settings"), - tooltip="配置启动器选项", + # 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")], ) diff --git a/src/common/logger.py b/src/common/logger.py index 9c159232e..bf82cffae 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -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 diff --git a/src/heart_flow/background_tasks.py b/src/heart_flow/background_tasks.py index 301c2984c..5ed664e0e 100644 --- a/src/heart_flow/background_tasks.py +++ b/src/heart_flow/background_tasks.py @@ -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, + # ) diff --git a/src/heart_flow/interest_logger.py b/src/heart_flow/interest_logger.py index 38baf4785..1fe289b89 100644 --- a/src/heart_flow/interest_logger.py +++ b/src/heart_flow/interest_logger.py @@ -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) diff --git a/src/heart_flow/subheartflow_manager.py b/src/heart_flow/subheartflow_manager.py index b09f10844..1cf584964 100644 --- a/src/heart_flow/subheartflow_manager.py +++ b/src/heart_flow/subheartflow_manager.py @@ -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}") + + # --- 结束新增 --- #