Compare commits
5 Commits
99f4d3d1dd
...
4f940e9a86
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f940e9a86 | |||
| 56c067b710 | |||
| 16900b0a20 | |||
| 34bb5d61cc | |||
| ff927d6739 |
@ -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()
|
||||
|
||||
@ -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 避免触发后台完成通知轮询,保持主对话输入区可用。
|
||||
|
||||
@ -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(未设置,下游默认 50);0 → 无上限;正整数 → 该值。"""
|
||||
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 = []
|
||||
|
||||
@ -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,仅传统模式生效):
|
||||
# 未设置 → 默认 50;0(或负数)→ 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
|
||||
|
||||
|
||||
@ -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 读全文。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 是子任务 id,sender 的 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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(压缩随进程死亡,不可能仍在进行),会导致前端误以为仍在压缩。
|
||||
判定依据:标记写入时记录了发起进程的 pid(compression_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,
|
||||
|
||||
206
server/files.py
206
server/files.py
@ -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
|
||||
|
||||
@ -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:写入显式 null(sanitize 会保留 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)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
})();
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
});
|
||||
})();
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),不再存在对话级全局锁。
|
||||
|
||||
// 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。
|
||||
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
@ -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=true(taskInProgress=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: '对话自动压缩中',
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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: '当前对话正在压缩,请稍后再试',
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -135,6 +135,9 @@ export function dataState() {
|
||||
// 对话压缩状态
|
||||
compressing: false,
|
||||
compressionInProgress: false,
|
||||
// 正在压缩的对话 id:压缩锁只作用于该对话本身,不影响其他对话与 /new 新建页
|
||||
// (多对话独立运行后,压缩不再是对话级全局锁)。
|
||||
compressionConversationId: null,
|
||||
compressionMode: '',
|
||||
compressionStage: '',
|
||||
compressionError: '',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)变化,
|
||||
// 工具1完成切到工具2时文字有 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 = () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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/'' = 默认 50;0 = 无上限;正整数 = 该值 */
|
||||
/** 子智能体最大执行轮次(仅传统后台子智能体;多智能体成员不受限):null/'' = 默认 50;0 = 无上限;正整数 = 该值 */
|
||||
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) {
|
||||
|
||||
@ -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(老接口未下发)/非法值 → 默认 20;null/''/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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user