diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 132e9e4..bf56b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - name: Run Mypy run: uv run mypy . + - name: Run Tests + run: uv run pytest tests/ + - name: Build wheel run: uv build --wheel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee54fa..59eb089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,9 @@ jobs: - name: Run Mypy run: uv run mypy . + - name: Run Tests + run: uv run pytest tests/ + - name: Build project run: uv build diff --git a/README.md b/README.md index 8388668..733dc65 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ - **Skills 架构**:全新设计的技能系统,将基础工具(Tools)与智能代理(Agents)分层管理,支持自动发现与注册。 - **Skills 热重载**:自动扫描 `skills/` 目录,检测到变更后即时重载工具与 Agent,无需重启服务。 - **配置热更新 + WebUI**:使用 `config.toml` 配置,支持热更新;提供 WebUI 在线编辑与校验。 -- **会话白名单(群/私聊)**:只需配置 `access.allowed_group_ids` / `access.allowed_private_ids` 两个列表,即可把机器人“锁”在指定群与指定私聊里;避免被拉进陌生群误触发、也避免工具/定时任务把消息误发到不该去的地方(默认留空不限制)。 +- **多模型池**:支持配置多个 AI 模型,可轮询、随机选择或用户指定;支持多模型并发比较,选择最佳结果继续对话。详见 [多模型功能文档](docs/multi-model.md)。 +- **会话白名单(群/私聊)**:只需配置 `access.allowed_group_ids` / `access.allowed_private_ids` 两个列表,即可把机器人"锁"在指定群与指定私聊里;避免被拉进陌生群误触发、也避免工具/定时任务把消息误发到不该去的地方(默认留空不限制)。 - **并发防重复执行(进行中摘要)**:对私聊与 `@机器人` 场景在首轮前预占位,并在后续请求注入 `【进行中的任务】` 上下文,减少"催促/追问"导致的重复任务执行;支持通过 `features.inflight_pre_register_enabled`(预注册占位,默认启用)和 `features.inflight_summary_enabled`(摘要生成,默认禁用)独立控制。 - **并行工具执行**:无论是主 AI 还是子 Agent,均支持 `asyncio` 并发工具调用,大幅提升多任务处理速度(如同时读取多个文件或搜索多个关键词)。 - **智能 Agent 矩阵**:内置多个专业 Agent,分工协作处理复杂任务。 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..47f5801 --- /dev/null +++ b/biome.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": ["src/Undefined/webui/static/js/**/*.js"] + }, + "linter": { + "rules": { + "correctness": { + "noUnusedVariables": "off" + } + } + } +} diff --git a/config.toml.example b/config.toml.example index 9c3e00e..46b5174 100644 --- a/config.toml.example +++ b/config.toml.example @@ -54,6 +54,7 @@ ws_url = "ws://127.0.0.1:3001" # en: Access token (optional). token = "" +[models] # zh: 对话模型配置(主模型,处理每一条消息)。 # en: Chat model config (the main model, processing each message). [models.chat] @@ -85,6 +86,19 @@ thinking_include_budget = true # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = false +# zh: 模型池配置(可选,支持多模型轮询/随机/用户指定)。 +# en: Model pool configuration (optional, supports round-robin/random/user-specified). +[models.chat.pool] +# zh: 是否启用模型池。 +# en: Enable model pool. +enabled = false +# zh: 分配策略:default(用户指定)/ round_robin(轮询)/ random(随机)。 +# en: Strategy: default (user-specified) / round_robin / random. +strategy = "default" +# zh: 模型池列表(每项需填 model_name、api_url、api_key,其余字段可选,缺省继承主模型)。 +# en: Model pool entries (model_name, api_url, api_key required; others optional, inherit from primary). +models = [] + # zh: 视觉模型配置(用于图片描述和 OCR)。 # en: Vision model config (image description and OCR). [models.vision] @@ -178,6 +192,19 @@ thinking_include_budget = true # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = false +# zh: Agent 模型池配置(可选,支持多模型轮询/随机/用户指定)。 +# en: Agent model pool configuration (optional, supports round-robin/random/user-specified). +[models.agent.pool] +# zh: 是否启用 Agent 模型池。 +# en: Enable agent model pool. +enabled = false +# zh: 分配策略:default(用户指定)/ round_robin(轮询)/ random(随机)。 +# en: Strategy: default (user-specified) / round_robin / random. +strategy = "default" +# zh: Agent 模型池列表(每项需填 model_name、api_url、api_key,其余字段可选,缺省继承主模型)。 +# en: Agent model pool entries (model_name, api_url, api_key required; others optional, inherit from primary). +models = [] + # zh: 进行中任务摘要模型配置(可选,用于并发真空期的"处理中摘要")。仅当 [queue] 下 inflight_summary_enabled = true 时生效。 # en: Inflight summary model config (optional, used to generate in-progress task summaries). Only effective when inflight_summary_enabled = true under [queue]. # zh: 当 api_url/api_key/model_name 任一为空时,自动回退到 [models.chat]。 @@ -281,6 +308,9 @@ inflight_pre_register_enabled = true # en: - true: enable, generate action summary asynchronously # en: - false: disable (default), no summary generation inflight_summary_enabled = false +# zh: 多模型池全局开关(关闭后所有多模型功能禁用,行为与原版一致)。无特殊需求不建议启用。 +# en: Global model pool switch. When disabled, all multi-model features are disabled. Not recommended unless you have specific needs. +pool_enabled = false # zh: 彩蛋功能(可选)。 # en: Easter egg features (optional) diff --git a/docs/multi-model.md b/docs/multi-model.md new file mode 100644 index 0000000..af79cc6 --- /dev/null +++ b/docs/multi-model.md @@ -0,0 +1,135 @@ +# 多模型池功能 + +## 功能概述 + +- **Chat 模型池(私聊)**:轮询/随机自动切换,或在私聊中通过「选X」指定模型 +- **Agent 模型池**:按策略(轮询/随机)自动分配,无需用户干预 + +> 仅私聊支持用户手动切换 Chat 模型;群聊始终使用主模型。 + +## 配置方式 + +### 方式一:WebUI + +启动 `uv run Undefined-webui`,登录后进入「配置修改」页: + +- **全局开关**:`features` → `pool_enabled` 设为 `true` +- **Chat 模型池**:`models` → `chat` → `pool`,设置 `enabled`、`strategy`,在 `models` 列表中添加/移除条目 +- **Agent 模型池**:`models` → `agent` → `pool`,同上 + +每次修改自动保存并热更新,无需重启。 + +### 方式二:直接编辑 config.toml + +## 配置 + +### 1. 全局开关 + +```toml +[features] +pool_enabled = true # 默认 false,需显式开启 +``` + +### 2. Chat 模型池 + +```toml +[models.chat.pool] +enabled = true +strategy = "round_robin" # "default" | "round_robin" | "random" + +[[models.chat.pool.models]] +model_name = "claude-sonnet-4-20250514" +api_url = "https://api.anthropic.com/v1" +api_key = "sk-ant-xxx" +# 其他字段(max_tokens, thinking_* 等)可选,缺省继承主模型 + +[[models.chat.pool.models]] +model_name = "deepseek-chat" +api_url = "https://api.deepseek.com/v1" +api_key = "sk-ds-xxx" +``` + +### 3. Agent 模型池 + +```toml +[models.agent.pool] +enabled = true +strategy = "round_robin" # "default" | "round_robin" | "random" + +[[models.agent.pool.models]] +model_name = "claude-sonnet-4-20250514" +api_url = "https://api.anthropic.com/v1" +api_key = "sk-ant-xxx" +``` + +### strategy 说明 + +| 值 | 行为 | +|----|------| +| `default` | 只使用主模型,忽略池中模型 | +| `round_robin` | 按顺序轮流使用池中模型 | +| `random` | 每次随机选择池中模型 | + +> `pool.models` 中只有 `model_name` 必填,其余字段缺省时继承主模型配置。 + +## 私聊使用方法 + +### 自动轮换 + +配置 `strategy = "round_robin"` 或 `"random"` 后,私聊请求会自动在池中模型间切换,无需任何操作。 + +### 手动指定模型(私聊) + +1. 私聊发送 `/compare <问题>` 或 `/pk <问题>`,bot 并发请求所有模型并编号返回: + +``` +你: /compare 写一首关于春天的诗 + +bot: +正在向 3 个模型发送问题,请稍候... + +问题: 写一首关于春天的诗 + +【1】gpt-4o +春风拂面暖如酥... + +【2】claude-sonnet-4-20250514 +春日融融暖意浓... + +【3】deepseek-chat +春回大地万象新... + +回复「选X」可切换到该模型并继续对话 +``` + +2. 5 分钟内回复 `选2`,后续私聊固定使用第 2 个模型继续对话。 + +3. 偏好持久化保存在 `data/model_preferences.json`,重启后保留。 + +## 开关层级 + +``` +features.pool_enabled ← 全局总开关(false 时完全不生效) + └─ models.chat.pool.enabled ← Chat 模型池开关 + └─ models.agent.pool.enabled ← Agent 模型池开关 +``` + +## 注意事项 + +- 不同模型使用独立队列,互不影响 +- 所有模型的 Token 使用均会被统计 +- 「选X」状态 5 分钟后过期 +- 群聊不受多模型池影响,始终使用主模型 + +## 代码结构 + +| 文件 | 职责 | +|------|------| +| `config/models.py` | `ModelPool`, `ModelPoolEntry` 数据类 | +| `config/loader.py` | 解析 pool 配置,字段缺省继承主模型 | +| `ai/model_selector.py` | 纯选择逻辑:策略、偏好存储、compare 状态 | +| `services/model_pool.py` | 私聊交互服务:`/compare`、「选X」、`select_chat_config` | +| `services/ai_coordinator.py` | 持有 `ModelPoolService`,私聊队列投递时选模型 | +| `handlers.py` | 私聊消息委托给 `model_pool.handle_private_message()` | +| `skills/agents/runner.py` | Agent 执行时调用 `model_selector.select_agent_config()` | +| `utils/queue_intervals.py` | 注册 pool 模型的队列间隔 | diff --git a/src/Undefined/ai/client.py b/src/Undefined/ai/client.py index 0aadda3..389bb40 100644 --- a/src/Undefined/ai/client.py +++ b/src/Undefined/ai/client.py @@ -13,6 +13,7 @@ import httpx from Undefined.ai.llm import ModelRequester +from Undefined.ai.model_selector import ModelSelector from Undefined.ai.multimodal import MultimodalAnalyzer from Undefined.ai.prompts import PromptBuilder from Undefined.ai.summaries import SummaryService @@ -150,6 +151,9 @@ def __init__( anthropic_skill_registry=self.anthropic_skill_registry, ) + # 初始化模型选择器 + self.model_selector = ModelSelector() + # 绑定上下文资源扫描路径(基于注册表 watch_paths) scan_paths = [ p @@ -246,6 +250,15 @@ async def init_mcp_async() -> None: self._mcp_init_task = asyncio.create_task(init_mcp_async()) + # 异步加载模型偏好 + async def load_preferences_async() -> None: + try: + await self.model_selector.load_preferences() + except Exception as exc: + logger.warning("[初始化] 加载模型偏好失败: %s", exc) + + self._preferences_load_task = asyncio.create_task(load_preferences_async()) + logger.info("[初始化] AIClient 初始化完成") async def close(self) -> None: @@ -393,6 +406,18 @@ def _get_runtime_config(self) -> Config: return get_config(strict=False) + def _find_chat_config_by_name(self, model_name: str) -> ChatModelConfig: + """根据模型名查找配置(主模型或池中模型)""" + if model_name == self.chat_config.model_name: + return self.chat_config + if self.chat_config.pool and self.chat_config.pool.enabled: + for entry in self.chat_config.pool.models: + if entry.model_name == model_name: + return self.model_selector._entry_to_chat_config( + entry, self.chat_config + ) + return self.chat_config + def _get_prefetch_tool_names(self) -> list[str]: runtime_config = self._get_runtime_config() return list(runtime_config.prefetch_tools) @@ -867,11 +892,19 @@ async def ask( if inflight_location is None: inflight_location = self._build_inflight_location(tool_context) + # 动态选择模型(等待偏好加载就绪,避免竞态) + await self.model_selector.wait_ready() + selected_model_name = pre_context.get("selected_model_name") + if selected_model_name: + effective_chat_config = self._find_chat_config_by_name(selected_model_name) + else: + effective_chat_config = self.chat_config + max_iterations = 1000 iteration = 0 conversation_ended = False any_tool_executed = False - cot_compat = getattr(self.chat_config, "thinking_tool_call_compat", False) + cot_compat = getattr(effective_chat_config, "thinking_tool_call_compat", False) cot_compat_logged = False cot_missing_logged = False @@ -888,7 +921,7 @@ async def _clear_inflight_on_exit() -> None: try: result = await self.request_model( - model_config=self.chat_config, + model_config=effective_chat_config, messages=messages, max_tokens=8192, call_type="chat", @@ -931,7 +964,7 @@ async def _clear_inflight_on_exit() -> None: cot_compat and log_thinking and tools - and getattr(self.chat_config, "thinking_enabled", False) + and getattr(effective_chat_config, "thinking_enabled", False) and not reasoning_content and tool_calls and not cot_missing_logged diff --git a/src/Undefined/ai/model_selector.py b/src/Undefined/ai/model_selector.py new file mode 100644 index 0000000..6251590 --- /dev/null +++ b/src/Undefined/ai/model_selector.py @@ -0,0 +1,267 @@ +"""模型池选择器""" + +from __future__ import annotations + +import asyncio +import logging +import random +import re +import threading +import time +from pathlib import Path + +from Undefined.config.models import ( + AgentModelConfig, + ChatModelConfig, + ModelPool, + ModelPoolEntry, +) +from Undefined.utils.io import read_json, write_json + +logger = logging.getLogger(__name__) + +_DEFAULT_PREFERENCES_PATH = Path("data/model_preferences.json") +_DEFAULT_COMPARE_EXPIRE_SECONDS = 300 + + +class ModelSelector: + """根据策略和用户偏好从模型池中选择模型""" + + def __init__( + self, + preferences_path: Path | None = None, + compare_expire_seconds: float = _DEFAULT_COMPARE_EXPIRE_SECONDS, + ) -> None: + self._preferences_path = preferences_path or _DEFAULT_PREFERENCES_PATH + self._compare_expire_seconds = compare_expire_seconds + self._lock = asyncio.Lock() + self._rr_lock = threading.Lock() + self._rr_counters: dict[str, int] = {} + self._preferences: dict[tuple[int, int], dict[str, str]] = {} + # pending_compares 只存模型名列表,不存配置对象 + self._pending_compares: dict[tuple[int, int], tuple[list[str], float]] = {} + self._loaded = asyncio.Event() + + def select_chat_config( + self, + primary: ChatModelConfig, + group_id: int = 0, + user_id: int = 0, + global_enabled: bool = True, + ) -> ChatModelConfig: + """选择 chat 模型配置""" + if ( + not global_enabled + or primary.pool is None + or not primary.pool.enabled + or not primary.pool.models + ): + return primary + + pref_key = (group_id, user_id) + pref = self._preferences.get(pref_key, {}).get("chat") + if pref: + entry = self._find_entry(primary.pool, pref) + if entry: + return self._entry_to_chat_config(entry, primary) + if pref_key in self._preferences and "chat" in self._preferences[pref_key]: + del self._preferences[pref_key]["chat"] + if not self._preferences[pref_key]: + del self._preferences[pref_key] + + entry = self._select_by_strategy(primary.pool, "chat") + if entry is None: + return primary + return self._entry_to_chat_config(entry, primary) + + def select_agent_config( + self, + primary: AgentModelConfig, + group_id: int = 0, + user_id: int = 0, + global_enabled: bool = True, + ) -> AgentModelConfig: + """选择 agent 模型配置""" + if ( + not global_enabled + or primary.pool is None + or not primary.pool.enabled + or not primary.pool.models + ): + return primary + + pref_key = (group_id, user_id) + pref = self._preferences.get(pref_key, {}).get("agent") + if pref: + entry = self._find_entry(primary.pool, pref) + if entry: + return self._entry_to_agent_config(entry, primary) + if pref_key in self._preferences and "agent" in self._preferences[pref_key]: + del self._preferences[pref_key]["agent"] + if not self._preferences[pref_key]: + del self._preferences[pref_key] + + entry = self._select_by_strategy(primary.pool, "agent") + if entry is None: + return primary + return self._entry_to_agent_config(entry, primary) + + def get_all_chat_models( + self, primary: ChatModelConfig + ) -> list[tuple[str, ChatModelConfig]]: + """获取所有可用 chat 模型(主模型 + 池中模型)""" + result: list[tuple[str, ChatModelConfig]] = [(primary.model_name, primary)] + if primary.pool and primary.pool.enabled: + for entry in primary.pool.models: + if entry.model_name != primary.model_name: + result.append( + (entry.model_name, self._entry_to_chat_config(entry, primary)) + ) + return result + + def set_preference( + self, group_id: int, user_id: int, pool_key: str, model_name: str + ) -> None: + """设置用户模型偏好""" + key = (group_id, user_id) + if key not in self._preferences: + self._preferences[key] = {} + self._preferences[key][pool_key] = model_name + + def clear_preference(self, group_id: int, user_id: int, pool_key: str) -> None: + """清除用户模型偏好""" + key = (group_id, user_id) + if key in self._preferences: + self._preferences[key].pop(pool_key, None) + + def get_preference(self, group_id: int, user_id: int, pool_key: str) -> str | None: + """获取用户模型偏好""" + key = (group_id, user_id) + return self._preferences.get(key, {}).get(pool_key) + + def set_pending_compare( + self, group_id: int, user_id: int, model_names: list[str] + ) -> None: + """存储比较待选状态(只存模型名,不存配置对象)""" + self._pending_compares[(group_id, user_id)] = (model_names, time.time()) + + def try_resolve_compare(self, group_id: int, user_id: int, text: str) -> str | None: + """尝试解析"选X"消息,返回选中的模型名""" + key = (group_id, user_id) + pending = self._pending_compares.get(key) + if pending is None: + return None + model_names, ts = pending + if time.time() - ts > self._compare_expire_seconds: + self._pending_compares.pop(key, None) + return None + + match = re.match(r"选\s*(\d+)", text.strip()) + if not match: + return None + idx = int(match.group(1)) + if idx < 1 or idx > len(model_names): + return None + + self._pending_compares.pop(key, None) + return model_names[idx - 1] + + async def load_preferences(self) -> None: + """启动时从磁盘加载偏好""" + async with self._lock: + try: + data = await read_json(self._preferences_path) + if not isinstance(data, dict): + return + for key_str, prefs in data.items(): + parts = key_str.split("_", 1) + if len(parts) != 2: + continue + try: + gid, uid = int(parts[0]), int(parts[1]) + except ValueError: + continue + if isinstance(prefs, dict): + self._preferences[(gid, uid)] = prefs + logger.info("[模型选择器] 已加载 %d 个用户偏好", len(self._preferences)) + except FileNotFoundError: + logger.debug("[模型选择器] 偏好文件不存在,跳过加载") + except Exception as exc: + logger.warning("[模型选择器] 加载偏好失败: %s", exc) + finally: + self._loaded.set() + + async def wait_ready(self) -> None: + """等待偏好加载完成,供外部调用方确保初始化就绪""" + await self._loaded.wait() + + async def save_preferences(self) -> None: + """持久化偏好到磁盘""" + async with self._lock: + try: + data: dict[str, dict[str, str]] = {} + for (gid, uid), prefs in self._preferences.items(): + if prefs: + data[f"{gid}_{uid}"] = prefs + await write_json(self._preferences_path, data) + except Exception as exc: + logger.warning("[模型选择器] 保存偏好失败: %s", exc) + + def _select_by_strategy( + self, pool: ModelPool, pool_key: str + ) -> ModelPoolEntry | None: + """按策略选择模型""" + if pool.strategy == "default" or not pool.models: + return None + if pool.strategy == "random": + return random.choice(pool.models) + if pool.strategy == "round_robin": + with self._rr_lock: + idx = self._rr_counters.get(pool_key, 0) + entry = pool.models[idx % len(pool.models)] + self._rr_counters[pool_key] = idx + 1 + return entry + return None + + def _find_entry(self, pool: ModelPool, model_name: str) -> ModelPoolEntry | None: + """在池中查找指定模型""" + for entry in pool.models: + if entry.model_name == model_name: + return entry + return None + + @staticmethod + def _entry_to_chat_config( + entry: ModelPoolEntry, primary: ChatModelConfig + ) -> ChatModelConfig: + """将 ModelPoolEntry 转为 ChatModelConfig""" + return ChatModelConfig( + api_url=entry.api_url, + api_key=entry.api_key, + model_name=entry.model_name, + max_tokens=entry.max_tokens, + queue_interval_seconds=entry.queue_interval_seconds, + thinking_enabled=entry.thinking_enabled, + thinking_budget_tokens=entry.thinking_budget_tokens, + thinking_include_budget=entry.thinking_include_budget, + thinking_tool_call_compat=entry.thinking_tool_call_compat, + pool=primary.pool, + ) + + @staticmethod + def _entry_to_agent_config( + entry: ModelPoolEntry, primary: AgentModelConfig + ) -> AgentModelConfig: + """将 ModelPoolEntry 转为 AgentModelConfig""" + return AgentModelConfig( + api_url=entry.api_url, + api_key=entry.api_key, + model_name=entry.model_name, + max_tokens=entry.max_tokens, + queue_interval_seconds=entry.queue_interval_seconds, + thinking_enabled=entry.thinking_enabled, + thinking_budget_tokens=entry.thinking_budget_tokens, + thinking_include_budget=entry.thinking_include_budget, + thinking_tool_call_compat=entry.thinking_tool_call_compat, + pool=primary.pool, + ) diff --git a/src/Undefined/config/__init__.py b/src/Undefined/config/__init__.py index cd48746..04d6246 100644 --- a/src/Undefined/config/__init__.py +++ b/src/Undefined/config/__init__.py @@ -8,6 +8,8 @@ AgentModelConfig, ChatModelConfig, InflightSummaryModelConfig, + ModelPool, + ModelPoolEntry, SecurityModelConfig, VisionModelConfig, ) @@ -19,6 +21,8 @@ "SecurityModelConfig", "AgentModelConfig", "InflightSummaryModelConfig", + "ModelPool", + "ModelPoolEntry", "get_config", "get_config_manager", "load_webui_settings", diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index b0ceab1..5e0fc4c 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -35,6 +35,8 @@ "security_model.queue_interval_seconds", "agent_model.queue_interval_seconds", "inflight_summary_model.queue_interval_seconds", + "chat_model.pool", + "agent_model.pool", } _MODEL_NAME_KEYS: set[str] = { diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 33e13d6..13817bb 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -31,6 +31,8 @@ def load_dotenv( AgentModelConfig, ChatModelConfig, InflightSummaryModelConfig, + ModelPool, + ModelPoolEntry, SecurityModelConfig, VisionModelConfig, ) @@ -372,6 +374,7 @@ class Config: security_model: SecurityModelConfig agent_model: AgentModelConfig inflight_summary_model: InflightSummaryModelConfig + model_pool_enabled: bool log_level: str log_file_path: str log_max_size: int @@ -600,6 +603,10 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi data, chat_model ) + model_pool_enabled = _coerce_bool( + _get_value(data, ("features", "pool_enabled"), "MODEL_POOL_ENABLED"), False + ) + superadmin_qq, admin_qqs = cls._merge_admins( superadmin_qq=superadmin_qq, admin_qqs=admin_qqs ) @@ -1036,6 +1043,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi security_model=security_model, agent_model=agent_model, inflight_summary_model=inflight_summary_model, + model_pool_enabled=model_pool_enabled, log_level=log_level, log_file_path=log_file_path, log_max_size=log_max_size_mb * 1024 * 1024, @@ -1183,6 +1191,65 @@ def security_check_enabled(self) -> bool: return bool(self.security_model_enabled) + @staticmethod + def _parse_model_pool( + data: dict[str, Any], + model_section: str, + primary_config: ChatModelConfig | AgentModelConfig, + ) -> ModelPool | None: + """解析模型池配置,缺省字段继承 primary_config""" + pool_data = data.get("models", {}).get(model_section, {}).get("pool") + if not isinstance(pool_data, dict): + return None + + enabled = _coerce_bool(pool_data.get("enabled"), False) + strategy = _coerce_str(pool_data.get("strategy"), "default").strip().lower() + if strategy not in ("default", "round_robin", "random"): + strategy = "default" + + raw_models = pool_data.get("models") + if not isinstance(raw_models, list): + return ModelPool(enabled=enabled, strategy=strategy) + + entries: list[ModelPoolEntry] = [] + for item in raw_models: + if not isinstance(item, dict): + continue + name = _coerce_str(item.get("model_name"), "").strip() + if not name: + continue + entries.append( + ModelPoolEntry( + api_url=_coerce_str(item.get("api_url"), primary_config.api_url), + api_key=_coerce_str(item.get("api_key"), primary_config.api_key), + model_name=name, + max_tokens=_coerce_int( + item.get("max_tokens"), primary_config.max_tokens + ), + queue_interval_seconds=_coerce_float( + item.get("queue_interval_seconds"), + primary_config.queue_interval_seconds, + ), + thinking_enabled=_coerce_bool( + item.get("thinking_enabled"), primary_config.thinking_enabled + ), + thinking_budget_tokens=_coerce_int( + item.get("thinking_budget_tokens"), + primary_config.thinking_budget_tokens, + ), + thinking_include_budget=_coerce_bool( + item.get("thinking_include_budget"), + primary_config.thinking_include_budget, + ), + thinking_tool_call_compat=_coerce_bool( + item.get("thinking_tool_call_compat"), + primary_config.thinking_tool_call_compat, + ), + ) + ) + + return ModelPool(enabled=enabled, strategy=strategy, models=entries) + @staticmethod def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: queue_interval_seconds = _coerce_float( @@ -1204,7 +1271,7 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: legacy_env_key="CHAT_MODEL_DEEPSEEK_NEW_COT_SUPPORT", ) ) - return ChatModelConfig( + config = ChatModelConfig( api_url=_coerce_str( _get_value(data, ("models", "chat", "api_url"), "CHAT_MODEL_API_URL"), "", @@ -1243,6 +1310,8 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: thinking_include_budget=thinking_include_budget, thinking_tool_call_compat=thinking_tool_call_compat, ) + config.pool = Config._parse_model_pool(data, "chat", config) + return config @staticmethod def _parse_vision_model_config(data: dict[str, Any]) -> VisionModelConfig: @@ -1416,7 +1485,7 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: legacy_env_key="AGENT_MODEL_DEEPSEEK_NEW_COT_SUPPORT", ) ) - return AgentModelConfig( + config = AgentModelConfig( api_url=_coerce_str( _get_value(data, ("models", "agent", "api_url"), "AGENT_MODEL_API_URL"), "", @@ -1455,6 +1524,8 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: thinking_include_budget=thinking_include_budget, thinking_tool_call_compat=thinking_tool_call_compat, ) + config.pool = Config._parse_model_pool(data, "agent", config) + return config @staticmethod def _parse_inflight_summary_model_config( diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 899ef60..e8e8f30 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -1,6 +1,32 @@ """配置模型定义""" -from dataclasses import dataclass +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class ModelPoolEntry: + """模型池中的单个模型条目(已合并缺省值后的完整配置)""" + + api_url: str + api_key: str + model_name: str + max_tokens: int + queue_interval_seconds: float = 1.0 + thinking_enabled: bool = False + thinking_budget_tokens: int = 0 + thinking_include_budget: bool = True + thinking_tool_call_compat: bool = False + + +@dataclass +class ModelPool: + """模型池配置""" + + enabled: bool = True # 是否启用模型池功能 + strategy: str = "default" # "default" | "round_robin" | "random" + models: list[ModelPoolEntry] = field(default_factory=list) @dataclass @@ -18,6 +44,7 @@ class ChatModelConfig: thinking_tool_call_compat: bool = ( False # 思维链 + 工具调用兼容(回传 reasoning_content) ) + pool: ModelPool | None = None # 模型池配置 @dataclass @@ -68,6 +95,7 @@ class AgentModelConfig: thinking_tool_call_compat: bool = ( False # 思维链 + 工具调用兼容(回传 reasoning_content) ) + pool: ModelPool | None = None # 模型池配置 @dataclass diff --git a/src/Undefined/handlers.py b/src/Undefined/handlers.py index b716009..77813ed 100644 --- a/src/Undefined/handlers.py +++ b/src/Undefined/handlers.py @@ -254,6 +254,10 @@ async def handle_message(self, event: dict[str, Any]) -> None: return # 私聊消息直接触发回复 + if await self.ai_coordinator.model_pool.handle_private_message( + private_sender_id, text + ): + return await self.ai_coordinator.handle_private_reply( private_sender_id, text, diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index ce07884..29ec68a 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -7,6 +7,7 @@ from Undefined.context import RequestContext from Undefined.context_resource_registry import collect_context_resources from Undefined.render import render_html_to_image, render_markdown_to_html +from Undefined.services.model_pool import ModelPoolService from Undefined.services.queue_manager import QueueManager from Undefined.utils.history import MessageHistoryManager from Undefined.utils.sender import MessageSender @@ -64,6 +65,7 @@ def __init__( self.scheduler = scheduler self.security = security self.command_dispatcher = command_dispatcher + self.model_pool = ModelPoolService(ai, config, sender) async def handle_auto_reply( self, @@ -204,13 +206,19 @@ async def handle_private_reply( user_id, ) + # 动态选择模型(私聊 group_id=0) + effective_config = self.model_pool.select_chat_config( + self.config.chat_model, user_id=user_id + ) + request_data["selected_model_name"] = effective_config.model_name + if user_id == self.config.superadmin_qq: await self.queue_manager.add_superadmin_request( - request_data, model_name=self.config.chat_model.model_name + request_data, model_name=effective_config.model_name ) else: await self.queue_manager.add_private_request( - request_data, model_name=self.config.chat_model.model_name + request_data, model_name=effective_config.model_name ) async def execute_reply(self, request: dict[str, Any]) -> None: @@ -392,6 +400,7 @@ async def send_private_cb(uid: int, msg: str) -> None: "user_id": user_id, "is_private_chat": True, "sender_name": sender_name, + "selected_model_name": request.get("selected_model_name"), }, ) if result: diff --git a/src/Undefined/services/model_pool.py b/src/Undefined/services/model_pool.py new file mode 100644 index 0000000..2e6787a --- /dev/null +++ b/src/Undefined/services/model_pool.py @@ -0,0 +1,107 @@ +"""多模型池私聊处理服务""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +from Undefined.config.models import ChatModelConfig +from Undefined.utils.sender import MessageSender + +if TYPE_CHECKING: + from Undefined.config import Config + +logger = logging.getLogger(__name__) + + +class ModelPoolService: + """封装多模型池的私聊交互逻辑,与消息处理层解耦""" + + def __init__(self, ai: Any, config: "Config", sender: MessageSender) -> None: + self._ai = ai + self._config = config + self._sender = sender + + async def handle_private_message(self, user_id: int, text: str) -> bool: + """处理私聊多模型指令,返回 True 表示消息已被消费""" + if not self._config.model_pool_enabled: + return False + + selector = self._ai.model_selector + + selected = selector.try_resolve_compare(0, user_id, text) + if selected: + selector.set_preference(0, user_id, "chat", selected) + await selector.save_preferences() + await self._sender.send_private_message( + user_id, f"已切换到模型: {selected}" + ) + return True + + stripped = text.strip() + for prefix in ("/compare ", "/pk "): + if stripped.startswith(prefix): + await self._run_compare(user_id, stripped[len(prefix) :].strip()) + return True + + return False + + async def _run_compare(self, user_id: int, prompt: str) -> None: + if not prompt: + await self._sender.send_private_message(user_id, "用法: /compare <问题>") + return + + selector = self._ai.model_selector + all_models = selector.get_all_chat_models(self._config.chat_model) + + if len(all_models) < 2: + await self._sender.send_private_message( + user_id, "模型池中只有一个模型,无法比较" + ) + return + + await self._sender.send_private_message( + user_id, f"正在向 {len(all_models)} 个模型发送问题,请稍候..." + ) + + messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}] + + async def _query(name: str, cfg: ChatModelConfig) -> tuple[str, str]: + try: + result = await self._ai.request_model( + model_config=cfg, + messages=list(messages), + max_tokens=cfg.max_tokens, + call_type="compare", + ) + content = ( + result.get("choices", [{}])[0].get("message", {}).get("content", "") + ) + return name, content.strip() or "(空回复)" + except Exception as exc: + return name, f"(请求失败: {exc})" + + results = await asyncio.gather(*[_query(n, c) for n, c in all_models]) + + lines: list[str] = [f"问题: {prompt}", ""] + for i, (name, content) in enumerate(results, 1): + if len(content) > 500: + content = content[:497] + "..." + lines += [f"【{i}】{name}", content, ""] + lines.append("回复「选X」可切换到该模型并继续对话") + + await self._sender.send_private_message(user_id, "\n".join(lines)) + selector.set_pending_compare(0, user_id, [n for n, _ in results]) + + def select_chat_config( + self, primary: ChatModelConfig, user_id: int + ) -> ChatModelConfig: + """按用户偏好/策略选择私聊 chat 模型""" + result: ChatModelConfig = self._ai.model_selector.select_chat_config( + primary, + group_id=0, + user_id=user_id, + global_enabled=self._config.model_pool_enabled, + ) + return result diff --git a/src/Undefined/skills/agents/runner.py b/src/Undefined/skills/agents/runner.py index 9b8f43d..1c491e2 100644 --- a/src/Undefined/skills/agents/runner.py +++ b/src/Undefined/skills/agents/runner.py @@ -72,6 +72,14 @@ async def run_agent_with_tools( return "AI client 未在上下文中提供" agent_config = ai_client.agent_config + # 动态选择 agent 模型 + group_id = context.get("group_id", 0) or 0 + user_id = context.get("user_id", 0) or 0 + runtime_config = context.get("runtime_config") + global_enabled = runtime_config.model_pool_enabled if runtime_config else False + agent_config = ai_client.model_selector.select_agent_config( + agent_config, group_id=group_id, user_id=user_id, global_enabled=global_enabled + ) system_prompt = await load_prompt_text(agent_dir, default_prompt) # 注入 agent 私有 Anthropic Skills 元数据到 system prompt diff --git a/src/Undefined/utils/queue_intervals.py b/src/Undefined/utils/queue_intervals.py index 200bbcf..fa5c668 100644 --- a/src/Undefined/utils/queue_intervals.py +++ b/src/Undefined/utils/queue_intervals.py @@ -25,4 +25,13 @@ def build_model_queue_intervals(config: Config) -> dict[str, float]: if not name: continue intervals[name] = float(interval) + + # 注册 pool 中模型的队列间隔 + for pool_source in (config.chat_model, config.agent_model): + if pool_source.pool and pool_source.pool.enabled: + for entry in pool_source.pool.models: + name = entry.model_name.strip() + if name and name not in intervals: + intervals[name] = float(entry.queue_interval_seconds) + return intervals diff --git a/src/Undefined/webui/routes.py b/src/Undefined/webui/routes.py deleted file mode 100644 index 9e7f16a..0000000 --- a/src/Undefined/webui/routes.py +++ /dev/null @@ -1,904 +0,0 @@ -import asyncio -import ipaddress -import logging -import json -import platform -import re -import tomllib -import os -import time -from pathlib import Path - -from aiohttp import web -from aiohttp.web_response import Response -from typing import cast, Any - -try: - import psutil - - _PSUTIL_AVAILABLE = True -except Exception: # pragma: no cover - psutil = None - _PSUTIL_AVAILABLE = False - - -from Undefined.config.loader import CONFIG_PATH, load_toml_data, DEFAULT_WEBUI_PASSWORD -from Undefined.config import get_config_manager, load_webui_settings - -from Undefined import __version__ -from .core import BotProcessController, SessionStore -from Undefined.utils.self_update import ( - GitUpdatePolicy, - apply_git_update, - check_git_update_eligibility, - restart_process, -) -from .utils import ( - read_config_source, - validate_toml, - validate_required_config, - tail_file, - load_default_data, - load_comment_map, - merge_defaults, - apply_patch, - render_toml, - sort_config, -) - -logger = logging.getLogger(__name__) - -SESSION_COOKIE = "undefined_webui" -TOKEN_COOKIE = "undefined_webui_token" -SESSION_TTL_SECONDS = 8 * 60 * 60 -LOGIN_ATTEMPT_LIMIT = 5 -LOGIN_ATTEMPT_WINDOW = 5 * 60 -LOGIN_BLOCK_SECONDS = 15 * 60 - -_LOGIN_ATTEMPTS: dict[str, list[float]] = {} -_LOGIN_BLOCKED_UNTIL: dict[str, float] = {} - -# 使用相对路径定位资源目录 -STATIC_DIR = Path(__file__).parent / "static" -TEMPLATE_DIR = Path(__file__).parent / "templates" - -routes = web.RouteTableDef() - -_CPU_PERCENT_PRIMED = False - - -def _clamp_percent(value: float) -> float: - return max(0.0, min(100.0, value)) - - -def _read_cpu_times() -> tuple[int, int] | None: - try: - stat_path = Path("/proc/stat") - if not stat_path.exists(): - return None - first_line = stat_path.read_text(encoding="utf-8").splitlines()[0] - if not first_line.startswith("cpu "): - return None - parts = first_line.split()[1:] - values = [int(p) for p in parts] - if len(values) < 4: - return None - idle = values[3] + (values[4] if len(values) > 4 else 0) - total = sum(values) - return idle, total - except Exception: - return None - - -async def _get_cpu_usage_percent() -> float | None: - global _CPU_PERCENT_PRIMED - - if _PSUTIL_AVAILABLE and psutil is not None: - try: - usage = psutil.cpu_percent(interval=None) - # psutil 文档说明首次非阻塞采样可能为 0,需要预热一次。 - if not _CPU_PERCENT_PRIMED: - _CPU_PERCENT_PRIMED = True - await asyncio.sleep(0.12) - usage = psutil.cpu_percent(interval=None) - return _clamp_percent(float(usage)) - except Exception: - logger.debug("[WebUI] psutil CPU 采集失败,回退 /proc 方案", exc_info=True) - - first = _read_cpu_times() - if not first: - return None - idle_1, total_1 = first - await asyncio.sleep(0.15) - second = _read_cpu_times() - if not second: - return None - idle_2, total_2 = second - total_delta = total_2 - total_1 - idle_delta = idle_2 - idle_1 - if total_delta <= 0: - return None - usage = (1 - idle_delta / total_delta) * 100 - return _clamp_percent(usage) - - -def _read_cpu_model() -> str: - model = platform.processor() - if model and model.strip(): - return model.strip() - - # Linux 下补充 /proc 兜底,其它平台直接返回 Unknown。 - cpuinfo_path = Path("/proc/cpuinfo") - if cpuinfo_path.exists(): - for line in cpuinfo_path.read_text(encoding="utf-8").splitlines(): - if line.lower().startswith("model name"): - parts = line.split(":", 1) - if len(parts) == 2: - candidate = parts[1].strip() - if candidate: - return candidate - return "Unknown" - - -def _read_memory_info() -> tuple[float, float, float] | None: - if _PSUTIL_AVAILABLE and psutil is not None: - try: - memory = psutil.virtual_memory() - total_gb = float(memory.total) / 1024 / 1024 / 1024 - used_gb = float(memory.used) / 1024 / 1024 / 1024 - usage = float(memory.percent) - return total_gb, used_gb, _clamp_percent(usage) - except Exception: - logger.debug("[WebUI] psutil 内存采集失败,回退 /proc 方案", exc_info=True) - - meminfo_path = Path("/proc/meminfo") - if not meminfo_path.exists(): - return None - total_kb = None - available_kb = None - for line in meminfo_path.read_text(encoding="utf-8").splitlines(): - if line.startswith("MemTotal:"): - total_kb = int(line.split()[1]) - elif line.startswith("MemAvailable:"): - available_kb = int(line.split()[1]) - if total_kb is None or available_kb is None: - return None - used_kb = max(0, total_kb - available_kb) - total_gb = total_kb / 1024 / 1024 - used_gb = used_kb / 1024 / 1024 - usage = (used_kb / total_kb) * 100 if total_kb else 0.0 - return total_gb, used_gb, usage - - -def _resolve_log_path() -> Path: - log_path = Path("logs/bot.log") - try: - if CONFIG_PATH.exists(): - with open(CONFIG_PATH, "rb") as f: - cfg = tomllib.load(f) - path_str = cfg.get("logging", {}).get("file_path") - if path_str: - log_path = Path(path_str) - except Exception: - pass - return log_path - - -def _resolve_webui_log_path() -> Path: - return Path("logs/webui.log") - - -def _is_log_file(path: Path) -> bool: - name = path.name - if ".log" not in name: - return False - lowered = name.lower() - if lowered.endswith((".tar", ".tar.gz", ".tgz")): - return False - return True - - -def _list_log_files(base_path: Path) -> list[Path]: - files = [base_path] - if base_path.parent.exists(): - prefix = f"{base_path.name}." - for candidate in base_path.parent.glob(f"{base_path.name}.*"): - suffix = candidate.name[len(prefix) :] - if suffix.isdigit(): - files.append(candidate) - - def _sort_key(path: Path) -> tuple[int, str]: - if path.name == base_path.name: - return (0, "") - match = re.search(r"\.([0-9]+)$", path.name) - if match: - return (1, match.group(1).zfill(6)) - return (2, path.name) - - return sorted(files, key=_sort_key) - - -def _list_all_log_files(log_dir: Path) -> list[Path]: - if not log_dir.exists(): - return [] - files = [path for path in log_dir.glob("*.log*") if _is_log_file(path)] - files.sort(key=lambda path: path.name) - return files - - -def _resolve_log_file(base_path: Path, file_name: str | None) -> Path | None: - if not file_name: - return base_path - for path in _list_log_files(base_path): - if path.name == file_name: - return path - return None - - -def _resolve_any_log_file(log_dir: Path, file_name: str | None) -> Path | None: - if not file_name: - return None - for path in _list_all_log_files(log_dir): - if path.name == file_name: - return path - return None - - -# Global instances (injected via app, but for routes simplicity using global here/app context is better) -# For simplicity in this functional refactor, we will attach them to app['bot'] etc. - - -def get_bot(request: web.Request) -> BotProcessController: - return cast(BotProcessController, request.app["bot"]) - - -def get_session_store(request: web.Request) -> SessionStore: - return cast(SessionStore, request.app["session_store"]) - - -def get_settings(request: web.Request) -> Any: - return request.app["settings"] - - -def check_auth(request: web.Request) -> bool: - sessions = get_session_store(request) - # Extract token from cookie or header - token = request.cookies.get(SESSION_COOKIE) - if not token: - token = request.headers.get("X-Auth-Token") - - return sessions.is_valid(token) - - -def _get_client_ip(request: web.Request) -> str: - if request.remote: - return request.remote - peer = request.transport.get_extra_info("peername") if request.transport else None - if isinstance(peer, tuple) and peer: - return str(peer[0]) - return "unknown" - - -def _is_loopback_address(addr: str) -> bool: - try: - return ipaddress.ip_address(addr).is_loopback - except ValueError: - return False - - -def _is_local_request(request: web.Request) -> bool: - addr = _get_client_ip(request) - return _is_loopback_address(addr) - - -def _check_login_rate_limit(client_ip: str) -> tuple[bool, int]: - now = time.time() - blocked_until = _LOGIN_BLOCKED_UNTIL.get(client_ip, 0) - if blocked_until > now: - return False, int(blocked_until - now) - attempts = _LOGIN_ATTEMPTS.get(client_ip, []) - attempts = [ts for ts in attempts if now - ts <= LOGIN_ATTEMPT_WINDOW] - _LOGIN_ATTEMPTS[client_ip] = attempts - return True, 0 - - -def _record_login_failure(client_ip: str) -> tuple[bool, int]: - now = time.time() - attempts = _LOGIN_ATTEMPTS.get(client_ip, []) - attempts = [ts for ts in attempts if now - ts <= LOGIN_ATTEMPT_WINDOW] - attempts.append(now) - if len(attempts) >= LOGIN_ATTEMPT_LIMIT: - _LOGIN_ATTEMPTS.pop(client_ip, None) - _LOGIN_BLOCKED_UNTIL[client_ip] = now + LOGIN_BLOCK_SECONDS - return False, LOGIN_BLOCK_SECONDS - _LOGIN_ATTEMPTS[client_ip] = attempts - return True, 0 - - -def _clear_login_failures(client_ip: str) -> None: - _LOGIN_ATTEMPTS.pop(client_ip, None) - _LOGIN_BLOCKED_UNTIL.pop(client_ip, None) - - -@routes.get("/") -async def index_handler(request: web.Request) -> Response: - # Serve the SPA HTML - # We inject some initial state into the HTML to avoid an extra RTT - settings = get_settings(request) - - html_file = TEMPLATE_DIR / "index.html" - if not html_file.exists(): - return web.Response(text="Index template not found", status=500) - - html = html_file.read_text(encoding="utf-8") - - license_file = Path("LICENSE") - license_text = ( - license_file.read_text(encoding="utf-8") if license_file.exists() else "" - ) - - lang = request.cookies.get("undefined_lang", "zh") - theme = request.cookies.get("undefined_theme", "light") - - # Inject initial state - redirect_to_config = bool(request.app.get("redirect_to_config_once")) - initial_state = { - "using_default_password": settings.using_default_password, - "config_exists": settings.config_exists, - "redirect_to_config": redirect_to_config, - "version": __version__, - "license": license_text, - "lang": lang, - "theme": theme, - } - - if redirect_to_config: - # one-time per WebUI process lifetime - request.app["redirect_to_config_once"] = False - - initial_state_json = json.dumps(initial_state).replace(" Response: - try: - data = await request.json() - except Exception: - return web.json_response( - {"success": False, "error": "Invalid JSON"}, status=400 - ) - password = data.get("password") - settings = get_settings(request) - - if settings.using_default_password: - return web.json_response( - { - "success": False, - "error": "Default password is disabled. Please update it first.", - "code": "default_password", - }, - status=403, - ) - - client_ip = _get_client_ip(request) - allowed, retry_after = _check_login_rate_limit(client_ip) - if not allowed: - return web.json_response( - { - "success": False, - "error": "Too many login attempts. Please try again later.", - "retry_after": retry_after, - "code": "rate_limited", - }, - status=429, - ) - - if password == settings.password: - _clear_login_failures(client_ip) - token = get_session_store(request).create() - resp = web.json_response({"success": True}) - # Set both cookies for maximum compatibility - resp.set_cookie( - SESSION_COOKIE, - token, - httponly=True, - samesite="Lax", - max_age=SESSION_TTL_SECONDS, - ) - return resp - - ok, block_seconds = _record_login_failure(client_ip) - if not ok: - return web.json_response( - { - "success": False, - "error": "Too many login attempts. Please try again later.", - "retry_after": block_seconds, - "code": "rate_limited", - }, - status=429, - ) - - return web.json_response( - {"success": False, "error": "Invalid password"}, status=401 - ) - - -@routes.get("/api/session") -async def session_handler(request: web.Request) -> Response: - settings = get_settings(request) - authenticated = check_auth(request) - summary = "locked" - if authenticated: - summary = f"{settings.url}:{settings.port} | ready" - payload = { - "authenticated": authenticated, - "using_default_password": settings.using_default_password, - "config_exists": settings.config_exists, - "summary": summary, - } - return web.json_response(payload) - - -@routes.post("/api/logout") -async def logout_handler(request: web.Request) -> Response: - token = request.cookies.get(SESSION_COOKIE) or request.headers.get("X-Auth-Token") - get_session_store(request).revoke(token) - resp = web.json_response({"success": True}) - resp.del_cookie(SESSION_COOKIE) - resp.del_cookie(TOKEN_COOKIE) - return resp - - -@routes.get("/api/status") -async def status_handler(request: web.Request) -> Response: - bot = get_bot(request) - status = bot.status() - if not check_auth(request): - return web.json_response( - { - "running": bool(status.get("running")), - "public": True, - } - ) - return web.json_response(status) - - -@routes.post("/api/password") -async def password_handler(request: web.Request) -> Response: - try: - data = await request.json() - except Exception: - return web.json_response( - {"success": False, "error": "Invalid JSON"}, status=400 - ) - - current_password = str(data.get("current_password") or "").strip() - new_password = str(data.get("new_password") or "").strip() - - settings = get_settings(request) - authenticated = check_auth(request) - - if not authenticated: - if not settings.using_default_password: - return web.json_response( - {"success": False, "error": "Unauthorized"}, status=401 - ) - if not _is_local_request(request): - return web.json_response( - { - "success": False, - "error": "Password change requires local access when using default password.", - "code": "local_required", - }, - status=403, - ) - - if not current_password or current_password != settings.password: - return web.json_response( - {"success": False, "error": "Current password is incorrect."}, - status=400, - ) - - if not new_password: - return web.json_response( - {"success": False, "error": "New password is required."}, status=400 - ) - - if new_password == settings.password: - return web.json_response( - {"success": False, "error": "New password must be different."}, status=400 - ) - - if new_password == DEFAULT_WEBUI_PASSWORD: - return web.json_response( - { - "success": False, - "error": "New password cannot be the default value.", - }, - status=400, - ) - - source = read_config_source() - try: - data_dict = ( - tomllib.loads(source["content"]) if source["content"].strip() else {} - ) - except tomllib.TOMLDecodeError as exc: - return web.json_response( - {"success": False, "error": f"TOML parse error: {exc}"}, status=400 - ) - if not isinstance(data_dict, dict): - data_dict = {} - - patched = apply_patch(data_dict, {"webui.password": new_password}) - rendered = render_toml(patched) - CONFIG_PATH.write_text(rendered, encoding="utf-8") - get_config_manager().reload() - request.app["settings"] = load_webui_settings() - get_session_store(request).clear() - - resp = web.json_response({"success": True, "message": "Password updated"}) - resp.del_cookie(SESSION_COOKIE) - resp.del_cookie(TOKEN_COOKIE) - return resp - - -@routes.post("/api/bot/{action}") -async def bot_action_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - action = request.match_info["action"] - bot = get_bot(request) - - if action == "start": - status = await bot.start() - return web.json_response(status) - elif action == "stop": - status = await bot.stop() - return web.json_response(status) - - return web.json_response({"error": "Invalid action"}, status=400) - - -def _truncate(text: str, *, max_chars: int = 12000) -> str: - if len(text) <= max_chars: - return text - head = text[:max_chars] - return head + "\n... (truncated)" - - -@routes.post("/api/update-restart") -async def update_restart_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - policy = GitUpdatePolicy() - eligibility = await asyncio.to_thread(check_git_update_eligibility, policy) - if not eligibility.eligible: - return web.json_response( - { - "success": True, - "eligible": False, - "updated": False, - "reason": eligibility.reason, - "origin_url": eligibility.origin_url, - "branch": eligibility.branch, - "will_restart": False, - "output": _truncate(eligibility.output or ""), - } - ) - - bot = get_bot(request) - was_running = bool(bot.status().get("running")) - - # Stop bot first to avoid holding files/resources during update. - try: - await asyncio.wait_for(bot.stop(), timeout=8) - except asyncio.TimeoutError: - return web.json_response( - {"success": False, "error": "Bot stop timeout"}, status=500 - ) - - if was_running: - marker = Path("data/cache/pending_bot_autostart") - marker.parent.mkdir(parents=True, exist_ok=True) - try: - marker.write_text("1", encoding="utf-8") - except OSError: - pass - - result = await asyncio.to_thread(apply_git_update, policy) - - payload: dict[str, object] = { - "success": True, - "eligible": result.eligible, - "updated": result.updated, - "reason": result.reason, - "origin_url": result.origin_url, - "branch": result.branch, - "old_rev": result.old_rev, - "new_rev": result.new_rev, - "remote_rev": result.remote_rev, - "uv_synced": result.uv_synced, - "uv_sync_attempted": result.uv_sync_attempted, - "output": _truncate(result.output or ""), - } - - # Only restart when we are on official origin/main and update logic ran successfully. - can_restart_after_update = not ( - result.updated and result.uv_sync_attempted and not result.uv_synced - ) - will_restart = bool( - result.eligible - and result.reason in {"updated", "up_to_date"} - and can_restart_after_update - ) - payload["will_restart"] = will_restart - - if not will_restart: - # Cleanup marker if we are not going to restart. - if was_running: - try: - Path("data/cache/pending_bot_autostart").unlink(missing_ok=True) - except OSError: - pass - try: - await bot.start() - except Exception: - logger.debug( - "[WebUI] 更新未触发重启,恢复机器人进程失败", exc_info=True - ) - return web.json_response(payload) - - async def _restart_soon() -> None: - await asyncio.sleep(0.25) - # Best-effort: keep current working directory unless it is outside the repo. - chdir = result.repo_root - if chdir is not None: - try: - os.chdir(chdir) - except OSError: - pass - restart_process(module="Undefined.webui", chdir=None) - - asyncio.create_task(_restart_soon()) - return web.json_response(payload) - - -@routes.get("/api/config") -async def get_config_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - return web.json_response(read_config_source()) - - -@routes.post("/api/config") -async def save_config_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - data = await request.json() - content = data.get("content") - - if not content: - return web.json_response({"error": "No content provided"}, status=400) - - valid, msg = validate_toml(content) - if not valid: - return web.json_response({"success": False, "error": msg}, status=400) - - try: - CONFIG_PATH.write_text(content, encoding="utf-8") - get_config_manager().reload() - # Validate logic requirements (optional, warn but save is ok if syntax is valid) - logic_valid, logic_msg = validate_required_config() - return web.json_response( - { - "success": True, - "message": "Saved", - "warning": None if logic_valid else logic_msg, - } - ) - except Exception as e: - return web.json_response({"success": False, "error": str(e)}, status=500) - - -@routes.get("/api/config/summary") -async def config_summary_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - data = load_toml_data() - defaults = load_default_data() - summary = merge_defaults(defaults, data) - ordered = sort_config(summary) - comments = load_comment_map() - return web.json_response({"data": ordered, "comments": comments}) - - -@routes.post("/api/patch") -async def config_patch_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - try: - body = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON"}, status=400) - patch = body.get("patch") - if not isinstance(patch, dict): - return web.json_response({"error": "Invalid payload"}, status=400) - - source = read_config_source() - try: - data = tomllib.loads(source["content"]) if source["content"].strip() else {} - except tomllib.TOMLDecodeError as exc: - return web.json_response({"error": f"TOML parse error: {exc}"}, status=400) - - if not isinstance(data, dict): - data = {} - - patched = apply_patch(data, patch) - rendered = render_toml(patched) - CONFIG_PATH.write_text(rendered, encoding="utf-8") - get_config_manager().reload() - validation_ok, validation_msg = validate_required_config() - - return web.json_response( - { - "success": True, - "message": "Saved", - "warning": None if validation_ok else validation_msg, - } - ) - - -@routes.get("/api/logs") -async def logs_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - lines = int(request.query.get("lines", "200")) - lines = max(1, min(lines, 2000)) - log_type = request.query.get("type", "bot") - file_name = request.query.get("file") - if log_type == "webui": - log_path = _resolve_webui_log_path() - target_path = _resolve_log_file(log_path, file_name) - elif log_type == "all": - target_path = _resolve_any_log_file(Path("logs"), file_name) - else: - log_path = _resolve_log_path() - target_path = _resolve_log_file(log_path, file_name) - if target_path is None: - return web.json_response({"error": "Log file not found"}, status=404) - content = tail_file(target_path, lines) - return web.Response(text=content) - - -@routes.get("/api/logs/files") -async def logs_files_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - log_type = request.query.get("type", "bot") - if log_type == "webui": - log_path = _resolve_webui_log_path() - files_list = _list_log_files(log_path) - current_name = log_path.name - elif log_type == "all": - log_path = Path("logs") - files_list = _list_all_log_files(log_path) - current_name = "" - else: - log_path = _resolve_log_path() - files_list = _list_log_files(log_path) - current_name = log_path.name - - files: list[dict[str, Any]] = [] - for path in files_list: - try: - stat = path.stat() - size = stat.st_size - mtime = int(stat.st_mtime) - exists = True - except FileNotFoundError: - size = 0 - mtime = 0 - exists = False - files.append( - { - "name": path.name, - "size": size, - "modified": mtime, - "current": path.name == current_name, - "exists": exists, - } - ) - return web.json_response({"files": files, "current": current_name}) - - -@routes.get("/api/logs/stream") -async def logs_stream_handler(request: web.Request) -> web.StreamResponse: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - lines = int(request.query.get("lines", "200")) - lines = max(1, min(lines, 2000)) - log_type = request.query.get("type", "bot") - if log_type == "all": - return web.json_response( - {"error": "Streaming only supports bot/webui logs"}, status=400 - ) - log_path = _resolve_webui_log_path() if log_type == "webui" else _resolve_log_path() - file_name = request.query.get("file") - if file_name and file_name != log_path.name: - return web.json_response( - {"error": "Streaming only supports current log"}, status=400 - ) - - headers = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - } - resp = web.StreamResponse(status=200, reason="OK", headers=headers) - await resp.prepare(request) - - last_payload: str | None = None - try: - while True: - if request.transport is None or request.transport.is_closing(): - break - payload = tail_file(log_path, lines) - if payload != last_payload: - data = "\n".join(f"data: {line}" for line in payload.splitlines()) - await resp.write((data + "\n\n").encode("utf-8")) - last_payload = payload - await asyncio.sleep(1) - except asyncio.CancelledError: - logger.debug("[WebUI] logs stream cancelled") - except (ConnectionResetError, RuntimeError): - pass - finally: - try: - await resp.write_eof() - except Exception: - pass - - return resp - - -@routes.get("/api/system") -async def system_info_handler(request: web.Request) -> Response: - if not check_auth(request): - return web.json_response({"error": "Unauthorized"}, status=401) - - cpu_usage = await _get_cpu_usage_percent() - memory_info = _read_memory_info() - - payload = { - "cpu_model": _read_cpu_model(), - "cpu_usage_percent": None if cpu_usage is None else round(cpu_usage, 1), - "memory_total_gb": None, - "memory_used_gb": None, - "memory_usage_percent": None, - "system_version": platform.platform(), - "system_release": platform.release(), - "system_arch": platform.machine(), - "python_version": platform.python_version(), - "undefined_version": __version__, - } - - if memory_info: - total_gb, used_gb, usage = memory_info - payload["memory_total_gb"] = round(total_gb, 2) - payload["memory_used_gb"] = round(used_gb, 2) - payload["memory_usage_percent"] = round(usage, 1) - - return web.json_response(payload) diff --git a/src/Undefined/webui/routes/__init__.py b/src/Undefined/webui/routes/__init__.py new file mode 100644 index 0000000..8de7695 --- /dev/null +++ b/src/Undefined/webui/routes/__init__.py @@ -0,0 +1,4 @@ +from ._shared import routes + +__all__ = ["routes"] +from . import _index, _auth, _bot, _config, _logs, _system # noqa: F401 register handlers diff --git a/src/Undefined/webui/routes/_auth.py b/src/Undefined/webui/routes/_auth.py new file mode 100644 index 0000000..cbe363e --- /dev/null +++ b/src/Undefined/webui/routes/_auth.py @@ -0,0 +1,182 @@ +import tomllib + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.config.loader import CONFIG_PATH, DEFAULT_WEBUI_PASSWORD +from Undefined.config import get_config_manager, load_webui_settings +from ._shared import ( + routes, + SESSION_COOKIE, + TOKEN_COOKIE, + SESSION_TTL_SECONDS, + get_settings, + get_session_store, + check_auth, + _get_client_ip, + _is_local_request, + _check_login_rate_limit, + _record_login_failure, + _clear_login_failures, +) +from ..utils import read_config_source, apply_patch, render_toml + + +@routes.post("/api/login") +async def login_handler(request: web.Request) -> Response: + try: + data = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON"}, status=400 + ) + + password = data.get("password") + settings = get_settings(request) + + if settings.using_default_password: + return web.json_response( + { + "success": False, + "error": "Default password is disabled. Please update it first.", + "code": "default_password", + }, + status=403, + ) + + client_ip = _get_client_ip(request) + allowed, retry_after = _check_login_rate_limit(client_ip) + if not allowed: + return web.json_response( + { + "success": False, + "error": "Too many login attempts. Please try again later.", + "retry_after": retry_after, + "code": "rate_limited", + }, + status=429, + ) + + if password == settings.password: + _clear_login_failures(client_ip) + token = get_session_store(request).create() + resp = web.json_response({"success": True}) + resp.set_cookie( + SESSION_COOKIE, + token, + httponly=True, + samesite="Lax", + max_age=SESSION_TTL_SECONDS, + ) + return resp + + ok, block_seconds = _record_login_failure(client_ip) + if not ok: + return web.json_response( + { + "success": False, + "error": "Too many login attempts. Please try again later.", + "retry_after": block_seconds, + "code": "rate_limited", + }, + status=429, + ) + return web.json_response( + {"success": False, "error": "Invalid password"}, status=401 + ) + + +@routes.get("/api/session") +async def session_handler(request: web.Request) -> Response: + settings = get_settings(request) + authenticated = check_auth(request) + summary = f"{settings.url}:{settings.port} | ready" if authenticated else "locked" + return web.json_response( + { + "authenticated": authenticated, + "using_default_password": settings.using_default_password, + "config_exists": settings.config_exists, + "summary": summary, + } + ) + + +@routes.post("/api/logout") +async def logout_handler(request: web.Request) -> Response: + token = request.cookies.get(SESSION_COOKIE) or request.headers.get("X-Auth-Token") + get_session_store(request).revoke(token) + resp = web.json_response({"success": True}) + resp.del_cookie(SESSION_COOKIE) + resp.del_cookie(TOKEN_COOKIE) + return resp + + +@routes.post("/api/password") +async def password_handler(request: web.Request) -> Response: + try: + data = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON"}, status=400 + ) + + current_password = str(data.get("current_password") or "").strip() + new_password = str(data.get("new_password") or "").strip() + settings = get_settings(request) + authenticated = check_auth(request) + + if not authenticated: + if not settings.using_default_password: + return web.json_response( + {"success": False, "error": "Unauthorized"}, status=401 + ) + if not _is_local_request(request): + return web.json_response( + { + "success": False, + "error": "Password change requires local access when using default password.", + "code": "local_required", + }, + status=403, + ) + + if not current_password or current_password != settings.password: + return web.json_response( + {"success": False, "error": "Current password is incorrect."}, status=400 + ) + if not new_password: + return web.json_response( + {"success": False, "error": "New password is required."}, status=400 + ) + if new_password == settings.password: + return web.json_response( + {"success": False, "error": "New password must be different."}, status=400 + ) + if new_password == DEFAULT_WEBUI_PASSWORD: + return web.json_response( + {"success": False, "error": "New password cannot be the default value."}, + status=400, + ) + + source = read_config_source() + try: + data_dict = ( + tomllib.loads(source["content"]) if source["content"].strip() else {} + ) + except tomllib.TOMLDecodeError as exc: + return web.json_response( + {"success": False, "error": f"TOML parse error: {exc}"}, status=400 + ) + if not isinstance(data_dict, dict): + data_dict = {} + + patched = apply_patch(data_dict, {"webui.password": new_password}) + CONFIG_PATH.write_text(render_toml(patched), encoding="utf-8") + get_config_manager().reload() + request.app["settings"] = load_webui_settings() + get_session_store(request).clear() + + resp = web.json_response({"success": True, "message": "Password updated"}) + resp.del_cookie(SESSION_COOKIE) + resp.del_cookie(TOKEN_COOKIE) + return resp diff --git a/src/Undefined/webui/routes/_bot.py b/src/Undefined/webui/routes/_bot.py new file mode 100644 index 0000000..0d46692 --- /dev/null +++ b/src/Undefined/webui/routes/_bot.py @@ -0,0 +1,131 @@ +import asyncio +import os +from pathlib import Path + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.utils.self_update import ( + GitUpdatePolicy, + apply_git_update, + check_git_update_eligibility, + restart_process, +) +from ._shared import routes, check_auth, get_bot + + +def _truncate(text: str, *, max_chars: int = 12000) -> str: + if len(text) <= max_chars: + return text + return text[:max_chars] + "\n... (truncated)" + + +@routes.get("/api/status") +async def status_handler(request: web.Request) -> Response: + bot = get_bot(request) + status = bot.status() + if not check_auth(request): + return web.json_response( + {"running": bool(status.get("running")), "public": True} + ) + return web.json_response(status) + + +@routes.post("/api/bot/{action}") +async def bot_action_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + action = request.match_info["action"] + bot = get_bot(request) + if action == "start": + return web.json_response(await bot.start()) + elif action == "stop": + return web.json_response(await bot.stop()) + return web.json_response({"error": "Invalid action"}, status=400) + + +@routes.post("/api/update-restart") +async def update_restart_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + + policy = GitUpdatePolicy() + eligibility = await asyncio.to_thread(check_git_update_eligibility, policy) + if not eligibility.eligible: + return web.json_response( + { + "success": True, + "eligible": False, + "updated": False, + "reason": eligibility.reason, + "origin_url": eligibility.origin_url, + "branch": eligibility.branch, + "will_restart": False, + "output": _truncate(eligibility.output or ""), + } + ) + + bot = get_bot(request) + was_running = bool(bot.status().get("running")) + try: + await asyncio.wait_for(bot.stop(), timeout=8) + except asyncio.TimeoutError: + return web.json_response( + {"success": False, "error": "Bot stop timeout"}, status=500 + ) + + if was_running: + marker = Path("data/cache/pending_bot_autostart") + marker.parent.mkdir(parents=True, exist_ok=True) + try: + marker.write_text("1", encoding="utf-8") + except OSError: + pass + + result = await asyncio.to_thread(apply_git_update, policy) + payload: dict[str, object] = { + "success": True, + "eligible": result.eligible, + "updated": result.updated, + "reason": result.reason, + "origin_url": result.origin_url, + "branch": result.branch, + "old_rev": result.old_rev, + "new_rev": result.new_rev, + "remote_rev": result.remote_rev, + "uv_synced": result.uv_synced, + "uv_sync_attempted": result.uv_sync_attempted, + "output": _truncate(result.output or ""), + } + + can_restart = not ( + result.updated and result.uv_sync_attempted and not result.uv_synced + ) + will_restart = bool( + result.eligible and result.reason in {"updated", "up_to_date"} and can_restart + ) + payload["will_restart"] = will_restart + + if not will_restart: + if was_running: + try: + Path("data/cache/pending_bot_autostart").unlink(missing_ok=True) + except OSError: + pass + try: + await bot.start() + except Exception: + pass + return web.json_response(payload) + + async def _restart_soon() -> None: + await asyncio.sleep(0.25) + if result.repo_root is not None: + try: + os.chdir(result.repo_root) + except OSError: + pass + restart_process(module="Undefined.webui", chdir=None) + + asyncio.create_task(_restart_soon()) + return web.json_response(payload) diff --git a/src/Undefined/webui/routes/_config.py b/src/Undefined/webui/routes/_config.py new file mode 100644 index 0000000..26770f0 --- /dev/null +++ b/src/Undefined/webui/routes/_config.py @@ -0,0 +1,97 @@ +import tomllib + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.config.loader import CONFIG_PATH, load_toml_data +from Undefined.config import get_config_manager +from ._shared import routes, check_auth +from ..utils import ( + read_config_source, + validate_toml, + validate_required_config, + load_default_data, + load_comment_map, + merge_defaults, + apply_patch, + render_toml, + sort_config, +) + + +@routes.get("/api/config") +async def get_config_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + return web.json_response(read_config_source()) + + +@routes.post("/api/config") +async def save_config_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + data = await request.json() + content = data.get("content") + if not content: + return web.json_response({"error": "No content provided"}, status=400) + valid, msg = validate_toml(content) + if not valid: + return web.json_response({"success": False, "error": msg}, status=400) + try: + CONFIG_PATH.write_text(content, encoding="utf-8") + get_config_manager().reload() + logic_valid, logic_msg = validate_required_config() + return web.json_response( + { + "success": True, + "message": "Saved", + "warning": None if logic_valid else logic_msg, + } + ) + except Exception as e: + return web.json_response({"success": False, "error": str(e)}, status=500) + + +@routes.get("/api/config/summary") +async def config_summary_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + data = load_toml_data() + defaults = load_default_data() + summary = merge_defaults(defaults, data) + ordered = sort_config(summary) + comments = load_comment_map() + return web.json_response({"data": ordered, "comments": comments}) + + +@routes.post("/api/patch") +async def config_patch_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + patch = body.get("patch") + if not isinstance(patch, dict): + return web.json_response({"error": "Invalid payload"}, status=400) + + source = read_config_source() + try: + data = tomllib.loads(source["content"]) if source["content"].strip() else {} + except tomllib.TOMLDecodeError as exc: + return web.json_response({"error": f"TOML parse error: {exc}"}, status=400) + if not isinstance(data, dict): + data = {} + + patched = apply_patch(data, patch) + CONFIG_PATH.write_text(render_toml(patched), encoding="utf-8") + get_config_manager().reload() + validation_ok, validation_msg = validate_required_config() + return web.json_response( + { + "success": True, + "message": "Saved", + "warning": None if validation_ok else validation_msg, + } + ) diff --git a/src/Undefined/webui/routes/_index.py b/src/Undefined/webui/routes/_index.py new file mode 100644 index 0000000..14f317f --- /dev/null +++ b/src/Undefined/webui/routes/_index.py @@ -0,0 +1,44 @@ +import json + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined import __version__ +from ._shared import routes, TEMPLATE_DIR, get_settings + + +@routes.get("/") +async def index_handler(request: web.Request) -> Response: + settings = get_settings(request) + html_file = TEMPLATE_DIR / "index.html" + if not html_file.exists(): + return web.Response(text="Index template not found", status=500) + + html = html_file.read_text(encoding="utf-8") + license_text = "" + from pathlib import Path + + license_file = Path("LICENSE") + if license_file.exists(): + license_text = license_file.read_text(encoding="utf-8") + + lang = request.cookies.get("undefined_lang", "zh") + theme = request.cookies.get("undefined_theme", "light") + redirect_to_config = bool(request.app.get("redirect_to_config_once")) + + initial_state = { + "using_default_password": settings.using_default_password, + "config_exists": settings.config_exists, + "redirect_to_config": redirect_to_config, + "version": __version__, + "license": license_text, + "lang": lang, + "theme": theme, + } + if redirect_to_config: + request.app["redirect_to_config_once"] = False + + initial_state_json = json.dumps(initial_state).replace(" Path: + log_path = Path("logs/bot.log") + try: + if CONFIG_PATH.exists(): + with open(CONFIG_PATH, "rb") as f: + cfg = tomllib.load(f) + path_str = cfg.get("logging", {}).get("file_path") + if path_str: + log_path = Path(path_str) + except Exception: + pass + return log_path + + +def _resolve_webui_log_path() -> Path: + return Path("logs/webui.log") + + +def _is_log_file(path: Path) -> bool: + name = path.name + if ".log" not in name: + return False + return not name.lower().endswith((".tar", ".tar.gz", ".tgz")) + + +def _list_log_files(base_path: Path) -> list[Path]: + files = [base_path] + if base_path.parent.exists(): + prefix = f"{base_path.name}." + for candidate in base_path.parent.glob(f"{base_path.name}.*"): + suffix = candidate.name[len(prefix) :] + if suffix.isdigit(): + files.append(candidate) + + def _sort_key(path: Path) -> tuple[int, str]: + if path.name == base_path.name: + return (0, "") + match = re.search(r"\.([0-9]+)$", path.name) + if match: + return (1, match.group(1).zfill(6)) + return (2, path.name) + + return sorted(files, key=_sort_key) + + +def _list_all_log_files(log_dir: Path) -> list[Path]: + if not log_dir.exists(): + return [] + files = [p for p in log_dir.glob("*.log*") if _is_log_file(p)] + files.sort(key=lambda p: p.name) + return files + + +def _resolve_log_file(base_path: Path, file_name: str | None) -> Path | None: + if not file_name: + return base_path + return next((p for p in _list_log_files(base_path) if p.name == file_name), None) + + +def _resolve_any_log_file(log_dir: Path, file_name: str | None) -> Path | None: + if not file_name: + return None + return next((p for p in _list_all_log_files(log_dir) if p.name == file_name), None) + + +@routes.get("/api/logs") +async def logs_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + lines = max(1, min(int(request.query.get("lines", "200")), 2000)) + log_type = request.query.get("type", "bot") + file_name = request.query.get("file") + if log_type == "webui": + target_path = _resolve_log_file(_resolve_webui_log_path(), file_name) + elif log_type == "all": + target_path = _resolve_any_log_file(Path("logs"), file_name) + else: + target_path = _resolve_log_file(_resolve_log_path(), file_name) + if target_path is None: + return web.json_response({"error": "Log file not found"}, status=404) + return web.Response(text=tail_file(target_path, lines)) + + +@routes.get("/api/logs/files") +async def logs_files_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + log_type = request.query.get("type", "bot") + if log_type == "webui": + log_path = _resolve_webui_log_path() + files_list = _list_log_files(log_path) + current_name = log_path.name + elif log_type == "all": + files_list = _list_all_log_files(Path("logs")) + current_name = "" + else: + log_path = _resolve_log_path() + files_list = _list_log_files(log_path) + current_name = log_path.name + + files: list[dict[str, Any]] = [] + for path in files_list: + try: + stat = path.stat() + files.append( + { + "name": path.name, + "size": stat.st_size, + "modified": int(stat.st_mtime), + "current": path.name == current_name, + "exists": True, + } + ) + except FileNotFoundError: + files.append( + { + "name": path.name, + "size": 0, + "modified": 0, + "current": path.name == current_name, + "exists": False, + } + ) + return web.json_response({"files": files, "current": current_name}) + + +@routes.get("/api/logs/stream") +async def logs_stream_handler(request: web.Request) -> web.StreamResponse: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + lines = max(1, min(int(request.query.get("lines", "200")), 2000)) + log_type = request.query.get("type", "bot") + if log_type == "all": + return web.json_response( + {"error": "Streaming only supports bot/webui logs"}, status=400 + ) + log_path = _resolve_webui_log_path() if log_type == "webui" else _resolve_log_path() + file_name = request.query.get("file") + if file_name and file_name != log_path.name: + return web.json_response( + {"error": "Streaming only supports current log"}, status=400 + ) + + resp = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + await resp.prepare(request) + last_payload: str | None = None + try: + while True: + if request.transport is None or request.transport.is_closing(): + break + payload = tail_file(log_path, lines) + if payload != last_payload: + data = "\n".join(f"data: {line}" for line in payload.splitlines()) + await resp.write((data + "\n\n").encode("utf-8")) + last_payload = payload + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + except (ConnectionResetError, RuntimeError): + pass + finally: + try: + await resp.write_eof() + except Exception: + pass + return resp diff --git a/src/Undefined/webui/routes/_shared.py b/src/Undefined/webui/routes/_shared.py new file mode 100644 index 0000000..ac6216a --- /dev/null +++ b/src/Undefined/webui/routes/_shared.py @@ -0,0 +1,96 @@ +import ipaddress +import time +from pathlib import Path +from typing import Any, cast + +from aiohttp import web + +from ..core import BotProcessController, SessionStore + +routes = web.RouteTableDef() + +STATIC_DIR = Path(__file__).parent.parent / "static" +TEMPLATE_DIR = Path(__file__).parent.parent / "templates" + +SESSION_COOKIE = "undefined_webui" +TOKEN_COOKIE = "undefined_webui_token" +SESSION_TTL_SECONDS = 8 * 60 * 60 +LOGIN_ATTEMPT_LIMIT = 5 +LOGIN_ATTEMPT_WINDOW = 5 * 60 +LOGIN_BLOCK_SECONDS = 15 * 60 + +_LOGIN_ATTEMPTS: dict[str, list[float]] = {} +_LOGIN_BLOCKED_UNTIL: dict[str, float] = {} + + +def get_bot(request: web.Request) -> BotProcessController: + return cast(BotProcessController, request.app["bot"]) + + +def get_session_store(request: web.Request) -> SessionStore: + return cast(SessionStore, request.app["session_store"]) + + +def get_settings(request: web.Request) -> Any: + return request.app["settings"] + + +def check_auth(request: web.Request) -> bool: + sessions = get_session_store(request) + token = request.cookies.get(SESSION_COOKIE) or request.headers.get("X-Auth-Token") + return sessions.is_valid(token) + + +def _get_client_ip(request: web.Request) -> str: + if request.remote: + return request.remote + peer = request.transport.get_extra_info("peername") if request.transport else None + if isinstance(peer, tuple) and peer: + return str(peer[0]) + return "unknown" + + +def _is_loopback_address(addr: str) -> bool: + try: + return ipaddress.ip_address(addr).is_loopback + except ValueError: + return False + + +def _is_local_request(request: web.Request) -> bool: + return _is_loopback_address(_get_client_ip(request)) + + +def _check_login_rate_limit(client_ip: str) -> tuple[bool, int]: + now = time.time() + blocked_until = _LOGIN_BLOCKED_UNTIL.get(client_ip, 0) + if blocked_until > now: + return False, int(blocked_until - now) + attempts = [ + ts + for ts in _LOGIN_ATTEMPTS.get(client_ip, []) + if now - ts <= LOGIN_ATTEMPT_WINDOW + ] + _LOGIN_ATTEMPTS[client_ip] = attempts + return True, 0 + + +def _record_login_failure(client_ip: str) -> tuple[bool, int]: + now = time.time() + attempts = [ + ts + for ts in _LOGIN_ATTEMPTS.get(client_ip, []) + if now - ts <= LOGIN_ATTEMPT_WINDOW + ] + attempts.append(now) + if len(attempts) >= LOGIN_ATTEMPT_LIMIT: + _LOGIN_ATTEMPTS.pop(client_ip, None) + _LOGIN_BLOCKED_UNTIL[client_ip] = now + LOGIN_BLOCK_SECONDS + return False, LOGIN_BLOCK_SECONDS + _LOGIN_ATTEMPTS[client_ip] = attempts + return True, 0 + + +def _clear_login_failures(client_ip: str) -> None: + _LOGIN_ATTEMPTS.pop(client_ip, None) + _LOGIN_BLOCKED_UNTIL.pop(client_ip, None) diff --git a/src/Undefined/webui/routes/_system.py b/src/Undefined/webui/routes/_system.py new file mode 100644 index 0000000..70cebc7 --- /dev/null +++ b/src/Undefined/webui/routes/_system.py @@ -0,0 +1,138 @@ +import asyncio +import platform +from pathlib import Path + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined import __version__ +from ._shared import routes, check_auth + +try: + import psutil + + _PSUTIL_AVAILABLE = True +except Exception: + psutil = None + _PSUTIL_AVAILABLE = False + +_CPU_PERCENT_PRIMED = False + + +def _clamp_percent(value: float) -> float: + return max(0.0, min(100.0, value)) + + +def _read_cpu_times() -> tuple[int, int] | None: + try: + stat_path = Path("/proc/stat") + if not stat_path.exists(): + return None + first_line = stat_path.read_text(encoding="utf-8").splitlines()[0] + if not first_line.startswith("cpu "): + return None + parts = first_line.split()[1:] + values = [int(p) for p in parts] + if len(values) < 4: + return None + idle = values[3] + (values[4] if len(values) > 4 else 0) + return idle, sum(values) + except Exception: + return None + + +async def _get_cpu_usage_percent() -> float | None: + global _CPU_PERCENT_PRIMED + if _PSUTIL_AVAILABLE and psutil is not None: + try: + usage = psutil.cpu_percent(interval=None) + if not _CPU_PERCENT_PRIMED: + _CPU_PERCENT_PRIMED = True + await asyncio.sleep(0.12) + usage = psutil.cpu_percent(interval=None) + return _clamp_percent(float(usage)) + except Exception: + pass + first = _read_cpu_times() + if not first: + return None + idle_1, total_1 = first + await asyncio.sleep(0.15) + second = _read_cpu_times() + if not second: + return None + idle_2, total_2 = second + total_delta = total_2 - total_1 + if total_delta <= 0: + return None + return _clamp_percent((1 - (idle_2 - idle_1) / total_delta) * 100) + + +def _read_cpu_model() -> str: + model = platform.processor() + if model and model.strip(): + return model.strip() + cpuinfo_path = Path("/proc/cpuinfo") + if cpuinfo_path.exists(): + for line in cpuinfo_path.read_text(encoding="utf-8").splitlines(): + if line.lower().startswith("model name"): + parts = line.split(":", 1) + if len(parts) == 2 and parts[1].strip(): + return parts[1].strip() + return "Unknown" + + +def _read_memory_info() -> tuple[float, float, float] | None: + if _PSUTIL_AVAILABLE and psutil is not None: + try: + mem = psutil.virtual_memory() + return ( + float(mem.total) / 1024**3, + float(mem.used) / 1024**3, + _clamp_percent(float(mem.percent)), + ) + except Exception: + pass + meminfo_path = Path("/proc/meminfo") + if not meminfo_path.exists(): + return None + total_kb = available_kb = None + for line in meminfo_path.read_text(encoding="utf-8").splitlines(): + if line.startswith("MemTotal:"): + total_kb = int(line.split()[1]) + elif line.startswith("MemAvailable:"): + available_kb = int(line.split()[1]) + if total_kb is None or available_kb is None: + return None + used_kb = max(0, total_kb - available_kb) + return ( + total_kb / 1024**2, + used_kb / 1024**2, + (used_kb / total_kb * 100 if total_kb else 0.0), + ) + + +@routes.get("/api/system") +async def system_info_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + cpu_usage = await _get_cpu_usage_percent() + memory_info = _read_memory_info() + payload: dict[str, object] = { + "cpu_model": _read_cpu_model(), + "cpu_usage_percent": None if cpu_usage is None else round(cpu_usage, 1), + "memory_total_gb": None, + "memory_used_gb": None, + "memory_usage_percent": None, + "system_version": platform.platform(), + "system_release": platform.release(), + "system_arch": platform.machine(), + "python_version": platform.python_version(), + "undefined_version": __version__, + } + if memory_info: + total_gb, used_gb, usage = memory_info + payload["memory_total_gb"] = round(total_gb, 2) + payload["memory_used_gb"] = round(used_gb, 2) + payload["memory_usage_percent"] = round(usage, 1) + return web.json_response(payload) diff --git a/src/Undefined/webui/static/css/.stylelintrc.json b/src/Undefined/webui/static/css/.stylelintrc.json new file mode 100644 index 0000000..b7d76c9 --- /dev/null +++ b/src/Undefined/webui/static/css/.stylelintrc.json @@ -0,0 +1,11 @@ +{ + "rules": { + "block-no-empty": true, + "color-no-invalid-hex": true, + "declaration-block-no-duplicate-properties": true, + "no-duplicate-selectors": true, + "no-empty-source": true, + "property-no-unknown": true, + "unit-no-unknown": true + } +} diff --git a/src/Undefined/webui/static/css/app.css b/src/Undefined/webui/static/css/app.css new file mode 100644 index 0000000..ceedbae --- /dev/null +++ b/src/Undefined/webui/static/css/app.css @@ -0,0 +1,88 @@ +.sidebar { + background-color: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + padding: 24px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.brand { font-family: var(--font-serif); font-size: 22px; font-weight: 500; display: flex; align-items: center; gap: 12px; } + +.brand-dot { + width: 10px; height: 10px; + background-color: var(--accent-color); + border-radius: 50%; + box-shadow: 0 0 0 6px rgba(217, 119, 87, 0.15); +} + +.nav-links { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } + +.nav-item { + padding: 12px 16px; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-weight: 500; + cursor: pointer; + transition: 0.2s; + border: 1px solid transparent; + background: transparent; + text-align: left; + font: inherit; +} +.nav-item:hover { background-color: rgba(0, 0, 0, 0.03); color: var(--text-primary); } +.nav-item:focus-visible { outline: 2px solid rgba(217, 119, 87, 0.6); outline-offset: 2px; } +.nav-item.active { background-color: var(--bg-card); color: var(--accent-color); box-shadow: var(--shadow-sm); border: 1px solid var(--border-color); } + +.mobile-nav { + display: none; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0 20px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; + position: sticky; + top: 0; + background: var(--bg-app); + z-index: 120; +} +.mobile-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } +.mobile-brand { display: flex; align-items: center; gap: 10px; font-family: var(--font-serif); font-size: 18px; font-weight: 500; } +.mobile-tabs { display: flex; gap: 8px; flex: 1; overflow-x: auto; padding-bottom: 6px; } +.mobile-tabs .nav-item { border-radius: 999px; padding: 8px 14px; border: 1px solid var(--border-color); background: var(--bg-card); white-space: nowrap; font-size: 12px; } +.mobile-tabs .nav-item.active { color: var(--accent-color); border-color: rgba(217, 119, 87, 0.4); } + +.sidebar-footer { margin-top: auto; display: flex; flex-direction: column; gap: 16px; padding-top: 20px; border-top: 1px solid var(--border-color); } +.sidebar-footer-actions { display: flex; gap: 8px; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +.main-content { padding: 40px 60px; overflow-y: auto; max-width: 1400px; scrollbar-gutter: stable; } + +.header { margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; } +.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.config-toolbar { align-items: center; } +.toolbar-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + +.log-tabs { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } +.log-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); font-size: 12px; cursor: pointer; transition: 0.2s; } +.log-tab:hover { color: var(--text-primary); background: var(--bg-app); } +.log-tab.active { background: var(--accent-subtle); color: var(--accent-color); border-color: rgba(217, 119, 87, 0.35); } + +.search-group { display: flex; align-items: center; gap: 8px; flex: 1; } +.search-group .form-control { flex: 1; min-width: 220px; max-width: 100%; } + +.header.sticky { + position: sticky; top: 0; z-index: 100; + background: var(--bg-app); + padding: 20px 0; + margin: -40px -60px 32px -60px; + padding-left: 60px; padding-right: 60px; + border-bottom: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); +} + +.page-title { font-family: var(--font-serif); font-size: 32px; margin: 0 0 4px 0; font-weight: 500; } +.page-subtitle { color: var(--text-secondary); font-size: 14px; } diff --git a/src/Undefined/webui/static/css/base.css b/src/Undefined/webui/static/css/base.css new file mode 100644 index 0000000..f32df4d --- /dev/null +++ b/src/Undefined/webui/static/css/base.css @@ -0,0 +1,30 @@ +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font-sans); + background-color: var(--bg-app); + color: var(--text-primary); + line-height: 1.5; + transition: background-color 0.3s ease, color 0.3s ease; + min-height: 100vh; +} + +.noise { + position: fixed; + inset: 0; + background-image: url('data:image/svg+xml;utf8,'); + pointer-events: none; + mix-blend-mode: multiply; + opacity: 0.18; + z-index: 9999; +} + +.full-view { min-height: 100vh; display: none; flex-direction: column; } +.full-view.active { display: flex; } + +.app-container { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; +} diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css new file mode 100644 index 0000000..70eef23 --- /dev/null +++ b/src/Undefined/webui/static/css/components.css @@ -0,0 +1,130 @@ +.card { background-color: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 32px; box-shadow: var(--shadow-lg); margin-bottom: 24px; } +.empty-state { border: 1px dashed var(--border-color); border-radius: var(--radius-md); padding: 24px; text-align: center; color: var(--text-secondary); background: var(--bg-card); box-shadow: var(--shadow-sm); margin-bottom: 24px; } +.card-title { font-size: 14px; text-transform: uppercase; letter-spacing: 1.6px; font-weight: 600; color: var(--accent-color); margin-bottom: 16px; } +.panel { background-color: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px 20px; margin-bottom: 12px; box-shadow: var(--shadow-sm); transition: transform 0.2s ease, box-shadow 0.2s ease; } +.panel:hover { box-shadow: var(--shadow-md); } +.panel-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } + +.badge { padding: 4px 10px; border-radius: 99px; font-size: 12px; font-weight: 600; background: var(--bg-app); color: var(--text-secondary); border: 1px solid var(--border-color); } +.badge.warn { background: var(--accent-subtle); color: var(--accent-color); } +.badge.success { background: rgba(74, 124, 89, 0.12); color: var(--success); border: 1px solid rgba(74, 124, 89, 0.3); } +.badge.error { background: rgba(201, 64, 64, 0.12); color: var(--error); border: 1px solid rgba(201, 64, 64, 0.3); } + +/* Forms */ +.form-grid { column-gap: 28px; column-width: 360px; } +.config-card { display: inline-block; width: 100%; margin-bottom: 28px; break-inside: avoid; } +.config-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.config-card-actions { display: flex; align-items: center; gap: 8px; } +.form-section-hint { margin: -6px 0 16px; font-size: 12px; color: var(--text-secondary); line-height: 1.6; } +.form-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px 20px; } +.config-card.is-collapsed .form-fields, +.config-card.is-collapsed .form-section-hint, +.config-card.is-collapsed .form-subsection { display: none; } +.config-card.is-collapsed { padding: 16px 20px; } +.config-card.is-collapsed .config-card-header { margin-bottom: 0; align-items: center; } +.config-card.is-collapsed .form-section-title { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; line-height: 1.2; } +.config-card.is-hidden { display: none; } +.config-card.force-open .form-fields { display: grid; } +.config-card.force-open .form-section-hint, +.config-card.force-open .form-subsection { display: block; } +.form-subsection { grid-column: 1 / -1; padding-top: 12px; margin-top: 8px; border-top: 1px dashed var(--border-color); } +.form-subsection .form-fields { margin-top: 12px; } +.form-subtitle { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--text-tertiary); } +.form-subtitle-hint { margin-top: 6px; font-size: 12px; color: var(--text-secondary); } +.form-section-title { font-size: 14px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; margin-bottom: 20px; color: var(--accent-color); border-bottom: 1px solid var(--accent-subtle); padding-bottom: 8px; } +.form-group { margin-bottom: 18px; } +.form-group.is-hidden { display: none; } +.form-group.is-match { border: 1px solid rgba(217, 119, 87, 0.4); border-radius: var(--radius-sm); padding: 10px; background: rgba(217, 119, 87, 0.05); } +.form-label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; text-transform: capitalize; } +.form-hint { font-size: 12px; color: var(--text-tertiary); margin-bottom: 8px; } +.form-control { width: 100%; padding: 10px 14px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-input); color: var(--text-primary); font-size: 14px; outline: none; } +.form-control-sm { padding: 8px 12px; font-size: 12px; } +.form-textarea { min-height: 120px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; } +.form-control:focus { border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1); } + +/* Buttons */ +.btn { padding: 10px 20px; border-radius: 99px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.2s; position: relative; } +.btn:hover { background: var(--bg-app); } +.btn:focus-visible { outline: 2px solid rgba(217, 119, 87, 0.6); outline-offset: 2px; } +.btn:disabled, .btn.is-loading { opacity: 0.6; cursor: not-allowed; } +.btn.is-loading::after { content: ""; width: 12px; height: 12px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; display: inline-block; margin-left: 8px; animation: spin 0.8s linear infinite; } +.btn.primary { background: var(--accent-color); color: white; border-color: transparent; } +.btn.primary:hover { background: var(--accent-hover); } +.btn.ghost { background: transparent; border-color: var(--border-color); color: var(--text-secondary); } +.btn.ghost:hover { background: var(--bg-sidebar); color: var(--text-primary); } +.btn-sm { padding: 6px 14px; font-size: 12px; } +.btn.danger { color: var(--error); border-color: rgba(201, 64, 64, 0.2); } +.btn.danger:hover { background: rgba(201, 64, 64, 0.05); } +.danger-link { color: var(--error); background: transparent; border: none; text-align: left; padding: 0; font-size: 14px; } + +.save-status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-tertiary); transition: opacity 0.3s ease; } +.save-status.active { color: var(--accent-color); } +.dot-pulse { position: relative; width: 8px; height: 8px; border-radius: 50%; background-color: var(--accent-color); color: var(--accent-color); animation: dotPulse 1.5s infinite linear; } + +/* Status */ +.status-msg { font-size: 13px; margin-bottom: 12px; } +.warning-banner { background: var(--accent-subtle); color: var(--accent-color); padding: 12px 20px; border-radius: var(--radius-md); margin-bottom: 24px; border: 1px solid rgba(217, 119, 87, 0.2); font-size: 13px; } + +/* Overview */ +.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; } +.stat-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0; } +.stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1.4px; color: var(--text-tertiary); } +.stat-value { font-size: 14px; font-weight: 600; color: var(--text-primary); text-align: right; } +.metric-block { margin-bottom: 18px; } +.progress { height: 8px; border-radius: 999px; background: var(--bg-deep); border: 1px solid var(--border-color); overflow: hidden; } +.progress-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-color), var(--accent-hover)); transition: width 0.4s ease; } + +/* Log Viewer */ +.log-viewer { background: #1e1e1a; color: #e0dbd1; font-family: var(--font-mono); font-size: 13px; padding: 24px; border-radius: var(--radius-md); height: 600px; overflow-y: auto; white-space: pre-wrap; border: 1px solid var(--border-color); } +#logLevelFilter { min-width: 120px; max-width: 100%; } +#logFileSelect { min-width: 160px; max-width: 100%; } +#logSearchInput { min-width: 180px; max-width: 100%; } +#btnJumpLogs { visibility: hidden; pointer-events: none; } +.log-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 10px; min-height: 18px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.log-meta:empty { visibility: hidden; } +.log-highlight { background: rgba(217, 119, 87, 0.2); color: #fff3ea; padding: 0 2px; border-radius: 4px; } +.log-timestamp { color: #f1c391; } +.ansi-red { color: #e06c75; } +.ansi-green { color: #98c379; } +.ansi-yellow { color: #e5c07b; } +.ansi-blue { color: #61afef; } +.ansi-magenta { color: #c678dd; } +.ansi-cyan { color: #56b6c2; } + +/* Toggle */ +.toggle-wrapper { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.toggle-input { display: none; } +.toggle-track { width: 40px; height: 22px; background: var(--border-color); border-radius: 99px; position: relative; transition: 0.2s; } +.toggle-handle { width: 18px; height: 18px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } +.toggle-input:checked + .toggle-track { background: var(--accent-color); } +.toggle-input:checked + .toggle-track .toggle-handle { transform: translateX(18px); } + +/* Utility */ +.w-full { width: 100%; } +.muted { color: var(--text-secondary); } +.muted-sm { color: var(--text-tertiary); font-size: 12px; } + +/* Toast */ +.toast-container { position: fixed; top: 24px; right: 24px; z-index: 10000; display: flex; flex-direction: column; gap: 12px; pointer-events: none; } +.toast { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px 20px; box-shadow: var(--shadow-lg); min-width: 300px; max-width: 400px; pointer-events: auto; animation: slideIn 0.3s ease; display: flex; align-items: center; gap: 12px; font-size: 14px; } +.toast.success { border-left: 4px solid var(--success); } +.toast.error { border-left: 4px solid var(--error); } +.toast.warning { border-left: 4px solid var(--warning); } +.toast.info { border-left: 4px solid var(--accent-color); } +.toast.removing { animation: slideOut 0.3s ease forwards; } + +/* Animations */ +@keyframes dotPulse { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.5; } + 100% { transform: scale(1); opacity: 1; } +} +@keyframes slideIn { + from { transform: translateX(400px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(400px); opacity: 0; } +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/src/Undefined/webui/static/css/landing.css b/src/Undefined/webui/static/css/landing.css new file mode 100644 index 0000000..f59d305 --- /dev/null +++ b/src/Undefined/webui/static/css/landing.css @@ -0,0 +1,37 @@ +.landing-header { + padding: 26px 60px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.landing-hero { + padding: 0 60px 60px; + display: grid; + grid-template-columns: 1.4fr 0.8fr; + gap: 40px; + align-items: start; +} + +.hero-content { padding: 40px; } + +.eyebrow { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--text-tertiary); + margin-bottom: 8px; +} + +.hero-content h1 { + font-family: var(--font-serif); + font-size: 48px; + margin: 12px 0; + font-weight: 500; +} + +.tagline { font-size: 16px; line-height: 1.6; margin-bottom: 12px; } + +.cta-row { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; } + +.header-actions { display: flex; align-items: center; gap: 12px; } diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css new file mode 100644 index 0000000..a0ddf4a --- /dev/null +++ b/src/Undefined/webui/static/css/responsive.css @@ -0,0 +1,31 @@ +@media (max-width: 1100px) { + .landing-hero { grid-template-columns: 1fr; } +} + +@media (max-width: 960px) { + .main-content { padding: 30px; } + .header { flex-direction: column; align-items: flex-start; gap: 16px; } + .header.sticky { margin: -30px -30px 24px -30px; padding-left: 30px; padding-right: 30px; } +} + +@media (max-width: 768px) { + .app-container { grid-template-columns: 1fr; } + .sidebar { display: none; } + .mobile-nav { display: flex; } + .landing-header, .landing-hero, .main-content { padding: 20px; } + .header.sticky { margin: -20px -20px 20px -20px; padding-left: 20px; padding-right: 20px; } + .card { padding: 22px; } + .form-grid { column-count: 1; column-width: auto; } + .form-fields { grid-template-columns: 1fr; } + .log-viewer { height: 420px; } + .noise { opacity: 0.08; } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/Undefined/webui/static/css/style.css b/src/Undefined/webui/static/css/style.css index a90e601..baaff6d 100644 --- a/src/Undefined/webui/static/css/style.css +++ b/src/Undefined/webui/static/css/style.css @@ -1,1128 +1,6 @@ -/* Anthropic / Premium Warm Style Variables */ -:root { - /* Paper/Warm Foundation */ - --bg-app: #f9f5f1; - --bg-sidebar: #f0ebe4; - --bg-card: #ffffff; - --bg-input: #ffffff; - --bg-deep: #efe6d8; - --bg-glow: #fff7ec; - - /* Text Colors */ - --text-primary: #3d3935; - --text-secondary: #6e675f; - --text-tertiary: #9e968c; - - /* Accents */ - --accent-color: #d97757; - --accent-hover: #c56545; - --accent-subtle: #fbeee9; - - /* Borders & Shadows */ - --border-color: #e6e0d8; - --border-focus: #d97757; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -1px rgba(0, 0, 0, 0.03); - --shadow-lg: 0 24px 60px rgba(31, 27, 22, 0.12); - - /* Status Colors */ - --success: #4a7c59; - --warning: #cc8925; - --error: #c94040; - - /* Typography */ - --font-serif: "IBM Plex Serif", "Times New Roman", serif; - --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-mono: "IBM Plex Mono", "Menlo", monospace; - - /* Dimensions */ - --sidebar-width: 240px; - --radius-sm: 8px; - --radius-md: 14px; - --radius-lg: 24px; -} - -[data-theme="dark"] { - --bg-app: #0f1112; - --bg-sidebar: #161b1f; - --bg-card: #171c1f; - --bg-input: #12171a; - --bg-deep: #14181a; - --bg-glow: #1b2125; - - --text-primary: #f4efe7; - --text-secondary: #b2a79b; - --text-tertiary: #6d6256; - - --border-color: #2b3439; - --accent-subtle: #2d1f1c; - --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.45); -} - -/* Reset & Base */ -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: var(--font-sans); - background-color: var(--bg-app); - color: var(--text-primary); - line-height: 1.5; - transition: background-color 0.3s ease, color 0.3s ease; - min-height: 100vh; -} - -.noise { - position: fixed; - inset: 0; - background-image: url('data:image/svg+xml;utf8,'); - pointer-events: none; - mix-blend-mode: multiply; - opacity: 0.18; - z-index: 9999; -} - -/* Layouts */ -.full-view { - min-height: 100vh; - display: none; - flex-direction: column; -} - -.full-view.active { - display: flex; -} - -.app-container { - display: grid; - grid-template-columns: var(--sidebar-width) 1fr; - min-height: 100vh; -} - -/* Landing Section */ -.landing-header { - padding: 26px 60px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.landing-hero { - padding: 0 60px 60px; - display: grid; - grid-template-columns: 1.4fr 0.8fr; - gap: 40px; - align-items: start; -} - -.hero-content { - padding: 40px; -} - -.eyebrow { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 2px; - color: var(--text-tertiary); - margin-bottom: 8px; -} - -.hero-content h1 { - font-family: var(--font-serif); - font-size: 48px; - margin: 12px 0; - font-weight: 500; -} - -.tagline { - font-size: 16px; - line-height: 1.6; - margin-bottom: 12px; -} - -.cta-row { - display: flex; - gap: 12px; - margin-top: 32px; - flex-wrap: wrap; -} - -.header-actions { - display: flex; - align-items: center; - gap: 12px; -} - -/* Sidebar (App) */ -.sidebar { - background-color: var(--bg-sidebar); - border-right: 1px solid var(--border-color); - padding: 24px; - display: flex; - flex-direction: column; - gap: 32px; -} - -.brand { - font-family: var(--font-serif); - font-size: 22px; - font-weight: 500; - display: flex; - align-items: center; - gap: 12px; -} - -.brand-dot { - width: 10px; - height: 10px; - background-color: var(--accent-color); - border-radius: 50%; - box-shadow: 0 0 0 6px rgba(217, 119, 87, 0.15); -} - -.nav-links { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.nav-item { - padding: 12px 16px; - border-radius: var(--radius-md); - color: var(--text-secondary); - font-weight: 500; - cursor: pointer; - transition: 0.2s; - border: 1px solid transparent; - background: transparent; - text-align: left; - font: inherit; -} - -.nav-item:hover { - background-color: rgba(0, 0, 0, 0.03); - color: var(--text-primary); -} - -.nav-item:focus-visible { - outline: 2px solid rgba(217, 119, 87, 0.6); - outline-offset: 2px; -} - -.nav-item.active { - background-color: var(--bg-card); - color: var(--accent-color); - box-shadow: var(--shadow-sm); - border: 1px solid var(--border-color); -} - -.mobile-nav { - display: none; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 0 20px; - border-bottom: 1px solid var(--border-color); - margin-bottom: 20px; - position: sticky; - top: 0; - background: var(--bg-app); - z-index: 120; -} - -.mobile-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.mobile-brand { - display: flex; - align-items: center; - gap: 10px; - font-family: var(--font-serif); - font-size: 18px; - font-weight: 500; -} - -.mobile-tabs { - display: flex; - gap: 8px; - flex: 1; - overflow-x: auto; - padding-bottom: 6px; -} - -.mobile-tabs .nav-item { - border-radius: 999px; - padding: 8px 14px; - border: 1px solid var(--border-color); - background: var(--bg-card); - white-space: nowrap; - font-size: 12px; -} - -.mobile-tabs .nav-item.active { - color: var(--accent-color); - border-color: rgba(217, 119, 87, 0.4); -} - -.sidebar-footer { - margin-top: auto; - display: flex; - flex-direction: column; - gap: 16px; - padding-top: 20px; - border-top: 1px solid var(--border-color); -} - -.sidebar-footer-actions { - display: flex; - gap: 8px; -} - -.tab-content { - display: none; -} - -.tab-content.active { - display: block; -} - -/* Main Content */ -.main-content { - padding: 40px 60px; - overflow-y: auto; - max-width: 1400px; - scrollbar-gutter: stable; -} - -.header { - margin-bottom: 32px; - display: flex; - justify-content: space-between; - align-items: flex-end; -} - -.toolbar { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.config-toolbar { - align-items: center; -} - -.toolbar-group { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.log-tabs { - display: flex; - gap: 8px; - margin-top: 12px; - flex-wrap: wrap; -} - -.log-tab { - padding: 6px 12px; - border-radius: 999px; - border: 1px solid var(--border-color); - background: var(--bg-card); - color: var(--text-secondary); - font-size: 12px; - cursor: pointer; - transition: 0.2s; -} - -.log-tab:hover { - color: var(--text-primary); - background: var(--bg-app); -} - -.log-tab.active { - background: var(--accent-subtle); - color: var(--accent-color); - border-color: rgba(217, 119, 87, 0.35); -} - -.search-group { - display: flex; - align-items: center; - gap: 8px; - flex: 1; -} - -.search-group .form-control { - flex: 1; - min-width: 220px; - max-width: 100%; -} - -.header.sticky { - position: sticky; - top: 0; - z-index: 100; - background: var(--bg-app); - padding: 20px 0; - margin: -40px -60px 32px -60px; - padding-left: 60px; - padding-right: 60px; - border-bottom: 1px solid var(--border-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); -} - -.page-title { - font-family: var(--font-serif); - font-size: 32px; - margin: 0 0 4px 0; - font-weight: 500; -} - -.page-subtitle { - color: var(--text-secondary); - font-size: 14px; -} - -/* Components */ -.card { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 32px; - box-shadow: var(--shadow-lg); - margin-bottom: 24px; -} - -.empty-state { - border: 1px dashed var(--border-color); - border-radius: var(--radius-md); - padding: 24px; - text-align: center; - color: var(--text-secondary); - background: var(--bg-card); - box-shadow: var(--shadow-sm); - margin-bottom: 24px; -} - -.card-title { - font-size: 14px; - text-transform: uppercase; - letter-spacing: 1.6px; - font-weight: 600; - color: var(--accent-color); - margin-bottom: 16px; -} - -.panel { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px 20px; - margin-bottom: 12px; - box-shadow: var(--shadow-sm); - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.panel:hover { - box-shadow: var(--shadow-md); -} - -.panel-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.badge { - padding: 4px 10px; - border-radius: 99px; - font-size: 12px; - font-weight: 600; - background: var(--bg-app); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.badge.warn { - background: var(--accent-subtle); - color: var(--accent-color); -} - -.badge.success { - background: rgba(74, 124, 89, 0.12); - color: var(--success); - border: 1px solid rgba(74, 124, 89, 0.3); -} - -.badge.error { - background: rgba(201, 64, 64, 0.12); - color: var(--error); - border: 1px solid rgba(201, 64, 64, 0.3); -} - -/* Form Styles */ -.form-grid { - column-gap: 28px; - column-width: 360px; -} - -.config-card { - display: inline-block; - width: 100%; - margin-bottom: 28px; - break-inside: avoid; -} - -.config-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.config-card-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.form-section-hint { - margin: -6px 0 16px; - font-size: 12px; - color: var(--text-secondary); - line-height: 1.6; -} - -.form-fields { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px 20px; -} - -.config-card.is-collapsed .form-fields, -.config-card.is-collapsed .form-section-hint, -.config-card.is-collapsed .form-subsection { - display: none; -} - -.config-card.is-collapsed { - padding: 16px 20px; -} - -.config-card.is-collapsed .config-card-header { - margin-bottom: 0; - align-items: center; -} - -.config-card.is-collapsed .form-section-title { - margin-bottom: 0; - padding-bottom: 0; - border-bottom: 0; - line-height: 1.2; -} - -.config-card.is-hidden { - display: none; -} - -.config-card.force-open .form-fields { - display: grid; -} - -.config-card.force-open .form-section-hint, -.config-card.force-open .form-subsection { - display: block; -} - -.form-subsection { - grid-column: 1 / -1; - padding-top: 12px; - margin-top: 8px; - border-top: 1px dashed var(--border-color); -} - -.form-subsection .form-fields { - margin-top: 12px; -} - -.form-subtitle { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1.5px; - color: var(--text-tertiary); -} - -.form-subtitle-hint { - margin-top: 6px; - font-size: 12px; - color: var(--text-secondary); -} - -.form-section-title { - font-size: 14px; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: 600; - margin-bottom: 20px; - color: var(--accent-color); - border-bottom: 1px solid var(--accent-subtle); - padding-bottom: 8px; -} - -.form-group { - margin-bottom: 18px; -} - -.form-group.is-hidden { - display: none; -} - -.form-group.is-match { - border: 1px solid rgba(217, 119, 87, 0.4); - border-radius: var(--radius-sm); - padding: 10px; - background: rgba(217, 119, 87, 0.05); -} - -.form-label { - display: block; - font-size: 12px; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 8px; - text-transform: capitalize; -} - -.form-hint { - font-size: 12px; - color: var(--text-tertiary); - margin-bottom: 8px; -} - -.form-control { - width: 100%; - padding: 10px 14px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-color); - background: var(--bg-input); - color: var(--text-primary); - font-size: 14px; - outline: none; -} - -.form-control-sm { - padding: 8px 12px; - font-size: 12px; -} - -.form-textarea { - min-height: 120px; - resize: vertical; - font-family: var(--font-mono); - line-height: 1.6; -} - -.form-control:focus { - border-color: var(--accent-color); - box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1); -} - -/* Buttons */ -.btn { - padding: 10px 20px; - border-radius: 99px; - border: 1px solid var(--border-color); - background: var(--bg-card); - color: var(--text-primary); - font-weight: 600; - font-size: 14px; - cursor: pointer; - transition: 0.2s; - position: relative; -} - -.btn:hover { - background: var(--bg-app); -} - -.btn:focus-visible { - outline: 2px solid rgba(217, 119, 87, 0.6); - outline-offset: 2px; -} - -.btn:disabled, -.btn.is-loading { - opacity: 0.6; - cursor: not-allowed; -} - -.btn.is-loading::after { - content: ""; - width: 12px; - height: 12px; - border-radius: 50%; - border: 2px solid currentColor; - border-top-color: transparent; - display: inline-block; - margin-left: 8px; - animation: spin 0.8s linear infinite; -} - -.btn.primary { - background: var(--accent-color); - color: white; - border-color: transparent; -} - -.btn.primary:hover { - background: var(--accent-hover); -} - -.btn.ghost { - background: transparent; - border-color: var(--border-color); - color: var(--text-secondary); -} - -.btn.ghost:hover { - background: var(--bg-sidebar); - color: var(--text-primary); -} - -.save-status { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--text-tertiary); - transition: opacity 0.3s ease; -} - -.save-status.active { - color: var(--accent-color); -} - -.dot-pulse { - position: relative; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--accent-color); - color: var(--accent-color); - animation: dotPulse 1.5s infinite linear; -} - -@keyframes dotPulse { - 0% { - transform: scale(1); - opacity: 1; - } - - 50% { - transform: scale(1.5); - opacity: 0.5; - } - - 100% { - transform: scale(1); - opacity: 1; - } -} - -.btn-sm { - padding: 6px 14px; - font-size: 12px; -} - -.btn.danger { - color: var(--error); - border-color: rgba(201, 64, 64, 0.2); -} - -.btn.danger:hover { - background: rgba(201, 64, 64, 0.05); -} - -.danger-link { - color: var(--error); - background: transparent; - border: none; - text-align: left; - padding: 0; - font-size: 14px; -} - -/* Status & Messages */ -.status-msg { - font-size: 13px; - margin-bottom: 12px; -} - -.warning-banner { - background: var(--accent-subtle); - color: var(--accent-color); - padding: 12px 20px; - border-radius: var(--radius-md); - margin-bottom: 24px; - border: 1px solid rgba(217, 119, 87, 0.2); - font-size: 13px; -} - -.overview-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 24px; -} - -.stat-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 8px 0; -} - -.stat-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1.4px; - color: var(--text-tertiary); -} - -.stat-value { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - text-align: right; -} - -.metric-block { - margin-bottom: 18px; -} - -.progress { - height: 8px; - border-radius: 999px; - background: var(--bg-deep); - border: 1px solid var(--border-color); - overflow: hidden; -} - -.progress-bar { - height: 100%; - width: 0; - background: linear-gradient(90deg, var(--accent-color), var(--accent-hover)); - transition: width 0.4s ease; -} - -/* Log Viewer */ -.log-viewer { - background: var(--bg-deep); - /* Warm dark for logs too? Or just pitch black? */ - background: #1e1e1a; - color: #e0dbd1; - font-family: var(--font-mono); - font-size: 13px; - padding: 24px; - border-radius: var(--radius-md); - height: 600px; - overflow-y: auto; - white-space: pre-wrap; - border: 1px solid var(--border-color); -} - -#logLevelFilter { - min-width: 120px; - max-width: 100%; -} - -#logFileSelect { - min-width: 160px; - max-width: 100%; -} - -#logSearchInput { - min-width: 180px; - max-width: 100%; -} - -#btnJumpLogs { - visibility: hidden; - pointer-events: none; -} - -.log-meta { - font-size: 12px; - color: var(--text-tertiary); - margin-bottom: 10px; - min-height: 18px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.log-meta:empty { - visibility: hidden; -} - -.log-highlight { - background: rgba(217, 119, 87, 0.2); - color: #fff3ea; - padding: 0 2px; - border-radius: 4px; -} - -.log-timestamp { - color: #f1c391; -} - -.ansi-red { - color: #e06c75; -} - -.ansi-green { - color: #98c379; -} - -.ansi-yellow { - color: #e5c07b; -} - -.ansi-blue { - color: #61afef; -} - -.ansi-magenta { - color: #c678dd; -} - -.ansi-cyan { - color: #56b6c2; -} - -/* Toggle Switch */ -.toggle-wrapper { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; -} - -.toggle-input { - display: none; -} - -.toggle-track { - width: 40px; - height: 22px; - background: var(--border-color); - border-radius: 99px; - position: relative; - transition: 0.2s; -} - -.toggle-handle { - width: 18px; - height: 18px; - background: white; - border-radius: 50%; - position: absolute; - top: 2px; - left: 2px; - transition: 0.2s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.toggle-input:checked+.toggle-track { - background: var(--accent-color); -} - -.toggle-input:checked+.toggle-track .toggle-handle { - transform: translateX(18px); -} - -/* Utility */ -.w-full { - width: 100%; -} - -.muted { - color: var(--text-secondary); -} - -.muted-sm { - color: var(--text-tertiary); - font-size: 12px; -} - -/* Toast Notifications */ -.toast-container { - position: fixed; - top: 24px; - right: 24px; - z-index: 10000; - display: flex; - flex-direction: column; - gap: 12px; - pointer-events: none; -} - -.toast { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px 20px; - box-shadow: var(--shadow-lg); - min-width: 300px; - max-width: 400px; - pointer-events: auto; - animation: slideIn 0.3s ease; - display: flex; - align-items: center; - gap: 12px; - font-size: 14px; -} - -.toast.success { - border-left: 4px solid var(--success); -} - -.toast.error { - border-left: 4px solid var(--error); -} - -.toast.warning { - border-left: 4px solid var(--warning); -} - -.toast.info { - border-left: 4px solid var(--accent-color); -} - -@keyframes slideIn { - from { - transform: translateX(400px); - opacity: 0; - } - - to { - transform: translateX(0); - opacity: 1; - } -} - -@keyframes slideOut { - from { - transform: translateX(0); - opacity: 1; - } - - to { - transform: translateX(400px); - opacity: 0; - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.toast.removing { - animation: slideOut 0.3s ease forwards; -} - -@media (max-width: 1100px) { - .landing-hero { - grid-template-columns: 1fr; - } -} - -@media (max-width: 960px) { - .main-content { - padding: 30px; - } - - .header { - flex-direction: column; - align-items: flex-start; - gap: 16px; - } - - .header.sticky { - margin: -30px -30px 24px -30px; - padding-left: 30px; - padding-right: 30px; - } -} - -@media (max-width: 768px) { - .app-container { - grid-template-columns: 1fr; - } - - .sidebar { - display: none; - } - - .mobile-nav { - display: flex; - } - - .landing-header, - .landing-hero, - .main-content { - padding: 20px; - } - - .header.sticky { - margin: -20px -20px 20px -20px; - padding-left: 20px; - padding-right: 20px; - } - - .card { - padding: 22px; - } - - .form-grid { - column-count: 1; - column-width: auto; - } - - .form-fields { - grid-template-columns: 1fr; - } - - .log-viewer { - height: 420px; - } - - .noise { - opacity: 0.08; - } -} - -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} +@import url("variables.css"); +@import url("base.css"); +@import url("landing.css"); +@import url("app.css"); +@import url("components.css"); +@import url("responsive.css"); diff --git a/src/Undefined/webui/static/css/variables.css b/src/Undefined/webui/static/css/variables.css new file mode 100644 index 0000000..1e1802c --- /dev/null +++ b/src/Undefined/webui/static/css/variables.css @@ -0,0 +1,52 @@ +:root { + --bg-app: #f9f5f1; + --bg-sidebar: #f0ebe4; + --bg-card: #ffffff; + --bg-input: #ffffff; + --bg-deep: #efe6d8; + --bg-glow: #fff7ec; + + --text-primary: #3d3935; + --text-secondary: #6e675f; + --text-tertiary: #9e968c; + + --accent-color: #d97757; + --accent-hover: #c56545; + --accent-subtle: #fbeee9; + + --border-color: #e6e0d8; + --border-focus: #d97757; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --shadow-lg: 0 24px 60px rgba(31, 27, 22, 0.12); + + --success: #4a7c59; + --warning: #cc8925; + --error: #c94040; + + --font-serif: "IBM Plex Serif", "Times New Roman", serif; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "IBM Plex Mono", "Menlo", monospace; + + --sidebar-width: 240px; + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 24px; +} + +[data-theme="dark"] { + --bg-app: #0f1112; + --bg-sidebar: #161b1f; + --bg-card: #171c1f; + --bg-input: #12171a; + --bg-deep: #14181a; + --bg-glow: #1b2125; + + --text-primary: #f4efe7; + --text-secondary: #b2a79b; + --text-tertiary: #6d6256; + + --border-color: #2b3439; + --accent-subtle: #2d1f1c; + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.45); +} diff --git a/src/Undefined/webui/static/js/api.js b/src/Undefined/webui/static/js/api.js new file mode 100644 index 0000000..f9ab282 --- /dev/null +++ b/src/Undefined/webui/static/js/api.js @@ -0,0 +1,72 @@ +async function api(path, options = {}) { + const headers = options.headers || {}; + if (options.method === "POST" && options.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + const res = await fetch(path, { + ...options, + headers, + credentials: options.credentials || "same-origin", + }); + if (res.status === 401) { + state.authenticated = false; + refreshUI(); + throw new Error("Unauthorized"); + } + return res; +} + +function shouldFetch(kind) { + return Date.now() >= (state.nextFetchAt[kind] || 0); +} + +function recordFetchError(kind) { + const current = state.fetchBackoff[kind] || 0; + const next = Math.min(5, current + 1); + state.fetchBackoff[kind] = next; + state.nextFetchAt[kind] = Date.now() + Math.min(15000, 1000 * 2 ** next); +} + +function recordFetchSuccess(kind) { + state.fetchBackoff[kind] = 0; + state.nextFetchAt[kind] = 0; +} + +function startStatusTimer() { + if (!state.statusTimer) { + state.statusTimer = setInterval(fetchStatus, REFRESH_INTERVALS.status); + } +} + +function stopStatusTimer() { + if (state.statusTimer) { + clearInterval(state.statusTimer); + state.statusTimer = null; + } +} + +function startSystemTimer() { + if (!state.systemTimer) { + state.systemTimer = setInterval(fetchSystemInfo, REFRESH_INTERVALS.system); + } +} + +function stopSystemTimer() { + if (state.systemTimer) { + clearInterval(state.systemTimer); + state.systemTimer = null; + } +} + +function startLogTimer() { + if (!state.logTimer) { + state.logTimer = setInterval(fetchLogs, REFRESH_INTERVALS.logs); + } +} + +function stopLogTimer() { + if (state.logTimer) { + clearInterval(state.logTimer); + state.logTimer = null; + } +} diff --git a/src/Undefined/webui/static/js/auth.js b/src/Undefined/webui/static/js/auth.js new file mode 100644 index 0000000..a47a3ff --- /dev/null +++ b/src/Undefined/webui/static/js/auth.js @@ -0,0 +1,91 @@ +async function login(pwd, statusId, buttonId) { + const s = get(statusId); + const button = buttonId ? get(buttonId) : null; + s.innerText = t("auth.signing_in"); + setButtonLoading(button, true); + try { + const res = await api("/api/login", { + method: "POST", + body: JSON.stringify({ password: pwd }) + }); + const data = await res.json(); + if (data.success) { + state.authenticated = true; + await checkSession(); + refreshUI(); + s.innerText = ""; + } else { + if (data.code === "default_password") { + s.innerText = t("auth.change_required"); + showToast(t("auth.change_required"), "warning", 5000); + } else { + s.innerText = data.error || t("auth.login_failed"); + } + } + } catch (e) { + s.innerText = e.message || t("auth.login_failed"); + } finally { + setButtonLoading(button, false); + } +} + +async function changePassword(currentId, newId, statusId, buttonId) { + const statusEl = get(statusId); + const button = buttonId ? get(buttonId) : null; + const currentEl = get(currentId); + const newEl = get(newId); + const currentPassword = currentEl ? currentEl.value.trim() : ""; + const newPassword = newEl ? newEl.value.trim() : ""; + + if (!currentPassword || !newPassword || currentPassword === newPassword) { + if (statusEl) statusEl.innerText = t("auth.password_update_failed"); + return; + } + + if (statusEl) statusEl.innerText = t("common.loading"); + setButtonLoading(button, true); + try { + const res = await api("/api/password", { + method: "POST", + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) + }); + const data = await res.json(); + if (data.success) { + if (statusEl) statusEl.innerText = t("auth.password_updated_login"); + showToast(t("auth.password_updated_login"), "success", 4000); + if (currentEl) currentEl.value = ""; + if (newEl) newEl.value = ""; + state.usingDefaultPassword = false; + await login(newPassword, statusId, buttonId); + } else { + const msg = data.code === "local_required" + ? t("auth.password_change_local") + : (data.error || t("auth.password_update_failed")); + if (statusEl) statusEl.innerText = msg; + showToast(msg, "error", 5000); + } + } catch (e) { + const msg = e.message || t("auth.password_update_failed"); + if (statusEl) statusEl.innerText = msg; + showToast(`${t("auth.password_update_failed")}: ${msg}`, "error", 5000); + } finally { + setButtonLoading(button, false); + } +} + +async function checkSession() { + try { + const res = await api("/api/session"); + const data = await res.json(); + state.authenticated = data.authenticated; + state.usingDefaultPassword = !!data.using_default_password; + const warning = get("warningBox"); + if (warning) warning.style.display = data.using_default_password ? "block" : "none"; + const navFooter = get("navFooter"); + if (navFooter) navFooter.innerText = data.summary || ""; + updateAuthPanels(); + return data; + } catch (e) { + return { authenticated: false }; + } +} diff --git a/src/Undefined/webui/static/js/bot.js b/src/Undefined/webui/static/js/bot.js new file mode 100644 index 0000000..23f487f --- /dev/null +++ b/src/Undefined/webui/static/js/bot.js @@ -0,0 +1,114 @@ +async function fetchStatus() { + if (!shouldFetch("status")) return; + try { + const res = await api("/api/status"); + const data = await res.json(); + state.bot = data; + recordFetchSuccess("status"); + updateBotUI(); + } catch (e) { + recordFetchError("status"); + } +} + +function updateBotUI() { + const badge = get("botStateBadge"); + const metaL = get("botStatusMetaLanding"); + const hintL = get("botHintLanding"); + + if (state.bot.running) { + badge.innerText = t("bot.status.running"); + badge.className = "badge success"; + const pidText = state.bot.pid != null ? `PID: ${state.bot.pid}` : "PID: --"; + const uptimeText = state.bot.uptime_seconds != null ? `Uptime: ${Math.round(state.bot.uptime_seconds)}s` : ""; + const parts = [pidText, uptimeText].filter(Boolean); + metaL.innerText = parts.length ? parts.join(" | ") : "--"; + hintL.innerText = t("bot.hint.running"); + get("botStartBtnLanding").disabled = true; + get("botStopBtnLanding").disabled = false; + } else { + badge.innerText = t("bot.status.stopped"); + badge.className = "badge"; + metaL.innerText = "--"; + hintL.innerText = t("bot.hint.stopped"); + get("botStartBtnLanding").disabled = false; + get("botStopBtnLanding").disabled = true; + } +} + +async function botAction(action) { + try { + await api(`/api/bot/${action}`, { method: "POST" }); + await fetchStatus(); + } catch (e) { } +} + +function startWebuiRestartPoll() { + let attempts = 0; + const timer = setInterval(async () => { + attempts += 1; + try { + const res = await fetch("/api/session", { credentials: "same-origin" }); + if (res.ok) { clearInterval(timer); location.reload(); } + } catch (e) { } + if (attempts > 60) clearInterval(timer); + }, 1000); +} + +async function updateAndRestartWebui(button) { + if (!state.authenticated) { + showToast(t("auth.unauthorized"), "error", 5000); + return; + } + setButtonLoading(button, true); + try { + showToast(t("update.working"), "info", 4000); + const res = await api("/api/update-restart", { method: "POST" }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || t("update.failed")); + if (!data.eligible) { + showToast(`${t("update.not_eligible")}: ${data.reason || ""}`.trim(), "warning", 7000); + return; + } + if (data.will_restart === false) { + if (data.output) console.log(data.output); + showToast(t("update.no_restart"), "warning", 8000); + return; + } + showToast(data.updated ? t("update.updated_restarting") : t("update.uptodate_restarting"), + data.updated ? "success" : "info", 6000); + startWebuiRestartPoll(); + } catch (e) { + showToast(`${t("update.failed")}: ${e.message || e}`.trim(), "error", 8000); + } finally { + setButtonLoading(button, false); + } +} + +async function fetchSystemInfo() { + if (!shouldFetch("system")) return; + try { + const res = await api("/api/system"); + const data = await res.json(); + const cpuUsage = data.cpu_usage_percent ?? 0; + const memUsage = data.memory_usage_percent ?? 0; + + get("systemCpuModel").innerText = data.cpu_model || "--"; + get("systemCpuUsage").innerText = data.cpu_usage_percent != null ? `${cpuUsage}%` : "--"; + get("systemMemory").innerText = + data.memory_total_gb != null && data.memory_used_gb != null + ? `${data.memory_used_gb} GB / ${data.memory_total_gb} GB` : "--"; + get("systemMemoryUsage").innerText = data.memory_usage_percent != null ? `${memUsage}%` : "--"; + get("systemVersion").innerText = data.system_version || "--"; + get("systemArch").innerText = data.system_arch || "--"; + get("systemKernel").innerText = data.system_release || "--"; + get("systemPythonVersion").innerText = data.python_version || "--"; + get("systemUndefinedVersion").innerText = data.undefined_version || "--"; + + get("systemCpuBar").style.width = `${Math.min(100, Math.max(0, cpuUsage))}%`; + get("systemMemoryBar").style.width = `${Math.min(100, Math.max(0, memUsage))}%`; + recordFetchSuccess("system"); + } catch (e) { + recordFetchError("system"); + } +} diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js new file mode 100644 index 0000000..13eecfc --- /dev/null +++ b/src/Undefined/webui/static/js/config-form.js @@ -0,0 +1,477 @@ +async function loadConfig() { + if (state.configLoading) return; + state.configLoading = true; + state.configLoaded = false; + setConfigState("loading"); + try { + const res = await api("/api/config/summary"); + const data = await res.json(); + state.config = data.data; + state.comments = data.comments || {}; + state.configLoaded = true; + buildConfigForm(); + setConfigState(null); + } catch (e) { + setConfigState("error"); + showToast(t("config.error"), "error", 5000); + } finally { + state.configLoading = false; + } +} + +function getComment(path) { + const entry = state.comments && state.comments[path]; + if (!entry) return ""; + if (typeof entry === "string") return entry; + return entry[state.lang] || entry.en || entry.zh || ""; +} + +function updateCommentTexts() { + document.querySelectorAll("[data-comment-path]").forEach(el => { + const path = el.getAttribute("data-comment-path"); + if (path) el.innerText = getComment(path); + }); +} + +function updateConfigSearchIndex() { + document.querySelectorAll(".form-group").forEach(group => { + const label = group.querySelector(".form-label"); + const hint = group.querySelector(".form-hint"); + const path = group.dataset.path || ""; + group.dataset.searchText = `${path} ${label ? label.innerText : ""} ${hint ? hint.innerText : ""}`.toLowerCase(); + }); +} + +function buildConfigForm() { + const container = get("formSections"); + if (!container) return; + container.textContent = ""; + + for (const [section, values] of Object.entries(state.config)) { + if (typeof values !== "object" || Array.isArray(values)) continue; + + const card = document.createElement("div"); + card.className = "card config-card"; + card.dataset.section = section; + const collapsed = !!state.configCollapsed[section]; + card.classList.toggle("is-collapsed", collapsed); + + const header = document.createElement("div"); + header.className = "config-card-header"; + + const title = document.createElement("h3"); + title.className = "form-section-title"; + title.textContent = section; + header.appendChild(title); + + const actions = document.createElement("div"); + actions.className = "config-card-actions"; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "btn ghost btn-sm"; + toggle.dataset.section = section; + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); + toggle.addEventListener("click", () => toggleSection(section)); + actions.appendChild(toggle); + + header.appendChild(actions); + card.appendChild(header); + + const sectionComment = getComment(section); + if (sectionComment) { + const hint = document.createElement("p"); + hint.className = "form-section-hint"; + hint.innerText = sectionComment; + hint.dataset.commentPath = section; + card.appendChild(hint); + } + + const fieldGrid = document.createElement("div"); + fieldGrid.className = "form-fields"; + card.appendChild(fieldGrid); + + for (const [key, val] of Object.entries(values)) { + if (typeof val === "object" && !Array.isArray(val)) { + const subSection = document.createElement("div"); + subSection.className = "form-subsection"; + + const subTitle = document.createElement("div"); + subTitle.className = "form-subtitle"; + subTitle.innerText = `[${section}.${key}]`; + subSection.appendChild(subTitle); + + const subCommentKey = `${section}.${key}`; + const subComment = getComment(subCommentKey); + if (subComment) { + const subHint = document.createElement("div"); + subHint.className = "form-subtitle-hint"; + subHint.innerText = subComment; + subHint.dataset.commentPath = subCommentKey; + subSection.appendChild(subHint); + } + + const subGrid = document.createElement("div"); + subGrid.className = "form-fields"; + for (const [sk, sv] of Object.entries(val)) { + const subPath = `${section}.${key}.${sk}`; + if (sv !== null && typeof sv === "object" && !Array.isArray(sv)) { + subGrid.appendChild(createSubSubSection(subPath, sv)); + } else if (Array.isArray(sv) && (AOT_PATHS.has(subPath) || sv.some(i => typeof i === "object" && i !== null))) { + subGrid.appendChild(createAotWidget(subPath, sv)); + } else { + subGrid.appendChild(createField(subPath, sv)); + } + } + subSection.appendChild(subGrid); + fieldGrid.appendChild(subSection); + continue; + } + fieldGrid.appendChild(createField(`${section}.${key}`, val)); + } + container.appendChild(card); + } + + updateConfigSearchIndex(); + applyConfigFilter(); +} + +function toggleSection(section) { + state.configCollapsed[section] = !state.configCollapsed[section]; + document.querySelectorAll(".config-card").forEach(card => { + if (card.dataset.section !== section) return; + const collapsed = !!state.configCollapsed[section]; + card.classList.toggle("is-collapsed", collapsed); + const toggle = card.querySelector(".config-card-actions button"); + if (toggle) { + toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + } + }); +} + +function setAllSectionsCollapsed(collapsed) { + document.querySelectorAll(".config-card").forEach(card => { + const section = card.dataset.section; + if (!section) return; + state.configCollapsed[section] = collapsed; + card.classList.toggle("is-collapsed", collapsed); + const toggle = card.querySelector(".config-card-actions button"); + if (toggle) { + toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + } + }); +} + +function applyConfigFilter() { + if (!state.configLoaded) return; + const query = state.configSearch.trim().toLowerCase(); + let matchCount = 0; + document.querySelectorAll(".config-card").forEach(card => { + let cardMatches = 0; + card.querySelectorAll(".form-group").forEach(group => { + const isMatch = !query || (group.dataset.searchText || "").includes(query); + group.classList.toggle("is-hidden", !isMatch); + group.classList.toggle("is-match", isMatch && query.length > 0); + if (isMatch) cardMatches += 1; + }); + card.querySelectorAll(".form-subsection").forEach(section => { + section.style.display = section.querySelector(".form-group:not(.is-hidden)") ? "" : "none"; + }); + card.classList.toggle("force-open", query.length > 0); + card.classList.toggle("is-hidden", query.length > 0 && cardMatches === 0); + matchCount += cardMatches; + }); + if (query.length > 0 && matchCount === 0) { + setConfigState("empty"); + } else if (state.configLoaded) { + setConfigState(null); + } +} + +function showSaveStatus(status, text) { + const el = get("saveStatus"); + const txt = get("saveStatusText"); + state.saveStatus = status; + if (status === "saving") { + el.style.opacity = "1"; + el.classList.add("active"); + txt.innerText = text || t("config.saving"); + } else if (status === "saved") { + el.classList.remove("active"); + txt.innerText = text || t("config.saved"); + setTimeout(() => { + if (!state.saveTimer) { el.style.opacity = "0"; state.saveStatus = "idle"; updateSaveStatusText(); } + }, 2000); + } else if (status === "error") { + el.classList.remove("active"); + txt.innerText = text || t("config.save_error"); + el.style.opacity = "1"; + } +} + +function isSensitiveKey(path) { + return /(password|token|secret|api_key|apikey|access_key|private_key)/i.test(path); +} + +function isLongText(value) { + return typeof value === "string" && (value.length > 80 || value.includes("\n")); +} + +function createField(path, val) { + const group = document.createElement("div"); + group.className = "form-group"; + group.dataset.path = path; + + const label = document.createElement("label"); + label.className = "form-label"; + label.innerText = path.split(".").pop(); + group.appendChild(label); + + const comment = getComment(path); + if (comment) { + const hint = document.createElement("div"); + hint.className = "form-hint"; + hint.innerText = comment; + hint.dataset.commentPath = path; + group.appendChild(hint); + } + + group.dataset.searchText = `${path} ${comment || ""}`.toLowerCase(); + + let input; + if (typeof val === "boolean") { + const wrapper = document.createElement("label"); + wrapper.className = "toggle-wrapper"; + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "toggle-input config-input"; + toggle.dataset.path = path; + toggle.dataset.valueType = "boolean"; + toggle.checked = Boolean(val); + const track = document.createElement("span"); + track.className = "toggle-track"; + const handle = document.createElement("span"); + handle.className = "toggle-handle"; + track.appendChild(handle); + wrapper.appendChild(toggle); + wrapper.appendChild(track); + group.appendChild(wrapper); + input = toggle; + input.onchange = () => autoSave(); + } else { + const isArray = Array.isArray(val); + const isNumber = typeof val === "number"; + const isSecret = isSensitiveKey(path); + + if (isLongText(val)) { + input = document.createElement("textarea"); + input.className = "form-control form-textarea config-input"; + input.value = val || ""; + input.dataset.valueType = "string"; + } else { + input = document.createElement("input"); + input.className = "form-control config-input"; + if (isNumber) { + input.type = "number"; + input.step = "any"; + input.value = String(val); + input.dataset.valueType = "number"; + } else if (isArray) { + input.type = "text"; + input.value = val.join(", "); + input.dataset.valueType = "array"; + input.dataset.arrayType = val.every(item => typeof item === "number") ? "number" : "string"; + } else { + input.type = isSecret ? "password" : "text"; + input.value = val == null ? "" : String(val); + input.dataset.valueType = "string"; + if (isSecret) input.setAttribute("autocomplete", "new-password"); + } + } + + input.dataset.path = path; + group.appendChild(input); + input.oninput = () => { + if (state.saveTimer) clearTimeout(state.saveTimer); + showSaveStatus("saving", t("config.typing")); + state.saveTimer = setTimeout(() => { state.saveTimer = null; autoSave(); }, 1000); + }; + } + return group; +} + +const AOT_PATHS = new Set(["models.chat.pool.models", "models.agent.pool.models"]); + +function createSubSubSection(path, obj) { + const div = document.createElement("div"); + div.className = "form-subsection"; + const title = document.createElement("div"); + title.className = "form-subtitle"; + title.innerText = `[${path}]`; + div.appendChild(title); + const comment = getComment(path); + if (comment) { + const hint = document.createElement("div"); + hint.className = "form-subtitle-hint"; + hint.innerText = comment; + hint.dataset.commentPath = path; + div.appendChild(hint); + } + const grid = document.createElement("div"); + grid.className = "form-fields"; + for (const [k, v] of Object.entries(obj)) { + const subPath = `${path}.${k}`; + if (AOT_PATHS.has(subPath) || (Array.isArray(v) && v.length > 0 && v.every(i => typeof i === "object" && i !== null))) { + grid.appendChild(createAotWidget(subPath, v)); + } else { + grid.appendChild(createField(subPath, v)); + } + } + div.appendChild(grid); + return div; +} + +function createAotEntry(path, entry) { + const div = document.createElement("div"); + div.className = "aot-entry"; + div.style.cssText = "border:1px solid var(--border);border-radius:6px;padding:10px;margin-bottom:8px;"; + const fields = document.createElement("div"); + fields.className = "form-fields"; + for (const [k, v] of Object.entries(entry)) { + const fg = document.createElement("div"); + fg.className = "form-group"; + fg.dataset.path = `${path}[].${k}`; + const lbl = document.createElement("label"); + lbl.className = "form-label"; + lbl.innerText = k; + fg.appendChild(lbl); + const isSecret = isSensitiveKey(k); + const inp = document.createElement("input"); + inp.className = "form-control aot-field-input"; + inp.type = isSecret ? "password" : "text"; + inp.value = v == null ? "" : String(v); + inp.dataset.fieldKey = k; + if (isSecret) inp.setAttribute("autocomplete", "new-password"); + inp.oninput = () => { + if (state.saveTimer) clearTimeout(state.saveTimer); + showSaveStatus("saving", t("config.typing")); + state.saveTimer = setTimeout(() => { state.saveTimer = null; autoSave(); }, 1000); + }; + fg.appendChild(inp); + fields.appendChild(fg); + } + div.appendChild(fields); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn ghost btn-sm"; + removeBtn.innerText = t("config.aot_remove"); + removeBtn.onclick = () => { div.remove(); autoSave(); }; + div.appendChild(removeBtn); + return div; +} + +function createAotWidget(path, arr) { + const DEFAULT_ENTRY = { model_name: "", api_url: "", api_key: "" }; + const container = document.createElement("div"); + container.className = "form-group"; + container.dataset.path = path; + const lbl = document.createElement("div"); + lbl.className = "form-label"; + lbl.innerText = path.split(".").pop(); + container.appendChild(lbl); + const comment = getComment(path); + if (comment) { + const hint = document.createElement("div"); + hint.className = "form-hint"; + hint.innerText = comment; + hint.dataset.commentPath = path; + container.appendChild(hint); + } + const entriesDiv = document.createElement("div"); + entriesDiv.dataset.aotPath = path; + container.appendChild(entriesDiv); + (arr || []).forEach(entry => entriesDiv.appendChild(createAotEntry(path, entry))); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "btn ghost btn-sm"; + addBtn.style.marginTop = "4px"; + addBtn.innerText = t("config.aot_add"); + addBtn.onclick = () => { + const template = arr && arr.length > 0 ? Object.fromEntries(Object.keys(arr[0]).map(k => [k, ""])) : DEFAULT_ENTRY; + entriesDiv.appendChild(createAotEntry(path, template)); + autoSave(); + }; + container.appendChild(addBtn); + return container; +} + +async function autoSave() { + showSaveStatus("saving"); + + const patch = {}; + document.querySelectorAll(".config-input").forEach(input => { + const path = input.dataset.path; + let val; + if (input.type === "checkbox") { + val = input.checked; + } else { + const raw = input.value; + const valueType = input.dataset.valueType || "string"; + if (valueType === "number") { + const trimmed = raw.trim(); + if (!trimmed) { val = ""; } + else { + val = trimmed.includes(".") ? parseFloat(trimmed) : parseInt(trimmed, 10); + if (Number.isNaN(val)) val = raw; + } + } else if (valueType === "array") { + const items = raw.split(",").map(s => s.trim()).filter(Boolean); + val = input.dataset.arrayType === "number" + ? items.map(item => { const num = Number(item); return Number.isNaN(num) ? item : num; }) + : items; + } else { + val = raw; + } + } + patch[path] = val; + }); + + document.querySelectorAll("[data-aot-path]").forEach(container => { + const aotPath = container.dataset.aotPath; + const entries = []; + container.querySelectorAll(".aot-entry").forEach(entry => { + const obj = {}; + entry.querySelectorAll(".aot-field-input").forEach(inp => { obj[inp.dataset.fieldKey] = inp.value; }); + entries.push(obj); + }); + patch[aotPath] = entries; + }); + + try { + const res = await api("/api/patch", { method: "POST", body: JSON.stringify({ patch }) }); + const data = await res.json(); + if (data.success) { + showSaveStatus("saved"); + if (data.warning) showToast(`${t("common.warning")}: ${data.warning}`, "warning", 5000); + } else { + showSaveStatus("error", t("config.save_error")); + showToast(`${t("common.error")}: ${data.error}`, "error", 5000); + } + } catch (e) { + showSaveStatus("error", t("config.save_network_error")); + showToast(`${t("common.error")}: ${e.message}`, "error", 5000); + } +} + +async function resetConfig() { + if (!confirm(t("config.reset_confirm"))) return; + try { + await loadConfig(); + showToast(t("config.reload_success"), "info"); + } catch (e) { + showToast(t("config.reload_error"), "error"); + } +} diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js new file mode 100644 index 0000000..780c179 --- /dev/null +++ b/src/Undefined/webui/static/js/i18n.js @@ -0,0 +1,262 @@ +const I18N = { + zh: { + "landing.title": "Undefined 控制台", + "landing.kicker": "WebUI", + "landing.subtitle": "提供配置管理、日志追踪与运行控制的统一入口。", + "landing.cta": "进入控制台", + "landing.config": "配置修改", + "landing.logs": "查看日志", + "landing.about": "关于项目", + "theme.light": "浅色", + "theme.dark": "深色", + "common.loading": "加载中...", + "common.error": "发生错误", + "common.saved": "已保存", + "common.warning": "警告", + "tabs.landing": "首页", + "tabs.overview": "运行概览", + "tabs.config": "配置修改", + "tabs.logs": "运行日志", + "tabs.about": "项目说明", + "overview.title": "运行概览", + "overview.subtitle": "当前系统资源与运行环境快照。", + "overview.refresh": "刷新", + "overview.system": "系统信息", + "overview.resources": "资源使用", + "overview.runtime": "运行环境", + "overview.cpu_model": "CPU 型号", + "overview.cpu_usage": "CPU 占用率", + "overview.memory": "内存容量", + "overview.memory_usage": "内存占用率", + "overview.system_version": "系统版本", + "overview.system_arch": "系统架构", + "overview.undefined_version": "Undefined 版本", + "overview.python_version": "Python 版本", + "overview.kernel": "内核版本", + "bot.title": "机器人运行状态", + "bot.start": "启动机器人", + "bot.stop": "停止机器人", + "bot.status.running": "运行中", + "bot.status.stopped": "未启动", + "bot.hint.running": "机器人正在运行并处理事件。", + "bot.hint.stopped": "机器人当前离线。", + "auth.title": "解锁控制台", + "auth.subtitle": "请输入 WebUI 密码以继续操作。", + "auth.placeholder": "请输入 WebUI 密码", + "auth.sign_in": "登 录", + "auth.sign_out": "退出登录", + "auth.default_password": "默认密码仍在使用,请尽快修改 webui.password 并重启 WebUI。", + "auth.change_required": "默认密码已禁用,请先设置新密码。", + "auth.reset_title": "设置新密码", + "auth.current_placeholder": "当前密码", + "auth.new_placeholder": "新密码", + "auth.update_password": "更新密码", + "auth.password_updated": "密码已更新,请使用新密码登录。", + "auth.password_updated_login": "密码已更新,正在登录...", + "auth.password_update_failed": "密码更新失败", + "auth.password_change_local": "默认密码模式下仅允许本机修改密码。", + "auth.signing_in": "登录中...", + "auth.login_failed": "登录失败", + "auth.unauthorized": "未登录或会话过期", + "config.title": "配置修改", + "config.subtitle": "按分类逐项调整配置,保存后自动触发热更新。", + "config.save": "保存更改", + "config.reset": "重置更改", + "config.reset_confirm": "确定要撤销所有本地更改吗?这将从服务器重新加载配置。", + "config.search_placeholder": "搜索配置...", + "config.clear_search": "清除搜索", + "config.expand_all": "全部展开", + "config.collapse_all": "全部折叠", + "config.expand_section": "展开", + "config.collapse_section": "折叠", + "config.loading": "正在加载配置...", + "config.error": "配置加载失败,请重试。", + "config.no_results": "未找到匹配项。", + "config.typing": "输入中...", + "config.saving": "保存中...", + "config.saved": "已保存", + "config.save_error": "保存失败", + "config.save_network_error": "网络错误", + "config.reload_success": "配置已从服务器重新加载。", + "config.reload_error": "配置重载失败。", + "config.bootstrap_created": "检测到缺少 config.toml,已从示例生成;请在此页完善配置并保存。", + "logs.title": "运行日志", + "logs.subtitle": "实时查看日志尾部输出。", + "logs.auto": "自动刷新", + "logs.refresh": "刷新", + "logs.initializing": "正在连接日志...", + "logs.search_placeholder": "搜索日志...", + "logs.clear": "清空", + "logs.copy": "复制", + "logs.download": "下载", + "logs.pause": "暂停", + "logs.resume": "继续", + "logs.jump_bottom": "回到底部", + "logs.tab.bot": "Bot 日志", + "logs.tab.webui": "WebUI 日志", + "logs.tab.all": "其他日志", + "logs.file.current": "当前", + "logs.file.history": "历史", + "logs.file.other": "文件", + "logs.empty": "暂无日志。", + "logs.error": "日志加载失败。", + "logs.unauthorized": "未登录,无法读取日志。", + "logs.copied": "日志已复制。", + "logs.download_ready": "日志已准备下载。", + "logs.cleared": "日志已清空。", + "logs.paused": "已暂停", + "logs.filtered": "已过滤", + "logs.level.all": "全部", + "logs.level.info": "Info", + "logs.level.warn": "Warn", + "logs.level.error": "Error", + "logs.level.debug": "Debug", + "logs.level_gte": "该等级及以上", + "about.title": "项目信息", + "about.subtitle": "关于 Undefined 项目的作者及许可协议。", + "about.author": "作者", + "about.author_name": "Null (pylindex@qq.com)", + "about.version": "版本", + "about.license": "许可协议", + "about.license_name": "MIT License", + "config.aot_add": "+ 添加条目", + "config.aot_remove": "移除", + "update.restart": "更新并重启", + "update.working": "正在检查更新...", + "update.updated_restarting": "更新完成,正在重启 WebUI...", + "update.uptodate_restarting": "已是最新版本,正在重启 WebUI...", + "update.not_eligible": "未满足更新条件(仅支持官方 origin/main)", + "update.failed": "更新失败", + "update.no_restart": "更新已完成但未重启(请检查 uv sync 输出)", + }, + en: { + "landing.title": "Undefined Console", + "landing.kicker": "WebUI", + "landing.subtitle": "A unified entry point for configuration, log tracking, and runtime control.", + "landing.cta": "Enter Console", + "landing.config": "Edit Config", + "landing.logs": "View Logs", + "landing.about": "About", + "theme.light": "Light", + "theme.dark": "Dark", + "common.loading": "Loading...", + "common.error": "An error occurred", + "common.saved": "Saved", + "common.warning": "Warning", + "tabs.landing": "Landing", + "tabs.overview": "Overview", + "tabs.config": "Configuration", + "tabs.logs": "System Logs", + "tabs.about": "About", + "overview.title": "Overview", + "overview.subtitle": "System resources and runtime snapshot.", + "overview.refresh": "Refresh", + "overview.system": "System", + "overview.resources": "Resources", + "overview.runtime": "Runtime", + "overview.cpu_model": "CPU Model", + "overview.cpu_usage": "CPU Usage", + "overview.memory": "Memory", + "overview.memory_usage": "Memory Usage", + "overview.system_version": "System Version", + "overview.system_arch": "Architecture", + "overview.undefined_version": "Undefined Version", + "overview.python_version": "Python Version", + "overview.kernel": "Kernel", + "bot.title": "Bot Status", + "bot.start": "Start Bot", + "bot.stop": "Stop Bot", + "bot.status.running": "Running", + "bot.status.stopped": "Stopped", + "bot.hint.running": "Bot is active and processing events.", + "bot.hint.stopped": "Bot is currently offline.", + "auth.title": "Unlock Console", + "auth.subtitle": "Please enter your WebUI password.", + "auth.placeholder": "WebUI password", + "auth.sign_in": "Sign In", + "auth.sign_out": "Sign Out", + "auth.default_password": "Default password is in use. Please change webui.password and restart.", + "auth.change_required": "Default password is disabled. Please set a new password.", + "auth.reset_title": "Set New Password", + "auth.current_placeholder": "Current password", + "auth.new_placeholder": "New password", + "auth.update_password": "Update Password", + "auth.password_updated": "Password updated. Please sign in again.", + "auth.password_updated_login": "Password updated. Signing in...", + "auth.password_update_failed": "Password update failed", + "auth.password_change_local": "Password change requires local access when using default password.", + "auth.signing_in": "Signing in...", + "auth.login_failed": "Login failed", + "auth.unauthorized": "Unauthorized or session expired", + "config.title": "Configuration", + "config.subtitle": "Adjust settings by category. Changes trigger hot reload.", + "config.save": "Save Changes", + "config.reset": "Revert Changes", + "config.reset_confirm": "Are you sure you want to revert all local changes? This will reload the configuration from the server.", + "config.search_placeholder": "Search config...", + "config.clear_search": "Clear search", + "config.expand_all": "Expand all", + "config.collapse_all": "Collapse all", + "config.expand_section": "Expand", + "config.collapse_section": "Collapse", + "config.loading": "Loading configuration...", + "config.error": "Failed to load configuration.", + "config.no_results": "No matching results.", + "config.typing": "Typing...", + "config.saving": "Saving...", + "config.saved": "Saved", + "config.save_error": "Save failed", + "config.save_network_error": "Network error", + "config.reload_success": "Configuration reloaded from server.", + "config.reload_error": "Failed to reload configuration.", + "config.bootstrap_created": "config.toml was missing and has been generated from the example. Please review and save your configuration.", + "logs.title": "System Logs", + "logs.subtitle": "Real-time view of recent log output.", + "logs.auto": "Auto Refresh", + "logs.refresh": "Refresh", + "logs.initializing": "Initializing log connection...", + "logs.search_placeholder": "Search logs...", + "logs.clear": "Clear", + "logs.copy": "Copy", + "logs.download": "Download", + "logs.pause": "Pause", + "logs.resume": "Resume", + "logs.jump_bottom": "Jump to bottom", + "logs.tab.bot": "Bot Logs", + "logs.tab.webui": "WebUI Logs", + "logs.tab.all": "Other Logs", + "logs.file.current": "Current", + "logs.file.history": "History", + "logs.file.other": "File", + "logs.empty": "No logs available.", + "logs.error": "Failed to load logs.", + "logs.unauthorized": "Unauthorized to access logs.", + "logs.copied": "Logs copied.", + "logs.download_ready": "Logs download ready.", + "logs.cleared": "Logs cleared.", + "logs.paused": "Paused", + "logs.filtered": "Filtered", + "logs.level.all": "All", + "logs.level.info": "Info", + "logs.level.warn": "Warn", + "logs.level.error": "Error", + "logs.level.debug": "Debug", + "logs.level_gte": "And above", + "about.title": "About Project", + "about.subtitle": "Information about authors and open source licenses.", + "about.author": "Author", + "about.author_name": "Null (pylindex@qq.com)", + "about.version": "Version", + "about.license": "License", + "about.license_name": "MIT License", + "update.restart": "Update & Restart", + "update.working": "Checking for updates...", + "update.updated_restarting": "Updated. Restarting WebUI...", + "update.uptodate_restarting": "Up to date. Restarting WebUI...", + "update.not_eligible": "Update not eligible (official origin/main only)", + "update.failed": "Update failed", + "update.no_restart": "Updated but not restarted (check uv sync output)", + "config.aot_add": "+ Add Entry", + "config.aot_remove": "Remove", + } +}; diff --git a/src/Undefined/webui/static/js/log-view.js b/src/Undefined/webui/static/js/log-view.js new file mode 100644 index 0000000..d2c61a1 --- /dev/null +++ b/src/Undefined/webui/static/js/log-view.js @@ -0,0 +1,219 @@ +async function fetchLogs(force = false) { + if (!force && !shouldFetch("logs")) return; + const container = get("logContainer"); + if (container && !state.logsRaw) { + container.dataset.placeholder = "true"; + container.innerText = t("logs.initializing"); + } + if (state.logType === "all" && !state.logFile) { + state.logsRaw = ""; + renderLogs(); + return; + } + try { + const params = new URLSearchParams({ lines: "200", type: state.logType }); + if (state.logFile) params.set("file", state.logFile); + const res = await api(`/api/logs?${params.toString()}`); + const text = await res.text(); + state.logsRaw = text || ""; + recordFetchSuccess("logs"); + renderLogs(); + } catch (e) { + recordFetchError("logs"); + if (!container) return; + container.dataset.placeholder = "true"; + container.innerText = e.message === "Unauthorized" ? t("logs.unauthorized") : t("logs.error"); + updateLogMeta(0, 0); + } +} + +function filterLogLines(raw) { + const query = state.logSearch.trim().toLowerCase(); + const rawLines = raw ? raw.split(/\r?\n/) : []; + const base = window.LogsController + ? window.LogsController.filterLogLines(raw, { level: state.logLevel, gte: state.logLevelGte }) + : { filtered: rawLines, total: rawLines.length }; + + let filtered = base.filtered; + if (query) filtered = filtered.filter(line => line.toLowerCase().includes(query)); + + const total = base.total ?? rawLines.length; + const matched = filtered.filter(line => line.length > 0).length; + return { filtered, total, matched }; +} + +function formatLogText(text) { + if (!text) return ""; + let escaped = escapeHtml(text); + const query = state.logSearch.trim(); + if (query) { + const regex = new RegExp(escapeRegExp(query), "gi"); + escaped = escaped.replace(regex, '$&'); + } + escaped = escaped.replace( + /(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/g, + '$1' + ); + return escaped + .replace(/\x1b\[31m/g, '') + .replace(/\x1b\[32m/g, '') + .replace(/\x1b\[33m/g, '') + .replace(/\x1b\[34m/g, '') + .replace(/\x1b\[35m/g, '') + .replace(/\x1b\[36m/g, '') + .replace(/\x1b\[0m/g, ''); +} + +function renderLogs() { + const container = get("logContainer"); + if (!container) return; + if (!state.logsRaw) { + container.dataset.placeholder = "true"; + container.innerText = t("logs.empty"); + state.logAtBottom = true; + updateLogJumpButton(); + updateLogMeta(0, 0); + return; + } + const { filtered, total, matched } = filterLogLines(state.logsRaw); + if (filtered.length === 0) { + container.dataset.placeholder = "true"; + container.innerText = t("logs.empty"); + state.logAtBottom = true; + updateLogJumpButton(); + updateLogMeta(total, 0); + return; + } + container.innerHTML = formatLogText(filtered.join("\n")); + container.dataset.placeholder = "false"; + if (state.logAutoRefresh && state.logAtBottom) container.scrollTop = container.scrollHeight; + updateLogJumpButton(); + updateLogMeta(total, matched); +} + +function updateLogMeta(total, matched) { + const meta = get("logMeta"); + if (!meta) return; + const parts = []; + if (state.logsPaused) parts.push(t("logs.paused")); + if (state.logLevel !== "all" || state.logSearch.trim() || state.logLevelGte) { + parts.push(`${t("logs.filtered")}: ${total > 0 ? `${matched}/${total}` : "0/0"}`); + } + meta.innerText = parts.join(" | "); +} + +function updateLogJumpButton() { + const button = get("btnJumpLogs"); + if (!button) return; + button.style.visibility = state.logAtBottom ? "hidden" : "visible"; + button.style.pointerEvents = state.logAtBottom ? "none" : "auto"; +} + +function bindLogScroll() { + if (state.logScrollBound) return; + const container = get("logContainer"); + if (!container) return; + container.addEventListener("scroll", () => { + state.logAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 24; + updateLogJumpButton(); + }); + state.logScrollBound = true; + updateLogJumpButton(); +} + +function startLogStream() { + if (state.logStream || state.logStreamFailed || !window.EventSource) return false; + if (!state.logStreamEnabled) return false; + state.logStreamFailed = false; + const params = new URLSearchParams({ lines: "200", type: state.logType }); + const stream = new EventSource(`/api/logs/stream?${params.toString()}`); + state.logStream = stream; + stream.onmessage = (event) => { + state.logsRaw = event.data || ""; + recordFetchSuccess("logs"); + renderLogs(); + }; + stream.onerror = () => { + state.logStreamFailed = true; + stopLogStream(); + if (state.logAutoRefresh && !state.logsPaused) startLogTimer(); + }; + return true; +} + +function stopLogStream() { + if (state.logStream) { state.logStream.close(); state.logStream = null; } +} + +function updateLogRefreshState() { + if (state.view !== "app" || state.tab !== "logs" || document.hidden || !state.authenticated) { + stopLogStream(); stopLogTimer(); return; + } + if (state.logsPaused || !state.logAutoRefresh) { + stopLogStream(); stopLogTimer(); return; + } + if (!state.logStreamEnabled) { stopLogStream(); startLogTimer(); return; } + if (startLogStream()) { stopLogTimer(); return; } + startLogTimer(); +} + +async function copyLogsToClipboard() { + const text = state.logsRaw || ""; + if (!text) { showToast(t("logs.empty"), "info"); return; } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.cssText = "position:fixed;opacity:0"; + document.body.appendChild(textarea); + textarea.focus(); textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + showToast(t("logs.copied"), "success"); + } catch (e) { + showToast(`${t("common.error")}: ${e.message}`, "error"); + } +} + +async function fetchLogFiles(force = false) { + if (state.logFiles[state.logType] && !force) { updateLogFileSelect(); return; } + try { + const res = await api(`/api/logs/files?type=${state.logType}`); + const data = await res.json(); + state.logFiles[state.logType] = data.files || []; + state.logFileCurrent = data.current || ""; + updateLogFileSelect(); + } catch (e) { + state.logFiles[state.logType] = []; + } +} + +function setLogType(type) { + if (state.logType === type) return; + state.logType = type; + state.logFile = ""; + state.logsRaw = ""; + state.logStreamFailed = false; + updateLogTabs(); + fetchLogFiles(true); + renderLogs(); + updateLogRefreshState(); +} + +function downloadLogs() { + const text = state.logsRaw || ""; + if (!text) { showToast(t("logs.empty"), "info"); return; } + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `undefined-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + showToast(t("logs.download_ready"), "info"); +} diff --git a/src/Undefined/webui/static/js/main.js b/src/Undefined/webui/static/js/main.js index 8ef3c4a..b1fa4c6 100644 --- a/src/Undefined/webui/static/js/main.js +++ b/src/Undefined/webui/static/js/main.js @@ -1,1574 +1,3 @@ -/** - * Undefined WebUI Main Script - */ - -const I18N = { - zh: { - "landing.title": "Undefined 控制台", - "landing.kicker": "WebUI", - "landing.subtitle": "提供配置管理、日志追踪与运行控制的统一入口。", - "landing.cta": "进入控制台", - "landing.config": "配置修改", - "landing.logs": "查看日志", - "landing.about": "关于项目", - "theme.light": "浅色", - "theme.dark": "深色", - "common.loading": "加载中...", - "common.error": "发生错误", - "common.saved": "已保存", - "common.warning": "警告", - "tabs.landing": "首页", - "tabs.overview": "运行概览", - "tabs.config": "配置修改", - "tabs.logs": "运行日志", - "tabs.about": "项目说明", - "overview.title": "运行概览", - "overview.subtitle": "当前系统资源与运行环境快照。", - "overview.refresh": "刷新", - "overview.system": "系统信息", - "overview.resources": "资源使用", - "overview.runtime": "运行环境", - "overview.cpu_model": "CPU 型号", - "overview.cpu_usage": "CPU 占用率", - "overview.memory": "内存容量", - "overview.memory_usage": "内存占用率", - "overview.system_version": "系统版本", - "overview.system_arch": "系统架构", - "overview.undefined_version": "Undefined 版本", - "overview.python_version": "Python 版本", - "overview.kernel": "内核版本", - "bot.title": "机器人运行状态", - "bot.start": "启动机器人", - "bot.stop": "停止机器人", - "bot.status.running": "运行中", - "bot.status.stopped": "未启动", - "bot.hint.running": "机器人正在运行并处理事件。", - "bot.hint.stopped": "机器人当前离线。", - "auth.title": "解锁控制台", - "auth.subtitle": "请输入 WebUI 密码以继续操作。", - "auth.placeholder": "请输入 WebUI 密码", - "auth.sign_in": "登 录", - "auth.sign_out": "退出登录", - "auth.default_password": "默认密码仍在使用,请尽快修改 webui.password 并重启 WebUI。", - "auth.change_required": "默认密码已禁用,请先设置新密码。", - "auth.reset_title": "设置新密码", - "auth.current_placeholder": "当前密码", - "auth.new_placeholder": "新密码", - "auth.update_password": "更新密码", - "auth.password_updated": "密码已更新,请使用新密码登录。", - "auth.password_updated_login": "密码已更新,正在登录...", - "auth.password_update_failed": "密码更新失败", - "auth.password_change_local": "默认密码模式下仅允许本机修改密码。", - "auth.signing_in": "登录中...", - "auth.login_failed": "登录失败", - "auth.unauthorized": "未登录或会话过期", - "config.title": "配置修改", - "config.subtitle": "按分类逐项调整配置,保存后自动触发热更新。", - "config.save": "保存更改", - "config.reset": "重置更改", - "config.reset_confirm": "确定要撤销所有本地更改吗?这将从服务器重新加载配置。", - "config.search_placeholder": "搜索配置...", - "config.clear_search": "清除搜索", - "config.expand_all": "全部展开", - "config.collapse_all": "全部折叠", - "config.expand_section": "展开", - "config.collapse_section": "折叠", - "config.loading": "正在加载配置...", - "config.error": "配置加载失败,请重试。", - "config.no_results": "未找到匹配项。", - "config.typing": "输入中...", - "config.saving": "保存中...", - "config.saved": "已保存", - "config.save_error": "保存失败", - "config.save_network_error": "网络错误", - "config.reload_success": "配置已从服务器重新加载。", - "config.reload_error": "配置重载失败。", - "config.bootstrap_created": "检测到缺少 config.toml,已从示例生成;请在此页完善配置并保存。", - "logs.title": "运行日志", - "logs.subtitle": "实时查看日志尾部输出。", - "logs.auto": "自动刷新", - "logs.refresh": "刷新", - "logs.initializing": "正在连接日志...", - "logs.search_placeholder": "搜索日志...", - "logs.clear": "清空", - "logs.copy": "复制", - "logs.download": "下载", - "logs.pause": "暂停", - "logs.resume": "继续", - "logs.jump_bottom": "回到底部", - "logs.tab.bot": "Bot 日志", - "logs.tab.webui": "WebUI 日志", - "logs.tab.all": "其他日志", - "logs.file.current": "当前", - "logs.file.history": "历史", - "logs.file.other": "文件", - "logs.empty": "暂无日志。", - "logs.error": "日志加载失败。", - "logs.unauthorized": "未登录,无法读取日志。", - "logs.copied": "日志已复制。", - "logs.download_ready": "日志已准备下载。", - "logs.cleared": "日志已清空。", - "logs.paused": "已暂停", - "logs.filtered": "已过滤", - "logs.level.all": "全部", - "logs.level.info": "Info", - "logs.level.warn": "Warn", - "logs.level.error": "Error", - "logs.level.debug": "Debug", - "logs.level_gte": "该等级及以上", - "about.title": "项目信息", - "about.subtitle": "关于 Undefined 项目的作者及许可协议。", - "about.author": "作者", - "about.author_name": "Null (pylindex@qq.com)", - "about.version": "版本", - "about.license": "许可协议", - "about.license_name": "MIT License", - - "update.restart": "更新并重启", - "update.working": "正在检查更新...", - "update.updated_restarting": "更新完成,正在重启 WebUI...", - "update.uptodate_restarting": "已是最新版本,正在重启 WebUI...", - "update.not_eligible": "未满足更新条件(仅支持官方 origin/main)", - "update.failed": "更新失败", - "update.no_restart": "更新已完成但未重启(请检查 uv sync 输出)", - }, - en: { - "landing.title": "Undefined Console", - "landing.kicker": "WebUI", - "landing.subtitle": "A unified entry point for configuration, log tracking, and runtime control.", - "landing.cta": "Enter Console", - "landing.config": "Edit Config", - "landing.logs": "View Logs", - "landing.about": "About", - "theme.light": "Light", - "theme.dark": "Dark", - "common.loading": "Loading...", - "common.error": "An error occurred", - "common.saved": "Saved", - "common.warning": "Warning", - "tabs.landing": "Landing", - "tabs.overview": "Overview", - "tabs.config": "Configuration", - "tabs.logs": "System Logs", - "tabs.about": "About", - "overview.title": "Overview", - "overview.subtitle": "System resources and runtime snapshot.", - "overview.refresh": "Refresh", - "overview.system": "System", - "overview.resources": "Resources", - "overview.runtime": "Runtime", - "overview.cpu_model": "CPU Model", - "overview.cpu_usage": "CPU Usage", - "overview.memory": "Memory", - "overview.memory_usage": "Memory Usage", - "overview.system_version": "System Version", - "overview.system_arch": "Architecture", - "overview.undefined_version": "Undefined Version", - "overview.python_version": "Python Version", - "overview.kernel": "Kernel", - "bot.title": "Bot Status", - "bot.start": "Start Bot", - "bot.stop": "Stop Bot", - "bot.status.running": "Running", - "bot.status.stopped": "Stopped", - "bot.hint.running": "Bot is active and processing events.", - "bot.hint.stopped": "Bot is currently offline.", - "auth.title": "Unlock Console", - "auth.subtitle": "Please enter your WebUI password.", - "auth.placeholder": "WebUI password", - "auth.sign_in": "Sign In", - "auth.sign_out": "Sign Out", - "auth.default_password": "Default password is in use. Please change webui.password and restart.", - "auth.change_required": "Default password is disabled. Please set a new password.", - "auth.reset_title": "Set New Password", - "auth.current_placeholder": "Current password", - "auth.new_placeholder": "New password", - "auth.update_password": "Update Password", - "auth.password_updated": "Password updated. Please sign in again.", - "auth.password_updated_login": "Password updated. Signing in...", - "auth.password_update_failed": "Password update failed", - "auth.password_change_local": "Password change requires local access when using default password.", - "auth.signing_in": "Signing in...", - "auth.login_failed": "Login failed", - "auth.unauthorized": "Unauthorized or session expired", - "config.title": "Configuration", - "config.subtitle": "Adjust settings by category. Changes trigger hot reload.", - "config.save": "Save Changes", - "config.reset": "Revert Changes", - "config.reset_confirm": "Are you sure you want to revert all local changes? This will reload the configuration from the server.", - "config.search_placeholder": "Search config...", - "config.clear_search": "Clear search", - "config.expand_all": "Expand all", - "config.collapse_all": "Collapse all", - "config.expand_section": "Expand", - "config.collapse_section": "Collapse", - "config.loading": "Loading configuration...", - "config.error": "Failed to load configuration.", - "config.no_results": "No matching results.", - "config.typing": "Typing...", - "config.saving": "Saving...", - "config.saved": "Saved", - "config.save_error": "Save failed", - "config.save_network_error": "Network error", - "config.reload_success": "Configuration reloaded from server.", - "config.reload_error": "Failed to reload configuration.", - "config.bootstrap_created": "config.toml was missing and has been generated from the example. Please review and save your configuration.", - "logs.title": "System Logs", - "logs.subtitle": "Real-time view of recent log output.", - "logs.auto": "Auto Refresh", - "logs.refresh": "Refresh", - "logs.initializing": "Initializing log connection...", - "logs.search_placeholder": "Search logs...", - "logs.clear": "Clear", - "logs.copy": "Copy", - "logs.download": "Download", - "logs.pause": "Pause", - "logs.resume": "Resume", - "logs.jump_bottom": "Jump to bottom", - "logs.tab.bot": "Bot Logs", - "logs.tab.webui": "WebUI Logs", - "logs.tab.all": "Other Logs", - "logs.file.current": "Current", - "logs.file.history": "History", - "logs.file.other": "File", - "logs.empty": "No logs available.", - "logs.error": "Failed to load logs.", - "logs.unauthorized": "Unauthorized to access logs.", - "logs.copied": "Logs copied.", - "logs.download_ready": "Logs download ready.", - "logs.cleared": "Logs cleared.", - "logs.paused": "Paused", - "logs.filtered": "Filtered", - "logs.level.all": "All", - "logs.level.info": "Info", - "logs.level.warn": "Warn", - "logs.level.error": "Error", - "logs.level.debug": "Debug", - "logs.level_gte": "And above", - "about.title": "About Project", - "about.subtitle": "Information about authors and open source licenses.", - "about.author": "Author", - "about.author_name": "Null (pylindex@qq.com)", - "about.version": "Version", - "about.license": "License", - "about.license_name": "MIT License", - - "update.restart": "Update & Restart", - "update.working": "Checking for updates...", - "update.updated_restarting": "Updated. Restarting WebUI...", - "update.uptodate_restarting": "Up to date. Restarting WebUI...", - "update.not_eligible": "Update not eligible (official origin/main only)", - "update.failed": "Update failed", - "update.no_restart": "Updated but not restarted (check uv sync output)", - } -}; - -function readJsonScript(id, fallback) { - const el = document.getElementById(id); - if (!el) return fallback; - try { - const text = (el.textContent || "").trim(); - if (!text) return fallback; - return JSON.parse(text); - } catch (e) { - return fallback; - } -} - -const initialState = readJsonScript("initial-state", {}); -const initialView = readJsonScript("initial-view", "landing"); - -const state = { - lang: (initialState && initialState.lang) || getCookie("undefined_lang") || "zh", - theme: "light", - authenticated: false, - usingDefaultPassword: !!(initialState && initialState.using_default_password), - configExists: !!(initialState && initialState.config_exists), - tab: "overview", - view: initialView || "landing", - config: {}, - comments: {}, - configCollapsed: {}, - configSearch: "", - configLoading: false, - configLoaded: false, - bot: { running: false, pid: null, uptime: 0 }, - logsRaw: "", - logSearch: "", - logLevel: "all", - logLevelGte: false, - logType: "bot", - logFiles: {}, - logFile: "", - logFileCurrent: "", - logStreamEnabled: true, - logsPaused: false, - logAutoRefresh: true, - logStream: null, - logStreamFailed: false, - logAtBottom: true, - logScrollBound: false, - logTimer: null, - statusTimer: null, - systemTimer: null, - saveTimer: null, - saveStatus: "idle", - fetchBackoff: { status: 0, system: 0, logs: 0 }, - nextFetchAt: { status: 0, system: 0, logs: 0 }, -}; - -const REFRESH_INTERVALS = { - status: 3000, - system: 1000, - logs: 1000, -}; - -const THEME_COLORS = { - light: "#f9f5f1", - dark: "#0f1112", -}; - -const LOG_LEVELS = window.LogsController ? window.LogsController.LOG_LEVELS : ["all"]; - -// Utils -function get(id) { return document.getElementById(id); } -function t(key) { return I18N[state.lang][key] || key; } - -function escapeHtml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function setButtonLoading(button, loading) { - if (!button) return; - button.disabled = loading; - button.classList.toggle("is-loading", loading); - button.setAttribute("aria-busy", loading ? "true" : "false"); -} - -function getCookie(name) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); -} - -function setCookie(name, value, days = 30) { - const d = new Date(); - d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); - let expires = "expires=" + d.toUTCString(); - document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax"; -} - -function updateI18N() { - document.querySelectorAll("[data-i18n]").forEach(el => { - const key = el.getAttribute("data-i18n"); - el.innerText = t(key); - }); - document.querySelectorAll("[data-i18n-placeholder]").forEach(el => { - const key = el.getAttribute("data-i18n-placeholder"); - el.placeholder = t(key); - }); - updateToggleLabels(); - updateCommentTexts(); - updateConfigSearchIndex(); - applyConfigFilter(); - updateSectionToggleLabels(); - updateLogPlaceholder(); - updateLogFilterLabels(); - updateLogPauseLabel(); - updateLogTabs(); - updateLogFileSelect(); - updateSaveStatusText(); - updateConfigStateLabel(); - renderLogs(); -} - -function updateToggleLabels() { - const langLabel = state.lang === "zh" ? "English" : "中文"; - document.querySelectorAll('[data-action="toggle-lang"]').forEach(btn => { - btn.innerText = langLabel; - }); - const themeLabel = state.theme === "dark" ? t("theme.dark") : t("theme.light"); - document.querySelectorAll('[data-action="toggle-theme"]').forEach(btn => { - btn.innerText = themeLabel; - }); -} - -function updateLogPlaceholder() { - const container = get("logContainer"); - if (!container) return; - if (container.dataset.placeholder === "true") { - container.innerText = t("logs.initializing"); - } -} - -function updateLogFilterLabels() { - const select = get("logLevelFilter"); - if (!select) return; - const current = select.value || state.logLevel; - while (select.firstChild) { - select.removeChild(select.firstChild); - } - LOG_LEVELS.forEach(level => { - const option = document.createElement("option"); - option.value = level; - option.textContent = t(`logs.level.${level}`); - select.appendChild(option); - }); - const valid = LOG_LEVELS.includes(current) ? current : "all"; - select.value = valid; - state.logLevel = valid; -} - -function updateLogPauseLabel() { - const button = get("btnPauseLogs"); - if (!button) return; - button.innerText = state.logsPaused ? t("logs.resume") : t("logs.pause"); -} - -function updateLogTabs() { - document.querySelectorAll(".log-tab").forEach(tab => { - const logType = tab.dataset.logType; - tab.classList.toggle("active", logType === state.logType); - }); -} - -function updateLogFileSelect() { - const select = get("logFileSelect"); - if (!select) return; - const files = state.logFiles[state.logType] || []; - select.disabled = files.length === 0; - while (select.firstChild) { - select.removeChild(select.firstChild); - } - files.forEach(file => { - const option = document.createElement("option"); - option.value = file.name; - let label = file.current ? t("logs.file.current") : t("logs.file.history"); - if (state.logType === "all") { - label = t("logs.file.other"); - } - option.textContent = `${file.name} (${label})`; - select.appendChild(option); - }); - if (!state.logFile || !files.find(file => file.name === state.logFile)) { - state.logFile = state.logFileCurrent || (files[0] && files[0].name) || ""; - } - select.value = state.logFile; - updateLogStreamEligibility(); -} - -function updateLogStreamEligibility() { - if (state.logType === "all") { - state.logStreamEnabled = false; - return; - } - state.logStreamEnabled = !state.logFile || state.logFile === state.logFileCurrent; -} - -function updateConfigStateLabel() { - const stateEl = get("configState"); - if (!stateEl) return; - const key = stateEl.dataset.i18nState; - if (key) { - stateEl.innerText = t(key); - } -} - -function updateSaveStatusText() { - const txt = get("saveStatusText"); - if (!txt) return; - if (state.saveStatus === "saving") { - txt.innerText = t("config.saving"); - } else if (state.saveStatus === "saved") { - txt.innerText = t("config.saved"); - } else if (state.saveStatus === "error") { - txt.innerText = t("config.save_error"); - } else { - txt.innerText = t("config.saving"); - } -} - -function updateAuthPanels() { - const usingDefault = !!state.usingDefaultPassword; - const showLanding = !state.authenticated && state.view === "landing"; - const showAppLogin = !state.authenticated && state.view === "app"; - - const landingLogin = get("landingLoginBox"); - const landingReset = get("landingPasswordResetBox"); - if (landingLogin) { - landingLogin.style.display = showLanding && !usingDefault ? "block" : "none"; - } - if (landingReset) { - landingReset.style.display = showLanding && usingDefault ? "block" : "none"; - } - - const appLogin = get("appLoginBox"); - const appReset = get("appPasswordResetBox"); - if (appLogin) { - appLogin.style.display = showAppLogin && !usingDefault ? "block" : "none"; - } - if (appReset) { - appReset.style.display = showAppLogin && usingDefault ? "block" : "none"; - } -} - -function updateSectionToggleLabels() { - document.querySelectorAll(".config-card").forEach(card => { - const section = card.dataset.section; - const toggle = card.querySelector(".config-card-actions button"); - if (!toggle || !section) return; - const collapsed = !!state.configCollapsed[section]; - toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - }); -} - -function updateThemeColor(theme) { - const meta = document.getElementById("themeColorMeta"); - if (!meta) return; - meta.setAttribute("content", THEME_COLORS[theme] || THEME_COLORS.light); -} - -function applyTheme(theme) { - const normalized = theme === "dark" ? "dark" : "light"; - state.theme = normalized; - document.documentElement.setAttribute("data-theme", normalized); - setCookie("undefined_theme", normalized); - updateToggleLabels(); - updateThemeColor(normalized); -} - -function showToast(message, type = "info", duration = 3000) { - const container = get("toast-container"); - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.innerText = message; - container.appendChild(toast); - - setTimeout(() => { - toast.classList.add("removing"); - setTimeout(() => toast.remove(), 300); - }, duration); -} - -async function api(path, options = {}) { - const headers = options.headers || {}; - if (options.method === "POST" && options.body && !headers["Content-Type"]) { - headers["Content-Type"] = "application/json"; - } - - const res = await fetch(path, { - ...options, - headers, - credentials: options.credentials || "same-origin", - }); - if (res.status === 401) { - state.authenticated = false; - refreshUI(); - throw new Error("Unauthorized"); - } - return res; -} - -function setConfigState(mode) { - const stateEl = get("configState"); - const grid = get("formSections"); - if (!stateEl || !grid) return; - if (!mode) { - stateEl.style.display = "none"; - stateEl.dataset.i18nState = ""; - grid.style.display = "block"; - return; - } - const keyMap = { - loading: "config.loading", - error: "config.error", - empty: "config.no_results", - }; - const key = keyMap[mode] || "common.error"; - stateEl.dataset.i18nState = key; - stateEl.innerText = t(key); - stateEl.style.display = "block"; - grid.style.display = mode === "loading" || mode === "error" ? "none" : "block"; -} - -function shouldFetch(kind) { - return Date.now() >= (state.nextFetchAt[kind] || 0); -} - -function recordFetchError(kind) { - const current = state.fetchBackoff[kind] || 0; - const next = Math.min(5, current + 1); - state.fetchBackoff[kind] = next; - const delay = Math.min(15000, 1000 * Math.pow(2, next)); - state.nextFetchAt[kind] = Date.now() + delay; -} - -function recordFetchSuccess(kind) { - state.fetchBackoff[kind] = 0; - state.nextFetchAt[kind] = 0; -} - -function startStatusTimer() { - if (!state.statusTimer) { - state.statusTimer = setInterval(fetchStatus, REFRESH_INTERVALS.status); - } -} - -function stopStatusTimer() { - if (state.statusTimer) { - clearInterval(state.statusTimer); - state.statusTimer = null; - } -} - -function startSystemTimer() { - if (!state.systemTimer) { - state.systemTimer = setInterval(fetchSystemInfo, REFRESH_INTERVALS.system); - } -} - -function stopSystemTimer() { - if (state.systemTimer) { - clearInterval(state.systemTimer); - state.systemTimer = null; - } -} - -function startLogTimer() { - if (!state.logTimer) { - state.logTimer = setInterval(fetchLogs, REFRESH_INTERVALS.logs); - } -} - -function stopLogTimer() { - if (state.logTimer) { - clearInterval(state.logTimer); - state.logTimer = null; - } -} - -// Actions -async function login(pwd, statusId, buttonId) { - const s = get(statusId); - const button = buttonId ? get(buttonId) : null; - s.innerText = t("auth.signing_in"); - setButtonLoading(button, true); - try { - const res = await api("/api/login", { - method: "POST", - body: JSON.stringify({ password: pwd }) - }); - const data = await res.json(); - if (data.success) { - state.authenticated = true; - await checkSession(); - refreshUI(); - s.innerText = ""; - } else { - if (data.code === "default_password") { - s.innerText = t("auth.change_required"); - showToast(t("auth.change_required"), "warning", 5000); - } else { - s.innerText = data.error || t("auth.login_failed"); - } - } - } catch (e) { - s.innerText = e.message || t("auth.login_failed"); - } finally { - setButtonLoading(button, false); - } -} - -async function changePassword(currentId, newId, statusId, buttonId) { - const statusEl = get(statusId); - const button = buttonId ? get(buttonId) : null; - const currentEl = get(currentId); - const newEl = get(newId); - const currentPassword = currentEl ? currentEl.value.trim() : ""; - const newPassword = newEl ? newEl.value.trim() : ""; - - if (!currentPassword || !newPassword) { - if (statusEl) statusEl.innerText = t("auth.password_update_failed"); - return; - } - if (currentPassword === newPassword) { - if (statusEl) statusEl.innerText = t("auth.password_update_failed"); - return; - } - - if (statusEl) statusEl.innerText = t("common.loading"); - setButtonLoading(button, true); - try { - const res = await api("/api/password", { - method: "POST", - body: JSON.stringify({ - current_password: currentPassword, - new_password: newPassword - }) - }); - const data = await res.json(); - if (data.success) { - if (statusEl) statusEl.innerText = t("auth.password_updated_login"); - showToast(t("auth.password_updated_login"), "success", 4000); - if (currentEl) currentEl.value = ""; - if (newEl) newEl.value = ""; - state.usingDefaultPassword = false; - await login(newPassword, statusId, buttonId); - } else { - const msg = data.code === "local_required" - ? t("auth.password_change_local") - : (data.error || t("auth.password_update_failed")); - if (statusEl) statusEl.innerText = msg; - showToast(msg, "error", 5000); - } - } catch (e) { - const msg = e.message || t("auth.password_update_failed"); - if (statusEl) statusEl.innerText = msg; - showToast(`${t("auth.password_update_failed")}: ${msg}`, "error", 5000); - } finally { - setButtonLoading(button, false); - } -} - -async function checkSession() { - try { - const res = await api("/api/session"); - const data = await res.json(); - state.authenticated = data.authenticated; - state.usingDefaultPassword = !!data.using_default_password; - const warning = get("warningBox"); - if (warning) { - warning.style.display = data.using_default_password ? "block" : "none"; - } - const navFooter = get("navFooter"); - if (navFooter) { - navFooter.innerText = data.summary || ""; - } - updateAuthPanels(); - return data; - } catch (e) { - return { authenticated: false }; - } -} - -async function fetchStatus() { - if (!shouldFetch("status")) return; - try { - const res = await api("/api/status"); - const data = await res.json(); - state.bot = data; - recordFetchSuccess("status"); - updateBotUI(); - } catch (e) { - recordFetchError("status"); - } -} - -function updateBotUI() { - const badge = get("botStateBadge"); - const metaL = get("botStatusMetaLanding"); - const hintL = get("botHintLanding"); - - if (state.bot.running) { - badge.innerText = t("bot.status.running"); - badge.className = "badge success"; - const pidText = state.bot.pid != null ? `PID: ${state.bot.pid}` : "PID: --"; - const uptimeText = state.bot.uptime_seconds != null - ? `Uptime: ${Math.round(state.bot.uptime_seconds)}s` - : ""; - const parts = [pidText, uptimeText].filter(Boolean); - metaL.innerText = parts.length ? parts.join(" | ") : "--"; - hintL.innerText = t("bot.hint.running"); - get("botStartBtnLanding").disabled = true; - get("botStopBtnLanding").disabled = false; - } else { - badge.innerText = t("bot.status.stopped"); - badge.className = "badge"; - metaL.innerText = "--"; - hintL.innerText = t("bot.hint.stopped"); - get("botStartBtnLanding").disabled = false; - get("botStopBtnLanding").disabled = true; - } -} - -async function botAction(action) { - try { - await api(`/api/bot/${action}`, { method: "POST" }); - await fetchStatus(); - } catch (e) { } -} - -function startWebuiRestartPoll() { - let attempts = 0; - const timer = setInterval(async () => { - attempts += 1; - try { - const res = await fetch("/api/session", { credentials: "same-origin" }); - if (res.ok) { - clearInterval(timer); - location.reload(); - } - } catch (e) { - // ignore during restart - } - if (attempts > 60) { - clearInterval(timer); - } - }, 1000); -} - -async function updateAndRestartWebui(button) { - if (!state.authenticated) { - showToast(t("auth.unauthorized"), "error", 5000); - return; - } - setButtonLoading(button, true); - try { - showToast(t("update.working"), "info", 4000); - const res = await api("/api/update-restart", { method: "POST" }); - const data = await res.json(); - if (!data.success) { - throw new Error(data.error || t("update.failed")); - } - if (!data.eligible) { - showToast(`${t("update.not_eligible")}: ${data.reason || ""}`.trim(), "warning", 7000); - return; - } - - if (data.will_restart === false) { - if (data.output) { - console.log(data.output); - } - showToast(t("update.no_restart"), "warning", 8000); - return; - } - - if (data.updated) { - showToast(t("update.updated_restarting"), "success", 6000); - } else { - showToast(t("update.uptodate_restarting"), "info", 6000); - } - - // The server will restart shortly; start polling to recover the UI. - startWebuiRestartPoll(); - } catch (e) { - showToast(`${t("update.failed")}: ${e.message || e}`.trim(), "error", 8000); - } finally { - setButtonLoading(button, false); - } -} - -async function loadConfig() { - if (state.configLoading) return; - state.configLoading = true; - state.configLoaded = false; - setConfigState("loading"); - try { - const res = await api("/api/config/summary"); - const data = await res.json(); - state.config = data.data; - state.comments = data.comments || {}; - state.configLoaded = true; - buildConfigForm(); - setConfigState(null); - } catch (e) { - setConfigState("error"); - showToast(t("config.error"), "error", 5000); - } finally { - state.configLoading = false; - } -} - -function getComment(path) { - const entry = state.comments && state.comments[path]; - if (!entry) return ""; - if (typeof entry === "string") return entry; - return entry[state.lang] || entry.en || entry.zh || ""; -} - -function updateCommentTexts() { - document.querySelectorAll("[data-comment-path]").forEach(el => { - const path = el.getAttribute("data-comment-path"); - if (!path) return; - const text = getComment(path); - el.innerText = text; - }); -} - -function updateConfigSearchIndex() { - document.querySelectorAll(".form-group").forEach(group => { - const label = group.querySelector(".form-label"); - const hint = group.querySelector(".form-hint"); - const path = group.dataset.path || ""; - const searchText = `${path} ${label ? label.innerText : ""} ${hint ? hint.innerText : ""}`.toLowerCase(); - group.dataset.searchText = searchText; - }); -} - -function buildConfigForm() { - const container = get("formSections"); - if (!container) return; - container.textContent = ""; - - // Sort sections by SECTION_ORDER logic (already handled by backend mostly, - // but here we render top level keys as cards) - for (const [section, values] of Object.entries(state.config)) { - if (typeof values !== "object" || Array.isArray(values)) continue; - - const card = document.createElement("div"); - card.className = "card config-card"; - card.dataset.section = section; - const collapsed = !!state.configCollapsed[section]; - card.classList.toggle("is-collapsed", collapsed); - - const header = document.createElement("div"); - header.className = "config-card-header"; - - const title = document.createElement("h3"); - title.className = "form-section-title"; - title.textContent = section; - header.appendChild(title); - - const actions = document.createElement("div"); - actions.className = "config-card-actions"; - - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "btn ghost btn-sm"; - toggle.dataset.section = section; - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); - toggle.addEventListener("click", () => toggleSection(section)); - actions.appendChild(toggle); - - header.appendChild(actions); - card.appendChild(header); - - const sectionComment = getComment(section); - if (sectionComment) { - const hint = document.createElement("p"); - hint.className = "form-section-hint"; - hint.innerText = sectionComment; - hint.dataset.commentPath = section; - card.appendChild(hint); - } - - const fieldGrid = document.createElement("div"); - fieldGrid.className = "form-fields"; - card.appendChild(fieldGrid); - - for (const [key, val] of Object.entries(values)) { - // Support one level deep (e.g. models.chat) if needed, - // but the backend summary merges them or keeps them as sub-objects. - if (typeof val === "object" && !Array.isArray(val)) { - // Nested Section - const subSection = document.createElement("div"); - subSection.className = "form-subsection"; - - const subTitle = document.createElement("div"); - subTitle.className = "form-subtitle"; - subTitle.innerText = `[${section}.${key}]`; - subSection.appendChild(subTitle); - - const subCommentKey = `${section}.${key}`; - const subComment = getComment(subCommentKey); - if (subComment) { - const subHint = document.createElement("div"); - subHint.className = "form-subtitle-hint"; - subHint.innerText = subComment; - subHint.dataset.commentPath = subCommentKey; - subSection.appendChild(subHint); - } - - const subGrid = document.createElement("div"); - subGrid.className = "form-fields"; - for (const [sk, sv] of Object.entries(val)) { - subGrid.appendChild(createField(`${section}.${key}.${sk}`, sv)); - } - subSection.appendChild(subGrid); - fieldGrid.appendChild(subSection); - continue; - } - fieldGrid.appendChild(createField(`${section}.${key}`, val)); - } - container.appendChild(card); - } - - updateConfigSearchIndex(); - applyConfigFilter(); -} - -function toggleSection(section) { - state.configCollapsed[section] = !state.configCollapsed[section]; - document.querySelectorAll(".config-card").forEach(card => { - if (card.dataset.section !== section) return; - const collapsed = !!state.configCollapsed[section]; - card.classList.toggle("is-collapsed", collapsed); - const toggle = card.querySelector(".config-card-actions button"); - if (toggle) { - toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - } - }); -} - -function setAllSectionsCollapsed(collapsed) { - document.querySelectorAll(".config-card").forEach(card => { - const section = card.dataset.section; - if (!section) return; - state.configCollapsed[section] = collapsed; - card.classList.toggle("is-collapsed", collapsed); - const toggle = card.querySelector(".config-card-actions button"); - if (toggle) { - toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - } - }); -} - -function applyConfigFilter() { - if (!state.configLoaded) return; - const query = state.configSearch.trim().toLowerCase(); - let matchCount = 0; - document.querySelectorAll(".config-card").forEach(card => { - let cardMatches = 0; - card.querySelectorAll(".form-group").forEach(group => { - const haystack = group.dataset.searchText || ""; - const isMatch = !query || haystack.includes(query); - group.classList.toggle("is-hidden", !isMatch); - group.classList.toggle("is-match", isMatch && query.length > 0); - if (isMatch) cardMatches += 1; - }); - card.querySelectorAll(".form-subsection").forEach(section => { - const visible = section.querySelector(".form-group:not(.is-hidden)"); - section.style.display = visible ? "" : "none"; - }); - card.classList.toggle("force-open", query.length > 0); - card.classList.toggle("is-hidden", query.length > 0 && cardMatches === 0); - matchCount += cardMatches; - }); - - if (query.length > 0 && matchCount === 0) { - setConfigState("empty"); - } else if (state.configLoaded) { - setConfigState(null); - } -} - -function showSaveStatus(status, text) { - const el = get("saveStatus"); - const txt = get("saveStatusText"); - state.saveStatus = status; - if (status === "saving") { - el.style.opacity = "1"; - el.classList.add("active"); - txt.innerText = text || t("config.saving"); - } else if (status === "saved") { - el.classList.remove("active"); - txt.innerText = text || t("config.saved"); - setTimeout(() => { - if (!state.saveTimer) { - el.style.opacity = "0"; - state.saveStatus = "idle"; - updateSaveStatusText(); - } - }, 2000); - } else if (status === "error") { - el.classList.remove("active"); - txt.innerText = text || t("config.save_error"); - el.style.opacity = "1"; - } -} - -function isSensitiveKey(path) { - return /(password|token|secret|api_key|apikey|access_key|private_key)/i.test(path); -} - -function isLongText(value) { - return typeof value === "string" && (value.length > 80 || value.includes("\n")); -} - -function createField(path, val) { - const group = document.createElement("div"); - group.className = "form-group"; - group.dataset.path = path; - - const label = document.createElement("label"); - label.className = "form-label"; - label.innerText = path.split(".").pop(); - group.appendChild(label); - - const comment = getComment(path); - if (comment) { - const hint = document.createElement("div"); - hint.className = "form-hint"; - hint.innerText = comment; - hint.dataset.commentPath = path; - group.appendChild(hint); - } - - const searchText = `${path} ${comment || ""}`.toLowerCase(); - group.dataset.searchText = searchText; - - let input; - if (typeof val === "boolean") { - const wrapper = document.createElement("label"); - wrapper.className = "toggle-wrapper"; - const toggle = document.createElement("input"); - toggle.type = "checkbox"; - toggle.className = "toggle-input config-input"; - toggle.dataset.path = path; - toggle.dataset.valueType = "boolean"; - toggle.checked = Boolean(val); - const track = document.createElement("span"); - track.className = "toggle-track"; - const handle = document.createElement("span"); - handle.className = "toggle-handle"; - track.appendChild(handle); - wrapper.appendChild(toggle); - wrapper.appendChild(track); - group.appendChild(wrapper); - input = toggle; - input.onchange = () => autoSave(); - } else { - const isArray = Array.isArray(val); - const isNumber = typeof val === "number"; - const isSecret = isSensitiveKey(path); - - if (isLongText(val)) { - input = document.createElement("textarea"); - input.className = "form-control form-textarea config-input"; - input.value = val || ""; - input.dataset.valueType = "string"; - } else { - input = document.createElement("input"); - input.className = "form-control config-input"; - if (isNumber) { - input.type = "number"; - input.step = "any"; - input.value = String(val); - input.dataset.valueType = "number"; - } else if (isArray) { - input.type = "text"; - input.value = val.join(", "); - input.dataset.valueType = "array"; - const arrayType = val.every(item => typeof item === "number") ? "number" : "string"; - input.dataset.arrayType = arrayType; - } else { - input.type = isSecret ? "password" : "text"; - input.value = val == null ? "" : String(val); - input.dataset.valueType = "string"; - if (isSecret) { - input.setAttribute("autocomplete", "new-password"); - } - } - } - - input.dataset.path = path; - group.appendChild(input); - input.oninput = () => { - if (state.saveTimer) clearTimeout(state.saveTimer); - showSaveStatus("saving", t("config.typing")); - state.saveTimer = setTimeout(() => { - state.saveTimer = null; - autoSave(); - }, 1000); - }; - } - return group; -} - -async function autoSave() { - showSaveStatus("saving"); - - const patch = {}; - document.querySelectorAll(".config-input").forEach(input => { - const path = input.dataset.path; - let val; - if (input.type === "checkbox") { - val = input.checked; - } else { - const raw = input.value; - const valueType = input.dataset.valueType || "string"; - if (valueType === "number") { - const trimmed = raw.trim(); - if (!trimmed) { - val = ""; - } else { - val = trimmed.includes(".") ? parseFloat(trimmed) : parseInt(trimmed, 10); - if (Number.isNaN(val)) { - val = raw; - } - } - } else if (valueType === "array") { - const items = raw.split(",").map(s => s.trim()).filter(Boolean); - if (input.dataset.arrayType === "number") { - val = items.map(item => { - const num = Number(item); - return Number.isNaN(num) ? item : num; - }); - } else { - val = items; - } - } else { - val = raw; - } - } - patch[path] = val; - }); - - try { - const res = await api("/api/patch", { - method: "POST", - body: JSON.stringify({ patch }) - }); - const data = await res.json(); - if (data.success) { - showSaveStatus("saved"); - if (data.warning) { - showToast(`${t("common.warning")}: ${data.warning}`, "warning", 5000); - } - } else { - showSaveStatus("error", t("config.save_error")); - showToast(`${t("common.error")}: ${data.error}`, "error", 5000); - } - } catch (e) { - showSaveStatus("error", t("config.save_network_error")); - showToast(`${t("common.error")}: ${e.message}`, "error", 5000); - } -} - -async function resetConfig() { - if (!confirm(t("config.reset_confirm"))) return; - try { - await loadConfig(); - showToast(t("config.reload_success"), "info"); - } catch (e) { - showToast(t("config.reload_error"), "error"); - } -} - -async function fetchLogs(force = false) { - if (!force && !shouldFetch("logs")) return; - const container = get("logContainer"); - if (container && !state.logsRaw) { - container.dataset.placeholder = "true"; - container.innerText = t("logs.initializing"); - } - if (state.logType === "all" && !state.logFile) { - state.logsRaw = ""; - renderLogs(); - return; - } - try { - const params = new URLSearchParams({ - lines: "200", - type: state.logType, - }); - if (state.logFile) { - params.set("file", state.logFile); - } - const res = await api(`/api/logs?${params.toString()}`); - const text = await res.text(); - state.logsRaw = text || ""; - recordFetchSuccess("logs"); - renderLogs(); - } catch (e) { - recordFetchError("logs"); - if (!container) return; - container.dataset.placeholder = "true"; - container.innerText = e.message === "Unauthorized" ? t("logs.unauthorized") : t("logs.error"); - updateLogMeta(0, 0); - } -} - -function filterLogLines(raw) { - // 日志过滤:等级筛选交给 LogsController 统一处理 - const query = state.logSearch.trim().toLowerCase(); - const rawLines = raw ? raw.split(/\r?\n/) : []; - const base = window.LogsController - ? window.LogsController.filterLogLines(raw, { - level: state.logLevel, - gte: state.logLevelGte, - }) - : { filtered: rawLines, total: rawLines.length }; - - let filtered = base.filtered; - if (query) { - filtered = filtered.filter(line => line.toLowerCase().includes(query)); - } - - const total = base.total ?? rawLines.length; - const matched = filtered.filter(line => line.length > 0).length; - - return { filtered, total, matched }; -} - -function formatLogText(text) { - if (!text) return ""; - let escaped = escapeHtml(text); - const query = state.logSearch.trim(); - if (query) { - const regex = new RegExp(escapeRegExp(query), "gi"); - escaped = escaped.replace(regex, '$&'); - } - escaped = escaped.replace( - /(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/g, - '$1' - ); - return escaped - .replace(/\x1b\[31m/g, '') - .replace(/\x1b\[32m/g, '') - .replace(/\x1b\[33m/g, '') - .replace(/\x1b\[34m/g, '') - .replace(/\x1b\[35m/g, '') - .replace(/\x1b\[36m/g, '') - .replace(/\x1b\[0m/g, ''); -} - -function renderLogs() { - const container = get("logContainer"); - if (!container) return; - if (!state.logsRaw) { - container.dataset.placeholder = "true"; - container.innerText = t("logs.empty"); - state.logAtBottom = true; - updateLogJumpButton(); - updateLogMeta(0, 0); - return; - } - - const { filtered, total, matched } = filterLogLines(state.logsRaw); - if (filtered.length === 0) { - container.dataset.placeholder = "true"; - container.innerText = t("logs.empty"); - state.logAtBottom = true; - updateLogJumpButton(); - updateLogMeta(total, 0); - return; - } - - const formatted = formatLogText(filtered.join("\n")); - container.innerHTML = formatted; - container.dataset.placeholder = "false"; - if (state.logAutoRefresh && state.logAtBottom) { - container.scrollTop = container.scrollHeight; - } - updateLogJumpButton(); - updateLogMeta(total, matched); -} - -function updateLogMeta(total, matched) { - const meta = get("logMeta"); - if (!meta) return; - const parts = []; - if (state.logsPaused) { - parts.push(t("logs.paused")); - } - if (state.logLevel !== "all" || state.logSearch.trim() || state.logLevelGte) { - const stats = total > 0 ? `${matched}/${total}` : "0/0"; - parts.push(`${t("logs.filtered")}: ${stats}`); - } - meta.innerText = parts.join(" | "); -} - -function updateLogJumpButton() { - const button = get("btnJumpLogs"); - if (!button) return; - if (state.logAtBottom) { - button.style.visibility = "hidden"; - button.style.pointerEvents = "none"; - } else { - button.style.visibility = "visible"; - button.style.pointerEvents = "auto"; - } -} - -function bindLogScroll() { - if (state.logScrollBound) return; - const container = get("logContainer"); - if (!container) return; - container.addEventListener("scroll", () => { - const threshold = 24; - state.logAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; - updateLogJumpButton(); - }); - state.logScrollBound = true; - updateLogJumpButton(); -} - -function startLogStream() { - if (state.logStream || state.logStreamFailed || !window.EventSource) return false; - if (!state.logStreamEnabled) return false; - state.logStreamFailed = false; - const params = new URLSearchParams({ - lines: "200", - type: state.logType, - }); - const stream = new EventSource(`/api/logs/stream?${params.toString()}`); - state.logStream = stream; - - stream.onmessage = (event) => { - state.logsRaw = event.data || ""; - recordFetchSuccess("logs"); - renderLogs(); - }; - - stream.onerror = () => { - state.logStreamFailed = true; - stopLogStream(); - if (state.logAutoRefresh && !state.logsPaused) { - startLogTimer(); - } - }; - - return true; -} - -function stopLogStream() { - if (state.logStream) { - state.logStream.close(); - state.logStream = null; - } -} - -function updateLogRefreshState() { - if (state.view !== "app" || state.tab !== "logs" || document.hidden || !state.authenticated) { - stopLogStream(); - stopLogTimer(); - return; - } - if (state.logsPaused || !state.logAutoRefresh) { - stopLogStream(); - stopLogTimer(); - return; - } - if (!state.logStreamEnabled) { - stopLogStream(); - startLogTimer(); - return; - } - if (startLogStream()) { - stopLogTimer(); - return; - } - startLogTimer(); -} - -async function copyLogsToClipboard() { - const text = state.logsRaw || ""; - if (!text) { - showToast(t("logs.empty"), "info"); - return; - } - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - document.execCommand("copy"); - textarea.remove(); - } - showToast(t("logs.copied"), "success"); - } catch (e) { - showToast(`${t("common.error")}: ${e.message}`, "error"); - } -} - -async function fetchLogFiles(force = false) { - if (state.logFiles[state.logType] && !force) { - updateLogFileSelect(); - return; - } - try { - const res = await api(`/api/logs/files?type=${state.logType}`); - const data = await res.json(); - state.logFiles[state.logType] = data.files || []; - state.logFileCurrent = data.current || ""; - updateLogFileSelect(); - } catch (e) { - state.logFiles[state.logType] = []; - } -} - -function setLogType(type) { - if (state.logType === type) return; - state.logType = type; - state.logFile = ""; - state.logsRaw = ""; - state.logStreamFailed = false; - updateLogTabs(); - fetchLogFiles(true); - renderLogs(); - updateLogRefreshState(); -} - -function downloadLogs() { - const text = state.logsRaw || ""; - if (!text) { - showToast(t("logs.empty"), "info"); - return; - } - const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `undefined-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); - showToast(t("logs.download_ready"), "info"); -} - -async function fetchSystemInfo() { - if (!shouldFetch("system")) return; - try { - const res = await api("/api/system"); - const data = await res.json(); - const cpuUsage = data.cpu_usage_percent ?? 0; - const memUsage = data.memory_usage_percent ?? 0; - - get("systemCpuModel").innerText = data.cpu_model || "--"; - get("systemCpuUsage").innerText = data.cpu_usage_percent != null ? `${cpuUsage}%` : "--"; - get("systemMemory").innerText = - data.memory_total_gb != null && data.memory_used_gb != null - ? `${data.memory_used_gb} GB / ${data.memory_total_gb} GB` - : "--"; - get("systemMemoryUsage").innerText = data.memory_usage_percent != null ? `${memUsage}%` : "--"; - get("systemVersion").innerText = data.system_version || "--"; - get("systemArch").innerText = data.system_arch || "--"; - get("systemKernel").innerText = data.system_release || "--"; - get("systemPythonVersion").innerText = data.python_version || "--"; - get("systemUndefinedVersion").innerText = data.undefined_version || "--"; - - const cpuBar = get("systemCpuBar"); - const memBar = get("systemMemoryBar"); - cpuBar.style.width = `${Math.min(100, Math.max(0, cpuUsage))}%`; - memBar.style.width = `${Math.min(100, Math.max(0, memUsage))}%`; - recordFetchSuccess("system"); - } catch (e) { - recordFetchError("system"); - } -} - -// UI Controllers function refreshUI() { updateI18N(); get("view-landing").className = state.view === "landing" ? "full-view active" : "full-view"; @@ -1577,34 +6,22 @@ function refreshUI() { if (state.view === "app") { if (state.authenticated) { get("appContent").style.display = "block"; - if (!state.configLoaded) { - loadConfig(); - } + if (!state.configLoaded) loadConfig(); } else { get("appContent").style.display = "none"; state.configLoaded = false; } } - if (initialState && initialState.version) { - get("about-version-display").innerText = initialState.version; - } - if (initialState && initialState.license) { - get("about-license-display").innerText = initialState.license; - } + if (initialState && initialState.version) get("about-version-display").innerText = initialState.version; + if (initialState && initialState.license) get("about-license-display").innerText = initialState.license; updateAuthPanels(); if (state.view !== "app" || !state.authenticated) { - stopSystemTimer(); - stopLogStream(); - stopLogTimer(); - } - - if (state.view === "app" && state.tab === "logs" && state.authenticated) { - fetchLogFiles(); + stopSystemTimer(); stopLogStream(); stopLogTimer(); } - + if (state.view === "app" && state.tab === "logs" && state.authenticated) fetchLogFiles(); updateLogRefreshState(); } @@ -1618,10 +35,7 @@ function switchTab(tab) { }); if (tab === "overview") { - if (!document.hidden) { - startSystemTimer(); - fetchSystemInfo(); - } + if (!document.hidden) { startSystemTimer(); fetchSystemInfo(); } } else { stopSystemTimer(); } @@ -1630,19 +44,14 @@ function switchTab(tab) { if (!document.hidden) { fetchLogFiles(); updateLogRefreshState(); - if (!state.logsPaused) { - fetchLogs(true); - } + if (!state.logsPaused) fetchLogs(true); } } else { - stopLogStream(); - stopLogTimer(); + stopLogStream(); stopLogTimer(); } } -// Init async function init() { - // Bind Landing document.querySelectorAll('[data-action="toggle-lang"]').forEach(btn => { btn.addEventListener("click", () => { state.lang = state.lang === "zh" ? "en" : "zh"; @@ -1652,146 +61,57 @@ async function init() { }); document.querySelectorAll('[data-action="toggle-theme"]').forEach(btn => { - btn.addEventListener("click", () => { - const next = state.theme === "dark" ? "light" : "dark"; - applyTheme(next); - }); + btn.addEventListener("click", () => applyTheme(state.theme === "dark" ? "light" : "dark")); }); document.querySelectorAll('[data-action="open-app"]').forEach(el => { - el.onclick = () => { - state.view = "app"; - switchTab(el.getAttribute("data-tab")); - refreshUI(); - }; + el.onclick = () => { state.view = "app"; switchTab(el.getAttribute("data-tab")); refreshUI(); }; }); get("botStartBtnLanding").onclick = () => { - if (!state.authenticated) { - get("landingLoginStatus").innerText = t("auth.subtitle"); - get("landingPasswordInput").focus(); - return; - } + if (!state.authenticated) { get("landingLoginStatus").innerText = t("auth.subtitle"); get("landingPasswordInput").focus(); return; } botAction("start"); }; get("botStopBtnLanding").onclick = () => { - if (!state.authenticated) { - get("landingLoginStatus").innerText = t("auth.subtitle"); - get("landingPasswordInput").focus(); - return; - } + if (!state.authenticated) { get("landingLoginStatus").innerText = t("auth.subtitle"); get("landingPasswordInput").focus(); return; } botAction("stop"); }; - get("landingLoginBtn").onclick = () => - login(get("landingPasswordInput").value, "landingLoginStatus", "landingLoginBtn"); - get("appLoginBtn").onclick = () => - login(get("appPasswordInput").value, "appLoginStatus", "appLoginBtn"); + get("landingLoginBtn").onclick = () => login(get("landingPasswordInput").value, "landingLoginStatus", "landingLoginBtn"); + get("appLoginBtn").onclick = () => login(get("appPasswordInput").value, "appLoginStatus", "appLoginBtn"); const landingResetBtn = get("landingResetPasswordBtn"); if (landingResetBtn) { - landingResetBtn.onclick = () => - changePassword( - "landingCurrentPasswordInput", - "landingNewPasswordInput", - "landingResetStatus", - "landingResetPasswordBtn" - ); + landingResetBtn.onclick = () => changePassword("landingCurrentPasswordInput", "landingNewPasswordInput", "landingResetStatus", "landingResetPasswordBtn"); } const appResetBtn = get("appResetPasswordBtn"); if (appResetBtn) { - appResetBtn.onclick = () => - changePassword( - "appCurrentPasswordInput", - "appNewPasswordInput", - "appResetStatus", - "appResetPasswordBtn" - ); + appResetBtn.onclick = () => changePassword("appCurrentPasswordInput", "appNewPasswordInput", "appResetStatus", "appResetPasswordBtn"); } - get("landingPasswordInput").addEventListener("keydown", (event) => { - if (event.key === "Enter") { - login(get("landingPasswordInput").value, "landingLoginStatus", "landingLoginBtn"); - } - }); - get("appPasswordInput").addEventListener("keydown", (event) => { - if (event.key === "Enter") { - login(get("appPasswordInput").value, "appLoginStatus", "appLoginBtn"); - } - }); - - const landingCurrent = get("landingCurrentPasswordInput"); - const landingNew = get("landingNewPasswordInput"); - if (landingCurrent) { - landingCurrent.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - changePassword( - "landingCurrentPasswordInput", - "landingNewPasswordInput", - "landingResetStatus", - "landingResetPasswordBtn" - ); - } - }); - } - if (landingNew) { - landingNew.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - changePassword( - "landingCurrentPasswordInput", - "landingNewPasswordInput", - "landingResetStatus", - "landingResetPasswordBtn" - ); - } - }); - } + const bindEnterLogin = (inputId, statusId, btnId) => { + const el = get(inputId); + if (el) el.addEventListener("keydown", e => { if (e.key === "Enter") login(el.value, statusId, btnId); }); + }; + bindEnterLogin("landingPasswordInput", "landingLoginStatus", "landingLoginBtn"); + bindEnterLogin("appPasswordInput", "appLoginStatus", "appLoginBtn"); - const appCurrent = get("appCurrentPasswordInput"); - const appNew = get("appNewPasswordInput"); - if (appCurrent) { - appCurrent.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - changePassword( - "appCurrentPasswordInput", - "appNewPasswordInput", - "appResetStatus", - "appResetPasswordBtn" - ); - } - }); - } - if (appNew) { - appNew.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - changePassword( - "appCurrentPasswordInput", - "appNewPasswordInput", - "appResetStatus", - "appResetPasswordBtn" - ); - } + const bindEnterReset = (currentId, newId, statusId, btnId) => { + [get(currentId), get(newId)].forEach(el => { + if (el) el.addEventListener("keydown", e => { if (e.key === "Enter") changePassword(currentId, newId, statusId, btnId); }); }); - } + }; + bindEnterReset("landingCurrentPasswordInput", "landingNewPasswordInput", "landingResetStatus", "landingResetPasswordBtn"); + bindEnterReset("appCurrentPasswordInput", "appNewPasswordInput", "appResetStatus", "appResetPasswordBtn"); - // Bind App document.querySelectorAll(".nav-item").forEach(el => { el.addEventListener("click", () => { const v = el.getAttribute("data-view"); const tab = el.getAttribute("data-tab"); - if (v === "landing") { - state.view = "landing"; - refreshUI(); - } else if (tab) { - switchTab(tab); - } - }); - el.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - el.click(); - } + if (v === "landing") { state.view = "landing"; refreshUI(); } + else if (tab) switchTab(tab); }); + el.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.click(); } }); }); const resetBtn = get("btnResetConfig"); @@ -1802,11 +122,7 @@ async function init() { refreshLogsBtn.onclick = async () => { state.logStreamFailed = false; setButtonLoading(refreshLogsBtn, true); - try { - await fetchLogs(true); - } finally { - setButtonLoading(refreshLogsBtn, false); - } + try { await fetchLogs(true); } finally { setButtonLoading(refreshLogsBtn, false); } }; } @@ -1814,20 +130,12 @@ async function init() { if (refreshOverviewBtn) { refreshOverviewBtn.onclick = async () => { setButtonLoading(refreshOverviewBtn, true); - try { - await fetchSystemInfo(); - } finally { - setButtonLoading(refreshOverviewBtn, false); - } + try { await fetchSystemInfo(); } finally { setButtonLoading(refreshOverviewBtn, false); } }; } const updateRestartBtn = get("btnUpdateRestart"); - if (updateRestartBtn) { - updateRestartBtn.onclick = async () => { - await updateAndRestartWebui(updateRestartBtn); - }; - } + if (updateRestartBtn) updateRestartBtn.onclick = () => updateAndRestartWebui(updateRestartBtn); const pauseLogsBtn = get("btnPauseLogs"); if (pauseLogsBtn) { @@ -1836,19 +144,12 @@ async function init() { pauseLogsBtn.innerText = state.logsPaused ? t("logs.resume") : t("logs.pause"); renderLogs(); updateLogRefreshState(); - if (!state.logsPaused) { - state.logStreamFailed = false; - fetchLogs(true); - } + if (!state.logsPaused) { state.logStreamFailed = false; fetchLogs(true); } }; } document.querySelectorAll(".log-tab").forEach(tab => { - tab.addEventListener("click", () => { - const logType = tab.dataset.logType || "bot"; - setLogType(logType); - fetchLogs(true); - }); + tab.addEventListener("click", () => { setLogType(tab.dataset.logType || "bot"); fetchLogs(true); }); }); const logFileSelect = get("logFileSelect"); @@ -1866,168 +167,98 @@ async function init() { state.logAutoRefresh = logAutoRefresh.checked; logAutoRefresh.onchange = () => { state.logAutoRefresh = logAutoRefresh.checked; - if (state.logAutoRefresh) { - state.logStreamFailed = false; - if (!state.logsPaused) { - fetchLogs(true); - } - } + if (state.logAutoRefresh) { state.logStreamFailed = false; if (!state.logsPaused) fetchLogs(true); } updateLogRefreshState(); }; } const logLevelFilter = get("logLevelFilter"); if (logLevelFilter) { - logLevelFilter.onchange = () => { - state.logLevel = logLevelFilter.value || "all"; - renderLogs(); - }; + logLevelFilter.onchange = () => { state.logLevel = logLevelFilter.value || "all"; renderLogs(); }; } const logLevelGteToggle = get("logLevelGteToggle"); if (logLevelGteToggle) { state.logLevelGte = logLevelGteToggle.checked; - logLevelGteToggle.onchange = () => { - state.logLevelGte = logLevelGteToggle.checked; - renderLogs(); - }; + logLevelGteToggle.onchange = () => { state.logLevelGte = logLevelGteToggle.checked; renderLogs(); }; } const logSearchInput = get("logSearchInput"); if (logSearchInput) { - logSearchInput.addEventListener("input", () => { - state.logSearch = logSearchInput.value || ""; - renderLogs(); - }); + logSearchInput.addEventListener("input", () => { state.logSearch = logSearchInput.value || ""; renderLogs(); }); } const logClearBtn = get("btnClearLogs"); - if (logClearBtn) { - logClearBtn.onclick = () => { - state.logsRaw = ""; - renderLogs(); - showToast(t("logs.cleared"), "info"); - }; - } + if (logClearBtn) logClearBtn.onclick = () => { state.logsRaw = ""; renderLogs(); showToast(t("logs.cleared"), "info"); }; const logCopyBtn = get("btnCopyLogs"); - if (logCopyBtn) { - logCopyBtn.onclick = copyLogsToClipboard; - } + if (logCopyBtn) logCopyBtn.onclick = copyLogsToClipboard; const logDownloadBtn = get("btnDownloadLogs"); - if (logDownloadBtn) { - logDownloadBtn.onclick = downloadLogs; - } + if (logDownloadBtn) logDownloadBtn.onclick = downloadLogs; const logJumpBtn = get("btnJumpLogs"); if (logJumpBtn) { logJumpBtn.onclick = () => { const container = get("logContainer"); - if (container) { - container.scrollTop = container.scrollHeight; - state.logAtBottom = true; - updateLogJumpButton(); - } + if (container) { container.scrollTop = container.scrollHeight; state.logAtBottom = true; updateLogJumpButton(); } }; } const configSearchInput = get("configSearchInput"); if (configSearchInput) { - configSearchInput.addEventListener("input", () => { - state.configSearch = configSearchInput.value || ""; - applyConfigFilter(); - }); + configSearchInput.addEventListener("input", () => { state.configSearch = configSearchInput.value || ""; applyConfigFilter(); }); } const configSearchClear = get("configSearchClear"); if (configSearchClear && configSearchInput) { - configSearchClear.onclick = () => { - configSearchInput.value = ""; - state.configSearch = ""; - applyConfigFilter(); - configSearchInput.focus(); - }; + configSearchClear.onclick = () => { configSearchInput.value = ""; state.configSearch = ""; applyConfigFilter(); configSearchInput.focus(); }; } const expandAllBtn = get("btnExpandAll"); - if (expandAllBtn) { - expandAllBtn.onclick = () => setAllSectionsCollapsed(false); - } + if (expandAllBtn) expandAllBtn.onclick = () => setAllSectionsCollapsed(false); const collapseAllBtn = get("btnCollapseAll"); - if (collapseAllBtn) { - collapseAllBtn.onclick = () => setAllSectionsCollapsed(true); - } + if (collapseAllBtn) collapseAllBtn.onclick = () => setAllSectionsCollapsed(true); + const logout = async () => { - try { - await api("/api/logout", { method: "POST" }); - } catch (e) { - // ignore - } - setToken(null); + try { await api("/api/logout", { method: "POST" }); } catch (e) { } state.authenticated = false; state.view = "landing"; refreshUI(); }; - get("logoutBtn").onclick = logout; get("mobileLogoutBtn").onclick = logout; - // Initial data - if (initialState && initialState.theme) { - applyTheme(initialState.theme); - } else { - applyTheme("light"); - } + applyTheme(initialState && initialState.theme ? initialState.theme : "light"); try { const session = await checkSession(); state.authenticated = !!session.authenticated; } catch (e) { - console.error("Session check failed", e); state.authenticated = false; } - const shouldRedirectToConfig = !!( - initialState && initialState.redirect_to_config - ); - if (shouldRedirectToConfig) { - state.view = "app"; - switchTab("config"); - } + const shouldRedirectToConfig = !!(initialState && initialState.redirect_to_config); + if (shouldRedirectToConfig) { state.view = "app"; switchTab("config"); } document.addEventListener("visibilitychange", () => { if (document.hidden) { - stopStatusTimer(); - stopSystemTimer(); - stopLogStream(); - stopLogTimer(); - return; + stopStatusTimer(); stopSystemTimer(); stopLogStream(); stopLogTimer(); return; } - startStatusTimer(); - if (state.view === "app" && state.tab === "overview") { - startSystemTimer(); - fetchSystemInfo(); - } + if (state.view === "app" && state.tab === "overview") { startSystemTimer(); fetchSystemInfo(); } if (state.view === "app" && state.tab === "logs") { updateLogRefreshState(); - if (!state.logsPaused) { - fetchLogs(true); - } + if (!state.logsPaused) fetchLogs(true); } }); refreshUI(); - if (shouldRedirectToConfig) { - showToast(t("config.bootstrap_created"), "info", 6500); - } + if (shouldRedirectToConfig) showToast(t("config.bootstrap_created"), "info", 6500); bindLogScroll(); fetchStatus(); - if (!document.hidden) { - startStatusTimer(); - } + if (!document.hidden) startStatusTimer(); } document.addEventListener("DOMContentLoaded", init); diff --git a/src/Undefined/webui/static/js/state.js b/src/Undefined/webui/static/js/state.js new file mode 100644 index 0000000..c4001e3 --- /dev/null +++ b/src/Undefined/webui/static/js/state.js @@ -0,0 +1,102 @@ +function readJsonScript(id, fallback) { + const el = document.getElementById(id); + if (!el) return fallback; + try { + const text = (el.textContent || "").trim(); + if (!text) return fallback; + return JSON.parse(text); + } catch (e) { + return fallback; + } +} + +const initialState = readJsonScript("initial-state", {}); +const initialView = readJsonScript("initial-view", "landing"); + +const state = { + lang: (initialState && initialState.lang) || getCookie("undefined_lang") || "zh", + theme: "light", + authenticated: false, + usingDefaultPassword: !!(initialState && initialState.using_default_password), + configExists: !!(initialState && initialState.config_exists), + tab: "overview", + view: initialView || "landing", + config: {}, + comments: {}, + configCollapsed: {}, + configSearch: "", + configLoading: false, + configLoaded: false, + bot: { running: false, pid: null, uptime: 0 }, + logsRaw: "", + logSearch: "", + logLevel: "all", + logLevelGte: false, + logType: "bot", + logFiles: {}, + logFile: "", + logFileCurrent: "", + logStreamEnabled: true, + logsPaused: false, + logAutoRefresh: true, + logStream: null, + logStreamFailed: false, + logAtBottom: true, + logScrollBound: false, + logTimer: null, + statusTimer: null, + systemTimer: null, + saveTimer: null, + saveStatus: "idle", + fetchBackoff: { status: 0, system: 0, logs: 0 }, + nextFetchAt: { status: 0, system: 0, logs: 0 }, +}; + +const REFRESH_INTERVALS = { + status: 3000, + system: 1000, + logs: 1000, +}; + +const THEME_COLORS = { + light: "#f9f5f1", + dark: "#0f1112", +}; + +const LOG_LEVELS = window.LogsController ? window.LogsController.LOG_LEVELS : ["all"]; + +function get(id) { return document.getElementById(id); } +function t(key) { return I18N[state.lang][key] || key; } + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function setButtonLoading(button, loading) { + if (!button) return; + button.disabled = loading; + button.classList.toggle("is-loading", loading); + button.setAttribute("aria-busy", loading ? "true" : "false"); +} + +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + +function setCookie(name, value, days = 30) { + const d = new Date(); + d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); + const expires = `expires=${d.toUTCString()}`; + document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`; +} diff --git a/src/Undefined/webui/static/js/ui.js b/src/Undefined/webui/static/js/ui.js new file mode 100644 index 0000000..17ab62f --- /dev/null +++ b/src/Undefined/webui/static/js/ui.js @@ -0,0 +1,190 @@ +function updateI18N() { + document.querySelectorAll("[data-i18n]").forEach(el => { + el.innerText = t(el.getAttribute("data-i18n")); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach(el => { + el.placeholder = t(el.getAttribute("data-i18n-placeholder")); + }); + updateToggleLabels(); + updateCommentTexts(); + updateConfigSearchIndex(); + applyConfigFilter(); + updateSectionToggleLabels(); + updateLogPlaceholder(); + updateLogFilterLabels(); + updateLogPauseLabel(); + updateLogTabs(); + updateLogFileSelect(); + updateSaveStatusText(); + updateConfigStateLabel(); + renderLogs(); +} + +function updateToggleLabels() { + const langLabel = state.lang === "zh" ? "English" : "中文"; + document.querySelectorAll('[data-action="toggle-lang"]').forEach(btn => { + btn.innerText = langLabel; + }); + const themeLabel = state.theme === "dark" ? t("theme.dark") : t("theme.light"); + document.querySelectorAll('[data-action="toggle-theme"]').forEach(btn => { + btn.innerText = themeLabel; + }); +} + +function updateLogPlaceholder() { + const container = get("logContainer"); + if (!container) return; + if (container.dataset.placeholder === "true") { + container.innerText = t("logs.initializing"); + } +} + +function updateLogFilterLabels() { + const select = get("logLevelFilter"); + if (!select) return; + const current = select.value || state.logLevel; + while (select.firstChild) select.removeChild(select.firstChild); + LOG_LEVELS.forEach(level => { + const option = document.createElement("option"); + option.value = level; + option.textContent = t(`logs.level.${level}`); + select.appendChild(option); + }); + const valid = LOG_LEVELS.includes(current) ? current : "all"; + select.value = valid; + state.logLevel = valid; +} + +function updateLogPauseLabel() { + const button = get("btnPauseLogs"); + if (!button) return; + button.innerText = state.logsPaused ? t("logs.resume") : t("logs.pause"); +} + +function updateLogTabs() { + document.querySelectorAll(".log-tab").forEach(tab => { + tab.classList.toggle("active", tab.dataset.logType === state.logType); + }); +} + +function updateLogFileSelect() { + const select = get("logFileSelect"); + if (!select) return; + const files = state.logFiles[state.logType] || []; + select.disabled = files.length === 0; + while (select.firstChild) select.removeChild(select.firstChild); + files.forEach(file => { + const option = document.createElement("option"); + option.value = file.name; + let label = file.current ? t("logs.file.current") : t("logs.file.history"); + if (state.logType === "all") label = t("logs.file.other"); + option.textContent = `${file.name} (${label})`; + select.appendChild(option); + }); + if (!state.logFile || !files.find(file => file.name === state.logFile)) { + state.logFile = state.logFileCurrent || (files[0] && files[0].name) || ""; + } + select.value = state.logFile; + updateLogStreamEligibility(); +} + +function updateLogStreamEligibility() { + if (state.logType === "all") { + state.logStreamEnabled = false; + return; + } + state.logStreamEnabled = !state.logFile || state.logFile === state.logFileCurrent; +} + +function updateConfigStateLabel() { + const stateEl = get("configState"); + if (!stateEl) return; + const key = stateEl.dataset.i18nState; + if (key) stateEl.innerText = t(key); +} + +function updateSaveStatusText() { + const txt = get("saveStatusText"); + if (!txt) return; + if (state.saveStatus === "saving") { + txt.innerText = t("config.saving"); + } else if (state.saveStatus === "saved") { + txt.innerText = t("config.saved"); + } else if (state.saveStatus === "error") { + txt.innerText = t("config.save_error"); + } else { + txt.innerText = t("config.saving"); + } +} + +function updateAuthPanels() { + const usingDefault = !!state.usingDefaultPassword; + const showLanding = !state.authenticated && state.view === "landing"; + const showAppLogin = !state.authenticated && state.view === "app"; + + const landingLogin = get("landingLoginBox"); + const landingReset = get("landingPasswordResetBox"); + if (landingLogin) landingLogin.style.display = showLanding && !usingDefault ? "block" : "none"; + if (landingReset) landingReset.style.display = showLanding && usingDefault ? "block" : "none"; + + const appLogin = get("appLoginBox"); + const appReset = get("appPasswordResetBox"); + if (appLogin) appLogin.style.display = showAppLogin && !usingDefault ? "block" : "none"; + if (appReset) appReset.style.display = showAppLogin && usingDefault ? "block" : "none"; +} + +function updateSectionToggleLabels() { + document.querySelectorAll(".config-card").forEach(card => { + const section = card.dataset.section; + const toggle = card.querySelector(".config-card-actions button"); + if (!toggle || !section) return; + const collapsed = !!state.configCollapsed[section]; + toggle.innerText = collapsed ? t("config.expand_section") : t("config.collapse_section"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + }); +} + +function updateThemeColor(theme) { + const meta = document.getElementById("themeColorMeta"); + if (!meta) return; + meta.setAttribute("content", THEME_COLORS[theme] || THEME_COLORS.light); +} + +function applyTheme(theme) { + const normalized = theme === "dark" ? "dark" : "light"; + state.theme = normalized; + document.documentElement.setAttribute("data-theme", normalized); + setCookie("undefined_theme", normalized); + updateToggleLabels(); + updateThemeColor(normalized); +} + +function showToast(message, type = "info", duration = 3000) { + const container = get("toast-container"); + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.innerText = message; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add("removing"); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +function setConfigState(mode) { + const stateEl = get("configState"); + const grid = get("formSections"); + if (!stateEl || !grid) return; + if (!mode) { + stateEl.style.display = "none"; + stateEl.dataset.i18nState = ""; + grid.style.display = "block"; + return; + } + const keyMap = { loading: "config.loading", error: "config.error", empty: "config.no_results" }; + const key = keyMap[mode] || "common.error"; + stateEl.dataset.i18nState = key; + stateEl.innerText = t(key); + stateEl.style.display = "block"; + grid.style.display = mode === "loading" || mode === "error" ? "none" : "block"; +} diff --git a/src/Undefined/webui/templates/index.html b/src/Undefined/webui/templates/index.html index ad99704..f47d605 100644 --- a/src/Undefined/webui/templates/index.html +++ b/src/Undefined/webui/templates/index.html @@ -367,6 +367,14 @@

