Compare commits

..

5 Commits

Author SHA1 Message Date
4f940e9a86 fix(avatar): 移除并行工具轮播,头像固定显示当前工具,文字过渡与SVG切换同步 2026-08-12 23:41:34 +08:00
56c067b710 feat(memory): 新增项目记忆最大注入条数设置,记忆索引按最近修改排序
- 个人空间-上下文新增「最大记忆注入」(默认 20,最小 5,留空无上限)
- 提示词中项目记忆索引从文件名字典序改为按 mtime 倒序注入
- 超上限时截断并注入说明:共 N 条、仅注入前 x 条、其余用 search_project_memory 检索
- 修复 memory_system 模板条件块只删标记不删占位文字导致的「暂无xx记忆」残留
2026-08-12 23:07:14 +08:00
16900b0a20 fix(chat): 修复子智能体完成通知不实时显示,压缩残留锁死输入与切换
- 通知:inject_runtime_user_message 不再把子智能体任务 id 透传为事件顶层
  task_id(sender setdefault 不覆盖,前端任务归属守卫丢弃事件;socket 通道
 在轮询模式下不渲染,双通道断头导致只能刷新后从历史看到)。后台指令因透传
 的是 command_id 而不受影响
- 通知:修复 _persist_and_echo_preceding_notice 回显写在 except 分支内,
 批量通知前 N-1 条从不实时回显
- 压缩:移除「压缩中切换/新建对话需确认并取消压缩」拦截(多对话独立运行后
 无存在意义)
- 压缩:新增 compressionConversationId,输入/发送/停止锁按对话隔离,/new
 新建页不再被其他对话的压缩状态锁定
- 压缩:metadata 记录 compression_pid,status/compression_status 读取时按
 pid 懒清理进程重启后的残留 in_progress 标记;run_deep_compression 异常时
 自动清理标记
2026-08-12 22:27:31 +08:00
34bb5d61cc fix(personalization): 修复子智能体最大执行轮次保存丢失,多智能体豁免该上限
- sanitize_personalization_payload 白名单注册 sub_agent_max_turns /
  sub_agent_compress_threshold_tokens(此前保存时被静默丢弃,刷新回默认)
- PUT /api/multiagent/settings 恢复默认由 pop() 改为写显式 null,
  绕开 save 的 fallback=existing 复活旧值问题
- task.py:multi_agent_mode 任务一律不设轮次上限(含历史任务恢复),
  该设置仅对传统后台子智能体生效
- 个人中心文案标明适用范围
2026-08-12 19:14:22 +08:00
ff927d6739 refactor(files): 下线独立 /file-manager 页面及其写操作端点
- 删除页面路由:/file-manager、/file-manager/editor、/file-preview/<path>
- 删除写端点:/api/gui/files/{create,delete,rename,copy,move,upload,download/batch}
- 保留主 SPA 共享端点:/api/files、entries、text、download、/api/project/files/search
- 拆除前端入口链:QuickMenu → InputComposer → App.vue → openGuiFileManager
- 删除 static/file_manager/ 目录(git 历史可恢复)
- sanitize_filename_preserve_unicode 迁至 server/utils_common.py,
  清理 5 个拆分文件的死 import,修复 api_v1/chat 上传的引用来源
- 安全收益:create/rename 路径穿越与 preview 存储型 XSS 随页面物理消解
2026-08-12 19:14:01 +08:00
44 changed files with 475 additions and 1959 deletions

View File

@ -1,5 +1,6 @@
import asyncio
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
@ -55,6 +56,7 @@ from modules.easter_egg_manager import EasterEggManager
from modules.personalization_manager import (
load_personalization_config,
build_personalization_prompt,
resolve_project_memory_inject_limit,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
@ -89,11 +91,19 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
def _render_optional_block(text: str, tag: str, keep: bool) -> str:
"""渲染 [tag]...[/tag] 条件块keep=True 去掉标记保留内容keep=False 整块(含内容)删除。"""
pattern = re.compile(r"\[" + re.escape(tag) + r"\](.*?)\[/" + re.escape(tag) + r"\]", re.DOTALL)
if keep:
return pattern.sub(lambda m: m.group(1), text)
return pattern.sub("", text)
class MemoryMixin:
"""MainTerminalContextMixin memory 能力 mixin。"""
def _scan_project_memories(self):
"""扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description"""
"""扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description,按最近修改时间倒序返回"""
try:
memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME
except Exception:
@ -120,9 +130,12 @@ class MemoryMixin:
"file": md_file.name,
"name": name or md_file.stem,
"description": description or "",
"mtime": md_file.stat().st_mtime,
})
except Exception:
pass
# 最近修改的排前面mtime 相同时保持文件名顺序sorted 基数 + 稳定排序)
results.sort(key=lambda item: item.get("mtime", 0), reverse=True)
return results
def _build_memory_system_content(self) -> str:
@ -137,10 +150,23 @@ class MemoryMixin:
except Exception:
global_memory_text = ""
try:
personalization_config = (
getattr(self.context_manager, "custom_personalization_config", None)
or load_personalization_config(self.data_dir)
)
except Exception:
personalization_config = None
inject_limit = resolve_project_memory_inject_limit(personalization_config)
project_memories = self._scan_project_memories()
if project_memories:
total_count = len(project_memories)
truncated = inject_limit is not None and total_count > inject_limit
shown_memories = project_memories[:inject_limit] if truncated else project_memories
if shown_memories:
lines = []
for m in project_memories:
for m in shown_memories:
desc = m.get("description", "")
if desc:
lines.append(f".astrion/memory/{m['file']}{desc}")
@ -151,16 +177,16 @@ class MemoryMixin:
project_memory_list = ""
result = template
if global_memory_text:
result = result.replace("{global_memory}", global_memory_text)
result = result.replace("[global_memory_empty]", "").replace("[/global_memory_empty]", "")
else:
result = result.replace("{global_memory}", "")
result = result.replace("{global_memory}", global_memory_text)
result = _render_optional_block(result, "global_memory_empty", keep=not global_memory_text)
if project_memory_list:
result = result.replace("{project_memory_list}", project_memory_list)
result = result.replace("[project_memory_empty]", "").replace("[/project_memory_empty]", "")
else:
result = result.replace("{project_memory_list}", "")
result = result.replace("{project_memory_list}", project_memory_list)
result = _render_optional_block(result, "project_memory_empty", keep=not project_memories)
if truncated:
result = result.replace("{project_memory_total}", str(total_count))
result = result.replace("{project_memory_limit}", str(inject_limit))
result = result.replace("{project_memory_remaining}", str(total_count - inject_limit))
result = _render_optional_block(result, "project_memory_truncated", keep=truncated)
return result.strip()

View File

@ -2032,16 +2032,13 @@ class MainTerminalToolsExecutionMixin:
task_message = build_master_dispatch_text(arguments.get("task", ""))
summary_text = (arguments.get("summary") or f"{role.name}作业")[:80]
thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast"
# 读取子智能体压缩阈值与最大轮次配置
# 读取子智能体压缩阈值配置(多智能体成员长期存在,不设轮次上限,
# sub_agent_max_turns 仅对传统后台子智能体生效,这里不读取)
_compress_threshold = 150_000
_max_turns = None
try:
from modules.personalization_manager import load_personalization_config
_prefs = load_personalization_config(data_dir) or {}
_compress_threshold = int(_prefs.get("sub_agent_compress_threshold_tokens", 150_000))
_raw_max_turns = _prefs.get("sub_agent_max_turns")
if _raw_max_turns is not None:
_max_turns = int(_raw_max_turns)
except Exception:
pass
# 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用)
@ -2059,7 +2056,7 @@ class MainTerminalToolsExecutionMixin:
system_prompt=system_prompt,
task_message=task_message,
compress_threshold_tokens=_compress_threshold,
max_turns=_max_turns,
max_turns=None, # 多智能体成员不设轮次上限task.py 对 multi_agent_mode 亦强制豁免)
)
# 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。
# run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。

View File

