Compare commits
10 Commits
411cbf71ee
...
b81d6760bd
| Author | SHA1 | Date | |
|---|---|---|---|
| b81d6760bd | |||
| f3206357e9 | |||
| 089a3ad0c9 | |||
| d0af9755c6 | |||
| 7c2cc93585 | |||
| 4a2c9891c8 | |||
| 57f218d57c | |||
| e25384d342 | |||
| afd1bb9d28 | |||
| 6e8321cf7e |
49
.env.example
Normal file
49
.env.example
Normal file
@ -0,0 +1,49 @@
|
||||
# === API Keys ===============================================================
|
||||
# 基础推理模型(必填)
|
||||
AGENT_API_BASE_URL=https://api.example.com
|
||||
AGENT_API_KEY=your-api-key
|
||||
AGENT_MODEL_ID=deepseek-chat
|
||||
|
||||
# 智能思考模型(可选,留空则回退到基础模型)
|
||||
AGENT_THINKING_API_BASE_URL=
|
||||
AGENT_THINKING_API_KEY=
|
||||
AGENT_THINKING_MODEL_ID=
|
||||
|
||||
# 第三方搜索(可选)
|
||||
AGENT_TAVILY_API_KEY=
|
||||
|
||||
# 每轮最大响应 token(可选)
|
||||
AGENT_DEFAULT_RESPONSE_MAX_TOKENS=32768
|
||||
|
||||
# === 管理员账户 ===============================================================
|
||||
# 使用 `python - <<'PY' ... generate_password_hash` 生成哈希
|
||||
AGENT_ADMIN_USERNAME=admin
|
||||
AGENT_ADMIN_PASSWORD_HASH=
|
||||
|
||||
# === Web Session =============================================================
|
||||
WEB_SECRET_KEY=replace-with-random-hex
|
||||
|
||||
# === 终端容器沙箱 =============================================================
|
||||
# 模式:docker / host
|
||||
TERMINAL_SANDBOX_MODE=docker
|
||||
# 容器镜像及挂载路径
|
||||
TERMINAL_SANDBOX_IMAGE=python:3.11-slim
|
||||
TERMINAL_SANDBOX_MOUNT_PATH=/workspace
|
||||
TERMINAL_SANDBOX_SHELL=/bin/bash
|
||||
# 资源与网络限制
|
||||
TERMINAL_SANDBOX_NETWORK=none
|
||||
TERMINAL_SANDBOX_CPUS=0.5
|
||||
TERMINAL_SANDBOX_MEMORY=1g
|
||||
# 附加绑定目录(逗号分隔,可留空)
|
||||
TERMINAL_SANDBOX_BINDS=
|
||||
# 运行时路径及容器名称前缀
|
||||
TERMINAL_SANDBOX_BIN=docker
|
||||
TERMINAL_SANDBOX_NAME_PREFIX=agent-term
|
||||
# 启动失败时是否强制报错(1=强制容器,0=允许回退到宿主机)
|
||||
TERMINAL_SANDBOX_REQUIRE=0
|
||||
# 注入到容器的额外环境变量(以 TERMINAL_SANDBOX_ENV_* 命名)
|
||||
# 例如:TERMINAL_SANDBOX_ENV_HTTP_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# === 资源控制 ================================================================
|
||||
PROJECT_MAX_STORAGE_MB=2048
|
||||
MAX_ACTIVE_USER_CONTAINERS=8
|
||||
45
README.md
45
README.md
@ -95,29 +95,27 @@
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **配置模型与限额**
|
||||
在 `config/api.py` 设置大模型 API 信息,在 `config/limits.py` 调整工具阈值;常用示例:
|
||||
```python
|
||||
# config/api.py
|
||||
API_BASE_URL = "https://api.deepseek.com"
|
||||
API_KEY = "sk-..."
|
||||
MODEL_ID = "deepseek-chat"
|
||||
TAVILY_API_KEY = "tvly-..." # 可选
|
||||
|
||||
# config/limits.py
|
||||
READ_TOOL_DEFAULT_MAX_CHARS = 30000
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE = 1
|
||||
READ_TOOL_DEFAULT_CONTEXT_AFTER = 1
|
||||
READ_TOOL_MAX_MATCHES = 50
|
||||
2. **配置环境变量(敏感信息)**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填写 API Key / 管理员哈希 / 终端沙箱配置
|
||||
```
|
||||
其他常用配置:
|
||||
- `config/paths.py`:项目根目录、数据/日志路径
|
||||
- `config/terminal.py`:多终端缓冲与超时
|
||||
- `config/security.py`:敏感命令、路径黑名单、需要确认的操作
|
||||
- `config/conversation.py`:对话存储、索引与备份
|
||||
- `config/ui.py`:日志格式与版本号
|
||||
关键项说明:
|
||||
- `AGENT_API_BASE_URL` / `AGENT_API_KEY` / `AGENT_MODEL_ID`:模型服务入口(必要)。
|
||||
- `AGENT_THINKING_*`:可选的“思考模式”专用模型,不填则回退到基础模型。
|
||||
- `AGENT_ADMIN_USERNAME` / `AGENT_ADMIN_PASSWORD_HASH`:管理员账号(使用 `generate_password_hash` 生成 PBKDF2 哈希)。
|
||||
- `WEB_SECRET_KEY`:Flask Session 密钥,建议使用 `secrets.token_hex(32)`。
|
||||
- `TERMINAL_SANDBOX_*`:终端容器镜像、挂载路径、资源额度(默认 0.5 核心 + 1GB 内存),若本地无 Docker,可将 `TERMINAL_SANDBOX_REQUIRE` 置为 `0`,系统会自动退回宿主机终端。
|
||||
- `PROJECT_MAX_STORAGE_MB`:单个项目允许的最大磁盘占用(默认 2GB,超限写入会被拒绝)。
|
||||
- `MAX_ACTIVE_USER_CONTAINERS`:并发允许的用户容器数量,超过后会提示“资源繁忙”。
|
||||
|
||||
3. **运行方式**
|
||||
3. **(可选)调整 Python 配置**
|
||||
- `config/limits.py`:读写/搜索等工具上限。
|
||||
- `config/paths.py`:项目、日志、数据目录。
|
||||
- `config/terminal.py`:终端并发数、缓冲大小(环境变量会覆盖默认值)。
|
||||
- `config/security.py`:命令/路径黑名单及需要二次确认的工具。
|
||||
|
||||
4. **运行方式**
|
||||
```bash
|
||||
# CLI(推荐快速试验)
|
||||
python main.py
|
||||
@ -128,7 +126,7 @@
|
||||
```
|
||||
启动时可选择“快速模式”或“思考模式”,并指定项目路径(默认为 `./project`)。
|
||||
|
||||
4. **子智能体测试服务(可选)**
|
||||
5. **子智能体测试服务(可选)**
|
||||
```bash
|
||||
python sub_agent/server.py
|
||||
# 默认监听 8092 端口,供 create_sub_agent/wait_sub_agent 工具调用
|
||||
@ -183,6 +181,9 @@
|
||||
## 开发与测试建议
|
||||
|
||||
- **本地运行**:`python main.py` / `python web_server.py`;调试 Web 端建议同时开启浏览器控制台观察 WebSocket 日志。
|
||||
- **终端隔离**:默认通过 `TERMINAL_SANDBOX_*` 配置启动 Docker 容器执行命令,每个终端挂载独立工作区;若本地无容器运行时,可暂时将 `TERMINAL_SANDBOX_REQUIRE=0` 退回宿主机模式。
|
||||
- **快捷命令环境**:`run_command` / `run_python` 会在专用的“工具容器”中执行,复用同一虚拟环境与依赖,减少对宿主机的影响;空闲超过 `TOOLBOX_TERMINAL_IDLE_SECONDS` 会自动释放。
|
||||
- **资源保护**:容器默认限制为 0.5 vCPU / 1GB 内存,项目磁盘超过 2GB 会拒绝写入;当活跃用户容器达到 `MAX_ACTIVE_USER_CONTAINERS` 时系统会返回“资源繁忙”提示,避免服务器过载。
|
||||
- **日志**:CLI 模式下输出使用 `OUTPUT_FORMATS` 定义的 Emoji;Web 模式所有工具事件都会写入 `logs/debug_stream.log`。
|
||||
- **数据隔离**:多用户目录位于 `users/<username>/`;请避免将真实密钥提交到仓库,必要时扩展 `.gitignore`。
|
||||
- **测试**:暂未配置自动化测试,可参考 `test/` 目录(如 `api_interceptor_server.py`)编写自定义脚本。
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
"""Config package initializer,保持对旧 `from config import ...` 的兼容。"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_dotenv():
|
||||
"""在未显式导入 python-dotenv 的情况下,尽量从仓库根目录加载 .env。"""
|
||||
env_path = Path(__file__).resolve().parents[1] / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
except Exception:
|
||||
# 加载失败时静默继续,保持兼容
|
||||
pass
|
||||
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
from . import api as _api
|
||||
from . import paths as _paths
|
||||
from . import limits as _limits
|
||||
|
||||
@ -1,19 +1,29 @@
|
||||
"""API 和外部服务配置。"""
|
||||
|
||||
API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
API_KEY = "3e96a682-919d-45c1-acb2-53bc4e9660d3"
|
||||
MODEL_ID = "kimi-k2-250905"
|
||||
import os
|
||||
|
||||
|
||||
def _env(name: str, default: str = "", required: bool = False) -> str:
|
||||
value = os.environ.get(name, default)
|
||||
if required and not value:
|
||||
raise RuntimeError(f"缺少必要环境变量: {name}")
|
||||
return value
|
||||
|
||||
|
||||
API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
|
||||
API_KEY = _env("AGENT_API_KEY", required=True)
|
||||
MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-chat")
|
||||
|
||||
# 推理模型配置(智能思考模式使用)
|
||||
THINKING_API_BASE_URL = "https://api.moonshot.cn/v1"
|
||||
THINKING_API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y"
|
||||
THINKING_MODEL_ID = "kimi-k2-thinking"
|
||||
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
|
||||
THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY)
|
||||
THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID)
|
||||
|
||||
# Tavily 搜索
|
||||
TAVILY_API_KEY = "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"
|
||||
# Tavily 搜索(可选)
|
||||
TAVILY_API_KEY = _env("AGENT_TAVILY_API_KEY", "")
|
||||
|
||||
# 默认响应 token 限制
|
||||
DEFAULT_RESPONSE_MAX_TOKENS = 32768
|
||||
DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768"))
|
||||
|
||||
__all__ = [
|
||||
"API_BASE_URL",
|
||||
@ -25,9 +35,3 @@ __all__ = [
|
||||
"THINKING_API_KEY",
|
||||
"THINKING_MODEL_ID",
|
||||
]
|
||||
|
||||
'''
|
||||
API_BASE_URL = "https://api.moonshot.cn/v1"
|
||||
API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y",
|
||||
MODEL_ID = "kimi-k2-0905-preview"
|
||||
'''
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"""认证与后台账户配置。"""
|
||||
|
||||
ADMIN_USERNAME = "jojo"
|
||||
ADMIN_PASSWORD_HASH = "pbkdf2:sha256:600000$FSNAVncPXW6CBtfj$b7f093f4256de9d1a16d588565d4b1e108a9c66b2901884dd118c515258d78c7"
|
||||
import os
|
||||
|
||||
ADMIN_USERNAME = os.environ.get("AGENT_ADMIN_USERNAME", "")
|
||||
ADMIN_PASSWORD_HASH = os.environ.get("AGENT_ADMIN_PASSWORD_HASH", "")
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_USERNAME",
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""全局额度与工具限制配置。"""
|
||||
|
||||
import os
|
||||
|
||||
# 上下文与文件
|
||||
MAX_CONTEXT_SIZE = 100000
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
@ -36,6 +38,9 @@ READ_TOOL_MAX_CONTEXT_AFTER = 5
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES = 5
|
||||
READ_TOOL_MAX_MATCHES = 50
|
||||
|
||||
PROJECT_MAX_STORAGE_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048"))
|
||||
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024
|
||||
|
||||
__all__ = [
|
||||
"MAX_CONTEXT_SIZE",
|
||||
"MAX_FILE_SIZE",
|
||||
@ -63,4 +68,6 @@ __all__ = [
|
||||
"READ_TOOL_MAX_CONTEXT_AFTER",
|
||||
"READ_TOOL_DEFAULT_MAX_MATCHES",
|
||||
"READ_TOOL_MAX_MATCHES",
|
||||
"PROJECT_MAX_STORAGE_MB",
|
||||
"PROJECT_MAX_STORAGE_BYTES",
|
||||
]
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""终端与会话管理配置。"""
|
||||
|
||||
import os
|
||||
|
||||
MAX_TERMINALS = 3
|
||||
TERMINAL_BUFFER_SIZE = 100000
|
||||
TERMINAL_DISPLAY_SIZE = 50000
|
||||
@ -10,6 +12,37 @@ TERMINAL_SNAPSHOT_MAX_LINES = 200
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS = 6000000
|
||||
TERMINAL_INPUT_MAX_CHARS = 20000
|
||||
|
||||
|
||||
def _parse_bindings(raw_value: str):
|
||||
items = []
|
||||
for chunk in raw_value.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
items.append(chunk)
|
||||
return items
|
||||
|
||||
|
||||
_env_prefix = "TERMINAL_SANDBOX_ENV_"
|
||||
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
|
||||
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
|
||||
TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
|
||||
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
||||
TERMINAL_SANDBOX_NETWORK = os.environ.get("TERMINAL_SANDBOX_NETWORK", "none")
|
||||
TERMINAL_SANDBOX_CPUS = os.environ.get("TERMINAL_SANDBOX_CPUS", "")
|
||||
TERMINAL_SANDBOX_MEMORY = os.environ.get("TERMINAL_SANDBOX_MEMORY", "")
|
||||
TERMINAL_SANDBOX_BINDS = _parse_bindings(os.environ.get("TERMINAL_SANDBOX_BINDS", ""))
|
||||
TERMINAL_SANDBOX_BIN = os.environ.get("TERMINAL_SANDBOX_BIN", "docker")
|
||||
TERMINAL_SANDBOX_NAME_PREFIX = os.environ.get("TERMINAL_SANDBOX_NAME_PREFIX", "agent-term")
|
||||
TERMINAL_SANDBOX_ENV = {
|
||||
key[len(_env_prefix):]: value
|
||||
for key, value in os.environ.items()
|
||||
if key.startswith(_env_prefix)
|
||||
}
|
||||
TERMINAL_SANDBOX_REQUIRE = os.environ.get("TERMINAL_SANDBOX_REQUIRE", "0") not in {"0", "false", "False"}
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900"))
|
||||
MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8"))
|
||||
|
||||
__all__ = [
|
||||
"MAX_TERMINALS",
|
||||
"TERMINAL_BUFFER_SIZE",
|
||||
@ -20,4 +53,18 @@ __all__ = [
|
||||
"TERMINAL_SNAPSHOT_MAX_LINES",
|
||||
"TERMINAL_SNAPSHOT_MAX_CHARS",
|
||||
"TERMINAL_INPUT_MAX_CHARS",
|
||||
"TERMINAL_SANDBOX_MODE",
|
||||
"TERMINAL_SANDBOX_IMAGE",
|
||||
"TERMINAL_SANDBOX_MOUNT_PATH",
|
||||
"TERMINAL_SANDBOX_SHELL",
|
||||
"TERMINAL_SANDBOX_NETWORK",
|
||||
"TERMINAL_SANDBOX_CPUS",
|
||||
"TERMINAL_SANDBOX_MEMORY",
|
||||
"TERMINAL_SANDBOX_BINDS",
|
||||
"TERMINAL_SANDBOX_BIN",
|
||||
"TERMINAL_SANDBOX_NAME_PREFIX",
|
||||
"TERMINAL_SANDBOX_ENV",
|
||||
"TERMINAL_SANDBOX_REQUIRE",
|
||||
"TOOLBOX_TERMINAL_IDLE_SECONDS",
|
||||
"MAX_ACTIVE_USER_CONTAINERS",
|
||||
]
|
||||
|
||||
@ -16,7 +16,7 @@ OUTPUT_FORMATS = {
|
||||
"session": "📺 [会话]",
|
||||
}
|
||||
|
||||
AGENT_VERSION = "v1.1"
|
||||
AGENT_VERSION = "v3.2"
|
||||
|
||||
LOG_LEVEL = "INFO"
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
@ -15,7 +15,11 @@ try:
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -30,7 +34,11 @@ except ImportError:
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
@ -41,6 +49,11 @@ from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent_manager import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
)
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager
|
||||
@ -64,6 +77,10 @@ class MainTerminal:
|
||||
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
|
||||
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
|
||||
self.context_manager.main_terminal = self
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
|
||||
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
|
||||
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
|
||||
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
|
||||
self.file_manager = FileManager(project_path)
|
||||
self.search_engine = SearchEngine()
|
||||
@ -84,6 +101,7 @@ class MainTerminal:
|
||||
project_path=self.project_path,
|
||||
data_dir=str(self.data_dir)
|
||||
)
|
||||
self.easter_egg_manager = EasterEggManager()
|
||||
self._announced_sub_agent_tasks = set()
|
||||
|
||||
# 聚焦文件管理
|
||||
@ -95,8 +113,12 @@ class MainTerminal:
|
||||
self.pending_modify_request = None # {"path": str}
|
||||
|
||||
# 工具启用状态
|
||||
self.tool_category_states = {key: True for key in TOOL_CATEGORIES}
|
||||
self.tool_category_states = {
|
||||
key: category.default_enabled
|
||||
for key, category in TOOL_CATEGORIES.items()
|
||||
}
|
||||
self.disabled_tools = set()
|
||||
self.disabled_notice_tools = set()
|
||||
self._refresh_disabled_tools()
|
||||
|
||||
# 新增:自动开始新对话
|
||||
@ -402,7 +424,7 @@ class MainTerminal:
|
||||
snapshot.append({
|
||||
"id": key,
|
||||
"label": category.label,
|
||||
"enabled": self.tool_category_states.get(key, True),
|
||||
"enabled": self.tool_category_states.get(key, category.default_enabled),
|
||||
"tools": list(category.tools),
|
||||
})
|
||||
return snapshot
|
||||
@ -410,18 +432,23 @@ class MainTerminal:
|
||||
def _refresh_disabled_tools(self) -> None:
|
||||
"""刷新禁用工具列表 / Refresh disabled tool set."""
|
||||
disabled = set()
|
||||
notice = set()
|
||||
for key, enabled in self.tool_category_states.items():
|
||||
if not enabled:
|
||||
disabled.update(TOOL_CATEGORIES[key].tools)
|
||||
category = TOOL_CATEGORIES[key]
|
||||
disabled.update(category.tools)
|
||||
if not getattr(category, "silent_when_disabled", False):
|
||||
notice.update(category.tools)
|
||||
self.disabled_tools = disabled
|
||||
self.disabled_notice_tools = notice
|
||||
|
||||
def _format_disabled_tool_notice(self) -> Optional[str]:
|
||||
"""生成禁用工具提示信息 / Format disabled tool notice."""
|
||||
if not self.disabled_tools:
|
||||
if not self.disabled_notice_tools:
|
||||
return None
|
||||
|
||||
lines = ["=== 工具可用性提醒 ==="]
|
||||
for tool_name in sorted(self.disabled_tools):
|
||||
for tool_name in sorted(self.disabled_notice_tools):
|
||||
lines.append(f"{tool_name}:已被用户禁用")
|
||||
lines.append("=== 提示结束 ===")
|
||||
return "\n".join(lines)
|
||||
@ -894,6 +921,8 @@ class MainTerminal:
|
||||
|
||||
def define_tools(self) -> List[Dict]:
|
||||
"""定义可用工具(添加确认工具)"""
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
@ -1219,7 +1248,7 @@ class MainTerminal:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
||||
"description": f"当现有资料不足时搜索外部信息(当前时间 {current_time})。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1456,6 +1485,23 @@ class MainTerminal:
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_easter_egg",
|
||||
"description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood(灌水)或 snake(贪吃蛇)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"description": "彩蛋标识,目前支持 flood(灌水)与 snake(贪吃蛇)。"
|
||||
}
|
||||
},
|
||||
"required": ["effect"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
if self.disabled_tools:
|
||||
@ -2050,6 +2096,9 @@ class MainTerminal:
|
||||
agent_id=arguments.get("agent_id")
|
||||
)
|
||||
|
||||
elif tool_name == "trigger_easter_egg":
|
||||
result = self.easter_egg_manager.trigger_effect(arguments.get("effect"))
|
||||
|
||||
else:
|
||||
result = {"success": False, "error": f"未知工具: {tool_name}"}
|
||||
|
||||
@ -2106,8 +2155,16 @@ class MainTerminal:
|
||||
system_prompt = self.load_prompt("main_system")
|
||||
|
||||
# 格式化系统提示
|
||||
container_path = self.container_mount_path or "/workspace"
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=self.project_path,
|
||||
project_path=container_path,
|
||||
container_path=container_path,
|
||||
container_cpus=container_cpus,
|
||||
container_memory=container_memory,
|
||||
project_storage=project_storage,
|
||||
file_tree=context["project_info"]["file_tree"],
|
||||
memory=context["memory"],
|
||||
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@ -2131,6 +2188,18 @@ class MainTerminal:
|
||||
thinking_prompt = self.load_prompt("thinking_mode_guidelines").strip()
|
||||
if thinking_prompt:
|
||||
messages.append({"role": "system", "content": thinking_prompt})
|
||||
|
||||
personalization_config = load_personalization_config(self.data_dir)
|
||||
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
|
||||
if personalization_block:
|
||||
personalization_template = self.load_prompt("personalization").strip()
|
||||
if personalization_template and "{personalization_block}" in personalization_template:
|
||||
personalization_text = personalization_template.format(personalization_block=personalization_block)
|
||||
elif personalization_template:
|
||||
personalization_text = f"{personalization_template}\n{personalization_block}"
|
||||
else:
|
||||
personalization_text = personalization_block
|
||||
messages.append({"role": "system", "content": personalization_text})
|
||||
|
||||
# 添加对话历史(保留完整结构,包括tool_calls和tool消息)
|
||||
conversation = context["conversation"]
|
||||
|
||||
@ -9,9 +9,17 @@ from typing import Dict, List
|
||||
class ToolCategory:
|
||||
"""工具类别的结构化定义。"""
|
||||
|
||||
def __init__(self, label: str, tools: List[str]):
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
tools: List[str],
|
||||
default_enabled: bool = True,
|
||||
silent_when_disabled: bool = False,
|
||||
):
|
||||
self.label = label
|
||||
self.tools = tools
|
||||
self.default_enabled = default_enabled
|
||||
self.silent_when_disabled = silent_when_disabled
|
||||
|
||||
|
||||
TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
@ -60,4 +68,10 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
label="子智能体",
|
||||
tools=["create_sub_agent", "wait_sub_agent", "close_sub_agent"],
|
||||
),
|
||||
"easter_egg": ToolCategory(
|
||||
label="彩蛋实验",
|
||||
tools=["trigger_easter_egg"],
|
||||
default_enabled=False,
|
||||
silent_when_disabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
45
doc/phase1_summary.md
Normal file
45
doc/phase1_summary.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Phase 1 Summary – 环境变量与容器隔离
|
||||
|
||||
## 已完成的关键改动
|
||||
|
||||
1. **配置去硬编码**
|
||||
- `config/api.py` / `config/auth.py` 读取 `.env` 中的密钥和凭证,`config/__init__.py` 自动加载 `.env`,彻底清理仓库里的明文 key。
|
||||
- `.env.example` 提供可复制的模板,`.env` 记录当前部署所用值,防止环境遗漏。
|
||||
|
||||
2. **终端容器化**
|
||||
- 所有实时终端、`run_command`、`run_python` 全部运行在 `my-agent-shell` 镜像(基于 `python:3.11-slim`)中;镜像预装常用系统工具与 Python 依赖,并内置虚拟环境。
|
||||
- 工具容器懒加载,可复用同一个会话,空闲超时自动回收,日志可追踪容器启动/释放。
|
||||
|
||||
3. **资源配额**
|
||||
- `.env` 控制 0.5 vCPU / 1GB RAM 的容器配额,项目目录写入前会进行 2GB 存储配额检查。
|
||||
- `TERMINAL_SANDBOX_*`/`PROJECT_MAX_STORAGE_MB`/`TOOLBOX_TERMINAL_IDLE_SECONDS`/`MAX_ACTIVE_USER_CONTAINERS` 等可在环境中统一调整。
|
||||
|
||||
4. **并发保护与体验**
|
||||
- 新增 `active_users` 计数,限制同时在线账户总数;超限时 `/login` 与 Ajax 登录都会跳转到 `resource_busy` 页面,提示“资源繁忙,请稍后重试”。
|
||||
- 登出/会话释放时及时归还 slot,确保超过配额不会导致服务器崩溃。
|
||||
|
||||
5. **系统提示同步**
|
||||
- main prompt 中不再暴露宿主机路径,只告知模型“你在隔离容器(挂载目录 `/workspace`)中工作”,附带 CPU/内存/磁盘配额说明,减少对真实环境的暴露。
|
||||
|
||||
6. **安全检查**
|
||||
- `doc/security_review.md` 更新了第一阶段完成项:环境变量治理、终端容器化、资源限制、繁忙提示等,便于下一阶段延续。
|
||||
|
||||
## 后续建议
|
||||
|
||||
1. **用户级容器**
|
||||
- 当前仍是“多终端共享多个容器”。下一阶段可收敛为“每用户一个长生命周期容器”,集中处理终端/文件/工具请求,进一步简化资源管理。
|
||||
|
||||
2. **文件操作代理**
|
||||
- 目前 `FileManager` 仍在宿主机写入。可以考虑在用户容器内提供文件服务/API,让 append/modify 等也走容器,宿主机只负责挂载和备份。
|
||||
|
||||
3. **监控与预警**
|
||||
- 接入 Docker/系统 metrics,记录容器 CPU/Mem/IO 使用情况,并在触达阈值前告警;后续可结合日志汇报高危命令。
|
||||
|
||||
4. **CI / 镜像自动构建**
|
||||
- 将 `docker/terminal.Dockerfile` 与 `toolbox-requirements.txt` 纳入 CI pipeline,自动构建并推送 `my-agent-shell`,避免手工 build。
|
||||
|
||||
5. **权限最小化**
|
||||
- 研究 rootless Docker 或容器内非 root 用户,进一步降低 /etc/shadow 等容器内敏感文件被误读的风险。
|
||||
|
||||
如需接手下一阶段,请先阅读同目录下的 `security_review.md`,了解之前的风险总览及尚未推进的条目。祝顺利!
|
||||
|
||||
86
doc/security_review.md
Normal file
86
doc/security_review.md
Normal file
@ -0,0 +1,86 @@
|
||||
# AI Agent 系统安全评审
|
||||
|
||||
- **评审日期**:2024-xx-xx
|
||||
- **评审范围**:当前开源仓库中的 CLI/Web 入口(`main.py`、`web_server.py`)、核心模块(`core/`, `modules/`)、配置(`config/`)、静态前端(`static/`)及数据目录(`data/`, `users/`)。
|
||||
- **评审目标**:识别阻碍产品化落地的安全缺口,明确优先级与改进路径,作为后续全面加固(尤其是“终端隔离 + 多租户环境”)的依据。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统与数据现状
|
||||
|
||||
### 1.1 主要角色 & 组件
|
||||
- **CLI 入口 (`main.py`)**:默认启动 Web 模式,直接在宿主机 `DEFAULT_PROJECT_PATH` 内读写文件。
|
||||
- **Web 服务 (`web_server.py`)**:基于 Flask + Socket.IO,整合登录、文件操作、终端调度、任务工具调用,所有状态保存在单进程内存 + 本地文件夹。
|
||||
- **用户与工作区 (`modules/user_manager.py`)**:账号、邀请码、工作目录全部持久化到 `./data/*.json`,未引入数据库或权限控制。
|
||||
- **终端/命令执行 (`modules/terminal_manager.py`, `modules/persistent_terminal.py`)**:调度宿主机 shell 进程,缺乏容器或 cgroup;多用户共享一台机器的真实文件系统。
|
||||
- **静态前端 (`static/index.html`, `static/app.js`)**:大体量单文件脚本,所有逻辑在浏览器中运行,没有打包、模块划分或安全加固。
|
||||
|
||||
### 1.3 第一阶段整改进展
|
||||
- ✅ **Secrets 与配置**:所有 API key/账号信息已迁移至 `.env`,`config/__init__.py` 启动时自动加载并校验,仓库不再存在明文 key。
|
||||
- ✅ **终端容器化**:实时终端与 `run_command/run_python` 默认运行在 `my-agent-shell` Docker 容器中,配额为 0.5 vCPU / 1GB 内存,项目目录通过卷挂载;宿主机仅负责持久化。
|
||||
- ✅ **资源与并发控制**:新增 `PROJECT_MAX_STORAGE_MB`、`MAX_ACTIVE_USER_CONTAINERS` 等限制,写入前会进行配额检查,活跃用户达到上限时提供 `resource_busy` 页面提示。
|
||||
- ✅ **系统提示**:模型接收的环境信息仅包含容器挂载路径与资源上限,不再暴露宿主机真实路径。
|
||||
|
||||
### 1.2 关键数据资产
|
||||
| 资产 | 存储位置 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 模型/API 密钥 | `config/api.py`, 环境变量(未来) | 目前以明文写在仓库 |
|
||||
| 管理员凭据 | `config/auth.py` | 包含固定用户名与 PBKDF2 哈希 |
|
||||
| 用户账号/工作区索引 | `data/users.json`, `data/invite_codes.json`, `users/<uid>/` | JSON 文件无加密、无访问控制 |
|
||||
| 对话与工具日志 | `data/conversations/*`, `logs/` | 包含终端输出、上传文件、任务上下文 |
|
||||
| 用户上传文件 | `users/<uid>/project/user_upload` | 与终端共享同一路径 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 攻击面概览
|
||||
- **HTTP API**:Flask 路由与 REST 接口缺乏 CSRF、防爆破、速率限制,`CORS(app)` 与 `cors_allowed_origins="*"` 允许任意来源发起请求。
|
||||
- **WebSocket/Socket.IO**:所有事件透过会话 cookie 鉴权,没有额外的 token 或设备指纹,连接转发逻辑在内存字典 `connection_users` 中维护。
|
||||
- **文件上传/下载**:`/api/upload`、`/api/gui/files/upload` 接受任意类型文件,未做恶意内容扫描,仅依赖 `MAX_UPLOAD_SIZE=50MB`。
|
||||
- **终端命令执行**:浏览器或 API 调用可直接在宿主机 shell 运行命令,代码运行结果与项目文件落在真实文件系统。
|
||||
- **配置与日志**:所有 secrets、日志、备份位于仓库根目录,可被具备 shell 访问权的任意人读取。
|
||||
|
||||
---
|
||||
|
||||
## 3. 主要安全发现(按严重度排序)
|
||||
|
||||
| # | 严重度 | 问题 & 证据 | 影响 | 建议 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Critical | **执行环境无隔离**:`modules/persistent_terminal.py:87-178` 直接在宿主机上 `subprocess.Popen` shell,所有命令与文件操作共享真实系统。 | 任意 Web 用户可读取/修改宿主机文件、横向移动、破坏系统。 | 必须引入容器/VM 沙箱,将项目、依赖与命令执行限制在受控环境,并对资源设置限额(CPU/Mem/IO)。 |
|
||||
| 2 | Critical | **明文 API Key / Secret**:`config/api.py:3-25` 存在硬编码模型 key,`config/auth.py:1-7` 暴露管理员用户名 + 哈希。 | 仓库一旦共享即泄漏密钥;攻击者可伪造管理员账户或重放 API 请求。 | 将所有 secrets 挪到环境变量 / Secret Manager,删除仓库中的明文;在启动时校验缺省值并阻止运行。 |
|
||||
| 3 | High | **Flask SECRET_KEY 固定且公开**:`web_server.py:54-58` 将 `SECRET_KEY='your-secret-key-here'` 写死,且默认启用 `CORS(*)`。 | 攻击者可伪造 session cookie、冒充任意用户、解密/篡改会话。 | 将 SECRET_KEY 存储于环境变量,启用 `SESSION_COOKIE_SECURE/HTTPONLY/SAMESITE`,并限制 CORS 源。 |
|
||||
| 4 | High | **鉴权与速率限制缺失**:登录接口没有 CAPTCHA/速率限制;`api_login_required` 仅检查 session;Socket.IO 连接未绑定 IP/指纹。 | 暴力破解、会话劫持、重放攻击均无防护;一旦 cookie 泄漏即可接管终端。 | 引入 Flask-Limiter 等中间件,记录失败次数,必要时强制多因子或设备锁定;WebSocket 握手附带一次性 token。 |
|
||||
| 5 | High | **多租户数据无物理隔离**:虽然 `UserManager.ensure_user_workspace` 为每个用户创建子目录,但同一进程拥有全部路径读写权限,且 FileManager (`modules/file_manager.py`) 仅按“项目根目录”限制,无法阻止管理员会话访问他人目录。 | 横向越权风险高,任何被攻破的账号都可以遍历/窃取其他租户数据。 | 将每个用户放入独立容器/VM,并由调度服务负责映射;API 层使用数据库/ACL 校验 user_id 与路径的对应关系。 |
|
||||
| 6 | Medium | **用户与会话数据存储在本地 JSON**:`modules/user_manager.py:167-195` 将账号、密码哈希、邀请码写入 `data/users.json`,没有备份策略、并发安全或加密。 | 易被本地用户读取/篡改;当并发写入时有数据损坏风险,也无法满足审计/恢复。 | 引入关系型数据库或托管身份服务;对敏感字段做透明加密,提供备份与迁移策略。 |
|
||||
| 7 | Medium | **缺乏 CSRF、防重放与安全头部**:所有 POST/PUT 接口(如 `/api/gui/files/*`, `/api/upload`)均未校验 CSRF token。 | 登陆态用户可被恶意网站诱导发起操作(上传/删除/运行命令)。 | 在 Flask 层加入 CSRF 保护(WTF-CSRF 或自定义 token),并添加 Strict-Transport-Security、Content-Security-Policy 等响应头。 |
|
||||
| 8 | Medium | **文件上传仅做文件名校验**:`web_server.py:841-907, 985-1069` 只调用 `sanitize_filename_preserve_unicode`,但未检测 MIME、内容或执行权限。 | 可上传脚本并经终端执行;针对 Windows/Unix 的路径和符号链接处理也未覆盖。 | 引入内容扫描(ClamAV/自建规则)、限制文件类型/数量,并将上传目录与执行目录隔离。 |
|
||||
| 9 | Medium | **日志/终端信息缺乏审计与脱敏**:`logs/`、`data/conversations/` 中保留所有指令和模型输出,没有访问控制。 | 可能泄漏用户代码、密钥;出现安全事件时也很难追踪。 | 将日志写入集中式系统(ELK/ClickHouse),对敏感字段脱敏,建立查询与保留策略。 |
|
||||
| 10 | Low | **配置默认值缺乏环境检测**:如 `DEFAULT_PROJECT_PATH="./project"`、`MAX_UPLOAD_SIZE=50MB` 固定写入;`main.py` 未检查当前用户权限。 | 误配置可能导致数据写入到未知磁盘或权限不足引发异常。 | 在启动阶段校验运行环境(磁盘权限、必需目录、环境变量),并提供友好报错与文档。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 优先修复路线(建议迭代 0 → 2)
|
||||
|
||||
1. **密钥治理与配置基线**
|
||||
- 移除所有硬编码 secrets,提供 `.env.example`;启动脚本在缺失密钥时直接失败。
|
||||
- 配置 Flask session 安全选项、限制 CORS 来源、添加基本安全 HTTP 头。
|
||||
|
||||
2. **执行环境隔离 PoC**
|
||||
- 选定容器技术(Docker/LXC/Firecracker),将 `TerminalManager` 改造为“容器调度器”,所有命令走容器内的 `/workspace` 目录。
|
||||
- 引入任务代理服务(FastAPI + Celery/RabbitMQ)分离 Web API 与执行层,给每个用户/对话绑定独立容器生命周期。
|
||||
|
||||
3. **身份/鉴权与数据持久化升级**
|
||||
- 使用数据库管理用户、会话与权限,加入账号锁定、审计日志、API rate limit。
|
||||
- 为日志、对话、上传文件建立访问控制与加密策略,提供备份/恢复。
|
||||
|
||||
4. **安全运维体系**
|
||||
- 落地集中日志、异常告警、指标监控;在部署脚本中加入 CIS/Baseline 检查。
|
||||
- 定期执行依赖扫描(pip-audit/Dependabot)、容器镜像漏洞检测,并把密钥轮换流程流程化。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后续工作与协作建议
|
||||
- 建议在 `docs/` 中维护《安全基线》《运维手册》《应急预案》,并在 PR 模板中新增安全检查项。
|
||||
- 组建最小安全清单:每次上线前确认终端隔离、密钥、日志、鉴权、备份五项指标均处于“通过”状态。
|
||||
- 在完成 PoC 后,再讨论“前端重写、UI 组件化”等体验向任务,确保基础安全能力可复用到后续版本。
|
||||
|
||||
> 本文档会随着重构推进持续更新;若需深入某一条风险(如容器隔离方案、密钥管理流程),可另开文档展开细化设计。
|
||||
35
docker/terminal.Dockerfile
Normal file
35
docker/terminal.Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
curl \
|
||||
wget \
|
||||
ca-certificates \
|
||||
git \
|
||||
build-essential \
|
||||
openssh-client \
|
||||
expect \
|
||||
zip \
|
||||
unzip \
|
||||
locales \
|
||||
tzdata && \
|
||||
sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen && \
|
||||
locale-gen && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/workspace
|
||||
|
||||
COPY docker/toolbox-requirements.txt /tmp/toolbox-requirements.txt
|
||||
|
||||
RUN python -m venv /opt/agent-venv && \
|
||||
/opt/agent-venv/bin/pip install --no-cache-dir --upgrade pip && \
|
||||
/opt/agent-venv/bin/pip install --no-cache-dir -r /tmp/toolbox-requirements.txt && \
|
||||
rm -f /tmp/toolbox-requirements.txt
|
||||
|
||||
ENV AGENT_TOOLBOX_VENV=/opt/agent-venv
|
||||
ENV PATH="/opt/agent-venv/bin:${PATH}"
|
||||
18
docker/toolbox-requirements.txt
Normal file
18
docker/toolbox-requirements.txt
Normal file
@ -0,0 +1,18 @@
|
||||
requests==2.32.3
|
||||
httpx==0.28.1
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.3.0
|
||||
pandas==2.2.3
|
||||
numpy==2.1.3
|
||||
pillow==11.0.0
|
||||
matplotlib==3.9.2
|
||||
sympy==1.13.3
|
||||
tqdm==4.67.1
|
||||
pyyaml==6.0.2
|
||||
jinja2==3.1.4
|
||||
python-docx==1.1.2
|
||||
docx2txt==0.8
|
||||
openpyxl==3.1.5
|
||||
python-pptx==1.0.2
|
||||
pdfplumber==0.11.4
|
||||
PyPDF2==3.0.1
|
||||
78
modules/easter_egg_manager.py
Normal file
78
modules/easter_egg_manager.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""彩蛋触发管理器。
|
||||
|
||||
负责根据工具参数返回彩蛋元数据,供前端渲染对应特效。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class EasterEggManager:
|
||||
"""管理隐藏彩蛋效果的触发逻辑。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 预置彩蛋效果元数据,新增特效可在此扩展
|
||||
self.effects: Dict[str, Dict[str, object]] = {
|
||||
"flood": {
|
||||
"label": "灌水",
|
||||
"aliases": ["flood", "water", "wave", "灌水", "注水"],
|
||||
"message": "淡蓝色水面从底部缓缓上涨,并带有柔和波纹。",
|
||||
"duration_seconds": 45,
|
||||
"intensity_range": (0.87, 0.93),
|
||||
"notes": "特效为半透明覆盖层,不会阻挡交互。",
|
||||
},
|
||||
"snake": {
|
||||
"label": "贪吃蛇",
|
||||
"aliases": ["snake", "贪吃蛇", "snakegame", "snake_game"],
|
||||
"message": "发光的丝带贪吃蛇追逐苹果,吃满 20 个后会一路远行离开屏幕。",
|
||||
"duration_seconds": 200,
|
||||
"notes": "动画为独立 Canvas,不影响页面点击。",
|
||||
"apples_target": 20,
|
||||
"initial_apples": 3,
|
||||
},
|
||||
}
|
||||
|
||||
def trigger_effect(self, effect: str) -> Dict[str, object]:
|
||||
"""
|
||||
根据传入的 effect 名称查找彩蛋。
|
||||
|
||||
Args:
|
||||
effect: 彩蛋标识或别名。
|
||||
|
||||
Returns:
|
||||
dict: 包含触发状态与前端所需的特效参数。
|
||||
"""
|
||||
effect_key = (effect or "").strip().lower()
|
||||
if not effect_key:
|
||||
return self._build_error("缺少 effect 参数")
|
||||
|
||||
for effect_id, metadata in self.effects.items():
|
||||
aliases = metadata.get("aliases", [])
|
||||
if effect_key == effect_id or effect_key in aliases:
|
||||
payload = {
|
||||
"success": True,
|
||||
"effect": effect_id,
|
||||
"display_name": metadata.get("label", effect_id),
|
||||
}
|
||||
payload.setdefault("duration_seconds", 30)
|
||||
for key, value in metadata.items():
|
||||
if key in {"label", "aliases"}:
|
||||
continue
|
||||
payload[key] = value
|
||||
return payload
|
||||
|
||||
return self._build_error(f"未知彩蛋: {effect_key}")
|
||||
|
||||
def _build_error(self, message: str) -> Dict[str, object]:
|
||||
"""返回格式化的错误信息。"""
|
||||
return {
|
||||
"success": False,
|
||||
"error": message,
|
||||
"available_effects": self.available_effects,
|
||||
}
|
||||
|
||||
@property
|
||||
def available_effects(self) -> List[str]:
|
||||
"""返回可用彩蛋 ID 列表。"""
|
||||
return list(self.effects.keys())
|
||||
@ -12,6 +12,7 @@ try:
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
PROJECT_MAX_STORAGE_BYTES,
|
||||
)
|
||||
except ImportError: # 兼容全局环境中存在同名包的情况
|
||||
import sys
|
||||
@ -25,12 +26,26 @@ except ImportError: # 兼容全局环境中存在同名包的情况
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
PROJECT_MAX_STORAGE_BYTES,
|
||||
)
|
||||
# 临时禁用长度检查
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
class FileManager:
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
|
||||
def _get_project_size(self) -> int:
|
||||
"""计算项目目录的总大小(字节)"""
|
||||
total = 0
|
||||
if not self.project_path.exists():
|
||||
return 0
|
||||
for path in self.project_path.rglob('*'):
|
||||
try:
|
||||
if path.is_file():
|
||||
total += path.stat().st_size
|
||||
except PermissionError:
|
||||
continue
|
||||
return total
|
||||
|
||||
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
|
||||
"""
|
||||
@ -490,6 +505,20 @@ class FileManager:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 检测到大量转义字符,建议检查内容格式")
|
||||
|
||||
try:
|
||||
current_size = self._get_project_size()
|
||||
existing_size = full_path.stat().st_size if full_path.exists() else 0
|
||||
if mode == "a":
|
||||
projected_total = current_size + len(content)
|
||||
else:
|
||||
projected_total = current_size - existing_size + len(content)
|
||||
if PROJECT_MAX_STORAGE_BYTES and projected_total > PROJECT_MAX_STORAGE_BYTES:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "写入失败:超出项目磁盘配额",
|
||||
"limit_bytes": PROJECT_MAX_STORAGE_BYTES,
|
||||
"project_size_bytes": current_size,
|
||||
"attempt_size_bytes": len(content)
|
||||
}
|
||||
# 创建父目录
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@ -11,15 +11,49 @@ from datetime import datetime
|
||||
import threading
|
||||
import queue
|
||||
from collections import deque
|
||||
import shutil
|
||||
import uuid
|
||||
try:
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
TERMINAL_OUTPUT_WAIT,
|
||||
TERMINAL_INPUT_MAX_CHARS,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_IMAGE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_SHELL,
|
||||
TERMINAL_SANDBOX_NETWORK,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
TERMINAL_SANDBOX_BINDS,
|
||||
TERMINAL_SANDBOX_BIN,
|
||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
TERMINAL_SANDBOX_ENV,
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
TERMINAL_OUTPUT_WAIT,
|
||||
TERMINAL_INPUT_MAX_CHARS,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_IMAGE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_SHELL,
|
||||
TERMINAL_SANDBOX_NETWORK,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
TERMINAL_SANDBOX_BINDS,
|
||||
TERMINAL_SANDBOX_BIN,
|
||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
TERMINAL_SANDBOX_ENV,
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
)
|
||||
|
||||
class PersistentTerminal:
|
||||
"""单个持久化终端实例"""
|
||||
@ -31,7 +65,10 @@ class PersistentTerminal:
|
||||
shell_command: str = None,
|
||||
broadcast_callback: Callable = None,
|
||||
max_buffer_size: int = 20000,
|
||||
display_size: int = 5000
|
||||
display_size: int = 5000,
|
||||
project_path: Optional[str] = None,
|
||||
sandbox_mode: Optional[str] = None,
|
||||
sandbox_options: Optional[Dict] = None,
|
||||
):
|
||||
"""
|
||||
初始化持久化终端
|
||||
@ -45,7 +82,9 @@ class PersistentTerminal:
|
||||
display_size: 显示大小限制
|
||||
"""
|
||||
self.session_name = session_name
|
||||
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
||||
self.working_dir = Path(working_dir).resolve() if working_dir else Path.cwd()
|
||||
self.project_path = Path(project_path).resolve() if project_path else self.working_dir
|
||||
self.host_shell_command = shell_command
|
||||
self.shell_command = shell_command
|
||||
self.broadcast = broadcast_callback
|
||||
self.max_buffer_size = max_buffer_size
|
||||
@ -83,49 +122,127 @@ class PersistentTerminal:
|
||||
|
||||
# 系统特定设置
|
||||
self.is_windows = sys.platform == "win32"
|
||||
sandbox_defaults = {
|
||||
"image": TERMINAL_SANDBOX_IMAGE,
|
||||
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
"shell": TERMINAL_SANDBOX_SHELL,
|
||||
"network": TERMINAL_SANDBOX_NETWORK,
|
||||
"cpus": TERMINAL_SANDBOX_CPUS,
|
||||
"memory": TERMINAL_SANDBOX_MEMORY,
|
||||
"binds": list(TERMINAL_SANDBOX_BINDS),
|
||||
"bin": TERMINAL_SANDBOX_BIN,
|
||||
"name_prefix": TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
"env": dict(TERMINAL_SANDBOX_ENV),
|
||||
"require": TERMINAL_SANDBOX_REQUIRE,
|
||||
}
|
||||
if sandbox_options:
|
||||
for key, value in sandbox_options.items():
|
||||
if key == "binds" and isinstance(value, list):
|
||||
sandbox_defaults[key] = list(value)
|
||||
elif key == "env" and isinstance(value, dict):
|
||||
sandbox_defaults[key] = dict(value)
|
||||
else:
|
||||
sandbox_defaults[key] = value
|
||||
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
|
||||
self.sandbox_options = sandbox_defaults
|
||||
self.sandbox_required = bool(self.sandbox_options.get("require"))
|
||||
self.sandbox_container_name = None
|
||||
self.execution_mode = "host"
|
||||
self.using_container = False
|
||||
self._sandbox_bin_path = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""启动终端进程(统一处理编码)"""
|
||||
"""启动终端进程(支持容器沙箱)"""
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
process = None
|
||||
selected_mode = self.sandbox_mode
|
||||
if selected_mode == "docker":
|
||||
try:
|
||||
process = self._start_docker_terminal()
|
||||
except Exception as exc:
|
||||
message = f"容器终端启动失败: {exc}"
|
||||
if self.sandbox_required:
|
||||
print(f"{OUTPUT_FORMATS['error']} {message}")
|
||||
return False
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机终端。")
|
||||
process = None
|
||||
selected_mode = "host"
|
||||
if process is None:
|
||||
process = self._start_host_terminal()
|
||||
selected_mode = "host"
|
||||
|
||||
if not process:
|
||||
return False
|
||||
|
||||
self.process = process
|
||||
self.is_running = True
|
||||
self.execution_mode = "docker" if self.using_container else "host"
|
||||
self.start_time = datetime.now()
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
|
||||
# 启动输出读取线程
|
||||
self.is_reading = True
|
||||
self.reader_thread = threading.Thread(target=self._read_output)
|
||||
self.reader_thread.daemon = True
|
||||
self.reader_thread.start()
|
||||
|
||||
# 宿主机Windows初始化
|
||||
if self.is_windows and not self.using_container:
|
||||
time.sleep(0.5)
|
||||
self.send_command("chcp 65001", wait_for_output=False)
|
||||
time.sleep(0.5)
|
||||
self.send_command("cls", wait_for_output=False)
|
||||
time.sleep(0.3)
|
||||
self.output_buffer.clear()
|
||||
self.total_output_size = 0
|
||||
|
||||
# 广播终端启动事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_started', {
|
||||
'session': self.session_name,
|
||||
'working_dir': str(self.working_dir),
|
||||
'shell': self.shell_command,
|
||||
'mode': self.execution_mode,
|
||||
'time': self.start_time.isoformat()
|
||||
})
|
||||
|
||||
mode_label = "容器" if self.using_container else "宿主机"
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话启动({mode_label}): {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
|
||||
self.is_running = False
|
||||
if self.using_container:
|
||||
self._stop_sandbox_container(force=True)
|
||||
return False
|
||||
|
||||
def _start_host_terminal(self):
|
||||
"""启动宿主机终端"""
|
||||
self.using_container = False
|
||||
self.is_windows = sys.platform == "win32"
|
||||
shell_cmd = self.host_shell_command
|
||||
if self.is_windows:
|
||||
shell_cmd = shell_cmd or "cmd.exe"
|
||||
else:
|
||||
shell_cmd = shell_cmd or os.environ.get('SHELL', '/bin/bash')
|
||||
self.shell_command = shell_cmd
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
try:
|
||||
# 确定使用的shell
|
||||
if self.is_windows:
|
||||
# Windows下使用CMD
|
||||
self.shell_command = self.shell_command or "cmd.exe"
|
||||
else:
|
||||
# Unix系统
|
||||
self.shell_command = self.shell_command or os.environ.get('SHELL', '/bin/bash')
|
||||
|
||||
# 设置环境变量
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
if self.is_windows:
|
||||
# Windows特殊设置
|
||||
env['CHCP'] = '65001' # UTF-8代码页
|
||||
|
||||
# Windows统一不使用text模式,手动处理编码
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0, # 无缓冲
|
||||
env=env
|
||||
)
|
||||
else:
|
||||
# Unix系统
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
|
||||
# Unix也不使用text模式,统一处理
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
env['CHCP'] = '65001'
|
||||
process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@ -134,48 +251,114 @@ class PersistentTerminal:
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
self.start_time = datetime.now()
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
|
||||
# 启动输出读取线程
|
||||
self.is_reading = True
|
||||
self.reader_thread = threading.Thread(target=self._read_output)
|
||||
self.reader_thread.daemon = True
|
||||
self.reader_thread.start()
|
||||
|
||||
# 如果是Windows,设置代码页
|
||||
if self.is_windows:
|
||||
time.sleep(0.5) # 等待终端初始化
|
||||
self.send_command("chcp 65001", wait_for_output=False)
|
||||
time.sleep(0.5)
|
||||
# 清屏以去除代码页设置的输出
|
||||
self.send_command("cls", wait_for_output=False)
|
||||
time.sleep(0.3)
|
||||
self.output_buffer.clear() # 清除初始化输出
|
||||
self.total_output_size = 0
|
||||
|
||||
# 广播终端启动事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_started', {
|
||||
'session': self.session_name,
|
||||
'working_dir': str(self.working_dir),
|
||||
'shell': self.shell_command,
|
||||
'time': self.start_time.isoformat()
|
||||
})
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话启动: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
|
||||
self.is_running = False
|
||||
return False
|
||||
else:
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
return process
|
||||
except FileNotFoundError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 无法找到终端程序: {shell_cmd}")
|
||||
return None
|
||||
|
||||
def _start_docker_terminal(self):
|
||||
"""启动容器化终端"""
|
||||
docker_bin = self.sandbox_options.get("bin") or "docker"
|
||||
docker_path = shutil.which(docker_bin)
|
||||
if not docker_path:
|
||||
message = f"未找到容器运行时: {docker_bin}"
|
||||
if self.sandbox_required:
|
||||
raise RuntimeError(message)
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message}")
|
||||
return None
|
||||
|
||||
image = self.sandbox_options.get("image")
|
||||
if not image:
|
||||
raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置")
|
||||
|
||||
mount_path = self.sandbox_options.get("mount_path") or "/workspace"
|
||||
working_dir = str(self.working_dir)
|
||||
if not self.working_dir.exists():
|
||||
self.working_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
container_name = f"{self.sandbox_options.get('name_prefix', 'agent-term')}-{uuid.uuid4().hex[:10]}"
|
||||
cmd = [
|
||||
docker_path,
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--name",
|
||||
container_name,
|
||||
"-w",
|
||||
mount_path,
|
||||
"-v",
|
||||
f"{working_dir}:{mount_path}",
|
||||
]
|
||||
|
||||
network = self.sandbox_options.get("network")
|
||||
if network:
|
||||
cmd += ["--network", network]
|
||||
cpus = self.sandbox_options.get("cpus")
|
||||
if cpus:
|
||||
cmd += ["--cpus", cpus]
|
||||
memory = self.sandbox_options.get("memory")
|
||||
if memory:
|
||||
cmd += ["--memory", memory]
|
||||
|
||||
for bind in self.sandbox_options.get("binds", []):
|
||||
bind = bind.strip()
|
||||
if bind:
|
||||
cmd += ["-v", bind]
|
||||
|
||||
envs = {
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"TERM": "xterm-256color",
|
||||
}
|
||||
for key, value in (self.sandbox_options.get("env") or {}).items():
|
||||
if value is not None:
|
||||
envs[key] = value
|
||||
for key, value in envs.items():
|
||||
cmd += ["-e", f"{key}={value}"]
|
||||
|
||||
cmd.append(image)
|
||||
shell_path = self.sandbox_options.get("shell") or "/bin/bash"
|
||||
if shell_path:
|
||||
cmd.append(shell_path)
|
||||
if shell_path.endswith("sh"):
|
||||
cmd.append("-i")
|
||||
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
except FileNotFoundError:
|
||||
message = f"无法执行容器运行时: {docker_path}"
|
||||
if self.sandbox_required:
|
||||
raise RuntimeError(message)
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message}")
|
||||
return None
|
||||
|
||||
self.sandbox_container_name = container_name
|
||||
self._sandbox_bin_path = docker_path
|
||||
self.shell_command = f"{shell_path} (sandbox:{image})"
|
||||
self.using_container = True
|
||||
self.is_windows = False
|
||||
return process
|
||||
|
||||
def _read_output(self):
|
||||
"""后台线程:持续读取输出(修复版,正确处理编码)"""
|
||||
@ -610,6 +793,7 @@ class PersistentTerminal:
|
||||
"is_running": self.is_running,
|
||||
"working_dir": str(self.working_dir),
|
||||
"shell": self.shell_command,
|
||||
"execution_mode": self.execution_mode,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"is_interactive": self.is_interactive,
|
||||
"last_command": self.last_command,
|
||||
@ -663,14 +847,37 @@ class PersistentTerminal:
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
if self.using_container:
|
||||
self._stop_sandbox_container()
|
||||
self.using_container = False
|
||||
self.execution_mode = "host"
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 终端会话关闭: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 关闭终端失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保进程被关闭"""
|
||||
if hasattr(self, 'is_running') and self.is_running:
|
||||
self.close()
|
||||
|
||||
def _stop_sandbox_container(self, force: bool = False):
|
||||
"""确保容器终端被停止"""
|
||||
if not self.sandbox_container_name or not self._sandbox_bin_path:
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
[self._sandbox_bin_path, "kill", self.sandbox_container_name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=3,
|
||||
check=False
|
||||
)
|
||||
except Exception:
|
||||
if force:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 强制终止容器 {self.sandbox_container_name} 失败,可能已退出。")
|
||||
finally:
|
||||
self.sandbox_container_name = None
|
||||
|
||||
174
modules/personalization_manager.py
Normal file
174
modules/personalization_manager.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""Utilities for managing per-user personalization settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional, Union
|
||||
|
||||
PERSONALIZATION_FILENAME = "personalization.json"
|
||||
MAX_SHORT_FIELD_LENGTH = 20
|
||||
MAX_CONSIDERATION_LENGTH = 50
|
||||
MAX_CONSIDERATION_ITEMS = 10
|
||||
TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"]
|
||||
|
||||
DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"self_identify": "",
|
||||
"user_name": "",
|
||||
"profession": "",
|
||||
"tone": "",
|
||||
"considerations": [],
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"PERSONALIZATION_FILENAME",
|
||||
"DEFAULT_PERSONALIZATION_CONFIG",
|
||||
"TONE_PRESETS",
|
||||
"MAX_CONSIDERATION_ITEMS",
|
||||
"load_personalization_config",
|
||||
"save_personalization_config",
|
||||
"ensure_personalization_config",
|
||||
"build_personalization_prompt",
|
||||
"sanitize_personalization_payload",
|
||||
]
|
||||
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _to_path(base: PathLike) -> Path:
|
||||
base_path = Path(base).expanduser()
|
||||
if base_path.is_dir():
|
||||
return base_path / PERSONALIZATION_FILENAME
|
||||
return base_path
|
||||
|
||||
|
||||
def ensure_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
|
||||
"""Ensure the personalization file exists and return its content."""
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
if not path.exists():
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2)
|
||||
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
return load_personalization_config(base_dir)
|
||||
|
||||
|
||||
def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
|
||||
"""Load personalization config; fall back to defaults on errors."""
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
if not path.exists():
|
||||
return ensure_personalization_config(base_dir)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return sanitize_personalization_payload(data)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# 重置为默认配置,避免错误阻塞
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2)
|
||||
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
|
||||
|
||||
def sanitize_personalization_payload(
|
||||
payload: Optional[Dict[str, Any]],
|
||||
fallback: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Normalize payload structure and clamp field lengths."""
|
||||
base = deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
if fallback:
|
||||
base.update(fallback)
|
||||
data = payload or {}
|
||||
|
||||
def _resolve_short_field(key: str) -> str:
|
||||
if key in data:
|
||||
return _sanitize_short_field(data.get(key))
|
||||
return _sanitize_short_field(base.get(key))
|
||||
|
||||
base["enabled"] = bool(data.get("enabled", base["enabled"]))
|
||||
base["self_identify"] = _resolve_short_field("self_identify")
|
||||
base["user_name"] = _resolve_short_field("user_name")
|
||||
base["profession"] = _resolve_short_field("profession")
|
||||
base["tone"] = _resolve_short_field("tone")
|
||||
if "considerations" in data:
|
||||
base["considerations"] = _sanitize_considerations(data.get("considerations"))
|
||||
else:
|
||||
base["considerations"] = _sanitize_considerations(base.get("considerations", []))
|
||||
return base
|
||||
|
||||
|
||||
def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist sanitized personalization config and return it."""
|
||||
existing = load_personalization_config(base_dir)
|
||||
config = sanitize_personalization_payload(payload, fallback=existing)
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
return config
|
||||
|
||||
|
||||
def build_personalization_prompt(
|
||||
config: Optional[Dict[str, Any]],
|
||||
include_header: bool = True
|
||||
) -> Optional[str]:
|
||||
"""Generate the personalization prompt text based on config."""
|
||||
if not config or not config.get("enabled"):
|
||||
return None
|
||||
|
||||
lines = []
|
||||
if include_header:
|
||||
lines.append("用户的个性化数据,请回答时务必参照这些信息")
|
||||
|
||||
if config.get("self_identify"):
|
||||
lines.append(f"用户希望你自称:{config['self_identify']}")
|
||||
if config.get("user_name"):
|
||||
lines.append(f"用户希望你称呼为:{config['user_name']}")
|
||||
if config.get("profession"):
|
||||
lines.append(f"用户的职业是:{config['profession']}")
|
||||
if config.get("tone"):
|
||||
lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流")
|
||||
|
||||
considerations: Iterable[str] = config.get("considerations") or []
|
||||
considerations = [item for item in considerations if item]
|
||||
if considerations:
|
||||
lines.append("用户希望你在回答问题时必须考虑的信息是:")
|
||||
for idx, item in enumerate(considerations, 1):
|
||||
lines.append(f"{idx}. {item}")
|
||||
|
||||
if len(lines) == (1 if include_header else 0):
|
||||
# 没有任何有效内容时不注入
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _sanitize_short_field(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
return text[:MAX_SHORT_FIELD_LENGTH]
|
||||
|
||||
|
||||
def _sanitize_considerations(value: Any) -> list:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
cleaned = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
if not text:
|
||||
continue
|
||||
cleaned.append(text[:MAX_CONSIDERATION_LENGTH])
|
||||
if len(cleaned) >= MAX_CONSIDERATION_ITEMS:
|
||||
break
|
||||
return cleaned
|
||||
@ -12,7 +12,19 @@ try:
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_IMAGE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_SHELL,
|
||||
TERMINAL_SANDBOX_NETWORK,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
TERMINAL_SANDBOX_BINDS,
|
||||
TERMINAL_SANDBOX_BIN,
|
||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
TERMINAL_SANDBOX_ENV,
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -26,7 +38,19 @@ except ImportError:
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_IMAGE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_SHELL,
|
||||
TERMINAL_SANDBOX_NETWORK,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
TERMINAL_SANDBOX_BINDS,
|
||||
TERMINAL_SANDBOX_BIN,
|
||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
TERMINAL_SANDBOX_ENV,
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
)
|
||||
|
||||
from modules.persistent_terminal import PersistentTerminal
|
||||
@ -41,7 +65,9 @@ class TerminalManager:
|
||||
max_terminals: int = None,
|
||||
terminal_buffer_size: int = None,
|
||||
terminal_display_size: int = None,
|
||||
broadcast_callback: Callable = None
|
||||
broadcast_callback: Callable = None,
|
||||
sandbox_mode: Optional[str] = None,
|
||||
sandbox_options: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
初始化终端管理器
|
||||
@ -61,6 +87,30 @@ class TerminalManager:
|
||||
self.max_snapshot_lines = TERMINAL_SNAPSHOT_MAX_LINES
|
||||
self.max_snapshot_chars = TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
self.broadcast = broadcast_callback
|
||||
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
|
||||
default_sandbox_options = {
|
||||
"image": TERMINAL_SANDBOX_IMAGE,
|
||||
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
"shell": TERMINAL_SANDBOX_SHELL,
|
||||
"network": TERMINAL_SANDBOX_NETWORK,
|
||||
"cpus": TERMINAL_SANDBOX_CPUS,
|
||||
"memory": TERMINAL_SANDBOX_MEMORY,
|
||||
"binds": list(TERMINAL_SANDBOX_BINDS),
|
||||
"bin": TERMINAL_SANDBOX_BIN,
|
||||
"name_prefix": TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
"env": dict(TERMINAL_SANDBOX_ENV),
|
||||
"require": TERMINAL_SANDBOX_REQUIRE,
|
||||
}
|
||||
if sandbox_options:
|
||||
# 深拷贝,确保不会影响默认值
|
||||
for key, value in sandbox_options.items():
|
||||
if key == "binds" and isinstance(value, list):
|
||||
default_sandbox_options[key] = list(value)
|
||||
elif key == "env" and isinstance(value, dict):
|
||||
default_sandbox_options[key] = dict(value)
|
||||
else:
|
||||
default_sandbox_options[key] = value
|
||||
self.sandbox_options = default_sandbox_options
|
||||
|
||||
# 终端会话字典
|
||||
self.terminals: Dict[str, PersistentTerminal] = {}
|
||||
@ -113,7 +163,7 @@ class TerminalManager:
|
||||
else:
|
||||
work_path = self.project_path
|
||||
|
||||
# 获取合适的shell命令
|
||||
# 获取合适的shell命令(用于宿主机或回退模式)
|
||||
shell_command = self.factory.get_shell_command()
|
||||
|
||||
# 创建终端实例
|
||||
@ -123,7 +173,10 @@ class TerminalManager:
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
display_size=self.terminal_display_size,
|
||||
project_path=str(self.project_path),
|
||||
sandbox_mode=self.sandbox_mode,
|
||||
sandbox_options=self.sandbox_options
|
||||
)
|
||||
|
||||
# 启动终端
|
||||
@ -154,7 +207,7 @@ class TerminalManager:
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"working_dir": str(work_path),
|
||||
"shell": shell_command,
|
||||
"shell": terminal.shell_command or shell_command,
|
||||
"is_active": make_active,
|
||||
"total_sessions": len(self.terminals)
|
||||
}
|
||||
@ -235,7 +288,7 @@ class TerminalManager:
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
working_dir = str(terminal.working_dir)
|
||||
shell_command = terminal.shell_command or self.factory.get_shell_command()
|
||||
shell_command = self.factory.get_shell_command()
|
||||
|
||||
terminal.close()
|
||||
del self.terminals[target_session]
|
||||
@ -246,7 +299,10 @@ class TerminalManager:
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
display_size=self.terminal_display_size,
|
||||
project_path=str(self.project_path),
|
||||
sandbox_mode=self.sandbox_mode,
|
||||
sandbox_options=self.sandbox_options
|
||||
)
|
||||
|
||||
if not new_terminal.start():
|
||||
@ -272,7 +328,7 @@ class TerminalManager:
|
||||
self.broadcast('terminal_reset', {
|
||||
'session': target_session,
|
||||
'working_dir': working_dir,
|
||||
'shell': shell_command,
|
||||
'shell': new_terminal.shell_command or shell_command,
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
self.broadcast('terminal_list_update', {
|
||||
@ -284,7 +340,7 @@ class TerminalManager:
|
||||
"success": True,
|
||||
"session": target_session,
|
||||
"working_dir": working_dir,
|
||||
"shell": shell_command,
|
||||
"shell": new_terminal.shell_command or shell_command,
|
||||
"message": "终端会话已重置并重新启动"
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,8 @@ try:
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS,
|
||||
MAX_RUN_COMMAND_CHARS
|
||||
MAX_RUN_COMMAND_CHARS,
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
)
|
||||
except ImportError:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
@ -24,8 +25,10 @@ except ImportError:
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS,
|
||||
MAX_RUN_COMMAND_CHARS
|
||||
MAX_RUN_COMMAND_CHARS,
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
)
|
||||
from modules.toolbox_container import ToolboxContainer
|
||||
|
||||
class TerminalOperator:
|
||||
def __init__(self, project_path: str):
|
||||
@ -34,6 +37,7 @@ class TerminalOperator:
|
||||
# 自动检测Python命令
|
||||
self.python_cmd = self._detect_python_command()
|
||||
print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}")
|
||||
self._toolbox: Optional[ToolboxContainer] = None
|
||||
|
||||
def _detect_python_command(self) -> str:
|
||||
"""
|
||||
@ -73,6 +77,14 @@ class TerminalOperator:
|
||||
|
||||
# 如果都没找到,根据平台返回默认值
|
||||
return "python" if sys.platform == "win32" else "python3"
|
||||
|
||||
def _get_toolbox(self) -> ToolboxContainer:
|
||||
if self._toolbox is None:
|
||||
self._toolbox = ToolboxContainer(
|
||||
project_path=str(self.project_path),
|
||||
idle_timeout=TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
)
|
||||
return self._toolbox
|
||||
|
||||
def _validate_command(self, command: str) -> Tuple[bool, str]:
|
||||
"""验证命令安全性"""
|
||||
@ -131,20 +143,15 @@ class TerminalOperator:
|
||||
}
|
||||
|
||||
# 设置工作目录
|
||||
if working_dir:
|
||||
work_path = (self.project_path / working_dir).resolve()
|
||||
# 确保工作目录在项目内
|
||||
try:
|
||||
work_path.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工作目录必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
else:
|
||||
work_path = self.project_path
|
||||
try:
|
||||
work_path = self._resolve_work_path(working_dir)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工作目录必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
|
||||
|
||||
@ -152,7 +159,53 @@ class TerminalOperator:
|
||||
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
||||
|
||||
try:
|
||||
# 创建进程
|
||||
toolbox_raw = await self._get_toolbox().run(command, work_path, timeout=timeout)
|
||||
result_payload = self._format_toolbox_output(toolbox_raw)
|
||||
except Exception as exc:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 工具容器执行失败,回退到本地子进程: {exc}")
|
||||
result_payload = await self._run_command_subprocess(command, work_path, timeout)
|
||||
|
||||
# 字符数检查
|
||||
if result_payload.get("success") and "output" in result_payload:
|
||||
char_count = len(result_payload["output"])
|
||||
if char_count > MAX_RUN_COMMAND_CHARS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"结果内容过大,有{char_count}字符,请使用限制字符数的获取内容方式,根据程度选择10k以内的数",
|
||||
"char_count": char_count,
|
||||
"limit": MAX_RUN_COMMAND_CHARS,
|
||||
"command": command
|
||||
}
|
||||
|
||||
return result_payload
|
||||
|
||||
def _resolve_work_path(self, working_dir: Optional[str]) -> Path:
|
||||
if working_dir:
|
||||
work_path = (self.project_path / working_dir).resolve()
|
||||
work_path.relative_to(self.project_path)
|
||||
return work_path
|
||||
return self.project_path
|
||||
|
||||
def _format_toolbox_output(self, payload: Dict) -> Dict:
|
||||
success = bool(payload.get("success"))
|
||||
output_text = payload.get("output", "") or ""
|
||||
result = {
|
||||
"success": success,
|
||||
"output": output_text,
|
||||
"return_code": 0 if success else -1
|
||||
}
|
||||
if not success:
|
||||
result["error"] = payload.get("error") or payload.get("message", "命令执行失败")
|
||||
if "message" in payload:
|
||||
result["message"] = payload["message"]
|
||||
if "status" in payload:
|
||||
result["status"] = payload["status"]
|
||||
if "truncated" in payload:
|
||||
result["truncated"] = payload["truncated"]
|
||||
return result
|
||||
|
||||
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@ -161,7 +214,6 @@ class TerminalOperator:
|
||||
shell=True
|
||||
)
|
||||
|
||||
# 等待执行完成
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
@ -177,12 +229,10 @@ class TerminalOperator:
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 解码输出
|
||||
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
|
||||
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
|
||||
|
||||
success = process.returncode == 0
|
||||
|
||||
if success:
|
||||
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
|
||||
else:
|
||||
@ -200,7 +250,7 @@ class TerminalOperator:
|
||||
truncated = True
|
||||
combined_output = combined_output[-MAX_RUN_COMMAND_CHARS:]
|
||||
|
||||
result_payload = {
|
||||
response = {
|
||||
"success": success,
|
||||
"command": command,
|
||||
"output": combined_output,
|
||||
@ -208,17 +258,16 @@ class TerminalOperator:
|
||||
"truncated": truncated
|
||||
}
|
||||
if stderr_text:
|
||||
result_payload["stderr"] = stderr_text
|
||||
return result_payload
|
||||
|
||||
except Exception as e:
|
||||
response["stderr"] = stderr_text
|
||||
return response
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"执行失败: {str(e)}",
|
||||
"error": f"执行失败: {str(exc)}",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
|
||||
async def run_python_code(
|
||||
self,
|
||||
code: str,
|
||||
|
||||
156
modules/toolbox_container.py
Normal file
156
modules/toolbox_container.py
Normal file
@ -0,0 +1,156 @@
|
||||
# modules/toolbox_container.py - 专用工具容器管理器
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from modules.persistent_terminal import PersistentTerminal
|
||||
from config import (
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_IMAGE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_SHELL,
|
||||
TERMINAL_SANDBOX_NETWORK,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
TERMINAL_SANDBOX_BINDS,
|
||||
TERMINAL_SANDBOX_BIN,
|
||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||
TERMINAL_SANDBOX_ENV,
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _build_sandbox_options() -> Dict:
|
||||
"""构造与终端一致的沙箱配置."""
|
||||
name_prefix = TERMINAL_SANDBOX_NAME_PREFIX
|
||||
if name_prefix:
|
||||
name_prefix = f"{name_prefix}-toolbox"
|
||||
else:
|
||||
name_prefix = "toolbox-term"
|
||||
return {
|
||||
"image": TERMINAL_SANDBOX_IMAGE,
|
||||
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
"shell": TERMINAL_SANDBOX_SHELL,
|
||||
"network": TERMINAL_SANDBOX_NETWORK,
|
||||
"cpus": TERMINAL_SANDBOX_CPUS,
|
||||
"memory": TERMINAL_SANDBOX_MEMORY,
|
||||
"binds": list(TERMINAL_SANDBOX_BINDS),
|
||||
"bin": TERMINAL_SANDBOX_BIN,
|
||||
"name_prefix": name_prefix,
|
||||
"env": dict(TERMINAL_SANDBOX_ENV),
|
||||
"require": TERMINAL_SANDBOX_REQUIRE,
|
||||
}
|
||||
|
||||
|
||||
class ToolboxContainer:
|
||||
"""为 run_command/run_python 提供的专用容器."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str,
|
||||
sandbox_mode: Optional[str] = None,
|
||||
sandbox_options: Optional[Dict] = None,
|
||||
idle_timeout: int = TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
|
||||
options = _build_sandbox_options()
|
||||
if sandbox_options:
|
||||
for key, value in sandbox_options.items():
|
||||
if key == "binds" and isinstance(value, list):
|
||||
options[key] = list(value)
|
||||
elif key == "env" and isinstance(value, dict):
|
||||
options[key] = dict(value)
|
||||
else:
|
||||
options[key] = value
|
||||
self.sandbox_options = options
|
||||
self.idle_timeout = max(0, int(idle_timeout)) if idle_timeout is not None else 0
|
||||
|
||||
self._terminal: Optional[PersistentTerminal] = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._session_name = f"toolbox-{uuid.uuid4().hex[:10]}"
|
||||
self._last_used = 0.0
|
||||
|
||||
async def _ensure_terminal(self) -> PersistentTerminal:
|
||||
"""确保容器已启动。"""
|
||||
async with self._lock:
|
||||
if self._terminal and self._terminal.is_running:
|
||||
return self._terminal
|
||||
|
||||
terminal = PersistentTerminal(
|
||||
session_name=self._session_name,
|
||||
working_dir=str(self.project_path),
|
||||
broadcast_callback=None,
|
||||
project_path=str(self.project_path),
|
||||
sandbox_mode=self.sandbox_mode,
|
||||
sandbox_options=self.sandbox_options,
|
||||
)
|
||||
|
||||
if not terminal.start():
|
||||
raise RuntimeError("工具容器启动失败,请检查 Docker 或本地 shell 环境。")
|
||||
|
||||
self._terminal = terminal
|
||||
self._last_used = time.time()
|
||||
print(f"[Toolbox] 工具容器已启动 (session={self._session_name}, mode={terminal.execution_mode})")
|
||||
return terminal
|
||||
|
||||
def _wrap_command(self, command: str, work_path: Path, execution_mode: Optional[str]) -> str:
|
||||
"""根据执行模式动态拼接 cd 指令。"""
|
||||
if work_path == self.project_path:
|
||||
return command
|
||||
|
||||
try:
|
||||
relative = work_path.relative_to(self.project_path).as_posix()
|
||||
except ValueError:
|
||||
relative = ""
|
||||
|
||||
if not relative:
|
||||
return command
|
||||
|
||||
if execution_mode == "docker":
|
||||
mount_path = self.sandbox_options.get("mount_path", "/workspace").rstrip("/")
|
||||
target = f"{mount_path}/{relative}"
|
||||
else:
|
||||
target = str(work_path)
|
||||
|
||||
return f"cd {shlex.quote(target)} && {command}"
|
||||
|
||||
async def run(self, command: str, work_path: Path, timeout: Optional[float] = None) -> Dict:
|
||||
"""在容器/主机终端中执行命令。"""
|
||||
terminal = await self._ensure_terminal()
|
||||
shell_command = self._wrap_command(command, work_path, terminal.execution_mode)
|
||||
result = await asyncio.to_thread(
|
||||
terminal.send_command,
|
||||
shell_command,
|
||||
True,
|
||||
timeout,
|
||||
)
|
||||
self._last_used = time.time()
|
||||
self._cleanup_if_idle()
|
||||
return result
|
||||
|
||||
def _cleanup_if_idle(self):
|
||||
if self.idle_timeout <= 0 or not self._terminal:
|
||||
return
|
||||
if time.time() - self._last_used >= self.idle_timeout:
|
||||
self._terminal.close()
|
||||
print(f"[Toolbox] 工具容器空闲超时,已释放 (session={self._session_name})")
|
||||
self._terminal = None
|
||||
|
||||
def shutdown(self):
|
||||
"""立即关闭容器。"""
|
||||
if self._terminal:
|
||||
self._terminal.close()
|
||||
print(f"[Toolbox] 工具容器已关闭 (session={self._session_name})")
|
||||
self._terminal = None
|
||||
|
||||
@property
|
||||
def execution_mode(self) -> Optional[str]:
|
||||
if self._terminal:
|
||||
return self._terminal.execution_mode
|
||||
return None
|
||||
@ -16,6 +16,7 @@ from config import (
|
||||
USER_SPACE_DIR,
|
||||
USERS_DB_FILE,
|
||||
)
|
||||
from modules.personalization_manager import ensure_personalization_config
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -128,6 +129,7 @@ class UserManager:
|
||||
# 初始化数据子目录
|
||||
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||
ensure_personalization_config(data_dir)
|
||||
|
||||
return UserWorkspace(
|
||||
username=username,
|
||||
|
||||
@ -243,7 +243,8 @@ tree -L 2
|
||||
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
|
||||
|
||||
## 当前环境信息
|
||||
- 项目路径: {project_path}
|
||||
- 项目路径: 你运行在隔离容器中(挂载目录 {container_path}),宿主机路径已对你隐藏
|
||||
- 资源限制: 容器内核数上限 {container_cpus},内存 {container_memory},项目磁盘配额 {project_storage}
|
||||
- 项目文件结构: {file_tree}
|
||||
- 长期记忆: {memory}
|
||||
- 当前时间: {current_time}
|
||||
@ -256,4 +257,6 @@ tree -L 2
|
||||
4. **用户友好**:用简单的语言解释复杂的操作
|
||||
5. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
|
||||
如果用户设置了个性化信息,根据用户的个性化需求回答
|
||||
|
||||
2
prompts/personalization.txt
Normal file
2
prompts/personalization.txt
Normal file
@ -0,0 +1,2 @@
|
||||
以下内容为用户提供的个性化设置信息,请务必在整个任务过程中遵循:
|
||||
{personalization_block}
|
||||
@ -13,6 +13,7 @@
|
||||
* 是否需要创建/修改/删除文件、运行终端命令或脚本?
|
||||
* 是否需要创建/等待/关闭子智能体?
|
||||
* 是否需要更新主记忆或任务记忆?
|
||||
* 如果用户开启了个性化模式,要考虑哪些用户要求的必须考虑的点?
|
||||
|
||||
2. **正式输出阶段**
|
||||
- 直接向用户说明你的计划:描述每一步准备做什么、需要哪些工具或文件。
|
||||
|
||||
448
static/app.js
448
static/app.js
@ -71,7 +71,8 @@ const ICONS = Object.freeze({
|
||||
triangleAlert: '/static/icons/triangle-alert.svg',
|
||||
user: '/static/icons/user.svg',
|
||||
wrench: '/static/icons/wrench.svg',
|
||||
x: '/static/icons/x.svg'
|
||||
x: '/static/icons/x.svg',
|
||||
zap: '/static/icons/zap.svg'
|
||||
});
|
||||
|
||||
const TOOL_ICON_MAP = Object.freeze({
|
||||
@ -102,7 +103,8 @@ const TOOL_ICON_MAP = Object.freeze({
|
||||
unfocus_file: 'eye',
|
||||
update_memory: 'brain',
|
||||
wait_sub_agent: 'clock',
|
||||
web_search: 'search'
|
||||
web_search: 'search',
|
||||
trigger_easter_egg: 'sparkles'
|
||||
});
|
||||
|
||||
const TOOL_CATEGORY_ICON_MAP = Object.freeze({
|
||||
@ -113,7 +115,8 @@ const TOOL_CATEGORY_ICON_MAP = Object.freeze({
|
||||
terminal_command: 'terminal',
|
||||
memory: 'brain',
|
||||
todo: 'stickyNote',
|
||||
sub_agent: 'bot'
|
||||
sub_agent: 'bot',
|
||||
easter_egg: 'sparkles'
|
||||
});
|
||||
|
||||
function injectScriptSequentially(urls, onSuccess, onFailure) {
|
||||
@ -219,12 +222,12 @@ async function bootstrapApp() {
|
||||
autoScrollEnabled: true,
|
||||
|
||||
// 面板宽度控制
|
||||
leftWidth: 280,
|
||||
leftWidth: 350,
|
||||
rightWidth: 420,
|
||||
rightCollapsed: true,
|
||||
isResizing: false,
|
||||
resizingPanel: null,
|
||||
minPanelWidth: 200,
|
||||
minPanelWidth: 350,
|
||||
maxPanelWidth: 600,
|
||||
|
||||
// 工具状态跟踪
|
||||
@ -251,6 +254,25 @@ async function bootstrapApp() {
|
||||
currentConversationId: null,
|
||||
currentConversationTitle: '当前对话',
|
||||
personalPageVisible: false,
|
||||
personalizationLoading: false,
|
||||
personalizationSaving: false,
|
||||
personalizationLoaded: false,
|
||||
personalizationStatus: '',
|
||||
personalizationError: '',
|
||||
personalizationMaxConsiderations: 10,
|
||||
personalizationToggleUpdating: false,
|
||||
overlayPressActive: false,
|
||||
personalForm: {
|
||||
enabled: false,
|
||||
self_identify: '',
|
||||
user_name: '',
|
||||
profession: '',
|
||||
tone: '',
|
||||
considerations: []
|
||||
},
|
||||
tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'],
|
||||
newConsideration: '',
|
||||
draggedConsiderationIndex: null,
|
||||
|
||||
// 搜索功能
|
||||
searchQuery: '',
|
||||
@ -302,6 +324,15 @@ async function bootstrapApp() {
|
||||
todoList: null,
|
||||
icons: ICONS,
|
||||
toolCategoryIcons: TOOL_CATEGORY_ICON_MAP,
|
||||
easterEgg: {
|
||||
active: false,
|
||||
effect: null,
|
||||
payload: null,
|
||||
instance: null,
|
||||
cleanupTimer: null,
|
||||
destroying: false,
|
||||
destroyPromise: null
|
||||
},
|
||||
|
||||
// 右键菜单相关
|
||||
contextMenu: {
|
||||
@ -389,6 +420,10 @@ async function bootstrapApp() {
|
||||
clearInterval(this.subAgentPollTimer);
|
||||
this.subAgentPollTimer = null;
|
||||
}
|
||||
const cleanup = this.destroyEasterEggEffect(true);
|
||||
if (cleanup && typeof cleanup.catch === 'function') {
|
||||
cleanup.catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -849,11 +884,12 @@ async function bootstrapApp() {
|
||||
lastAction.content += data.content;
|
||||
}
|
||||
this.$forceUpdate();
|
||||
if (lastAction && lastAction.blockId) {
|
||||
this.$nextTick(() => this.scrollThinkingToBottom(lastAction.blockId));
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
if (lastAction && lastAction.blockId) {
|
||||
this.scrollThinkingToBottom(lastAction.blockId);
|
||||
}
|
||||
this.conditionalScrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1074,6 +1110,14 @@ async function bootstrapApp() {
|
||||
if (data.result !== undefined) {
|
||||
targetAction.tool.result = data.result;
|
||||
}
|
||||
if (targetAction.tool && targetAction.tool.name === 'trigger_easter_egg' && data.result !== undefined) {
|
||||
const eggPromise = this.handleEasterEggPayload(data.result);
|
||||
if (eggPromise && typeof eggPromise.catch === 'function') {
|
||||
eggPromise.catch((error) => {
|
||||
console.warn('彩蛋处理异常:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.message !== undefined) {
|
||||
targetAction.tool.message = data.message;
|
||||
}
|
||||
@ -1582,6 +1626,9 @@ async function bootstrapApp() {
|
||||
} else {
|
||||
this.conversations.push(...data.data.conversations);
|
||||
}
|
||||
if (this.currentConversationId) {
|
||||
this.promoteConversationToTop(this.currentConversationId);
|
||||
}
|
||||
this.hasMoreConversations = data.data.has_more;
|
||||
console.log(`已加载 ${this.conversations.length} 个对话`);
|
||||
|
||||
@ -2119,12 +2166,221 @@ async function bootstrapApp() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
openPersonalPage() {
|
||||
async openPersonalPage() {
|
||||
this.personalPageVisible = true;
|
||||
if (!this.personalizationLoaded && !this.personalizationLoading) {
|
||||
await this.fetchPersonalization();
|
||||
}
|
||||
},
|
||||
|
||||
closePersonalPage() {
|
||||
this.personalPageVisible = false;
|
||||
this.draggedConsiderationIndex = null;
|
||||
this.overlayPressActive = false;
|
||||
},
|
||||
|
||||
handleOverlayPressStart(event) {
|
||||
if (event && event.type === 'mousedown' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
this.overlayPressActive = true;
|
||||
},
|
||||
|
||||
handleOverlayPressEnd() {
|
||||
if (!this.overlayPressActive) {
|
||||
return;
|
||||
}
|
||||
this.overlayPressActive = false;
|
||||
this.closePersonalPage();
|
||||
},
|
||||
|
||||
handleOverlayPressCancel() {
|
||||
this.overlayPressActive = false;
|
||||
},
|
||||
|
||||
async handlePersonalizationToggle() {
|
||||
if (this.personalizationToggleUpdating) {
|
||||
return;
|
||||
}
|
||||
const newValue = !!this.personalForm.enabled;
|
||||
const previousValue = !newValue;
|
||||
this.personalizationToggleUpdating = true;
|
||||
this.personalizationStatus = '';
|
||||
this.personalizationError = '';
|
||||
try {
|
||||
const resp = await fetch('/api/personalization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ enabled: newValue })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '更新失败');
|
||||
}
|
||||
const statusLabel = newValue ? '已启用' : '已停用';
|
||||
this.personalizationStatus = statusLabel;
|
||||
setTimeout(() => {
|
||||
if (this.personalizationStatus === statusLabel) {
|
||||
this.personalizationStatus = '';
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
this.personalForm.enabled = previousValue;
|
||||
this.personalizationError = error.message || '更新失败';
|
||||
} finally {
|
||||
this.personalizationToggleUpdating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
const resp = await fetch('/logout', { method: 'POST' });
|
||||
let result = {};
|
||||
try {
|
||||
result = await resp.json();
|
||||
} catch (err) {
|
||||
result = {};
|
||||
}
|
||||
if (!resp.ok || (result && result.success === false)) {
|
||||
const message = (result && (result.error || result.message)) || '退出失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
alert(`退出登录失败:${error.message || '请稍后重试'}`);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPersonalization() {
|
||||
this.personalizationLoading = true;
|
||||
this.personalizationError = '';
|
||||
try {
|
||||
const resp = await fetch('/api/personalization');
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '加载失败');
|
||||
}
|
||||
this.applyPersonalizationData(result.data || {});
|
||||
this.personalizationLoaded = true;
|
||||
} catch (error) {
|
||||
this.personalizationError = error.message || '加载失败';
|
||||
alert(`加载个性化配置失败:${this.personalizationError}`);
|
||||
} finally {
|
||||
this.personalizationLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
applyPersonalizationData(data) {
|
||||
this.personalForm = {
|
||||
enabled: !!data.enabled,
|
||||
self_identify: data.self_identify || '',
|
||||
user_name: data.user_name || '',
|
||||
profession: data.profession || '',
|
||||
tone: data.tone || '',
|
||||
considerations: Array.isArray(data.considerations) ? [...data.considerations] : []
|
||||
};
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
clearPersonalizationFeedback() {
|
||||
this.personalizationStatus = '';
|
||||
this.personalizationError = '';
|
||||
},
|
||||
|
||||
async savePersonalization() {
|
||||
if (this.personalizationSaving) {
|
||||
return;
|
||||
}
|
||||
this.personalizationSaving = true;
|
||||
this.personalizationStatus = '';
|
||||
this.personalizationError = '';
|
||||
try {
|
||||
const resp = await fetch('/api/personalization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.personalForm)
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
this.applyPersonalizationData(result.data || {});
|
||||
this.personalizationStatus = '已保存';
|
||||
setTimeout(() => {
|
||||
this.personalizationStatus = '';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
this.personalizationError = error.message || '保存失败';
|
||||
alert(`保存个性化配置失败:${this.personalizationError}`);
|
||||
} finally {
|
||||
this.personalizationSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
addConsideration() {
|
||||
if (!this.newConsideration) {
|
||||
return;
|
||||
}
|
||||
if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) {
|
||||
alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`);
|
||||
return;
|
||||
}
|
||||
this.personalForm.considerations = [
|
||||
...this.personalForm.considerations,
|
||||
this.newConsideration
|
||||
];
|
||||
this.newConsideration = '';
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
removeConsideration(index) {
|
||||
const items = [...this.personalForm.considerations];
|
||||
items.splice(index, 1);
|
||||
this.personalForm.considerations = items;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
applyTonePreset(preset) {
|
||||
this.personalForm.tone = preset;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
handleConsiderationDragStart(index, event) {
|
||||
this.draggedConsiderationIndex = index;
|
||||
if (event && event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
},
|
||||
|
||||
handleConsiderationDragOver(index, event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.draggedConsiderationIndex === null || this.draggedConsiderationIndex === index) {
|
||||
return;
|
||||
}
|
||||
const items = [...this.personalForm.considerations];
|
||||
const [moved] = items.splice(this.draggedConsiderationIndex, 1);
|
||||
items.splice(index, 0, moved);
|
||||
this.personalForm.considerations = items;
|
||||
this.draggedConsiderationIndex = index;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
handleConsiderationDrop(index, event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.draggedConsiderationIndex = null;
|
||||
},
|
||||
|
||||
handleConsiderationDragEnd() {
|
||||
this.draggedConsiderationIndex = null;
|
||||
},
|
||||
|
||||
async toggleThinkingMode() {
|
||||
@ -2515,7 +2771,7 @@ async function bootstrapApp() {
|
||||
if (!this.quickMenuOpen) {
|
||||
this.quickMenuOpen = true;
|
||||
}
|
||||
this.loadToolSettings();
|
||||
this.loadToolSettings(true);
|
||||
}
|
||||
},
|
||||
|
||||
@ -2557,12 +2813,41 @@ async function bootstrapApp() {
|
||||
|
||||
handleInputFocus() {
|
||||
this.inputIsFocused = true;
|
||||
this.closeQuickMenu();
|
||||
},
|
||||
|
||||
handleInputBlur() {
|
||||
this.inputIsFocused = false;
|
||||
},
|
||||
|
||||
handleRealtimeTerminalClick() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.openRealtimeTerminal();
|
||||
},
|
||||
|
||||
handleFocusPanelToggleClick() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.toggleFocusPanel();
|
||||
},
|
||||
|
||||
handleTokenPanelToggleClick() {
|
||||
if (!this.currentConversationId) {
|
||||
return;
|
||||
}
|
||||
this.toggleTokenPanel();
|
||||
},
|
||||
|
||||
handleCompressConversationClick() {
|
||||
if (this.compressing || this.streamingMessage || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.compressConversation();
|
||||
},
|
||||
|
||||
autoResizeInput() {
|
||||
this.$nextTick(() => {
|
||||
const textarea = this.$refs.stadiumInput;
|
||||
@ -3232,6 +3517,147 @@ async function bootstrapApp() {
|
||||
document.body.style.cursor = '';
|
||||
},
|
||||
|
||||
async handleEasterEggPayload(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
let parsed = payload;
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch (error) {
|
||||
console.warn('无法解析彩蛋结果:', payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
if (parsed.error) {
|
||||
console.warn('彩蛋触发失败:', parsed.error);
|
||||
}
|
||||
await this.destroyEasterEggEffect(true);
|
||||
return;
|
||||
}
|
||||
const effectName = (parsed.effect || '').toLowerCase();
|
||||
if (!effectName) {
|
||||
console.warn('彩蛋结果缺少 effect 字段');
|
||||
return;
|
||||
}
|
||||
await this.startEasterEggEffect(effectName, parsed);
|
||||
},
|
||||
|
||||
async startEasterEggEffect(effectName, payload = {}) {
|
||||
const registry = window.EasterEggRegistry;
|
||||
if (!registry) {
|
||||
console.warn('EasterEggRegistry 尚未加载,无法播放彩蛋');
|
||||
return;
|
||||
}
|
||||
if (!registry.has(effectName)) {
|
||||
console.warn('未注册的彩蛋 effect:', effectName);
|
||||
await this.destroyEasterEggEffect(true);
|
||||
return;
|
||||
}
|
||||
const root = this.$refs.easterEggRoot;
|
||||
if (!root) {
|
||||
console.warn('未找到彩蛋根节点');
|
||||
return;
|
||||
}
|
||||
await this.destroyEasterEggEffect(true);
|
||||
this.easterEgg.active = true;
|
||||
this.easterEgg.effect = effectName;
|
||||
this.easterEgg.payload = payload;
|
||||
const instance = registry.start(effectName, {
|
||||
root,
|
||||
payload,
|
||||
app: this
|
||||
});
|
||||
if (!instance) {
|
||||
this.finishEasterEggCleanup();
|
||||
return;
|
||||
}
|
||||
this.easterEgg.instance = instance;
|
||||
this.easterEgg.destroyPromise = null;
|
||||
this.easterEgg.destroying = false;
|
||||
if (this.easterEgg.cleanupTimer) {
|
||||
clearTimeout(this.easterEgg.cleanupTimer);
|
||||
}
|
||||
const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45);
|
||||
this.easterEgg.cleanupTimer = setTimeout(() => {
|
||||
const cleanup = this.destroyEasterEggEffect(false);
|
||||
if (cleanup && typeof cleanup.catch === 'function') {
|
||||
cleanup.catch(() => {});
|
||||
}
|
||||
}, durationSeconds * 1000);
|
||||
if (payload.message) {
|
||||
console.info(`[彩蛋] ${payload.display_name || effectName}: ${payload.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
destroyEasterEggEffect(forceImmediate = false) {
|
||||
if (this.easterEgg.cleanupTimer) {
|
||||
clearTimeout(this.easterEgg.cleanupTimer);
|
||||
this.easterEgg.cleanupTimer = null;
|
||||
}
|
||||
const instance = this.easterEgg.instance;
|
||||
if (!instance) {
|
||||
this.finishEasterEggCleanup();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.easterEgg.destroying) {
|
||||
return this.easterEgg.destroyPromise || Promise.resolve();
|
||||
}
|
||||
this.easterEgg.destroying = true;
|
||||
let result;
|
||||
try {
|
||||
result = instance.destroy({
|
||||
immediate: forceImmediate,
|
||||
payload: this.easterEgg.payload,
|
||||
root: this.$refs.easterEggRoot || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('销毁彩蛋时发生错误:', error);
|
||||
this.easterEgg.destroying = false;
|
||||
this.finishEasterEggCleanup();
|
||||
return Promise.resolve();
|
||||
}
|
||||
const finalize = () => {
|
||||
this.easterEgg.destroyPromise = null;
|
||||
this.easterEgg.destroying = false;
|
||||
this.finishEasterEggCleanup();
|
||||
};
|
||||
if (result && typeof result.then === 'function') {
|
||||
this.easterEgg.destroyPromise = result.then(() => {
|
||||
finalize();
|
||||
}).catch((error) => {
|
||||
console.warn('彩蛋清理失败:', error);
|
||||
finalize();
|
||||
});
|
||||
return this.easterEgg.destroyPromise;
|
||||
} else {
|
||||
finalize();
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
|
||||
finishEasterEggCleanup() {
|
||||
if (this.easterEgg.cleanupTimer) {
|
||||
clearTimeout(this.easterEgg.cleanupTimer);
|
||||
this.easterEgg.cleanupTimer = null;
|
||||
}
|
||||
const root = this.$refs.easterEggRoot;
|
||||
if (root) {
|
||||
root.innerHTML = '';
|
||||
}
|
||||
this.easterEgg.active = false;
|
||||
this.easterEgg.effect = null;
|
||||
this.easterEgg.payload = null;
|
||||
this.easterEgg.instance = null;
|
||||
this.easterEgg.destroyPromise = null;
|
||||
this.easterEgg.destroying = false;
|
||||
},
|
||||
|
||||
// 格式化token显示(修复NaN问题)
|
||||
formatTokenCount(tokens) {
|
||||
// 确保tokens是数字,防止NaN
|
||||
|
||||
38
static/easter-eggs/flood.css
Normal file
38
static/easter-eggs/flood.css
Normal file
@ -0,0 +1,38 @@
|
||||
.easter-egg-overlay .easter-egg-water {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.easter-egg-overlay .easter-egg-water-container {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.easter-egg-overlay .easter-egg-water-container .wave {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
background-repeat: repeat-x;
|
||||
background-position: bottom;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.easter-egg-overlay .easter-egg-water-container .wave.wave1 {
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
.easter-egg-overlay .easter-egg-water-container .wave.wave2 {
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.easter-egg-overlay .easter-egg-water-container .wave.wave3 {
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
267
static/easter-eggs/flood.js
Normal file
267
static/easter-eggs/flood.js
Normal file
@ -0,0 +1,267 @@
|
||||
(function registerFloodEffect(global) {
|
||||
const registry = global.EasterEggRegistry;
|
||||
if (!registry) {
|
||||
console.error('[easter-eggs:flood] 未找到 EasterEggRegistry,无法注册特效');
|
||||
return;
|
||||
}
|
||||
|
||||
registry.register('flood', function createFloodEffect(context = {}) {
|
||||
const root = context.root;
|
||||
if (!root) {
|
||||
throw new Error('缺少彩蛋根节点');
|
||||
}
|
||||
|
||||
const payload = context.payload || {};
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'easter-egg-water';
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'easter-egg-water-container';
|
||||
|
||||
wrapper.appendChild(container);
|
||||
root.appendChild(wrapper);
|
||||
|
||||
const waves = createWaves(container);
|
||||
|
||||
const state = {
|
||||
payload,
|
||||
root,
|
||||
wrapper,
|
||||
container,
|
||||
waves,
|
||||
styleNode: null,
|
||||
retreatPromise: null,
|
||||
retreatTimeout: null
|
||||
};
|
||||
|
||||
runFloodAnimation(state);
|
||||
|
||||
return {
|
||||
/**
|
||||
* 销毁特效。
|
||||
* @param {{immediate?: boolean}} options
|
||||
*/
|
||||
destroy(options = {}) {
|
||||
const immediate = Boolean(options.immediate);
|
||||
if (immediate) {
|
||||
cleanupFloodState(state);
|
||||
return null;
|
||||
}
|
||||
if (state.retreatPromise) {
|
||||
return state.retreatPromise;
|
||||
}
|
||||
return startFloodRetreat(state);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function createWaves(container) {
|
||||
const waves = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const wave = document.createElement('div');
|
||||
wave.className = `wave wave${i}`;
|
||||
container.appendChild(wave);
|
||||
waves.push(wave);
|
||||
}
|
||||
return waves;
|
||||
}
|
||||
|
||||
function runFloodAnimation(state) {
|
||||
const { container, waves, payload } = state;
|
||||
if (!container || !waves.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.setAttribute('data-easter-egg', 'flood');
|
||||
document.head.appendChild(styleEl);
|
||||
state.styleNode = styleEl;
|
||||
const sheet = styleEl.sheet;
|
||||
if (!sheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.animation = 'none';
|
||||
container.style.height = '0%';
|
||||
void container.offsetHeight;
|
||||
|
||||
const range = Array.isArray(payload.intensity_range) && payload.intensity_range.length === 2
|
||||
? payload.intensity_range
|
||||
: [0.85, 0.92];
|
||||
const minHeight = Math.min(range[0], range[1]);
|
||||
const maxHeight = Math.max(range[0], range[1]);
|
||||
const targetHeight = randRange(minHeight * 100, maxHeight * 100);
|
||||
const riseDuration = randRange(30, 40);
|
||||
const easing = pickOne([
|
||||
'ease-in-out',
|
||||
'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'cubic-bezier(0.42, 0, 0.58, 1)'
|
||||
]) || 'ease-in-out';
|
||||
const riseName = uniqueKey('flood_rise');
|
||||
sheet.insertRule(
|
||||
`@keyframes ${riseName} { 0% { height: 0%; } 100% { height: ${targetHeight}%; } }`,
|
||||
sheet.cssRules.length
|
||||
);
|
||||
container.style.animation = `${riseName} ${riseDuration}s ${easing} forwards`;
|
||||
|
||||
const directionSets = [
|
||||
[1, 1, -1],
|
||||
[1, -1, -1],
|
||||
[-1, 1, 1],
|
||||
[-1, -1, 1]
|
||||
];
|
||||
const directions = pickOne(directionSets) || [1, -1, 1];
|
||||
const colors = [
|
||||
'rgba(135, 206, 250, 0.35)',
|
||||
'rgba(100, 181, 246, 0.45)',
|
||||
'rgba(33, 150, 243, 0.4)'
|
||||
];
|
||||
|
||||
waves.forEach((wave, index) => {
|
||||
const svgData = buildFloodWaveShape(index);
|
||||
const color = colors[index % colors.length];
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgData.width} 1000" preserveAspectRatio="none"><path d="${svgData.path}" fill="${color}"/></svg>`;
|
||||
const encoded = encodeURIComponent(svg);
|
||||
wave.style.backgroundImage = `url("data:image/svg+xml,${encoded}")`;
|
||||
wave.style.backgroundSize = `${svgData.waveWidth}px 100%`;
|
||||
|
||||
const animationName = uniqueKey(`flood_wave_${index}`);
|
||||
const startPosition = randInt(-200, 200);
|
||||
const distance = svgData.waveWidth * (directions[index] || 1);
|
||||
const duration = index === 0 ? randRange(16, 22) : index === 1 ? randRange(11, 16) : randRange(7, 12);
|
||||
const delay = randRange(0, 1.5);
|
||||
sheet.insertRule(
|
||||
`@keyframes ${animationName} { 0% { background-position-x: ${startPosition}px; } 100% { background-position-x: ${startPosition + distance}px; } }`,
|
||||
sheet.cssRules.length
|
||||
);
|
||||
wave.style.animation = `${animationName} ${duration}s linear infinite`;
|
||||
wave.style.animationDelay = `${delay}s`;
|
||||
wave.style.backgroundPositionX = `${startPosition}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function startFloodRetreat(state) {
|
||||
const { container } = state;
|
||||
if (!container) {
|
||||
cleanupFloodState(state);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
state.retreatPromise = new Promise((resolve) => {
|
||||
const measuredHeight = container.offsetHeight || container.clientHeight;
|
||||
const computedHeight = window.getComputedStyle(container).height;
|
||||
const currentHeight = measuredHeight
|
||||
? `${measuredHeight}px`
|
||||
: (computedHeight && computedHeight !== 'auto' ? computedHeight : '0px');
|
||||
container.style.animation = 'none';
|
||||
container.style.transition = 'none';
|
||||
container.style.height = currentHeight;
|
||||
void container.offsetHeight;
|
||||
|
||||
const retreatDuration = 8;
|
||||
container.style.transition = `height ${retreatDuration}s ease-in-out`;
|
||||
requestAnimationFrame(() => {
|
||||
container.style.height = '0px';
|
||||
});
|
||||
|
||||
state.retreatTimeout = window.setTimeout(() => {
|
||||
container.style.transition = 'none';
|
||||
cleanupFloodState(state);
|
||||
resolve();
|
||||
}, retreatDuration * 1000);
|
||||
});
|
||||
|
||||
return state.retreatPromise;
|
||||
}
|
||||
|
||||
function cleanupFloodState(state) {
|
||||
if (state.retreatTimeout) {
|
||||
clearTimeout(state.retreatTimeout);
|
||||
state.retreatTimeout = null;
|
||||
}
|
||||
clearFloodAnimations(state);
|
||||
teardownFloodStyle(state);
|
||||
if (state.wrapper && state.wrapper.parentNode) {
|
||||
state.wrapper.parentNode.removeChild(state.wrapper);
|
||||
}
|
||||
state.wrapper = null;
|
||||
state.container = null;
|
||||
state.waves = [];
|
||||
state.retreatPromise = null;
|
||||
}
|
||||
|
||||
function clearFloodAnimations(state) {
|
||||
const { container, waves } = state;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.style.animation = 'none';
|
||||
container.style.transition = 'none';
|
||||
container.style.height = '0%';
|
||||
waves.forEach((wave) => {
|
||||
wave.style.animation = 'none';
|
||||
wave.style.backgroundImage = '';
|
||||
wave.style.backgroundSize = '';
|
||||
wave.style.backgroundPositionX = '0px';
|
||||
});
|
||||
}
|
||||
|
||||
function teardownFloodStyle(state) {
|
||||
if (state.styleNode && state.styleNode.parentNode) {
|
||||
state.styleNode.parentNode.removeChild(state.styleNode);
|
||||
}
|
||||
state.styleNode = null;
|
||||
}
|
||||
|
||||
function buildFloodWaveShape(layerIndex) {
|
||||
const baseHeight = 180 + layerIndex * 12;
|
||||
const cycles = 4;
|
||||
let path = `M0,${baseHeight}`;
|
||||
let currentX = 0;
|
||||
let previousAmplitude = randRange(40, 80);
|
||||
|
||||
for (let i = 0; i < cycles; i++) {
|
||||
const waveLength = randRange(700, 900);
|
||||
const minAmp = Math.max(20, previousAmplitude - 20);
|
||||
const maxAmp = Math.min(90, previousAmplitude + 20);
|
||||
const amplitude = randRange(minAmp, maxAmp);
|
||||
previousAmplitude = amplitude;
|
||||
const halfWave = waveLength / 2;
|
||||
|
||||
const peakX = currentX + halfWave / 2;
|
||||
path += ` Q${peakX},${baseHeight - amplitude} ${currentX + halfWave},${baseHeight}`;
|
||||
|
||||
const troughX = currentX + halfWave + halfWave / 2;
|
||||
path += ` Q${troughX},${baseHeight + amplitude} ${currentX + waveLength},${baseHeight}`;
|
||||
|
||||
currentX += waveLength;
|
||||
}
|
||||
|
||||
path += ` L${currentX},1000 L0,1000 Z`;
|
||||
return {
|
||||
path,
|
||||
width: currentX,
|
||||
waveWidth: currentX / cycles
|
||||
};
|
||||
}
|
||||
|
||||
function randRange(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return Math.floor(randRange(min, max + 1));
|
||||
}
|
||||
|
||||
function pickOne(arr) {
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function uniqueKey(prefix) {
|
||||
const random = Math.random().toString(36).slice(2, 7);
|
||||
return `${prefix}_${Date.now()}_${random}`;
|
||||
}
|
||||
})(window);
|
||||
67
static/easter-eggs/registry.js
Normal file
67
static/easter-eggs/registry.js
Normal file
@ -0,0 +1,67 @@
|
||||
(function initEasterEggRegistry(global) {
|
||||
/**
|
||||
* 一个极简的彩蛋注册表,用于在主应用与各特效实现之间解耦。
|
||||
* 每个特效需要调用 register(effectName, factory),factory 必须返回带有 destroy 方法的实例。
|
||||
*/
|
||||
const registry = {
|
||||
effects: Object.create(null),
|
||||
|
||||
/**
|
||||
* 注册彩蛋。
|
||||
* @param {string} effectName
|
||||
* @param {(context: object) => { destroy: Function }} factory
|
||||
*/
|
||||
register(effectName, factory) {
|
||||
const key = (effectName || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
console.warn('[EasterEggRegistry] 忽略空 effectName');
|
||||
return;
|
||||
}
|
||||
if (typeof factory !== 'function') {
|
||||
console.warn('[EasterEggRegistry] 注册失败,factory 不是函数:', effectName);
|
||||
return;
|
||||
}
|
||||
this.effects[key] = factory;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建彩蛋实例。
|
||||
* @param {string} effectName
|
||||
* @param {object} context
|
||||
* @returns {object|null}
|
||||
*/
|
||||
start(effectName, context = {}) {
|
||||
const key = (effectName || '').trim().toLowerCase();
|
||||
const factory = this.effects[key];
|
||||
if (!factory) {
|
||||
console.warn('[EasterEggRegistry] 未找到特效:', effectName);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const instance = factory({
|
||||
...context,
|
||||
effect: key,
|
||||
});
|
||||
if (!instance || typeof instance.destroy !== 'function') {
|
||||
console.warn('[EasterEggRegistry] 特效未返回 destroy 方法:', effectName);
|
||||
return null;
|
||||
}
|
||||
return instance;
|
||||
} catch (error) {
|
||||
console.error('[EasterEggRegistry] 特效初始化失败:', effectName, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
has(effectName) {
|
||||
const key = (effectName || '').trim().toLowerCase();
|
||||
return Boolean(this.effects[key]);
|
||||
},
|
||||
|
||||
list() {
|
||||
return Object.keys(this.effects);
|
||||
},
|
||||
};
|
||||
|
||||
global.EasterEggRegistry = registry;
|
||||
})(window);
|
||||
13
static/easter-eggs/snake.css
Normal file
13
static/easter-eggs/snake.css
Normal file
@ -0,0 +1,13 @@
|
||||
.easter-egg-overlay .snake-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.easter-egg-overlay canvas.snake-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
503
static/easter-eggs/snake.js
Normal file
503
static/easter-eggs/snake.js
Normal file
@ -0,0 +1,503 @@
|
||||
(function registerSnakeEffect(global) {
|
||||
const registry = global.EasterEggRegistry;
|
||||
if (!registry) {
|
||||
console.error('[easter-eggs:snake] 未找到 EasterEggRegistry,无法注册特效');
|
||||
return;
|
||||
}
|
||||
|
||||
registry.register('snake', function createSnakeEffect(context = {}) {
|
||||
const root = context.root;
|
||||
if (!root) {
|
||||
throw new Error('缺少彩蛋根节点');
|
||||
}
|
||||
const payload = context.payload || {};
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'snake-overlay';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'snake-canvas';
|
||||
container.appendChild(canvas);
|
||||
root.appendChild(container);
|
||||
|
||||
const effect = new SnakeEffect(container, canvas, payload, context.app);
|
||||
|
||||
return {
|
||||
destroy(options = {}) {
|
||||
return effect.stop(options);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
class SnakeEffect {
|
||||
constructor(container, canvas, payload, app) {
|
||||
this.container = container;
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.payload = payload || {};
|
||||
this.app = app || null;
|
||||
this.targetApples = Math.max(1, Math.floor(Number(this.payload.apples_target) || 10));
|
||||
this.initialApples = Math.max(1, Math.floor(Number(this.payload.initial_apples) || 3));
|
||||
this.apples = [];
|
||||
this.applesEaten = 0;
|
||||
this.appleFadeInDuration = 800;
|
||||
this.appleFadeOutDuration = 1500;
|
||||
this.finalRun = false;
|
||||
this.running = true;
|
||||
this.finished = false;
|
||||
this.cleanupRequested = false;
|
||||
this.cleanupResolve = null;
|
||||
this.cleanupPromise = new Promise((resolve) => {
|
||||
this.cleanupResolve = resolve;
|
||||
});
|
||||
this.appNotified = false;
|
||||
this.pendingResizeId = null;
|
||||
|
||||
this.resize = this.resize.bind(this);
|
||||
window.addEventListener('resize', this.resize);
|
||||
this.resize();
|
||||
this.scheduleResize();
|
||||
|
||||
this.snake = new Snake(this);
|
||||
|
||||
const spawnTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
for (let i = 0; i < this.initialApples; i++) {
|
||||
this.apples.push(this.createApple(spawnTime));
|
||||
}
|
||||
|
||||
this.animate = this.animate.bind(this);
|
||||
this.lastTime = 0;
|
||||
this.frameInterval = 1000 / 60;
|
||||
this.rafId = requestAnimationFrame(this.animate);
|
||||
}
|
||||
|
||||
scheduleResize() {
|
||||
if (this.pendingResizeId) {
|
||||
cancelAnimationFrame(this.pendingResizeId);
|
||||
}
|
||||
this.pendingResizeId = requestAnimationFrame(() => {
|
||||
this.pendingResizeId = null;
|
||||
this.resize();
|
||||
});
|
||||
}
|
||||
|
||||
resize() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
let width = Math.floor(rect.width);
|
||||
let height = Math.floor(rect.height);
|
||||
|
||||
if (width < 2 || height < 2) {
|
||||
width = window.innerWidth || document.documentElement.clientWidth || 1;
|
||||
height = window.innerHeight || document.documentElement.clientHeight || 1;
|
||||
}
|
||||
|
||||
width = Math.max(1, width);
|
||||
height = Math.max(1, height);
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
if (this.snake && typeof this.snake.handleResize === 'function') {
|
||||
this.snake.handleResize(width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createApple(timestamp = null) {
|
||||
const margin = 60;
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
const now = timestamp != null
|
||||
? timestamp
|
||||
: (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
return {
|
||||
x: margin + Math.random() * Math.max(1, width - margin * 2),
|
||||
y: margin + Math.random() * Math.max(1, height - margin * 2),
|
||||
opacity: 0,
|
||||
fadeInStart: now,
|
||||
fadeOutStart: null
|
||||
};
|
||||
}
|
||||
|
||||
handleAppleConsumed(index) {
|
||||
this.applesEaten += 1;
|
||||
const reachedGoal = this.applesEaten >= this.targetApples;
|
||||
if (reachedGoal) {
|
||||
this.apples.splice(index, 1);
|
||||
this.startFinalRun();
|
||||
} else if (this.apples[index]) {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
this.apples[index] = this.createApple(now);
|
||||
}
|
||||
}
|
||||
|
||||
startFinalRun(force = false) {
|
||||
if (this.finalRun) {
|
||||
return;
|
||||
}
|
||||
this.finalRun = true;
|
||||
this.appleFadeOutStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
this.apples.forEach((apple) => {
|
||||
apple.fadeOutStart = this.appleFadeOutStart;
|
||||
});
|
||||
this.snake.beginFinalRun();
|
||||
if (force) {
|
||||
this.applesEaten = Math.max(this.applesEaten, this.targetApples);
|
||||
}
|
||||
}
|
||||
|
||||
drawApples(timestamp) {
|
||||
const ctx = this.ctx;
|
||||
const now = timestamp != null
|
||||
? timestamp
|
||||
: (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
for (let i = this.apples.length - 1; i >= 0; i--) {
|
||||
const apple = this.apples[i];
|
||||
if (apple.fadeOutStart) {
|
||||
const progress = Math.min(1, (now - apple.fadeOutStart) / this.appleFadeOutDuration);
|
||||
apple.opacity = 1 - progress;
|
||||
if (apple.opacity <= 0.02) {
|
||||
this.apples.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
} else if (apple.fadeInStart) {
|
||||
const progress = Math.min(1, (now - apple.fadeInStart) / this.appleFadeInDuration);
|
||||
apple.opacity = progress;
|
||||
if (apple.opacity >= 0.995) {
|
||||
apple.fadeInStart = null;
|
||||
apple.opacity = 1;
|
||||
}
|
||||
} else {
|
||||
apple.opacity = 1;
|
||||
}
|
||||
|
||||
ctx.fillStyle = `hsla(${this.snake.hue}, 70%, 65%, ${apple.opacity})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(apple.x, apple.y, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
animate(timestamp) {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
this.rafId = requestAnimationFrame(this.animate);
|
||||
const elapsed = timestamp - this.lastTime;
|
||||
if (elapsed < this.frameInterval) {
|
||||
return;
|
||||
}
|
||||
this.lastTime = timestamp - (elapsed % this.frameInterval);
|
||||
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
this.snake.findNearestApple();
|
||||
this.snake.update();
|
||||
this.drawApples(timestamp);
|
||||
this.snake.draw();
|
||||
|
||||
if (this.finalRun && this.apples.length === 0 && this.snake.isCompletelyOffscreen(80)) {
|
||||
this.finish(false, !this.cleanupRequested);
|
||||
}
|
||||
}
|
||||
|
||||
stop(options = {}) {
|
||||
const immediate = Boolean(options && options.immediate);
|
||||
if (immediate) {
|
||||
this.cleanupRequested = true;
|
||||
this.finish(true, false);
|
||||
return null;
|
||||
}
|
||||
if (this.finished) {
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
this.cleanupRequested = true;
|
||||
if (!this.finalRun) {
|
||||
this.startFinalRun(true);
|
||||
}
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
finish(immediate = false, notifyCompletion = false) {
|
||||
if (this.finished) {
|
||||
return;
|
||||
}
|
||||
this.finished = true;
|
||||
this.running = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
if (this.pendingResizeId) {
|
||||
cancelAnimationFrame(this.pendingResizeId);
|
||||
this.pendingResizeId = null;
|
||||
}
|
||||
window.removeEventListener('resize', this.resize);
|
||||
if (this.container && this.container.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
if (this.cleanupResolve) {
|
||||
this.cleanupResolve();
|
||||
this.cleanupResolve = null;
|
||||
}
|
||||
if (immediate) {
|
||||
this.apples.length = 0;
|
||||
}
|
||||
if (notifyCompletion && !this.cleanupRequested) {
|
||||
this.notifyAppForCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
notifyAppForCleanup() {
|
||||
if (this.appNotified) {
|
||||
return;
|
||||
}
|
||||
this.appNotified = true;
|
||||
if (this.app && typeof this.app.destroyEasterEggEffect === 'function') {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.app.destroyEasterEggEffect(true);
|
||||
} catch (error) {
|
||||
console.warn('自动清理贪吃蛇彩蛋失败:', error);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Snake {
|
||||
constructor(effect) {
|
||||
this.effect = effect;
|
||||
this.canvas = effect.canvas;
|
||||
this.ctx = effect.ctx;
|
||||
this.radius = 14;
|
||||
this.targetLength = 28;
|
||||
this.currentLength = 28;
|
||||
this.speed = 2;
|
||||
this.angle = 0;
|
||||
this.targetAngle = 0;
|
||||
this.hue = 30;
|
||||
this.currentTarget = null;
|
||||
this.targetStartTime = Date.now();
|
||||
this.targetTimeout = 10000;
|
||||
this.timedOut = false;
|
||||
this.finalRunAngle = null;
|
||||
this.initializeEntryPosition();
|
||||
}
|
||||
|
||||
initializeEntryPosition() {
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
const margin = 120;
|
||||
const side = Math.floor(Math.random() * 4);
|
||||
let startX;
|
||||
let startY;
|
||||
switch (side) {
|
||||
case 0:
|
||||
startX = -margin;
|
||||
startY = Math.random() * height;
|
||||
break;
|
||||
case 1:
|
||||
startX = width + margin;
|
||||
startY = Math.random() * height;
|
||||
break;
|
||||
case 2:
|
||||
startX = Math.random() * width;
|
||||
startY = -margin;
|
||||
break;
|
||||
default:
|
||||
startX = Math.random() * width;
|
||||
startY = height + margin;
|
||||
}
|
||||
const targetX = width / 2;
|
||||
const targetY = height / 2;
|
||||
const entryAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||
this.angle = entryAngle;
|
||||
this.targetAngle = entryAngle;
|
||||
const tailX = startX - Math.cos(entryAngle) * this.targetLength;
|
||||
const tailY = startY - Math.sin(entryAngle) * this.targetLength;
|
||||
this.path = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: tailX, y: tailY }
|
||||
];
|
||||
}
|
||||
|
||||
handleResize(width, height) {
|
||||
this.path.forEach((point) => {
|
||||
point.x = Math.max(-40, Math.min(width + 40, point.x));
|
||||
point.y = Math.max(-40, Math.min(height + 40, point.y));
|
||||
});
|
||||
}
|
||||
|
||||
findNearestApple() {
|
||||
if (this.effect.finalRun) {
|
||||
return;
|
||||
}
|
||||
const apples = this.effect.apples;
|
||||
if (!apples.length) {
|
||||
this.currentTarget = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (this.currentTarget && (now - this.targetStartTime) < this.targetTimeout) {
|
||||
const targetStillExists = apples.includes(this.currentTarget);
|
||||
if (targetStillExists) {
|
||||
const dx = this.currentTarget.x - this.path[0].x;
|
||||
const dy = this.currentTarget.y - this.path[0].y;
|
||||
this.targetAngle = Math.atan2(dy, dx);
|
||||
this.timedOut = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let targetApple = null;
|
||||
let targetDistance = this.timedOut ? -Infinity : Infinity;
|
||||
|
||||
apples.forEach((apple) => {
|
||||
const dx = apple.x - this.path[0].x;
|
||||
const dy = apple.y - this.path[0].y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (this.timedOut) {
|
||||
if (distance > targetDistance) {
|
||||
targetDistance = distance;
|
||||
targetApple = apple;
|
||||
}
|
||||
} else if (distance < targetDistance) {
|
||||
targetDistance = distance;
|
||||
targetApple = apple;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetApple) {
|
||||
if (this.currentTarget !== targetApple) {
|
||||
this.currentTarget = targetApple;
|
||||
this.targetStartTime = now;
|
||||
this.timedOut = false;
|
||||
} else if ((now - this.targetStartTime) >= this.targetTimeout) {
|
||||
this.timedOut = true;
|
||||
}
|
||||
|
||||
const dx = targetApple.x - this.path[0].x;
|
||||
const dy = targetApple.y - this.path[0].y;
|
||||
this.targetAngle = Math.atan2(dy, dx);
|
||||
}
|
||||
}
|
||||
|
||||
beginFinalRun() {
|
||||
if (this.finalRunAngle == null) {
|
||||
this.finalRunAngle = this.angle;
|
||||
}
|
||||
this.targetAngle = this.finalRunAngle;
|
||||
this.currentTarget = null;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.effect.finalRun) {
|
||||
let angleDiff = this.targetAngle - this.angle;
|
||||
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
|
||||
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
|
||||
const maxTurnRate = 0.03;
|
||||
this.angle += angleDiff * maxTurnRate;
|
||||
} else if (this.finalRunAngle != null) {
|
||||
this.angle = this.finalRunAngle;
|
||||
}
|
||||
|
||||
const head = { ...this.path[0] };
|
||||
head.x += Math.cos(this.angle) * this.speed;
|
||||
head.y += Math.sin(this.angle) * this.speed;
|
||||
if (!this.effect.finalRun) {
|
||||
const margin = 0;
|
||||
if (head.x < -margin) head.x = -margin;
|
||||
if (head.x > this.canvas.width + margin) head.x = this.canvas.width + margin;
|
||||
if (head.y < -margin) head.y = -margin;
|
||||
if (head.y > this.canvas.height + margin) head.y = this.canvas.height + margin;
|
||||
}
|
||||
|
||||
this.path.unshift(head);
|
||||
|
||||
if (!this.effect.finalRun) {
|
||||
this.checkApples(head);
|
||||
}
|
||||
|
||||
this.trimPath();
|
||||
}
|
||||
|
||||
checkApples(head) {
|
||||
const apples = this.effect.apples;
|
||||
for (let i = 0; i < apples.length; i++) {
|
||||
const apple = apples[i];
|
||||
const dx = head.x - apple.x;
|
||||
const dy = head.y - apple.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < this.radius + 14) {
|
||||
this.targetLength += 28 * 3;
|
||||
this.currentTarget = null;
|
||||
this.effect.handleAppleConsumed(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimPath() {
|
||||
let pathLength = 0;
|
||||
for (let i = 0; i < this.path.length - 1; i++) {
|
||||
const dx = this.path[i].x - this.path[i + 1].x;
|
||||
const dy = this.path[i].y - this.path[i + 1].y;
|
||||
pathLength += Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
while (this.path.length > 2 && pathLength > this.targetLength) {
|
||||
const last = this.path[this.path.length - 1];
|
||||
const secondLast = this.path[this.path.length - 2];
|
||||
const dx = secondLast.x - last.x;
|
||||
const dy = secondLast.y - last.y;
|
||||
const segmentLength = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (pathLength - segmentLength >= this.targetLength) {
|
||||
this.path.pop();
|
||||
pathLength -= segmentLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentLength = pathLength;
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (this.path.length < 2) {
|
||||
return;
|
||||
}
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = `hsl(${this.hue}, 70%, 65%)`;
|
||||
ctx.lineWidth = this.radius * 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = `hsl(${this.hue}, 80%, 55%)`;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.path[0].x, this.path[0].y);
|
||||
for (let i = 1; i < this.path.length; i++) {
|
||||
ctx.lineTo(this.path[i].x, this.path[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
isCompletelyOffscreen(margin = 40) {
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
return this.path.every((point) => {
|
||||
return (
|
||||
point.x < -margin ||
|
||||
point.x > width + margin ||
|
||||
point.y < -margin ||
|
||||
point.y > height + margin
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
})(window);
|
||||
13
static/icons/zap.svg
Normal file
13
static/icons/zap.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@ -27,6 +27,8 @@
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/easter-eggs/flood.css">
|
||||
<link rel="stylesheet" href="/static/easter-eggs/snake.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@ -168,21 +170,30 @@
|
||||
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
|
||||
<div class="sidebar-status">
|
||||
<div class="compact-status-card">
|
||||
<div class="status-top">
|
||||
<span class="logo icon-label">
|
||||
<span class="icon icon-md"
|
||||
<div class="status-line">
|
||||
<div class="status-brand">
|
||||
<span class="icon icon-lg status-logo"
|
||||
:style="iconStyle('bot')"
|
||||
aria-hidden="true"></span>
|
||||
<span>AI Agent</span>
|
||||
</span>
|
||||
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
|
||||
</div>
|
||||
<div class="status-bottom">
|
||||
<span class="thinking-chip">{{ thinkingMode ? '思考模式' : '快速模式' }}</span>
|
||||
<span class="connection-chip" :class="{ connected: isConnected }">
|
||||
<span class="status-dot" :class="{ active: isConnected }"></span>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
</span>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">AI Agent</span>
|
||||
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-indicators">
|
||||
<span class="mode-indicator"
|
||||
:class="{ thinking: thinkingMode, fast: !thinkingMode }">
|
||||
<transition name="mode-icon" mode="out-in">
|
||||
<span class="icon icon-sm"
|
||||
:style="iconStyle(thinkingMode ? 'brain' : 'zap')"
|
||||
:key="thinkingMode ? 'brain' : 'zap'"
|
||||
aria-hidden="true"></span>
|
||||
</transition>
|
||||
</span>
|
||||
<span class="connection-dot"
|
||||
:class="{ active: isConnected }"
|
||||
:title="isConnected ? '已连接' : '未连接'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -735,25 +746,25 @@
|
||||
<div class="submenu-list">
|
||||
<button type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="openRealtimeTerminal"
|
||||
@click="handleRealtimeTerminalClick"
|
||||
:disabled="!isConnected">
|
||||
实时终端
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="toggleFocusPanel"
|
||||
@click="handleFocusPanelToggleClick"
|
||||
:disabled="!isConnected">
|
||||
聚焦面板
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="toggleTokenPanel"
|
||||
@click="handleTokenPanelToggleClick"
|
||||
:disabled="!currentConversationId">
|
||||
用量统计
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="compressConversation"
|
||||
@click="handleCompressConversationClick"
|
||||
:disabled="compressing || streamingMessage || !isConnected">
|
||||
{{ compressing ? '压缩中...' : '压缩对话' }}
|
||||
</button>
|
||||
@ -801,19 +812,168 @@
|
||||
<transition name="personal-page-fade">
|
||||
<div class="personal-page-overlay"
|
||||
v-if="personalPageVisible"
|
||||
@click.self="closePersonalPage">
|
||||
@mousedown.self="handleOverlayPressStart"
|
||||
@mouseup.self="handleOverlayPressEnd"
|
||||
@mouseleave.self="handleOverlayPressCancel"
|
||||
@touchstart.self.prevent="handleOverlayPressStart"
|
||||
@touchend.self.prevent="handleOverlayPressEnd"
|
||||
@touchcancel.self="handleOverlayPressCancel">
|
||||
<div class="personal-page-card">
|
||||
<h2>个人空间</h2>
|
||||
<p>敬请期待,个人页面正在建设中。</p>
|
||||
<button type="button"
|
||||
class="personal-page-close"
|
||||
@click="closePersonalPage">
|
||||
返回工作区
|
||||
</button>
|
||||
<div class="personal-page-header">
|
||||
<div>
|
||||
<h2>个人空间</h2>
|
||||
<p>配置 AI 智能体的个性化偏好</p>
|
||||
</div>
|
||||
<div class="personal-page-actions">
|
||||
<button type="button"
|
||||
class="personal-page-logout"
|
||||
@click="handleLogout">
|
||||
退出登录
|
||||
</button>
|
||||
<button type="button"
|
||||
class="personal-page-close"
|
||||
@click="closePersonalPage">
|
||||
返回工作区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personalization-body" v-if="!personalizationLoading">
|
||||
<label class="personal-toggle">
|
||||
<span class="toggle-text">
|
||||
<span class="toggle-title">启用个性化提示</span>
|
||||
<span class="toggle-desc">开启后才会注入您的偏好</span>
|
||||
</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox"
|
||||
v-model="personalForm.enabled"
|
||||
:disabled="personalizationToggleUpdating"
|
||||
@change="handlePersonalizationToggle">
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<form class="personal-form" @submit.prevent="savePersonalization">
|
||||
<div class="personalization-sections">
|
||||
<div class="personal-section personal-info">
|
||||
<label class="personal-field">
|
||||
<span>您希望AI智能体怎么自称?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.self_identify"
|
||||
maxlength="20"
|
||||
placeholder="如:小秘、助理小A"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<label class="personal-field">
|
||||
<span>您希望AI智能体怎么称呼您?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.user_name"
|
||||
maxlength="20"
|
||||
placeholder="如:Jojo、老师"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<label class="personal-field">
|
||||
<span>您的职业是?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.profession"
|
||||
maxlength="20"
|
||||
placeholder="如:产品经理、设计师"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<div class="personal-field">
|
||||
<label>
|
||||
<span>您希望AI智能体用何种语气与您交流?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.tone"
|
||||
maxlength="20"
|
||||
placeholder="请选择或输入语气"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<div class="tone-preset-row">
|
||||
<span>快速填入:</span>
|
||||
<div class="tone-preset-buttons">
|
||||
<button type="button"
|
||||
v-for="preset in tonePresets"
|
||||
:key="preset"
|
||||
@click.prevent="applyTonePreset(preset)">
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personal-right-column">
|
||||
<div class="personal-section personal-considerations">
|
||||
<div class="personal-field">
|
||||
<span>您希望AI智能体在回答问题时必须考虑的信息是?</span>
|
||||
<div class="consideration-input">
|
||||
<input type="text"
|
||||
v-model.trim="newConsideration"
|
||||
maxlength="50"
|
||||
placeholder="输入后点击 + 号添加"
|
||||
@input="clearPersonalizationFeedback">
|
||||
<button type="button"
|
||||
class="consideration-add"
|
||||
:disabled="!newConsideration || personalForm.considerations.length >= personalizationMaxConsiderations"
|
||||
@click="addConsideration">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul class="consideration-list" v-if="personalForm.considerations.length">
|
||||
<li v-for="(item, idx) in personalForm.considerations"
|
||||
:key="`consideration-${idx}`"
|
||||
class="consideration-item"
|
||||
draggable="true"
|
||||
@dragstart="handleConsiderationDragStart(idx, $event)"
|
||||
@dragover.prevent="handleConsiderationDragOver(idx, $event)"
|
||||
@drop.prevent="handleConsiderationDrop(idx, $event)"
|
||||
@dragend="handleConsiderationDragEnd">
|
||||
<span class="drag-handle" aria-hidden="true">≡</span>
|
||||
<span class="consideration-text">{{ item }}</span>
|
||||
<button type="button"
|
||||
class="consideration-remove"
|
||||
@click="removeConsideration(idx)">
|
||||
−
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="consideration-hint" v-else>尚未添加任何必备信息</p>
|
||||
<p class="consideration-limit">最多 {{ personalizationMaxConsiderations }} 条,可拖动排序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personal-form-actions">
|
||||
<div class="personal-status-group">
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status success"
|
||||
v-if="personalizationStatus">{{ personalizationStatus }}</span>
|
||||
</transition>
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status error"
|
||||
v-if="personalizationError">{{ personalizationError }}</span>
|
||||
</transition>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="primary"
|
||||
:disabled="personalizationSaving"
|
||||
@click="savePersonalization">
|
||||
{{ personalizationSaving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="personalization-loading" v-else>
|
||||
正在加载个性化配置...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<div class="easter-egg-overlay"
|
||||
v-show="easterEgg.active"
|
||||
:class="{ active: easterEgg.active }"
|
||||
aria-hidden="true"
|
||||
ref="easterEggRoot">
|
||||
</div>
|
||||
<div class="context-menu"
|
||||
v-if="contextMenu.visible"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
@ -871,6 +1031,10 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- 加载彩蛋模块 -->
|
||||
<script src="/static/easter-eggs/registry.js"></script>
|
||||
<script src="/static/easter-eggs/flood.js"></script>
|
||||
<script src="/static/easter-eggs/snake.js"></script>
|
||||
<!-- 加载应用脚本 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
@ -124,6 +124,10 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (resp.status === 503) {
|
||||
window.location.href = '/resource_busy';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/';
|
||||
|
||||
48
static/resource_busy.html
Normal file
48
static/resource_busy.html
Normal file
@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>资源繁忙</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
background: #f7f7f7;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
padding: 48px 56px;
|
||||
max-width: 460px;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px;
|
||||
color: #e63946;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>资源繁忙,请稍后重试</h1>
|
||||
<p>当前在线用户已达上限,为保证服务器稳定,暂时无法创建新的会话。</p>
|
||||
<p class="hint">请稍等片刻刷新页面,或联系管理员提升配额。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
547
static/style.css
547
static/style.css
@ -108,7 +108,7 @@ body {
|
||||
|
||||
.agent-version {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@ -135,19 +135,6 @@ body {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--claude-muted);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--claude-success);
|
||||
box-shadow: 0 0 8px rgba(118, 176, 134, 0.45);
|
||||
}
|
||||
|
||||
/* 主容器 */
|
||||
.main-container {
|
||||
display: flex;
|
||||
@ -650,47 +637,91 @@ o-conversations {
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 12px 30px rgba(61, 57, 41, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-top {
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.status-bottom {
|
||||
.status-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-logo {
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thinking-chip {
|
||||
background: var(--claude-accent);
|
||||
color: #fffef8;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.connection-chip {
|
||||
.mode-indicator {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
justify-content: center;
|
||||
background: var(--claude-accent);
|
||||
color: #fffef8;
|
||||
box-shadow: 0 8px 20px rgba(189, 93, 58, 0.25);
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.connection-chip.connected {
|
||||
border-color: rgba(94, 159, 109, 0.5);
|
||||
color: var(--claude-text);
|
||||
.mode-indicator.fast {
|
||||
background: #ffcc4d;
|
||||
box-shadow: 0 8px 20px rgba(255, 204, 77, 0.35);
|
||||
}
|
||||
|
||||
.mode-indicator .icon {
|
||||
--icon-size: 18px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--claude-muted);
|
||||
box-shadow: 0 0 0 4px rgba(121, 109, 94, 0.18);
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.connection-dot.active {
|
||||
background: var(--claude-success);
|
||||
box-shadow: 0 0 0 6px rgba(118, 176, 134, 0.25);
|
||||
}
|
||||
|
||||
.mode-icon-enter-active,
|
||||
.mode-icon-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-icon-enter-from,
|
||||
.mode-icon-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(8deg);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@ -2527,40 +2558,56 @@ o-files {
|
||||
justify-content: center;
|
||||
z-index: 400;
|
||||
padding: 20px;
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
will-change: opacity, backdrop-filter;
|
||||
}
|
||||
|
||||
.personal-page-card {
|
||||
width: min(90vw, 420px);
|
||||
width: min(95vw, 860px);
|
||||
background: #fffaf4;
|
||||
border-radius: 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
|
||||
padding: 40px 48px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
text-align: left;
|
||||
color: var(--claude-text);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.personal-page-card h2 {
|
||||
font-size: 26px;
|
||||
margin-bottom: 12px;
|
||||
.personal-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.personal-page-card p {
|
||||
font-size: 15px;
|
||||
.personal-page-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.personal-page-header h2 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.personal-page-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--claude-text-secondary);
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-page-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 22px;
|
||||
align-self: flex-start;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||
color: #fffdf8;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 14px 28px rgba(189, 93, 58, 0.2);
|
||||
@ -2572,12 +2619,400 @@ o-files {
|
||||
box-shadow: 0 18px 34px rgba(189, 93, 58, 0.3);
|
||||
}
|
||||
|
||||
.personal-page-logout {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--claude-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.personal-page-logout:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 24px rgba(38, 28, 18, 0.12);
|
||||
}
|
||||
|
||||
.personalization-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 180px);
|
||||
padding-right: 6px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.personalization-body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.personal-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-desc {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #d7d1c5;
|
||||
border-radius: 30px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .switch-slider {
|
||||
background: var(--claude-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .switch-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.personal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.personalization-sections {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.personal-section {
|
||||
flex: 1 1 0;
|
||||
min-width: 240px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.personal-right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1 1 0;
|
||||
max-width: none;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.personal-right-column .personal-section {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .personal-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-list {
|
||||
max-height: 260px;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.personalization-sections {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.personal-right-column {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-field input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-field input:focus {
|
||||
outline: 2px solid rgba(189, 93, 58, 0.35);
|
||||
border-color: rgba(189, 93, 58, 0.6);
|
||||
}
|
||||
|
||||
.tone-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.tone-preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tone-preset-buttons button {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-preset-buttons button:hover {
|
||||
border-color: var(--claude-accent);
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
|
||||
.consideration-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.consideration-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.consideration-add {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--claude-accent);
|
||||
color: #fffdf8;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.consideration-add:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.consideration-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consideration-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(118, 103, 84, 0.5);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
font-size: 16px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.consideration-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
.consideration-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #d64545;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.consideration-hint,
|
||||
.consideration-limit {
|
||||
font-size: 13px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.personal-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 12px 0 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.personal-status-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.personal-form-actions .primary {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.personal-form-actions .primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.personal-form-actions .status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.personal-form-actions .status.success {
|
||||
color: #0f9d58;
|
||||
}
|
||||
|
||||
.personal-form-actions .status.error {
|
||||
color: #d64545;
|
||||
}
|
||||
|
||||
.personal-status-fade-enter-active,
|
||||
.personal-status-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-status-fade-enter-from,
|
||||
.personal-status-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.personalization-loading {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-page-fade-enter-active,
|
||||
.personal-page-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-page-fade-enter-from,
|
||||
.personal-page-fade-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.personal-page-overlay.personal-page-fade-enter-active .personal-page-card,
|
||||
.personal-page-overlay.personal-page-fade-leave-active .personal-page-card {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-page-overlay.personal-page-fade-enter-from .personal-page-card,
|
||||
.personal-page-overlay.personal-page-fade-leave-to .personal-page-card {
|
||||
transform: translateY(18px) scale(0.985);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 彩蛋灌水特效 */
|
||||
.easter-egg-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.easter-egg-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ OUTPUT_FORMATS = {
|
||||
"session": "📺 [会话]",
|
||||
}
|
||||
|
||||
AGENT_VERSION = "v1.1"
|
||||
AGENT_VERSION = "v3.2"
|
||||
|
||||
LOG_LEVEL = "INFO"
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
@ -15,7 +15,11 @@ try:
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -30,7 +34,11 @@ except ImportError:
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
|
||||
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
@ -65,6 +73,10 @@ class MainTerminal:
|
||||
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
|
||||
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
|
||||
self.context_manager.main_terminal = self
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
|
||||
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
|
||||
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
|
||||
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
|
||||
self.file_manager = FileManager(project_path)
|
||||
self.search_engine = SearchEngine()
|
||||
@ -96,8 +108,12 @@ class MainTerminal:
|
||||
self.pending_modify_request = None # {"path": str}
|
||||
|
||||
# 工具启用状态
|
||||
self.tool_category_states = {key: True for key in TOOL_CATEGORIES}
|
||||
self.tool_category_states = {
|
||||
key: category.default_enabled
|
||||
for key, category in TOOL_CATEGORIES.items()
|
||||
}
|
||||
self.disabled_tools = set()
|
||||
self.disabled_notice_tools = set()
|
||||
self._refresh_disabled_tools()
|
||||
|
||||
# 新增:自动开始新对话
|
||||
@ -403,7 +419,7 @@ class MainTerminal:
|
||||
snapshot.append({
|
||||
"id": key,
|
||||
"label": category.label,
|
||||
"enabled": self.tool_category_states.get(key, True),
|
||||
"enabled": self.tool_category_states.get(key, category.default_enabled),
|
||||
"tools": list(category.tools),
|
||||
})
|
||||
return snapshot
|
||||
@ -411,18 +427,23 @@ class MainTerminal:
|
||||
def _refresh_disabled_tools(self) -> None:
|
||||
"""刷新禁用工具列表 / Refresh disabled tool set."""
|
||||
disabled = set()
|
||||
notice = set()
|
||||
for key, enabled in self.tool_category_states.items():
|
||||
if not enabled:
|
||||
disabled.update(TOOL_CATEGORIES[key].tools)
|
||||
category = TOOL_CATEGORIES[key]
|
||||
disabled.update(category.tools)
|
||||
if not getattr(category, "silent_when_disabled", False):
|
||||
notice.update(category.tools)
|
||||
self.disabled_tools = disabled
|
||||
self.disabled_notice_tools = notice
|
||||
|
||||
def _format_disabled_tool_notice(self) -> Optional[str]:
|
||||
"""生成禁用工具提示信息 / Format disabled tool notice."""
|
||||
if not self.disabled_tools:
|
||||
if not self.disabled_notice_tools:
|
||||
return None
|
||||
|
||||
lines = ["=== 工具可用性提醒 ==="]
|
||||
for tool_name in sorted(self.disabled_tools):
|
||||
for tool_name in sorted(self.disabled_notice_tools):
|
||||
lines.append(f"{tool_name}:已被用户禁用")
|
||||
lines.append("=== 提示结束 ===")
|
||||
return "\n".join(lines)
|
||||
@ -880,6 +901,8 @@ class MainTerminal:
|
||||
|
||||
def define_tools(self) -> List[Dict]:
|
||||
"""定义可用工具(添加确认工具)"""
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
@ -1205,7 +1228,7 @@ class MainTerminal:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
||||
"description": f"当现有资料不足时搜索外部信息(当前时间 {current_time})。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1428,6 +1451,23 @@ class MainTerminal:
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_easter_egg",
|
||||
"description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood(灌水)或 snake(贪吃蛇)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"description": "彩蛋标识,目前支持 flood(灌水)与 snake(贪吃蛇)。"
|
||||
}
|
||||
},
|
||||
"required": ["effect"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
if self.disabled_tools:
|
||||
@ -2007,6 +2047,13 @@ class MainTerminal:
|
||||
timeout_seconds=arguments.get("timeout_seconds")
|
||||
)
|
||||
|
||||
elif tool_name == "trigger_easter_egg":
|
||||
result = {
|
||||
"success": False,
|
||||
"error": "子智能体未启用彩蛋特效",
|
||||
"available_effects": []
|
||||
}
|
||||
|
||||
else:
|
||||
result = {"success": False, "error": f"未知工具: {tool_name}"}
|
||||
|
||||
@ -2062,8 +2109,16 @@ class MainTerminal:
|
||||
system_prompt = self.load_prompt("main_system")
|
||||
|
||||
# 格式化系统提示
|
||||
container_path = self.container_mount_path or "/workspace"
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=self.project_path,
|
||||
project_path=container_path,
|
||||
container_path=container_path,
|
||||
container_cpus=container_cpus,
|
||||
container_memory=container_memory,
|
||||
project_storage=project_storage,
|
||||
file_tree=context["project_info"]["file_tree"],
|
||||
memory=context["memory"],
|
||||
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@ -9,9 +9,17 @@ from typing import Dict, List
|
||||
class ToolCategory:
|
||||
"""工具类别的结构化定义。"""
|
||||
|
||||
def __init__(self, label: str, tools: List[str]):
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
tools: List[str],
|
||||
default_enabled: bool = True,
|
||||
silent_when_disabled: bool = False,
|
||||
):
|
||||
self.label = label
|
||||
self.tools = tools
|
||||
self.default_enabled = default_enabled
|
||||
self.silent_when_disabled = silent_when_disabled
|
||||
|
||||
|
||||
TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
@ -56,4 +64,14 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
label="待办事项",
|
||||
tools=["todo_create", "todo_update_task", "todo_finish", "todo_finish_confirm"],
|
||||
),
|
||||
"sub_agent": ToolCategory(
|
||||
label="子智能体",
|
||||
tools=["create_sub_agent", "wait_sub_agent", "close_sub_agent"],
|
||||
),
|
||||
"easter_egg": ToolCategory(
|
||||
label="彩蛋实验",
|
||||
tools=["trigger_easter_egg"],
|
||||
default_enabled=False,
|
||||
silent_when_disabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
@ -243,7 +243,8 @@ tree -L 2
|
||||
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
|
||||
|
||||
## 当前环境信息
|
||||
- 项目路径: {project_path}
|
||||
- 项目路径: 你运行在隔离容器中(挂载目录 {container_path}),宿主机路径已对你隐藏
|
||||
- 资源限制: 容器内核数上限 {container_cpus},内存 {container_memory},项目磁盘配额 {project_storage}
|
||||
- 项目文件结构: {file_tree}
|
||||
- 长期记忆: {memory}
|
||||
- 当前时间: {current_time}
|
||||
@ -256,4 +257,4 @@ tree -L 2
|
||||
4. **用户友好**:用简单的语言解释复杂的操作
|
||||
5. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
|
||||
@ -8,14 +8,30 @@ from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
|
||||
from config import (
|
||||
MAX_CONTEXT_SIZE,
|
||||
DATA_DIR,
|
||||
PROMPTS_DIR,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
|
||||
from config import (
|
||||
MAX_CONTEXT_SIZE,
|
||||
DATA_DIR,
|
||||
PROMPTS_DIR,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
from utils.conversation_manager import ConversationManager
|
||||
|
||||
class ContextManager:
|
||||
@ -24,6 +40,10 @@ class ContextManager:
|
||||
self.initial_project_path = self.project_path
|
||||
self.workspace_root = Path(__file__).resolve().parents[1]
|
||||
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
|
||||
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
|
||||
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
|
||||
self.temp_files = {} # 临时加载的文件内容
|
||||
self.file_annotations = {} # 文件备注
|
||||
self.conversation_history = [] # 当前对话历史(内存中)
|
||||
@ -1092,8 +1112,16 @@ class ContextManager:
|
||||
system_prompt = self.load_prompt("main_system")
|
||||
|
||||
# 格式化系统提示
|
||||
container_path = self.container_mount_path or "/workspace"
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=self.project_path,
|
||||
project_path=container_path,
|
||||
container_path=container_path,
|
||||
container_cpus=container_cpus,
|
||||
container_memory=container_memory,
|
||||
project_storage=project_storage,
|
||||
file_tree=context["project_info"]["file_tree"],
|
||||
memory=context["memory"],
|
||||
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@ -8,20 +8,40 @@ from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
|
||||
from config import (
|
||||
MAX_CONTEXT_SIZE,
|
||||
DATA_DIR,
|
||||
PROMPTS_DIR,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
|
||||
from config import (
|
||||
MAX_CONTEXT_SIZE,
|
||||
DATA_DIR,
|
||||
PROMPTS_DIR,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
)
|
||||
from utils.conversation_manager import ConversationManager
|
||||
|
||||
class ContextManager:
|
||||
def __init__(self, project_path: str, data_dir: Optional[str] = None):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.initial_project_path = self.project_path
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
|
||||
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
|
||||
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
|
||||
self.workspace_root = Path(__file__).resolve().parents[1]
|
||||
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
|
||||
self.temp_files = {} # 临时加载的文件内容
|
||||
@ -1097,8 +1117,16 @@ class ContextManager:
|
||||
system_prompt = self.load_prompt("main_system")
|
||||
|
||||
# 格式化系统提示
|
||||
container_path = self.container_mount_path or "/workspace"
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=self.project_path,
|
||||
project_path=container_path,
|
||||
container_path=container_path,
|
||||
container_cpus=container_cpus,
|
||||
container_memory=container_memory,
|
||||
project_storage=project_storage,
|
||||
file_tree=context["project_info"]["file_tree"],
|
||||
memory=context["memory"],
|
||||
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@ -21,6 +21,7 @@ from datetime import datetime
|
||||
from collections import defaultdict, deque
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.routing import BaseConverter
|
||||
import secrets
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@ -42,14 +43,23 @@ from config import (
|
||||
DEFAULT_PROJECT_PATH,
|
||||
LOGS_DIR,
|
||||
AGENT_VERSION,
|
||||
THINKING_FAST_INTERVAL
|
||||
THINKING_FAST_INTERVAL,
|
||||
MAX_ACTIVE_USER_CONTAINERS
|
||||
)
|
||||
from modules.user_manager import UserManager, UserWorkspace
|
||||
from modules.gui_file_manager import GuiFileManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
save_personalization_config,
|
||||
)
|
||||
|
||||
app = Flask(__name__, static_folder='static')
|
||||
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||
_secret_key = os.environ.get("WEB_SECRET_KEY") or os.environ.get("SECRET_KEY")
|
||||
if not _secret_key:
|
||||
_secret_key = secrets.token_hex(32)
|
||||
print("[security] WEB_SECRET_KEY 未设置,已生成临时密钥(重启后失效)。")
|
||||
app.config['SECRET_KEY'] = _secret_key
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
|
||||
CORS(app)
|
||||
|
||||
@ -67,6 +77,7 @@ user_terminals: Dict[str, WebTerminal] = {}
|
||||
terminal_rooms: Dict[str, set] = {}
|
||||
connection_users: Dict[str, str] = {}
|
||||
stop_flags: Dict[str, Dict[str, Any]] = {}
|
||||
active_users: set = set()
|
||||
|
||||
DEFAULT_PORT = 8091
|
||||
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
|
||||
@ -219,6 +230,10 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
||||
username = (username or get_current_username())
|
||||
if not username:
|
||||
return None, None
|
||||
if username not in active_users:
|
||||
if len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
|
||||
raise RuntimeError("资源繁忙:终端资源已用尽,请稍后重试。")
|
||||
active_users.add(username)
|
||||
workspace = user_manager.ensure_user_workspace(username)
|
||||
terminal = user_terminals.get(username)
|
||||
if not terminal:
|
||||
@ -242,7 +257,10 @@ def with_terminal(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
username = get_current_username()
|
||||
terminal, workspace = get_user_resources(username)
|
||||
try:
|
||||
terminal, workspace = get_user_resources(username)
|
||||
except RuntimeError as exc:
|
||||
return jsonify({"error": str(exc), "code": "resource_busy"}), 503
|
||||
if not terminal or not workspace:
|
||||
return jsonify({"error": "System not initialized"}), 503
|
||||
kwargs.update({
|
||||
@ -258,7 +276,10 @@ def get_terminal_for_sid(sid: str) -> Tuple[Optional[str], Optional[WebTerminal]
|
||||
username = connection_users.get(sid)
|
||||
if not username:
|
||||
return None, None, None
|
||||
terminal, workspace = get_user_resources(username)
|
||||
try:
|
||||
terminal, workspace = get_user_resources(username)
|
||||
except RuntimeError:
|
||||
return username, None, None
|
||||
return username, terminal, workspace
|
||||
|
||||
|
||||
@ -465,6 +486,8 @@ def login():
|
||||
if request.method == 'GET':
|
||||
if is_logged_in():
|
||||
return redirect('/new')
|
||||
if len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
|
||||
return app.send_static_file('resource_busy.html'), 503
|
||||
return app.send_static_file('login.html')
|
||||
|
||||
data = request.get_json() or {}
|
||||
@ -475,11 +498,15 @@ def login():
|
||||
if not record:
|
||||
return jsonify({"success": False, "error": "账号或密码错误"}), 401
|
||||
|
||||
if record.username not in active_users and len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
|
||||
return jsonify({"success": False, "error": "资源繁忙,请稍后重试", "code": "resource_busy"}), 503
|
||||
|
||||
session['logged_in'] = True
|
||||
session['username'] = record.username
|
||||
session['thinking_mode'] = app.config.get('DEFAULT_THINKING_MODE', False)
|
||||
session.permanent = True
|
||||
user_manager.ensure_user_workspace(record.username)
|
||||
active_users.add(record.username)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@ -513,6 +540,8 @@ def logout():
|
||||
session.clear()
|
||||
if username and username in user_terminals:
|
||||
user_terminals.pop(username, None)
|
||||
if username in active_users:
|
||||
active_users.discard(username)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@ -649,6 +678,33 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna
|
||||
"message": "切换思考模式时发生异常"
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/personalization', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取个性化配置"""
|
||||
try:
|
||||
data = load_personalization_config(workspace.data_dir)
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/personalization', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""更新个性化配置"""
|
||||
payload = request.get_json() or {}
|
||||
try:
|
||||
config = save_personalization_config(workspace.data_dir, payload)
|
||||
return jsonify({"success": True, "data": config})
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/files')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@ -3775,3 +3831,6 @@ if __name__ == "__main__":
|
||||
port=args.port,
|
||||
debug=args.debug
|
||||
)
|
||||
@app.route('/resource_busy')
|
||||
def resource_busy_page():
|
||||
return app.send_static_file('resource_busy.html'), 503
|
||||
|
||||
Loading…
Reference in New Issue
Block a user