Feat:让启动器使用api,修改gui设计
This commit is contained in:
5
@flet_new_.mdc
Normal file
5
@flet_new_.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
27
launcher.py
27
launcher.py
@@ -15,11 +15,13 @@ from src.MaiGoi.ui_views import (
|
||||
create_main_view,
|
||||
create_console_view,
|
||||
create_adapters_view,
|
||||
create_settings_view,
|
||||
create_process_output_view,
|
||||
)
|
||||
from src.MaiGoi.config_manager import load_config
|
||||
|
||||
# --- Import the new settings view --- #
|
||||
from src.MaiGoi.ui_settings_view import create_settings_view
|
||||
|
||||
# --- Global AppState instance --- #
|
||||
# This holds all the state previously scattered as globals
|
||||
app_state = AppState()
|
||||
@@ -109,6 +111,7 @@ def route_change(route: ft.RouteChangeEvent):
|
||||
adapters_view = create_adapters_view(page, app_state)
|
||||
page.views.append(adapters_view)
|
||||
elif target_route == "/settings":
|
||||
# Call the new settings view function
|
||||
settings_view = create_settings_view(page, app_state)
|
||||
page.views.append(settings_view)
|
||||
|
||||
@@ -161,6 +164,9 @@ def view_pop(e: ft.ViewPopEvent):
|
||||
# --- Main Application Setup --- #
|
||||
def main(page: ft.Page):
|
||||
# Load initial config and store in state
|
||||
# 启动时清除/logs/interest/interest_history.log
|
||||
if os.path.exists("logs/interest/interest_history.log"):
|
||||
os.remove("logs/interest/interest_history.log")
|
||||
loaded_config = load_config()
|
||||
app_state.gui_config = loaded_config
|
||||
app_state.adapter_paths = loaded_config.get("adapters", []).copy() # Get adapter paths
|
||||
@@ -179,12 +185,21 @@ def main(page: ft.Page):
|
||||
print("[Main] FilePicker created and added to page overlay.")
|
||||
|
||||
page.title = "MaiBot 启动器"
|
||||
page.window_width = 500
|
||||
page.window_height = 650 # Increased height slightly for monitor
|
||||
page.window.width = 1400
|
||||
page.window.height = 1000 # Increased height slightly for monitor
|
||||
page.vertical_alignment = ft.MainAxisAlignment.START
|
||||
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
|
||||
|
||||
# --- Apply Theme from Config --- #
|
||||
saved_theme = app_state.gui_config.get("theme", "System").upper()
|
||||
try:
|
||||
page.theme_mode = ft.ThemeMode[saved_theme]
|
||||
print(f"[Main] Applied theme from config: {page.theme_mode}")
|
||||
except KeyError:
|
||||
print(f"[Main] Warning: Invalid theme '{saved_theme}' in config. Falling back to System.")
|
||||
page.theme_mode = ft.ThemeMode.SYSTEM
|
||||
page.padding = 10 # Reduced padding slightly
|
||||
|
||||
page.padding = 0 # <-- 将页面 padding 设置为 0
|
||||
|
||||
# --- Create the main 'Start Bot' button and store in state --- #
|
||||
# This button needs to exist before the first route_change call
|
||||
@@ -210,6 +225,10 @@ def main(page: ft.Page):
|
||||
# Prevent immediate close to allow cleanup
|
||||
page.window_prevent_close = True
|
||||
|
||||
# --- Hide Native Title Bar --- #
|
||||
# page.window_title_bar_hidden = True
|
||||
# page.window.frameless = True
|
||||
|
||||
# --- Initial Navigation --- #
|
||||
# Trigger the initial route change to build the first view
|
||||
page.go(page.route if page.route else "/")
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
BIN
src/MaiGoi/assets/button_shape.png
Normal file
BIN
src/MaiGoi/assets/button_shape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
BIN
src/MaiGoi/assets/icon.png
Normal file
BIN
src/MaiGoi/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
BIN
src/MaiGoi/assets/lihui.png
Normal file
BIN
src/MaiGoi/assets/lihui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
BIN
src/MaiGoi/assets/lihui_bhl.png
Normal file
BIN
src/MaiGoi/assets/lihui_bhl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
@@ -1,72 +1,99 @@
|
||||
import os
|
||||
import toml
|
||||
|
||||
# Use tomlkit for dumping to preserve comments/formatting if needed,
|
||||
# but stick to `toml` for loading unless specific features are required.
|
||||
import tomlkit
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
CONFIG_DIR = "config"
|
||||
CONFIG_FILE = "gui_config.toml"
|
||||
DEFAULT_CONFIG = {"adapters": []}
|
||||
CONFIG_DIR = Path("config")
|
||||
# Define default filenames for different config types
|
||||
CONFIG_FILES = {"gui": "gui_config.toml", "lpmm": "lpmm_config.toml", "bot": "bot_config.toml"}
|
||||
DEFAULT_GUI_CONFIG = {"adapters": [], "theme": "System"} # Add default theme
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Gets the full path to the config file."""
|
||||
# Assume script_dir is the project root for simplicity here
|
||||
# A more robust solution might involve finding the project root explicitly
|
||||
script_dir = Path(os.path.dirname(os.path.abspath(__file__))).parent.parent
|
||||
config_path = script_dir / CONFIG_DIR / CONFIG_FILE
|
||||
def get_config_path(config_type: str = "gui") -> Optional[Path]:
|
||||
"""Gets the full path to the specified config file type."""
|
||||
filename = CONFIG_FILES.get(config_type)
|
||||
if not filename:
|
||||
print(f"[Config] Error: Unknown config type '{config_type}'")
|
||||
return None
|
||||
|
||||
# Determine the base directory relative to this file
|
||||
# Assumes config_manager.py is in src/MaiGoi/
|
||||
try:
|
||||
script_dir = Path(__file__).parent.parent.parent # Project Root (MaiBot-Core/)
|
||||
config_path = script_dir / CONFIG_DIR / filename
|
||||
return config_path
|
||||
except Exception as e:
|
||||
print(f"[Config] Error determining config path for type '{config_type}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Loads the configuration from the TOML file."""
|
||||
config_path = get_config_path()
|
||||
print(f"[Config] Loading config from: {config_path}")
|
||||
def load_config(config_type: str = "gui") -> Dict[str, Any]:
|
||||
"""Loads the configuration from the specified TOML file type."""
|
||||
config_path = get_config_path(config_type)
|
||||
if not config_path:
|
||||
return {} # Return empty dict if path is invalid
|
||||
|
||||
print(f"[Config] Loading {config_type} config from: {config_path}")
|
||||
default_config_to_use = DEFAULT_GUI_CONFIG if config_type == "gui" else {}
|
||||
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
|
||||
if config_path.is_file():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
# Use standard toml for loading, it's generally more robust
|
||||
config_data = toml.load(f)
|
||||
# Ensure essential keys exist, merge with defaults if necessary
|
||||
# For now, just check for 'adapters'
|
||||
print(f"[Config] {config_type} config loaded successfully.")
|
||||
# Basic check for GUI config default keys
|
||||
if config_type == "gui":
|
||||
if "adapters" not in config_data:
|
||||
config_data["adapters"] = DEFAULT_CONFIG["adapters"]
|
||||
print("[Config] Config loaded successfully.")
|
||||
config_data["adapters"] = DEFAULT_GUI_CONFIG["adapters"]
|
||||
if "theme" not in config_data:
|
||||
config_data["theme"] = DEFAULT_GUI_CONFIG["theme"]
|
||||
return config_data
|
||||
else:
|
||||
print("[Config] Config file not found, using default config.")
|
||||
# Save default config if file doesn't exist
|
||||
save_config(DEFAULT_CONFIG)
|
||||
return DEFAULT_CONFIG.copy() # Return a copy
|
||||
print(f"[Config] {config_type} config file not found, using default.")
|
||||
# Save default config only if it's the GUI config
|
||||
if config_type == "gui":
|
||||
save_config(default_config_to_use, config_type=config_type)
|
||||
return default_config_to_use.copy() # Return a copy
|
||||
except FileNotFoundError:
|
||||
print("[Config] Config file not found (FileNotFoundError), using default config.")
|
||||
save_config(DEFAULT_CONFIG) # Attempt to save default
|
||||
return DEFAULT_CONFIG.copy()
|
||||
print(f"[Config] {config_type} config file not found (FileNotFoundError), using default.")
|
||||
if config_type == "gui":
|
||||
save_config(default_config_to_use, config_type=config_type) # Attempt to save default
|
||||
return default_config_to_use.copy()
|
||||
except toml.TomlDecodeError as e:
|
||||
print(f"[Config] Error decoding TOML file: {e}. Using default config.")
|
||||
# Optionally: backup the corrupted file here
|
||||
return DEFAULT_CONFIG.copy()
|
||||
print(f"[Config] Error decoding {config_type} TOML file: {e}. Using default.")
|
||||
return default_config_to_use.copy()
|
||||
except Exception as e:
|
||||
print(f"[Config] An unexpected error occurred loading config: {e}. Using default config.")
|
||||
print(f"[Config] An unexpected error occurred loading {config_type} config: {e}.")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return DEFAULT_CONFIG.copy()
|
||||
return default_config_to_use.copy()
|
||||
|
||||
|
||||
def save_config(config_data: Dict[str, Any]) -> bool:
|
||||
"""Saves the configuration dictionary to the TOML file."""
|
||||
config_path = get_config_path()
|
||||
print(f"[Config] Saving config to: {config_path}")
|
||||
def save_config(config_data: Dict[str, Any], config_type: str = "gui") -> bool:
|
||||
"""Saves the configuration dictionary to the specified TOML file type."""
|
||||
config_path = get_config_path(config_type)
|
||||
if not config_path:
|
||||
return False # Cannot save if path is invalid
|
||||
|
||||
print(f"[Config] Saving {config_type} config to: {config_path}")
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
toml.dump(config_data, f)
|
||||
print("[Config] Config saved successfully.")
|
||||
# Use tomlkit.dump if preserving format/comments is important
|
||||
# Otherwise, stick to toml.dump for simplicity
|
||||
tomlkit.dump(config_data, f) # Using tomlkit here
|
||||
print(f"[Config] {config_type} config saved successfully.")
|
||||
return True
|
||||
except IOError as e:
|
||||
print(f"[Config] Error writing config file (IOError): {e}")
|
||||
print(f"[Config] Error writing {config_type} config file (IOError): {e}")
|
||||
except Exception as e:
|
||||
print(f"[Config] An unexpected error occurred saving config: {e}")
|
||||
print(f"[Config] An unexpected error occurred saving {config_type} config: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -5,23 +5,53 @@ import json
|
||||
import time
|
||||
import traceback
|
||||
import random
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
# --- 配置 (可以从 launcher.py 传入或在此处定义) ---
|
||||
LOG_FILE_PATH = os.path.join("logs", "interest", "interest_history.log")
|
||||
# 移除临时文件路径注释,保留作为备用
|
||||
GUI_COMMAND_PATH = "temp_command/gui_command.json" # 旧方式,保留作为备用
|
||||
API_HOST = "localhost" # API主机名
|
||||
API_PORT = 8000 # API端口,默认值
|
||||
API_BASE_URL = f"http://{API_HOST}:{API_PORT}/api/v1" # API基础URL
|
||||
|
||||
# 如果设置了环境变量,则使用环境变量中的配置
|
||||
if "MAIBOT_API_PORT" in os.environ:
|
||||
try:
|
||||
API_PORT = int(os.environ["MAIBOT_API_PORT"])
|
||||
API_BASE_URL = f"http://{API_HOST}:{API_PORT}/api/v1"
|
||||
print(f"[配置] 使用环境变量中的API端口: {API_PORT}")
|
||||
except ValueError:
|
||||
print(f"[配置] 环境变量MAIBOT_API_PORT值无效: {os.environ['MAIBOT_API_PORT']}")
|
||||
|
||||
print(f"[配置] 使用API地址: {API_BASE_URL}")
|
||||
|
||||
REFRESH_INTERVAL_SECONDS = 1 # 刷新间隔(秒)
|
||||
MAX_HISTORY_POINTS = 1000 # 图表数据点 (Tkinter version uses 1000)
|
||||
MAX_STREAMS_TO_DISPLAY = 15 # 最多显示的流数量 (Tkinter version uses 15)
|
||||
MAX_QUEUE_SIZE = 30 # 历史想法队列最大长度 (Tkinter version uses 30)
|
||||
CHART_HEIGHT = 250 # 图表区域高度
|
||||
DEFAULT_AUTO_SCROLL = True # 默认开启自动滚动
|
||||
|
||||
# --- 子流聊天状态枚举 --- #
|
||||
# 与心流模块中定义保持一致
|
||||
CHAT_STATES = [
|
||||
{"key": "ABSENT", "text": "没在看群"},
|
||||
{"key": "CHAT", "text": "随便水群"},
|
||||
{"key": "FOCUSED", "text": "认真水群"},
|
||||
]
|
||||
|
||||
# --- 重要: API使用的实际枚举值,确保与ChatState一致 --- #
|
||||
# API需要的是中文描述值,而不是英文枚举键
|
||||
API_CHAT_STATE_VALUES = {"ABSENT": "没在看群", "CHAT": "随便水群", "FOCUSED": "认真水群"}
|
||||
|
||||
|
||||
# --- 辅助函数 ---
|
||||
def format_timestamp(ts):
|
||||
"""辅助函数:格式化时间戳,处理 None 或无效值"""
|
||||
if ts is None:
|
||||
return "N/A"
|
||||
return "无"
|
||||
try:
|
||||
# 假设 ts 是 float 类型的时间戳
|
||||
dt_object = datetime.fromtimestamp(float(ts))
|
||||
@@ -38,6 +68,33 @@ def get_random_flet_color():
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
# --- 新增: 发送GUI命令到文件 ---
|
||||
def send_gui_command(subflow_id, target_state):
|
||||
"""发送GUI命令到文件,用于改变子心流状态"""
|
||||
try:
|
||||
# 确保目录存在
|
||||
command_dir = os.path.dirname(GUI_COMMAND_PATH)
|
||||
if command_dir: # 如果有目录部分
|
||||
os.makedirs(command_dir, exist_ok=True)
|
||||
|
||||
# 创建命令数据
|
||||
command_data = {
|
||||
"subflow_id": subflow_id,
|
||||
"target_state": target_state, # 不再转为大写,保留原始状态值
|
||||
}
|
||||
|
||||
# 写入文件
|
||||
with open(GUI_COMMAND_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(command_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[InterestMonitor] 已发送命令: 将子流 {subflow_id} 设置为 {target_state}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[InterestMonitor] 发送GUI命令出错: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
class InterestMonitorDisplay(ft.Column):
|
||||
"""一个 Flet 控件,用于显示兴趣监控图表和信息。"""
|
||||
|
||||
@@ -53,24 +110,19 @@ class InterestMonitorDisplay(ft.Column):
|
||||
self.stream_colors = {} # {stream_id: color_string}
|
||||
self.selected_stream_id_for_details = None
|
||||
self.last_log_read_time = 0 # 上次读取日志的时间戳
|
||||
self.is_expanded = True # 新增:跟踪是否展开显示
|
||||
self.stream_details = {} # 存储子流详情
|
||||
# 新增:监控器切换显示回调函数
|
||||
self.on_toggle = None # 外部可以设置此回调函数
|
||||
|
||||
# --- 新增:存储其他参数 ---
|
||||
# 顶层信息 (直接使用 Text 控件引用)
|
||||
self.global_mai_state_text = ft.Text("状态: N/A", size=11)
|
||||
self.global_main_mind_text = ft.Text(
|
||||
"想法: N/A", size=11, overflow=ft.TextOverflow.ELLIPSIS, tooltip="完整想法请查看历史记录"
|
||||
)
|
||||
self.global_subflow_count_text = ft.Text("子流数: 0", size=11)
|
||||
self.global_mai_state_text = ft.Text("状态: N/A | 活跃聊天数: 0", size=10, width=300)
|
||||
# 子流最新状态 (key: stream_id)
|
||||
self.stream_sub_minds = {}
|
||||
self.stream_chat_states = {}
|
||||
self.stream_threshold_status = {}
|
||||
self.stream_last_active = {}
|
||||
# self.stream_last_interaction = {} # Tkinter 有但日志似乎没有?
|
||||
|
||||
# 新增:历史想法队列
|
||||
self.main_mind_history = deque(maxlen=MAX_QUEUE_SIZE)
|
||||
self.last_main_mind_timestamp = 0
|
||||
|
||||
# --- UI 控件引用 ---
|
||||
self.status_text = ft.Text("正在初始化监控器...", size=10, color=ft.colors.SECONDARY)
|
||||
@@ -79,8 +131,6 @@ class InterestMonitorDisplay(ft.Column):
|
||||
self.global_info_row = ft.Row(
|
||||
controls=[
|
||||
self.global_mai_state_text,
|
||||
self.global_main_mind_text,
|
||||
self.global_subflow_count_text,
|
||||
],
|
||||
spacing=15,
|
||||
wrap=False, # 防止换行
|
||||
@@ -97,36 +147,83 @@ class InterestMonitorDisplay(ft.Column):
|
||||
)
|
||||
|
||||
self.stream_dropdown = ft.Dropdown(
|
||||
label="选择流查看详情", options=[], width=300, on_change=self.on_stream_selected
|
||||
label="选择流查看详情",
|
||||
options=[],
|
||||
width=200, # 调整宽度以适应并排布局
|
||||
on_change=self.on_stream_selected,
|
||||
)
|
||||
|
||||
# 创建合并的详情图表
|
||||
self.detail_chart_combined = ft.LineChart(height=CHART_HEIGHT)
|
||||
|
||||
# --- 创建状态控制下拉菜单 ---
|
||||
self.state_dropdown = ft.Dropdown(
|
||||
label="选择目标状态",
|
||||
options=[ft.dropdown.Option(text=state["text"], key=state["key"]) for state in CHAT_STATES],
|
||||
width=150, # 调整宽度以适应并排布局
|
||||
)
|
||||
|
||||
# --- 创建控制按钮 ---
|
||||
self.control_button = ft.ElevatedButton(
|
||||
"设置状态",
|
||||
icon=ft.icons.SWAP_HORIZ,
|
||||
on_click=self.handle_control_button_click,
|
||||
disabled=True, # 初始禁用
|
||||
)
|
||||
|
||||
# --- 控制按钮行 ---
|
||||
self.control_row = ft.Row(
|
||||
[
|
||||
self.state_dropdown,
|
||||
self.control_button,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=10,
|
||||
)
|
||||
self.detail_chart_interest = ft.LineChart(height=CHART_HEIGHT)
|
||||
self.detail_chart_probability = ft.LineChart(height=CHART_HEIGHT)
|
||||
|
||||
# --- 单个流详情文本控件 (Column) ---
|
||||
self.detail_texts = ft.Column(
|
||||
[
|
||||
# --- 添加新的 Text 控件来显示详情 ---
|
||||
ft.Text("想法: N/A", size=11, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS), # index 0: sub_mind
|
||||
ft.Text("状态: N/A", size=11), # index 1: chat_state
|
||||
ft.Text("阈值以上: N/A", size=11), # index 2: threshold
|
||||
ft.Text("最后活跃: N/A", size=11), # index 3: last_active
|
||||
# ft.Text("最后交互: N/A", size=11), # 如果需要的话
|
||||
# --- 合并所有详情为一行 ---
|
||||
ft.Text(
|
||||
"状态: 无 | 最后活跃: 无",
|
||||
size=20,
|
||||
no_wrap=True,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
tooltip="查看详细状态信息",
|
||||
),
|
||||
],
|
||||
spacing=2,
|
||||
)
|
||||
|
||||
# --- 历史想法 ListView ---
|
||||
self.mind_history_listview = ft.ListView(expand=True, spacing=5, padding=5)
|
||||
# --- 新增:切换显示按钮 ---
|
||||
self.toggle_button = ft.IconButton(
|
||||
icon=ft.icons.ARROW_UPWARD, tooltip="隐藏兴趣监控", on_click=self.toggle_display
|
||||
)
|
||||
|
||||
# --- 新增:顶部栏包含状态和切换按钮 ---
|
||||
self.top_bar = ft.Row(
|
||||
[
|
||||
self.status_text,
|
||||
# ft.Spacer(), # Flet没有Spacer组件
|
||||
ft.Container(expand=True), # 使用可扩展容器代替Spacer
|
||||
self.toggle_button,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
)
|
||||
|
||||
# --- 构建整体布局 ---
|
||||
# 创建 Tabs 控件 (现在是 Column 的直接子控件)
|
||||
# 创建 Tabs 控件
|
||||
self.tabs_control = ft.Tabs(
|
||||
selected_index=0,
|
||||
animation_duration=300,
|
||||
tabs=[
|
||||
ft.Tab(
|
||||
text="所有流兴趣度",
|
||||
content=ft.Row(
|
||||
content=ft.Column(
|
||||
controls=[
|
||||
self.global_info_row, # 将全局信息行移动到这里
|
||||
ft.Row(
|
||||
controls=[
|
||||
self.main_chart, # 图表在左侧
|
||||
self.legend_column, # 图例在右侧
|
||||
@@ -134,49 +231,210 @@ class InterestMonitorDisplay(ft.Column):
|
||||
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||
expand=True, # 让 Row 扩展
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ft.Tab(
|
||||
text="单个流详情",
|
||||
content=ft.Column(
|
||||
[
|
||||
self.stream_dropdown,
|
||||
ft.Divider(height=5, color=ft.colors.TRANSPARENT),
|
||||
self.detail_texts, # 显示文本信息的 Column
|
||||
ft.Divider(height=10, color=ft.colors.TRANSPARENT),
|
||||
# 添加顶部间距,防止被标签遮挡
|
||||
ft.Container(height=10),
|
||||
# --- 修改:流选择、状态设置和详情文本放在同一行 ---
|
||||
ft.Row(
|
||||
[
|
||||
ft.Column(
|
||||
[ft.Text("兴趣度", weight=ft.FontWeight.BOLD), self.detail_chart_interest],
|
||||
expand=1,
|
||||
),
|
||||
ft.Column(
|
||||
[ft.Text("HFC概率", weight=ft.FontWeight.BOLD), self.detail_chart_probability],
|
||||
expand=1,
|
||||
),
|
||||
self.stream_dropdown, # 流选择下拉菜单
|
||||
ft.Container(width=10), # 添加间距
|
||||
self.control_row, # 状态控制行
|
||||
ft.Container(width=15), # 添加间距
|
||||
self.detail_texts, # 显示文本信息的 Column (现在移到这一行)
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
ft.Divider(height=10, color=ft.colors.TRANSPARENT),
|
||||
# 合并的图表显示
|
||||
ft.Column(
|
||||
[ft.Text("兴趣度和HFC概率", weight=ft.FontWeight.BOLD), self.detail_chart_combined],
|
||||
expand=1,
|
||||
),
|
||||
],
|
||||
scroll=ft.ScrollMode.ADAPTIVE, # 自适应滚动
|
||||
),
|
||||
),
|
||||
# --- 添加历史想法 Tab ---
|
||||
ft.Tab(
|
||||
text="麦麦历史想法",
|
||||
content=self.mind_history_listview, # 直接使用 ListView
|
||||
),
|
||||
],
|
||||
expand=True, # 让 Tabs 在父 Column 中扩展
|
||||
)
|
||||
|
||||
# 主要内容区域(可隐藏部分)
|
||||
self.content_area = ft.Column(
|
||||
[
|
||||
self.tabs_control, # 标签页
|
||||
],
|
||||
expand=True,
|
||||
)
|
||||
|
||||
self.controls = [
|
||||
self.status_text,
|
||||
self.global_info_row, # 添加全局信息行
|
||||
# --- Tabs 直接放在 Column 里 --- #
|
||||
self.tabs_control,
|
||||
self.top_bar, # 顶部栏包含状态和切换按钮
|
||||
self.content_area, # 可隐藏的内容区域
|
||||
]
|
||||
|
||||
print("[InterestMonitor] 初始化完成")
|
||||
|
||||
# --- 新增: 状态切换处理函数 ---
|
||||
async def change_stream_state(self, e):
|
||||
"""处理状态切换按钮点击"""
|
||||
if not self.selected_stream_id_for_details or not self.state_dropdown.value:
|
||||
# 显示错误提示
|
||||
if self.page:
|
||||
self.page.snack_bar = ft.SnackBar(content=ft.Text("请先选择子流和目标状态"), show_close_icon=True)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
subflow_id = self.selected_stream_id_for_details
|
||||
target_state = self.state_dropdown.value # 这是英文的枚举值如 "ABSENT"
|
||||
|
||||
# 获取对应的中文显示文本,用于通知
|
||||
state_text = next((state["text"] for state in CHAT_STATES if state["key"] == target_state), target_state)
|
||||
|
||||
try:
|
||||
# 使用API切换子心流状态
|
||||
success, error_msg = await self.change_subheartflow_status(subflow_id, target_state)
|
||||
|
||||
if success:
|
||||
# 命令发送成功
|
||||
if self.page:
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(f"已成功将子流 {subflow_id} 设置为 {state_text}"),
|
||||
show_close_icon=True,
|
||||
bgcolor=ft.colors.GREEN_200,
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
else:
|
||||
# 命令发送失败
|
||||
if self.page:
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(f"命令发送失败: {error_msg}"), show_close_icon=True, bgcolor=ft.colors.RED_200
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
|
||||
except Exception as ex:
|
||||
print(f"[调试] 切换子心流状态时出错: {ex}")
|
||||
traceback.print_exc()
|
||||
if self.page:
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(f"命令发送失败,请查看日志: {str(ex)}"),
|
||||
show_close_icon=True,
|
||||
bgcolor=ft.colors.RED_200,
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
|
||||
async def change_subheartflow_status(self, subflow_id, target_state):
|
||||
"""通过API改变子心流状态"""
|
||||
try:
|
||||
# 验证参数
|
||||
if not subflow_id:
|
||||
print("[调试] 错误: subflow_id为空")
|
||||
return False, "子流ID不能为空"
|
||||
|
||||
# 验证状态值是否为有效的枚举
|
||||
valid_states = [state["key"] for state in CHAT_STATES]
|
||||
if target_state not in valid_states:
|
||||
print(f"[调试] 错误: 无效的目标状态 {target_state},有效值: {valid_states}")
|
||||
return False, f"无效的目标状态: {target_state}"
|
||||
|
||||
# 转换状态到API期望的格式
|
||||
api_state_value = API_CHAT_STATE_VALUES.get(target_state, target_state)
|
||||
print(f"[调试] 转换状态值: {target_state} -> {api_state_value}")
|
||||
|
||||
url = f"{API_BASE_URL}/gui/subheartflow/forced_change_status"
|
||||
|
||||
# API需要的是查询参数,使用转换后的状态值
|
||||
params = {"subheartflow_id": subflow_id, "status": api_state_value}
|
||||
|
||||
print(f"[调试] 准备发送API请求: URL={url}")
|
||||
print(f"[调试] URL参数={params}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client: # 增加超时时间到30秒
|
||||
print("[调试] 正在发送API请求...")
|
||||
try:
|
||||
response = await client.post(url, params=params)
|
||||
|
||||
print(f"[调试] 收到API响应: 状态码={response.status_code}")
|
||||
print(f"[调试] 响应内容: {response.text}")
|
||||
except httpx.TimeoutException:
|
||||
print(f"[调试] API请求超时,服务器可能未运行或端口配置错误: {url}")
|
||||
return False, "API请求超时"
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result = response.json()
|
||||
print(f"[调试] 解析响应JSON: {result}")
|
||||
if result.get("status") == "success":
|
||||
print(f"[InterestMonitor] API请求成功: 将子流 {subflow_id} 设置为 {target_state}")
|
||||
return True, None
|
||||
else:
|
||||
error_msg = result.get("reason", "未知错误")
|
||||
print(f"[InterestMonitor] API请求失败: {error_msg}")
|
||||
return False, error_msg
|
||||
except json.JSONDecodeError:
|
||||
print(f"[调试] 响应不是有效的JSON: {response.text}")
|
||||
return False, "服务器响应不是有效的JSON"
|
||||
else:
|
||||
print(f"[InterestMonitor] API请求失败: HTTP状态码 {response.status_code}")
|
||||
return False, f"HTTP错误: {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[InterestMonitor] 调用API出错: {e}")
|
||||
traceback.print_exc()
|
||||
return False, str(e)
|
||||
|
||||
def handle_control_button_click(self, e):
|
||||
"""处理控制按钮点击,启动异步任务"""
|
||||
try:
|
||||
print("[调试] 控制按钮被点击")
|
||||
if self.page:
|
||||
print("[调试] 准备启动异步任务")
|
||||
|
||||
# 创建一个不需要参数的异步包装函数
|
||||
async def async_wrapper():
|
||||
return await self.change_stream_state(e)
|
||||
|
||||
# 使用包装函数作为任务
|
||||
async_task = self.page.run_task(async_wrapper)
|
||||
print(f"[调试] 异步任务已启动: {async_task}")
|
||||
else:
|
||||
print("[调试] 错误: self.page 为 None")
|
||||
except Exception as ex:
|
||||
print(f"[调试] 启动任务时出错: {ex}")
|
||||
traceback.print_exc()
|
||||
|
||||
def toggle_display(self, e):
|
||||
"""切换兴趣监控的显示/隐藏状态"""
|
||||
self.is_expanded = not self.is_expanded
|
||||
|
||||
# 更新按钮图标和提示
|
||||
if self.is_expanded:
|
||||
self.toggle_button.icon = ft.icons.ARROW_DOWNWARD
|
||||
self.toggle_button.tooltip = "隐藏兴趣监控"
|
||||
else:
|
||||
self.toggle_button.icon = ft.icons.ARROW_UPWARD
|
||||
self.toggle_button.tooltip = "显示兴趣监控"
|
||||
|
||||
# 切换内容区域的可见性
|
||||
self.content_area.visible = self.is_expanded
|
||||
|
||||
# 调用回调函数通知父容器
|
||||
if self.on_toggle:
|
||||
self.on_toggle(self.is_expanded)
|
||||
|
||||
# 更新UI
|
||||
self.update()
|
||||
|
||||
def did_mount(self):
|
||||
print("[InterestMonitor] 控件已挂载,启动日志读取任务")
|
||||
if self.page:
|
||||
@@ -196,10 +454,8 @@ class InterestMonitorDisplay(ft.Column):
|
||||
async def log_reader_loop(self):
|
||||
while True:
|
||||
try:
|
||||
mind_history_updated = await self.load_and_process_log()
|
||||
await self.load_and_process_log()
|
||||
await self.update_charts()
|
||||
if mind_history_updated:
|
||||
await self.refresh_mind_listview()
|
||||
except asyncio.CancelledError:
|
||||
print("[InterestMonitor] 日志读取循环被取消")
|
||||
break
|
||||
@@ -211,16 +467,15 @@ class InterestMonitorDisplay(ft.Column):
|
||||
await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
|
||||
|
||||
async def load_and_process_log(self):
|
||||
"""读取并处理日志文件的新增内容。返回是否有新的 Main Mind 记录。"""
|
||||
mind_history_updated = False # 跟踪是否有新的 Main Mind
|
||||
"""读取并处理日志文件的新增内容。"""
|
||||
if not os.path.exists(LOG_FILE_PATH):
|
||||
self.update_status("日志文件未找到", ft.colors.WARNING)
|
||||
return mind_history_updated
|
||||
self.update_status("日志文件未找到", ft.colors.ORANGE)
|
||||
return
|
||||
|
||||
try:
|
||||
file_mod_time = os.path.getmtime(LOG_FILE_PATH)
|
||||
if file_mod_time <= self.last_log_read_time:
|
||||
return mind_history_updated
|
||||
return
|
||||
|
||||
print(f"[InterestMonitor] 检测到日志文件更新 (修改时间: {file_mod_time}), 正在读取...", flush=True)
|
||||
|
||||
@@ -232,11 +487,9 @@ class InterestMonitorDisplay(ft.Column):
|
||||
self.stream_chat_states.clear()
|
||||
self.stream_threshold_status.clear()
|
||||
self.stream_last_active.clear()
|
||||
# self.stream_last_interaction.clear()
|
||||
|
||||
read_count = 0
|
||||
error_count = 0
|
||||
# 注意:Flet 版本目前没有实现时间过滤,会读取整个文件
|
||||
|
||||
with open(LOG_FILE_PATH, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
@@ -290,10 +543,11 @@ class InterestMonitorDisplay(ft.Column):
|
||||
|
||||
for subflow_entry in subflows:
|
||||
stream_id = subflow_entry.get("stream_id")
|
||||
interest = subflow_entry.get("interest")
|
||||
# 兼容两种字段名
|
||||
interest = subflow_entry.get("interest", subflow_entry.get("interest_level"))
|
||||
group_name = subflow_entry.get("group_name", stream_id)
|
||||
# 新增: 获取子流概率
|
||||
probability = subflow_entry.get("probability")
|
||||
# 兼容两种概率字段名
|
||||
probability = subflow_entry.get("probability", subflow_entry.get("start_hfc_probability"))
|
||||
|
||||
if stream_id is None or interest is None:
|
||||
continue
|
||||
@@ -331,7 +585,6 @@ class InterestMonitorDisplay(ft.Column):
|
||||
self.stream_chat_states[stream_id] = subflow_entry.get("sub_chat_state", "N/A")
|
||||
self.stream_threshold_status[stream_id] = subflow_entry.get("is_above_threshold", False)
|
||||
self.stream_last_active[stream_id] = subflow_entry.get("chat_state_changed_time")
|
||||
# self.stream_last_interaction[stream_id] = ... # 如果日志中有
|
||||
|
||||
except json.JSONDecodeError:
|
||||
error_count += 1
|
||||
@@ -356,28 +609,39 @@ class InterestMonitorDisplay(ft.Column):
|
||||
|
||||
# 更新全局信息控件 (如果 page 存在)
|
||||
if self.page:
|
||||
# TODO: Update global info based on latest log entry? Or aggregate?
|
||||
# For now, let's update with the last processed entry's data if available
|
||||
# 更新全局状态信息
|
||||
if log_entry: # Check if log_entry was populated
|
||||
self.global_mai_state_text.value = f"状态: {log_entry.get('mai_state', 'N/A')}"
|
||||
self.global_main_mind_text.value = f"想法: {log_entry.get('main_mind', 'N/A')}"
|
||||
self.global_main_mind_text.tooltip = log_entry.get("main_mind", "N/A")
|
||||
self.global_subflow_count_text.value = f"子流数: {log_entry.get('subflow_count', '0')}"
|
||||
self.global_info_row.update() # Update the row
|
||||
mai_state = log_entry.get("mai_state", "N/A")
|
||||
# subflow_count = log_entry.get('subflow_count', '0')
|
||||
|
||||
# --- 处理 Main Mind 历史 ---
|
||||
if "main_mind" in log_entry and entry_timestamp > self.last_main_mind_timestamp:
|
||||
self.main_mind_history.append(log_entry)
|
||||
self.last_main_mind_timestamp = entry_timestamp
|
||||
mind_history_updated = True
|
||||
# 获取当前时间和行数信息,格式化为状态信息的一部分
|
||||
current_time = datetime.now().strftime("%H:%M:%S")
|
||||
status_info = f"读取于{current_time}"
|
||||
if error_count > 0:
|
||||
status_info += f" (跳过 {error_count} 行)"
|
||||
|
||||
self.global_info_row.update() # 直接调用 update()
|
||||
# 将所有信息合并到一行显示
|
||||
if mai_state != "N/A":
|
||||
if mai_state == "PEEKING":
|
||||
mai_state_str = "看一眼手机"
|
||||
elif mai_state == "NORMAL_CHAT":
|
||||
mai_state_str = "正常看手机"
|
||||
elif mai_state == "FOCUSED_CHAT":
|
||||
mai_state_str = "专心看手机"
|
||||
elif mai_state == "OFFLINE":
|
||||
mai_state_str = "不在线"
|
||||
|
||||
combined_info = f"{status_info} | 状态: {mai_state_str}"
|
||||
self.global_mai_state_text.value = combined_info
|
||||
self.global_info_row.update()
|
||||
|
||||
# 更新状态文本的颜色
|
||||
color = ft.colors.GREEN if error_count == 0 else ft.colors.ORANGE
|
||||
self.global_mai_state_text.color = color
|
||||
|
||||
# 更新下拉列表选项
|
||||
await self.update_dropdown_options()
|
||||
|
||||
return mind_history_updated # 返回是否有新 mind 记录
|
||||
|
||||
except IOError as e:
|
||||
print(f"读取日志文件时发生 IO 错误: {e}")
|
||||
self.update_status(f"日志 IO 错误: {e}", ft.colors.ERROR)
|
||||
@@ -386,40 +650,29 @@ class InterestMonitorDisplay(ft.Column):
|
||||
traceback.print_exc()
|
||||
self.update_status(f"处理日志时出错: {e}", ft.colors.ERROR)
|
||||
|
||||
return mind_history_updated
|
||||
|
||||
async def refresh_mind_listview(self):
|
||||
"""刷新历史想法列表视图。"""
|
||||
if not self.page:
|
||||
return # 无法更新
|
||||
|
||||
self.mind_history_listview.controls.clear()
|
||||
for entry in self.main_mind_history:
|
||||
ts = entry.get("timestamp", 0)
|
||||
dt_str = format_timestamp(ts)
|
||||
main_mind = entry.get("main_mind", "")
|
||||
mai_state = entry.get("mai_state", "")
|
||||
subflow_count = entry.get("subflow_count", "")
|
||||
# 使用 Markdown 加粗时间,简化显示
|
||||
text_content = f"**[{dt_str}]** 状态:{mai_state} 子流:{subflow_count}\n{main_mind}"
|
||||
self.mind_history_listview.controls.append(
|
||||
ft.Markdown(text_content, selectable=True, extension_set=ft.MarkdownExtensionSet.COMMON_MARK)
|
||||
)
|
||||
|
||||
# print(f"[InterestMonitor] 刷新历史想法列表,共 {len(self.mind_history_listview.controls)} 条")
|
||||
self.mind_history_listview.update() # 更新 ListView
|
||||
# 滚动到底部 (如果需要)
|
||||
# await asyncio.sleep(0.1) # 短暂延迟确保控件更新
|
||||
# self.mind_history_listview.scroll_to(offset=-1, duration=300) # 滚动到底部
|
||||
# 注意:scroll_to 可能需要在 page 上下文或特定条件下才有效
|
||||
|
||||
async def update_charts(self):
|
||||
all_series = []
|
||||
legend_items = [] # 存储图例控件
|
||||
|
||||
# 检查是否有足够的数据生成图表
|
||||
if not self.stream_history:
|
||||
print("[InterestMonitor] 警告: 没有流历史数据可用于生成图表")
|
||||
self.update_status("无图表数据可用", ft.colors.ORANGE)
|
||||
# 清空图表
|
||||
self.main_chart.data_series = []
|
||||
self.legend_column.controls = []
|
||||
self.update()
|
||||
return
|
||||
|
||||
active_streams_sorted = sorted(
|
||||
self.stream_history.items(), key=lambda item: item[1][-1][1] if item[1] else -1, reverse=True
|
||||
)[:MAX_STREAMS_TO_DISPLAY]
|
||||
|
||||
# 调试信息
|
||||
print(f"[InterestMonitor] 有 {len(active_streams_sorted)} 个活跃流可用于图表")
|
||||
for stream_id, history in active_streams_sorted:
|
||||
print(f"[InterestMonitor] 流 {stream_id}: {len(history)} 个数据点")
|
||||
|
||||
min_ts, max_ts = self.get_time_range(self.stream_history)
|
||||
|
||||
for stream_id, history in active_streams_sorted:
|
||||
@@ -431,6 +684,15 @@ class InterestMonitorDisplay(ft.Column):
|
||||
if not mpl_dates:
|
||||
continue
|
||||
|
||||
# 为颜色分配固定的颜色,如果不存在
|
||||
if stream_id not in self.stream_colors:
|
||||
self.stream_colors[stream_id] = get_random_flet_color()
|
||||
|
||||
# 获取或设置显示名称
|
||||
if stream_id not in self.stream_display_names:
|
||||
group_name = self.stream_details.get(stream_id, {}).get("group_name", stream_id)
|
||||
self.stream_display_names[stream_id] = group_name
|
||||
|
||||
data_points = [ft.LineChartDataPoint(x=ts, y=interest) for ts, interest in zip(mpl_dates, interests)]
|
||||
all_series.append(
|
||||
ft.LineChartData(
|
||||
@@ -454,6 +716,7 @@ class InterestMonitorDisplay(ft.Column):
|
||||
)
|
||||
except Exception as plot_err:
|
||||
print(f"绘制主图表/图例时跳过 Stream {stream_id}: {plot_err}")
|
||||
traceback.print_exc() # 添加完整的错误堆栈
|
||||
continue
|
||||
|
||||
# --- 更新主图表 ---
|
||||
@@ -466,17 +729,32 @@ class InterestMonitorDisplay(ft.Column):
|
||||
# --- 更新图例 ---
|
||||
self.legend_column.controls = legend_items
|
||||
|
||||
# 只有在选择了流的情况下更新详情图表
|
||||
if self.selected_stream_id_for_details:
|
||||
await self.update_detail_charts(self.selected_stream_id_for_details)
|
||||
else:
|
||||
print("[InterestMonitor] 未选择流,跳过详情图表更新")
|
||||
|
||||
if self.page:
|
||||
# 更新整个控件,包含图表和图例的更新
|
||||
self.update()
|
||||
else:
|
||||
print("[InterestMonitor] 警告: self.page 为 None,无法更新图表 UI")
|
||||
|
||||
async def update_detail_charts(self, stream_id):
|
||||
interest_series = []
|
||||
probability_series = []
|
||||
combined_series = []
|
||||
min_ts_detail, max_ts_detail = None, None
|
||||
|
||||
# --- 增加检查:如果没有选择流ID或流ID不在历史记录中,则直接返回
|
||||
if not stream_id or stream_id not in self.stream_history:
|
||||
print(f"[InterestMonitor] 没有找到流ID或未选择流ID: {stream_id}")
|
||||
# 清空图表
|
||||
self.detail_chart_combined.data_series = []
|
||||
|
||||
# 确保更新详情文本,清空信息
|
||||
await self.update_detail_texts(None)
|
||||
return
|
||||
|
||||
# --- 兴趣度图 ---
|
||||
if stream_id and stream_id in self.stream_history and self.stream_history[stream_id]:
|
||||
min_ts_detail, max_ts_detail = self.get_time_range({stream_id: self.stream_history[stream_id]})
|
||||
@@ -487,7 +765,7 @@ class InterestMonitorDisplay(ft.Column):
|
||||
interest_data_points = [
|
||||
ft.LineChartDataPoint(x=ts, y=interest) for ts, interest in zip(mpl_dates, interests)
|
||||
]
|
||||
interest_series.append(
|
||||
combined_series.append(
|
||||
ft.LineChartData(
|
||||
data_points=interest_data_points,
|
||||
color=self.stream_colors.get(stream_id, ft.colors.BLUE),
|
||||
@@ -516,30 +794,29 @@ class InterestMonitorDisplay(ft.Column):
|
||||
if max_prob_ts is not None:
|
||||
max_ts_detail = max(max_ts_detail, max_prob_ts)
|
||||
|
||||
# 调整HFC概率值到兴趣度的比例范围,便于在一个图表中显示
|
||||
# 兴趣度范围0-10,将概率值x10
|
||||
scaled_probabilities = [prob * 10 for prob in probabilities]
|
||||
|
||||
probability_data_points = [
|
||||
ft.LineChartDataPoint(x=ts, y=prob) for ts, prob in zip(prob_dates, probabilities)
|
||||
ft.LineChartDataPoint(x=ts, y=prob) for ts, prob in zip(prob_dates, scaled_probabilities)
|
||||
]
|
||||
probability_series.append(
|
||||
combined_series.append(
|
||||
ft.LineChartData(
|
||||
data_points=probability_data_points,
|
||||
color=self.stream_colors.get(stream_id, ft.colors.GREEN),
|
||||
color=ft.colors.GREEN,
|
||||
stroke_width=2,
|
||||
)
|
||||
)
|
||||
except Exception as plot_err:
|
||||
print(f"绘制详情概率图时出错 Stream {stream_id}: {plot_err}")
|
||||
|
||||
self.detail_chart_interest.data_series = interest_series
|
||||
self.detail_chart_interest.min_y = 0
|
||||
self.detail_chart_interest.max_y = 10
|
||||
self.detail_chart_interest.min_x = min_ts_detail
|
||||
self.detail_chart_interest.max_x = max_ts_detail
|
||||
|
||||
self.detail_chart_probability.data_series = probability_series
|
||||
self.detail_chart_probability.min_y = 0
|
||||
self.detail_chart_probability.max_y = 1.05
|
||||
self.detail_chart_probability.min_x = min_ts_detail
|
||||
self.detail_chart_probability.max_x = max_ts_detail
|
||||
# 更新合并图表
|
||||
self.detail_chart_combined.data_series = combined_series
|
||||
self.detail_chart_combined.min_y = 0
|
||||
self.detail_chart_combined.max_y = 10
|
||||
self.detail_chart_combined.min_x = min_ts_detail
|
||||
self.detail_chart_combined.max_x = max_ts_detail
|
||||
|
||||
await self.update_detail_texts(stream_id)
|
||||
|
||||
@@ -547,61 +824,86 @@ class InterestMonitorDisplay(ft.Column):
|
||||
current_value = self.stream_dropdown.value
|
||||
options = []
|
||||
valid_stream_ids = set()
|
||||
sorted_items = sorted(self.stream_display_names.items(), key=lambda item: item[1])
|
||||
|
||||
# 调试信息
|
||||
print(f"[InterestMonitor] 更新流下拉列表,当前有 {len(self.stream_history)} 个流")
|
||||
|
||||
# 确保所有流都有显示名称
|
||||
for stream_id in self.stream_history.keys():
|
||||
if stream_id not in self.stream_display_names:
|
||||
# 如果没有显示名称,使用group_name或stream_id
|
||||
group_name = self.stream_details.get(stream_id, {}).get("group_name", stream_id)
|
||||
self.stream_display_names[stream_id] = group_name
|
||||
print(f"[InterestMonitor] 为流 {stream_id} 设置显示名称: {group_name}")
|
||||
|
||||
# 排序所有流数据用于下拉列表
|
||||
sorted_items = sorted(
|
||||
[
|
||||
(stream_id, self.stream_display_names.get(stream_id, stream_id))
|
||||
for stream_id in self.stream_history.keys()
|
||||
],
|
||||
key=lambda item: item[1], # 按显示名称排序
|
||||
)
|
||||
|
||||
for stream_id, display_name in sorted_items:
|
||||
if stream_id in self.stream_history and self.stream_history[stream_id]:
|
||||
# 使用 f"DisplayName (StreamID)" 格式?
|
||||
option_text = f"{display_name}"
|
||||
options.append(ft.dropdown.Option(key=stream_id, text=option_text))
|
||||
valid_stream_ids.add(stream_id)
|
||||
print(f"[InterestMonitor] 添加流选项: {stream_id} ({display_name})")
|
||||
|
||||
self.stream_dropdown.options = options
|
||||
# Flet Dropdown 的 value 似乎是用 key (stream_id) 来匹配的
|
||||
if current_value not in valid_stream_ids:
|
||||
# 如果之前的 stream_id 不再有效,尝试选择第一个,否则清空
|
||||
|
||||
# 如果当前值无效,选择第一个选项或清空
|
||||
if not current_value or current_value not in valid_stream_ids:
|
||||
new_value = options[0].key if options else None
|
||||
if self.stream_dropdown.value != new_value: # 避免不必要的更新循环
|
||||
if self.stream_dropdown.value != new_value:
|
||||
print(f"[InterestMonitor] 设置新的选中流: {new_value}")
|
||||
self.stream_dropdown.value = new_value
|
||||
self.selected_stream_id_for_details = new_value
|
||||
await self.update_detail_charts(new_value)
|
||||
|
||||
if self.page and self.stream_dropdown.page: # 确保控件已挂载再更新
|
||||
# 确保按钮状态正确
|
||||
self.control_button.disabled = not self.stream_dropdown.value
|
||||
|
||||
if self.page and self.stream_dropdown.page:
|
||||
self.stream_dropdown.update()
|
||||
self.control_button.update()
|
||||
|
||||
async def on_stream_selected(self, e):
|
||||
selected_id = e.control.value # value 应该是 stream_id (key)
|
||||
print(f"[InterestMonitor] 选择了 Stream ID: {selected_id}")
|
||||
if self.selected_stream_id_for_details != selected_id:
|
||||
self.selected_stream_id_for_details = selected_id
|
||||
# 启用控制按钮
|
||||
self.control_button.disabled = selected_id is None
|
||||
await self.update_detail_charts(selected_id)
|
||||
# Dropdown 更新是自动的,但图表和文本需要手动触发父容器更新
|
||||
if self.page:
|
||||
self.update()
|
||||
|
||||
async def update_detail_texts(self, stream_id):
|
||||
if not self.detail_texts or not hasattr(self.detail_texts, "controls") or len(self.detail_texts.controls) < 4:
|
||||
if not self.detail_texts or not hasattr(self.detail_texts, "controls") or len(self.detail_texts.controls) < 1:
|
||||
print("[InterestMonitor] 错误:detail_texts 未正确初始化或控件不足")
|
||||
return
|
||||
|
||||
if stream_id:
|
||||
if stream_id and stream_id in self.stream_history:
|
||||
sub_mind = self.stream_sub_minds.get(stream_id, "N/A")
|
||||
chat_state = self.stream_chat_states.get(stream_id, "N/A")
|
||||
threshold = self.stream_threshold_status.get(stream_id, False)
|
||||
last_active_ts = self.stream_last_active.get(stream_id)
|
||||
last_active_str = format_timestamp(last_active_ts)
|
||||
|
||||
self.detail_texts.controls[0].value = f"想法: {sub_mind}"
|
||||
self.detail_texts.controls[0].tooltip = sub_mind # 添加 tooltip
|
||||
self.detail_texts.controls[1].value = f"状态: {chat_state}"
|
||||
self.detail_texts.controls[2].value = f"阈值以上: {'是' if threshold else '否'}"
|
||||
self.detail_texts.controls[3].value = f"最后活跃: {format_timestamp(last_active_ts)}"
|
||||
# self.detail_texts.controls[4].value = ... # 如果有更多详情
|
||||
# 合并详情为一行
|
||||
detail_text = f"状态: {chat_state} | 最后活跃: {last_active_str}"
|
||||
if sub_mind and sub_mind != "N/A" and sub_mind.strip():
|
||||
detail_text = f"想法: {sub_mind} | {detail_text}"
|
||||
|
||||
self.detail_texts.controls[0].value = detail_text
|
||||
self.detail_texts.controls[0].tooltip = detail_text # 完整文本作为tooltip
|
||||
else:
|
||||
self.detail_texts.controls[0].value = "想法: N/A"
|
||||
self.detail_texts.controls[0].tooltip = "N/A"
|
||||
self.detail_texts.controls[1].value = "状态: N/A"
|
||||
self.detail_texts.controls[2].value = "阈值以上: N/A"
|
||||
self.detail_texts.controls[3].value = "最后活跃: N/A"
|
||||
# self.detail_texts.controls[4].value = "N/A"
|
||||
# 默认显示
|
||||
self.detail_texts.controls[0].value = "状态: 无 | 最后活跃: 无"
|
||||
self.detail_texts.controls[0].tooltip = "暂无详细信息"
|
||||
|
||||
if self.page and self.detail_texts.page: # 确保控件已挂载再更新
|
||||
self.detail_texts.update()
|
||||
@@ -609,14 +911,24 @@ class InterestMonitorDisplay(ft.Column):
|
||||
def update_status(self, message: str, color: str = ft.colors.SECONDARY):
|
||||
max_len = 150
|
||||
display_message = (message[:max_len] + "...") if len(message) > max_len else message
|
||||
|
||||
# 保留当前状态信息的一部分(如果存在)
|
||||
if "|" in self.global_mai_state_text.value:
|
||||
status_part = self.global_mai_state_text.value.split("|")[1].strip()
|
||||
self.status_text.value = f"{display_message} | {status_part}"
|
||||
else:
|
||||
self.status_text.value = display_message
|
||||
|
||||
self.status_text.color = color
|
||||
if self.page and self.status_text.page:
|
||||
self.status_text.update()
|
||||
|
||||
def get_time_range(self, history_dict, is_prob=False):
|
||||
"""获取所有数据点的时间范围,确保即使没有数据也能返回有效的时间范围"""
|
||||
all_ts = []
|
||||
target_history_key = self.probability_history if is_prob else self.stream_history
|
||||
|
||||
try:
|
||||
for stream_id, _history in history_dict.items():
|
||||
# 使用正确的历史记录字典
|
||||
actual_history = target_history_key.get(stream_id)
|
||||
@@ -624,13 +936,68 @@ class InterestMonitorDisplay(ft.Column):
|
||||
all_ts.extend([ts for ts, _ in actual_history])
|
||||
|
||||
if not all_ts:
|
||||
# 如果没有时间戳,返回当前时间前后一小时的范围
|
||||
now = time.time()
|
||||
return now - 3600, now
|
||||
print(f"[InterestMonitor] 警告: 没有找到时间戳数据,使用当前时间: {now}")
|
||||
return now - 3600, now + 60
|
||||
|
||||
# 确保时间戳是有效的数字
|
||||
valid_ts = [ts for ts in all_ts if isinstance(ts, (int, float))]
|
||||
if not valid_ts:
|
||||
now = time.time()
|
||||
print(f"[InterestMonitor] 警告: 没有有效的时间戳数据,使用当前时间: {now}")
|
||||
return now - 3600, now + 60
|
||||
|
||||
min_ts = min(valid_ts)
|
||||
max_ts = max(valid_ts)
|
||||
|
||||
# 确保时间范围不为零(避免图表问题)
|
||||
if min_ts == max_ts:
|
||||
print(f"[InterestMonitor] 警告: 最小和最大时间戳相同: {min_ts}")
|
||||
padding = 60 # 如果只有一个点,前后加1分钟
|
||||
else:
|
||||
padding = (max_ts - min_ts) * 0.05 # 正常情况下添加5%的填充
|
||||
|
||||
min_ts = min(all_ts)
|
||||
max_ts = max(all_ts)
|
||||
padding = (max_ts - min_ts) * 0.05 if max_ts > min_ts else 10
|
||||
return min_ts - padding, max_ts + padding
|
||||
except Exception as e:
|
||||
# 出现任何错误都返回当前时间范围
|
||||
now = time.time()
|
||||
print(f"[InterestMonitor] 获取时间范围时出错: {e}")
|
||||
traceback.print_exc()
|
||||
return now - 3600, now + 60
|
||||
|
||||
def send_gui_command_file(self, subflow_id, target_state):
|
||||
"""使用文件方式发送命令(备用方法)"""
|
||||
try:
|
||||
# 确保目录存在
|
||||
command_dir = os.path.dirname(GUI_COMMAND_PATH)
|
||||
if command_dir: # 如果有目录部分
|
||||
os.makedirs(command_dir, exist_ok=True)
|
||||
|
||||
# 创建命令数据
|
||||
command_data = {
|
||||
"subflow_id": subflow_id,
|
||||
"target_state": target_state, # 不转为大写,保留原始状态值
|
||||
}
|
||||
|
||||
# 写入文件
|
||||
with open(GUI_COMMAND_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(command_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[InterestMonitor] 已通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}")
|
||||
if self.page:
|
||||
self.page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(f"通过文件方式发送命令: 将子流 {subflow_id} 设置为 {target_state}"),
|
||||
show_close_icon=True,
|
||||
bgcolor=ft.colors.ORANGE_200, # 使用不同颜色表示使用了备用方式
|
||||
)
|
||||
self.page.snack_bar.open = True
|
||||
self.page.update()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[InterestMonitor] 发送GUI命令文件出错: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
# --- 测试部分保持不变 ---
|
||||
|
||||
159
src/MaiGoi/flet_rules.py
Normal file
159
src/MaiGoi/flet_rules.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Flet UI开发的规则和最佳实践
|
||||
|
||||
这个文件记录了在使用Flet开发UI界面时发现的重要规则和最佳实践,
|
||||
可以帮助避免常见错误并提高代码质量。
|
||||
"""
|
||||
|
||||
# ===== Container相关规则 =====
|
||||
|
||||
"""
|
||||
规则1: Container没有controls属性
|
||||
Container只有content属性,不能直接访问controls。必须通过container.content访问内容。
|
||||
|
||||
错误示例:
|
||||
container.controls.append(...) # 错误! Container没有controls属性
|
||||
|
||||
正确示例:
|
||||
container.content = ft.Column([]) # 先设置content为一个有controls属性的控件
|
||||
container.content.controls.append(...) # 然后通过content访问controls
|
||||
"""
|
||||
|
||||
"""
|
||||
规则2: Card没有padding属性
|
||||
Card控件不直接支持padding,必须用Container包装来添加padding。
|
||||
|
||||
错误示例:
|
||||
ft.Card(padding=10, content=...) # 错误! Card没有padding属性
|
||||
|
||||
正确示例:
|
||||
ft.Card(
|
||||
content=ft.Container(
|
||||
content=...,
|
||||
padding=10
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
# ===== UI更新规则 =====
|
||||
|
||||
"""
|
||||
规则3: 控件必须先添加到页面才能调用update()
|
||||
调用控件的update()方法前,确保该控件已经添加到页面中,否则会报错。
|
||||
|
||||
错误示例:
|
||||
new_column = ft.Column([])
|
||||
new_column.update() # 错误! 控件还未添加到页面
|
||||
|
||||
正确示例:
|
||||
# 区分初始加载和用户交互
|
||||
def add_item(e=None, is_initial=False):
|
||||
# 创建新控件...
|
||||
items_column.controls.append(new_control)
|
||||
|
||||
# 只在用户交互时更新UI
|
||||
if not is_initial and e is not None:
|
||||
items_column.update()
|
||||
"""
|
||||
|
||||
"""
|
||||
规则4: 嵌套结构展开/折叠时的更新策略
|
||||
处理嵌套数据结构(如字典)的展开/折叠时,要小心控制update()的调用时机。
|
||||
|
||||
最佳实践:
|
||||
1. 在生成UI结构时不要调用update()
|
||||
2. 在用户交互(如点击展开按钮)后再调用update()
|
||||
3. 始终从父容器调用update(),而不是每个子控件都调用
|
||||
4. 添加异常处理,防止动态生成控件时的错误导致整个UI崩溃
|
||||
"""
|
||||
|
||||
# ===== 数据类型处理规则 =====
|
||||
|
||||
"""
|
||||
规则5: 特殊处理集合类型(set)
|
||||
Python中的set类型在UI表示时需要特殊处理,将其转换为可编辑的表单控件。
|
||||
|
||||
最佳实践:
|
||||
1. 为set类型实现专门的UI控件(如_create_set_control)
|
||||
2. 添加错误处理,即使创建控件失败也要提供备选显示方式
|
||||
3. 小心处理类型转换,确保UI中的数据变更能正确应用到set类型
|
||||
|
||||
示例:
|
||||
if isinstance(value, set):
|
||||
try:
|
||||
return create_set_control(value)
|
||||
except Exception:
|
||||
return ft.Text(f"{value} (不可编辑)", italic=True)
|
||||
"""
|
||||
|
||||
"""
|
||||
规则6: 动态UI组件的初始化与更新分离
|
||||
创建动态UI组件时,将初始化和更新逻辑分开处理。
|
||||
|
||||
最佳实践:
|
||||
1. 初始化时只创建控件,不调用update()
|
||||
2. 使用标志(如is_initial)区分初始加载和用户交互
|
||||
3. 只在用户交互时调用update()
|
||||
4. 更新数据模型和更新UI分开处理
|
||||
|
||||
示例:
|
||||
# 添加现有项目,使用is_initial=True标记为初始化
|
||||
for item in values:
|
||||
add_item(item, is_initial=True)
|
||||
|
||||
# 用户添加新项目时,不使用is_initial参数
|
||||
add_button.on_click = lambda e: add_item(new_value)
|
||||
"""
|
||||
|
||||
# ===== 其他实用规则 =====
|
||||
|
||||
"""
|
||||
规则7: 始终使用正确的padding格式
|
||||
Flet中padding必须使用正确的格式,不能直接传入数字。
|
||||
|
||||
错误示例:
|
||||
ft.Padding(padding=10, content=...) # 错误
|
||||
|
||||
正确示例:
|
||||
ft.Padding(padding=ft.padding.all(10), content=...)
|
||||
ft.Container(padding=ft.padding.all(10), content=...)
|
||||
"""
|
||||
|
||||
"""
|
||||
规则8: 控件引用路径注意层级关系
|
||||
访问嵌套控件时注意层级关系,特别是当使用Container包装其他控件时。
|
||||
|
||||
错误示例:
|
||||
# 如果card的内容是Container且Container的内容是Column
|
||||
button = card.controls[-1] # 错误! Card没有controls属性
|
||||
|
||||
正确示例:
|
||||
# 正确的访问路径
|
||||
button = card.content.content.controls[-1]
|
||||
"""
|
||||
|
||||
# ===== 自定义控件规则 (Flet v0.21.0+) =====
|
||||
|
||||
"""
|
||||
规则9: 弃用 UserControl,直接继承基础控件
|
||||
Flet v0.21.0 及更高版本已弃用 `ft.UserControl`。
|
||||
创建自定义控件时,应直接继承自 Flet 的基础控件,如 `ft.Column`, `ft.Row`, `ft.Card`, `ft.Text` 等。
|
||||
|
||||
修改步骤:
|
||||
1. 更改类定义: `class MyControl(ft.Column):` 替换 `class MyControl(ft.UserControl):`
|
||||
2. 将 `build()` 方法中的 UI 构建逻辑移至 `__init__` 方法。
|
||||
3. 在 `__init__` 中调用 `super().__init__(...)` 并传递基础控件所需的参数。
|
||||
4. 在 `__init__` 中直接将子控件添加到 `self.controls`。
|
||||
5. 移除 `build()` 方法。
|
||||
|
||||
错误示例 (已弃用):
|
||||
class OldCustom(ft.UserControl):
|
||||
def build(self):
|
||||
return ft.Text("Old way")
|
||||
|
||||
正确示例 (继承 ft.Column):
|
||||
class NewCustom(ft.Column):
|
||||
def __init__(self):
|
||||
super().__init__(spacing=5)
|
||||
self.controls.append(ft.Text("New way"))
|
||||
"""
|
||||
@@ -53,7 +53,7 @@ def update_buttons_state(page: Optional[ft.Page], app_state: "AppState", is_runn
|
||||
console_button.on_click = new_onclick
|
||||
needs_update = True
|
||||
else:
|
||||
new_text = "MaiCore 主控室"
|
||||
new_text = "启动 MaiCore"
|
||||
new_color = ft.colors.with_opacity(0.6, ft.colors.GREEN_ACCENT_100)
|
||||
new_onclick = _start_action # Use def
|
||||
if (
|
||||
@@ -317,9 +317,43 @@ async def output_processor_loop(
|
||||
break
|
||||
|
||||
if output_lv:
|
||||
# 如果在手动观看模式(自动滚动关闭),记录首个元素索引
|
||||
if process_id == "bot.py" and hasattr(app_state, "manual_viewing") and app_state.manual_viewing:
|
||||
# 只有在自动滚动关闭时才保存视图位置
|
||||
if not getattr(output_lv, "auto_scroll", True):
|
||||
# 记录当前第一个可见元素的索引
|
||||
first_visible_idx = 0
|
||||
if hasattr(output_lv, "first_visible") and output_lv.first_visible is not None:
|
||||
first_visible_idx = output_lv.first_visible
|
||||
|
||||
# 添加新行
|
||||
output_lv.controls.extend(lines_to_add)
|
||||
|
||||
# 移除过多的行
|
||||
removal_count = 0
|
||||
while len(output_lv.controls) > 1000:
|
||||
output_lv.controls.pop(0)
|
||||
removal_count += 1
|
||||
|
||||
# 如果移除了行,需要调整首个可见元素的索引
|
||||
if removal_count > 0 and first_visible_idx > removal_count:
|
||||
new_idx = max(0, first_visible_idx - removal_count)
|
||||
# 设置滚动位置到调整后的索引
|
||||
output_lv.first_visible = new_idx
|
||||
else:
|
||||
# 保持当前滚动位置
|
||||
output_lv.first_visible = first_visible_idx
|
||||
else:
|
||||
# 自动滚动开启时,正常添加
|
||||
output_lv.controls.extend(lines_to_add)
|
||||
while len(output_lv.controls) > 1000:
|
||||
output_lv.controls.pop(0) # Limit lines
|
||||
else:
|
||||
# 对于非主控制台输出,或没有手动观看模式,正常处理
|
||||
output_lv.controls.extend(lines_to_add)
|
||||
while len(output_lv.controls) > 1000:
|
||||
output_lv.controls.pop(0) # Limit lines
|
||||
|
||||
if output_lv.visible and page:
|
||||
try:
|
||||
await update_page_safe(page)
|
||||
@@ -447,7 +481,7 @@ def start_managed_process(
|
||||
output_lv = app_state.output_list_view # Use the main console view
|
||||
else:
|
||||
# Create and store a new ListView for this specific process
|
||||
output_lv = ft.ListView(expand=True, spacing=2, padding=5, auto_scroll=True) # Default auto_scroll on
|
||||
output_lv = ft.ListView(expand=True, spacing=2, padding=5, auto_scroll=True) # 始终默认开启自动滚动
|
||||
new_process_state.output_list_view = output_lv
|
||||
|
||||
# Add starting message to the determined ListView
|
||||
@@ -460,16 +494,87 @@ def start_managed_process(
|
||||
print(f"[Start Managed - {process_id}] Starting subprocess: {full_path}", flush=True)
|
||||
sub_env = os.environ.copy()
|
||||
# Set env vars if needed (e.g., for colorization)
|
||||
sub_env["LOGURU_COLORIZE"] = "True" # Tell Loguru (inside bot.py) to colorize
|
||||
sub_env["FORCE_COLOR"] = "1" # Force libraries that check this to use color
|
||||
sub_env["SIMPLE_OUTPUT"] = "True" # Custom flag for bot.py to use simple format
|
||||
sub_env["LOGURU_COLORIZE"] = "True"
|
||||
sub_env["FORCE_COLOR"] = "1"
|
||||
sub_env["SIMPLE_OUTPUT"] = "True"
|
||||
print(
|
||||
f"[Start Managed - {process_id}] Subprocess environment set: COLORIZE={sub_env.get('LOGURU_COLORIZE')}, FORCE_COLOR={sub_env.get('FORCE_COLOR')}, SIMPLE_OUTPUT={sub_env.get('SIMPLE_OUTPUT')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# --- 修改启动命令 ---
|
||||
cmd_list = []
|
||||
executable_path = "" # 用于日志记录
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
# 打包后运行
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
|
||||
# 修改逻辑:这次我们直接指定 _internal 目录下的 Python 解释器
|
||||
# 而不是尝试其他选项
|
||||
try:
|
||||
# _internal 目录是 PyInstaller 默认放置 Python 解释器的位置
|
||||
internal_dir = os.path.join(executable_dir, "_internal")
|
||||
|
||||
if os.path.exists(internal_dir):
|
||||
print(f"[Start Managed - {process_id}] 找到 _internal 目录: {internal_dir}")
|
||||
|
||||
# 在 _internal 目录中查找 python.exe
|
||||
python_exe = None
|
||||
python_paths = []
|
||||
|
||||
# 首先尝试直接查找
|
||||
direct_python = os.path.join(internal_dir, "python.exe")
|
||||
if os.path.exists(direct_python):
|
||||
python_exe = direct_python
|
||||
python_paths.append(direct_python)
|
||||
|
||||
# 如果没找到,进行递归搜索
|
||||
if not python_exe:
|
||||
for root, _, files in os.walk(internal_dir):
|
||||
if "python.exe" in files:
|
||||
path = os.path.join(root, "python.exe")
|
||||
python_paths.append(path)
|
||||
if not python_exe: # 只取第一个找到的
|
||||
python_exe = path
|
||||
|
||||
# 记录所有找到的路径
|
||||
if python_paths:
|
||||
print(f"[Start Managed - {process_id}] 在 _internal 中找到的所有 Python.exe: {python_paths}")
|
||||
|
||||
if python_exe:
|
||||
# 找到 Python 解释器,使用它来运行脚本
|
||||
cmd_list = [python_exe, "-u", full_path]
|
||||
executable_path = python_exe
|
||||
print(f"[Start Managed - {process_id}] 使用打包内部的 Python: {executable_path}")
|
||||
else:
|
||||
# 如果找不到,只能使用脚本文件直接执行
|
||||
print(f"[Start Managed - {process_id}] 无法在 _internal 目录中找到 python.exe")
|
||||
cmd_list = [full_path]
|
||||
executable_path = full_path
|
||||
print(f"[Start Managed - {process_id}] 直接执行脚本: {executable_path}")
|
||||
else:
|
||||
# _internal 目录不存在,尝试直接执行脚本
|
||||
print(f"[Start Managed - {process_id}] _internal 目录不存在: {internal_dir}")
|
||||
cmd_list = [full_path]
|
||||
executable_path = full_path
|
||||
print(f"[Start Managed - {process_id}] 直接执行脚本: {executable_path}")
|
||||
except Exception as path_err:
|
||||
print(f"[Start Managed - {process_id}] 查找 Python 路径时出错: {path_err}")
|
||||
# 如果出现异常,尝试直接执行脚本
|
||||
cmd_list = [full_path]
|
||||
executable_path = full_path
|
||||
print(f"[Start Managed - {process_id}] 出错回退:直接执行脚本 {executable_path}")
|
||||
else:
|
||||
# 源码运行,使用当前的 Python 解释器
|
||||
cmd_list = [sys.executable, "-u", full_path]
|
||||
executable_path = sys.executable
|
||||
print(f"[Start Managed - {process_id}] 源码模式:使用当前 Python ({executable_path})")
|
||||
|
||||
print(f"[Start Managed - {process_id}] 最终命令列表: {cmd_list}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-u", full_path], # -u for unbuffered output
|
||||
cmd_list, # 使用构建好的命令列表
|
||||
cwd=app_state.script_dir,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
|
||||
@@ -40,7 +40,8 @@ class AppState:
|
||||
self.output_list_view: Optional[ft.ListView] = None
|
||||
self.start_bot_button: Optional[ft.FilledButton] = None
|
||||
self.console_action_button: Optional[ft.ElevatedButton] = None
|
||||
self.is_auto_scroll_enabled: bool = False
|
||||
self.is_auto_scroll_enabled: bool = True # 默认启用自动滚动
|
||||
self.manual_viewing: bool = False # 手动观看模式标识,用于修复自动滚动关闭时的位移问题
|
||||
self.interest_monitor_control: Optional[InterestMonitorDisplay] = None
|
||||
|
||||
# Script directory (useful for paths)
|
||||
|
||||
916
src/MaiGoi/toml_form_generator.py
Normal file
916
src/MaiGoi/toml_form_generator.py
Normal file
@@ -0,0 +1,916 @@
|
||||
import flet as ft
|
||||
import tomlkit
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_template_with_comments(template_filename: str = "bot_config_template.toml"):
|
||||
"""
|
||||
加载指定的模板文件,保留所有注释。
|
||||
|
||||
Args:
|
||||
template_filename: 要加载的模板文件名 (相对于 template/ 目录)。
|
||||
|
||||
Returns:
|
||||
包含注释的TOML文档对象,如果失败则返回空文档。
|
||||
"""
|
||||
try:
|
||||
# 首先尝试从相对路径加载 (相对于项目根目录)
|
||||
# 假设此脚本位于 src/MaiGoi/
|
||||
base_path = Path(__file__).parent.parent.parent
|
||||
template_path = base_path / "template" / template_filename
|
||||
|
||||
if template_path.exists():
|
||||
print(f"找到模板文件: {template_path}")
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
return tomlkit.parse(f.read())
|
||||
else:
|
||||
print(f"警告: 模板文件不存在: {template_path}")
|
||||
return tomlkit.document()
|
||||
except Exception as e:
|
||||
print(f"加载模板文件 '{template_filename}' 出错: {e}")
|
||||
return tomlkit.document()
|
||||
|
||||
|
||||
def get_comment_for_key(template_doc, key_path: str) -> str:
|
||||
"""
|
||||
获取指定键路径的注释 (修正版)
|
||||
|
||||
Args:
|
||||
template_doc: 包含注释的TOML文档
|
||||
key_path: 点分隔的键路径,例如 "bot.qq"
|
||||
|
||||
Returns:
|
||||
该键对应的注释字符串,如果没有则返回空字符串
|
||||
"""
|
||||
if not template_doc:
|
||||
return ""
|
||||
|
||||
try:
|
||||
parts = key_path.split(".")
|
||||
current_item = template_doc
|
||||
|
||||
# 逐级导航到目标项或其父表
|
||||
for i, part in enumerate(parts):
|
||||
if part not in current_item:
|
||||
print(f"警告: 路径部分 '{part}' 在 {'.'.join(parts[:i])} 中未找到")
|
||||
return "" # 路径不存在
|
||||
|
||||
# 如果是最后一个部分,我们找到了目标项
|
||||
if i == len(parts) - 1:
|
||||
target_item = current_item[part]
|
||||
|
||||
# --- 尝试从 trivia 获取注释 ---
|
||||
if hasattr(target_item, "trivia") and hasattr(target_item.trivia, "comment"):
|
||||
comment_lines = target_item.trivia.comment.split("\n")
|
||||
# 去除每行的 '#' 和首尾空格
|
||||
cleaned_comment = "\n".join([line.strip().lstrip("#").strip() for line in comment_lines])
|
||||
if cleaned_comment:
|
||||
return cleaned_comment
|
||||
|
||||
# --- 如果是顶级表,也检查容器自身的 trivia ---
|
||||
# (tomlkit 对于顶级表的注释存储方式可能略有不同)
|
||||
if isinstance(target_item, (tomlkit.items.Table, tomlkit.container.Container)) and len(parts) == 1:
|
||||
if hasattr(target_item, "trivia") and hasattr(target_item.trivia, "comment"):
|
||||
comment_lines = target_item.trivia.comment.split("\n")
|
||||
cleaned_comment = "\n".join([line.strip().lstrip("#").strip() for line in comment_lines])
|
||||
if cleaned_comment:
|
||||
return cleaned_comment
|
||||
|
||||
# 如果 trivia 中没有,尝试一些旧版或不常用的属性 (风险较高)
|
||||
# if hasattr(target_item, '_comment'): # 不推荐
|
||||
# return str(target_item._comment).strip(" #")
|
||||
|
||||
# 如果以上都找不到,返回空
|
||||
return ""
|
||||
|
||||
# 继续导航到下一级
|
||||
current_item = current_item[part]
|
||||
# 如果中间路径不是表/字典,则无法继续
|
||||
if not isinstance(current_item, (dict, tomlkit.items.Table, tomlkit.container.Container)):
|
||||
print(f"警告: 路径部分 '{part}' 指向的不是表结构,无法继续导航")
|
||||
return ""
|
||||
|
||||
return "" # 理论上不应执行到这里,除非 key_path 为空
|
||||
|
||||
except Exception as e:
|
||||
# 打印更详细的错误信息,包括路径和异常类型
|
||||
print(f"获取注释时发生意外错误 (路径: {key_path}): {type(e).__name__} - {e}")
|
||||
# print(traceback.format_exc()) # 可选:打印完整堆栈跟踪
|
||||
return ""
|
||||
|
||||
|
||||
class TomlFormGenerator:
|
||||
"""用于将TOML配置生成Flet表单控件的类。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page: ft.Page,
|
||||
config_data: Dict[str, Any],
|
||||
parent_container: ft.Column,
|
||||
template_filename: str = "bot_config_template.toml",
|
||||
):
|
||||
"""
|
||||
初始化表单生成器。
|
||||
|
||||
Args:
|
||||
page: Flet Page 对象 (用于强制刷新)
|
||||
config_data: TOML配置数据(嵌套字典)
|
||||
parent_container: 要添加控件的父容器
|
||||
template_filename: 要使用的模板文件名 (相对于 template/ 目录)
|
||||
"""
|
||||
self.page = page # <-- 保存 Page 对象
|
||||
self.config_data = config_data # 保存对原始数据的引用(重要!)
|
||||
self.parent_container = parent_container
|
||||
self.controls_map = {} # 映射 full_path 到 Flet 控件
|
||||
self.expanded_sections = set() # 记录展开的部分
|
||||
|
||||
# 加载指定的模板文档
|
||||
self.template_doc = load_template_with_comments(template_filename)
|
||||
|
||||
if not self.template_doc.value:
|
||||
print(f"警告:加载的模板 '{template_filename}' 为空,注释功能将不可用。")
|
||||
|
||||
def build_form(self):
|
||||
"""构建整个表单。"""
|
||||
self.parent_container.controls.clear()
|
||||
self.controls_map.clear() # 清空控件映射
|
||||
# 使用 self.config_data 构建表单
|
||||
self._process_toml_section(self.config_data, self.parent_container)
|
||||
|
||||
def _get_comment(self, key_path: str) -> str:
|
||||
"""获取指定键路径的注释,并确保结果是字符串"""
|
||||
try:
|
||||
comment = get_comment_for_key(self.template_doc, key_path)
|
||||
# 确保返回值是字符串
|
||||
if comment and isinstance(comment, str):
|
||||
return comment
|
||||
except Exception as e:
|
||||
print(f"获取注释出错: {key_path}, {e}")
|
||||
return "" # 如果出现任何问题,返回空字符串
|
||||
|
||||
def _process_toml_section(
|
||||
self,
|
||||
section_data: Dict[str, Any],
|
||||
container: Union[ft.Column, ft.Container],
|
||||
section_path: str = "",
|
||||
indent: int = 0,
|
||||
):
|
||||
"""
|
||||
递归处理TOML配置的一个部分。
|
||||
|
||||
Args:
|
||||
section_data: 要处理的配置部分
|
||||
container: 放置控件的容器(可以是Column或Container)
|
||||
section_path: 当前部分的路径(用于跟踪嵌套层级)
|
||||
indent: 当前缩进级别
|
||||
"""
|
||||
# 确保container是有controls属性的对象
|
||||
if isinstance(container, ft.Container):
|
||||
if container.content and hasattr(container.content, "controls"):
|
||||
container = container.content
|
||||
else:
|
||||
# 如果Container没有有效的content,创建一个Column
|
||||
container.content = ft.Column([])
|
||||
container = container.content
|
||||
|
||||
if not hasattr(container, "controls"):
|
||||
raise ValueError(f"传递给_process_toml_section的容器必须有controls属性,got: {type(container)}")
|
||||
|
||||
# 先处理所有子部分(嵌套表)
|
||||
subsections = {}
|
||||
simple_items = {}
|
||||
|
||||
# 分离子部分和简单值
|
||||
for key, value in section_data.items():
|
||||
if isinstance(value, (dict, tomlkit.items.Table)):
|
||||
subsections[key] = value
|
||||
else:
|
||||
simple_items[key] = value
|
||||
|
||||
# 处理简单值
|
||||
for key, value in simple_items.items():
|
||||
full_path = f"{section_path}.{key}" if section_path else key
|
||||
control = self._create_control_for_value(key, value, full_path)
|
||||
if control:
|
||||
if indent > 0: # 添加缩进
|
||||
row = ft.Row(
|
||||
[
|
||||
ft.Container(width=indent * 20), # 每级缩进20像素
|
||||
control,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
)
|
||||
container.controls.append(row)
|
||||
else:
|
||||
container.controls.append(control)
|
||||
|
||||
# 处理子部分
|
||||
for key, value in subsections.items():
|
||||
full_path = f"{section_path}.{key}" if section_path else key
|
||||
|
||||
# 创建一个可展开/折叠的部分
|
||||
is_expanded = full_path in self.expanded_sections
|
||||
|
||||
# 获取此部分的注释(安全获取)
|
||||
section_comment = self._get_comment(full_path)
|
||||
|
||||
# 创建子部分的标题行
|
||||
section_title_elems = [
|
||||
ft.Container(width=indent * 20) if indent > 0 else ft.Container(width=0),
|
||||
ft.IconButton(
|
||||
icon=ft.icons.ARROW_DROP_DOWN if is_expanded else ft.icons.ARROW_RIGHT,
|
||||
on_click=lambda e, path=full_path: self._toggle_section(e, path),
|
||||
),
|
||||
ft.Text(key, weight=ft.FontWeight.BOLD, size=16),
|
||||
]
|
||||
|
||||
# 如果有注释,添加一个Info图标并设置tooltip
|
||||
if section_comment and len(section_comment) > 0:
|
||||
try:
|
||||
section_title_elems.append(
|
||||
ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=section_comment, icon_size=16)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"创建信息图标时出错: {full_path}, {e}")
|
||||
|
||||
section_title = ft.Row(
|
||||
section_title_elems,
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
)
|
||||
|
||||
container.controls.append(section_title)
|
||||
|
||||
# 创建子部分的容器
|
||||
subsection_column = ft.Column([])
|
||||
subsection_container = ft.Container(content=subsection_column, visible=is_expanded)
|
||||
container.controls.append(subsection_container)
|
||||
|
||||
# 递归处理子部分
|
||||
if is_expanded:
|
||||
self._process_toml_section(value, subsection_column, full_path, indent + 1)
|
||||
|
||||
def _toggle_section(self, e, section_path):
|
||||
"""切换部分的展开/折叠状态。"""
|
||||
# 使用一个简化和更稳定的方法来处理toggle
|
||||
print(f"切换部分: {section_path}")
|
||||
|
||||
# 在点击的行的下一个容器中查找
|
||||
parent_row = e.control.parent
|
||||
if not parent_row or not isinstance(parent_row, ft.Row):
|
||||
print(f"错误: 无法找到父行: {e.control.parent}")
|
||||
return
|
||||
|
||||
parent_container = parent_row.parent
|
||||
if not parent_container or not hasattr(parent_container, "controls"):
|
||||
print(f"错误: 无法找到父容器: {parent_row.parent}")
|
||||
return
|
||||
|
||||
# 找到当前行在父容器中的索引
|
||||
try:
|
||||
row_index = parent_container.controls.index(parent_row)
|
||||
except ValueError:
|
||||
print(f"错误: 在父容器中找不到行: {parent_row}")
|
||||
return
|
||||
|
||||
# 检查下一个控件是否是子部分容器
|
||||
if row_index + 1 >= len(parent_container.controls):
|
||||
print(f"错误: 行索引超出范围: {row_index + 1} >= {len(parent_container.controls)}")
|
||||
return
|
||||
|
||||
subsection_container = parent_container.controls[row_index + 1]
|
||||
print(f"找到子部分容器: {type(subsection_container).__name__}")
|
||||
|
||||
# 切换展开/折叠状态
|
||||
if section_path in self.expanded_sections:
|
||||
# 折叠
|
||||
e.control.icon = ft.icons.ARROW_RIGHT
|
||||
self.expanded_sections.remove(section_path)
|
||||
subsection_container.visible = False
|
||||
# parent_container.update() # <-- 改为 page.update()
|
||||
else:
|
||||
# 展开
|
||||
e.control.icon = ft.icons.ARROW_DROP_DOWN
|
||||
self.expanded_sections.add(section_path)
|
||||
subsection_container.visible = True
|
||||
|
||||
# 如果容器刚刚变为可见,且内容为空,则加载内容
|
||||
if subsection_container.visible:
|
||||
# 获取子部分的内容列
|
||||
subsection_content = None
|
||||
if isinstance(subsection_container, ft.Container) and subsection_container.content:
|
||||
subsection_content = subsection_container.content
|
||||
else:
|
||||
subsection_content = subsection_container
|
||||
|
||||
# 如果内容是Column且为空,则加载内容
|
||||
if isinstance(subsection_content, ft.Column) and len(subsection_content.controls) == 0:
|
||||
# 获取配置数据
|
||||
parts = section_path.split(".")
|
||||
current = self.config_data
|
||||
for part in parts:
|
||||
if part and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
print(f"警告: 配置路径不存在: {part} in {section_path}")
|
||||
# parent_container.update() # <-- 改为 page.update()
|
||||
self.page.update() # <-- 在这里也强制页面更新
|
||||
return
|
||||
|
||||
# 递归处理子部分
|
||||
if isinstance(current, (dict, tomlkit.items.Table)):
|
||||
indent = len(parts) # 使用路径部分数量作为缩进级别
|
||||
try:
|
||||
# 处理内容但不立即更新UI
|
||||
self._process_toml_section(current, subsection_content, section_path, indent)
|
||||
# 只在完成内容处理后更新一次UI
|
||||
# parent_container.update() # <-- 改为 page.update()
|
||||
except Exception as ex:
|
||||
print(f"处理子部分时出错: {ex}")
|
||||
else:
|
||||
print(f"警告: 配置数据不是字典类型: {type(current).__name__}")
|
||||
# parent_container.update() # <-- 改为 page.update()
|
||||
# else:
|
||||
# 如果只是切换可见性,简单更新父容器
|
||||
# parent_container.update() # <-- 改为 page.update()
|
||||
|
||||
# 强制更新整个页面
|
||||
if self.page:
|
||||
try:
|
||||
self.page.update() # <-- 在函数末尾强制页面更新
|
||||
except Exception as page_update_e:
|
||||
print(f"强制页面更新失败: {page_update_e}")
|
||||
else:
|
||||
print("警告: _toggle_section 中无法访问 Page 对象进行更新")
|
||||
|
||||
def _create_control_for_value(self, key: str, value: Any, full_path: str) -> Optional[ft.Control]:
|
||||
"""
|
||||
根据值的类型创建适当的控件。
|
||||
|
||||
Args:
|
||||
key: 配置键
|
||||
value: 配置值
|
||||
full_path: 配置项的完整路径
|
||||
|
||||
Returns:
|
||||
对应类型的Flet控件
|
||||
"""
|
||||
# 获取注释(安全获取)
|
||||
comment = self._get_comment(full_path)
|
||||
comment_valid = isinstance(comment, str) and len(comment) > 0
|
||||
|
||||
# 根据类型创建不同的控件
|
||||
if isinstance(value, bool):
|
||||
return self._create_boolean_control(key, value, full_path, comment if comment_valid else "")
|
||||
elif isinstance(value, (int, float)):
|
||||
return self._create_number_control(key, value, full_path, comment if comment_valid else "")
|
||||
elif isinstance(value, str):
|
||||
return self._create_string_control(key, value, full_path, comment if comment_valid else "")
|
||||
elif isinstance(value, list):
|
||||
return self._create_list_control(key, value, full_path, comment if comment_valid else "")
|
||||
elif isinstance(value, set):
|
||||
# 特殊处理集合类型(groups部分经常使用)
|
||||
print(f"处理集合类型: {key} = {value}")
|
||||
try:
|
||||
return self._create_set_control(key, value, full_path, comment if comment_valid else "")
|
||||
except Exception as e:
|
||||
print(f"创建集合控件时出错: {e}")
|
||||
# 如果创建失败,返回只读文本
|
||||
return ft.Text(f"{key}: {value} (集合类型,处理失败)", italic=True)
|
||||
else:
|
||||
# 其他类型默认显示为只读文本
|
||||
control = ft.Text(f"{key}: {value} (类型不支持编辑: {type(value).__name__})", italic=True)
|
||||
|
||||
# 如果有有效的注释,添加图标
|
||||
if comment_valid:
|
||||
try:
|
||||
# 在只读文本旁加上注释图标
|
||||
return ft.Row([control, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
|
||||
except Exception:
|
||||
pass # 如果添加图标失败,仍返回原始控件
|
||||
|
||||
return control
|
||||
|
||||
def _update_config_value(self, path: str, new_value: Any):
|
||||
"""递归地更新 self.config_data 中嵌套字典的值。"""
|
||||
keys = path.split(".")
|
||||
d = self.config_data
|
||||
try:
|
||||
for key in keys[:-1]:
|
||||
d = d[key]
|
||||
# 确保最后一个键存在并且可以赋值
|
||||
if keys[-1] in d:
|
||||
# 类型转换 (尝试)
|
||||
original_value = d[keys[-1]]
|
||||
try:
|
||||
if isinstance(original_value, bool):
|
||||
new_value = str(new_value).lower() in ("true", "1", "yes")
|
||||
elif isinstance(original_value, int):
|
||||
new_value = int(new_value)
|
||||
elif isinstance(original_value, float):
|
||||
new_value = float(new_value)
|
||||
# Add other type checks if needed (e.g., list, set)
|
||||
except (ValueError, TypeError) as e:
|
||||
print(
|
||||
f"类型转换错误 ({path}): 输入 '{new_value}' ({type(new_value)}), 期望类型 {type(original_value)}. 错误: {e}"
|
||||
)
|
||||
# 保留原始类型或回退?暂时保留新值,让用户修正
|
||||
# new_value = original_value # 或者可以选择回退
|
||||
pass # Keep new_value as is for now
|
||||
|
||||
d[keys[-1]] = new_value
|
||||
print(f"配置已更新: {path} = {new_value}")
|
||||
else:
|
||||
print(f"警告: 尝试更新不存在的键: {path}")
|
||||
except KeyError:
|
||||
print(f"错误: 更新配置时找不到路径: {path}")
|
||||
except TypeError:
|
||||
print(f"错误: 尝试在非字典对象中更新键: {path}")
|
||||
except Exception as e:
|
||||
print(f"更新配置时发生未知错误 ({path}): {e}")
|
||||
|
||||
# 注意:这里不需要调用 page.update(),因为这是内部数据更新
|
||||
# 调用保存按钮时,会使用更新后的 self.config_data
|
||||
|
||||
def _create_boolean_control(self, key: str, value: bool, path: str, comment: str = "") -> ft.Control:
|
||||
"""创建布尔值的开关控件。"""
|
||||
|
||||
def on_change(e):
|
||||
self._update_config_value(path, e.control.value)
|
||||
|
||||
switch = ft.Switch(label=key, value=value, on_change=on_change)
|
||||
|
||||
# 如果有注释,添加一个Info图标
|
||||
if comment and len(comment) > 0:
|
||||
try:
|
||||
return ft.Row([switch, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
|
||||
except Exception as e:
|
||||
print(f"创建布尔控件的注释图标时出错: {path}, {e}")
|
||||
|
||||
return switch
|
||||
|
||||
def _create_number_control(self, key: str, value: Union[int, float], path: str, comment: str = "") -> ft.Control:
|
||||
"""创建数字输入控件。"""
|
||||
|
||||
def on_change(e):
|
||||
try:
|
||||
# 尝试转换为原始类型
|
||||
if isinstance(value, int):
|
||||
converted = int(e.control.value)
|
||||
else:
|
||||
converted = float(e.control.value)
|
||||
self._update_config_value(path, converted)
|
||||
except (ValueError, TypeError):
|
||||
pass # 忽略无效输入
|
||||
|
||||
text_field = ft.TextField(
|
||||
label=key,
|
||||
value=str(value),
|
||||
input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9.-]"),
|
||||
on_change=on_change,
|
||||
)
|
||||
|
||||
# 如果有注释,添加一个信息图标
|
||||
if comment and len(comment) > 0:
|
||||
try:
|
||||
return ft.Row([text_field, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
|
||||
except Exception as e:
|
||||
print(f"创建数字控件的注释图标时出错: {path}, {e}")
|
||||
|
||||
return text_field
|
||||
|
||||
def _create_string_control(self, key: str, value: str, path: str, comment: str = "") -> ft.Control:
|
||||
"""创建字符串输入控件。"""
|
||||
|
||||
def on_change(e):
|
||||
self._update_config_value(path, e.control.value)
|
||||
|
||||
# 若字符串较长,使用多行文本
|
||||
multiline = len(value) > 30 or "\n" in value
|
||||
|
||||
text_field = ft.TextField(
|
||||
label=key,
|
||||
value=value,
|
||||
multiline=multiline,
|
||||
min_lines=1,
|
||||
max_lines=5 if multiline else 1,
|
||||
on_change=on_change,
|
||||
)
|
||||
|
||||
# 如果有注释,添加一个Info图标
|
||||
if comment and len(comment) > 0:
|
||||
try:
|
||||
return ft.Row([text_field, ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16)])
|
||||
except Exception as e:
|
||||
print(f"创建字符串控件的注释图标时出错: {path}, {e}")
|
||||
|
||||
return text_field
|
||||
|
||||
def _create_list_control(self, key: str, value: List[Any], path: str, comment: str = "") -> ft.Control:
|
||||
"""创建列表控件。"""
|
||||
# 创建一个可编辑的列表控件
|
||||
# 首先创建一个Column存放列表项目和控制按钮
|
||||
title_row = ft.Row(
|
||||
[
|
||||
ft.Text(f"{key}:", weight=ft.FontWeight.BOLD),
|
||||
]
|
||||
)
|
||||
|
||||
# 如果有注释,添加一个Info图标
|
||||
if comment and len(comment) > 0:
|
||||
try:
|
||||
title_row.controls.append(ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16))
|
||||
except Exception as e:
|
||||
print(f"创建列表控件的注释图标时出错: {path}, {e}")
|
||||
|
||||
column = ft.Column([title_row])
|
||||
|
||||
# 创建一个内部Column用于存放列表项
|
||||
items_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
# 创建添加新项目的函数
|
||||
def add_item(e=None, default_value=None, is_initial=False):
|
||||
# 确定新项目的类型(基于现有项目或默认为字符串)
|
||||
item_type = str
|
||||
if value and len(value) > 0:
|
||||
if isinstance(value[0], int):
|
||||
item_type = int
|
||||
elif isinstance(value[0], float):
|
||||
item_type = float
|
||||
elif isinstance(value[0], bool):
|
||||
item_type = bool
|
||||
|
||||
# 创建新项目的默认值
|
||||
if default_value is None:
|
||||
if item_type is int:
|
||||
default_value = 0
|
||||
elif item_type is float:
|
||||
default_value = 0.0
|
||||
elif item_type is bool:
|
||||
default_value = False
|
||||
else:
|
||||
default_value = ""
|
||||
|
||||
# 创建当前索引
|
||||
index = len(items_column.controls)
|
||||
|
||||
# 创建删除项目的函数
|
||||
def delete_item(e):
|
||||
# 删除此项目
|
||||
items_column.controls.remove(item_row)
|
||||
# 更新列表中的值
|
||||
update_list_value()
|
||||
# 确保UI更新
|
||||
items_column.update()
|
||||
# 更新整个表单
|
||||
column.update()
|
||||
|
||||
# 创建项目控件(根据类型)
|
||||
if item_type is bool:
|
||||
item_control = ft.Switch(value=default_value)
|
||||
elif item_type in (int, float):
|
||||
item_control = ft.TextField(
|
||||
value=str(default_value),
|
||||
input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9.-]"),
|
||||
width=200,
|
||||
)
|
||||
else: # 字符串
|
||||
item_control = ft.TextField(value=default_value, width=200)
|
||||
|
||||
# 添加控件的更改事件
|
||||
def on_item_change(e):
|
||||
# 获取新值
|
||||
new_val = e.control.value
|
||||
# 转换类型
|
||||
if item_type is int:
|
||||
try:
|
||||
new_val = int(new_val)
|
||||
except ValueError:
|
||||
new_val = 0
|
||||
elif item_type is float:
|
||||
try:
|
||||
new_val = float(new_val)
|
||||
except ValueError:
|
||||
new_val = 0.0
|
||||
elif item_type is bool:
|
||||
new_val = bool(new_val)
|
||||
# 更新列表中的值
|
||||
update_list_value()
|
||||
|
||||
# 添加更改事件
|
||||
if item_type is bool:
|
||||
item_control.on_change = on_item_change
|
||||
else:
|
||||
item_control.on_change = on_item_change
|
||||
|
||||
# 创建行包含项目控件和删除按钮
|
||||
item_row = ft.Row(
|
||||
[ft.Text(f"[{index}]"), item_control, ft.IconButton(icon=ft.icons.DELETE, on_click=delete_item)],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
)
|
||||
|
||||
# 将行添加到列表中
|
||||
items_column.controls.append(item_row)
|
||||
|
||||
# 只有在用户交互时更新UI,初始加载时不更新
|
||||
if not is_initial and e is not None:
|
||||
# 更新UI - 确保整个控件都更新
|
||||
try:
|
||||
items_column.update()
|
||||
column.update()
|
||||
except Exception as update_e:
|
||||
print(f"更新列表控件时出错: {path}, {update_e}")
|
||||
|
||||
return item_control
|
||||
|
||||
# 创建更新列表值的函数
|
||||
def update_list_value():
|
||||
new_list = []
|
||||
for item_row in items_column.controls:
|
||||
if len(item_row.controls) < 2:
|
||||
continue # 跳过格式不正确的行
|
||||
|
||||
item_control = item_row.controls[1] # 获取TextField或Switch
|
||||
|
||||
# 根据控件类型获取值
|
||||
if isinstance(item_control, ft.Switch):
|
||||
new_list.append(item_control.value)
|
||||
elif isinstance(item_control, ft.TextField):
|
||||
# 根据原始列表中的类型转换值
|
||||
if value and len(value) > 0:
|
||||
if isinstance(value[0], int):
|
||||
try:
|
||||
new_list.append(int(item_control.value))
|
||||
except ValueError:
|
||||
new_list.append(0)
|
||||
elif isinstance(value[0], float):
|
||||
try:
|
||||
new_list.append(float(item_control.value))
|
||||
except ValueError:
|
||||
new_list.append(0.0)
|
||||
else:
|
||||
new_list.append(item_control.value)
|
||||
else:
|
||||
new_list.append(item_control.value)
|
||||
|
||||
# 更新TOML配置
|
||||
try:
|
||||
self._update_config_value(path, new_list)
|
||||
except Exception as e:
|
||||
print(f"更新列表值时出错: {path}, {e}")
|
||||
|
||||
# 添加现有项目,使用is_initial=True标记为初始化
|
||||
for item in value:
|
||||
add_item(default_value=item, is_initial=True)
|
||||
|
||||
# 添加按钮行
|
||||
button_row = ft.Row(
|
||||
[ft.ElevatedButton("添加项目", icon=ft.icons.ADD, on_click=add_item)], alignment=ft.MainAxisAlignment.START
|
||||
)
|
||||
|
||||
# 将组件添加到主Column
|
||||
column.controls.append(items_column)
|
||||
column.controls.append(button_row)
|
||||
|
||||
# 将整个列表控件包装在一个Card中,让它看起来更独立
|
||||
# Card不支持padding参数,使用Container包裹
|
||||
return ft.Card(content=ft.Container(content=column, padding=10))
|
||||
|
||||
def _create_set_control(self, key: str, value: set, path: str, comment: str = "") -> ft.Control:
|
||||
"""创建集合控件。"""
|
||||
# 创建一个可编辑的列表控件
|
||||
# 首先创建一个Column存放列表项目和控制按钮
|
||||
title_row = ft.Row(
|
||||
[
|
||||
ft.Text(f"{key} (集合):", weight=ft.FontWeight.BOLD),
|
||||
]
|
||||
)
|
||||
|
||||
# 如果有注释,添加一个Info图标
|
||||
if comment and len(comment) > 0:
|
||||
try:
|
||||
title_row.controls.append(ft.IconButton(icon=ft.icons.INFO_OUTLINE, tooltip=comment, icon_size=16))
|
||||
except Exception as e:
|
||||
print(f"创建集合控件的注释图标时出错: {path}, {e}")
|
||||
|
||||
column = ft.Column([title_row])
|
||||
|
||||
# 创建一个内部Column用于存放集合项
|
||||
items_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
# 创建一个用于输入的文本框
|
||||
new_item_field = ft.TextField(label="添加新项目", hint_text="输入值后按Enter添加", width=300)
|
||||
|
||||
# 创建一个列表存储当前集合值
|
||||
current_values = list(value)
|
||||
|
||||
# 创建添加新项目的函数
|
||||
def add_item(e=None, item_value=None, is_initial=False):
|
||||
if e and hasattr(e, "control") and e.control == new_item_field:
|
||||
# 从文本框获取值
|
||||
item_value = new_item_field.value.strip()
|
||||
if not item_value:
|
||||
return
|
||||
new_item_field.value = "" # 清空输入框
|
||||
if not is_initial: # 只有在用户交互时更新
|
||||
try:
|
||||
new_item_field.update()
|
||||
except Exception as update_e:
|
||||
print(f"更新文本框时出错: {path}, {update_e}")
|
||||
|
||||
if item_value is None or item_value == "":
|
||||
return
|
||||
|
||||
# 判断值的类型(假设集合中所有元素类型一致)
|
||||
item_type = str
|
||||
if current_values and len(current_values) > 0:
|
||||
if isinstance(current_values[0], int):
|
||||
item_type = int
|
||||
elif isinstance(current_values[0], float):
|
||||
item_type = float
|
||||
elif isinstance(current_values[0], bool):
|
||||
item_type = bool
|
||||
|
||||
# 转换类型
|
||||
if item_type is int:
|
||||
try:
|
||||
item_value = int(item_value)
|
||||
except ValueError:
|
||||
return # 如果无法转换则忽略
|
||||
elif item_type is float:
|
||||
try:
|
||||
item_value = float(item_value)
|
||||
except ValueError:
|
||||
return # 如果无法转换则忽略
|
||||
elif item_type is bool:
|
||||
if item_value.lower() in ("true", "yes", "1", "y"):
|
||||
item_value = True
|
||||
elif item_value.lower() in ("false", "no", "0", "n"):
|
||||
item_value = False
|
||||
else:
|
||||
return # 无效的布尔值
|
||||
|
||||
# 检查是否已存在(集合特性)
|
||||
if item_value in current_values:
|
||||
return # 如果已存在则忽略
|
||||
|
||||
# 添加到当前值列表
|
||||
current_values.append(item_value)
|
||||
|
||||
# 创建删除项目的函数
|
||||
def delete_item(e):
|
||||
# 删除此项目
|
||||
current_values.remove(item_value)
|
||||
items_column.controls.remove(item_row)
|
||||
# 更新集合中的值
|
||||
update_set_value()
|
||||
# 确保UI更新
|
||||
try:
|
||||
items_column.update()
|
||||
column.update() # 更新整个表单
|
||||
except Exception as update_e:
|
||||
print(f"更新集合UI时出错: {path}, {update_e}")
|
||||
|
||||
# 创建行包含项目文本和删除按钮
|
||||
item_row = ft.Row(
|
||||
[ft.Text(str(item_value)), ft.IconButton(icon=ft.icons.DELETE, on_click=delete_item)],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
)
|
||||
|
||||
# 将行添加到列表中
|
||||
items_column.controls.append(item_row)
|
||||
|
||||
# 只有在用户交互时更新UI,初始加载时不更新
|
||||
if not is_initial and e is not None:
|
||||
# 更新UI
|
||||
try:
|
||||
items_column.update()
|
||||
column.update() # 确保整个表单都更新
|
||||
except Exception as update_e:
|
||||
print(f"更新集合UI时出错: {path}, {update_e}")
|
||||
|
||||
# 更新集合值
|
||||
update_set_value()
|
||||
|
||||
# 创建更新集合值的函数
|
||||
def update_set_value():
|
||||
# 从current_values创建一个新集合
|
||||
try:
|
||||
new_set = set(current_values)
|
||||
# 更新TOML配置
|
||||
self._update_config_value(path, new_set)
|
||||
except Exception as e:
|
||||
print(f"更新集合值时出错: {path}, {e}")
|
||||
|
||||
# 添加键盘事件处理
|
||||
def on_key_press(e):
|
||||
if e.key == "Enter":
|
||||
add_item(e)
|
||||
|
||||
new_item_field.on_submit = add_item
|
||||
|
||||
# 添加现有项目,使用is_initial=True标记为初始化
|
||||
for item in value:
|
||||
add_item(item_value=item, is_initial=True)
|
||||
|
||||
# 添加输入框
|
||||
input_row = ft.Row(
|
||||
[
|
||||
new_item_field,
|
||||
ft.IconButton(
|
||||
icon=ft.icons.ADD, on_click=lambda e: add_item(e, item_value=new_item_field.value.strip())
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
)
|
||||
|
||||
# 将组件添加到主Column
|
||||
column.controls.append(items_column)
|
||||
column.controls.append(input_row)
|
||||
|
||||
# 将整个集合控件包装在一个Card中,让它看起来更独立
|
||||
# Card不支持padding参数,使用Container包裹
|
||||
return ft.Card(content=ft.Container(content=column, padding=10))
|
||||
|
||||
|
||||
def load_bot_config_template(app_state) -> Dict[str, Any]:
|
||||
"""
|
||||
加载bot_config_template.toml文件作为参考。
|
||||
|
||||
Returns:
|
||||
带有注释的TOML文档
|
||||
"""
|
||||
template_path = Path(app_state.script_dir) / "template/bot_config_template.toml"
|
||||
if template_path.exists():
|
||||
try:
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
return tomlkit.parse(f.read()) # 使用parse而不是load以保留注释
|
||||
except Exception as e:
|
||||
print(f"加载模板配置文件失败: {e}")
|
||||
return tomlkit.document()
|
||||
|
||||
|
||||
def get_bot_config_path(app_state) -> Path:
|
||||
"""
|
||||
获取配置文件路径
|
||||
"""
|
||||
config_path = Path(app_state.script_dir) / "config/bot_config.toml"
|
||||
return config_path
|
||||
|
||||
|
||||
def load_bot_config(app_state) -> Dict[str, Any]:
|
||||
"""
|
||||
加载bot_config.toml文件
|
||||
|
||||
如果文件不存在,会尝试从模板创建
|
||||
"""
|
||||
config_path = get_bot_config_path(app_state)
|
||||
|
||||
# 如果配置文件不存在,尝试从模板创建
|
||||
if not config_path.exists():
|
||||
template_config = load_bot_config_template(app_state)
|
||||
if template_config:
|
||||
print(f"配置文件不存在,尝试从模板创建: {config_path}")
|
||||
try:
|
||||
# 确保目录存在
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 保存模板内容到配置文件
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
tomlkit.dump(template_config, f)
|
||||
print(f"成功从模板创建配置文件: {config_path}")
|
||||
return template_config
|
||||
except Exception as e:
|
||||
print(f"从模板创建配置文件失败: {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
# 加载配置文件
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return tomlkit.load(f)
|
||||
except Exception as e:
|
||||
print(f"加载配置文件失败: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def create_toml_form(
|
||||
page: ft.Page,
|
||||
config_data: Dict[str, Any],
|
||||
container: ft.Column,
|
||||
template_filename: str = "bot_config_template.toml",
|
||||
):
|
||||
"""
|
||||
创建并构建TOML表单。
|
||||
|
||||
Args:
|
||||
page: Flet Page 对象
|
||||
config_data: TOML配置数据
|
||||
container: 放置表单的父容器
|
||||
template_filename: 要使用的模板文件名
|
||||
Returns:
|
||||
创建的 TomlFormGenerator 实例
|
||||
"""
|
||||
generator = TomlFormGenerator(page, config_data, container, template_filename)
|
||||
generator.build_form()
|
||||
return generator # Return the generator instance
|
||||
265
src/MaiGoi/ui_env_editor.py
Normal file
265
src/MaiGoi/ui_env_editor.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import flet as ft
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# --- .env File Handling Logic ---
|
||||
|
||||
|
||||
def load_env_data(env_path: Path) -> List[Tuple[str, str]]:
|
||||
"""Loads key-value pairs from a .env file, skipping comments and empty lines."""
|
||||
variables = []
|
||||
if not env_path.exists():
|
||||
print(f"[Env Editor] .env file not found at {env_path}")
|
||||
return variables
|
||||
|
||||
try:
|
||||
with open(env_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
# Basic handling for quotes (remove if present at ends)
|
||||
if len(value) >= 2 and value.startswith(("'", '"')) and value.endswith(("'", '"')):
|
||||
value = value[1:-1]
|
||||
variables.append((key, value))
|
||||
# else: Handle lines without '='? Maybe ignore them.
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Env Editor] Error loading .env file {env_path}: {e}")
|
||||
|
||||
return variables
|
||||
|
||||
|
||||
def save_env_data(env_path: Path, variables: List[Tuple[str, str]]):
|
||||
"""Saves key-value pairs back to the .env file, overwriting existing content."""
|
||||
try:
|
||||
with open(env_path, "w", encoding="utf-8") as f:
|
||||
for key, value in variables:
|
||||
# Basic quoting if value contains spaces or special chars?
|
||||
# For simplicity, just write key=value for now.
|
||||
# Advanced quoting logic can be added if needed.
|
||||
f.write(f"{key}={value}\n")
|
||||
print(f"[Env Editor] Successfully saved data to {env_path}")
|
||||
except Exception as e:
|
||||
print(f"[Env Editor] Error saving .env file {env_path}: {e}")
|
||||
# Optionally raise or show error to user
|
||||
|
||||
|
||||
# --- Flet UI Component ---
|
||||
|
||||
|
||||
# Inherit directly from ft.Column instead of ft.UserControl
|
||||
class EnvEditor(ft.Column):
|
||||
"""A Flet Column containing controls for editing .env file variables."""
|
||||
|
||||
def __init__(self, app_state):
|
||||
# Initialize the Column base class
|
||||
# Pass Column properties like spacing, scroll, expand here
|
||||
super().__init__(spacing=5, scroll=ft.ScrollMode.ADAPTIVE, expand=True)
|
||||
|
||||
self.app_state = app_state
|
||||
self.env_path = Path(self.app_state.script_dir) / ".env"
|
||||
self.variables = load_env_data(self.env_path)
|
||||
|
||||
# UI Controls - Define them as instance attributes
|
||||
self.variable_rows_column = ft.Column([], spacing=5, scroll=ft.ScrollMode.ADAPTIVE)
|
||||
self.add_key_field = ft.TextField(label="New Key", width=150)
|
||||
self.add_value_field = ft.TextField(label="New Value", expand=True)
|
||||
self.save_button = ft.ElevatedButton("Save Changes", icon=ft.icons.SAVE, on_click=self._save_changes)
|
||||
self.status_text = ft.Text("") # For showing save status/errors
|
||||
|
||||
# --- Build the UI directly within __init__ ---
|
||||
self._populate_rows() # Populate rows initially
|
||||
|
||||
add_row = ft.Row(
|
||||
[
|
||||
self.add_key_field,
|
||||
self.add_value_field,
|
||||
ft.IconButton(
|
||||
icon=ft.icons.ADD_CIRCLE_OUTLINE,
|
||||
tooltip="Add Variable",
|
||||
on_click=self._add_variable_row_interactive,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
)
|
||||
|
||||
# Add controls directly to self (the Column)
|
||||
self.controls.extend(
|
||||
[
|
||||
ft.Text(".env File Editor", style=ft.TextThemeStyle.HEADLINE_SMALL),
|
||||
ft.Text(f"Editing: {self.env_path.name} (in {self.env_path.parent})"),
|
||||
ft.Divider(),
|
||||
self.variable_rows_column, # Add the column that holds the variable rows
|
||||
ft.Divider(),
|
||||
ft.Text("Add New Variable:", style=ft.TextThemeStyle.LABEL_LARGE),
|
||||
add_row,
|
||||
ft.Divider(),
|
||||
ft.Row([self.save_button, self.status_text], alignment=ft.MainAxisAlignment.START),
|
||||
]
|
||||
)
|
||||
# No need to return anything from __init__
|
||||
|
||||
def _populate_rows(self):
|
||||
"""Clears and refills the variable rows column based on self.variables."""
|
||||
self.variable_rows_column.controls.clear()
|
||||
for index, (key, value) in enumerate(self.variables):
|
||||
self.variable_rows_column.controls.append(self._create_variable_row(index, key, value))
|
||||
# No need to update here, usually called during init or after add/delete
|
||||
|
||||
def _create_variable_row(self, index: int, key: str, value: str) -> ft.Row:
|
||||
"""Creates a Row control for a single key-value pair."""
|
||||
key_field = ft.TextField(value=key, expand=2, data=index)
|
||||
value_field = ft.TextField(value=value, expand=5, data=index)
|
||||
|
||||
# Update self.variables when text fields change (optional, safer to update only on save)
|
||||
# key_field.on_change = self._update_variable_from_ui
|
||||
# value_field.on_change = self._update_variable_from_ui
|
||||
|
||||
return ft.Row(
|
||||
[
|
||||
key_field,
|
||||
value_field,
|
||||
ft.IconButton(
|
||||
icon=ft.icons.DELETE_OUTLINE,
|
||||
tooltip="Delete Variable",
|
||||
data=index, # Store index to know which one to delete
|
||||
on_click=self._delete_variable_row,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
key=str(index), # Assign a key for potential targeted updates
|
||||
)
|
||||
|
||||
def _add_variable_row_interactive(self, e):
|
||||
"""Adds a variable row based on the 'Add New' fields and updates the UI."""
|
||||
new_key = self.add_key_field.value.strip()
|
||||
new_value = self.add_value_field.value.strip()
|
||||
|
||||
if not new_key:
|
||||
# Access page via self.page if the control is mounted
|
||||
if self.page:
|
||||
self.page.show_snack_bar(ft.SnackBar(ft.Text("Key cannot be empty."), open=True))
|
||||
return
|
||||
|
||||
# Check if key already exists? For now, allow duplicates, save will handle last one.
|
||||
|
||||
# Add to internal list
|
||||
self.variables.append((new_key, new_value))
|
||||
|
||||
# Add UI row
|
||||
new_index = len(self.variables) - 1
|
||||
self.variable_rows_column.controls.append(self._create_variable_row(new_index, new_key, new_value))
|
||||
|
||||
# Clear add fields
|
||||
self.add_key_field.value = ""
|
||||
self.add_value_field.value = ""
|
||||
|
||||
self.update() # Update this Column
|
||||
# If page exists, update page too (might be redundant if Column update cascades)
|
||||
# if self.page: self.page.update()
|
||||
|
||||
def _delete_variable_row(self, e):
|
||||
"""Deletes a variable row from the UI and the internal list."""
|
||||
index_to_delete = e.control.data
|
||||
|
||||
if 0 <= index_to_delete < len(self.variables):
|
||||
# Find the row control to remove
|
||||
row_to_remove = None
|
||||
for control in self.variable_rows_column.controls:
|
||||
# Check the data attribute of the delete button inside the row
|
||||
if (
|
||||
isinstance(control, ft.Row)
|
||||
and len(control.controls) > 2
|
||||
and isinstance(control.controls[2], ft.IconButton)
|
||||
and control.controls[2].data == index_to_delete
|
||||
):
|
||||
row_to_remove = control
|
||||
break
|
||||
|
||||
# Remove from internal list *first*
|
||||
if index_to_delete < len(self.variables): # Double check index after finding row
|
||||
del self.variables[index_to_delete]
|
||||
else:
|
||||
print(f"[Env Editor] Error: Index {index_to_delete} out of bounds after finding row.")
|
||||
return
|
||||
|
||||
# Remove from UI column if found
|
||||
if row_to_remove:
|
||||
self.variable_rows_column.controls.remove(row_to_remove)
|
||||
|
||||
# Need to re-index remaining rows' data attributes
|
||||
self._reindex_rows()
|
||||
|
||||
self.update() # Update this Column
|
||||
# if self.page: self.page.update()
|
||||
else:
|
||||
print(f"[Env Editor] Error: Invalid index to delete: {index_to_delete}")
|
||||
|
||||
def _reindex_rows(self):
|
||||
"""Updates the data attribute (index) of controls in each row after deletion."""
|
||||
for i, row in enumerate(self.variable_rows_column.controls):
|
||||
if isinstance(row, ft.Row) and len(row.controls) > 2:
|
||||
# Update index on key field, value field, and delete button
|
||||
if isinstance(row.controls[0], ft.TextField):
|
||||
row.controls[0].data = i
|
||||
if isinstance(row.controls[1], ft.TextField):
|
||||
row.controls[1].data = i
|
||||
if isinstance(row.controls[2], ft.IconButton):
|
||||
row.controls[2].data = i
|
||||
|
||||
def _save_changes(self, e):
|
||||
"""Collects data from UI rows and saves to the .env file."""
|
||||
updated_variables = []
|
||||
has_error = False
|
||||
keys = set()
|
||||
|
||||
for row_index, row in enumerate(self.variable_rows_column.controls):
|
||||
if isinstance(row, ft.Row) and len(row.controls) >= 2:
|
||||
key_field = row.controls[0]
|
||||
value_field = row.controls[1]
|
||||
if isinstance(key_field, ft.TextField) and isinstance(value_field, ft.TextField):
|
||||
key = key_field.value.strip()
|
||||
value = value_field.value # Keep original spacing/quotes for value for now
|
||||
if not key:
|
||||
has_error = True
|
||||
# Use row_index which reflects the current visual order
|
||||
self.status_text.value = f"Error: Row {row_index + 1} has an empty key."
|
||||
self.status_text.color = ft.colors.RED
|
||||
break # Stop processing on first error
|
||||
if key in keys:
|
||||
print(f"[Env Editor] Warning: Duplicate key '{key}' found. Last occurrence will be saved.")
|
||||
# Or show error? Let's allow for now, last wins on save.
|
||||
keys.add(key)
|
||||
updated_variables.append((key, value))
|
||||
else:
|
||||
has_error = True
|
||||
self.status_text.value = "Error: Invalid row structure found."
|
||||
self.status_text.color = ft.colors.RED
|
||||
break
|
||||
else: # Handle cases where row might not be what's expected
|
||||
print(f"[Env Editor] Warning: Skipping unexpected control type in variable column: {type(row)}")
|
||||
|
||||
if not has_error:
|
||||
try:
|
||||
save_env_data(self.env_path, updated_variables)
|
||||
self.variables = updated_variables # Update internal state
|
||||
self.status_text.value = "Changes saved successfully!"
|
||||
self.status_text.color = ft.colors.GREEN
|
||||
except Exception as ex:
|
||||
self.status_text.value = f"Error saving file: {ex}"
|
||||
self.status_text.color = ft.colors.RED
|
||||
|
||||
self.status_text.update()
|
||||
|
||||
|
||||
# --- Function to create the main view containing the editor ---
|
||||
# This can be called from ui_settings_view.py
|
||||
def create_env_editor_page_content(page: ft.Page, app_state) -> ft.Control:
|
||||
"""Creates the EnvEditor control."""
|
||||
# EnvEditor is now the Column itself
|
||||
editor = EnvEditor(app_state)
|
||||
return editor
|
||||
348
src/MaiGoi/ui_settings_view.py
Normal file
348
src/MaiGoi/ui_settings_view.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import flet as ft
|
||||
import tomlkit
|
||||
|
||||
from .state import AppState
|
||||
from .utils import show_snackbar # Assuming show_snackbar is in utils
|
||||
from .toml_form_generator import create_toml_form, load_bot_config, get_bot_config_path
|
||||
from .config_manager import load_config, save_config
|
||||
from .ui_env_editor import create_env_editor_page_content
|
||||
|
||||
|
||||
def save_bot_config(page: ft.Page, app_state: AppState, new_config_data: dict):
|
||||
"""将修改后的 Bot 配置保存回文件。"""
|
||||
config_path = get_bot_config_path(app_state)
|
||||
try:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
# Use tomlkit.dumps to preserve formatting/comments as much as possible
|
||||
# It might need refinement based on how UI controls update the dict
|
||||
tomlkit.dump(new_config_data, f)
|
||||
show_snackbar(page, "Bot 配置已保存!")
|
||||
# Optionally reload config into app_state if needed immediately elsewhere
|
||||
# app_state.bot_config = new_config_data # Or reload using a dedicated function
|
||||
except Exception as e:
|
||||
print(f"Error saving bot config: {e}")
|
||||
show_snackbar(page, f"保存 Bot 配置失败: {e}", error=True)
|
||||
|
||||
|
||||
def save_bot_config_changes(page: ft.Page, config_to_save: dict):
|
||||
"""Handles saving changes for bot_config.toml"""
|
||||
print("[Settings] Saving Bot Config (TOML) changes...")
|
||||
# Assuming save_config needs path, let's build it or adapt save_config
|
||||
# For now, let's assume save_config can handle type='bot'
|
||||
# config_path = get_bot_config_path(app_state) # Need app_state if using this
|
||||
success = save_config(config_to_save, config_type="bot")
|
||||
if success:
|
||||
message = "Bot 配置已保存!"
|
||||
else:
|
||||
message = "保存 Bot 配置失败。"
|
||||
show_snackbar(page, message, error=(not success))
|
||||
|
||||
|
||||
def save_lpmm_config_changes(page: ft.Page, config_to_save: dict):
|
||||
"""Handles saving changes for lpmm_config.toml"""
|
||||
print("[Settings] Saving LPMM Config (TOML) changes...")
|
||||
success = save_config(config_to_save, config_type="lpmm") # Use type 'lpmm'
|
||||
if success:
|
||||
message = "LPMM 配置已保存!"
|
||||
else:
|
||||
message = "保存 LPMM 配置失败。"
|
||||
show_snackbar(page, message, error=(not success))
|
||||
|
||||
|
||||
def save_gui_config_changes(page: ft.Page, app_state: AppState):
|
||||
"""Handles saving changes for gui_config.toml (currently just theme)"""
|
||||
print("[Settings] Saving GUI Config changes...")
|
||||
# gui_config is directly in app_state, no need to pass config_to_save
|
||||
success = save_config(app_state.gui_config, config_type="gui")
|
||||
if success:
|
||||
message = "GUI 配置已保存!"
|
||||
else:
|
||||
message = "保存 GUI 配置失败。"
|
||||
show_snackbar(page, message, error=(not success))
|
||||
|
||||
|
||||
def create_settings_view(page: ft.Page, app_state: AppState) -> ft.View:
|
||||
"""Creates the settings view with sections for different config files."""
|
||||
|
||||
# --- State for switching between editors ---
|
||||
content_area = ft.Column([], expand=True, scroll=ft.ScrollMode.ADAPTIVE)
|
||||
current_config_data = {} # Store loaded data for saving
|
||||
|
||||
# --- Function to load Bot config editor (Original TOML editor) ---
|
||||
def show_bot_config_editor(e=None):
|
||||
nonlocal current_config_data
|
||||
print("[Settings] Loading Bot Config Editor")
|
||||
try:
|
||||
current_bot_config = load_bot_config(app_state)
|
||||
if not current_bot_config:
|
||||
raise ValueError("Bot config could not be loaded.")
|
||||
current_config_data = current_bot_config
|
||||
content_area.controls.clear()
|
||||
# Pass the correct template filename string
|
||||
form_generator = create_toml_form(
|
||||
page, current_bot_config, content_area, template_filename="bot_config_template.toml"
|
||||
)
|
||||
save_button = ft.ElevatedButton(
|
||||
"保存 Bot 配置更改",
|
||||
icon=ft.icons.SAVE,
|
||||
on_click=lambda _: save_bot_config_changes(
|
||||
page, form_generator.config_data if hasattr(form_generator, "config_data") else current_config_data
|
||||
),
|
||||
)
|
||||
content_area.controls.append(ft.Divider())
|
||||
content_area.controls.append(save_button)
|
||||
except Exception as ex:
|
||||
content_area.controls.clear()
|
||||
content_area.controls.append(ft.Text(f"加载 Bot 配置时出错: {ex}", color=ft.colors.ERROR))
|
||||
if page:
|
||||
page.update()
|
||||
|
||||
# --- Function to load LPMM config editor ---
|
||||
def show_lpmm_editor(e=None):
|
||||
nonlocal current_config_data
|
||||
print("[Settings] Loading LPMM Config Editor")
|
||||
try:
|
||||
lpmm_config = load_config(config_type="lpmm")
|
||||
if not lpmm_config:
|
||||
raise ValueError("LPMM config could not be loaded.")
|
||||
current_config_data = lpmm_config
|
||||
content_area.controls.clear()
|
||||
# Pass the correct template filename string
|
||||
form_generator = create_toml_form(
|
||||
page, lpmm_config, content_area, template_filename="lpmm_config_template.toml"
|
||||
)
|
||||
save_button = ft.ElevatedButton(
|
||||
"保存 LPMM 配置更改",
|
||||
icon=ft.icons.SAVE,
|
||||
on_click=lambda _: save_lpmm_config_changes(
|
||||
page, form_generator.config_data if hasattr(form_generator, "config_data") else current_config_data
|
||||
),
|
||||
)
|
||||
content_area.controls.append(ft.Divider())
|
||||
content_area.controls.append(save_button)
|
||||
except Exception as ex:
|
||||
content_area.controls.clear()
|
||||
content_area.controls.append(ft.Text(f"加载 LPMM 配置时出错: {ex}", color=ft.colors.ERROR))
|
||||
if page:
|
||||
page.update()
|
||||
|
||||
# --- Function to load GUI settings editor ---
|
||||
def show_gui_settings(e=None):
|
||||
# GUI config is simpler, might not need full form generator
|
||||
# We'll load it directly from app_state and save app_state.gui_config
|
||||
print("[Settings] Loading GUI Settings Editor")
|
||||
content_area.controls.clear()
|
||||
|
||||
def change_theme(ev):
|
||||
selected_theme = ev.control.value.upper()
|
||||
page.theme_mode = ft.ThemeMode[selected_theme]
|
||||
app_state.gui_config["theme"] = selected_theme
|
||||
print(f"Theme changed to: {page.theme_mode}, updating app_state.gui_config")
|
||||
page.update() # Update theme immediately
|
||||
|
||||
# Get current theme from app_state or page
|
||||
current_theme_val = app_state.gui_config.get("theme", str(page.theme_mode).split(".")[-1]).capitalize()
|
||||
if current_theme_val not in ["System", "Light", "Dark"]:
|
||||
current_theme_val = "System" # Default fallback
|
||||
|
||||
theme_dropdown = ft.Dropdown(
|
||||
label="界面主题",
|
||||
value=current_theme_val,
|
||||
options=[
|
||||
ft.dropdown.Option("System"),
|
||||
ft.dropdown.Option("Light"),
|
||||
ft.dropdown.Option("Dark"),
|
||||
],
|
||||
on_change=change_theme,
|
||||
# expand=True, # Maybe not expand in this layout
|
||||
)
|
||||
|
||||
save_button = ft.ElevatedButton(
|
||||
"保存 GUI 设置", icon=ft.icons.SAVE, on_click=lambda _: save_gui_config_changes(page, app_state)
|
||||
)
|
||||
|
||||
content_area.controls.extend(
|
||||
[
|
||||
ft.Text("界面设置:", weight=ft.FontWeight.BOLD),
|
||||
ft.Row([theme_dropdown]),
|
||||
# Add more GUI controls here if needed in the future
|
||||
ft.Divider(),
|
||||
save_button,
|
||||
]
|
||||
)
|
||||
if page:
|
||||
page.update()
|
||||
|
||||
# --- Function to load .env editor ---
|
||||
def show_env_editor(e=None):
|
||||
# No config data to manage here, it handles its own save
|
||||
print("[Settings] Loading .env Editor")
|
||||
content_area.controls.clear()
|
||||
env_editor_content = create_env_editor_page_content(page, app_state)
|
||||
content_area.controls.append(env_editor_content)
|
||||
if page:
|
||||
page.update()
|
||||
|
||||
# --- Initial View Setup ---
|
||||
# Load the Bot config editor by default
|
||||
show_bot_config_editor()
|
||||
|
||||
return ft.View(
|
||||
"/settings",
|
||||
[
|
||||
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
|
||||
ft.Row(
|
||||
[
|
||||
ft.ElevatedButton("Bot 配置", icon=ft.icons.SETTINGS_SUGGEST, on_click=show_bot_config_editor),
|
||||
ft.ElevatedButton("LPMM 配置", icon=ft.icons.MEMORY, on_click=show_lpmm_editor),
|
||||
ft.ElevatedButton("GUI 设置", icon=ft.icons.BRUSH, on_click=show_gui_settings),
|
||||
ft.ElevatedButton(".env 配置", icon=ft.icons.EDIT, on_click=show_env_editor),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
wrap=True, # Allow buttons to wrap on smaller widths
|
||||
),
|
||||
ft.Divider(),
|
||||
content_area, # This holds the currently selected editor
|
||||
],
|
||||
scroll=ft.ScrollMode.ADAPTIVE,
|
||||
)
|
||||
|
||||
|
||||
# Note: Assumes save_config function exists and can handle saving
|
||||
# the bot_config dictionary back to its TOML file. You might need to
|
||||
# adjust the save_bot_config_changes function based on how saving is implemented.
|
||||
# Also assumes load_bot_config loads the data correctly for the TOML editor.
|
||||
|
||||
|
||||
def create_settings_view_old(page: ft.Page, app_state: AppState) -> ft.View:
|
||||
"""创建设置页面视图。"""
|
||||
|
||||
# --- GUI Settings ---
|
||||
def change_theme(e):
|
||||
selected_theme = e.control.value.upper()
|
||||
page.theme_mode = ft.ThemeMode[selected_theme]
|
||||
# Persist theme choice? Maybe in gui_config?
|
||||
app_state.gui_config["theme"] = selected_theme # Example persistence
|
||||
# Need a way to save gui_config too (similar to bot_config?)
|
||||
print(f"Theme changed to: {page.theme_mode}")
|
||||
page.update()
|
||||
|
||||
theme_dropdown = ft.Dropdown(
|
||||
label="界面主题",
|
||||
value=str(page.theme_mode).split(".")[-1].capitalize()
|
||||
if page.theme_mode
|
||||
else "System", # Handle None theme_mode
|
||||
options=[
|
||||
ft.dropdown.Option("System"),
|
||||
ft.dropdown.Option("Light"),
|
||||
ft.dropdown.Option("Dark"),
|
||||
],
|
||||
on_change=change_theme,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
gui_settings_card = ft.Card(
|
||||
content=ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.ListTile(title=ft.Text("GUI 设置")),
|
||||
ft.Row([theme_dropdown], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
# Add more GUI settings here
|
||||
]
|
||||
),
|
||||
padding=10,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Bot Settings (Placeholder) ---
|
||||
# TODO: Load bot_config.toml and dynamically generate controls
|
||||
config_path = get_bot_config_path(app_state)
|
||||
bot_config_content_area = ft.Column(expand=True, scroll=ft.ScrollMode.ADAPTIVE)
|
||||
bot_settings_card = ft.Card(
|
||||
content=ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.ListTile(title=ft.Text("Bot 配置 (bot_config.toml)")),
|
||||
ft.Text(f"配置文件路径: {config_path}", italic=True, size=10),
|
||||
ft.Divider(),
|
||||
# Placeholder - Controls will be added dynamically
|
||||
bot_config_content_area,
|
||||
ft.Divider(),
|
||||
ft.Row(
|
||||
[
|
||||
ft.ElevatedButton(
|
||||
"重新加载", icon=ft.icons.REFRESH, on_click=lambda _: print("Reload TBD")
|
||||
), # Placeholder action
|
||||
ft.ElevatedButton(
|
||||
"保存 Bot 配置", icon=ft.icons.SAVE, on_click=lambda _: print("Save TBD")
|
||||
), # Placeholder action
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.END,
|
||||
),
|
||||
]
|
||||
),
|
||||
padding=10,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Load and Display Bot Config ---
|
||||
# This needs error handling and dynamic UI generation
|
||||
try:
|
||||
# 使用新的加载方法
|
||||
loaded_bot_config = load_bot_config(app_state)
|
||||
|
||||
if loaded_bot_config:
|
||||
# 使用新的表单生成器创建动态表单
|
||||
create_toml_form(page, loaded_bot_config, bot_config_content_area, app_state)
|
||||
|
||||
# Update the save button's action
|
||||
save_button = bot_settings_card.content.content.controls[-1].controls[1] # Find the save button
|
||||
save_button.on_click = lambda _: save_bot_config(
|
||||
page, app_state, loaded_bot_config
|
||||
) # Pass the loaded config dict
|
||||
|
||||
# Add reload logic here
|
||||
reload_button = bot_settings_card.content.content.controls[-1].controls[0] # Find the reload button
|
||||
|
||||
def reload_action(_):
|
||||
bot_config_content_area.controls.clear()
|
||||
try:
|
||||
reloaded_config = load_bot_config(app_state)
|
||||
if reloaded_config:
|
||||
# 重新创建表单
|
||||
create_toml_form(page, reloaded_config, bot_config_content_area, app_state)
|
||||
# Update save button reference
|
||||
save_button.on_click = lambda _: save_bot_config(page, app_state, reloaded_config)
|
||||
show_snackbar(page, "Bot 配置已重新加载。")
|
||||
# 确保UI完全更新
|
||||
bot_config_content_area.update()
|
||||
bot_settings_card.update()
|
||||
else:
|
||||
bot_config_content_area.controls.append(
|
||||
ft.Text("重新加载失败: 无法加载配置文件", color=ft.colors.ERROR)
|
||||
)
|
||||
bot_config_content_area.update()
|
||||
except Exception as reload_e:
|
||||
bot_config_content_area.controls.append(ft.Text(f"重新加载失败: {reload_e}", color=ft.colors.ERROR))
|
||||
bot_config_content_area.update()
|
||||
page.update()
|
||||
|
||||
reload_button.on_click = reload_action
|
||||
else:
|
||||
bot_config_content_area.controls.append(
|
||||
ft.Text(f"错误: 无法加载配置文件 {config_path}", color=ft.colors.ERROR)
|
||||
)
|
||||
except Exception as e:
|
||||
bot_config_content_area.controls.append(ft.Text(f"加载配置文件出错: {e}", color=ft.colors.ERROR))
|
||||
|
||||
return ft.View(
|
||||
"/settings",
|
||||
[
|
||||
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
|
||||
gui_settings_card,
|
||||
bot_settings_card, # Add the bot settings card
|
||||
# Add more settings sections/cards as needed
|
||||
],
|
||||
scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling for the whole view
|
||||
padding=10,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import flet as ft
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import psutil
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Import components and state
|
||||
from .flet_interest_monitor import InterestMonitorDisplay
|
||||
@@ -10,8 +11,67 @@ if TYPE_CHECKING:
|
||||
from .state import AppState
|
||||
|
||||
|
||||
# --- 添加资源路径处理函数 ---
|
||||
def get_asset_path(relative_path: str) -> str:
|
||||
"""
|
||||
获取资源文件的正确路径,在打包环境和源码环境下都能正常工作。
|
||||
|
||||
Args:
|
||||
relative_path: 相对于项目根目录的资源路径,例如 "src/MaiGoi/assets/image.png"
|
||||
|
||||
Returns:
|
||||
str: 资源文件的绝对路径
|
||||
"""
|
||||
# 检查是否在打包环境中运行
|
||||
if getattr(sys, "frozen", False):
|
||||
# 打包环境
|
||||
# 获取应用程序所在目录
|
||||
base_dir = os.path.dirname(sys.executable)
|
||||
|
||||
# 尝试多种可能的路径
|
||||
possible_paths = [
|
||||
# 1. 直接在根目录下
|
||||
os.path.join(base_dir, os.path.basename(relative_path)),
|
||||
# 2. 保持原始相对路径结构
|
||||
# os.path.join(base_dir, relative_path),
|
||||
# 3. 在 _internal 目录下保持原始路径结构
|
||||
os.path.join(base_dir, "_internal", relative_path),
|
||||
# 4. 从路径中去掉 "src/" 部分
|
||||
# os.path.join(base_dir, relative_path.replace("src/", "", 1)),
|
||||
# 5. 只使用最后的文件名
|
||||
# os.path.join(base_dir, os.path.basename(relative_path)),
|
||||
]
|
||||
|
||||
# 尝试所有可能的路径
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
print(f"[AssetPath] 打包环境: 找到资源 '{relative_path}' 位置: {path}")
|
||||
return path
|
||||
|
||||
# 如果找不到任何匹配的路径,记录错误并返回原始路径
|
||||
print(f"[AssetPath] 警告: 在打包环境中找不到资源 '{relative_path}'")
|
||||
return os.path.join(base_dir, relative_path) # 返回可能的路径,以便更容易识别错误
|
||||
else:
|
||||
# 源码环境,直接使用相对路径
|
||||
# 假设 cwd 是项目根目录
|
||||
root_dir = os.getcwd()
|
||||
path = os.path.join(root_dir, relative_path)
|
||||
|
||||
# 验证路径是否存在
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else:
|
||||
print(f"[AssetPath] 警告: 在源码环境中找不到资源 '{relative_path}'")
|
||||
return relative_path # 返回原始路径,方便调试
|
||||
|
||||
|
||||
def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
"""Creates the main view ('/') of the application."""
|
||||
# --- Set Page Padding to Zero --- #
|
||||
page.padding = 0
|
||||
# page.update() # Update the page to apply the padding change - 移除这行,避免闪烁
|
||||
# ------------------------------ #
|
||||
|
||||
# Get the main button from state (should be created in launcher.py main)
|
||||
start_button = app_state.start_bot_button
|
||||
if not start_button:
|
||||
@@ -32,45 +92,156 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
card_radius = ft.border_radius.all(4) # Slightly softer edges for glass
|
||||
# card_bgcolor = ft.colors.with_opacity(0.05, ft.colors.BLUE_GREY_50) # Subtle background
|
||||
# Use a semi-transparent primary color for the frosted glass effect
|
||||
card_bgcolor = ft.colors.with_opacity(0.65, ft.colors.PRIMARY_CONTAINER) # Example: using theme container color
|
||||
_card_bgcolor = ft.colors.with_opacity(0.65, ft.colors.PRIMARY_CONTAINER) # Example: using theme container color
|
||||
|
||||
# --- Card Creation Function --- #
|
||||
def create_action_card(icon: str, text: str, on_click_handler, tooltip: str = None):
|
||||
def create_action_card(
|
||||
page: ft.Page,
|
||||
icon: str,
|
||||
subtitle: str,
|
||||
text: str,
|
||||
on_click_handler,
|
||||
tooltip: str = None,
|
||||
width: int = 450,
|
||||
height: int = 150,
|
||||
):
|
||||
# Removed icon parameter usage
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
subtitle_text = subtitle
|
||||
# darker_bgcolor ='#ffffff' # Default Light mode background
|
||||
|
||||
# --- Determine colors based on theme ---
|
||||
# is_dark = page.theme_mode == ft.ThemeMode.DARK
|
||||
# card_bgcolor_actual = ft.colors.BLACK if is_dark else '#ffffff' # Use BLACK for dark, white for light
|
||||
# main_text_color = ft.colors.GREY_200 if is_dark else ft.colors.BLACK # Light grey for dark, black for light
|
||||
# subtitle_color = ft.colors.GREY_500 if is_dark else ft.colors.with_opacity(0.7, ft.colors.GREY_500) # Darker grey for dark, lighter grey for light
|
||||
|
||||
# --- Use Theme Colors Instead ---
|
||||
# Let Flet handle the color adaptation based on theme
|
||||
# card_bgcolor_theme = ft.colors.SURFACE_VARIANT # Or PRIMARY_CONTAINER, SURFACE etc.
|
||||
# main_text_color_theme = ft.colors.ON_SURFACE_VARIANT
|
||||
# subtitle_color_theme = ft.colors.with_opacity(0.8, ft.colors.ON_SURFACE_VARIANT) # Slightly transparent
|
||||
card_bgcolor_theme = ft.colors.SURFACE # Use SURFACE for a generally whiter/lighter background
|
||||
main_text_color_theme = ft.colors.ON_SURFACE # Corresponding text color
|
||||
subtitle_color_theme = ft.colors.with_opacity(0.7, ft.colors.ON_SURFACE) # Slightly more transparent ON_SURFACE
|
||||
|
||||
# --- 使用辅助函数获取Emoji图片路径 --- #
|
||||
emoji_image_path = get_asset_path("src/MaiGoi/assets/button_shape.png") # 使用辅助函数获取正确路径
|
||||
|
||||
# --- Create Text Content --- #
|
||||
text_content_column = ft.Column(
|
||||
[
|
||||
# ft.Icon(name=icon, color=ft.colors.PRIMARY, size=20), # Icon Removed
|
||||
ft.Text(
|
||||
# --- Main Title Text ---
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
text,
|
||||
weight=ft.FontWeight.BOLD, # Bolder text
|
||||
size=20, # Even larger font size
|
||||
expand=True, # Allow text to take available space
|
||||
text_align=ft.TextAlign.CENTER, # Center text within the row
|
||||
weight=ft.FontWeight.W_800,
|
||||
size=50,
|
||||
text_align=ft.TextAlign.LEFT,
|
||||
font_family="SimSun",
|
||||
# color=ft.colors.BLACK,
|
||||
color=main_text_color_theme, # Use theme color
|
||||
),
|
||||
margin=ft.margin.only(top=-5),
|
||||
),
|
||||
# --- Subtitle Text (Wrapped in Container for Margin) ---
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
subtitle_text,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
size=20,
|
||||
# color=ft.colors.with_opacity(0.7, ft.colors.GREY_500),
|
||||
color=subtitle_color_theme, # Use theme color
|
||||
text_align=ft.TextAlign.LEFT,
|
||||
font_family="SimHei",
|
||||
),
|
||||
margin=ft.margin.only(top=-20, left=10),
|
||||
),
|
||||
],
|
||||
# alignment=ft.MainAxisAlignment.START, # Row alignment doesn't matter much with only text
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
# spacing=15, # Spacing removed as icon is gone
|
||||
spacing=0,
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.START,
|
||||
)
|
||||
|
||||
# --- Create Emoji Image Layer --- #
|
||||
emoji_image_layer = ft.Container(
|
||||
content=ft.Image(
|
||||
src=emoji_image_path,
|
||||
fit=ft.ImageFit.COVER, # <-- Change fit to COVER for zoom/fill effect
|
||||
),
|
||||
width=300, # *** Explicitly set a fixed width for the card ***
|
||||
# min_height=80, # Increase minimum height (Container doesn't have min_height)
|
||||
alignment=ft.alignment.center, # Center the image within the container
|
||||
# Position the container itself to overlap the right side
|
||||
right=-100, # <-- Allow container to extend beyond the right edge slightly
|
||||
top=10, # <-- Allow container to extend beyond the top edge slightly
|
||||
# bottom=5, # Remove bottom constraint
|
||||
width=300, # <-- Increase width of the image container area
|
||||
height=300, # <-- Give it a height too, slightly larger than card text area
|
||||
opacity=0.3, # <-- Set back to semi-transparent
|
||||
# expand=True # Optionally expand if needed
|
||||
rotate=ft.transform.Rotate(angle=0.2),
|
||||
# transform=ft.transform.Scale(scale_x=-1), # <-- Remove transform from container
|
||||
)
|
||||
|
||||
# --- Hover effect shadow --- #
|
||||
hover_shadow = ft.BoxShadow(
|
||||
spread_radius=2,
|
||||
blur_radius=15, # Slightly more blur on hover
|
||||
color=ft.colors.with_opacity(0.3, ft.colors.BLACK87), # Slightly darker shadow
|
||||
offset=ft.Offset(2, 4),
|
||||
)
|
||||
|
||||
# --- on_hover handler --- #
|
||||
def handle_hover(e):
|
||||
if e.data == "true": # Mouse enters
|
||||
e.control.scale = ft.transform.Scale(1.03)
|
||||
e.control.shadow = hover_shadow
|
||||
else: # Mouse exits
|
||||
e.control.scale = ft.transform.Scale(1.0)
|
||||
e.control.shadow = card_shadow # Restore original shadow
|
||||
e.control.update()
|
||||
|
||||
return ft.Container(
|
||||
# Use Stack to layer text and image
|
||||
content=ft.Stack(
|
||||
[
|
||||
# Layer 1: Text Content (aligned left implicitly by parent Row settings)
|
||||
# Need to wrap the column in a Row again if we removed the original one,
|
||||
# but let's try putting the column directly first if Stack handles alignment
|
||||
# We need padding inside the stack for the text
|
||||
ft.Container(
|
||||
content=text_content_column,
|
||||
padding=ft.padding.only(top=8, left=15, bottom=15, right=20), # Apply padding here
|
||||
),
|
||||
# Layer 2: Emoji Image
|
||||
emoji_image_layer,
|
||||
]
|
||||
),
|
||||
height=height,
|
||||
width=width,
|
||||
border_radius=card_radius,
|
||||
# border=card_border, # Border removed
|
||||
bgcolor=card_bgcolor,
|
||||
padding=ft.padding.symmetric(vertical=25, horizontal=20), # Further increase vertical padding for height
|
||||
margin=ft.margin.only(bottom=20), # Increased bottom margin for more spacing
|
||||
# bgcolor=darker_bgcolor,
|
||||
bgcolor=card_bgcolor_theme, # Use theme color
|
||||
# Padding is now applied to the inner container for text
|
||||
padding=0,
|
||||
margin=ft.margin.only(bottom=20), # Margin applied outside the hover effect
|
||||
shadow=card_shadow,
|
||||
on_click=on_click_handler,
|
||||
tooltip=tooltip,
|
||||
ink=True, # Add ripple effect on click
|
||||
ink=True,
|
||||
# rotate=ft.transform.Rotate(angle=0.1), # Remove rotate as it might conflict
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, # Clip overflowing image within card bounds
|
||||
# rotate=ft.transform.Rotate(angle=0.1), # Apply rotation outside hover if needed
|
||||
scale=ft.transform.Scale(1.0), # Initial scale
|
||||
animate_scale=ft.animation.Animation(200, "easeOutCubic"), # Animate scale changes
|
||||
on_hover=handle_hover, # Attach hover handler
|
||||
)
|
||||
|
||||
# --- Main Button Action --- #
|
||||
# Need process_manager for the main button action
|
||||
start_bot_card = create_action_card(
|
||||
page=page, # Pass page object
|
||||
icon=ft.icons.SMART_TOY_OUTLINED,
|
||||
text="启动麦麦Core",
|
||||
text="主控室",
|
||||
subtitle="在此启动 Bot",
|
||||
on_click_handler=lambda _: page.go("/console"),
|
||||
tooltip="打开 Bot 控制台视图 (在此启动 Bot)",
|
||||
)
|
||||
@@ -93,61 +264,98 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
]
|
||||
|
||||
# --- Create "More..." Card Separately for Stack --- #
|
||||
# more_options_card = create_action_card(
|
||||
# page=page,
|
||||
# icon=ft.icons.MORE_HORIZ_OUTLINED,
|
||||
# text="更多...",
|
||||
# subtitle="其他工具",
|
||||
# on_click_handler=None, # 这里不设置点击动作,因为我们会覆盖内容
|
||||
# tooltip="选择要运行的脚本",
|
||||
# width=300,
|
||||
# height=100,
|
||||
# )
|
||||
|
||||
# 创建一个包含 more_options_card 和 PopupMenuButton 的 Stack
|
||||
more_options_card_stack = ft.Container(
|
||||
content=ft.Row(
|
||||
content=ft.Stack(
|
||||
[
|
||||
ft.Text(
|
||||
"更多...", # Renamed text
|
||||
weight=ft.FontWeight.BOLD,
|
||||
size=14, # Smaller size for less emphasis
|
||||
# expand=True,
|
||||
text_align=ft.TextAlign.LEFT,
|
||||
# more_options_card, # 作为背景卡片
|
||||
# 将 PopupMenuButton 放在卡片上层
|
||||
ft.Container(
|
||||
content=ft.PopupMenuButton(
|
||||
items=menu_items,
|
||||
icon=ft.icons.MORE_VERT,
|
||||
icon_size=50,
|
||||
icon_color=ft.colors.ORANGE,
|
||||
tooltip="选择要运行的脚本",
|
||||
),
|
||||
ft.PopupMenuButton(items=menu_items, icon=ft.icons.MORE_VERT, tooltip="选择要运行的脚本"),
|
||||
],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=5, # Reduced spacing
|
||||
alignment=ft.MainAxisAlignment.END, # Align content to the end (right) of the row
|
||||
right=50, # 右侧距离
|
||||
top=20, # 顶部距离
|
||||
),
|
||||
# width=150, # Reduced width
|
||||
border_radius=card_radius,
|
||||
bgcolor=card_bgcolor,
|
||||
padding=ft.padding.symmetric(vertical=10, horizontal=15), # Reduced padding
|
||||
# margin=ft.margin.only(bottom=20), # Margin handled by Stack positioning
|
||||
shadow=card_shadow,
|
||||
]
|
||||
),
|
||||
height=150, # 与普通卡片相同高度
|
||||
width=450, # 与普通卡片相同宽度
|
||||
# 不需要设置 bgcolor 和 border_radius,因为 more_options_card 已包含这些样式
|
||||
rotate=ft.transform.Rotate(angle=0.12), # 与其他卡片使用相同的旋转角度
|
||||
)
|
||||
|
||||
# --- Main Column of Cards --- #
|
||||
main_cards_column = ft.Column(
|
||||
controls=[
|
||||
ft.Container(height=15), # Top spacing
|
||||
# start_button, # Removed direct button
|
||||
start_bot_card, # Use the card
|
||||
# Wrap start_bot_card
|
||||
ft.Container(
|
||||
content=start_bot_card,
|
||||
margin=ft.margin.only(top=20, right=10),
|
||||
rotate=ft.transform.Rotate(angle=0.12),
|
||||
),
|
||||
# --- Move Adapters Card Up --- #
|
||||
create_action_card(
|
||||
# Wrap Adapters card
|
||||
ft.Container(
|
||||
content=create_action_card(
|
||||
page=page, # Pass page object
|
||||
icon=ft.icons.EXTENSION_OUTLINED, # Example icon
|
||||
text="启动适配器...",
|
||||
text="适配器",
|
||||
subtitle="管理适配器脚本",
|
||||
on_click_handler=lambda _: page.go("/adapters"),
|
||||
tooltip="管理和运行适配器脚本",
|
||||
),
|
||||
margin=ft.margin.only(top=20, right=45),
|
||||
rotate=ft.transform.Rotate(angle=0.12),
|
||||
),
|
||||
# Re-add the LPMM script card
|
||||
create_action_card(
|
||||
# Wrap LPMM card
|
||||
ft.Container(
|
||||
content=create_action_card(
|
||||
page=page, # Pass page object
|
||||
icon=ft.icons.MODEL_TRAINING_OUTLINED, # Icon is not used visually but kept for consistency maybe
|
||||
text="麦麦学习",
|
||||
text="学习",
|
||||
subtitle="使用LPMM知识库",
|
||||
on_click_handler=lambda _: run_script("start_lpmm.bat", page, app_state),
|
||||
tooltip="运行学习脚本 (start_lpmm.bat)",
|
||||
),
|
||||
margin=ft.margin.only(top=20, right=15),
|
||||
rotate=ft.transform.Rotate(angle=0.12),
|
||||
),
|
||||
# more_options_card, # Add the new card with the popup menu (Moved to Stack)
|
||||
# --- Add Adapters and Settings Cards --- #
|
||||
create_action_card(
|
||||
# Wrap Settings card
|
||||
ft.Container(
|
||||
content=create_action_card(
|
||||
page=page, # Pass page object
|
||||
icon=ft.icons.SETTINGS_OUTLINED, # Example icon
|
||||
text="设置",
|
||||
subtitle="配置所有选项",
|
||||
on_click_handler=lambda _: page.go("/settings"),
|
||||
tooltip="配置启动器选项",
|
||||
),
|
||||
margin=ft.margin.only(top=20, right=60),
|
||||
rotate=ft.transform.Rotate(angle=0.12),
|
||||
),
|
||||
],
|
||||
# alignment=ft.MainAxisAlignment.START, # Default vertical alignment is START
|
||||
horizontal_alignment=ft.CrossAxisAlignment.START, # Align cards to the START (left)
|
||||
horizontal_alignment=ft.CrossAxisAlignment.END, # Align cards to the END (right)
|
||||
spacing=0, # Let card margin handle spacing
|
||||
# expand=True, # Remove expand from the inner column if using Stack
|
||||
)
|
||||
@@ -155,71 +363,101 @@ def create_main_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
return ft.View(
|
||||
"/", # Main view route
|
||||
[
|
||||
ft.AppBar(
|
||||
# title=ft.Text("MaiBot 工具箱", size=18, weight=ft.FontWeight.W_600), # Larger, bolder title
|
||||
# Use leading for custom title layout with a line
|
||||
leading=ft.Row(
|
||||
[
|
||||
ft.Container(width=4, height=28, bgcolor=ft.colors.PRIMARY, border_radius=2), # Vertical line
|
||||
ft.Container(width=5), # Use a Container for simple horizontal spacing
|
||||
ft.Text("MaiBot 工具箱", size=22, weight=ft.FontWeight.BOLD), # Larger title
|
||||
],
|
||||
spacing=5, # Spacing within the row
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
leading_width=300, # Adjust width to fit the custom leading widget
|
||||
center_title=False, # Left-align title
|
||||
),
|
||||
# --- Use Stack for Layout --- #
|
||||
ft.Stack(
|
||||
[
|
||||
# Main column of cards (aligned top-left implicitly)
|
||||
main_cards_column,
|
||||
# --- Giant Orange Stripe (Background) --- #
|
||||
ft.Container(
|
||||
bgcolor=ft.colors.with_opacity(1, ft.colors.ORANGE_ACCENT_200), # Orange with opacity
|
||||
width=3000, # Make it very wide
|
||||
height=1000, # Give it substantial height
|
||||
rotate=ft.transform.Rotate(0.12), # Apply rotation (adjust angle as needed)
|
||||
# alignment=ft.alignment.center, # Center it in the stack
|
||||
# Position it manually to better control placement with rotation
|
||||
left=-200,
|
||||
top=-500,
|
||||
opacity=1, # Overall opacity for the stripe
|
||||
),
|
||||
ft.Container(
|
||||
content=ft.Image(
|
||||
src=get_asset_path("src/MaiGoi/assets/button_shape.png"), # 使用辅助函数获取正确路径
|
||||
fit=ft.ImageFit.CONTAIN,
|
||||
),
|
||||
width=900,
|
||||
height=1800,
|
||||
left=35, # 距离左侧
|
||||
top=-420, # 距离顶部
|
||||
border_radius=ft.border_radius.all(10),
|
||||
rotate=ft.transform.Rotate(-1.2),
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, # Helps with rounded corners
|
||||
),
|
||||
ft.Container(
|
||||
bgcolor=ft.colors.with_opacity(1, ft.colors.ORANGE_ACCENT_200), # Orange with opacity
|
||||
width=1000, # Make it very wide
|
||||
height=1000, # Give it substantial height
|
||||
rotate=ft.transform.Rotate(0.12), # Apply rotation (adjust angle as needed)
|
||||
# alignment=ft.alignment.center, # Center it in the stack
|
||||
# Position it manually to better control placement with rotation
|
||||
left=280,
|
||||
top=-561.6,
|
||||
opacity=1, # Overall opacity for the stripe
|
||||
),
|
||||
# --- End Giant Orange Stripe ---
|
||||
ft.Container(
|
||||
bgcolor=ft.colors.with_opacity(1, ft.colors.PURPLE_200), # Orange with opacity
|
||||
width=800, # Make it very wide
|
||||
height=3000, # Give it substantial height
|
||||
rotate=ft.transform.Rotate(0.6), # Apply rotation (adjust angle as needed)
|
||||
# alignment=ft.alignment.center, # Center it in the stack
|
||||
# Position it manually to better control placement with rotation
|
||||
left=-500,
|
||||
top=-1600,
|
||||
opacity=1, # Overall opacity for the stripe
|
||||
),
|
||||
ft.Container(
|
||||
content=main_cards_column,
|
||||
top=20, # 距离顶部
|
||||
right=20, # 距离右侧
|
||||
),
|
||||
# --- End positioned Container ---
|
||||
# "More..." card aligned bottom-right
|
||||
ft.Container(
|
||||
content=more_options_card_stack,
|
||||
# Use Stack positioning properties instead of alignment
|
||||
right=10, # Distance from right edge
|
||||
bottom=10, # Distance from bottom edge
|
||||
# 重新定位"更多..."按钮
|
||||
right=10, # 距离右侧
|
||||
bottom=15, # 距离底部
|
||||
),
|
||||
# --- Add Large Text to Bottom Left ---
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"MAI",
|
||||
size=50,
|
||||
font_family="Microsoft YaHei",
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=ft.colors.with_opacity(1, ft.colors.WHITE10),
|
||||
),
|
||||
left=32,
|
||||
top=30,
|
||||
rotate=ft.transform.Rotate(-0.98),
|
||||
),
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"工具箱",
|
||||
size=80,
|
||||
font_family="Microsoft YaHei", # 使用相同的锐利字体
|
||||
weight=ft.FontWeight.W_700, # 加粗
|
||||
color=ft.colors.with_opacity(1, ft.colors.WHITE10),
|
||||
),
|
||||
left=-10,
|
||||
top=78,
|
||||
rotate=ft.transform.Rotate(-0.98),
|
||||
),
|
||||
# --- End Add Large Text ---
|
||||
],
|
||||
expand=True, # Make Stack fill the available space
|
||||
),
|
||||
# ft.Column(
|
||||
# [
|
||||
# ft.Container(height=15), # Top spacing
|
||||
# # start_button, # Removed direct button
|
||||
# start_bot_card, # Use the card
|
||||
# # Re-add the LPMM script card
|
||||
# create_action_card(
|
||||
# icon=ft.icons.MODEL_TRAINING_OUTLINED, # Icon is not used visually but kept for consistency maybe
|
||||
# text="麦麦学习",
|
||||
# on_click_handler=lambda _: run_script("start_lpmm.bat", page, app_state),
|
||||
# tooltip="运行学习脚本 (start_lpmm.bat)"
|
||||
# ),
|
||||
# # more_options_card, # Add the new card with the popup menu (Moved to Stack)
|
||||
# # --- Add Adapters and Settings Cards --- #
|
||||
# create_action_card(
|
||||
# icon=ft.icons.EXTENSION_OUTLINED, # Example icon
|
||||
# text="适配器...",
|
||||
# on_click_handler=lambda _: page.go("/adapters"),
|
||||
# tooltip="管理和运行适配器脚本"
|
||||
# ),
|
||||
# create_action_card(
|
||||
# icon=ft.icons.SETTINGS_OUTLINED, # Example icon
|
||||
# text="设置",
|
||||
# on_click_handler=lambda _: page.go("/settings"),
|
||||
# tooltip="配置启动器选项"
|
||||
# ),
|
||||
# ],
|
||||
# # alignment=ft.MainAxisAlignment.START, # Default vertical alignment is START
|
||||
# horizontal_alignment=ft.CrossAxisAlignment.START, # Align cards to the START (left)
|
||||
# spacing=0, # Let card margin handle spacing
|
||||
# expand=True,
|
||||
# )
|
||||
],
|
||||
padding=ft.padding.symmetric(horizontal=15), # Add horizontal padding to the view
|
||||
scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows
|
||||
# padding=ft.padding.symmetric(horizontal=20), # <-- 移除水平 padding
|
||||
# scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows
|
||||
)
|
||||
|
||||
|
||||
@@ -227,9 +465,11 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
"""Creates the console output view ('/console'), including the interest monitor."""
|
||||
# Get UI elements from state
|
||||
output_list_view = app_state.output_list_view
|
||||
# start_button = app_state.start_bot_button # Variable is assigned but never used
|
||||
from .process_manager import update_buttons_state # Dynamic import
|
||||
|
||||
# 默认开启自动滚动
|
||||
app_state.is_auto_scroll_enabled = True
|
||||
|
||||
# Create ListView if it doesn't exist (as a fallback, should be created by start_bot)
|
||||
if not output_list_view:
|
||||
output_list_view = ft.ListView(expand=True, spacing=2, auto_scroll=app_state.is_auto_scroll_enabled, padding=5)
|
||||
@@ -248,7 +488,35 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
|
||||
interest_monitor = app_state.interest_monitor_control
|
||||
|
||||
# --- Process Manager Functions (Import for button actions) ---
|
||||
# --- 为控制台输出和兴趣监控创建容器,以便动态调整大小 --- #
|
||||
output_container = ft.Container(
|
||||
content=output_list_view,
|
||||
expand=4, # 在左侧 Column 内部分配比例
|
||||
border=ft.border.only(bottom=ft.border.BorderSide(1, ft.colors.OUTLINE)),
|
||||
)
|
||||
|
||||
monitor_container = ft.Container(
|
||||
content=interest_monitor,
|
||||
expand=4, # 在左侧 Column 内部分配比例
|
||||
)
|
||||
|
||||
# --- 设置兴趣监控的切换回调函数 --- #
|
||||
def on_monitor_toggle(is_expanded):
|
||||
if is_expanded:
|
||||
# 监控器展开时,恢复原比例
|
||||
output_container.expand = 4
|
||||
monitor_container.expand = 4
|
||||
else:
|
||||
# 监控器隐藏时,让输出区占据更多空间
|
||||
output_container.expand = 9
|
||||
monitor_container.expand = 0
|
||||
|
||||
# 更新容器以应用新布局
|
||||
output_container.update()
|
||||
monitor_container.update()
|
||||
|
||||
# 为监控器设置回调函数
|
||||
interest_monitor.on_toggle = on_monitor_toggle
|
||||
|
||||
# --- Auto-scroll toggle button callback (remains separate) --- #
|
||||
def toggle_auto_scroll(e):
|
||||
@@ -256,6 +524,15 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
lv = app_state.output_list_view # Get potentially updated list view
|
||||
if lv:
|
||||
lv.auto_scroll = app_state.is_auto_scroll_enabled
|
||||
|
||||
# 当关闭自动滚动时,记录当前滚动位置
|
||||
if not app_state.is_auto_scroll_enabled:
|
||||
# 标记视图正在手动观看模式,以便在更新时保持位置
|
||||
app_state.manual_viewing = True
|
||||
else:
|
||||
# 开启自动滚动时,关闭手动观看模式
|
||||
app_state.manual_viewing = False
|
||||
|
||||
# Update button appearance (assuming button reference is available)
|
||||
# e.control is the Container now
|
||||
# We need to update the Text control stored in its data attribute
|
||||
@@ -376,20 +653,9 @@ def create_console_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
ft.Column(
|
||||
controls=[
|
||||
# 1. Console Output Area
|
||||
ft.Container(
|
||||
content=output_list_view, # From state
|
||||
expand=5, # 在左侧 Column 内部分配比例
|
||||
border=ft.border.only(bottom=ft.border.BorderSide(1, ft.colors.OUTLINE)),
|
||||
),
|
||||
output_container, # 使用容器替代直接引用
|
||||
# 2. Interest Monitor Area
|
||||
ft.Container(
|
||||
content=interest_monitor, # From state
|
||||
expand=4, # 在左侧 Column 内部分配比例
|
||||
# border=ft.border.all(1, ft.colors.OUTLINE), # 可以去掉这里的边框
|
||||
# border_radius=ft.border_radius.all(5),
|
||||
# padding=10, # 可以调整或去掉
|
||||
# margin=ft.margin.only(top=10),
|
||||
),
|
||||
monitor_container, # 使用容器替代直接引用
|
||||
],
|
||||
expand=True, # 让左侧 Column 占据 Row 的大部分空间
|
||||
),
|
||||
@@ -649,20 +915,14 @@ def create_adapters_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
|
||||
# --- Settings View --- #
|
||||
def create_settings_view(page: ft.Page, app_state: "AppState") -> ft.View:
|
||||
"""Creates the settings view (/settings)."""
|
||||
"""Placeholder for settings view."""
|
||||
# This function is now implemented in ui_settings_view.py
|
||||
# This placeholder can be removed if no longer referenced anywhere else.
|
||||
# For safety, let's keep it but make it clear it's deprecated/moved.
|
||||
print("Warning: Deprecated create_settings_view called in ui_views.py. Should use ui_settings_view.py version.")
|
||||
return ft.View(
|
||||
"/settings",
|
||||
[
|
||||
ft.AppBar(title=ft.Text("设置"), bgcolor=ft.colors.SURFACE_VARIANT),
|
||||
# Pass padding value positionally, use content keyword argument
|
||||
# ft.Padding(ft.padding.all(20), content=ft.Text("设置选项将在此处显示...")),
|
||||
# Use a Container with the padding property instead
|
||||
ft.Container(
|
||||
padding=ft.padding.all(20), # Set padding property on the Container
|
||||
content=ft.Text("设置选项将在此处显示..."), # Place the original content inside
|
||||
),
|
||||
# Add settings controls here later
|
||||
],
|
||||
"/settings_deprecated",
|
||||
[ft.AppBar(title=ft.Text("Settings (Deprecated)")), ft.Text("This view has moved to ui_settings_view.py")],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -916,6 +916,7 @@ INTEREST_CHAT_STYLE_CONFIG = (
|
||||
INTEREST_CHAT_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else INTEREST_CHAT_STYLE_CONFIG["advanced"]
|
||||
)
|
||||
|
||||
|
||||
def is_registered_module(record: dict) -> bool:
|
||||
"""检查是否为已注册的模块"""
|
||||
return record["extra"].get("module") in _handler_registry
|
||||
|
||||
@@ -78,6 +78,7 @@ class BackgroundTaskManager:
|
||||
self._into_focus_task: Optional[asyncio.Task] = None
|
||||
self._private_chat_activation_task: Optional[asyncio.Task] = None # 新增私聊激活任务引用
|
||||
self._tasks: List[Optional[asyncio.Task]] = [] # Keep track of all tasks
|
||||
self._detect_command_from_gui_task: Optional[asyncio.Task] = None # 新增GUI命令检测任务引用
|
||||
|
||||
async def start_tasks(self):
|
||||
"""启动所有后台任务
|
||||
@@ -135,6 +136,13 @@ class BackgroundTaskManager:
|
||||
f"私聊激活检查任务已启动 间隔:{PRIVATE_CHAT_ACTIVATION_CHECK_INTERVAL_SECONDS}s",
|
||||
"_private_chat_activation_task",
|
||||
),
|
||||
# 新增GUI命令检测任务配置
|
||||
# (
|
||||
# lambda: self._run_detect_command_from_gui_cycle(3),
|
||||
# "debug",
|
||||
# f"GUI命令检测任务已启动 间隔:{3}s",
|
||||
# "_detect_command_from_gui_task",
|
||||
# ),
|
||||
]
|
||||
|
||||
# 统一启动所有任务
|
||||
@@ -296,3 +304,11 @@ class BackgroundTaskManager:
|
||||
interval=interval,
|
||||
task_func=self.subheartflow_manager.sbhf_absent_private_into_focus,
|
||||
)
|
||||
|
||||
# # 有api之后删除
|
||||
# async def _run_detect_command_from_gui_cycle(self, interval: int):
|
||||
# await _run_periodic_loop(
|
||||
# task_name="Detect Command from GUI",
|
||||
# interval=interval,
|
||||
# task_func=self.subheartflow_manager.detect_command_from_gui,
|
||||
# )
|
||||
|
||||
@@ -101,7 +101,7 @@ class InterestLogger:
|
||||
try:
|
||||
current_timestamp = time.time()
|
||||
|
||||
main_mind = self.heartflow.current_mind
|
||||
# main_mind = self.heartflow.current_mind
|
||||
# 获取 Mai 状态名称
|
||||
mai_state_name = self.heartflow.current_state.get_current_state().name
|
||||
|
||||
@@ -109,7 +109,7 @@ class InterestLogger:
|
||||
|
||||
log_entry_base = {
|
||||
"timestamp": round(current_timestamp, 2),
|
||||
"main_mind": main_mind,
|
||||
# "main_mind": main_mind,
|
||||
"mai_state": mai_state_name,
|
||||
"subflow_count": len(all_subflow_states),
|
||||
"subflows": [],
|
||||
@@ -144,7 +144,7 @@ class InterestLogger:
|
||||
"sub_chat_state": state.get("chat_state", "未知"),
|
||||
"interest_level": interest_state.get("interest_level", 0.0),
|
||||
"start_hfc_probability": interest_state.get("start_hfc_probability", 0.0),
|
||||
"is_above_threshold": interest_state.get("is_above_threshold", False),
|
||||
# "is_above_threshold": interest_state.get("is_above_threshold", False),
|
||||
}
|
||||
subflow_details.append(subflow_entry)
|
||||
|
||||
|
||||
@@ -852,3 +852,52 @@ class SubHeartflowManager:
|
||||
# --- 结束新增 --- #
|
||||
|
||||
# --- 结束新增:处理来自 HeartFChatting 的状态转换请求 --- #
|
||||
|
||||
# 临时函数,用于GUI切换,有api后删除
|
||||
# async def detect_command_from_gui(self):
|
||||
# """检测来自GUI的命令"""
|
||||
# command_file = Path("temp_command/gui_command.json")
|
||||
# if not command_file.exists():
|
||||
# return
|
||||
|
||||
# try:
|
||||
# # 读取并解析命令文件
|
||||
# command_data = json.loads(command_file.read_text())
|
||||
# subflow_id = command_data.get("subflow_id")
|
||||
# target_state = command_data.get("target_state")
|
||||
|
||||
# if not subflow_id or not target_state:
|
||||
# logger.warning("GUI命令文件格式不正确,缺少必要字段")
|
||||
# return
|
||||
|
||||
# # 尝试转换为ChatState枚举
|
||||
# try:
|
||||
# target_state_enum = ChatState[target_state.upper()]
|
||||
# except KeyError:
|
||||
# logger.warning(f"无效的目标状态: {target_state}")
|
||||
# command_file.unlink()
|
||||
# return
|
||||
|
||||
# # 执行状态转换
|
||||
# await self.force_change_by_gui(subflow_id, target_state_enum)
|
||||
|
||||
# # 转换成功后删除文件
|
||||
# command_file.unlink()
|
||||
# logger.debug(f"已处理GUI命令并删除命令文件: {command_file}")
|
||||
|
||||
# except json.JSONDecodeError:
|
||||
# logger.warning("GUI命令文件不是有效的JSON格式")
|
||||
# except Exception as e:
|
||||
# logger.error(f"处理GUI命令时发生错误: {e}", exc_info=True)
|
||||
|
||||
# async def force_change_by_gui(self, subflow_id: Any, target_state: ChatState):
|
||||
# """强制改变指定子心流的状态"""
|
||||
# async with self._lock:
|
||||
# subflow = self.subheartflows.get(subflow_id)
|
||||
# if not subflow:
|
||||
# logger.warning(f"[强制状态转换] 尝试转换不存在的子心流 {subflow_id} 到 {target_state.value}")
|
||||
# return
|
||||
# await subflow.change_chat_state(target_state)
|
||||
# logger.info(f"[强制状态转换] 成功将 {subflow_id} 的状态转换为 {target_state.value}")
|
||||
|
||||
# --- 结束新增 --- #
|
||||
|
||||
Reference in New Issue
Block a user