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("", "<\\/")
- html = html.replace("__INITIAL_STATE__", initial_state_json)
- # Original used placeholders
- html = html.replace("__INITIAL_VIEW__", json.dumps("landing"))
- return web.Response(text=html, content_type="text/html")
-
-
-@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})
- # 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("", "<\\/")
+ html = html.replace("__INITIAL_STATE__", initial_state_json)
+ html = html.replace("__INITIAL_VIEW__", json.dumps("landing"))
+ return web.Response(text=html, content_type="text/html")
diff --git a/src/Undefined/webui/routes/_logs.py b/src/Undefined/webui/routes/_logs.py
new file mode 100644
index 0000000..48a057a
--- /dev/null
+++ b/src/Undefined/webui/routes/_logs.py
@@ -0,0 +1,188 @@
+import asyncio
+import re
+import tomllib
+from pathlib import Path
+from typing import Any
+
+from aiohttp import web
+from aiohttp.web_response import Response
+
+from Undefined.config.loader import CONFIG_PATH
+from ._shared import routes, check_auth
+from ..utils import tail_file
+
+
+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
+ 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
+
+
+
+
+
+
+
+