diff --git a/@flet_new_.mdc b/@flet_new_.mdc deleted file mode 100644 index b93c988bf..000000000 --- a/@flet_new_.mdc +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- diff --git a/launcher.py b/launcher.py index f5b1a4250..2bc47bcd6 100644 --- a/launcher.py +++ b/launcher.py @@ -2,6 +2,7 @@ import flet as ft import os import atexit import psutil # Keep for initial PID checks maybe, though state should handle it +# import asyncio # <--- 如果不再需要其他异步任务,可以考虑移除 # --- Import refactored modules --- # from src.MaiGoi.state import AppState @@ -45,6 +46,10 @@ def route_change(route: ft.RouteChangeEvent): page = route.page target_route = route.route + # --- 移除异步显示弹窗的辅助函数 --- + # async def show_python_path_dialog(): + # ... + # Clear existing views before adding new ones page.views.clear() @@ -54,9 +59,19 @@ def route_change(route: ft.RouteChangeEvent): # --- Handle Specific Routes --- # if target_route == "/console": + # 清理:移除之前添加的 is_python_dialog_opening 标志(如果愿意) + # app_state.is_python_dialog_opening = False # 可选清理 + console_view = create_console_view(page, app_state) page.views.append(console_view) + # --- 仅设置标志 --- + print(f"[Route Change /console] Checking python_path: '{app_state.python_path}'") + if not app_state.python_path: + print("[Route Change /console] python_path is empty, setting flag.") + app_state.needs_python_path_dialog = True + # *** 不再在这里打开弹窗 *** + # Check process status and potentially restart processor loop if needed is_running = app_state.bot_pid is not None and psutil.pid_exists(app_state.bot_pid) print( @@ -164,12 +179,18 @@ 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 + app_state.adapter_paths = loaded_config.get("adapters", []).copy() + app_state.bot_script_path = loaded_config.get("bot_script_path", "bot.py") # Load bot script path + + # 加载用户自定义的 Python 路径 + if "python_path" in loaded_config and os.path.exists(loaded_config["python_path"]): + app_state.python_path = loaded_config["python_path"] + print(f"[Main] 从配置加载 Python 路径: {app_state.python_path}") + print(f"[Main] Initial adapters loaded: {app_state.adapter_paths}") # Set script_dir in AppState early @@ -198,6 +219,26 @@ def main(page: ft.Page): except KeyError: print(f"[Main] Warning: Invalid theme '{saved_theme}' in config. Falling back to System.") page.theme_mode = ft.ThemeMode.SYSTEM + + # --- 自定义主题颜色 --- # + # 创建深色主题,使橙色变得更暗 + dark_theme = ft.Theme( + color_scheme_seed=ft.colors.ORANGE, + primary_color=ft.colors.ORANGE_700, # 使用更暗的橙色 + color_scheme=ft.ColorScheme( + primary=ft.colors.ORANGE_700, + primary_container=ft.colors.ORANGE_800, + ) + ) + + # 创建亮色主题 + light_theme = ft.Theme( + color_scheme_seed=ft.colors.ORANGE, + ) + + # 设置自定义主题 + page.theme = light_theme + page.dark_theme = dark_theme page.padding = 0 # <-- 将页面 padding 设置为 0 diff --git a/requirements.txt b/requirements.txt index cab258e29..8779b40e1 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/interest_monitor_gui.py b/scripts/interest_monitor_gui.py deleted file mode 100644 index 0c44507c8..000000000 --- a/scripts/interest_monitor_gui.py +++ /dev/null @@ -1,670 +0,0 @@ -import tkinter as tk -from tkinter import ttk -import time -import os -from datetime import datetime, timedelta -import random -from collections import deque -import json # 引入 json - -# --- 引入 Matplotlib --- -from matplotlib.figure import Figure -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -import matplotlib.dates as mdates # 用于处理日期格式 -import matplotlib # 导入 matplotlib - -# --- 配置 --- -LOG_FILE_PATH = os.path.join("logs", "interest", "interest_history.log") # 指向历史日志文件 -REFRESH_INTERVAL_MS = 200 # 刷新间隔 (毫秒) - 可以适当调长,因为读取文件可能耗时 -WINDOW_TITLE = "Interest Monitor (Live History)" -MAX_HISTORY_POINTS = 1000 # 图表上显示的最大历史点数 (可以增加) -MAX_STREAMS_TO_DISPLAY = 15 # 最多显示多少个聊天流的折线图 (可以增加) -MAX_QUEUE_SIZE = 30 # 新增:历史想法队列最大长度 - -# *** 添加 Matplotlib 中文字体配置 *** -# 尝试使用 'SimHei' 或 'Microsoft YaHei',如果找不到,matplotlib 会回退到默认字体 -# 确保你的系统上安装了这些字体 -matplotlib.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] -matplotlib.rcParams["axes.unicode_minus"] = False # 解决负号'-'显示为方块的问题 - - -def get_random_color(): - """生成随机颜色用于区分线条""" - return "#{:06x}".format(random.randint(0, 0xFFFFFF)) - - -def format_timestamp(ts): - """辅助函数:格式化时间戳,处理 None 或无效值""" - if ts is None: - return "N/A" - try: - # 假设 ts 是 float 类型的时间戳 - dt_object = datetime.fromtimestamp(float(ts)) - return dt_object.strftime("%Y-%m-%d %H:%M:%S") - except (ValueError, TypeError): - return "Invalid Time" - - -class InterestMonitorApp: - def __init__(self, root): - self._main_mind_loaded = None - self.root = root - self.root.title(WINDOW_TITLE) - self.root.geometry("1800x800") # 调整窗口大小以适应图表 - - # --- 数据存储 --- - # 使用 deque 来存储有限的历史数据点 - # key: stream_id, value: deque([(timestamp, interest_level), ...]) - self.stream_history = {} - # key: stream_id, value: deque([(timestamp, reply_probability), ...]) - self.probability_history = {} - self.stream_colors = {} # 为每个 stream 分配颜色 - self.stream_display_names = {} # 存储显示名称 (group_name) - self.selected_stream_id = tk.StringVar() # 用于 Combobox 绑定 - - # --- 新增:存储其他参数 --- - # 顶层信息 - self.latest_main_mind = tk.StringVar(value="N/A") - self.latest_mai_state = tk.StringVar(value="N/A") - self.latest_subflow_count = tk.IntVar(value=0) - # 子流最新状态 (key: stream_id) - self.stream_sub_minds = {} - self.stream_chat_states = {} - self.stream_threshold_status = {} - self.stream_last_active = {} - self.stream_last_interaction = {} - # 用于显示单个流详情的 StringVar - self.single_stream_sub_mind = tk.StringVar(value="想法: N/A") - self.single_stream_chat_state = tk.StringVar(value="状态: N/A") - self.single_stream_threshold = tk.StringVar(value="阈值: N/A") - self.single_stream_last_active = tk.StringVar(value="活跃: N/A") - self.single_stream_last_interaction = tk.StringVar(value="交互: N/A") - - # 新增:历史想法队列 - self.main_mind_history = deque(maxlen=MAX_QUEUE_SIZE) - self.last_main_mind_timestamp = 0 # 记录最后一条main_mind的时间戳 - - # --- UI 元素 --- - - # --- 新增:顶部全局信息框架 --- - self.global_info_frame = ttk.Frame(root, padding="5 0 5 5") # 顶部内边距调整 - self.global_info_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0)) # 底部外边距为0 - - ttk.Label(self.global_info_frame, text="全局状态:").pack(side=tk.LEFT, padx=(0, 10)) - ttk.Label(self.global_info_frame, textvariable=self.latest_mai_state).pack(side=tk.LEFT, padx=5) - ttk.Label(self.global_info_frame, text="想法:").pack(side=tk.LEFT, padx=(10, 0)) - ttk.Label(self.global_info_frame, textvariable=self.latest_main_mind).pack(side=tk.LEFT, padx=5) - ttk.Label(self.global_info_frame, text="子流数:").pack(side=tk.LEFT, padx=(10, 0)) - ttk.Label(self.global_info_frame, textvariable=self.latest_subflow_count).pack(side=tk.LEFT, padx=5) - - # 创建 Notebook (选项卡控件) - self.notebook = ttk.Notebook(root) - # 修改:fill 和 expand,让 notebook 填充剩余空间 - self.notebook.pack(pady=(5, 0), padx=10, fill=tk.BOTH, expand=1) # 顶部外边距改小 - - # --- 第一个选项卡:所有流 --- - self.frame_all = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_all, text="所有聊天流") - - # 状态标签 (移动到最底部) - self.status_label = tk.Label(root, text="Initializing...", anchor="w", fg="grey") - self.status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=(0, 5)) # 调整边距 - - # Matplotlib 图表设置 (用于第一个选项卡) - self.fig = Figure(figsize=(5, 4), dpi=100) - self.ax = self.fig.add_subplot(111) - # 配置在 update_plot 中进行,避免重复 - - # 创建 Tkinter 画布嵌入 Matplotlib 图表 (用于第一个选项卡) - self.canvas = FigureCanvasTkAgg(self.fig, master=self.frame_all) # <--- 放入 frame_all - self.canvas_widget = self.canvas.get_tk_widget() - self.canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=1) - - # --- 第二个选项卡:单个流 --- - self.frame_single = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_single, text="单个聊天流详情") - - # 单个流选项卡的上部控制区域 - self.control_frame_single = ttk.Frame(self.frame_single) - self.control_frame_single.pack(side=tk.TOP, fill=tk.X, pady=5) - - ttk.Label(self.control_frame_single, text="选择聊天流:").pack(side=tk.LEFT, padx=(0, 5)) - self.stream_selector = ttk.Combobox( - self.control_frame_single, textvariable=self.selected_stream_id, state="readonly", width=50 - ) - self.stream_selector.pack(side=tk.LEFT, fill=tk.X, expand=True) - self.stream_selector.bind("<>", self.on_stream_selected) - - # --- 新增:单个流详情显示区域 --- - self.single_stream_details_frame = ttk.Frame(self.frame_single, padding="5 5 5 0") - self.single_stream_details_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) - - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_sub_mind).pack(side=tk.LEFT, padx=5) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_chat_state).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_threshold).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_last_active).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_last_interaction).pack( - side=tk.LEFT, padx=5 - ) - - # Matplotlib 图表设置 (用于第二个选项卡) - self.fig_single = Figure(figsize=(5, 4), dpi=100) - # 修改:创建两个子图,一个显示兴趣度,一个显示概率 - self.ax_single_interest = self.fig_single.add_subplot(211) # 2行1列的第1个 - self.ax_single_probability = self.fig_single.add_subplot( - 212, sharex=self.ax_single_interest - ) # 2行1列的第2个,共享X轴 - - # 创建 Tkinter 画布嵌入 Matplotlib 图表 (用于第二个选项卡) - self.canvas_single = FigureCanvasTkAgg(self.fig_single, master=self.frame_single) # <--- 放入 frame_single - self.canvas_widget_single = self.canvas_single.get_tk_widget() - self.canvas_widget_single.pack(side=tk.TOP, fill=tk.BOTH, expand=1) - - # --- 新增第三个选项卡:麦麦历史想法 --- - self.frame_mind_history = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_mind_history, text="麦麦历史想法") - - # 聊天框样式的文本框(只读)+ 滚动条 - self.mind_text_scroll = tk.Scrollbar(self.frame_mind_history) - self.mind_text_scroll.pack(side=tk.RIGHT, fill=tk.Y) - self.mind_text = tk.Text( - self.frame_mind_history, - height=25, - state="disabled", - wrap="word", - font=("微软雅黑", 12), - yscrollcommand=self.mind_text_scroll.set, - ) - self.mind_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=1, padx=5, pady=5) - self.mind_text_scroll.config(command=self.mind_text.yview) - - # --- 初始化和启动刷新 --- - self.update_display() # 首次加载并开始刷新循环 - - def on_stream_selected(self, event=None): - """当 Combobox 选择改变时调用,更新单个流的图表""" - self.update_single_stream_plot() - - def load_main_mind_history(self): - """只读取包含main_mind的日志行,维护历史想法队列""" - if not os.path.exists(LOG_FILE_PATH): - return - - main_mind_entries = [] - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in f: - try: - log_entry = json.loads(line.strip()) - if "main_mind" in log_entry: - ts = log_entry.get("timestamp", 0) - main_mind_entries.append((ts, log_entry)) - except Exception: - continue - main_mind_entries.sort(key=lambda x: x[0]) - recent_entries = main_mind_entries[-MAX_QUEUE_SIZE:] - self.main_mind_history.clear() - for _ts, entry in recent_entries: - self.main_mind_history.append(entry) - if recent_entries: - self.last_main_mind_timestamp = recent_entries[-1][0] - # 首次加载时刷新 - self.refresh_mind_text() - except Exception: - pass - - def update_main_mind_history(self): - """实时监控log文件,发现新main_mind数据则更新队列和展示(仅有新数据时刷新)""" - if not os.path.exists(LOG_FILE_PATH): - return - - new_entries = [] - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in reversed(list(f)): - try: - log_entry = json.loads(line.strip()) - if "main_mind" in log_entry: - ts = log_entry.get("timestamp", 0) - if ts > self.last_main_mind_timestamp: - new_entries.append((ts, log_entry)) - else: - break - except Exception: - continue - if new_entries: - for ts, entry in sorted(new_entries): - if len(self.main_mind_history) >= MAX_QUEUE_SIZE: - self.main_mind_history.popleft() - self.main_mind_history.append(entry) - self.last_main_mind_timestamp = ts - self.refresh_mind_text() # 只有有新数据时才刷新 - except Exception: - pass - - def refresh_mind_text(self): - """刷新聊天框样式的历史想法展示""" - self.mind_text.config(state="normal") - self.mind_text.delete(1.0, tk.END) - for entry in self.main_mind_history: - ts = entry.get("timestamp", 0) - dt_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "" - main_mind = entry.get("main_mind", "") - mai_state = entry.get("mai_state", "") - subflow_count = entry.get("subflow_count", "") - msg = f"[{dt_str}] 状态:{mai_state} 子流:{subflow_count}\n{main_mind}\n\n" - self.mind_text.insert(tk.END, msg) - self.mind_text.see(tk.END) - self.mind_text.config(state="disabled") - - def load_and_update_history(self): - """从 history log 文件加载数据并更新历史记录""" - if not os.path.exists(LOG_FILE_PATH): - self.set_status(f"Error: Log file not found at {LOG_FILE_PATH}", "red") - # 如果文件不存在,不清空现有数据,以便显示最后一次成功读取的状态 - return - - # *** Reset display names each time we reload *** - new_stream_history = {} - new_stream_display_names = {} - new_probability_history = {} # <--- 重置概率历史 - # --- 新增:重置其他子流状态 --- (如果需要的话,但通常覆盖即可) - # self.stream_sub_minds = {} - # self.stream_chat_states = {} - # ... 等等 ... - - read_count = 0 - error_count = 0 - # *** Calculate the timestamp threshold for the last 30 minutes *** - current_time = time.time() - time_threshold = current_time - (15 * 60) # 30 minutes in seconds - - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in f: - read_count += 1 - try: - log_entry = json.loads(line.strip()) - timestamp = log_entry.get("timestamp") # 获取顶层时间戳 - - # *** 时间过滤 *** - if timestamp is None: - error_count += 1 - continue # 跳过没有时间戳的行 - try: - entry_timestamp = float(timestamp) - if entry_timestamp < time_threshold: - continue # 跳过时间过早的条目 - except (ValueError, TypeError): - error_count += 1 - continue # 跳过时间戳格式错误的行 - - # --- 新增:更新顶层信息 (使用最后一个有效行的数据) --- - self.latest_main_mind.set( - log_entry.get("main_mind", self.latest_main_mind.get()) - ) # 保留旧值如果缺失 - self.latest_mai_state.set(log_entry.get("mai_state", self.latest_mai_state.get())) - self.latest_subflow_count.set(log_entry.get("subflow_count", self.latest_subflow_count.get())) - - # --- 修改开始:迭代 subflows --- - subflows = log_entry.get("subflows") - if not isinstance(subflows, list): # 检查 subflows 是否存在且为列表 - error_count += 1 - continue # 跳过没有 subflows 或格式无效的行 - - for subflow_entry in subflows: - stream_id = subflow_entry.get("stream_id") - interest_level = subflow_entry.get("interest_level") - # 获取 group_name,如果不存在则回退到 stream_id - group_name = subflow_entry.get("group_name", stream_id) - # reply_probability = subflow_entry.get("reply_probability") # 获取概率值 # <-- 注释掉旧行 - start_hfc_probability = subflow_entry.get( - "start_hfc_probability" - ) # <-- 添加新行,读取新字段 - - # *** 检查必要的字段 *** - # 注意:时间戳已在顶层检查过 - if stream_id is None or interest_level is None: - # 这里可以选择记录子流错误,但暂时跳过 - continue # 跳过无效的 subflow 条目 - - # 确保 interest_level 可以转换为浮点数 - try: - interest_level_float = float(interest_level) - except (ValueError, TypeError): - continue # 跳过 interest_level 无效的 subflow - - # 如果是第一次读到这个 stream_id,则创建 deque - if stream_id not in new_stream_history: - new_stream_history[stream_id] = deque(maxlen=MAX_HISTORY_POINTS) - new_probability_history[stream_id] = deque(maxlen=MAX_HISTORY_POINTS) # 创建概率 deque - # 检查是否已有颜色,没有则分配 - if stream_id not in self.stream_colors: - self.stream_colors[stream_id] = get_random_color() - - # *** 存储此 stream_id 最新的显示名称 *** - new_stream_display_names[stream_id] = group_name - - # --- 新增:存储其他子流信息 --- - self.stream_sub_minds[stream_id] = subflow_entry.get("sub_mind", "N/A") - 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" - ) # 存储原始时间戳 - - # 添加数据点 (使用顶层时间戳) - new_stream_history[stream_id].append((entry_timestamp, interest_level_float)) - - # 添加概率数据点 (如果存在且有效) - # if reply_probability is not None: # <-- 注释掉旧判断 - if start_hfc_probability is not None: # <-- 修改判断条件 - try: - # 尝试将概率转换为浮点数 - # probability_float = float(reply_probability) # <-- 注释掉旧转换 - probability_float = float(start_hfc_probability) # <-- 使用新变量 - new_probability_history[stream_id].append((entry_timestamp, probability_float)) - except (TypeError, ValueError): - # 如果概率值无效,可以跳过或记录一个默认值,这里跳过 - pass - # --- 修改结束 --- - - except json.JSONDecodeError: - error_count += 1 - # logger.warning(f"Skipping invalid JSON line: {line.strip()}") - continue # 跳过无法解析的行 - # except (TypeError, ValueError) as e: # 这个外层 catch 可能不再需要,因为类型错误在内部处理了 - # error_count += 1 - # # logger.warning(f"Skipping line due to data type error ({e}): {line.strip()}") - # continue # 跳过数据类型错误的行 - - # 读取完成后,用新数据替换旧数据 - self.stream_history = new_stream_history - self.stream_display_names = new_stream_display_names # *** Update display names *** - self.probability_history = new_probability_history # <--- 更新概率历史 - # 清理不再存在的 stream_id 的附加信息 (可选,但保持一致性) - streams_to_remove = set(self.stream_sub_minds.keys()) - set(new_stream_history.keys()) - for sid in streams_to_remove: - self.stream_sub_minds.pop(sid, None) - self.stream_chat_states.pop(sid, None) - self.stream_threshold_status.pop(sid, None) - self.stream_last_active.pop(sid, None) - self.stream_last_interaction.pop(sid, None) - # 颜色和显示名称也应该清理,但当前逻辑是保留旧颜色 - # self.stream_colors.pop(sid, None) - status_msg = f"Data loaded at {datetime.now().strftime('%H:%M:%S')}. Lines read: {read_count}." - if error_count > 0: - status_msg += f" Skipped {error_count} invalid lines." - self.set_status(status_msg, "orange") - else: - self.set_status(status_msg, "green") - - except IOError as e: - self.set_status(f"Error reading file {LOG_FILE_PATH}: {e}", "red") - except Exception as e: - self.set_status(f"An unexpected error occurred during loading: {e}", "red") - - # --- 更新 Combobox --- - self.update_stream_selector() - - def update_stream_selector(self): - """更新单个流选项卡中的 Combobox 列表""" - # 创建 (display_name, stream_id) 对的列表,按 display_name 排序 - available_streams = sorted( - [ - (name, sid) - for sid, name in self.stream_display_names.items() - if sid in self.stream_history and self.stream_history[sid] - ], - key=lambda item: item[0], # 按显示名称排序 - ) - - # 更新 Combobox 的值 (仅显示 display_name) - self.stream_selector["values"] = [name for name, sid in available_streams] - - # 检查当前选中的 stream_id 是否仍然有效 - current_selection_name = self.selected_stream_id.get() - current_selection_valid = any(name == current_selection_name for name, sid in available_streams) - - if not current_selection_valid and available_streams: - # 如果当前选择无效,并且有可选流,则默认选中第一个 - self.selected_stream_id.set(available_streams[0][0]) - # 手动触发一次更新,因为 set 不会触发 <> - self.update_single_stream_plot() - elif not available_streams: - # 如果没有可选流,清空选择 - self.selected_stream_id.set("") - self.update_single_stream_plot() # 清空图表 - - def update_all_streams_plot(self): - """更新第一个选项卡的 Matplotlib 图表 (显示所有流)""" - self.ax.clear() # 清除旧图 - # *** 设置中文标题和标签 *** - self.ax.set_title("兴趣度随时间变化图 (所有活跃流)") - self.ax.set_xlabel("时间") - self.ax.set_ylabel("兴趣度") - self.ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - self.ax.grid(True) - self.ax.set_ylim(0, 10) # 固定 Y 轴范围 0-10 - - # 只绘制最新的 N 个 stream (按最后记录的兴趣度排序) - # 注意:现在是基于文件读取的快照排序,可能不是实时最新 - active_streams = sorted( - self.stream_history.items(), - key=lambda item: item[1][-1][1] if item[1] else 0, # 按最后兴趣度排序 - reverse=True, - )[:MAX_STREAMS_TO_DISPLAY] - - all_times = [] # 用于确定 X 轴范围 - - for stream_id, history in active_streams: - if not history: - continue - - timestamps, interests = zip(*history) - # 将 time.time() 时间戳转换为 matplotlib 可识别的日期格式 - try: - mpl_dates = [datetime.fromtimestamp(ts) for ts in timestamps] - all_times.extend(mpl_dates) # 收集所有时间点 - - # *** Use display name for label *** - display_label = self.stream_display_names.get(stream_id, stream_id) - - self.ax.plot( - mpl_dates, - interests, - label=display_label, # *** Use display_label *** - color=self.stream_colors.get(stream_id, "grey"), - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping plot for {stream_id} due to invalid timestamp: {e}") - continue - - if all_times: - # 根据数据动态调整 X 轴范围,留一点边距 - min_time = min(all_times) - max_time = max(all_times) - # delta = max_time - min_time - # self.ax.set_xlim(min_time - delta * 0.05, max_time + delta * 0.05) - self.ax.set_xlim(min_time, max_time) - - # 自动格式化X轴标签 - self.fig.autofmt_xdate() - else: - # 如果没有数据,设置一个默认的时间范围,例如最近一小时 - now = datetime.now() - one_hour_ago = now - timedelta(hours=1) - self.ax.set_xlim(one_hour_ago, now) - - # 添加图例 - if active_streams: - # 调整图例位置和大小 - # 字体已通过全局 matplotlib.rcParams 设置 - self.ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1), borderaxespad=0.0, fontsize="x-small") - # 调整布局,确保图例不被裁剪 - self.fig.tight_layout(rect=[0, 0, 0.85, 1]) # 右侧留出空间给图例 - - self.canvas.draw() # 重绘画布 - - def update_single_stream_plot(self): - """更新第二个选项卡的 Matplotlib 图表 (显示单个选定的流)""" - self.ax_single_interest.clear() - self.ax_single_probability.clear() - - # 设置子图标题和标签 - self.ax_single_interest.set_title("兴趣度") - self.ax_single_interest.set_ylim(0, 10) # 固定 Y 轴范围 0-10 - - # self.ax_single_probability.set_title("回复评估概率") # <-- 注释掉旧标题 - self.ax_single_probability.set_title("HFC 启动概率") # <-- 修改标题 - self.ax_single_probability.set_xlabel("时间") - # self.ax_single_probability.set_ylabel("概率") # <-- 注释掉旧标签 - self.ax_single_probability.set_ylabel("HFC 概率") # <-- 修改 Y 轴标签 - self.ax_single_probability.grid(True) - self.ax_single_probability.set_ylim(0, 1.05) # 固定 Y 轴范围 0-1 - self.ax_single_probability.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - - selected_name = self.selected_stream_id.get() - selected_sid = None - - # --- 新增:根据选中的名称找到 stream_id --- - if selected_name: - for sid, name in self.stream_display_names.items(): - if name == selected_name: - selected_sid = sid - break - - all_times = [] # 用于确定 X 轴范围 - - # --- 新增:绘制兴趣度图 --- - if selected_sid and selected_sid in self.stream_history and self.stream_history[selected_sid]: - history = self.stream_history[selected_sid] - timestamps, interests = zip(*history) - try: - mpl_dates = [datetime.fromtimestamp(ts) for ts in timestamps] - all_times.extend(mpl_dates) - self.ax_single_interest.plot( - mpl_dates, - interests, - color=self.stream_colors.get(selected_sid, "blue"), - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping interest plot for {selected_sid} due to invalid timestamp: {e}") - - # --- 新增:绘制概率图 --- - if selected_sid and selected_sid in self.probability_history and self.probability_history[selected_sid]: - prob_history = self.probability_history[selected_sid] - prob_timestamps, probabilities = zip(*prob_history) - try: - prob_mpl_dates = [datetime.fromtimestamp(ts) for ts in prob_timestamps] - # 注意:概率图的时间点可能与兴趣度不同,也需要加入 all_times - all_times.extend(prob_mpl_dates) - self.ax_single_probability.plot( - prob_mpl_dates, - probabilities, - color=self.stream_colors.get(selected_sid, "green"), # 可以用不同颜色 - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping probability plot for {selected_sid} due to invalid timestamp: {e}") - - # --- 新增:调整 X 轴范围和格式 --- - if all_times: - min_time = min(all_times) - max_time = max(all_times) - # 设置共享的 X 轴范围 - self.ax_single_interest.set_xlim(min_time, max_time) - # self.ax_single_probability.set_xlim(min_time, max_time) # sharex 会自动同步 - # 自动格式化X轴标签 (应用到共享轴的最后一个子图上通常即可) - self.fig_single.autofmt_xdate() - else: - # 如果没有数据,设置一个默认的时间范围 - now = datetime.now() - one_hour_ago = now - timedelta(hours=1) - self.ax_single_interest.set_xlim(one_hour_ago, now) - # self.ax_single_probability.set_xlim(one_hour_ago, now) # sharex 会自动同步 - - # --- 新增:更新单个流的详细信息标签 --- - self.update_single_stream_details(selected_sid) - - # --- 新增:重新绘制画布 --- - self.canvas_single.draw() - - def update_single_stream_details(self, stream_id): - """更新单个流详情区域的标签内容""" - if stream_id: - 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_interaction_ts = self.stream_last_interaction.get(stream_id) - - self.single_stream_sub_mind.set(f"想法: {sub_mind}") - self.single_stream_chat_state.set(f"状态: {chat_state}") - self.single_stream_threshold.set(f"阈值以上: {'是' if threshold else '否'}") - self.single_stream_last_active.set(f"最后活跃: {format_timestamp(last_active_ts)}") - self.single_stream_last_interaction.set(f"最后交互: {format_timestamp(last_interaction_ts)}") - else: - # 如果没有选择流,则清空详情 - self.single_stream_sub_mind.set("想法: N/A") - self.single_stream_chat_state.set("状态: N/A") - self.single_stream_threshold.set("阈值: N/A") - self.single_stream_last_active.set("活跃: N/A") - self.single_stream_last_interaction.set("交互: N/A") - - def update_display(self): - """主更新循环""" - try: - # --- 新增:首次加载历史想法 --- - if not hasattr(self, "_main_mind_loaded"): - self.load_main_mind_history() - self._main_mind_loaded = True - else: - self.update_main_mind_history() # 只有有新main_mind数据时才刷新界面 - # *** 修改:分别调用两个图表的更新方法 *** - self.load_and_update_history() # 从文件加载数据并更新内部状态 - self.update_all_streams_plot() # 更新所有流的图表 - self.update_single_stream_plot() # 更新单个流的图表 - except Exception as e: - # 提供更详细的错误信息 - import traceback - - error_msg = f"Error during update: {e}\n{traceback.format_exc()}" - self.set_status(error_msg, "red") - print(error_msg) # 打印详细错误到控制台 - - # 安排下一次刷新 - self.root.after(REFRESH_INTERVAL_MS, self.update_display) - - def set_status(self, message: str, color: str = "grey"): - """更新状态栏标签""" - # 限制状态栏消息长度 - max_len = 150 - display_message = (message[:max_len] + "...") if len(message) > max_len else message - self.status_label.config(text=display_message, fg=color) - - -if __name__ == "__main__": - # 导入 timedelta 用于默认时间范围 - from datetime import timedelta - - root = tk.Tk() - app = InterestMonitorApp(root) - root.mainloop() diff --git a/src/MaiGoi/assets/button_shape.png b/src/MaiGoi/assets/button_shape.png deleted file mode 100644 index 67f0f9adc..000000000 Binary files a/src/MaiGoi/assets/button_shape.png and /dev/null differ diff --git a/src/MaiGoi/assets/icon.png b/src/MaiGoi/assets/icon.png deleted file mode 100644 index 90db90aca..000000000 Binary files a/src/MaiGoi/assets/icon.png and /dev/null differ diff --git a/src/MaiGoi/assets/lihui.png b/src/MaiGoi/assets/lihui.png deleted file mode 100644 index 05430d69f..000000000 Binary files a/src/MaiGoi/assets/lihui.png and /dev/null differ diff --git a/src/MaiGoi/assets/lihui_bhl.png b/src/MaiGoi/assets/lihui_bhl.png deleted file mode 100644 index be05982e8..000000000 Binary files a/src/MaiGoi/assets/lihui_bhl.png and /dev/null differ diff --git a/src/MaiGoi/color_parser.py b/src/MaiGoi/color_parser.py deleted file mode 100644 index 305150d45..000000000 --- a/src/MaiGoi/color_parser.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -Parses log lines containing ANSI escape codes or Loguru-style color tags -into a list of Flet TextSpan objects for colored output. -""" - -import re -import flet as ft - -# Basic ANSI SGR (Select Graphic Rendition) codes mapping -# See: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters -# Focusing on common foreground colors and styles used by Loguru -ANSI_CODES = { - # Styles - "1": ft.FontWeight.BOLD, - "3": ft.TextStyle(italic=True), # Italic - "4": ft.TextStyle(decoration=ft.TextDecoration.UNDERLINE), # Underline - "22": ft.FontWeight.NORMAL, # Reset bold - "23": ft.TextStyle(italic=False), # Reset italic - "24": ft.TextStyle(decoration=ft.TextDecoration.NONE), # Reset underline - # Foreground Colors (30-37) - "30": ft.colors.BLACK, - "31": ft.colors.RED, - "32": ft.colors.GREEN, - "33": ft.colors.YELLOW, - "34": ft.colors.BLUE, - "35": ft.colors.PINK, - "36": ft.colors.CYAN, - "37": ft.colors.WHITE, - "39": None, # Default foreground color - # Bright Foreground Colors (90-97) - "90": ft.colors.with_opacity(0.7, ft.colors.BLACK), # Often rendered as gray - "91": ft.colors.RED_ACCENT, # Or RED_400 / LIGHT_RED - "92": ft.colors.LIGHT_GREEN, # Or GREEN_ACCENT - "93": ft.colors.YELLOW_ACCENT, # Or LIGHT_YELLOW - "94": ft.colors.LIGHT_BLUE, # Or BLUE_ACCENT - "95": ft.colors.PINK, # ANSI bright magenta maps well to Flet's PINK - "96": ft.colors.CYAN_ACCENT, - "97": ft.colors.WHITE70, # Brighter white -} - -# Loguru simple tags mapping (add more as needed from your logger.py) -# Using lowercase for matching -LOGURU_TAGS = { - "red": ft.colors.RED, - "green": ft.colors.GREEN, - "yellow": ft.colors.YELLOW, - "blue": ft.colors.BLUE, - "magenta": ft.colors.PINK, - "cyan": ft.colors.CYAN, - "white": ft.colors.WHITE, - "light-yellow": ft.colors.YELLOW_ACCENT, # Or specific yellow shade - "light-green": ft.colors.LIGHT_GREEN, - "light-magenta": ft.colors.PINK, # Or specific magenta shade - "light-cyan": ft.colors.CYAN_ACCENT, # Or specific cyan shade - "light-blue": ft.colors.LIGHT_BLUE, - "fg #ffd700": "#FFD700", # Handle specific hex colors like emoji - "fg #3399ff": "#3399FF", # Handle specific hex colors like emoji - "fg #66ccff": "#66CCFF", - "fg #005ba2": "#005BA2", - "fg #7cffe6": "#7CFFE6", # 海马体 - "fg #37ffb4": "#37FFB4", # LPMM - "fg #00788a": "#00788A", # 远程 - "fg #3fc1c9": "#3FC1C9", # Tools - # Add other colors used in your logger.py simple formats -} - -# Regex to find ANSI codes (basic SGR, true-color fg) OR Loguru tags -# Added specific capture for 38;2;r;g;b -ANSI_COLOR_REGEX = re.compile( - r"(\x1b\[(?:(?:(?:3[0-7]|9[0-7]|1|3|4|22|23|24);?)+|39|0)m)" # Group 1: Basic SGR codes (like 31, 1;32, 0, 39) - r"|" - r"(\x1b\[38;2;(\d{1,3});(\d{1,3});(\d{1,3})m)" # Group 2: Truecolor FG ( captures full code, Grp 3: R, Grp 4: G, Grp 5: B ) - # r"|(\x1b\[48;2;...m)" # Placeholder for Truecolor BG if needed later - r"|" - r"(<(/?)([^>]+)?>)" # Group 6: Loguru tags ( Grp 7: slash, Grp 8: content ) -) - - -def parse_log_line_to_spans(line: str) -> list[ft.TextSpan]: - """ - Parses a log line potentially containing ANSI codes OR Loguru tags - into a list of Flet TextSpan objects. - Uses a style stack for basic nesting. - """ - spans = [] - current_pos = 0 - # Stack holds TextStyle objects. Base style is default. - style_stack = [ft.TextStyle()] - - for match in ANSI_COLOR_REGEX.finditer(line): - start, end = match.span() - basic_ansi_code = match.group(1) - truecolor_ansi_code = match.group(2) - tc_r, tc_g, tc_b = match.group(3), match.group(4), match.group(5) - loguru_full_tag = match.group(6) - loguru_closing_slash = match.group(7) - loguru_tag_content = match.group(8) - - current_style = style_stack[-1] - - if start > current_pos: - spans.append(ft.TextSpan(line[current_pos:start], current_style)) - - if basic_ansi_code: - # --- Handle Basic ANSI --- - params = basic_ansi_code[2:-1] - if not params or params == "0": # Reset code - style_stack = [ft.TextStyle()] # Reset stack - else: - temp_style_dict = { - k: getattr(current_style, k, None) for k in ["color", "weight", "italic", "decoration"] - } - codes = params.split(";") - for code in filter(None, codes): - style_attr = ANSI_CODES.get(code) - if isinstance(style_attr, str): - temp_style_dict["color"] = style_attr - elif isinstance(style_attr, ft.FontWeight): - temp_style_dict["weight"] = None if code == "22" else style_attr - elif isinstance(style_attr, ft.TextStyle): - if style_attr.italic is not None: - temp_style_dict["italic"] = False if code == "23" else style_attr.italic - if style_attr.decoration is not None: - temp_style_dict["decoration"] = ( - ft.TextDecoration.NONE if code == "24" else style_attr.decoration - ) - elif style_attr is None and code == "39": - temp_style_dict["color"] = None - style_stack[-1] = ft.TextStyle(**{k: v for k, v in temp_style_dict.items() if v is not None}) - - elif truecolor_ansi_code: - # --- Handle Truecolor ANSI --- - try: - r, g, b = int(tc_r), int(tc_g), int(tc_b) - hex_color = f"#{r:02x}{g:02x}{b:02x}" - # print(f"--- TrueColor Debug: Parsed RGB ({r},{g},{b}) -> {hex_color} ---") - # Update color in the current style on stack top - temp_style_dict = { - k: getattr(current_style, k, None) for k in ["color", "weight", "italic", "decoration"] - } - temp_style_dict["color"] = hex_color - style_stack[-1] = ft.TextStyle(**{k: v for k, v in temp_style_dict.items() if v is not None}) - except (ValueError, TypeError) as e: - print(f"Error parsing truecolor ANSI: {e}, Code: {truecolor_ansi_code}") - # Keep current style if parsing fails - - elif loguru_full_tag: - if loguru_closing_slash: - if len(style_stack) > 1: - style_stack.pop() - # print(f"--- Loguru Debug: Closing Tag processed. Stack size: {len(style_stack)} ---") - elif loguru_tag_content: # Opening tag - tag_lower = loguru_tag_content.lower() - style_attr = LOGURU_TAGS.get(tag_lower) - - # print(f"--- Loguru Debug: Opening Tag --- ") - # print(f" Raw Content : {repr(loguru_tag_content)}") - # print(f" Lowercase Key: {repr(tag_lower)}") - # print(f" Found Attr : {repr(style_attr)} --- ") - - temp_style_dict = { - k: getattr(current_style, k, None) for k in ["color", "weight", "italic", "decoration"] - } - - if style_attr: - if isinstance(style_attr, str): - temp_style_dict["color"] = style_attr - # print(f" Applied Color: {style_attr}") - # ... (handle other style types if needed) - - # Push the new style only if the tag was recognized and resulted in a change - # (or check if style_attr is not None) - new_style = ft.TextStyle(**{k: v for k, v in temp_style_dict.items() if v is not None}) - # Avoid pushing identical style - if new_style != current_style: - style_stack.append(new_style) - # print(f" Pushed Style. Stack size: {len(style_stack)}") - # else: - # print(f" Style unchanged, stack not pushed.") - # else: - # print(f" Tag NOT FOUND in LOGURU_TAGS.") - # else: Invalid tag format? - - current_pos = end - - # Add any remaining text after the last match - final_style = style_stack[-1] - if current_pos < len(line): - spans.append(ft.TextSpan(line[current_pos:], final_style)) - - return [span for span in spans if span.text] - - -if __name__ == "__main__": - # Example Usage & Testing - test_lines = [ - "This is normal text.", - "\\x1b[31mThis is red text.\\x1b[0m And back to normal.", - "\\x1b[1;32mThis is bold green.\\x1b[0m", - "Text with red tag inside.", - "Nested yellow bold yellow.", # Bold tag not handled yet - "Light green message", - "Emoji color", - "\\x1b[94mBright Blue ANSI\\x1b[0m", - "\\x1b[3mItalic ANSI\\x1b[0m", - # Example from user image (simplified) - "\\x1b[37m2025-05-03 23:00:44\\x1b[0m | \\x1b[1mINFO\\x1b[0m | \\x1b[96m配置\\x1b[0m | \\x1b[1m成功加载配置文件: ...\\x1b[0m", - "\\x1b[1mDEBUG\\x1b[0m | \\x1b[94m人物信息\\x1b[0m | \\x1b[1m已加载 81 个用户名\\x1b[0m", - "TIME | 模块 | 消息", # Loguru format string itself - ] - - # Simple print test (won't show colors in standard terminal) - for t_line in test_lines: - print(f"--- Input: {repr(t_line)} ---") - parsed_spans = parse_log_line_to_spans(t_line) - print("Parsed Spans:") - for s in parsed_spans: - print( - f" Text: {repr(s.text)}, Style: color={s.style.color}, weight={s.style.weight}, italic={s.style.italic}, decoration={s.style.decoration}" - ) - print("-" * 20) - - # To visually test with Flet, you'd run this in a simple Flet app: - # import flet as ft - # def main(page: ft.Page): - # page.add(ft.Column([ - # ft.Text(spans=parse_log_line_to_spans(line)) for line in test_lines - # ])) - # ft.app(target=main) diff --git a/src/MaiGoi/config_manager.py b/src/MaiGoi/config_manager.py deleted file mode 100644 index 1552bde61..000000000 --- a/src/MaiGoi/config_manager.py +++ /dev/null @@ -1,100 +0,0 @@ -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, Optional - -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(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(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) - 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(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(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 {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_type} config: {e}.") - import traceback - - traceback.print_exc() - return default_config_to_use.copy() - - -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: - # 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_type} config file (IOError): {e}") - except Exception as e: - print(f"[Config] An unexpected error occurred saving {config_type} config: {e}") - import traceback - - traceback.print_exc() - return False diff --git a/src/MaiGoi/flet_interest_monitor.py b/src/MaiGoi/flet_interest_monitor.py deleted file mode 100644 index 255894d39..000000000 --- a/src/MaiGoi/flet_interest_monitor.py +++ /dev/null @@ -1,1132 +0,0 @@ -import flet as ft -import asyncio -import os -import json -import time -import traceback -import random -import httpx -from datetime import datetime - -# --- 配置 (可以从 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 "无" - try: - # 假设 ts 是 float 类型的时间戳 - dt_object = datetime.fromtimestamp(float(ts)) - return dt_object.strftime("%Y-%m-%d %H:%M:%S") - except (ValueError, TypeError): - return "Invalid Time" - - -def get_random_flet_color(): - """生成一个随机的 Flet 颜色字符串。""" - r = random.randint(50, 200) - g = random.randint(50, 200) - b = random.randint(50, 200) - 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 控件,用于显示兴趣监控图表和信息。""" - - def __init__(self): - super().__init__( - expand=True, - ) - # --- 状态变量 --- - self.log_reader_task = None - self.stream_history = {} # {stream_id: deque([(ts, interest), ...])} - self.probability_history = {} # {stream_id: deque([(ts, probability), ...])} - self.stream_display_names = {} # {stream_id: display_name} - 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 | 活跃聊天数: 0", size=10, width=300) - # 子流最新状态 (key: stream_id) - self.stream_sub_minds = {} - self.stream_chat_states = {} - self.stream_threshold_status = {} - self.stream_last_active = {} - - # --- UI 控件引用 --- - self.status_text = ft.Text("正在初始化监控器...", size=10, color=ft.colors.SECONDARY) - - # --- 全局信息 Row --- - self.global_info_row = ft.Row( - controls=[ - self.global_mai_state_text, - ], - spacing=15, - wrap=False, # 防止换行 - ) - - # --- 图表控件 --- - self.main_chart = ft.LineChart(height=CHART_HEIGHT, expand=True) - # --- 新增:图例 Column --- - self.legend_column = ft.Column( - controls=[], - width=150, # 给图例固定宽度 - scroll=ft.ScrollMode.ADAPTIVE, # 如果图例过多则滚动 - spacing=2, - ) - - self.stream_dropdown = ft.Dropdown( - 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, - ) - - # --- 单个流详情文本控件 (Column) --- - self.detail_texts = ft.Column( - [ - # --- 合并所有详情为一行 --- - ft.Text( - "状态: 无 | 最后活跃: 无", - size=20, - no_wrap=True, - overflow=ft.TextOverflow.ELLIPSIS, - tooltip="查看详细状态信息", - ), - ], - spacing=2, - ) - - # --- 新增:切换显示按钮 --- - 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 控件 - self.tabs_control = ft.Tabs( - selected_index=0, - animation_duration=300, - tabs=[ - ft.Tab( - text="所有流兴趣度", - content=ft.Column( - controls=[ - self.global_info_row, # 将全局信息行移动到这里 - ft.Row( - controls=[ - self.main_chart, # 图表在左侧 - self.legend_column, # 图例在右侧 - ], - vertical_alignment=ft.CrossAxisAlignment.START, - expand=True, # 让 Row 扩展 - ), - ], - ), - ), - ft.Tab( - text="单个流详情", - content=ft.Column( - [ - # 添加顶部间距,防止被标签遮挡 - ft.Container(height=10), - # --- 修改:流选择、状态设置和详情文本放在同一行 --- - ft.Row( - [ - 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, # 自适应滚动 - ), - ), - ], - expand=True, # 让 Tabs 在父 Column 中扩展 - ) - - # 主要内容区域(可隐藏部分) - self.content_area = ft.Column( - [ - self.tabs_control, # 标签页 - ], - expand=True, - ) - - self.controls = [ - 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: - # --- 首次加载历史想法 (可以在这里或 log_reader_loop 首次运行时加载) --- - # self.page.run_task(self.load_and_process_log, initial_load=True) # 传递标志? - self.log_reader_task = self.page.run_task(self.log_reader_loop) - # self.page.run_task(self.update_charts) # update_charts 会在 loop 中调用 - else: - print("[InterestMonitor] 错误: 无法访问 self.page 来启动后台任务") - - def will_unmount(self): - print("[InterestMonitor] 控件将卸载,取消日志读取任务") - if self.log_reader_task: - self.log_reader_task.cancel() - print("[InterestMonitor] 日志读取任务已取消 (will_unmount)") - - async def log_reader_loop(self): - while True: - try: - await self.load_and_process_log() - await self.update_charts() - except asyncio.CancelledError: - print("[InterestMonitor] 日志读取循环被取消") - break - except Exception as e: - print(f"[InterestMonitor] 日志读取循环出错: {e}") - traceback.print_exc() - self.update_status(f"日志读取错误: {e}", ft.colors.ERROR) - - await asyncio.sleep(REFRESH_INTERVAL_SECONDS) - - async def load_and_process_log(self): - """读取并处理日志文件的新增内容。""" - if not os.path.exists(LOG_FILE_PATH): - 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 - - print(f"[InterestMonitor] 检测到日志文件更新 (修改时间: {file_mod_time}), 正在读取...", flush=True) - - new_stream_history = {} - new_probability_history = {} - new_stream_display_names = {} - # 清理旧的子流状态,因为每次都重新读取文件 - self.stream_sub_minds.clear() - self.stream_chat_states.clear() - self.stream_threshold_status.clear() - self.stream_last_active.clear() - - read_count = 0 - error_count = 0 - - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in f: - read_count += 1 - try: - log_entry = json.loads(line.strip()) - if not isinstance(log_entry, dict): - continue - - entry_timestamp = log_entry.get("timestamp") - if entry_timestamp is None: - continue - - # --- 处理主兴趣流 --- # - stream_id = log_entry.get("stream_id") - interest = log_entry.get("interest") - probability = log_entry.get("probability") # 新增:获取概率 - - if stream_id is not None and interest is not None: - try: - interest_float = float(interest) - if stream_id not in new_stream_history: - new_stream_history[stream_id] = [] - # 避免重复添加相同时间戳的数据点 - if ( - not new_stream_history[stream_id] - or new_stream_history[stream_id][-1][0] < entry_timestamp - ): - new_stream_history[stream_id].append((entry_timestamp, interest_float)) - except (ValueError, TypeError): - pass # 忽略无法转换的值 - - # --- 处理概率 --- # - if stream_id is not None and probability is not None: - try: - prob_float = float(probability) - if stream_id not in new_probability_history: - new_probability_history[stream_id] = [] - if ( - not new_probability_history[stream_id] - or new_probability_history[stream_id][-1][0] < entry_timestamp - ): - new_probability_history[stream_id].append((entry_timestamp, prob_float)) - except (ValueError, TypeError): - pass # 忽略无法转换的值 - - # --- 处理子流 (subflows) --- # - subflows = log_entry.get("subflows") - if not isinstance(subflows, list): - continue - - for subflow_entry in subflows: - stream_id = subflow_entry.get("stream_id") - # 兼容两种字段名 - interest = subflow_entry.get("interest", subflow_entry.get("interest_level")) - group_name = subflow_entry.get("group_name", stream_id) - # 兼容两种概率字段名 - probability = subflow_entry.get("probability", subflow_entry.get("start_hfc_probability")) - - if stream_id is None or interest is None: - continue - try: - interest_float = float(interest) - except (ValueError, TypeError): - continue - - if stream_id not in new_stream_history: - new_stream_history[stream_id] = [] - self.stream_details[stream_id] = {"group_name": group_name} # 存储详情 - # 避免重复添加相同时间戳的数据点 - if ( - not new_stream_history[stream_id] - or new_stream_history[stream_id][-1][0] < entry_timestamp - ): - new_stream_history[stream_id].append((entry_timestamp, interest_float)) - - # --- 处理子流概率 --- # - if probability is not None: - try: - prob_float = float(probability) - if stream_id not in new_probability_history: - new_probability_history[stream_id] = [] - if ( - not new_probability_history[stream_id] - or new_probability_history[stream_id][-1][0] < entry_timestamp - ): - new_probability_history[stream_id].append((entry_timestamp, prob_float)) - except (ValueError, TypeError): - pass # 忽略无法转换的值 - - # --- 存储其他子流详情 (最新的会覆盖旧的) --- - self.stream_sub_minds[stream_id] = subflow_entry.get("sub_mind", "N/A") - 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") - - except json.JSONDecodeError: - error_count += 1 - continue - except Exception as line_err: - print(f"处理日志行时出错: {line_err}") # 打印行级错误 - error_count += 1 - continue - - # 更新状态 - self.stream_history = new_stream_history - self.probability_history = new_probability_history - self.stream_display_names = new_stream_display_names - self.last_log_read_time = file_mod_time - - status_msg = f"日志读取于 {datetime.now().strftime('%H:%M:%S')}. 行数: {read_count}." - if error_count > 0: - status_msg += f" 跳过 {error_count} 无效行." - self.update_status(status_msg, ft.colors.ORANGE) - else: - self.update_status(status_msg, ft.colors.GREEN) - - # 更新全局信息控件 (如果 page 存在) - if self.page: - # 更新全局状态信息 - if log_entry: # Check if log_entry was populated - mai_state = log_entry.get("mai_state", "N/A") - # subflow_count = log_entry.get('subflow_count', '0') - - # 获取当前时间和行数信息,格式化为状态信息的一部分 - current_time = datetime.now().strftime("%H:%M:%S") - status_info = f"读取于{current_time}" - if error_count > 0: - status_info += f" (跳过 {error_count} 行)" - - # 将所有信息合并到一行显示 - 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() - - except IOError as e: - print(f"读取日志文件时发生 IO 错误: {e}") - self.update_status(f"日志 IO 错误: {e}", ft.colors.ERROR) - except Exception as e: - print(f"处理日志时发生意外错误: {e}") - traceback.print_exc() - self.update_status(f"处理日志时出错: {e}", ft.colors.ERROR) - - 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: - if not history: - continue - try: - mpl_dates = [ts for ts, _ in history] - interests = [interest for _, interest in history] - 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( - data_points=data_points, - color=self.stream_colors.get(stream_id, ft.colors.BLACK), - stroke_width=2, - ) - ) - # --- 创建图例项 --- - legend_color = self.stream_colors.get(stream_id, ft.colors.BLACK) - display_name = self.stream_display_names.get(stream_id, stream_id) - legend_items.append( - ft.Row( - controls=[ - ft.Container(width=10, height=10, bgcolor=legend_color, border_radius=2), - ft.Text(display_name, size=10, overflow=ft.TextOverflow.ELLIPSIS), - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - ) - ) - except Exception as plot_err: - print(f"绘制主图表/图例时跳过 Stream {stream_id}: {plot_err}") - traceback.print_exc() # 添加完整的错误堆栈 - continue - - # --- 更新主图表 --- - self.main_chart.data_series = all_series - self.main_chart.min_y = 0 - self.main_chart.max_y = 10 - self.main_chart.min_x = min_ts - self.main_chart.max_x = max_ts - - # --- 更新图例 --- - self.legend_column.controls = legend_items - - # 只有在选择了流的情况下更新详情图表 - if self.selected_stream_id_for_details: - await self.update_detail_charts(self.selected_stream_id_for_details) - else: - print("[InterestMonitor] 未选择流,跳过详情图表更新") - - if self.page: - # 更新整个控件,包含图表和图例的更新 - self.update() - else: - print("[InterestMonitor] 警告: self.page 为 None,无法更新图表 UI") - - async def update_detail_charts(self, stream_id): - 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]}) - try: - mpl_dates = [ts for ts, _ in self.stream_history[stream_id]] - interests = [interest for _, interest in self.stream_history[stream_id]] - if mpl_dates: - interest_data_points = [ - ft.LineChartDataPoint(x=ts, y=interest) for ts, interest in zip(mpl_dates, interests) - ] - combined_series.append( - ft.LineChartData( - data_points=interest_data_points, - color=self.stream_colors.get(stream_id, ft.colors.BLUE), - stroke_width=2, - ) - ) - except Exception as plot_err: - print(f"绘制详情兴趣图时出错 Stream {stream_id}: {plot_err}") - - # --- 概率图 --- - if stream_id and stream_id in self.probability_history and self.probability_history[stream_id]: - try: - prob_dates = [ts for ts, _ in self.probability_history[stream_id]] - probabilities = [prob for _, prob in self.probability_history[stream_id]] - if prob_dates: - if min_ts_detail is None: # 如果兴趣图没有数据,单独计算时间范围 - min_ts_detail, max_ts_detail = self.get_time_range( - {stream_id: self.probability_history[stream_id]}, is_prob=True - ) - else: # 合并时间范围 - min_prob_ts, max_prob_ts = self.get_time_range( - {stream_id: self.probability_history[stream_id]}, is_prob=True - ) - if min_prob_ts is not None: - min_ts_detail = min(min_ts_detail, min_prob_ts) - 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, scaled_probabilities) - ] - combined_series.append( - ft.LineChartData( - data_points=probability_data_points, - color=ft.colors.GREEN, - stroke_width=2, - ) - ) - except Exception as plot_err: - print(f"绘制详情概率图时出错 Stream {stream_id}: {plot_err}") - - # 更新合并图表 - 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) - - async def update_dropdown_options(self): - current_value = self.stream_dropdown.value - options = [] - valid_stream_ids = set() - - # 调试信息 - 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]: - 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 - - # 如果当前值无效,选择第一个选项或清空 - 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: - 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) - - # 确保按钮状态正确 - 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) < 1: - print("[InterestMonitor] 错误:detail_texts 未正确初始化或控件不足") - return - - 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") - last_active_ts = self.stream_last_active.get(stream_id) - last_active_str = format_timestamp(last_active_ts) - - # 合并详情为一行 - 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 = "状态: 无 | 最后活跃: 无" - self.detail_texts.controls[0].tooltip = "暂无详细信息" - - if self.page and self.detail_texts.page: # 确保控件已挂载再更新 - self.detail_texts.update() - - def update_status(self, message: str, color: str = ft.colors.SECONDARY): - max_len = 150 - display_message = (message[:max_len] + "...") if len(message) > max_len else message - - # 保留当前状态信息的一部分(如果存在) - if "|" in self.global_mai_state_text.value: - status_part = self.global_mai_state_text.value.split("|")[1].strip() - self.status_text.value = f"{display_message} | {status_part}" - else: - self.status_text.value = display_message - - self.status_text.color = color - if self.page and self.status_text.page: - self.status_text.update() - - def get_time_range(self, history_dict, is_prob=False): - """获取所有数据点的时间范围,确保即使没有数据也能返回有效的时间范围""" - all_ts = [] - target_history_key = self.probability_history if is_prob else self.stream_history - - try: - for stream_id, _history in history_dict.items(): - # 使用正确的历史记录字典 - actual_history = target_history_key.get(stream_id) - 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() - print(f"[InterestMonitor] 获取时间范围时出错: {e}") - traceback.print_exc() - return now - 3600, now + 60 - - def send_gui_command_file(self, subflow_id, target_state): - """使用文件方式发送命令(备用方法)""" - try: - # 确保目录存在 - command_dir = os.path.dirname(GUI_COMMAND_PATH) - if command_dir: # 如果有目录部分 - os.makedirs(command_dir, exist_ok=True) - - # 创建命令数据 - command_data = { - "subflow_id": subflow_id, - "target_state": target_state, # 不转为大写,保留原始状态值 - } - - # 写入文件 - with open(GUI_COMMAND_PATH, "w", encoding="utf-8") as f: - json.dump(command_data, f, ensure_ascii=False, indent=2) - - print(f"[InterestMonitor] 已通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}") - if self.page: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}"), - show_close_icon=True, - bgcolor=ft.colors.ORANGE_200, # 使用不同颜色表示使用了备用方式 - ) - self.page.snack_bar.open = True - self.page.update() - return True - except Exception as e: - print(f"[InterestMonitor] 发送GUI命令文件出错: {e}") - traceback.print_exc() - return False - - -# --- 测试部分保持不变 --- -if __name__ == "__main__": - # ... (创建测试日志文件代码不变) ... - if not os.path.exists("logs/interest"): - os.makedirs("logs/interest") - test_log_path = LOG_FILE_PATH - with open(test_log_path, "w", encoding="utf-8") as f: - # ... (写入测试数据不变) ... - ts = time.time() - f.write( - json.dumps( - { - "timestamp": ts - 60, - "mai_state": "Idle", - "main_mind": "Start", - "subflow_count": 2, - "subflows": [ - { - "stream_id": "user1", - "group_name": "用户A", - "interest_level": 5, - "start_hfc_probability": 0.1, - "sub_mind": "Thinking about A", - "sub_chat_state": "Active", - "is_above_threshold": False, - "chat_state_changed_time": ts - 65, - }, - { - "stream_id": "user2", - "group_name": "用户B", - "interest_level": 3, - "start_hfc_probability": 0.05, - "sub_mind": "Thinking about B", - "sub_chat_state": "Idle", - "is_above_threshold": False, - "chat_state_changed_time": ts - 70, - }, - ], - } - ) - + "\n" - ) - f.write( - json.dumps( - { - "timestamp": ts - 30, - "mai_state": "Processing", - "main_mind": "Thinking", - "subflow_count": 2, - "subflows": [ - { - "stream_id": "user1", - "group_name": "用户A", - "interest_level": 6, - "start_hfc_probability": 0.2, - "sub_mind": "Processing A's request", - "sub_chat_state": "Active", - "is_above_threshold": True, - "chat_state_changed_time": ts - 65, - }, - { - "stream_id": "user2", - "group_name": "用户B", - "interest_level": 4, - "start_hfc_probability": 0.1, - "sub_mind": "Waiting for B", - "sub_chat_state": "Idle", - "is_above_threshold": False, - "chat_state_changed_time": ts - 70, - }, - ], - } - ) - + "\n" - ) - f.write( - json.dumps( - { - "timestamp": ts, - "mai_state": "Responding", - "main_mind": "Responding to A", - "subflow_count": 2, - "subflows": [ - { - "stream_id": "user1", - "group_name": "用户A", - "interest_level": 7, - "start_hfc_probability": 0.3, - "sub_mind": "Generating response A", - "sub_chat_state": "Active", - "is_above_threshold": True, - "chat_state_changed_time": ts - 65, - }, - { - "stream_id": "user2", - "group_name": "用户B", - "interest_level": 3, - "start_hfc_probability": 0.08, - "sub_mind": "Still waiting B", - "sub_chat_state": "Idle", - "is_above_threshold": False, - "chat_state_changed_time": ts - 70, - }, - ], - } - ) - + "\n" - ) - - async def main(page: ft.Page): - page.title = "Interest Monitor 测试" - page.vertical_alignment = ft.MainAxisAlignment.START - # --- 让窗口适应内容 --- - page.window_width = 800 # 增加宽度 - page.window_height = 650 # 增加高度 - page.padding = 10 # 统一内边距 - - monitor = InterestMonitorDisplay() - # --- 添加外层容器并设置属性 --- - container = ft.Container( - content=monitor, - expand=True, # 让容器扩展 - border=ft.border.all(1, ft.Colors.OUTLINE), - border_radius=ft.border_radius.all(5), - padding=10, - margin=ft.margin.only(top=10), - ) - page.add(container) # 将容器添加到页面 - - ft.app(target=main) diff --git a/src/MaiGoi/flet_rules.py b/src/MaiGoi/flet_rules.py deleted file mode 100644 index 9570b577c..000000000 --- a/src/MaiGoi/flet_rules.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -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 deleted file mode 100644 index 515ac9901..000000000 --- a/src/MaiGoi/process_manager.py +++ /dev/null @@ -1,700 +0,0 @@ -import flet as ft -import subprocess -import os -import sys -import platform -import threading -import queue -import traceback -import asyncio -import psutil -from typing import Optional, TYPE_CHECKING, Tuple - -# Import the color parser and AppState/ManagedProcessState -from .color_parser import parse_log_line_to_spans - -if TYPE_CHECKING: - from .state import AppState -from .utils import show_snackbar, update_page_safe # Add import here - -# --- Helper Function to Update Button States (Mostly Unchanged for now) --- # - - -def update_buttons_state(page: Optional[ft.Page], app_state: "AppState", is_running: bool): - """Updates the state (text, icon, color, on_click) of the console button.""" - console_button = app_state.console_action_button - needs_update = False - - # --- Define Button Actions (Point to adapted functions) --- # - # start_action = lambda _: start_bot_and_show_console(page, app_state) if page else None - # stop_action = lambda _: stop_bot_process(page, app_state) if page else None # stop_bot_process now calls stop_managed_process - def _start_action(_): - if page: - start_bot_and_show_console(page, app_state) - - def _stop_action(_): - if page: - stop_bot_process(page, app_state) - - if console_button: - button_text_control = console_button.content if isinstance(console_button.content, ft.Text) else None - if button_text_control: - if is_running: - new_text = "停止 MaiCore" - new_color = ft.colors.with_opacity(0.6, ft.colors.RED_ACCENT_100) - new_onclick = _stop_action # Use def - if ( - button_text_control.value != new_text - or console_button.bgcolor != new_color - or console_button.on_click != new_onclick - ): - button_text_control.value = new_text - console_button.bgcolor = new_color - console_button.on_click = new_onclick - needs_update = True - else: - new_text = "启动 MaiCore" - new_color = ft.colors.with_opacity(0.6, ft.colors.GREEN_ACCENT_100) - new_onclick = _start_action # Use def - if ( - button_text_control.value != new_text - or console_button.bgcolor != new_color - or console_button.on_click != new_onclick - ): - button_text_control.value = new_text - console_button.bgcolor = new_color - console_button.on_click = new_onclick - needs_update = True - else: - print("[Update Buttons] Warning: console_action_button content is not Text?") - - if needs_update and page: - print(f"[Update Buttons] State changed, triggering page update. is_running={is_running}") - # from .utils import update_page_safe # Moved import to top - page.run_task(update_page_safe, page) - - -# --- Generic Process Termination Helper --- -def _terminate_process_gracefully(process_id: str, handle: Optional[subprocess.Popen], pid: Optional[int]): - """Helper to attempt graceful termination, then kill.""" - stopped_cleanly = False - if handle and pid: - print(f"[_terminate] Attempting termination using handle for PID: {pid} (ID: {process_id})...", flush=True) - try: - if handle.poll() is None: - handle.terminate() - print(f"[_terminate] Sent terminate() to PID: {pid}. Waiting briefly...", flush=True) - try: - handle.wait(timeout=1.0) - print(f"[_terminate] Process PID: {pid} stopped after terminate().", flush=True) - stopped_cleanly = True - except subprocess.TimeoutExpired: - print(f"[_terminate] Terminate timed out for PID: {pid}. Attempting kill()...", flush=True) - try: - handle.kill() - print(f"[_terminate] Sent kill() to PID: {pid}.", flush=True) - except Exception as kill_err: - print(f"[_terminate] Error during kill() for PID: {pid}: {kill_err}", flush=True) - else: - print("[_terminate] Process poll() was not None before terminate (already stopped?).", flush=True) - stopped_cleanly = True # Already stopped - except Exception as e: - print(f"[_terminate] Error during terminate/wait for PID: {pid}: {e}", flush=True) - elif pid: - print( - f"[_terminate] No process handle, attempting psutil fallback for PID: {pid} (ID: {process_id})...", - flush=True, - ) - try: - if psutil.pid_exists(pid): - proc = psutil.Process(pid) - proc.terminate() - try: - proc.wait(timeout=1.0) - stopped_cleanly = True - except psutil.TimeoutExpired: - proc.kill() - print(f"[_terminate] psutil terminated/killed PID {pid}.", flush=True) - else: - print(f"[_terminate] psutil confirms PID {pid} does not exist.", flush=True) - stopped_cleanly = True # Already gone - except Exception as ps_err: - print(f"[_terminate] Error during psutil fallback for PID {pid}: {ps_err}", flush=True) - else: - print(f"[_terminate] Cannot terminate process ID '{process_id}': No handle or PID provided.", flush=True) - stopped_cleanly = True # Nothing to stop - - return stopped_cleanly - - -# --- Process Management Functions (Refactored for Multi-Process) --- # - - -def cleanup_on_exit(app_state: "AppState"): - """Registered with atexit to ensure ALL managed processes are killed on script exit.""" - print("--- [atexit Cleanup] Running cleanup function ---", flush=True) - # Iterate through a copy of the keys to avoid modification issues - process_ids = list(app_state.managed_processes.keys()) - print(f"[atexit Cleanup] Found managed process IDs: {process_ids}", flush=True) - - for process_id in process_ids: - process_state = app_state.managed_processes.get(process_id) - if process_state and process_state.pid: - print(f"[atexit Cleanup] Checking PID: {process_state.pid} for ID: {process_id}...", flush=True) - try: - # Use psutil directly as handles might be invalid in atexit - if psutil.pid_exists(process_state.pid): - print( - f"[atexit Cleanup] PID {process_state.pid} exists. Attempting termination/kill...", flush=True - ) - proc = psutil.Process(process_state.pid) - proc.terminate() - try: - proc.wait(timeout=0.5) - except psutil.TimeoutExpired: - proc.kill() - print( - f"[atexit Cleanup] psutil terminate/kill signal sent for PID {process_state.pid}.", flush=True - ) - else: - print(f"[atexit Cleanup] PID {process_state.pid} does not exist.", flush=True) - except psutil.NoSuchProcess: - print(f"[atexit Cleanup] psutil.NoSuchProcess error checking PID {process_state.pid}.", flush=True) - except Exception as ps_err: - print(f"[atexit Cleanup] Error cleaning up PID {process_state.pid}: {ps_err}", flush=True) - elif process_state: - print(f"[atexit Cleanup] Process ID '{process_id}' has no PID stored.", flush=True) - # else: Process ID might have been removed already - - print("--- [atexit Cleanup] Cleanup function finished ---", flush=True) - - -def handle_disconnect(page: Optional[ft.Page], app_state: "AppState", e): - """Handles UI disconnect. Sets the stop_event for the main bot.py process FOR NOW.""" - # TODO: In a full multi-process model, this might need to signal all running processes or be handled differently. - print(f"--- [Disconnect Event] Triggered! Setting main stop_event. Event data: {e} ---", flush=True) - if not app_state.stop_event.is_set(): # Still uses the old singleton event - app_state.stop_event.set() - print("[Disconnect Event] Main stop_event set. atexit handler will perform final cleanup.", flush=True) - - -# --- New Generic Stop Function --- -def stop_managed_process(process_id: str, page: Optional[ft.Page], app_state: "AppState"): - """Stops a specific managed process by its ID.""" - print(f"[Stop Managed] Request to stop process ID: '{process_id}'", flush=True) - process_state = app_state.managed_processes.get(process_id) - - if not process_state: - print(f"[Stop Managed] Process ID '{process_id}' not found in managed processes.", flush=True) - if page and process_id == "bot.py": # Show snackbar only for the main bot? - # from .utils import show_snackbar; show_snackbar(page, "Bot process not found or already stopped.") # Already imported at top - show_snackbar(page, "Bot process not found or already stopped.") - # If it's the main bot, ensure button state is correct - if process_id == "bot.py": - update_buttons_state(page, app_state, is_running=False) - return - - # Signal the specific stop event for this process - if not process_state.stop_event.is_set(): - print(f"[Stop Managed] Setting stop_event for ID: '{process_id}'", flush=True) - process_state.stop_event.set() - - # Attempt termination - _terminate_process_gracefully(process_id, process_state.process_handle, process_state.pid) - - # Update state in AppState dictionary - process_state.status = "stopped" - process_state.process_handle = None # Clear handle - process_state.pid = None # Clear PID - # Optionally remove the entry from the dictionary entirely? - # del app_state.managed_processes[process_id] - print(f"[Stop Managed] Marked process ID '{process_id}' as stopped in AppState.") - - # Update UI (specifically for the main bot for now) - if process_id == "bot.py": - # If the process being stopped is the main bot, update the console button - update_buttons_state(page, app_state, is_running=False) - # Also clear the old singleton state for compatibility - app_state.clear_process() # This now also updates the dict entry - - # TODO: Add UI update logic for other processes if a management view exists - - -# --- Adapted Old Stop Function (Calls the new generic one) --- -def stop_bot_process(page: Optional[ft.Page], app_state: "AppState"): - """(Called by Button) Stops the main bot.py process by calling stop_managed_process.""" - stop_managed_process("bot.py", page, app_state) - - -# --- Parameterized Reader Thread --- -def read_process_output( - app_state: "AppState", # Still pass app_state for global checks? Or remove? Let's keep for now. - process_handle: Optional[subprocess.Popen] = None, - output_queue: Optional[queue.Queue] = None, - stop_event: Optional[threading.Event] = None, - process_id: str = "bot.py", # ID for logging -): - """ - Background thread function to read raw output from a process and put it into a queue. - Defaults to using AppState singletons if specific handles/queues/events aren't provided. - """ - # Use provided arguments or default to AppState singletons - proc_handle = process_handle if process_handle is not None else app_state.bot_process - proc_queue = output_queue if output_queue is not None else app_state.output_queue - proc_stop_event = stop_event if stop_event is not None else app_state.stop_event - - if not proc_handle or not proc_handle.stdout: - if not proc_stop_event.is_set(): - print(f"[Reader Thread - {process_id}] Error: Process or stdout not available at start.", flush=True) - return - - print(f"[Reader Thread - {process_id}] Started.", flush=True) - try: - for line in iter(proc_handle.stdout.readline, ""): - if proc_stop_event.is_set(): - print(f"[Reader Thread - {process_id}] Stop event detected, exiting.", flush=True) - break - if line: - proc_queue.put(line.strip()) - else: - break # End of stream - except ValueError: - if not proc_stop_event.is_set(): - print(f"[Reader Thread - {process_id}] ValueError likely due to closed stdout.", flush=True) - except Exception as e: - if not proc_stop_event.is_set(): - print(f"[Reader Thread - {process_id}] Error reading output: {e}", flush=True) - finally: - if not proc_stop_event.is_set(): - try: - proc_queue.put(None) # Signal natural end - except Exception as q_err: - print(f"[Reader Thread - {process_id}] Error putting None signal: {q_err}", flush=True) - print(f"[Reader Thread - {process_id}] Finished.", flush=True) - - -# --- Parameterized Processor Loop --- -async def output_processor_loop( - page: Optional[ft.Page], - app_state: "AppState", # Pass AppState for PID checks and potentially global state access - process_id: str = "bot.py", # ID to identify the process and its state - # Defaults use AppState singletons for backward compatibility with bot.py - output_queue: Optional[queue.Queue] = None, - stop_event: Optional[threading.Event] = None, - target_list_view: Optional[ft.ListView] = None, -): - """ - Processes a specific output queue and updates the UI until stop_event is set. - Defaults to using AppState singletons if specific queue/event/view aren't provided. - """ - print(f"[Processor Loop - {process_id}] Started.", flush=True) - proc_queue = output_queue if output_queue is not None else app_state.output_queue - proc_stop_event = stop_event if stop_event is not None else app_state.stop_event - output_lv = target_list_view if target_list_view is not None else app_state.output_list_view - - # from .utils import update_page_safe # Moved to top - - while not proc_stop_event.is_set(): - lines_to_add = [] - process_ended_signal_received = False - - try: - while not proc_queue.empty(): - raw_line = proc_queue.get_nowait() - if raw_line is None: - process_ended_signal_received = True - print(f"[Processor Loop - {process_id}] Process ended signal received from reader.", flush=True) - lines_to_add.append(ft.Text(f"--- Process '{process_id}' Finished --- ", italic=True)) - break - else: - spans = parse_log_line_to_spans(raw_line) - lines_to_add.append(ft.Text(spans=spans, selectable=True, size=12)) - except queue.Empty: - pass - - if lines_to_add: - if proc_stop_event.is_set(): - break - - if output_lv: - # 如果在手动观看模式(自动滚动关闭),记录首个元素索引 - if process_id == "bot.py" and hasattr(app_state, "manual_viewing") and app_state.manual_viewing: - # 只有在自动滚动关闭时才保存视图位置 - if not getattr(output_lv, "auto_scroll", True): - # 记录当前第一个可见元素的索引 - first_visible_idx = 0 - if hasattr(output_lv, "first_visible") and output_lv.first_visible is not None: - first_visible_idx = output_lv.first_visible - - # 添加新行 - output_lv.controls.extend(lines_to_add) - - # 移除过多的行 - removal_count = 0 - while len(output_lv.controls) > 1000: - output_lv.controls.pop(0) - removal_count += 1 - - # 如果移除了行,需要调整首个可见元素的索引 - if removal_count > 0 and first_visible_idx > removal_count: - new_idx = max(0, first_visible_idx - removal_count) - # 设置滚动位置到调整后的索引 - output_lv.first_visible = new_idx - else: - # 保持当前滚动位置 - output_lv.first_visible = first_visible_idx - else: - # 自动滚动开启时,正常添加 - output_lv.controls.extend(lines_to_add) - while len(output_lv.controls) > 1000: - output_lv.controls.pop(0) # Limit lines - else: - # 对于非主控制台输出,或没有手动观看模式,正常处理 - output_lv.controls.extend(lines_to_add) - while len(output_lv.controls) > 1000: - output_lv.controls.pop(0) # Limit lines - - if output_lv.visible and page: - try: - await update_page_safe(page) - except Exception: - pass - # else: print(f"[Processor Loop - {process_id}] Warning: target_list_view is None...") - - if process_ended_signal_received: - print( - f"[Processor Loop - {process_id}] Process ended naturally. Setting stop event and cleaning up.", - flush=True, - ) - if not proc_stop_event.is_set(): - proc_stop_event.set() - # Update the specific process state in the dictionary - proc_state = app_state.managed_processes.get(process_id) - if proc_state: - proc_state.status = "stopped" - proc_state.process_handle = None - proc_state.pid = None - # If it's the main bot, also update the old state and buttons - if process_id == "bot.py": - app_state.clear_process() # Clears old state and marks new as stopped - update_buttons_state(page, app_state, is_running=False) - break - - # Check if the specific process died unexpectedly using its PID from managed_processes - current_proc_state = app_state.managed_processes.get(process_id) - current_pid = current_proc_state.pid if current_proc_state else None - - if current_pid is not None and not psutil.pid_exists(current_pid) and not proc_stop_event.is_set(): - print( - f"[Processor Loop - {process_id}] Process PID {current_pid} ended unexpectedly. Setting stop event.", - flush=True, - ) - proc_stop_event.set() - if current_proc_state: # Update state - current_proc_state.status = "stopped" - current_proc_state.process_handle = None - current_proc_state.pid = None - # Add message to its specific output view - if output_lv: - output_lv.controls.append(ft.Text(f"--- Process '{process_id}' Ended Unexpectedly ---", italic=True)) - if page and output_lv.visible: - try: - await update_page_safe(page) - except Exception: - pass - # If it's the main bot, update buttons and old state - if process_id == "bot.py": - app_state.clear_process() - update_buttons_state(page, app_state, is_running=False) - break - - try: - await asyncio.sleep(0.2) - except asyncio.CancelledError: - print(f"[Processor Loop - {process_id}] Cancelled during sleep.", flush=True) - if not proc_stop_event.is_set(): - proc_stop_event.set() - break - - print(f"[Processor Loop - {process_id}] Exited.", flush=True) - - -# --- New Generic Start Function --- -def start_managed_process( - script_path: str, - display_name: str, - page: ft.Page, - app_state: "AppState", - # target_list_view: Optional[ft.ListView] = None # Removed parameter -) -> Tuple[bool, Optional[str]]: - """ - Starts a managed background process, creates its state, and starts reader/processor. - Returns (success: bool, message: Optional[str]) - """ - # from .utils import show_snackbar # Dynamic import - Already imported at top - from .state import ManagedProcessState # Dynamic import - - process_id = script_path # Use script path as ID for now, ensure uniqueness later if needed - - # Prevent duplicate starts if ID already exists and is running - existing_state = app_state.managed_processes.get(process_id) - if ( - existing_state - and existing_state.status == "running" - and existing_state.pid - and psutil.pid_exists(existing_state.pid) - ): - msg = f"Process '{display_name}' (ID: {process_id}) is already running." - print(f"[Start Managed] {msg}", flush=True) - # show_snackbar(page, msg) # Maybe too noisy? - return False, msg - - full_path = os.path.join(app_state.script_dir, script_path) - if not os.path.exists(full_path): - msg = f"Error: Script file not found {script_path}" - print(f"[Start Managed] {msg}", flush=True) - show_snackbar(page, msg, error=True) - return False, msg - - print(f"[Start Managed] Preparing to start NEW process: {display_name} ({script_path})", flush=True) - - # Create NEW state object for this process with its OWN queue and event - # UNLESS it's bot.py, in which case we still use the old singletons for now - is_main_bot = script_path == "bot.py" - new_queue = app_state.output_queue if is_main_bot else queue.Queue() - new_event = app_state.stop_event if is_main_bot else threading.Event() - - new_process_state = ManagedProcessState( - process_id=process_id, - script_path=script_path, - display_name=display_name, - output_queue=new_queue, - stop_event=new_event, - status="starting", - ) - # Add to managed processes *before* starting - app_state.managed_processes[process_id] = new_process_state - - # --- Create and store ListView if not main bot --- # - output_lv: Optional[ft.ListView] = None - if is_main_bot: - 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) # 始终默认开启自动滚动 - new_process_state.output_list_view = output_lv - - # Add starting message to the determined ListView - if output_lv: - output_lv.controls.append(ft.Text(f"--- Starting {display_name} --- ", italic=True)) - else: # Should not happen if is_main_bot or created above - print(f"[Start Managed - {process_id}] Error: Could not determine target ListView.") - - try: - 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" - 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( - cmd_list, # 使用构建好的命令列表 - cwd=app_state.script_dir, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, - creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0, - env=sub_env, - ) - - # Update the state with handle and PID - new_process_state.process_handle = process - new_process_state.pid = process.pid - new_process_state.status = "running" - print(f"[Start Managed - {process_id}] Subprocess started. PID: {process.pid}", flush=True) - - # If it's the main bot, also update the old state vars for compatibility - if is_main_bot: - app_state.bot_process = process - app_state.bot_pid = process.pid - update_buttons_state(page, app_state, is_running=True) - - # Start the PARAMETERIZED reader thread - output_thread = threading.Thread( - target=read_process_output, - args=(app_state, process, new_queue, new_event, process_id), # Pass specific objects - daemon=True, - ) - output_thread.start() - print(f"[Start Managed - {process_id}] Output reader thread started.", flush=True) - - # Start the PARAMETERIZED processor loop task - # Pass the determined output_lv (either main console or the new one) - page.run_task(output_processor_loop, page, app_state, process_id, new_queue, new_event, output_lv) - print(f"[Start Managed - {process_id}] Output processor loop scheduled.", flush=True) - - return True, f"Process '{display_name}' started successfully." - - except Exception as e: - print(f"[Start Managed - {process_id}] Error during startup:", flush=True) - traceback.print_exc() - # Clean up state if startup failed - new_process_state.status = "error" - new_process_state.process_handle = None - new_process_state.pid = None - if process_id in app_state.managed_processes: # Might be redundant check - app_state.managed_processes[process_id].status = "error" - - if is_main_bot: # Update UI/state for main bot failure - app_state.clear_process() - update_buttons_state(page, app_state, is_running=False) - - error_message = str(e) if str(e) else repr(e) - show_snackbar(page, f"Error running {script_path}: {error_message}", error=True) - return False, f"Error starting process '{display_name}': {error_message}" - - -# --- Adapted Old Start Function (Calls the new generic one for bot.py) --- -def start_bot_and_show_console(page: ft.Page, app_state: "AppState"): - """Starts bot.py or navigates to its console view, managing state via AppState.""" - script_path_relative = "bot.py" - display_name = "MaiCore" - # from .utils import show_snackbar, update_page_safe # Dynamic imports - Already imported at top - - # Check running status using OLD state for now - is_running = app_state.bot_pid is not None and psutil.pid_exists(app_state.bot_pid) - print( - f"[Start Bot Click] Current state: is_running={is_running} (PID={app_state.bot_pid}), stop_event={app_state.stop_event.is_set()}", - flush=True, - ) - - if is_running: - print("[Start Bot Click] Process is running. Navigating to console.", flush=True) - show_snackbar(page, "Bot process is already running, showing console.") - # Ensure processor loop is running (it uses the singleton stop_event) - if app_state.stop_event.is_set(): - print("[Start Bot Click] Stop event was set, clearing and restarting processor loop.", flush=True) - app_state.stop_event.clear() - # Start the processor loop using defaults (targets main console view) - page.run_task(output_processor_loop, page, app_state) - - if page.route != "/console": - page.go("/console") - else: - page.run_task(update_page_safe, page) - return - - # --- Start the bot process --- - print("[Start Bot Click] Process not running. Starting new process via start_managed_process.", flush=True) - - # Clear and setup OLD ListView from state (used by default processor loop) - if app_state.output_list_view: - app_state.output_list_view.controls.clear() - app_state.output_list_view.auto_scroll = app_state.is_auto_scroll_enabled - print("[Start Bot Click] Cleared console history.", flush=True) - else: - app_state.output_list_view = ft.ListView( - expand=True, spacing=2, auto_scroll=app_state.is_auto_scroll_enabled, padding=5 - ) - print( - f"[Start Bot Click] Created new ListView with auto_scroll={app_state.is_auto_scroll_enabled}.", flush=True - ) - - # Reset OLD state (clears queue, event) - this also resets the managed state entry - app_state.reset_process_state() - - # Call the generic start function, targeting the main console list view - # This will use the OLD singleton queue/event because script_path == "bot.py" - # and start the default (non-parameterized call) reader/processor - # The call below now implicitly passes app_state.output_list_view because is_main_bot=True inside start_managed_process - success, message = start_managed_process( - script_path=script_path_relative, - display_name=display_name, - page=page, - app_state=app_state, - # target_list_view=app_state.output_list_view # Removed parameter - ) - - if success: - # Navigate to console view - page.go("/console") - # else: Error message already shown by start_managed_process diff --git a/src/MaiGoi/state.py b/src/MaiGoi/state.py deleted file mode 100644 index bad7260a1..000000000 --- a/src/MaiGoi/state.py +++ /dev/null @@ -1,139 +0,0 @@ -import flet as ft -import subprocess -import queue -import threading -from typing import Optional, List, Dict, Any -from dataclasses import dataclass, field - -# 从 flet_interest_monitor 导入,如果需要类型提示 -from .flet_interest_monitor import InterestMonitorDisplay - - -@dataclass -class ManagedProcessState: - """Holds the state for a single managed background process.""" - - process_id: str # Unique identifier (e.g., script path or UUID) - script_path: str - display_name: str - process_handle: Optional[subprocess.Popen] = None - pid: Optional[int] = None - output_queue: queue.Queue = field(default_factory=queue.Queue) - stop_event: threading.Event = field(default_factory=threading.Event) - status: str = "stopped" # e.g., "running", "stopped", "error" - # Store UI references if needed later, e.g., for dedicated output views - # output_view_controls: Optional[List[ft.Control]] = None - output_list_view: Optional[ft.ListView] = None # Added to hold the specific ListView for this process - - -class AppState: - """Holds the shared state of the launcher application.""" - - def __init__(self): - # Process related state - self.bot_process: Optional[subprocess.Popen] = None - self.bot_pid: Optional[int] = None - self.output_queue: queue.Queue = queue.Queue() - self.stop_event: threading.Event = threading.Event() - - # UI related state - 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 = True # 默认启用自动滚动 - self.manual_viewing: bool = False # 手动观看模式标识,用于修复自动滚动关闭时的位移问题 - self.interest_monitor_control: Optional[InterestMonitorDisplay] = None - - # Script directory (useful for paths) - self.script_dir: str = "" # Will be set during initialization in launcher.py - - # --- Configuration State --- # - self.gui_config: Dict[str, Any] = {} # Loaded from gui_config.toml - self.adapter_paths: List[str] = [] # Specific list of adapter paths from config - - # --- Process Management State (NEW - For multi-process support) --- # - self.managed_processes: Dict[str, ManagedProcessState] = {} - - def reset_process_state(self): - """Resets variables related to the bot process.""" - print("[AppState] Resetting process state.", flush=True) - self.bot_process = None - self.bot_pid = None - # Clear the queue? Maybe not, might lose messages if reset mid-operation - # while not self.output_queue.empty(): - # try: self.output_queue.get_nowait() - # except queue.Empty: break - self.stop_event.clear() # Ensure stop event is cleared - - # --- Reset corresponding NEW state (if exists) --- - process_id = "bot.py" - if process_id in self.managed_processes: - # Ensure the managed state reflects the reset event/queue - # (Since they point to the same objects for now, this is redundant but good practice) - self.managed_processes[process_id].stop_event = self.stop_event - self.managed_processes[process_id].output_queue = self.output_queue - self.managed_processes[process_id].status = "stopped" # Ensure status is reset before start - print(f"[AppState] Reset NEW managed state event/queue pointers and status for ID: '{process_id}'.") - - def set_process(self, process: subprocess.Popen, script_path: str = "bot.py", display_name: str = "MaiCore"): - """ - Sets the process handle and PID. - Also updates the new managed_processes dictionary for compatibility. - """ - # --- Update OLD state --- - self.bot_process = process - self.bot_pid = process.pid - # Reset stop event for the new process run - self.stop_event.clear() - # NOTE: We keep the OLD output_queue and stop_event separate for now, - # as the current reader/processor loops use them directly. - # In the future, the reader/processor will use the queue/event - # from the ManagedProcessState object. - - # --- Update NEW state --- - process_id = script_path # Use script_path as ID for now - new_process_state = ManagedProcessState( - process_id=process_id, - script_path=script_path, - display_name=display_name, - process_handle=process, - pid=process.pid, - # IMPORTANT: For now, use the *old* queue/event for the bot.py entry - # to keep existing reader/processor working without immediate changes. - # A true multi-process implementation would give each process its own. - output_queue=self.output_queue, - stop_event=self.stop_event, - status="running", - ) - self.managed_processes[process_id] = new_process_state - print( - f"[AppState] Set OLD process state (PID: {self.bot_pid}) and added/updated NEW managed state for ID: '{process_id}'" - ) - - def clear_process(self): - """ - Clears the process handle and PID. - Also updates the status in the new managed_processes dictionary. - """ - old_pid = self.bot_pid - process_id = "bot.py" # Assuming clear is for the main bot process - - # --- Clear OLD state --- - self.bot_process = None - self.bot_pid = None - # Don't clear stop_event here, it should be set to signal stopping. - # Don't clear output_queue, might still contain final messages. - - # --- Update NEW state --- - if process_id in self.managed_processes: - self.managed_processes[process_id].process_handle = None - self.managed_processes[process_id].pid = None - self.managed_processes[process_id].status = "stopped" - # Keep queue and event references for now - print( - f"[AppState] Cleared OLD process state (was PID: {old_pid}) and marked NEW managed state for ID: '{process_id}' as stopped." - ) - else: - print( - f"[AppState] Cleared OLD process state (was PID: {old_pid}). No corresponding NEW state found for ID: '{process_id}'." - ) diff --git a/src/MaiGoi/toml_form_generator.py b/src/MaiGoi/toml_form_generator.py deleted file mode 100644 index 5ba8ff5f7..000000000 --- a/src/MaiGoi/toml_form_generator.py +++ /dev/null @@ -1,916 +0,0 @@ -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 deleted file mode 100644 index 08f3a1da6..000000000 --- a/src/MaiGoi/ui_env_editor.py +++ /dev/null @@ -1,265 +0,0 @@ -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 deleted file mode 100644 index b15b06429..000000000 --- a/src/MaiGoi/ui_settings_view.py +++ /dev/null @@ -1,348 +0,0 @@ -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 deleted file mode 100644 index b1a74e85d..000000000 --- a/src/MaiGoi/ui_views.py +++ /dev/null @@ -1,1005 +0,0 @@ -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 - -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: - print("[Main View] Error: start_bot_button not initialized in state! Creating placeholder.") - start_button = ft.FilledButton("Error - Reload App") - app_state.start_bot_button = start_button # Store placeholder back just in case - - from .utils import run_script # Dynamic import to avoid cycles - - # --- Card Styling --- # - card_shadow = ft.BoxShadow( - spread_radius=1, - blur_radius=10, # Slightly more blur for frosted effect - color=ft.colors.with_opacity(0.2, ft.colors.BLACK87), - offset=ft.Offset(1, 2), - ) - # card_border = ft.border.all(1, ft.colors.with_opacity(0.5, ft.colors.SECONDARY)) # Optional: Remove border for cleaner glass look - 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 Creation Function --- # - 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 - 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.W_800, - size=50, - text_align=ft.TextAlign.LEFT, - font_family="SimSun", - # color=ft.colors.BLACK, - color=main_text_color_theme, # Use theme color - ), - margin=ft.margin.only(top=-5), - ), - # --- Subtitle Text (Wrapped in Container for Margin) --- - ft.Container( - content=ft.Text( - subtitle_text, - weight=ft.FontWeight.BOLD, - size=20, - # color=ft.colors.with_opacity(0.7, ft.colors.GREY_500), - color=subtitle_color_theme, # Use theme color - text_align=ft.TextAlign.LEFT, - font_family="SimHei", - ), - margin=ft.margin.only(top=-20, left=10), - ), - ], - 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 - ), - 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, - # 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, - # 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="主控室", - subtitle="在此启动 Bot", - on_click_handler=lambda _: page.go("/console"), - tooltip="打开 Bot 控制台视图 (在此启动 Bot)", - ) - # Note: We are not using app_state.start_bot_button directly here anymore - # The button state update logic in process_manager might need adjustment - # if we want this card's appearance to change (e.g., text to "返回控制台"). - # For now, it will always show "启动". - - # --- Define Popup Menu Items --- # - menu_items = [ - # ft.PopupMenuItem( - # text="麦麦学习", - # on_click=lambda _: run_script("start_lpmm.bat", page, app_state), - # ), - ft.PopupMenuItem( - text="人格生成(测试版)", - on_click=lambda _: run_script("start_personality.bat", page, app_state), - ), - # Add more items here if needed in the future - ] - - # --- 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.Stack( - [ - # 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, # 顶部距离 - ), - ] - ), - 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 - # 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 --- # - # 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 - # 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 --- # - # 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.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 - ) - - return ft.View( - "/", # Main view route - [ - ft.Stack( - [ - # --- 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, - # 重新定位"更多..."按钮 - 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 - ), - ], - # padding=ft.padding.symmetric(horizontal=20), # <-- 移除水平 padding - # scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows - ) - - -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 - 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) - app_state.output_list_view = output_list_view # Store back to state - print("[Create Console View] Fallback: Created ListView.") - - # --- Create or get InterestMonitorDisplay instance --- # - # Ensure the same instance is used if the view is recreated - if app_state.interest_monitor_control is None: - print("[Create Console View] Creating InterestMonitorDisplay instance") - app_state.interest_monitor_control = InterestMonitorDisplay() # Store in state - else: - print("[Create Console View] Using existing InterestMonitorDisplay instance from state") - # Optional: Trigger reactivation if needed - # asyncio.create_task(app_state.interest_monitor_control.start_updates_if_needed()) - - interest_monitor = app_state.interest_monitor_control - - # --- 为控制台输出和兴趣监控创建容器,以便动态调整大小 --- # - 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): - app_state.is_auto_scroll_enabled = not app_state.is_auto_scroll_enabled - 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 - text_control = e.control.data if isinstance(e.control.data, ft.Text) else None - if text_control: - text_control.value = "自动滚动 开" if app_state.is_auto_scroll_enabled else "自动滚动 关" - else: - print("[toggle_auto_scroll] Warning: Could not find Text control in button data.") - # The icon and tooltip are on the Container itself (though tooltip might be better on Text?) - # e.control.icon = ft.icons.PLAY_ARROW if app_state.is_auto_scroll_enabled else ft.icons.PAUSE # Icon removed - e.control.tooltip = "切换控制台自动滚动" # Tooltip remains useful - print(f"Auto-scroll {'enabled' if app_state.is_auto_scroll_enabled else 'disabled'}.", flush=True) - # Update the container to reflect text changes - # page.run_task(update_page_safe, page) # This updates the whole page - e.control.update() # Try updating only the container first - - # --- Card Styling (Copied from create_main_view for reuse) --- # - card_shadow = ft.BoxShadow( - spread_radius=1, - blur_radius=10, - color=ft.colors.with_opacity(0.2, ft.colors.BLACK87), - offset=ft.Offset(1, 2), - ) - card_radius = ft.border_radius.all(4) - card_bgcolor = ft.colors.with_opacity(0.65, ft.colors.PRIMARY_CONTAINER) - card_padding = ft.padding.symmetric(vertical=8, horizontal=12) # Smaller padding for console buttons - - # --- Create Buttons --- # - # Create the main action button (Start/Stop) as a styled Container - console_action_button_text = ft.Text("...") # Placeholder text, updated by update_buttons_state - console_action_button = ft.Container( - content=console_action_button_text, - bgcolor=card_bgcolor, # Apply style - border_radius=card_radius, - shadow=card_shadow, - padding=card_padding, - ink=True, - # on_click is set by update_buttons_state - ) - app_state.console_action_button = console_action_button # Store container ref - - # Create the auto-scroll toggle button as a styled Container with Text - auto_scroll_text_content = "自动滚动 开" if app_state.is_auto_scroll_enabled else "自动滚动 关" - auto_scroll_text = ft.Text(auto_scroll_text_content, size=12) - toggle_button = ft.Container( - content=auto_scroll_text, - tooltip="切换控制台自动滚动", - on_click=toggle_auto_scroll, # Attach click handler here - bgcolor=card_bgcolor, # Apply style - border_radius=card_radius, - shadow=card_shadow, - padding=card_padding, - ink=True, - # Remove left margin - margin=ft.margin.only(right=10), - ) - # Store the text control inside the toggle button container for updating - toggle_button.data = auto_scroll_text # Store Text reference in data attribute - - # --- 附加信息区 Column (在 View 级别创建) --- - info_top_section = ft.Column( - controls=[ - ft.Text("附加信息 - 上", weight=ft.FontWeight.BOLD), - ft.Divider(), - ft.Text("..."), # 上半部分占位符 - ], - expand=True, # 让上半部分填充可用垂直空间 - scroll=ft.ScrollMode.ADAPTIVE, - ) - info_bottom_section = ft.Column( - controls=[ - ft.Text("附加信息 - 下", weight=ft.FontWeight.BOLD), - ft.Divider(), - ft.Text("..."), # 下半部分占位符 - # 将按钮放在底部 - # Wrap the Row in a Container to apply padding - ft.Container( - content=ft.Row( - [console_action_button, toggle_button], - # alignment=ft.MainAxisAlignment.SPACE_AROUND, - alignment=ft.MainAxisAlignment.START, # Align buttons to the start - ), - # Apply padding to the container holding the row - padding=ft.padding.only(bottom=10), - ), - ], - # height=100, # 可以给下半部分固定高度,或者让它自适应 - spacing=5, - # Remove padding from the Column itself - # padding=ft.padding.only(bottom=10) - ) - info_column = ft.Column( - controls=[ - # ft.Text("附加信息区", weight=ft.FontWeight.BOLD), - # ft.Divider(), - info_top_section, - info_bottom_section, - ], - width=250, # 增加宽度 - # scroll=ft.ScrollMode.ADAPTIVE, # 内部分区滚动,外部不需要 - spacing=10, # 分区之间的间距 - ) - - # --- Set Initial Button State --- # - # Call the helper AFTER the button is created and stored in state - is_initially_running = app_state.bot_pid is not None and psutil.pid_exists(app_state.bot_pid) - update_buttons_state(page, app_state, is_running=is_initially_running) - - # --- 视图布局 --- # - return ft.View( - "/console", # View route - [ - ft.AppBar(title=ft.Text("Mai控制台")), - # --- 主要内容区域改为 Row --- # - ft.Row( - controls=[ - # --- 左侧 Column (可扩展) --- # - ft.Column( - controls=[ - # 1. Console Output Area - output_container, # 使用容器替代直接引用 - # 2. Interest Monitor Area - monitor_container, # 使用容器替代直接引用 - ], - expand=True, # 让左侧 Column 占据 Row 的大部分空间 - ), - # --- 右侧 Column (固定宽度) --- # - info_column, - ], - expand=True, # 让 Row 填满 AppBar 下方的空间 - ), - ], - padding=0, # View padding set to 0 - # Flet automatically handles calling will_unmount on UserControls like InterestMonitorDisplay - # when the view is removed or the app closes. - # on_disappear=lambda _: asyncio.create_task(interest_monitor.will_unmount_async()) if interest_monitor else None - ) - - -# --- Adapters View --- # -def create_adapters_view(page: ft.Page, app_state: "AppState") -> ft.View: - """Creates the view for managing adapters (/adapters).""" - # Import necessary functions - from .config_manager import save_config - from .utils import show_snackbar # Removed run_script import - - # Import process management functions - from .process_manager import start_managed_process, stop_managed_process - import psutil # To check if PID exists for status - - adapters_list_view = ft.ListView(expand=True, spacing=5) - - def update_adapters_list(): - """Refreshes the list view with current adapter paths and status-dependent buttons.""" - adapters_list_view.controls.clear() - for index, path in enumerate(app_state.adapter_paths): - process_id = path # Use path as the unique ID for now - process_state = app_state.managed_processes.get(process_id) - is_running = False - if ( - process_state - and process_state.status == "running" - and process_state.pid - and psutil.pid_exists(process_state.pid) - ): - is_running = True - - action_buttons = [] - if is_running: - # If running: View Output Button and Stop Button - action_buttons.append( - ft.IconButton( - ft.icons.VISIBILITY_OUTLINED, - tooltip="查看输出", - data=process_id, - on_click=lambda e: page.go(f"/adapters/{e.control.data}"), - icon_color=ft.colors.BLUE_GREY, # Neutral color - ) - ) - action_buttons.append( - ft.IconButton( - ft.icons.STOP_CIRCLE_OUTLINED, - tooltip="停止此适配器", - data=process_id, - # Call stop and then refresh the list view - on_click=lambda e: ( - stop_managed_process(e.control.data, page, app_state), - update_adapters_list(), - ), - icon_color=ft.colors.RED_ACCENT, - ) - ) - else: - # If stopped: Start Button - action_buttons.append( - ft.IconButton( - ft.icons.PLAY_ARROW_OUTLINED, - tooltip="启动此适配器脚本", - data=path, - on_click=lambda e: start_adapter_process(e, page, app_state), - icon_color=ft.colors.GREEN, - ) - ) - - adapters_list_view.controls.append( - ft.Row( - [ - ft.Text(path, expand=True, overflow=ft.TextOverflow.ELLIPSIS), - # Add action buttons based on state - *action_buttons, - # Keep the remove button - ft.IconButton( - ft.icons.DELETE_OUTLINE, - tooltip="移除此适配器", - data=index, # Store index to know which one to remove - on_click=remove_adapter, - icon_color=ft.colors.ERROR, - ), - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - ) - ) - # Trigger update if the list view is part of the page - if adapters_list_view.page: - adapters_list_view.update() - - def remove_adapter(e): - """Removes an adapter path based on the button's data (index).""" - index_to_remove = e.control.data - if 0 <= index_to_remove < len(app_state.adapter_paths): - removed_path = app_state.adapter_paths.pop(index_to_remove) - app_state.gui_config["adapters"] = app_state.adapter_paths - if save_config(app_state.gui_config): - update_adapters_list() - show_snackbar(page, f"已移除: {removed_path}") - else: - show_snackbar(page, "保存配置失败,未能移除", error=True) - # Revert state - app_state.adapter_paths.insert(index_to_remove, removed_path) - app_state.gui_config["adapters"] = app_state.adapter_paths - else: - show_snackbar(page, "移除时发生错误:无效索引", error=True) - - # --- Start Adapter Process Handler --- # - def start_adapter_process(e, page: ft.Page, app_state: "AppState"): - """Handles the click event for the start adapter button.""" - path_to_run = e.control.data - if not path_to_run or not isinstance(path_to_run, str): - show_snackbar(page, "运行错误:无效的适配器路径", error=True) - return - - display_name = os.path.basename(path_to_run) # Use filename as display name - process_id = path_to_run # Use path as ID - print(f"[Adapters View] Requesting start for: {display_name} (ID: {process_id})") - - # Call the generic start function from process_manager - # It will create the specific ListView in the state - success, message = start_managed_process( - script_path=path_to_run, - display_name=display_name, - page=page, - app_state=app_state, - # No target_list_view needed here, it creates its own - ) - - if success: - show_snackbar(page, f"正在启动: {display_name}") - update_adapters_list() # Refresh button states - # Navigate to the specific output view for this process - page.go(f"/adapters/{process_id}") - else: - # Error message already shown by start_managed_process via snackbar - update_adapters_list() # Refresh button states even on failure - - # --- Initial population of the list --- # - update_adapters_list() - - new_adapter_path_field = ft.TextField(label="新适配器路径 (.py 文件)", expand=True) - - # --- File Picker Logic --- # - def pick_adapter_file_result(e: ft.FilePickerResultEvent): - """Callback when the file picker dialog closes.""" - if e.files: - selected_file = e.files[0] # Get the first selected file - new_adapter_path_field.value = selected_file.path - new_adapter_path_field.update() - show_snackbar(page, f"已选择文件: {os.path.basename(selected_file.path)}") - else: - show_snackbar(page, "未选择文件") - - def open_file_picker(e): - """Opens the file picker dialog.""" - if app_state.file_picker: - app_state.file_picker.on_result = pick_adapter_file_result - app_state.file_picker.pick_files( - allow_multiple=False, - allowed_extensions=["py"], # Only allow Python files - dialog_title="选择适配器 Python 文件", - ) - else: - show_snackbar(page, "错误:无法打开文件选择器", error=True) - - # Ensure the file picker's on_result is connected when the view is created - if app_state.file_picker: - app_state.file_picker.on_result = pick_adapter_file_result - else: - # This case shouldn't happen if launcher.py runs correctly - print("[create_adapters_view] Warning: FilePicker not available during view creation.") - - def add_adapter(e): - """Adds a new adapter path to the list and config.""" - new_path = new_adapter_path_field.value.strip() - - if not new_path: - show_snackbar(page, "请输入适配器路径", error=True) - return - # Basic validation (you might want more robust checks) - if not new_path.lower().endswith(".py"): - show_snackbar(page, "路径应指向一个 Python (.py) 文件", error=True) - return - # Optional: Check if the file actually exists? Might be too strict. - # if not os.path.exists(new_path): - # show_snackbar(page, f"文件未找到: {new_path}", error=True) - # return - - if new_path in app_state.adapter_paths: - show_snackbar(page, "此适配器路径已存在") - return - - app_state.adapter_paths.append(new_path) - app_state.gui_config["adapters"] = app_state.adapter_paths - - save_successful = save_config(app_state.gui_config) - - if save_successful: - new_adapter_path_field.value = "" # Clear input field - update_adapters_list() # Update the list view - new_adapter_path_field.update() # Update the input field visually - show_snackbar(page, "适配器已添加") - else: - show_snackbar(page, "保存配置失败", error=True) - # Revert state if save failed - try: # Add try-except just in case pop fails unexpectedly - app_state.adapter_paths.pop() - app_state.gui_config["adapters"] = app_state.adapter_paths - except IndexError: - pass # Silently ignore if list was empty during failed save - - return ft.View( - "/adapters", - [ - ft.AppBar(title=ft.Text("适配器管理"), bgcolor=ft.colors.SURFACE_VARIANT), - # Use a Container with the padding property instead - ft.Container( - padding=ft.padding.all(10), # Set padding property on the Container - content=ft.Column( # Place the original content inside the Container - [ - ft.Text("已配置的适配器:"), - adapters_list_view, # ListView for adapters - ft.Divider(), - ft.Row( - [ - new_adapter_path_field, - # --- Add Browse Button --- # - ft.IconButton( - ft.icons.FOLDER_OPEN_OUTLINED, - tooltip="浏览文件...", - on_click=open_file_picker, # Call the file picker opener - ), - ft.IconButton(ft.icons.ADD_CIRCLE_OUTLINE, tooltip="添加适配器", on_click=add_adapter), - ] - ), - ], - expand=True, - ), - ), - ], - ) - - -# --- Settings View --- # -def create_settings_view(page: ft.Page, app_state: "AppState") -> ft.View: - """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_deprecated", - [ft.AppBar(title=ft.Text("Settings (Deprecated)")), ft.Text("This view has moved to ui_settings_view.py")], - ) - - -# --- Process Output View (for Adapters etc.) --- # -def create_process_output_view(page: ft.Page, app_state: "AppState", process_id: str) -> Optional[ft.View]: - """Creates a view to display the output of a specific managed process.""" - # Import stop function - from .process_manager import stop_managed_process - - process_state = app_state.managed_processes.get(process_id) - - if not process_state: - print(f"[Create Output View] Error: Process state not found for ID: {process_id}") - # Optionally show an error view or navigate back - # For now, return None, route_change might handle this - return None - - # Get or create the ListView for this process - # It should have been created and stored by start_managed_process - if process_state.output_list_view is None: - print(f"[Create Output View] Warning: ListView not found in state for {process_id}. Creating fallback.") - # Create a fallback, though this indicates an issue elsewhere - process_state.output_list_view = ft.ListView(expand=True, spacing=2, padding=5, auto_scroll=True) - process_state.output_list_view.controls.append( - ft.Text( - "--- Error: Output view created unexpectedly. Process might need restart. ---", - italic=True, - color=ft.colors.ERROR, - ) - ) - - output_lv = process_state.output_list_view - - # --- Stop Button --- # - stop_button = ft.ElevatedButton( - "停止进程", - icon=ft.icons.STOP_CIRCLE_OUTLINED, - on_click=lambda _: stop_managed_process(process_id, page, app_state), - bgcolor=ft.colors.with_opacity(0.6, ft.colors.RED_ACCENT_100), - color=ft.colors.WHITE, - tooltip=f"停止 {process_state.display_name}", - ) - - # --- Auto-scroll Toggle (Specific to this view) --- # - # Create a local state for this view's scroll toggle - is_this_view_auto_scroll = ft.Ref[bool]() - is_this_view_auto_scroll.current = True # Default to true - output_lv.auto_scroll = is_this_view_auto_scroll.current - - def toggle_this_view_auto_scroll(e): - is_this_view_auto_scroll.current = not is_this_view_auto_scroll.current - output_lv.auto_scroll = is_this_view_auto_scroll.current - e.control.text = "自动滚动 开" if is_this_view_auto_scroll.current else "自动滚动 关" - e.control.update() - print(f"Process '{process_id}' view auto-scroll set to: {is_this_view_auto_scroll.current}") - - auto_scroll_button = ft.OutlinedButton( - "自动滚动 开" if is_this_view_auto_scroll.current else "自动滚动 关", - # icon=ft.icons.SCROLLING, - icon=ft.icons.SWAP_VERT, # Use a valid icon for toggling - on_click=toggle_this_view_auto_scroll, - tooltip="切换此视图的自动滚动", - ) - - return ft.View( - route=f"/adapters/{process_id}", # Dynamic route - appbar=ft.AppBar( - title=ft.Text(f"输出: {process_state.display_name}"), - bgcolor=ft.colors.SURFACE_VARIANT, - actions=[ - stop_button, - auto_scroll_button, - ft.Container(width=5), # Spacer - ], - ), - controls=[ - output_lv # Display the specific ListView for this process - ], - padding=0, - ) diff --git a/src/MaiGoi/utils.py b/src/MaiGoi/utils.py deleted file mode 100644 index 20d58eec6..000000000 --- a/src/MaiGoi/utils.py +++ /dev/null @@ -1,126 +0,0 @@ -import flet as ft -import os -import sys -import subprocess -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from .state import AppState # Avoid circular import for type hinting - - -async def update_page_safe(page: Optional[ft.Page]): - """Safely call page.update() if the page object is valid.""" - if page: - try: - await page.update() - except Exception: - # Reduce noise, perhaps only print if debug is enabled later - # print(f"Error during safe page update: {e}") - pass # Silently ignore update errors, especially during shutdown - - -def show_snackbar(page: Optional[ft.Page], message: str, error: bool = False): - """Helper function to display a SnackBar.""" - if not page: - print(f"[Snackbar - No Page] {'Error' if error else 'Info'}: {message}") - return - try: - page.snack_bar = ft.SnackBar( - ft.Text(message), - bgcolor=ft.colors.ERROR if error else None, - open=True, - ) - page.update() - except Exception as e: - print(f"Error showing snackbar: {e}") - - -def run_script(script_path: str, page: Optional["ft.Page"], app_state: Optional["AppState"], is_python: bool = False): - """Runs a script file (.bat or .py) in a new process/window.""" - if not app_state or not app_state.script_dir: - print("[run_script] Error: AppState or script_dir not available.", flush=True) - if page: - show_snackbar(page, "错误:无法确定脚本目录", error=True) - return - - # Construct the full path to the script - full_script_path = os.path.join(app_state.script_dir, script_path) - print(f"[run_script] Attempting to run: {full_script_path}", flush=True) - - try: - if not os.path.exists(full_script_path): - print(f"[run_script] Error: Script file not found: {full_script_path}", flush=True) - if page: - show_snackbar(page, f"错误:脚本文件未找到\\n{script_path}", error=True) - return - - # --- Platform-specific execution --- # - if sys.platform == "win32": - if script_path.lower().endswith(".bat"): - print("[run_script] Using 'start cmd /k' for .bat on Windows.", flush=True) - # Use start cmd /k to keep the window open after script finishes - subprocess.Popen(f'start cmd /k "{full_script_path}"', shell=True, cwd=app_state.script_dir) - elif script_path.lower().endswith(".py"): - print("[run_script] Using Python executable for .py on Windows.", flush=True) - # Run Python script using the current interpreter in a new console window - # Using sys.executable ensures the correct Python environment is used. - # 'start' is a cmd command, so shell=True is needed. - # We don't use /k here, the Python process itself will keep the window open if needed (e.g., input()). - subprocess.Popen( - f'start "Running {script_path}" "{sys.executable}" "{full_script_path}"', - shell=True, - cwd=app_state.script_dir, - ) - else: - print( - f"[run_script] Attempting generic 'start' for unknown file type on Windows: {script_path}", - flush=True, - ) - # Try generic start for other file types, might open associated program - subprocess.Popen(f'start "{full_script_path}"', shell=True, cwd=app_state.script_dir) - else: # Linux/macOS - if script_path.lower().endswith(".py"): - print("[run_script] Using Python executable for .py on non-Windows.", flush=True) - # On Unix-like systems, we typically need a terminal emulator to see output. - # This example uses xterm, adjust if needed for other terminals (gnome-terminal, etc.) - # The '-e' flag is common for executing a command. - try: - subprocess.Popen(["xterm", "-e", sys.executable, full_script_path], cwd=app_state.script_dir) - except FileNotFoundError: - print( - "[run_script] xterm not found. Trying to run Python directly (output might be lost).", - flush=True, - ) - try: - subprocess.Popen([sys.executable, full_script_path], cwd=app_state.script_dir) - except Exception as e_direct: - print(f"[run_script] Error running Python script directly: {e_direct}", flush=True) - if page: - show_snackbar(page, f"运行脚本时出错: {e_direct}", error=True) - return - elif os.access(full_script_path, os.X_OK): # Check if it's executable - print("[run_script] Running executable script directly on non-Windows.", flush=True) - # Similar terminal issue might apply here if it's a console app - try: - subprocess.Popen([full_script_path], cwd=app_state.script_dir) - except Exception as e_exec: - print(f"[run_script] Error running executable script: {e_exec}", flush=True) - if page: - show_snackbar(page, f"运行脚本时出错: {e_exec}", error=True) - return - else: - print( - f"[run_script] Don't know how to run non-executable, non-python script on non-Windows: {script_path}", - flush=True, - ) - if page: - show_snackbar(page, f"无法运行此类型的文件: {script_path}", error=True) - return - - if page: - show_snackbar(page, f"正在尝试运行脚本: {script_path}") - - except Exception as e: - print(f"[run_script] Unexpected error running script '{script_path}': {e}", flush=True) - if page: - show_snackbar(page, f"运行脚本时发生意外错误: {e}", error=True)