MIT License

+ + + + + + + + diff --git a/src/Undefined/webui/utils.py b/src/Undefined/webui/utils.py deleted file mode 100644 index 513a9e8..0000000 --- a/src/Undefined/webui/utils.py +++ /dev/null @@ -1,386 +0,0 @@ -from __future__ import annotations - -import logging -import tomllib -from functools import lru_cache -from pathlib import Path -from typing import Any, cast - -from Undefined.config.loader import CONFIG_PATH, Config -from Undefined.utils.resources import resolve_resource_path - -logger = logging.getLogger(__name__) - -CONFIG_EXAMPLE_PATH = Path("config.toml.example") - - -def _resolve_config_example_path(path: Path = CONFIG_EXAMPLE_PATH) -> Path | None: - if path.exists(): - return path - try: - return resolve_resource_path(str(path)) - except FileNotFoundError: - return None - except Exception: - return None - - -TomlData = dict[str, Any] -CommentMap = dict[str, dict[str, str]] - -OrderMap = dict[str, list[str]] - - -def _build_order_map( - table: TomlData, - path: list[str] | None = None, - out: OrderMap | None = None, -) -> OrderMap: - """从 config.toml.example 推导配置渲染顺序。 - - - key 顺序使用 TOML 解析后的插入顺序 - - 递归记录每个 table 路径(例如 "", "core", "models.chat")下的 key 顺序 - """ - - if out is None: - out = {} - if path is None: - path = [] - - path_key = ".".join(path) if path else "" - out[path_key] = list(table.keys()) - - for key, value in table.items(): - if isinstance(value, dict): - _build_order_map(cast(TomlData, value), path + [key], out) - return out - - -@lru_cache -def get_config_order_map() -> OrderMap: - """获取配置顺序映射(以 config.toml.example 为准)。""" - - defaults = load_default_data() - if not defaults: - return {} - return _build_order_map(defaults) - - -def read_config_source() -> dict[str, Any]: - if CONFIG_PATH.exists(): - return { - "content": CONFIG_PATH.read_text(encoding="utf-8"), - "exists": True, - "source": str(CONFIG_PATH), - } - example_path = _resolve_config_example_path() - if example_path is not None and example_path.exists(): - return { - "content": example_path.read_text(encoding="utf-8"), - "exists": False, - "source": str(example_path), - } - return { - "content": "[core]\nbot_qq = 0\nsuperadmin_qq = 0\n", - "exists": False, - "source": "inline", - } - - -def ensure_config_toml( - config_path: Path = CONFIG_PATH, - example_path: Path = CONFIG_EXAMPLE_PATH, -) -> bool: - """确保 config.toml 存在。 - - - 当 config.toml 不存在时,优先从 config.toml.example 复制生成 - - 仅在本次调用确实创建了文件时返回 True - """ - - if config_path.exists(): - return False - - content: str - resolved_example = _resolve_config_example_path(example_path) - if resolved_example is not None and resolved_example.exists(): - try: - content = resolved_example.read_text(encoding="utf-8") - except Exception as exc: - logger.warning("读取 %s 失败,将使用内置模板: %s", resolved_example, exc) - content = "" - else: - content = "" - - if not content.strip(): - content = "[core]\nbot_qq = 0\nsuperadmin_qq = 0\n" - - try: - # 使用独占创建,避免并发启动时覆盖已有文件 - with open(config_path, "x", encoding="utf-8") as f: - f.write(content) - logger.info( - "已生成 %s(来源:%s)", - config_path, - resolved_example or example_path, - ) - return True - except FileExistsError: - return False - except Exception as exc: - logger.warning("生成 %s 失败: %s", config_path, exc) - return False - - -def validate_toml(content: str) -> tuple[bool, str]: - try: - tomllib.loads(content) - return True, "OK" - except tomllib.TOMLDecodeError as exc: - return False, f"TOML parse error: {exc}" - - -def validate_required_config() -> tuple[bool, str]: - try: - Config.load(strict=True) - return True, "OK" - except Exception as exc: - return False, str(exc) - - -def load_default_data() -> TomlData: - example_path = _resolve_config_example_path() - if example_path is None or not example_path.exists(): - return {} - try: - with open(example_path, "rb") as f: - data = tomllib.load(f) - if isinstance(data, dict): - return data - return {} - except Exception: - return {} - - -def _normalize_comment_buffer(buffer: list[str]) -> dict[str, str]: - if not buffer: - return {} - parts: dict[str, list[str]] = {} - for item in buffer: - lower = item.lower() - if lower.startswith("zh:"): - parts.setdefault("zh", []).append(item[3:].strip()) - elif lower.startswith("en:"): - parts.setdefault("en", []).append(item[3:].strip()) - else: - parts.setdefault("default", []).append(item) - - default = " ".join(parts.get("default", [])).strip() - zh_value = " ".join(parts.get("zh", [])).strip() - en_value = " ".join(parts.get("en", [])).strip() - - result: dict[str, str] = {} - if zh_value: - result["zh"] = zh_value - if en_value: - result["en"] = en_value - if default: - if "zh" not in result: - result["zh"] = default - if "en" not in result: - result["en"] = default - return result - - -def parse_comment_map(path: Path) -> CommentMap: - if not path.exists(): - return {} - comments: CommentMap = {} - buffer: list[str] = [] - current_section = "" - try: - lines = path.read_text(encoding="utf-8").splitlines() - except Exception: - return {} - - for raw_line in lines: - line = raw_line.strip() - if not line: - if buffer: - buffer.clear() - continue - if line.startswith("#"): - buffer.append(line.lstrip("#").strip()) - continue - if line.startswith("[") and line.endswith("]"): - section_name = line[1:-1].strip() - if buffer: - comment = _normalize_comment_buffer(buffer) - if comment: - comments[section_name] = comment - buffer.clear() - current_section = section_name - continue - if "=" in line: - key = line.split("=", 1)[0].strip() - if buffer: - comment = _normalize_comment_buffer(buffer) - if comment: - path_key = f"{current_section}.{key}" if current_section else key - comments[path_key] = comment - buffer.clear() - continue - if buffer: - buffer.clear() - return comments - - -def load_comment_map() -> CommentMap: - example_path = _resolve_config_example_path() - comments = parse_comment_map(example_path) if example_path else {} - if CONFIG_PATH.exists(): - overrides = parse_comment_map(CONFIG_PATH) - for key, value in overrides.items(): - if key not in comments: - comments[key] = value - continue - merged = dict(comments[key]) - merged.update(value) - comments[key] = merged - return comments - - -def merge_defaults(defaults: TomlData, data: TomlData) -> TomlData: - merged: TomlData = dict(defaults) - for key, value in data.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = merge_defaults(merged[key], value) - else: - merged[key] = value - return merged - - -def sort_config(data: TomlData) -> TomlData: - """按 config.toml.example 的顺序排序配置""" - - order_map = get_config_order_map() - ordered: TomlData = {} - sections = order_map.get("", []) - # 保证指定顺序的 section 在前面 - for s in sections: - if s in data: - val = data[s] - if isinstance(val, dict): - sub_ordered: TomlData = {} - keys = order_map.get(s, []) - for k in keys: - if k in val: - sub_ordered[k] = val[k] - for k in sorted(val.keys()): - if k not in sub_ordered: - sub_ordered[k] = val[k] - ordered[s] = sub_ordered - else: - ordered[s] = val - # 添加剩余的 section - for s in sorted(data.keys()): - if s not in ordered: - ordered[s] = data[s] - return ordered - - -def sorted_keys(table: TomlData, path: list[str]) -> list[str]: - path_key = ".".join(path) if path else "" - order = get_config_order_map().get(path_key) - if not order: - return sorted(table.keys()) - order_index = {name: idx for idx, name in enumerate(order)} - return sorted( - table.keys(), - key=lambda name: (order_index.get(name, 999), name), - ) - - -def format_value(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, str): - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - if isinstance(value, list): - items = ", ".join(format_value(item) for item in value) - return f"[{items}]" - return f'"{str(value)}"' - - -def render_table(path: list[str], table: TomlData) -> list[str]: - lines: list[str] = [] - items: list[str] = [] - for key in sorted_keys(table, path): - value = table[key] - if isinstance(value, dict): - continue - items.append(f"{key} = {format_value(value)}") - if items and path: - lines.append(f"[{'.'.join(path)}]") - lines.extend(items) - lines.append("") - elif items and not path: - lines.extend(items) - lines.append("") - - for key in sorted_keys(table, path): - value = table[key] - if not isinstance(value, dict): - continue - lines.extend(render_table(path + [key], value)) - return lines - - -def render_toml(data: TomlData) -> str: - if not data: - return "" - lines = render_table([], data) - return "\n".join(lines).rstrip() + "\n" - - -def apply_patch(data: TomlData, patch: dict[str, Any]) -> TomlData: - for path, value in patch.items(): - if not path: - continue - parts = path.split(".") - node = data - for key in parts[:-1]: - if key not in node or not isinstance(node[key], dict): - node[key] = {} - node = node[key] - node[parts[-1]] = value - return data - - -def tail_file(path: Path, lines: int) -> str: - if lines <= 0: - return "" - if not path.exists(): - return f"Log file not found: {path}" - try: - with open(path, "rb") as f: - f.seek(0, 2) - file_size = f.tell() - block_size = 4096 - data = bytearray() - remaining = file_size - while remaining > 0 and data.count(b"\n") <= lines: - read_size = min(block_size, remaining) - f.seek(remaining - read_size) - chunk = f.read(read_size) - data[:0] = chunk - remaining -= read_size - - # 使用 errors='replace' 防止截断导致的 unicode 错误 - text = data.decode("utf-8", errors="replace") - # 确保只返回最后 N 行 - return "\n".join(text.splitlines()[-lines:]) - except Exception as exc: - return f"Failed to read logs: {exc}" diff --git a/src/Undefined/webui/utils/__init__.py b/src/Undefined/webui/utils/__init__.py new file mode 100644 index 0000000..9d3c496 --- /dev/null +++ b/src/Undefined/webui/utils/__init__.py @@ -0,0 +1,43 @@ +from .config_io import ( + read_config_source, + ensure_config_toml, + load_default_data, + validate_toml, + validate_required_config, + tail_file, + CONFIG_EXAMPLE_PATH, + TomlData, +) +from .comment import CommentMap, parse_comment_map, load_comment_map +from .toml_render import ( + format_value, + render_table, + render_toml, + apply_patch, + sorted_keys, + get_config_order_map, + sort_config, + merge_defaults, +) + +__all__ = [ + "read_config_source", + "ensure_config_toml", + "load_default_data", + "validate_toml", + "validate_required_config", + "tail_file", + "CONFIG_EXAMPLE_PATH", + "TomlData", + "CommentMap", + "parse_comment_map", + "load_comment_map", + "format_value", + "render_table", + "render_toml", + "apply_patch", + "sorted_keys", + "get_config_order_map", + "sort_config", + "merge_defaults", +] diff --git a/src/Undefined/webui/utils/comment.py b/src/Undefined/webui/utils/comment.py new file mode 100644 index 0000000..df7fba7 --- /dev/null +++ b/src/Undefined/webui/utils/comment.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path + +from Undefined.config.loader import CONFIG_PATH +from .config_io import _resolve_config_example_path + +CommentMap = dict[str, dict[str, str]] + + +def _normalize_comment_buffer(buffer: list[str]) -> dict[str, str]: + if not buffer: + return {} + parts: dict[str, list[str]] = {} + for item in buffer: + lower = item.lower() + if lower.startswith("zh:"): + parts.setdefault("zh", []).append(item[3:].strip()) + elif lower.startswith("en:"): + parts.setdefault("en", []).append(item[3:].strip()) + else: + parts.setdefault("default", []).append(item) + default = " ".join(parts.get("default", [])).strip() + zh_value = " ".join(parts.get("zh", [])).strip() + en_value = " ".join(parts.get("en", [])).strip() + result: dict[str, str] = {} + if zh_value: + result["zh"] = zh_value + if en_value: + result["en"] = en_value + if default: + result.setdefault("zh", default) + result.setdefault("en", default) + return result + + +def parse_comment_map(path: Path) -> CommentMap: + if not path.exists(): + return {} + comments: CommentMap = {} + buffer: list[str] = [] + current_section = "" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except Exception: + return {} + for raw_line in lines: + line = raw_line.strip() + if not line: + buffer.clear() + continue + if line.startswith("#"): + buffer.append(line.lstrip("#").strip()) + continue + if line.startswith("[") and line.endswith("]"): + section_name = line[1:-1].strip() + if buffer: + comment = _normalize_comment_buffer(buffer) + if comment: + comments[section_name] = comment + buffer.clear() + current_section = section_name + continue + if "=" in line: + key = line.split("=", 1)[0].strip() + if buffer: + comment = _normalize_comment_buffer(buffer) + if comment: + path_key = f"{current_section}.{key}" if current_section else key + comments[path_key] = comment + buffer.clear() + continue + buffer.clear() + return comments + + +def load_comment_map() -> CommentMap: + example_path = _resolve_config_example_path() + comments = parse_comment_map(example_path) if example_path else {} + if CONFIG_PATH.exists(): + for key, value in parse_comment_map(CONFIG_PATH).items(): + if key not in comments: + comments[key] = value + else: + merged = dict(comments[key]) + merged.update(value) + comments[key] = merged + return comments diff --git a/src/Undefined/webui/utils/config_io.py b/src/Undefined/webui/utils/config_io.py new file mode 100644 index 0000000..5632f0b --- /dev/null +++ b/src/Undefined/webui/utils/config_io.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import logging +import tomllib +from pathlib import Path +from typing import Any + +from Undefined.config.loader import CONFIG_PATH, Config +from Undefined.utils.resources import resolve_resource_path + +logger = logging.getLogger(__name__) + +CONFIG_EXAMPLE_PATH = Path("config.toml.example") +TomlData = dict[str, Any] + + +def _resolve_config_example_path(path: Path = CONFIG_EXAMPLE_PATH) -> Path | None: + if path.exists(): + return path + try: + return resolve_resource_path(str(path)) + except Exception: + return None + + +def read_config_source() -> dict[str, Any]: + if CONFIG_PATH.exists(): + return { + "content": CONFIG_PATH.read_text(encoding="utf-8"), + "exists": True, + "source": str(CONFIG_PATH), + } + example_path = _resolve_config_example_path() + if example_path is not None and example_path.exists(): + return { + "content": example_path.read_text(encoding="utf-8"), + "exists": False, + "source": str(example_path), + } + return { + "content": "[core]\nbot_qq = 0\nsuperadmin_qq = 0\n", + "exists": False, + "source": "inline", + } + + +def ensure_config_toml( + config_path: Path = CONFIG_PATH, + example_path: Path = CONFIG_EXAMPLE_PATH, +) -> bool: + if config_path.exists(): + return False + resolved_example = _resolve_config_example_path(example_path) + content = "" + if resolved_example is not None and resolved_example.exists(): + try: + content = resolved_example.read_text(encoding="utf-8") + except Exception as exc: + logger.warning("读取 %s 失败,将使用内置模板: %s", resolved_example, exc) + if not content.strip(): + content = "[core]\nbot_qq = 0\nsuperadmin_qq = 0\n" + try: + with open(config_path, "x", encoding="utf-8") as f: + f.write(content) + logger.info( + "已生成 %s(来源:%s)", config_path, resolved_example or example_path + ) + return True + except FileExistsError: + return False + except Exception as exc: + logger.warning("生成 %s 失败: %s", config_path, exc) + return False + + +def validate_toml(content: str) -> tuple[bool, str]: + try: + tomllib.loads(content) + return True, "OK" + except tomllib.TOMLDecodeError as exc: + return False, f"TOML parse error: {exc}" + + +def validate_required_config() -> tuple[bool, str]: + try: + Config.load(strict=True) + return True, "OK" + except Exception as exc: + return False, str(exc) + + +def load_default_data() -> TomlData: + example_path = _resolve_config_example_path() + if example_path is None or not example_path.exists(): + return {} + try: + with open(example_path, "rb") as f: + data = tomllib.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def tail_file(path: Path, lines: int) -> str: + if lines <= 0: + return "" + if not path.exists(): + return f"Log file not found: {path}" + try: + with open(path, "rb") as f: + f.seek(0, 2) + file_size = f.tell() + block_size = 4096 + data = bytearray() + remaining = file_size + while remaining > 0 and data.count(b"\n") <= lines: + read_size = min(block_size, remaining) + f.seek(remaining - read_size) + data[:0] = f.read(read_size) + remaining -= read_size + return "\n".join(data.decode("utf-8", errors="replace").splitlines()[-lines:]) + except Exception as exc: + return f"Failed to read logs: {exc}" diff --git a/src/Undefined/webui/utils/toml_render.py b/src/Undefined/webui/utils/toml_render.py new file mode 100644 index 0000000..467e7db --- /dev/null +++ b/src/Undefined/webui/utils/toml_render.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Any, cast + +from .config_io import load_default_data + +TomlData = dict[str, Any] +OrderMap = dict[str, list[str]] + + +def _build_order_map( + table: TomlData, path: list[str] | None = None, out: OrderMap | None = None +) -> OrderMap: + if out is None: + out = {} + if path is None: + path = [] + path_key = ".".join(path) if path else "" + out[path_key] = list(table.keys()) + for key, value in table.items(): + if isinstance(value, dict): + _build_order_map(cast(TomlData, value), path + [key], out) + return out + + +@lru_cache +def get_config_order_map() -> OrderMap: + defaults = load_default_data() + if not defaults: + return {} + return _build_order_map(defaults) + + +def sorted_keys(table: TomlData, path: list[str]) -> list[str]: + path_key = ".".join(path) if path else "" + order = get_config_order_map().get(path_key) + if not order: + return sorted(table.keys()) + order_index = {name: idx for idx, name in enumerate(order)} + return sorted(table.keys(), key=lambda name: (order_index.get(name, 999), name)) + + +def format_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return f'"{value.replace(chr(92), chr(92) * 2).replace(chr(34), chr(92) + chr(34))}"' + if isinstance(value, list): + return f"[{', '.join(format_value(item) for item in value)}]" + return f'"{str(value)}"' + + +def _is_array_of_tables(value: Any) -> bool: + return ( + isinstance(value, list) + and bool(value) + and all(isinstance(i, dict) for i in value) + ) + + +def render_table(path: list[str], table: TomlData) -> list[str]: + lines: list[str] = [] + items = [ + f"{key} = {format_value(table[key])}" + for key in sorted_keys(table, path) + if not isinstance(table[key], dict) and not _is_array_of_tables(table[key]) + ] + if items and path: + lines.append(f"[{'.'.join(path)}]") + lines.extend(items) + lines.append("") + elif items: + lines.extend(items) + lines.append("") + for key in sorted_keys(table, path): + value = table[key] + if isinstance(value, dict): + lines.extend(render_table(path + [key], value)) + elif _is_array_of_tables(value): + aot_path = ".".join(path + [key]) + for item in value: + lines.append(f"[[{aot_path}]]") + for k in sorted_keys(item, path + [key]): + v = item[k] + if not isinstance(v, dict): + lines.append(f"{k} = {format_value(v)}") + lines.append("") + return lines + + +def render_toml(data: TomlData) -> str: + if not data: + return "" + return "\n".join(render_table([], data)).rstrip() + "\n" + + +def apply_patch(data: TomlData, patch: dict[str, Any]) -> TomlData: + for path, value in patch.items(): + if not path: + continue + parts = path.split(".") + node = data + for key in parts[:-1]: + if key not in node or not isinstance(node[key], dict): + node[key] = {} + node = node[key] + node[parts[-1]] = value + return data + + +def merge_defaults(defaults: TomlData, data: TomlData) -> TomlData: + merged: TomlData = dict(defaults) + for key, value in data.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = merge_defaults(merged[key], value) + else: + merged[key] = value + return merged + + +def sort_config(data: TomlData) -> TomlData: + order_map = get_config_order_map() + ordered: TomlData = {} + for s in order_map.get("", []): + if s in data: + val = data[s] + if isinstance(val, dict): + sub: TomlData = {} + for k in order_map.get(s, []): + if k in val: + sub[k] = val[k] + for k in sorted(val.keys()): + if k not in sub: + sub[k] = val[k] + ordered[s] = sub + else: + ordered[s] = val + for s in sorted(data.keys()): + if s not in ordered: + ordered[s] = data[s] + return ordered diff --git a/tests/test_model_pool.py b/tests/test_model_pool.py new file mode 100644 index 0000000..ff6deec --- /dev/null +++ b/tests/test_model_pool.py @@ -0,0 +1,827 @@ +"""多模型池功能测试""" + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from Undefined.ai.model_selector import ModelSelector +from Undefined.config.models import ChatModelConfig, ModelPool, ModelPoolEntry +from Undefined.services.model_pool import ModelPoolService + + +@pytest.fixture +def temp_preferences_path(tmp_path: Path) -> Path: + """创建临时偏好文件路径""" + return tmp_path / "model_preferences.json" + + +@pytest.fixture +def model_selector(temp_preferences_path: Path) -> ModelSelector: + """创建测试用的 ModelSelector 实例""" + return ModelSelector( + preferences_path=temp_preferences_path, + compare_expire_seconds=300, + ) + + +@pytest.fixture +def primary_chat_config() -> ChatModelConfig: + """创建主模型配置""" + pool = ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key2", + model_name="model-b", + max_tokens=4096, + ), + ], + ) + return ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="primary-key", + model_name="primary-model", + max_tokens=4096, + pool=pool, + ) + + +@pytest.fixture +def mock_ai_client() -> MagicMock: + """创建 mock AIClient""" + ai = MagicMock() + ai.request_model = AsyncMock() + return ai + + +@pytest.fixture +def mock_config() -> MagicMock: + """创建 mock Config""" + config = MagicMock() + config.model_pool_enabled = True + return config + + +@pytest.fixture +def mock_sender() -> MagicMock: + """创建 mock MessageSender""" + sender = MagicMock() + sender.send_private_message = AsyncMock() + return sender + + +@pytest.fixture +def model_pool_service( + mock_ai_client: MagicMock, + mock_config: MagicMock, + mock_sender: MagicMock, + model_selector: ModelSelector, +) -> ModelPoolService: + """创建 ModelPoolService 实例""" + mock_ai_client.model_selector = model_selector + return ModelPoolService(mock_ai_client, mock_config, mock_sender) + + +class TestModelSelectorCompare: + """测试 ModelSelector 的 compare 相关功能""" + + def test_set_and_resolve_compare(self, model_selector: ModelSelector) -> None: + """测试设置和解析 compare 状态""" + user_id = 12345 + models = ["model-a", "model-b", "model-c"] + + model_selector.set_pending_compare(0, user_id, models) + + # 测试正确的选择 + result = model_selector.try_resolve_compare(0, user_id, "选1") + assert result == "model-a" + + # 再次尝试应该失败(已消费) + result = model_selector.try_resolve_compare(0, user_id, "选1") + assert result is None + + def test_resolve_compare_with_spaces(self, model_selector: ModelSelector) -> None: + """测试带空格的选择""" + user_id = 12345 + models = ["model-a", "model-b"] + + model_selector.set_pending_compare(0, user_id, models) + + result = model_selector.try_resolve_compare(0, user_id, "选 2") + assert result == "model-b" + + def test_resolve_compare_invalid_index(self, model_selector: ModelSelector) -> None: + """测试无效的索引""" + user_id = 12345 + models = ["model-a", "model-b"] + + model_selector.set_pending_compare(0, user_id, models) + + # 索引超出范围 + result = model_selector.try_resolve_compare(0, user_id, "选3") + assert result is None + + # 索引为 0 + result = model_selector.try_resolve_compare(0, user_id, "选0") + assert result is None + + def test_resolve_compare_invalid_format( + self, model_selector: ModelSelector + ) -> None: + """测试无效的格式""" + user_id = 12345 + models = ["model-a", "model-b"] + + model_selector.set_pending_compare(0, user_id, models) + + # 不匹配的格式 + result = model_selector.try_resolve_compare(0, user_id, "选择1") + assert result is None + + result = model_selector.try_resolve_compare(0, user_id, "1") + assert result is None + + @pytest.mark.asyncio + async def test_compare_expiration(self, temp_preferences_path: Path) -> None: + """测试 compare 状态过期""" + selector = ModelSelector( + preferences_path=temp_preferences_path, + compare_expire_seconds=0.5, + ) + + user_id = 12345 + models = ["model-a", "model-b"] + + selector.set_pending_compare(0, user_id, models) + + # 立即解析应该成功 + result = selector.try_resolve_compare(0, user_id, "选1") + assert result == "model-a" + + # 重新设置 + selector.set_pending_compare(0, user_id, models) + + # 等待过期 + await asyncio.sleep(0.6) + + # 过期后应该失败 + result = selector.try_resolve_compare(0, user_id, "选1") + assert result is None + + +class TestModelSelectorPreference: + """测试 ModelSelector 的偏好管理""" + + def test_set_and_get_preference(self, model_selector: ModelSelector) -> None: + """测试设置和获取偏好""" + user_id = 12345 + + model_selector.set_preference(0, user_id, "chat", "model-a") + + result = model_selector.get_preference(0, user_id, "chat") + assert result == "model-a" + + def test_clear_preference(self, model_selector: ModelSelector) -> None: + """测试清除偏好""" + user_id = 12345 + + model_selector.set_preference(0, user_id, "chat", "model-a") + model_selector.clear_preference(0, user_id, "chat") + + result = model_selector.get_preference(0, user_id, "chat") + assert result is None + + def test_multiple_users_preferences(self, model_selector: ModelSelector) -> None: + """测试多用户偏好隔离""" + user1 = 12345 + user2 = 67890 + + model_selector.set_preference(0, user1, "chat", "model-a") + model_selector.set_preference(0, user2, "chat", "model-b") + + assert model_selector.get_preference(0, user1, "chat") == "model-a" + assert model_selector.get_preference(0, user2, "chat") == "model-b" + + @pytest.mark.asyncio + async def test_save_and_load_preferences(self, temp_preferences_path: Path) -> None: + """测试偏好持久化""" + selector1 = ModelSelector(preferences_path=temp_preferences_path) + await selector1.load_preferences() + + selector1.set_preference(0, 12345, "chat", "model-a") + selector1.set_preference(0, 67890, "chat", "model-b") + await selector1.save_preferences() + + # 创建新实例加载 + selector2 = ModelSelector(preferences_path=temp_preferences_path) + await selector2.load_preferences() + + assert selector2.get_preference(0, 12345, "chat") == "model-a" + assert selector2.get_preference(0, 67890, "chat") == "model-b" + + +class TestModelSelectorSelection: + """测试 ModelSelector 的模型选择逻辑""" + + def test_select_with_preference( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试根据偏好选择模型""" + user_id = 12345 + + model_selector.set_preference(0, user_id, "chat", "model-a") + + result = model_selector.select_chat_config( + primary_chat_config, + group_id=0, + user_id=user_id, + global_enabled=True, + ) + + assert result.model_name == "model-a" + + def test_select_without_preference_round_robin( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试无偏好时的轮询策略""" + user1 = 12345 + user2 = 67890 + + result1 = model_selector.select_chat_config( + primary_chat_config, group_id=0, user_id=user1, global_enabled=True + ) + result2 = model_selector.select_chat_config( + primary_chat_config, group_id=0, user_id=user2, global_enabled=True + ) + + # 轮询应该选择不同的模型 + assert result1.model_name == "model-a" + assert result2.model_name == "model-b" + + def test_select_with_disabled_pool( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试池禁用时返回主模型""" + user_id = 12345 + + result = model_selector.select_chat_config( + primary_chat_config, + group_id=0, + user_id=user_id, + global_enabled=False, + ) + + assert result.model_name == "primary-model" + + def test_get_all_chat_models( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试获取所有模型""" + models = model_selector.get_all_chat_models(primary_chat_config) + + assert len(models) == 3 + assert models[0][0] == "primary-model" + assert models[1][0] == "model-a" + assert models[2][0] == "model-b" + + +class TestModelPoolServiceHandleMessage: + """测试 ModelPoolService 的消息处理""" + + @pytest.mark.asyncio + async def test_handle_compare_command( + self, + model_pool_service: ModelPoolService, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试处理 /compare 命令""" + user_id = 12345 + mock_ai_client.request_model.return_value = { + "choices": [{"message": {"content": "测试回复"}}] + } + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ], + ), + ) + + consumed = await model_pool_service.handle_private_message( + user_id, "/compare 你好" + ) + + assert consumed is True + assert mock_sender.send_private_message.call_count == 2 + assert mock_ai_client.request_model.call_count == 2 + + @pytest.mark.asyncio + async def test_handle_pk_command( + self, + model_pool_service: ModelPoolService, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试处理 /pk 命令""" + user_id = 12345 + mock_ai_client.request_model.return_value = { + "choices": [{"message": {"content": "测试回复"}}] + } + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ], + ), + ) + + consumed = await model_pool_service.handle_private_message(user_id, "/pk 你好") + + assert consumed is True + assert mock_sender.send_private_message.call_count == 2 + + @pytest.mark.asyncio + async def test_handle_select_command( + self, + model_pool_service: ModelPoolService, + model_selector: ModelSelector, + mock_sender: MagicMock, + ) -> None: + """测试处理选择命令""" + user_id = 12345 + + model_selector.set_pending_compare(0, user_id, ["model-a", "model-b"]) + + consumed = await model_pool_service.handle_private_message(user_id, "选1") + + assert consumed is True + mock_sender.send_private_message.assert_called_once() + assert "model-a" in mock_sender.send_private_message.call_args[0][1] + assert model_selector.get_preference(0, user_id, "chat") == "model-a" + + @pytest.mark.asyncio + async def test_handle_normal_message( + self, model_pool_service: ModelPoolService + ) -> None: + """测试普通消息不被消费""" + user_id = 12345 + + consumed = await model_pool_service.handle_private_message(user_id, "你好") + + assert consumed is False + + @pytest.mark.asyncio + async def test_handle_message_when_pool_disabled( + self, model_pool_service: ModelPoolService, mock_config: MagicMock + ) -> None: + """测试池禁用时不处理消息""" + mock_config.model_pool_enabled = False + user_id = 12345 + + consumed = await model_pool_service.handle_private_message( + user_id, "/compare 你好" + ) + + assert consumed is False + + +class TestModelPoolServiceCompare: + """测试 ModelPoolService 的 compare 功能""" + + @pytest.mark.asyncio + async def test_compare_without_space( + self, model_pool_service: ModelPoolService, mock_sender: MagicMock + ) -> None: + """测试 /compare 后面没有空格不会被识别""" + user_id = 12345 + + consumed = await model_pool_service.handle_private_message(user_id, "/compare") + + assert consumed is False + mock_sender.send_private_message.assert_not_called() + + @pytest.mark.asyncio + async def test_compare_single_model( + self, + model_pool_service: ModelPoolService, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试只有一个模型时""" + user_id = 12345 + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=None, + ) + + consumed = await model_pool_service.handle_private_message( + user_id, "/compare 你好" + ) + + assert consumed is True + mock_sender.send_private_message.assert_called_once() + assert "只有一个模型" in mock_sender.send_private_message.call_args[0][1] + + @pytest.mark.asyncio + async def test_compare_with_error( + self, + model_pool_service: ModelPoolService, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试模型请求失败""" + user_id = 12345 + mock_ai_client.request_model.side_effect = Exception("API 错误") + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ], + ), + ) + + consumed = await model_pool_service.handle_private_message( + user_id, "/compare 你好" + ) + + assert consumed is True + assert mock_sender.send_private_message.call_count == 2 + final_message = mock_sender.send_private_message.call_args_list[1][0][1] + assert "请求失败" in final_message + + @pytest.mark.asyncio + async def test_compare_long_response_truncation( + self, + model_pool_service: ModelPoolService, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试长回复截断""" + user_id = 12345 + long_content = "x" * 600 + mock_ai_client.request_model.return_value = { + "choices": [{"message": {"content": long_content}}] + } + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ], + ), + ) + + consumed = await model_pool_service.handle_private_message( + user_id, "/compare 你好" + ) + + assert consumed is True + final_message = mock_sender.send_private_message.call_args_list[1][0][1] + assert "..." in final_message + + +class TestModelPoolServiceSelectConfig: + """测试 ModelPoolService 的模型配置选择""" + + def test_select_config_with_preference( + self, + model_pool_service: ModelPoolService, + model_selector: ModelSelector, + primary_chat_config: ChatModelConfig, + ) -> None: + """测试根据偏好选择配置""" + user_id = 12345 + model_selector.set_preference(0, user_id, "chat", "model-a") + + result = model_pool_service.select_chat_config(primary_chat_config, user_id) + + assert result.model_name == "model-a" + + def test_select_config_without_preference( + self, + model_pool_service: ModelPoolService, + primary_chat_config: ChatModelConfig, + ) -> None: + """测试无偏好时选择配置""" + user_id = 12345 + + result = model_pool_service.select_chat_config(primary_chat_config, user_id) + + assert result.model_name in ["model-a", "model-b"] + + +class TestModelPoolIntegration: + """测试完整的 pk -> 选择 -> 分支深入流程""" + + @pytest.mark.asyncio + async def test_full_workflow( + self, + model_pool_service: ModelPoolService, + model_selector: ModelSelector, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试完整工作流:pk -> 选择 -> 后续对话使用选中模型""" + user_id = 12345 + + # 模拟两个模型的不同回复 + def mock_request(model_config: Any, **kwargs: Any) -> dict[str, Any]: + if model_config.model_name == "primary-model": + return {"choices": [{"message": {"content": "主模型回复"}}]} + elif model_config.model_name == "model-a": + return {"choices": [{"message": {"content": "模型A回复"}}]} + else: + return {"choices": [{"message": {"content": "模型B回复"}}]} + + mock_ai_client.request_model.side_effect = mock_request + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key2", + model_name="model-b", + max_tokens=4096, + ), + ], + ), + ) + + # 步骤1: 执行 pk + consumed = await model_pool_service.handle_private_message(user_id, "/pk 你好") + assert consumed is True + assert mock_ai_client.request_model.call_count == 3 + + # 验证 compare 状态已设置 + final_message = mock_sender.send_private_message.call_args_list[1][0][1] + assert "【1】primary-model" in final_message + assert "【2】model-a" in final_message + assert "【3】model-b" in final_message + assert "选X" in final_message + + # 步骤2: 选择模型A + mock_sender.reset_mock() + consumed = await model_pool_service.handle_private_message(user_id, "选2") + assert consumed is True + mock_sender.send_private_message.assert_called_once() + assert "model-a" in mock_sender.send_private_message.call_args[0][1] + + # 验证偏好已设置 + assert model_selector.get_preference(0, user_id, "chat") == "model-a" + + # 步骤3: 后续对话应使用选中的模型 + result = model_pool_service.select_chat_config(mock_config.chat_model, user_id) + assert result.model_name == "model-a" + + @pytest.mark.asyncio + async def test_multiple_users_isolation( + self, + model_pool_service: ModelPoolService, + model_selector: ModelSelector, + mock_ai_client: MagicMock, + mock_sender: MagicMock, + mock_config: MagicMock, + ) -> None: + """测试多用户隔离""" + user1 = 12345 + user2 = 67890 + + mock_ai_client.request_model.return_value = { + "choices": [{"message": {"content": "测试回复"}}] + } + mock_config.chat_model = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name="primary-model", + max_tokens=4096, + pool=ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="model-a", + max_tokens=4096, + ), + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key2", + model_name="model-b", + max_tokens=4096, + ), + ], + ), + ) + + # 用户1 执行 pk + await model_pool_service.handle_private_message(user1, "/pk 问题1") + model_selector.set_pending_compare(0, user1, ["model-a", "model-b"]) + + # 用户2 执行 pk + await model_pool_service.handle_private_message(user2, "/pk 问题2") + model_selector.set_pending_compare(0, user2, ["model-a", "model-b"]) + + # 用户1 选择 model-a + await model_pool_service.handle_private_message(user1, "选1") + + # 用户2 选择 model-b + await model_pool_service.handle_private_message(user2, "选2") + + # 验证偏好隔离 + assert model_selector.get_preference(0, user1, "chat") == "model-a" + assert model_selector.get_preference(0, user2, "chat") == "model-b" + + # 验证后续对话使用各自的模型 + result1 = model_pool_service.select_chat_config(mock_config.chat_model, user1) + result2 = model_pool_service.select_chat_config(mock_config.chat_model, user2) + + assert result1.model_name == "model-a" + assert result2.model_name == "model-b" + + +class TestModelSelectorBugFixes: + """测试模型选择器的 bug 修复""" + + def test_clear_invalid_preference_when_model_removed( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试当用户偏好的模型从池中移除时,偏好被自动清除""" + user_id = 12345 + + # 设置用户偏好为 model-a + model_selector.set_preference(0, user_id, "chat", "model-a") + assert model_selector.get_preference(0, user_id, "chat") == "model-a" + + # 创建一个新的配置,池中不包含 model-a + new_pool = ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key2", + model_name="model-b", + max_tokens=4096, + ), + ], + ) + new_config = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="primary-key", + model_name="primary-model", + max_tokens=4096, + pool=new_pool, + ) + + # 选择模型时,由于 model-a 不在池中,应该清除偏好并回退到策略选择 + result = model_selector.select_chat_config( + new_config, group_id=0, user_id=user_id, global_enabled=True + ) + + # 验证偏好已被清除 + assert model_selector.get_preference(0, user_id, "chat") is None + # 验证回退到策略选择(round_robin 第一个模型) + assert result.model_name == "model-b" + + @pytest.mark.asyncio + async def test_round_robin_thread_safety( + self, model_selector: ModelSelector, primary_chat_config: ChatModelConfig + ) -> None: + """测试 round-robin 计数器在并发场景下的线程安全""" + import concurrent.futures + + # 并发选择模型 100 次 + def select_model() -> str: + result = model_selector.select_chat_config( + primary_chat_config, group_id=0, user_id=0, global_enabled=True + ) + return result.model_name + + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(select_model) for _ in range(100)] + results = [f.result() for f in futures] + + # 验证所有选择都成功(没有异常) + assert len(results) == 100 + # 验证 round-robin 轮询(model-a 和 model-b 应该大致各占一半) + count_a = results.count("model-a") + count_b = results.count("model-b") + assert count_a == 50 + assert count_b == 50 + + def test_get_all_chat_models_no_duplicate_primary( + self, model_selector: ModelSelector + ) -> None: + """测试 get_all_chat_models 不会重复包含主模型""" + # 创建一个配置,池中包含与主模型同名的模型 + pool = ModelPool( + enabled=True, + strategy="round_robin", + models=[ + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key1", + model_name="primary-model", # 与主模型同名 + max_tokens=4096, + ), + ModelPoolEntry( + api_url="https://api.example.com/v1", + api_key="key2", + model_name="model-b", + max_tokens=4096, + ), + ], + ) + config = ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="primary-key", + model_name="primary-model", + max_tokens=4096, + pool=pool, + ) + + # 获取所有模型 + all_models = model_selector.get_all_chat_models(config) + + # 验证主模型只出现一次 + model_names = [name for name, _ in all_models] + assert model_names.count("primary-model") == 1 + # 验证总共有 2 个模型(primary-model 和 model-b) + assert len(all_models) == 2 + assert set(model_names) == {"primary-model", "model-b"} diff --git a/tests/test_webui_render_toml.py b/tests/test_webui_render_toml.py new file mode 100644 index 0000000..83773f9 --- /dev/null +++ b/tests/test_webui_render_toml.py @@ -0,0 +1,60 @@ +"""render_toml array-of-tables 单元测试""" + +import tomllib + +from Undefined.webui.utils import render_toml + + +def _roundtrip(toml_str: str) -> dict: # type: ignore[type-arg] + data = tomllib.loads(toml_str) + rendered = render_toml(data) + return tomllib.loads(rendered) + + +class TestRenderTomlArrayOfTables: + def test_pool_models_roundtrip(self) -> None: + """[[models.chat.pool.models]] 经过 render_toml 后结构不变""" + src = """ +[models.chat.pool] +enabled = true +strategy = "round_robin" + +[[models.chat.pool.models]] +model_name = "gpt-4o" +api_url = "https://api.openai.com/v1" +api_key = "sk-a" + +[[models.chat.pool.models]] +model_name = "deepseek-chat" +api_url = "https://api.deepseek.com/v1" +api_key = "sk-b" +""" + data = _roundtrip(src) + pool = data["models"]["chat"]["pool"] + assert pool["enabled"] is True + assert pool["strategy"] == "round_robin" + assert len(pool["models"]) == 2 + assert pool["models"][0]["model_name"] == "gpt-4o" + assert pool["models"][1]["api_key"] == "sk-b" + + def test_empty_list_stays_inline(self) -> None: + """空列表仍渲染为内联数组""" + rendered = render_toml({"allowed": []}) + assert "allowed = []" in rendered + + def test_scalar_list_stays_inline(self) -> None: + """标量列表仍渲染为内联数组""" + data = _roundtrip("ids = [1, 2, 3]") + assert data["ids"] == [1, 2, 3] + + def test_aot_not_rendered_as_string(self) -> None: + """list[dict] 不能被渲染成字符串形式""" + src = """ +[[items]] +name = "a" +[[items]] +name = "b" +""" + rendered = render_toml(tomllib.loads(src)) + assert '"{' not in rendered + assert "[[items]]" in rendered