@ -37,6 +37,9 @@ TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN = 1
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX = 30
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT = 10
PROJECT_MEMORY_INJECT_LIMIT_MIN = 5
PROJECT_MEMORY_INJECT_LIMIT_DEFAULT = 20
PROJECT_MEMORY_INJECT_LIMIT_MAX = 100_000 # 防御性上限None 表示无上限
DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80_000
DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15
DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10
@ -73,6 +76,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"auto_generate_title": True,
"recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值
"tool_intent_enabled": True,
"skill_hints_enabled": False, # Skill 提示系统开关(默认关闭)
"skill_strict_terminal_enabled": False, # 强约束terminal 系列工具需先阅读 terminal-guide
@ -119,6 +123,9 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"goal_end_conditions": ["max_turns"], # 结束方式可多选max_turns / max_tokens
"goal_max_turns": GOAL_MAX_TURNS_DEFAULT, # 最多自动续命轮数
"goal_max_tokens": None, # 累计(输入+输出)token 上限None 表示不启用
# 传统模式子智能体设置(个人空间-子智能体管理;与多智能体无关)
"sub_agent_compress_threshold_tokens": 150000, # 子智能体上下文压缩阈值,最小 10000
"sub_agent_max_turns": None, # 子智能体最大执行轮次None-默认 50 / 0-无上限 / 正整数-该值
}
__all__ = [
@ -129,6 +136,9 @@ __all__ = [
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT",
"PROJECT_MEMORY_INJECT_LIMIT_MIN",
"PROJECT_MEMORY_INJECT_LIMIT_DEFAULT",
"resolve_project_memory_inject_limit",
"load_personalization_config",
"save_personalization_config",
"ensure_personalization_config",
@ -630,9 +640,78 @@ def sanitize_personalization_payload(
base.get("goal_max_tokens"), min_value=GOAL_MAX_TOKENS_MIN, max_value=GOAL_MAX_TOKENS_MAX
)
# 传统模式子智能体:上下文压缩阈值(最小 10000与 PUT /api/multiagent/settings 校验一致)
if "sub_agent_compress_threshold_tokens" in data:
base["sub_agent_compress_threshold_tokens"] = (
_sanitize_optional_int(
data.get("sub_agent_compress_threshold_tokens"),
min_value=10000,
max_value=10_000_000,
)
or 150000
)
else:
base["sub_agent_compress_threshold_tokens"] = (
_sanitize_optional_int(
base.get("sub_agent_compress_threshold_tokens"),
min_value=10000,
max_value=10_000_000,
)
or 150000
)
# 传统模式子智能体最大执行轮次None-默认 50 / 0-无上限 / 正整数-该值)
if "sub_agent_max_turns" in data:
base["sub_agent_max_turns"] = _sanitize_sub_agent_max_turns(data.get("sub_agent_max_turns"))
else:
base["sub_agent_max_turns"] = _sanitize_sub_agent_max_turns(base.get("sub_agent_max_turns"))
# 项目记忆索引最大注入条数None-无上限 / >=5-该值,默认 20
if "project_memory_inject_limit" in data:
base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(data.get("project_memory_inject_limit"))
else:
base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(base.get("project_memory_inject_limit"))
return base
def _sanitize_sub_agent_max_turns(value: Any) -> Optional[int]:
"""清洗子智能体最大轮次None/''/非法值/负数 → None未设置下游默认 500 → 无上限;正整数 → 该值。"""
if value is None or value == "" or isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
if parsed < 0:
return None
return min(parsed, 100_000) # 防御性上限;需要更大时用 0 表示无上限
def _sanitize_project_memory_inject_limit(value: Any) -> Optional[int]:
"""清洗项目记忆索引最大注入条数None/''/0/负数 → None无上限非法值 → 默认 20正整数钳到 [5, 100000]。"""
if value is None or value == "":
return None
if isinstance(value, bool):
return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT
try:
parsed = int(value)
except (TypeError, ValueError):
return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT
if parsed <= 0:
return None
return max(PROJECT_MEMORY_INJECT_LIMIT_MIN, min(parsed, PROJECT_MEMORY_INJECT_LIMIT_MAX))
def resolve_project_memory_inject_limit(config: Any) -> Optional[int]:
"""从个性化配置取项目记忆注入上限None-无上限;缺键/非法 → 默认 20。"""
if not isinstance(config, dict):
return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT
if "project_memory_inject_limit" not in config:
return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT
return _sanitize_project_memory_inject_limit(config.get("project_memory_inject_limit"))
def _sanitize_goal_end_conditions(value: Any) -> list:
"""清洗目标模式结束方式列表,保证至少包含 max_turns。"""
cleaned: list = []

View File

@ -79,14 +79,18 @@ class SubAgentTask:
# 上下文压缩配置
# 默认阈值 150k tokens可由外部覆盖如个人空间子智能体管理配置
self.compress_threshold_tokens: int = int(task_record.get("compress_threshold_tokens") or 150_000)
# 最大执行轮次(对应个人空间子智能体设置项 sub_agent_max_turns
# 最大执行轮次(对应个人空间子智能体设置项 sub_agent_max_turns,仅传统模式生效
# 未设置 → 默认 500或负数→ None 表示无上限;正整数 → 该值
_raw_max_turns = task_record.get("max_turns")
if _raw_max_turns is None:
self.max_turns: Optional[int] = 50
# 多智能体模式的子智能体是长期协作成员,一律无上限,不受该设置约束
if multi_agent_mode:
self.max_turns: Optional[int] = None
else:
_max_turns_int = int(_raw_max_turns)
self.max_turns = _max_turns_int if _max_turns_int > 0 else None
_raw_max_turns = task_record.get("max_turns")
if _raw_max_turns is None:
self.max_turns = 50
else:
_max_turns_int = int(_raw_max_turns)
self.max_turns = _max_turns_int if _max_turns_int > 0 else None
self.current_context_tokens: int = 0
self._compress_round: int = 0

View File

@ -6,10 +6,10 @@
- 记忆内容为提示而非事实,执行前应与实际代码验证
### 总体长期记忆
{global_memory}
[global_memory_empty]暂无总体长期记忆[/global_memory_empty]
{global_memory}[global_memory_empty]暂无总体长期记忆[/global_memory_empty]
### 项目记忆(.astrion/memory/
{project_memory_list}
[project_memory_empty]暂无项目记忆[/project_memory_empty]
以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。
[project_memory_truncated]共 {project_memory_total} 条记忆,按最近修改排序,仅注入前 {project_memory_limit} 条索引:
[/project_memory_truncated]{project_memory_list}[project_memory_empty]暂无项目记忆[/project_memory_empty]
[project_memory_truncated]其余 {project_memory_remaining} 条未注入(可用 search_project_memory 按关键词检索)。
[/project_memory_truncated]以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。

View File

@ -12,7 +12,7 @@ from flask import Blueprint, request, jsonify, send_file, session
from .api_auth import api_token_required
from .tasks import task_manager
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
from .files import sanitize_filename_preserve_unicode
from .utils_common import sanitize_filename_preserve_unicode
from .utils_common import debug_log
from config.model_profiles import get_registered_model_profiles
from core.tool_config import TOOL_CATEGORIES

View File

@ -7,7 +7,6 @@ from flask import Blueprint, request, jsonify, session, redirect, send_from_dire
from modules.personalization_manager import load_personalization_config
from modules.host_workspace_manager import resolve_host_workspace
from modules.user_manager import UserWorkspace
from config import (
TERMINAL_SANDBOX_MODE,
DATA_DIR,
@ -25,7 +24,6 @@ from .security import (
is_action_blocked,
clear_failures,
)
from .context import with_terminal, get_gui_manager
from . import state
from .utils_common import debug_log
@ -442,36 +440,6 @@ def terminal_page():
return current_app.send_static_file('terminal.html')
@auth_bp.route('/file-manager')
@login_required
def gui_file_manager_page():
from .auth_helpers import resolve_admin_policy
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return "文件管理器已被管理员禁用", 403
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'index.html')
@auth_bp.route('/file-manager/editor')
@login_required
def gui_file_editor_page():
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'editor.html')
@auth_bp.route('/file-preview/<path:relative_path>')
@login_required
@with_terminal
def gui_file_preview(relative_path: str, terminal, workspace: UserWorkspace, username: str):
manager = get_gui_manager(workspace)
try:
target = manager.prepare_download(relative_path)
if not target.is_file():
return "预览仅支持文件", 400
return send_from_directory(directory=target.parent, path=target.name, mimetype='text/html')
except Exception as exc:
return f"无法预览文件: {exc}", 400
@auth_bp.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/user-questions/pending', methods=['GET'])

View File

@ -42,7 +42,7 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
from server.utils_common import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/memory', methods=['GET'])

View File

@ -51,7 +51,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
import os
import re

View File

@ -22,6 +22,7 @@ from modules.personalization_manager import (
save_personalization_config,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
PROJECT_MEMORY_INJECT_LIMIT_MIN,
)
from modules.skills_manager import (
get_skills_catalog,
@ -43,7 +44,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@ -287,6 +287,10 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
"recent_conversations_prompt_limit_range": {
"min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
"max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
},
"project_memory_inject_limit_range": {
"min": PROJECT_MEMORY_INJECT_LIMIT_MIN,
"max": None,
}
})
except Exception as exc:
@ -373,6 +377,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
"recent_conversations_prompt_limit_range": {
"min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
"max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
},
"project_memory_inject_limit_range": {
"min": PROJECT_MEMORY_INJECT_LIMIT_MIN,
"max": None,
}
})
except ValueError as exc:

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/terminals')

View File

