Compare commits

...

10 Commits

43 changed files with 3617 additions and 288 deletions

49
.env.example Normal file
View 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

View File

@ -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` 定义的 EmojiWeb 模式所有工具事件都会写入 `logs/debug_stream.log`
- **数据隔离**:多用户目录位于 `users/<username>/`;请避免将真实密钥提交到仓库,必要时扩展 `.gitignore`
- **测试**:暂未配置自动化测试,可参考 `test/` 目录(如 `api_interceptor_server.py`)编写自定义脚本。

View File

@ -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

View File

@ -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"
'''

View File

@ -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",

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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"

View File

@ -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"]

View File

@ -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
View 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
View 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` 仅检查 sessionSocket.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 组件化”等体验向任务,确保基础安全能力可复用到后续版本。
> 本文档会随着重构推进持续更新;若需深入某一条风险(如容器隔离方案、密钥管理流程),可另开文档展开细化设计。

View 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}"

View 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

View 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())

View File

@ -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)

View File

@ -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

View 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

View File

@ -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": "终端会话已重置并重新启动"
}

View File

@ -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,

View 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

View File

@ -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,

View File

@ -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. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
如果用户设置了个性化信息,根据用户的个性化需求回答

View File

@ -0,0 +1,2 @@
以下内容为用户提供的个性化设置信息,请务必在整个任务过程中遵循:
{personalization_block}

View File

@ -13,6 +13,7 @@
* 是否需要创建/修改/删除文件、运行终端命令或脚本?
* 是否需要创建/等待/关闭子智能体?
* 是否需要更新主记忆或任务记忆?
* 如果用户开启了个性化模式,要考虑哪些用户要求的必须考虑的点?
2. **正式输出阶段**
- 直接向用户说明你的计划:描述每一步准备做什么、需要哪些工具或文件。

View File

@ -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

View 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
View 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);

View 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);

View 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
View 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
View 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

View File

@ -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>

View File

@ -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
View 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>

View File

@ -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;
}

View File

@ -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"

View File

@ -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")

View File

@ -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,
),
}

View File

@ -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. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。

View File

@ -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")

View File

@ -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")

View File

@ -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