@ -495,6 +495,10 @@ def _persist_and_echo_preceding_notice(
if cm is not None:
cm.add_conversation("user", message, metadata=metadata)
except Exception as exc:
debug_log(f"[CompletionNotice] 前置通知写入历史失败: {exc}")
# socketio 回显(在线客户端即时可见);轮询客户端由后续任务事件流回放,
# 前端按消息内容 dedup两条通道不会双显。
try:
echo_payload = {
"message": message,
"conversation_id": conversation_id,
@ -505,7 +509,7 @@ def _persist_and_echo_preceding_notice(
echo_payload["starts_work"] = False
echo_payload["metadata"] = {**metadata}
sender("user_message", echo_payload)
except Exception as exc:
except Exception:
pass
async def _dispatch_completion_user_notice(

View File

@ -145,7 +145,13 @@ def inject_runtime_user_message(
"runtime_guidance": True,
"runtime_guidance_original": raw,
}
for key in ("task_id", "command_id"):
# 关键:不能把子智能体任务 id 透传为顶层 task_id——本事件由主任务 sender
# 发出、走主任务事件流,若顶层 task_id 是子任务 idsender 的 setdefault
# 不会覆盖前端轮询的「任务归属守卫」task_id !== currentTaskId 即丢弃)
# 会把这条通知扔掉;叠加 socket 通道在轮询模式下不渲染,导致运行期 inline
# 完成通知只能刷新后从历史看到2026-08-12 定位,后台指令不受影响是因为
# 它透传的是 command_id 而非 task_id。子任务 id 仍保留在 metadata 中。
for key in ("command_id",):
value = (extra_metadata or {}).get(key)
if value is not None:
payload[key] = value

View File

@ -90,7 +90,7 @@ from .conversation_stats import (
collect_upload_events,
summarize_upload_events,
)
from .deep_compression import run_deep_compression
from .deep_compression import run_deep_compression, heal_stale_compression_flag
conversation_bp = Blueprint('conversation', __name__)
@ -1781,18 +1781,23 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
def get_conversation_compression_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try:
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {}
target_manager = terminal.context_manager._get_conversation_manager_for_id(normalized_id)
data = target_manager.load_conversation(normalized_id) or {}
meta = data.get("metadata", {}) or {}
# 与 /api/status 同一套懒清理:进程重启后残留的压缩标记按 pid 判活。
compression_in_progress = heal_stale_compression_flag(
target_manager, normalized_id, meta, context_manager=terminal.context_manager
)
return jsonify({
"success": True,
"data": {
"conversation_id": normalized_id,
"compression_in_progress": bool(meta.get("compression_in_progress", False)),
"compression_mode": meta.get("compression_mode"),
"compression_stage": meta.get("compression_stage"),
"compression_in_progress": compression_in_progress,
"compression_mode": meta.get("compression_mode") if compression_in_progress else None,
"compression_stage": meta.get("compression_stage") if compression_in_progress else None,
"compression_error": meta.get("compression_error"),
"compression_count": int(meta.get("compression_count", 0) or 0),
"compression_job_id": meta.get("compression_job_id"),
"compression_job_id": meta.get("compression_job_id") if compression_in_progress else None,
}
})
except Exception as e:

View File

@ -2,7 +2,9 @@ from __future__ import annotations
import asyncio
import json
import os
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -47,6 +49,89 @@ def _emit(sender, event_type: str, payload: Dict[str, Any]):
pass
def _clear_compression_state_on_error(func):
"""压缩过程异常退出时清理持久化的 compression_in_progress 标记。
压缩中途异常或调用侧取消若不清标记对话 metadata 会永久残留
in_progress=True前端据此锁输入栏/拦切换对话只能删对话解决
"""
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception:
try:
web_terminal = kwargs.get("web_terminal")
conversation_id = kwargs.get("conversation_id")
cm = getattr(web_terminal, "context_manager", None)
if cm is not None and conversation_id:
if getattr(cm, "current_conversation_id", None) == conversation_id:
cm.set_compression_state(in_progress=False)
else:
target_manager = (
cm._get_conversation_manager_for_id(conversation_id)
if hasattr(cm, "_get_conversation_manager_for_id")
else cm.conversation_manager
)
target_manager.update_conversation_metadata(conversation_id, {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
})
except Exception:
pass
raise
return wrapper
def heal_stale_compression_flag(
target_manager,
conversation_id: str,
metadata: Dict[str, Any],
context_manager=None,
) -> bool:
"""读取压缩进行态并懒清理残留标记,返回有效的 in_progress。
compression_in_progress 持久化在对话 metadata 进程在压缩中途被杀时标记
残留为 True压缩随进程死亡不可能仍在进行会导致前端误以为仍在压缩
判定依据标记写入时记录了发起进程的 pidcompression_pid
compression_mixin.set_compression_state与当前进程 pid 不一致或缺失
即旧版本写入的标记即判定为残留并清除磁盘 + 内存双清
"""
if not bool((metadata or {}).get("compression_in_progress", False)):
return False
flag_pid = (metadata or {}).get("compression_pid")
if flag_pid is not None and flag_pid == os.getpid():
return True
clear_updates = {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
}
try:
target_manager.update_conversation_metadata(conversation_id, clear_updates)
except Exception:
pass
# 同步内存中的 metadata该对话若正被加载工具循环的
# is_compression_in_progress() 读的是内存副本,不清理会一直误判压缩中。
try:
if (
context_manager is not None
and getattr(context_manager, "current_conversation_id", None) == conversation_id
and isinstance(getattr(context_manager, "conversation_metadata", None), dict)
):
context_manager.conversation_metadata.update(clear_updates)
except Exception:
pass
return False
def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
records = metadata.get("deep_compression_records")
if not isinstance(records, list):
@ -378,6 +463,7 @@ def _mark_history_compacted(history: List[Dict[str, Any]], *, round_index: int,
return marked
@_clear_compression_state_on_error
async def run_deep_compression(
*,
web_terminal,

View File

@ -1,39 +1,23 @@
"""文件与GUI文件管理相关路由。"""
"""文件相关共享路由(主 SPA 使用):项目结构、目录列举、下载、文本读写、@文件搜索。
/file-manager 独立页面的写操作端点create/delete/rename/copy/move/upload/batch
已随该页面下线移除页面路由见 git 历史
"""
from __future__ import annotations
import os
import re
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
from modules.upload_security import UploadSecurityError
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record
from .security import rate_limited
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response
from .context import with_terminal, get_gui_manager
from .utils_common import debug_log
files_bp = Blueprint("files", __name__)
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分"""
import re
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
cleaned = cleaned.replace("\\", "/").split("/")[-1]
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
return cleaned[:255]
@files_bp.route('/api/files')
@api_login_required
@with_terminal
@ -81,153 +65,6 @@ def gui_list_entries(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/create', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_create", 30, 60, scope="user")
def gui_create_entry(terminal, workspace, username):
payload = request.get_json() or {}
parent = payload.get('path') or ""
name = payload.get('name') or ""
entry_type = payload.get('type') or "file"
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.create_entry(parent, name, entry_type)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/delete', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_delete", 30, 60, scope="user")
def gui_delete_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.delete_entries(paths)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/rename', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_rename", 30, 60, scope="user")
def gui_rename_entry(terminal, workspace, username):
payload = request.get_json() or {}
path = payload.get('path')
new_name = payload.get('new_name')
if not path or not new_name:
return jsonify({"success": False, "error": "缺少 path 或 new_name"}), 400
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.rename_entry(path, new_name)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/copy', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_copy", 40, 120, scope="user")
def gui_copy_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.copy_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/move', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_move", 40, 120, scope="user")
def gui_move_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
manager = get_gui_manager(workspace)
try:
result = manager.move_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_upload", 10, 300, scope="user")
def gui_upload_entry(terminal, workspace, username):
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_upload"):
return jsonify({"success": False, "error": "文件上传已被管理员禁用"}), 403
if 'file' not in request.files:
return jsonify({"success": False, "error": "未找到文件"}), 400
file_obj = request.files['file']
if not file_obj or not file_obj.filename:
return jsonify({"success": False, "error": "文件名为空"}), 400
current_dir = request.form.get('path') or ""
raw_name = request.form.get('filename') or file_obj.filename
filename = sanitize_filename_preserve_unicode(raw_name) or secure_filename(raw_name)
if not filename:
return jsonify({"success": False, "error": "非法文件名"}), 400
manager = get_gui_manager(workspace)
try:
target_path = manager.prepare_upload(current_dir, filename)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
try:
relative_path = manager._to_relative(target_path)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
guard = get_upload_guard(workspace)
try:
result = guard.process_upload(
file_obj,
target_path,
username=username,
source="web_gui",
original_name=raw_name,
relative_path=relative_path,
)
except UploadSecurityError as exc:
return build_upload_error_response(exc)
except Exception as exc:
return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500
metadata = result.get("metadata", {})
return jsonify({
"success": True,
"path": relative_path,
"filename": target_path.name,
"scan": metadata.get("scan"),
"sha256": metadata.get("sha256"),
"size": metadata.get("size"),
})
@files_bp.route('/api/gui/files/download', methods=['GET'])
@api_login_required
@with_terminal
@ -254,37 +91,6 @@ def gui_download_entry(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/download/batch', methods=['POST'])
@api_login_required
@with_terminal
def gui_download_batch(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
if not paths:
return jsonify({"success": False, "error": "缺少待下载的路径"}), 400
manager = get_gui_manager(workspace)
try:
memory_file = BytesIO()
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for rel in paths:
target = manager.prepare_download(rel)
arc_base = rel.strip('/') or target.name
if target.is_dir():
for root, _, files in os.walk(target):
for file in files:
full_path = Path(root) / file
relative_sub = full_path.relative_to(target)
arcname = Path(arc_base) / relative_sub
zf.write(full_path, arcname=str(arcname))
else:
zf.write(target, arcname=arc_base)
memory_file.seek(0)
download_name = f"selected_{len(paths)}.zip"
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/text', methods=['GET', 'POST'])
@api_login_required
@with_terminal

View File

@ -250,8 +250,11 @@ def update_multi_agent_settings_api():
if "sub_agent_max_turns" in settings:
max_turns_raw = settings.get("sub_agent_max_turns")
if max_turns_raw is None:
if prefs.pop("sub_agent_max_turns", None) is not None:
# 置空恢复默认 50写入显式 nullsanitize 会保留 None不能 pop——
# save_personalization_config 的 fallback=existing 会把已存在的旧值带回来
if prefs.get("sub_agent_max_turns") is not None:
dirty = True
prefs["sub_agent_max_turns"] = None
else:
try:
max_turns = int(max_turns_raw)

View File

@ -128,13 +128,22 @@ def get_status(terminal, workspace, username):
status['conversation']['created_at'] = current_conv_data.get('created_at')
status['conversation']['updated_at'] = current_conv_data.get('updated_at')
meta = current_conv_data.get("metadata", {}) or {}
# 压缩标记是持久化的,进程在压缩中途被杀会残留 True压缩随进程
# 死亡),这里按 pid 懒清理,避免前端误判压缩中锁死输入栏。
from ..deep_compression import heal_stale_compression_flag
compression_in_progress = heal_stale_compression_flag(
terminal.context_manager._get_conversation_manager_for_id(current_conv),
current_conv,
meta,
context_manager=terminal.context_manager,
)
status['conversation']['compression'] = {
"in_progress": bool(meta.get("compression_in_progress", False)),
"mode": meta.get("compression_mode"),
"stage": meta.get("compression_stage"),
"in_progress": compression_in_progress,
"mode": meta.get("compression_mode") if compression_in_progress else None,
"stage": meta.get("compression_stage") if compression_in_progress else None,
"error": meta.get("compression_error"),
"count": int(meta.get("compression_count", 0) or 0),
"job_id": meta.get("compression_job_id"),
"job_id": meta.get("compression_job_id") if compression_in_progress else None,
}
except Exception as exc:
log_conn_diag(f"status conversation-meta-failed user={username} error={exc}")

View File

@ -17,6 +17,22 @@ def _sanitize_filename_component(text: str) -> str:
return safe or "untitled"
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分(原 server/files.py随 /file-manager 下线迁入)。"""
import re
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
cleaned = cleaned.replace("\\", "/").split("/")[-1]
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
return cleaned[:255]
def build_review_lines(messages, limit=None):
"""
将对话消息序列拍平成简化文本
@ -187,6 +203,7 @@ def log_streaming_debug_entry(data: Dict[str, Any]):
__all__ = [
"_sanitize_filename_component",
"sanitize_filename_preserve_unicode",
"build_review_lines",
"brief_log",
"debug_log",

View File

@ -1,899 +0,0 @@
(() => {
const API_BASE = '/api/gui/files';
const EDITOR_PAGE = '/file-manager/editor';
const state = {
currentPath: '',
items: [],
selected: new Set(),
lastSelectedIndex: null,
clipboard: null, // {mode: 'copy'|'cut', items: []}
treeCache: new Map(),
treeExpanded: new Set(['']),
isDraggingSelection: false,
dragStart: null,
selectionRect: null,
selectionJustFinished: false,
selectionDisabled: false,
};
const icons = {
directory: '📁',
default: '📄',
editable: '📝',
code: '💻',
markdown: '🧾',
image: '🖼️',
archive: '🗃️',
};
const fileGrid = document.getElementById('fileGrid');
const directoryTree = document.getElementById('directoryTree');
const breadcrumbEl = document.getElementById('breadcrumb');
const selectionInfo = document.getElementById('selectionInfo');
const statusBar = document.getElementById('statusBar');
const contextMenu = document.getElementById('contextMenu');
const dialogBackdrop = document.getElementById('dialogBackdrop');
const dialogTitle = document.getElementById('dialogTitle');
const dialogContent = document.getElementById('dialogContent');
const dialogCancel = document.getElementById('dialogCancel');
const dialogConfirm = document.getElementById('dialogConfirm');
const hiddenUploader = document.getElementById('hiddenUploader');
const pasteBtn = document.getElementById('btnPaste');
const newFolderBtn = document.getElementById('btnNewFolder');
const newFileBtn = document.getElementById('btnNewFile');
const refreshBtn = document.getElementById('btnRefresh');
const uploadBtn = document.getElementById('btnUpload');
const backBtn = document.getElementById('btnBack');
const returnChatBtn = document.getElementById('btnReturnChat');
const downloadBtn = document.getElementById('btnDownload');
const renameBtn = document.getElementById('btnRename');
const copyBtn = document.getElementById('btnCopy');
const cutBtn = document.getElementById('btnCut');
const deleteBtn = document.getElementById('btnDelete');
const toggleSelectionBtn = document.getElementById('btnToggleSelection');
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
const urlParams = new URLSearchParams(window.location.search);
const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, '');
dialogBackdrop.hidden = true;
let dialogHandlers = { confirm: null, cancel: null };
function clearDialogHandlers() {
dialogHandlers.confirm = null;
dialogHandlers.cancel = null;
}
function registerDialogHandlers(confirmHandler, cancelHandler) {
dialogHandlers.confirm = confirmHandler || null;
dialogHandlers.cancel = cancelHandler || null;
}
function closeDialog() {
dialogBackdrop.hidden = true;
clearDialogHandlers();
}
dialogCancel.addEventListener('click', () => {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogConfirm.addEventListener('click', () => {
if (dialogHandlers.confirm) {
const handler = dialogHandlers.confirm;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogBackdrop.addEventListener('click', (event) => {
if (event.target === dialogBackdrop) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !dialogBackdrop.hidden) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
closeDialog();
function showStatus(message) {
statusBar.textContent = message;
}
function formatSize(size) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleString();
}
function joinPath(base, name) {
if (!base) return name;
return `${base.replace(/\/$/, '')}/${name}`;
}
function getIcon(entry) {
if (entry.type === 'directory') return icons.directory;
if (entry.is_editable) return icons.editable;
const ext = entry.extension || '';
if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) {
return icons.image;
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return icons.archive;
}
if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) {
return icons.code;
}
if (['.md', '.markdown'].includes(ext)) {
return icons.markdown;
}
return icons.default;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || `请求失败 (${response.status})`;
throw new Error(message);
}
return data;
}
function updateUrl(path) {
const url = new URL(window.location.href);
if (path) {
url.searchParams.set('path', path);
} else {
url.searchParams.delete('path');
}
window.history.replaceState({}, '', url.pathname + url.search);
}
async function ensureAncestors(path) {
await ensureTreeNode('', false);
state.treeExpanded.add('');
if (!path) {
renderTree();
return;
}
const segments = path.split('/').filter(Boolean);
let current = '';
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
current = current ? `${current}/${segment}` : segment;
if (index < segments.length - 1) {
state.treeExpanded.add(current);
}
await ensureTreeNode(current, false);
}
renderTree();
}
async function loadDirectory(path = '', { updateHistory = true } = {}) {
hideContextMenu();
showStatus('加载中...');
try {
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
const resolvedPath = result.data.path || '';
state.currentPath = resolvedPath;
state.items = result.data.items || [];
const directoryEntries = state.items.filter((item) => item.type === 'directory');
state.treeCache.set(resolvedPath, directoryEntries);
if (updateHistory) {
updateUrl(resolvedPath);
}
await ensureAncestors(resolvedPath);
renderBreadcrumb(result.data.breadcrumb || []);
renderGrid();
updateSelection([]);
state.lastSelectedIndex = null;
showStatus(`已加载 ${state.items.length}`);
} catch (err) {
showStatus(err.message);
}
}
function renderBreadcrumb(crumbs) {
breadcrumbEl.innerHTML = '';
crumbs.forEach((crumb, index) => {
const span = document.createElement('span');
span.textContent = crumb.name;
span.dataset.path = crumb.path;
span.addEventListener('click', () => {
loadDirectory(crumb.path);
});
breadcrumbEl.appendChild(span);
if (index < crumbs.length - 1) {
const sep = document.createElement('span');
sep.textContent = '';
sep.classList.add('fm-breadcrumb-sep');
breadcrumbEl.appendChild(sep);
}
});
}
function renderGrid() {
fileGrid.innerHTML = '';
state.items.forEach((entry, index) => {
const card = document.createElement('div');
card.className = 'fm-card';
card.tabIndex = 0;
card.dataset.path = entry.path;
card.dataset.index = index;
if (state.selected.has(entry.path)) {
card.classList.add('selected');
}
const icon = document.createElement('div');
icon.className = 'fm-card-icon';
icon.textContent = getIcon(entry);
const name = document.createElement('div');
name.className = 'fm-card-name';
name.textContent = entry.name;
const meta = document.createElement('div');
meta.className = 'fm-card-meta';
const lines = [];
if (entry.type === 'file') {
lines.push(formatSize(entry.size));
} else {
lines.push('目录');
}
lines.push(formatTime(entry.modified_at));
meta.innerHTML = lines.join('<br>');
card.appendChild(icon);
card.appendChild(name);
card.appendChild(meta);
card.addEventListener('click', (event) => handleItemClick(event, entry, index));
card.addEventListener('dblclick', () => handleItemDoubleClick(entry));
card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry));
fileGrid.appendChild(card);
});
const overlay = document.createElement('div');
overlay.className = 'fm-drop-overlay';
overlay.textContent = '释放即可上传到此目录';
fileGrid.appendChild(overlay);
}
function updateSelection(paths, options = { append: false, range: false }) {
if (!options.append && !options.range) {
state.selected.clear();
if (paths.length === 0) {
state.lastSelectedIndex = null;
}
}
paths.forEach((path) => {
if (state.selected.has(path) && options.append) {
state.selected.delete(path);
} else {
state.selected.add(path);
}
});
syncSelectionUI();
}
function syncSelectionUI() {
const cards = fileGrid.querySelectorAll('.fm-card');
cards.forEach((card) => {
if (state.selected.has(card.dataset.path)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
selectionInfo.textContent = `已选中 ${state.selected.size}`;
pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length;
}
function handleItemClick(event, entry, index) {
const isMetaKey = event.metaKey || event.ctrlKey;
const isShiftKey = event.shiftKey;
if (isShiftKey && state.lastSelectedIndex !== null) {
const start = Math.min(state.lastSelectedIndex, index);
const end = Math.max(state.lastSelectedIndex, index);
const paths = state.items.slice(start, end + 1).map((item) => item.path);
updateSelection(paths, { range: true });
} else if (isMetaKey) {
updateSelection([entry.path], { append: true });
state.lastSelectedIndex = index;
} else {
updateSelection([entry.path], { append: false });
state.lastSelectedIndex = index;
}
}
function handleItemDoubleClick(entry) {
if (entry.type === 'directory') {
loadDirectory(entry.path);
return;
}
if (entry.is_editable) {
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
return;
}
window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank');
}
function handleItemContextMenu(event, entry) {
event.preventDefault();
if (!state.selected.has(entry.path)) {
updateSelection([entry.path], { append: false });
}
showContextMenu(event.clientX, event.clientY);
}
function showContextMenu(x, y) {
const single = state.selected.size === 1;
const singleEntry = single ? getSingleSelected() : null;
contextMenu.innerHTML = '';
const entries = [];
if (singleEntry) {
if (singleEntry.type === 'directory') {
entries.push({ label: '打开', action: openSelected, disabled: false });
} else if (singleEntry.is_editable) {
entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false });
if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
} else {
const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm';
if (isHtml) {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
} else if (state.selected.size > 0) {
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
entries.push(
{ label: '重命名', action: renameSelected, disabled: !single },
{ label: '复制', action: copySelected, disabled: state.selected.size === 0 },
{ label: '剪切', action: cutSelected, disabled: state.selected.size === 0 },
{ label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length },
{ label: '删除', action: deleteSelected, disabled: state.selected.size === 0 },
);
entries.forEach((item) => {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener('click', () => {
hideContextMenu();
item.action();
});
contextMenu.appendChild(btn);
});
contextMenu.style.display = 'block';
const { innerWidth, innerHeight } = window;
const menuRect = contextMenu.getBoundingClientRect();
const left = clamp(x, 0, innerWidth - menuRect.width);
const top = clamp(y, 0, innerHeight - menuRect.height);
contextMenu.style.left = `${left}px`;
contextMenu.style.top = `${top}px`;
}
function hideContextMenu() {
contextMenu.style.display = 'none';
}
function getSingleSelected() {
if (state.selected.size !== 1) return null;
const path = Array.from(state.selected)[0];
return state.items.find((item) => item.path === path) || null;
}
function openSelected() {
const entry = getSingleSelected();
if (!entry) return;
handleItemDoubleClick(entry);
}
function previewSelected() {
const entry = getSingleSelected();
if (!entry) return;
if (entry.extension !== '.html' && entry.extension !== '.htm') {
showStatus('仅支持预览 HTML 文件');
return;
}
window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank');
}
function openEditor() {
const entry = getSingleSelected();
if (!entry || !entry.is_editable) return;
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
}
function triggerBlobDownload(filename, blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
async function downloadSelected() {
if (!state.selected.size) return;
if (state.selected.size === 1) {
const path = Array.from(state.selected)[0];
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
return;
}
try {
const resp = await fetch(`${API_BASE}/download/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) })
});
if (!resp.ok) {
let message = '批量下载失败';
try {
const data = await resp.json();
message = data.error || data.message || message;
} catch (_) {
// ignore
}
throw new Error(message);
}
const blob = await resp.blob();
triggerBlobDownload(`selected_${Date.now()}.zip`, blob);
showStatus(`已开始下载 ${state.selected.size} 个项目`);
} catch (err) {
showStatus(err.message);
}
}
async function renameSelected() {
const entry = getSingleSelected();
if (!entry) return;
const newName = await promptDialog('重命名', entry.name);
if (!newName) return;
try {
await request(`${API_BASE}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: entry.path, new_name: newName }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function copySelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'copy', items: Array.from(state.selected) };
showStatus(`已复制 ${state.clipboard.items.length}`);
syncSelectionUI();
}
function cutSelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'cut', items: Array.from(state.selected) };
showStatus(`已剪切 ${state.clipboard.items.length}`);
syncSelectionUI();
}
async function pasteClipboard() {
if (!state.clipboard || !state.clipboard.items.length) return;
const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move';
try {
await request(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths: state.clipboard.items,
target_dir: state.currentPath,
}),
});
if (state.clipboard.mode === 'cut') {
state.clipboard = null;
}
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
async function deleteSelected() {
if (!state.selected.size) return;
const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`);
if (!confirm) return;
try {
await request(`${API_BASE}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function promptDialog(title, defaultValue = '') {
return new Promise((resolve) => {
dialogTitle.textContent = title;
dialogContent.innerHTML = '';
const input = document.createElement('input');
input.value = defaultValue;
input.autofocus = true;
dialogContent.appendChild(input);
const finish = (value) => {
closeDialog();
resolve(value);
};
registerDialogHandlers(() => finish(input.value.trim()), () => finish(null));
dialogBackdrop.hidden = false;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
finish(input.value.trim());
} else if (evt.key === 'Escape') {
evt.preventDefault();
finish(null);
}
});
setTimeout(() => input.select(), 50);
});
}
function confirmDialog(message) {
return new Promise((resolve) => {
dialogTitle.textContent = '确认操作';
dialogContent.innerHTML = `<p>${message}</p>`;
const finish = (value) => {
closeDialog();
resolve(value);
};
registerDialogHandlers(() => finish(true), () => finish(false));
dialogBackdrop.hidden = false;
});
}
function handleGlobalClick(event) {
if (!contextMenu.contains(event.target)) {
hideContextMenu();
}
}
function handleGridBackgroundClick(event) {
if (event.target === fileGrid) {
if (state.selectionJustFinished) {
return;
}
updateSelection([]);
}
}
function handleDragEnter(event) {
event.preventDefault();
fileGrid.classList.add('drop-target');
}
function handleDragOver(event) {
event.preventDefault();
}
function handleDragLeave(event) {
if (event.target === fileGrid) {
fileGrid.classList.remove('drop-target');
}
}
async function handleDrop(event) {
event.preventDefault();
fileGrid.classList.remove('drop-target');
if (!event.dataTransfer || !event.dataTransfer.files.length) return;
const files = event.dataTransfer.files;
await uploadFiles(files, state.currentPath);
}
async function uploadFiles(fileList, targetPath) {
for (const file of fileList) {
const form = new FormData();
form.append('file', file, file.name);
form.append('filename', file.name);
form.append('path', targetPath);
try {
await request(`${API_BASE}/upload`, {
method: 'POST',
body: form,
});
showStatus(`已上传 ${file.name}`);
} catch (err) {
showStatus(`上传失败:${err.message}`);
}
}
await loadDirectory(state.currentPath);
}
function initSelectionRectangle() {
fileGrid.addEventListener('pointerdown', (event) => {
if (state.selectionDisabled) return;
if (event.target !== fileGrid) return;
state.isDraggingSelection = true;
state.dragStart = { x: event.clientX, y: event.clientY };
state.selectionRect = document.createElement('div');
state.selectionRect.className = 'fm-selection-rect';
fileGrid.appendChild(state.selectionRect);
updateSelection([]);
state.selectionJustFinished = false;
fileGrid.setPointerCapture(event.pointerId);
});
fileGrid.addEventListener('pointermove', (event) => {
if (!state.isDraggingSelection || !state.selectionRect) return;
const rect = fileGrid.getBoundingClientRect();
const current = { x: event.clientX, y: event.clientY };
const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft;
const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop;
const width = Math.abs(state.dragStart.x - current.x);
const height = Math.abs(state.dragStart.y - current.y);
Object.assign(state.selectionRect.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
});
const selectionBox = {
left: Math.min(state.dragStart.x, current.x),
right: Math.max(state.dragStart.x, current.x),
top: Math.min(state.dragStart.y, current.y),
bottom: Math.max(state.dragStart.y, current.y),
};
const selected = [];
const cards = fileGrid.querySelectorAll('.fm-card');
cards.forEach((card) => {
const bounds = card.getBoundingClientRect();
const intersects = !(selectionBox.right < bounds.left ||
selectionBox.left > bounds.right ||
selectionBox.bottom < bounds.top ||
selectionBox.top > bounds.bottom);
if (intersects) {
selected.push(card.dataset.path);
}
});
updateSelection(selected);
});
fileGrid.addEventListener('pointerup', (event) => {
if (!state.isDraggingSelection) return;
state.isDraggingSelection = false;
if (state.selectionRect) {
state.selectionRect.remove();
state.selectionRect = null;
}
state.selectionJustFinished = true;
requestAnimationFrame(() => {
state.selectionJustFinished = false;
});
fileGrid.releasePointerCapture(event.pointerId);
});
}
async function ensureTreeNode(path, shouldRender = true) {
let changed = false;
if (!state.treeCache.has(path)) {
try {
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
const directories = result.data.items.filter((item) => item.type === 'directory');
state.treeCache.set(path, directories);
changed = true;
} catch (err) {
showStatus(err.message);
}
}
if (shouldRender && changed) {
renderTree();
}
return changed;
}
function renderTree() {
directoryTree.innerHTML = '';
const rootNode = createTreeNode('', '根目录');
directoryTree.appendChild(rootNode);
}
function createTreeNode(path, name) {
const li = document.createElement('li');
const header = document.createElement('div');
header.className = 'fm-tree-item';
if (path === state.currentPath) {
header.classList.add('active');
}
const toggle = document.createElement('span');
toggle.className = 'fm-tree-toggle';
toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸';
toggle.addEventListener('click', async (event) => {
event.stopPropagation();
if (state.treeExpanded.has(path)) {
state.treeExpanded.delete(path);
} else {
state.treeExpanded.add(path);
await ensureTreeNode(path, false);
}
renderTree();
});
const label = document.createElement('span');
label.textContent = name;
label.addEventListener('click', () => loadDirectory(path));
header.addEventListener('click', () => loadDirectory(path));
header.appendChild(toggle);
header.appendChild(label);
li.appendChild(header);
if (state.treeExpanded.has(path)) {
const children = document.createElement('ul');
children.className = 'fm-tree-children';
const dirs = state.treeCache.get(path) || [];
dirs.forEach((dir) => {
const child = createTreeNode(dir.path, dir.name);
children.appendChild(child);
});
li.appendChild(children);
}
return li;
}
function bindToolbar() {
newFolderBtn.addEventListener('click', async () => {
const name = await promptDialog('新建文件夹', '新建文件夹');
if (!name) return;
try {
await request(`${API_BASE}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: state.currentPath,
name,
type: 'directory',
}),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
});
newFileBtn.addEventListener('click', async () => {
const name = await promptDialog('新建文件', '新建文件.txt');
if (!name) return;
try {
await request(`${API_BASE}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: state.currentPath,
name,
type: 'file',
}),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
});
refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath));
uploadBtn.addEventListener('click', () => hiddenUploader.click());
backBtn.addEventListener('click', () => {
if (!state.currentPath) {
loadDirectory('');
return;
}
const segments = state.currentPath.split('/').filter(Boolean);
if (segments.length === 0) {
loadDirectory('');
return;
}
segments.pop();
const parentPath = segments.join('/');
loadDirectory(parentPath);
});
returnChatBtn.addEventListener('click', () => {
window.location.href = '/new';
});
downloadBtn.addEventListener('click', downloadSelected);
renameBtn.addEventListener('click', renameSelected);
copyBtn.addEventListener('click', copySelected);
cutBtn.addEventListener('click', cutSelected);
pasteBtn.addEventListener('click', pasteClipboard);
deleteBtn.addEventListener('click', deleteSelected);
toggleSelectionBtn.addEventListener('click', () => {
state.selectionDisabled = !state.selectionDisabled;
toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选';
const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选';
showStatus(msg);
});
hiddenUploader.addEventListener('change', async (event) => {
const files = event.target.files;
if (files && files.length) {
await uploadFiles(files, state.currentPath);
}
hiddenUploader.value = '';
});
}
function bindGlobalEvents() {
document.addEventListener('click', handleGlobalClick);
fileGrid.addEventListener('click', handleGridBackgroundClick);
fileGrid.addEventListener('contextmenu', (event) => {
if (event.target === fileGrid) {
event.preventDefault();
if (state.selected.size) {
showContextMenu(event.clientX, event.clientY);
}
}
});
fileGrid.addEventListener('dragenter', handleDragEnter);
fileGrid.addEventListener('dragover', handleDragOver);
fileGrid.addEventListener('dragleave', handleDragLeave);
fileGrid.addEventListener('drop', handleDrop);
initSelectionRectangle();
}
async function bootstrap() {
bindToolbar();
bindGlobalEvents();
await loadDirectory(initialPathParam, { updateHistory: false });
}
bootstrap().catch((err) => {
console.error(err);
showStatus(err.message);
});
})();

View File

@ -1,60 +0,0 @@
.fe-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 18, 26, 0.9);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.fe-left {
display: flex;
align-items: center;
gap: 12px;
}
.fe-right {
display: flex;
align-items: center;
gap: 12px;
}
.fe-path {
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
max-width: 52vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fe-status {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.fe-main {
height: calc(100vh - 64px);
}
#editorArea {
width: 100%;
height: 100%;
border: none;
outline: none;
background: #0f1119;
color: #f1f3f5;
font-size: 14px;
line-height: 1.6;
padding: 20px;
font-family: "Fira Code", "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
resize: none;
}
#editorArea:focus {
outline: none;
}

View File

@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文件编辑器</title>
<link rel="stylesheet" href="/static/file_manager/style.css">
<link rel="stylesheet" href="/static/file_manager/editor.css">
</head>
<body>
<div id="editorApp">
<header class="fe-header">
<div class="fe-left">
<button class="fm-btn" id="btnBack">← 返回</button>
<div class="fe-path" id="filePath"></div>
</div>
<div class="fe-right">
<span class="fe-status" id="statusInfo">未保存修改</span>
<button class="fm-btn" id="btnDownload">下载</button>
<button class="fm-btn" id="btnIncreaseFont">A+</button>
<button class="fm-btn" id="btnDecreaseFont">A-</button>
<button class="fm-btn primary" id="btnSave">保存</button>
</div>
</header>
<main class="fe-main">
<textarea id="editorArea" spellcheck="false"></textarea>
</main>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/editor.js"></script>
</body>
</html>

View File

@ -1,135 +0,0 @@
(() => {
const API_BASE = '/api/gui/files';
const params = new URLSearchParams(window.location.search);
const path = params.get('path');
const editorArea = document.getElementById('editorArea');
const filePathEl = document.getElementById('filePath');
const statusInfo = document.getElementById('statusInfo');
const saveBtn = document.getElementById('btnSave');
const downloadBtn = document.getElementById('btnDownload');
const backBtn = document.getElementById('btnBack');
const fontIncreaseBtn = document.getElementById('btnIncreaseFont');
const fontDecreaseBtn = document.getElementById('btnDecreaseFont');
let originalContent = '';
let dirty = false;
let fontSize = 14;
if (!path) {
editorArea.value = '缺少 path 参数,无法加载文件。';
editorArea.disabled = true;
saveBtn.disabled = true;
downloadBtn.disabled = true;
statusInfo.textContent = '缺少路径';
return;
}
filePathEl.textContent = path;
function setDirty(value) {
dirty = value;
statusInfo.textContent = dirty ? '有未保存的更改' : '已保存';
saveBtn.disabled = !dirty;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || `请求失败 (${response.status})`;
throw new Error(message);
}
return data;
}
async function loadFile() {
statusInfo.textContent = '加载中...';
try {
const result = await request(`${API_BASE}/text?path=${encodeURIComponent(path)}`);
originalContent = result.content || '';
editorArea.value = originalContent;
setDirty(false);
statusInfo.textContent = `最后修改时间:${result.modified_at}`;
} catch (err) {
editorArea.value = `文件加载失败:${err.message}`;
editorArea.disabled = true;
saveBtn.disabled = true;
statusInfo.textContent = '无法加载文件';
}
}
async function saveFile() {
statusInfo.textContent = '保存中...';
try {
await request(`${API_BASE}/text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: editorArea.value }),
});
originalContent = editorArea.value;
setDirty(false);
statusInfo.textContent = '已保存';
} catch (err) {
statusInfo.textContent = `保存失败:${err.message}`;
}
}
editorArea.addEventListener('input', () => {
if (editorArea.disabled) return;
setDirty(editorArea.value !== originalContent);
});
saveBtn.addEventListener('click', () => {
if (!dirty) return;
saveFile();
});
downloadBtn.addEventListener('click', () => {
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
});
const getParentDirectory = () => {
const segments = path.split('/').filter(Boolean);
if (segments.length <= 1) {
return '';
}
segments.pop();
return segments.join('/');
};
const navigateToManager = () => {
const parentDir = getParentDirectory();
const target = parentDir ? `/file-manager?path=${encodeURIComponent(parentDir)}` : '/file-manager';
window.location.href = target;
};
backBtn.addEventListener('click', () => {
if (dirty) {
const confirmLeave = window.confirm('有未保存的更改,确认要离开吗?');
if (!confirmLeave) return;
}
navigateToManager();
});
fontIncreaseBtn.addEventListener('click', () => {
fontSize = Math.min(fontSize + 1, 28);
editorArea.style.fontSize = `${fontSize}px`;
});
fontDecreaseBtn.addEventListener('click', () => {
fontSize = Math.max(fontSize - 1, 10);
editorArea.style.fontSize = `${fontSize}px`;
});
window.addEventListener('beforeunload', (event) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = '';
});
loadFile().catch((err) => {
console.error(err);
statusInfo.textContent = err.message;
});
})();

View File

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文件管理器</title>
<link rel="icon" type="image/svg+xml" href="/static/astrion-avatar.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/file_manager/style.css">
</head>
<body>
<div id="app">
<header class="fm-header">
<div class="fm-header-left">
<button class="fm-btn" id="btnBack" title="返回上一页">← 返回</button>
<button class="fm-btn" id="btnReturnChat" title="回到对话页面">返回对话</button>
<button class="fm-btn" id="btnToggleSelection" title="切换框选功能">禁用框选</button>
<div class="fm-breadcrumb" id="breadcrumb"></div>
</div>
<div class="fm-header-right">
<input type="file" id="hiddenUploader" hidden>
<button class="fm-btn" id="btnNewFolder">新建文件夹</button>
<button class="fm-btn" id="btnNewFile">新建文件</button>
<button class="fm-btn" id="btnUpload">上传</button>
<button class="fm-btn" id="btnRefresh">刷新</button>
</div>
</header>
<main class="fm-main">
<aside class="fm-sidebar">
<div class="fm-sidebar-title">目录</div>
<ul class="fm-tree" id="directoryTree"></ul>
</aside>
<section class="fm-content">
<div class="fm-toolbar">
<div class="fm-selection-info" id="selectionInfo">已选中 0 项</div>
<div class="fm-toolbar-right">
<button class="fm-btn" id="btnDownload">下载</button>
<button class="fm-btn" id="btnRename">重命名</button>
<button class="fm-btn" id="btnCopy">复制</button>
<button class="fm-btn" id="btnCut">剪切</button>
<button class="fm-btn" id="btnPaste" disabled>粘贴</button>
<button class="fm-btn danger" id="btnDelete">删除</button>
</div>
</div>
<div class="fm-grid" id="fileGrid"></div>
<div class="fm-status-bar" id="statusBar">拖拽文件到此处可上传到当前目录</div>
</section>
</main>
<div class="fm-context-menu" id="contextMenu"></div>
<div class="fm-dialog-backdrop" id="dialogBackdrop" hidden>
<div class="fm-dialog" id="dialog">
<h3 id="dialogTitle"></h3>
<div id="dialogContent"></div>
<div class="fm-dialog-actions">
<button class="fm-btn" id="dialogCancel">取消</button>
<button class="fm-btn primary" id="dialogConfirm">确定</button>
</div>
</div>
</div>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/app.js"></script>
</body>
</html>

View File

@ -1,357 +0,0 @@
* {
box-sizing: border-box;
}
html, body {
height: 100%;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
background: #1d1f27;
color: #f1f3f5;
}
a {
color: inherit;
text-decoration: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.fm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 18, 26, 0.9);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.fm-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.fm-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.fm-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.fm-breadcrumb span {
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.fm-breadcrumb span:hover {
background: rgba(255, 255, 255, 0.08);
}
.fm-btn {
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 14px;
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
color: inherit;
transition: background 0.2s, transform 0.2s;
}
.fm-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
.fm-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fm-btn.primary {
background: #3d8bfd;
color: #fff;
}
.fm-btn.primary:hover {
background: #377df5;
}
.fm-btn.danger {
background: #f06595;
color: #fff;
}
.fm-btn.danger:hover {
background: #e64980;
}
.fm-main {
flex: 1;
display: grid;
grid-template-columns: 260px 1fr;
overflow: hidden;
}
.fm-sidebar {
border-right: 1px solid rgba(255, 255, 255, 0.06);
padding: 16px 12px;
overflow-y: auto;
background: rgba(17, 18, 26, 0.92);
}
.fm-sidebar-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
}
.fm-tree {
list-style: none;
padding-left: 0;
margin: 0;
font-size: 14px;
}
.fm-tree li {
margin: 4px 0;
}
.fm-tree-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.fm-tree-item:hover,
.fm-tree-item.active {
background: rgba(255, 255, 255, 0.1);
}
.fm-tree-toggle {
width: 16px;
text-align: center;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
}
.fm-tree-children {
list-style: none;
padding-left: 16px;
margin: 6px 0 0;
border-left: 1px dashed rgba(255, 255, 255, 0.1);
}
.fm-content {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(17, 18, 26, 0.8);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.fm-toolbar-right {
display: flex;
gap: 10px;
}
.fm-selection-info {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
}
.fm-grid {
flex: 1;
padding: 18px;
position: relative;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.fm-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: space-between;
cursor: pointer;
transition: border 0.2s, background 0.2s, transform 0.2s;
user-select: none;
aspect-ratio: 1;
}
.fm-card:hover {
border-color: rgba(255, 255, 255, 0.16);
}
.fm-card.selected {
border-color: #3d8bfd;
background: rgba(61, 139, 253, 0.2);
}
.fm-card-icon {
font-size: 32px;
}
.fm-card-name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-card-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
}
.fm-status-bar {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
background: rgba(17, 18, 26, 0.8);
}
.fm-context-menu {
position: fixed;
z-index: 1000;
background: rgba(17, 18, 26, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 6px 0;
width: 180px;
display: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.fm-context-menu button {
width: 100%;
border: none;
background: transparent;
color: inherit;
padding: 8px 16px;
text-align: left;
font-size: 14px;
cursor: pointer;
}
.fm-context-menu button:hover {
background: rgba(255, 255, 255, 0.1);
}
.fm-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.fm-dialog-backdrop[hidden] {
display: none !important;
}
.fm-dialog {
background: #1f212b;
border-radius: 12px;
padding: 24px;
width: 360px;
max-width: 92vw;
}
.fm-dialog h3 {
margin: 0 0 16px;
}
.fm-dialog input,
.fm-dialog textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: inherit;
font-size: 14px;
}
.fm-dialog textarea {
min-height: 120px;
resize: vertical;
}
.fm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
.fm-selection-rect {
position: absolute;
border: 1px solid rgba(61, 139, 253, 0.8);
background: rgba(61, 139, 253, 0.25);
pointer-events: none;
z-index: 50;
}
.fm-drop-overlay {
position: absolute;
inset: 0;
border: 2px dashed rgba(61, 139, 253, 0.8);
background: rgba(61, 139, 253, 0.12);
display: none;
justify-content: center;
align-items: center;
font-size: 16px;
font-weight: 600;
color: rgba(61, 139, 253, 0.9);
}
.fm-grid.drop-target .fm-drop-overlay {
display: flex;
}

View File

@ -269,7 +269,7 @@
:tool-settings="toolSettings"
:tool-settings-loading="toolSettingsLoading"
:settings-open="settingsOpen"
:compressing="compressing || compressionInProgress"
:compressing="compressionActiveForCurrentConversation"
:current-conversation-id="currentConversationId"
:icon-style="iconStyle"
:tool-category-icon="toolCategoryIcon"
@ -320,7 +320,6 @@
@send-message="sendMessage"
@send-or-stop="handleSendOrStop"
@quick-upload="handleQuickUpload"
@open-file-manager="openGuiFileManager"
@toggle-tool-menu="toggleToolMenu"
@toggle-mode-menu="toggleModeMenu"
@toggle-model-menu="toggleModelMenu"

View File

@ -227,10 +227,23 @@ export const computed = {
displayModeSwitchDisabled() {
return !!this.policyUiBlocks.block_virtual_monitor;
},
compressionActiveForCurrentConversation() {
// 压缩锁按对话隔离:只有「正在查看的对话就是正在压缩的对话」时才锁;
// 其他对话与 /new 新建页currentConversationId 为空)不受影响。
if (!this.compressionInProgress && !this.compressing) {
return false;
}
const cid = this.compressionConversationId;
if (!cid) {
// 未记录来源(旧数据/异常路径兑底):只锁有打开对话的页面,/new 不锁
return !!this.currentConversationId;
}
return cid === this.currentConversationId;
},
displayLockEngaged() {
// 对话级并行后:运行中的任务不再硬锁输入栏——当前对话需要保持可输入以排队/停止,
// 其他对话需要保持可输入以并行运行。仅压缩期间保持锁定。
return !!this.compressionInProgress || !!this.compressing;
// 其他对话需要保持可输入以并行运行。仅当前对话压缩期间保持锁定。
return this.compressionActiveForCurrentConversation;
},
currentWorkspaceHasRunningTask() {
// 对话级隔离后:仅当「当前对话」有运行中任务时才拦截发送;
@ -258,8 +271,7 @@ export const computed = {
this.taskInProgress ||
monitorLock ||
this.stopRequested ||
this.compressionInProgress ||
this.compressing
this.compressionActiveForCurrentConversation
);
},
/**
@ -288,8 +300,7 @@ export const computed = {
!this.streamingUi &&
!this.stopRequested &&
!(this.monitorIsLocked && this.chatDisplayMode === 'monitor') &&
!this.compressionInProgress &&
!this.compressing
!this.compressionActiveForCurrentConversation
);
},
composerStreamingForInput() {

View File

@ -18,35 +18,8 @@ export const actionMethods = {
}
},
async createNewConversation() {
if (this.compressionInProgress || this.compressing) {
const confirmed = await this.confirmAction({
title: '压缩进行中',
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
confirmText: '确认',
cancelText: '取消'
});
if (!confirmed) {
return;
}
try {
if (this.currentConversationId) {
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
method: 'POST'
});
}
} catch (e) {
console.warn('取消压缩失败:', e);
}
this.compressionInProgress = false;
this.compressing = false;
this.compressionMode = '';
this.compressionStage = '';
if (this.compressionToastId) {
this.uiDismissToast(this.compressionToastId);
this.compressionToastId = null;
}
}
// 已移除「压缩中需确认并取消压缩」拦截:多对话独立运行后,某对话压缩中
// 不影响新建/切换其他对话(压缩锁已按对话隔离)。
debugLog('创建新对话...');
traceLog('createNewConversation:start', {
currentConversationId: this.currentConversationId,

View File

@ -184,36 +184,9 @@ export const loadMethods = {
// 不会串到新对话fire-and-forget不阻塞加载
this.flushReasoningEffortSave?.();
if ((this.compressionInProgress || this.compressing) && !force) {
const confirmed = await this.confirmAction({
title: '压缩进行中',
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
confirmText: '确认',
cancelText: '取消'
});
if (!confirmed) {
this.suppressTitleTyping = false;
this.titleReady = true;
return;
}
try {
if (this.currentConversationId) {
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
method: 'POST'
});
}
} catch (e) {
console.warn('取消压缩失败:', e);
}
this.compressionInProgress = false;
this.compressing = false;
this.compressionMode = '';
this.compressionStage = '';
if (this.compressionToastId) {
this.uiDismissToast(this.compressionToastId);
this.compressionToastId = null;
}
}
// 已移除「压缩中切换对话需确认/取消压缩」拦截:多对话独立运行后,压缩在
// 后端随对话任务进行,切换视图不影响压缩本身;压缩锁也按对话隔离
// compressionActiveForCurrentConversation不再存在对话级全局锁。
// 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。

View File

@ -44,6 +44,7 @@ export const chatMethods = {
this.compressing = true;
this.compressionInProgress = true;
this.compressionConversationId = this.currentConversationId;
this.compressionMode = 'manual';
this.compressionStage = 'requesting';
this.compressionError = '';
@ -120,6 +121,7 @@ export const chatMethods = {
}
this.compressing = false;
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
}

View File

@ -10,7 +10,7 @@ import {
export const sendMethods = {
async handleSendOrStop() {
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '当前不可发送/停止,请等待压缩完成',
@ -37,7 +37,7 @@ export const sendMethods = {
// 传统模式waitingForSubAgent=truetaskInProgress=true 。多智能体模式has_running_multi_agent=true。
// 此时新文本消息应直接发送,触发主智能体下一轮工作,而不是被进队列等任务结束。
const mainIdle = typeof this.mainChatIdle === 'function' ? this.mainChatIdle : (
!this.streamingUi && !this.stopRequested && !this.compressionInProgress && !this.compressing
!this.streamingUi && !this.stopRequested && !this.compressionActiveForCurrentConversation
);
if (this.composerBusy && mainIdle && hasText) {
// 如果有 pending 问题(子智能体询问主智能体),仍走问答路径,不走直接发送
@ -115,7 +115,7 @@ export const sendMethods = {
const presetText = typeof options?.presetText === 'string' ? options.presetText : null;
const usePresetText = presetText !== null;
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '压缩完成后才能继续发送消息',
@ -473,7 +473,7 @@ export const sendMethods = {
return;
}
this._stopTaskRunning = true;
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this._stopTaskRunning = false;
this.uiPushToast({
title: '对话自动压缩中',

View File

@ -204,17 +204,24 @@ export const resourceMethods = {
this.conversationHasImages = false;
this.conversationHasVideos = false;
}
// 压缩状态与 has_images/has_videos 同理status 反映的是后端 terminal 当前
// 对话的压缩状态。空对话/显式新建路由(/new上没有打开对话status 里的
// 压缩标记属于残留的旧对话上下文,应用会把输入锁错误地带到 /new 页。
const compression = status?.conversation?.compression;
if (compression && typeof compression === 'object') {
this.compressionInProgress = !!compression.in_progress;
this.compressionMode = compression.mode || '';
this.compressionStage = compression.stage || '';
this.compressionError = compression.error || '';
} else {
this.compressionInProgress = false;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';
if (hasConversation && !onExplicitNewRoute) {
if (compression && typeof compression === 'object') {
this.compressionInProgress = !!compression.in_progress;
this.compressionMode = compression.mode || '';
this.compressionStage = compression.stage || '';
this.compressionError = compression.error || '';
this.compressionConversationId = this.compressionInProgress ? this.currentConversationId : null;
} else {
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';
}
}
},

View File

@ -25,6 +25,10 @@ export const compressionMethods = {
}
const wasInProgress = !!this.compressionInProgress;
this.compressionInProgress = !!data.in_progress;
// 记录压缩所属对话:压缩锁只作用于该对话,不影响其他对话与 /new 新建页
this.compressionConversationId = this.compressionInProgress
? (data.conversation_id || this.currentConversationId || null)
: null;
this.compressionMode = data.mode || '';
this.compressionStage = data.stage || '';
if (this.compressionInProgress && !wasInProgress) {
@ -68,6 +72,7 @@ export const compressionMethods = {
this.compressionToastId = null;
}
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';

View File

@ -28,7 +28,7 @@ export const reviewMethods = {
if (this.compressing || this.streamingMessage || !this.isConnected) {
return;
}
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '当前对话正在压缩,请稍后再试',

View File

@ -138,12 +138,6 @@ export const systemMethods = {
}
return style;
},
openGuiFileManager() {
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
return;
}
window.open('/file-manager', '_blank');
},
renderMarkdown(content, isStreaming = false) {
return renderMarkdownHelper(content, isStreaming);
},

View File

@ -135,6 +135,9 @@ export function dataState() {
// 对话压缩状态
compressing: false,
compressionInProgress: false,
// 正在压缩的对话 id压缩锁只作用于该对话本身不影响其他对话与 /new 新建页
// (多对话独立运行后,压缩不再是对话级全局锁)。
compressionConversationId: null,
compressionMode: '',
compressionStage: '',
compressionError: '',

View File

@ -101,7 +101,6 @@ const props = withDefaults(
}
);
const emit = defineEmits<{
(event: 'active-index', index: number): void;
(event: 'poke'): void;
}>();
@ -314,18 +313,12 @@ function trackEyes() {
trackRaf = requestAnimationFrame(trackEyes);
}
// ---- ----
let carouselTimer: number | null = null;
// ---- face ----
let faceSwitchTimer: number | null = null;
let blinkTimer: number | null = null;
let nervousTimer: number | null = null;
let isBlinking = false;
let carouselIdx = 0;
const internalMode = ref<string | null>(null);
function stopCarousel() {
if (carouselTimer) clearInterval(carouselTimer);
carouselTimer = null;
}
function stopFaceSwitchTimer() {
if (faceSwitchTimer) clearTimeout(faceSwitchTimer);
faceSwitchTimer = null;
@ -375,7 +368,6 @@ function triggerNervous() {
stopAutoBlink();
stopNervousTimer();
stopTracking();
stopCarousel();
stopFaceSwitchTimer();
activeFaceKey.value = null;
sharedVisible.value = true;
@ -424,20 +416,6 @@ function setIconFace(key: string, animated = true) {
faceSwitchTimer = null;
}, 160);
}
function startCarousel(keys: string[]) {
stopCarousel();
stopFaceSwitchTimer();
carouselIdx = 0;
setIconFace(keys[0]);
emit('active-index', carouselIdx);
if (keys.length < 2) return;
carouselTimer = window.setInterval(() => {
carouselIdx = (carouselIdx + 1) % keys.length;
setIconFace(keys[carouselIdx]);
emit('active-index', carouselIdx);
}, 1450);
}
// ---- ----
function applyState() {
if (props.mode !== 'idle' && internalMode.value) {
@ -449,10 +427,11 @@ function applyState() {
if (mode === 'tool') {
stopTracking();
const keys = (props.toolKeys || []).filter(Boolean);
startCarousel(keys.length ? keys : ['command']);
//
// toolKeys
setIconFace(keys.length ? keys[0] : 'command');
return;
}
stopCarousel();
stopFaceSwitchTimer();
if (mode === 'think') {
stopTracking();
@ -493,7 +472,13 @@ function applyState() {
}
}
watch(() => [props.mode, props.toolKeys, props.tracking, internalMode.value], () => applyState(), { deep: true });
// toolKeys join avatarStatus computed
// intent applyState
// face
watch(
() => [props.mode, (props.toolKeys || []).join('\u001f'), props.tracking, internalMode.value],
() => applyState()
);
onMounted(() => {
// SVG
@ -504,7 +489,6 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMouseMove);
stopTracking();
stopCarousel();
stopFaceSwitchTimer();
stopAutoBlink();
stopNervousTimer();

View File

@ -106,7 +106,7 @@
</span>
<span
v-else-if="composerAvatarText"
:key="`tool-${props.avatarStatus?.mode}`"
:key="composerAvatarTextKey"
class="composer-avatar__text"
:class="{
'composer-avatar__text--right': !floatingStatusVisible,
@ -435,7 +435,6 @@
:goal-mode-armed="goalModeArmed"
:goal-running="goalRunning"
@quick-upload="triggerQuickUpload"
@open-file-manager="$emit('open-file-manager')"
@pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')"
@toggle-tool-menu="$emit('toggle-tool-menu')"
@ -605,7 +604,6 @@ const emit = defineEmits([
'send-message',
'send-or-stop',
'quick-upload',
'open-file-manager',
'pick-images',
'pick-video',
'toggle-tool-menu',
@ -977,6 +975,15 @@ const composerAvatarText = computed(() => {
return '';
});
// key running tool
// 12 out-in SVG
const composerAvatarTextKey = computed(() => {
const s = props.avatarStatus;
if (!s) return 'avatar-none';
if (s.mode === 'tool') return `avatar-tool-${s.toolKeys?.[0] || ''}`;
return `avatar-${s.mode}`;
});
const projectGitMenuOpen = ref<null | 'project' | 'branch'>(null);
const closeProjectGitMenu = () => {

View File

@ -11,15 +11,6 @@
>
{{ uploading ? '上传中...' : '上传文件' }}
</button>
<button
type="button"
class="menu-entry"
data-tutorial="quick-file-manager"
@click.stop="$emit('open-file-manager')"
:disabled="!isConnected"
>
文件管理
</button>
<button
type="button"
class="menu-entry"
@ -215,7 +206,6 @@ const props = defineProps<{
defineEmits<{
(event: 'quick-upload'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-tool-menu'): void;
(event: 'toggle-settings'): void;
(event: 'update-tool-category', id: string, enabled: boolean): void;

View File

@ -1087,6 +1087,21 @@
</button>
</div>
</div>
<div class="settings-input-row">
<span class="settings-row-title">最大记忆注入</span>
<div class="settings-number-row">
<input
type="number"
:min="projectMemoryInjectLimitMin"
:value="form.project_memory_inject_limit ?? ''"
placeholder="无上限"
@input="handleProjectMemoryInjectLimitInput"
@blur="commitProjectMemoryInjectLimitInput"
/><button type="button" @click="restoreProjectMemoryInjectLimit">
恢复默认
</button>
</div>
</div>
<div class="settings-group-block">
<div class="settings-group-title">
<span class="settings-row-title">上下文压缩策略</span
@ -1594,7 +1609,7 @@
<span class="settings-row-copy">
<span class="settings-row-title">最大执行轮次</span>
<span class="settings-row-desc">
子智能体单次任务的最大执行轮次一轮 = 一次模型调用留空默认 50 0 表示无上限慎用失控任务会持续消耗 API 额度
传统后台子智能体单次任务的最大执行轮次一轮 = 一次模型调用留空默认 50 0 表示无上限慎用失控任务会持续消耗 API 额度多智能体模式的团队成员是长期协作角色不受此限制
</span>
</span>
<div style="display: flex; gap: 6px; align-items: center">
@ -1940,6 +1955,7 @@ const {
toolCategories,
skillsCatalog,
recentConversationsPromptLimitRange,
projectMemoryInjectLimitMin,
experiments
} = storeToRefs(personalization);
@ -2580,7 +2596,7 @@ watch(
const subAgentRoles = ref<any[]>([]);
const subAgentRolesLoading = ref(false);
const subAgentCompressThreshold = ref(150000);
/** 子智能体最大执行轮次null/'' = 默认 500 = 无上限;正整数 = 该值 */
/** 子智能体最大执行轮次(仅传统后台子智能体;多智能体成员不受限)null/'' = 默认 500 = 无上限;正整数 = 该值 */
const subAgentMaxTurns = ref<number | null>(null);
const subAgentSettingsSaving = ref(false);
const subAgentModels = ref<any[]>([]);
@ -2893,6 +2909,32 @@ const restoreRecentConversationsPromptLimit = () => {
personalization.setRecentConversationsPromptLimit(null);
};
const handleProjectMemoryInjectLimitInput = (event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target) {
return;
}
personalization.updateField({
key: 'project_memory_inject_limit',
value: target.value
});
};
const commitProjectMemoryInjectLimitInput = (event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target || !target.value) {
// =
personalization.setProjectMemoryInjectLimit(null);
return;
}
const parsed = Number(target.value);
personalization.setProjectMemoryInjectLimit(Number.isNaN(parsed) ? null : parsed);
};
const restoreProjectMemoryInjectLimit = () => {
personalization.restoreProjectMemoryInjectLimit();
};
const handleCompressionNumberInput = (key: CompressionField, event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target || !target.value) {

View File

@ -17,6 +17,7 @@ interface PersonalForm {
auto_generate_title: boolean;
recent_conversations_prompt_enabled: boolean;
recent_conversations_prompt_limit: number | string;
project_memory_inject_limit: number | string | null;
tool_intent_enabled: boolean;
skill_hints_enabled: boolean;
skill_strict_terminal_enabled: boolean;
@ -94,11 +95,14 @@ interface PersonalizationState {
toolCategories: Array<{ id: string; label: string }>;
skillsCatalog: Array<{ id: string; label: string; description?: string }>;
recentConversationsPromptLimitRange: { min: number; max: number };
projectMemoryInjectLimitMin: number;
experiments: ExperimentState;
}
const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT = 10;
const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE = { min: 1, max: 30 };
const DEFAULT_PROJECT_MEMORY_INJECT_LIMIT = 20;
const PROJECT_MEMORY_INJECT_LIMIT_MIN = 5;
const DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80000;
const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000;
const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking'];
@ -215,6 +219,7 @@ const defaultForm = (): PersonalForm => ({
auto_generate_title: true,
recent_conversations_prompt_enabled: false,
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
tool_intent_enabled: true,
skill_hints_enabled: false,
skill_strict_terminal_enabled: false,
@ -338,6 +343,7 @@ export const usePersonalizationStore = defineStore('personalization', {
toolCategories: [],
skillsCatalog: [],
recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE },
projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN,
experiments: loadExperimentState()
}),
actions: {
@ -419,6 +425,9 @@ export const usePersonalizationStore = defineStore('personalization', {
recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
data.recent_conversations_prompt_limit
),
project_memory_inject_limit: this.normalizeProjectMemoryInjectLimit(
data.project_memory_inject_limit
),
tool_intent_enabled: !!data.tool_intent_enabled,
skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
@ -604,6 +613,25 @@ export const usePersonalizationStore = defineStore('personalization', {
}
return Math.max(min, Math.min(max, Math.round(parsed)));
},
normalizeProjectMemoryInjectLimit(value: any): number | null {
// undefined老接口未下发/非法值 → 默认 20null/''/0/负数 → null无上限正整数钳到 >= min
if (typeof value === 'undefined') {
return DEFAULT_PROJECT_MEMORY_INJECT_LIMIT;
}
if (value === null || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return DEFAULT_PROJECT_MEMORY_INJECT_LIMIT;
}
const rounded = Math.round(parsed);
if (rounded <= 0) {
return null;
}
const min = this.projectMemoryInjectLimitMin ?? PROJECT_MEMORY_INJECT_LIMIT_MIN;
return Math.max(min, rounded);
},
applyPersonalizationMeta(payload: any) {
if (payload && payload.recent_conversations_prompt_limit_range) {
const { min, max } = payload.recent_conversations_prompt_limit_range;
@ -616,6 +644,13 @@ export const usePersonalizationStore = defineStore('personalization', {
...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE
};
}
if (payload && payload.project_memory_inject_limit_range) {
const { min } = payload.project_memory_inject_limit_range;
this.projectMemoryInjectLimitMin =
typeof min === 'number' ? min : PROJECT_MEMORY_INJECT_LIMIT_MIN;
} else {
this.projectMemoryInjectLimitMin = PROJECT_MEMORY_INJECT_LIMIT_MIN;
}
if (payload && Array.isArray(payload.tool_categories)) {
this.toolCategories = payload.tool_categories
.map((item: { id?: string; label?: string } = {}) => ({
@ -786,6 +821,27 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback();
this.scheduleAutoSave();
},
setProjectMemoryInjectLimit(value: number | null) {
// null = 无上限
const target =
value === null || typeof value === 'undefined'
? null
: this.normalizeProjectMemoryInjectLimit(value);
this.form = {
...this.form,
project_memory_inject_limit: target
};
this.clearFeedback();
this.scheduleAutoSave();
},
restoreProjectMemoryInjectLimit() {
this.form = {
...this.form,
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT
};
this.clearFeedback();
this.scheduleAutoSave();
},
toggleDefaultToolCategory(categoryId: string) {
if (!categoryId) {
return;

View File

@ -97,6 +97,9 @@ class CompressionMixin:
"compression_error": error if in_progress else None,
"compression_resume_payload": resume_payload if in_progress else None,
"compression_job_id": job_id if in_progress else None,
# 记录发起压缩的进程 pid进程重启后该标记必然残留压缩随进程死亡
# status 读取侧据此做懒清理(见 deep_compression.heal_stale_compression_flag
"compression_pid": os.getpid() if in_progress else None,
}
for k, v in updates.items():
self.conversation_metadata[k] = v