diff --git a/prompt/guideline_prompt.md b/prompt/guideline_prompt.md index cd5eba2..510cc06 100644 --- a/prompt/guideline_prompt.md +++ b/prompt/guideline_prompt.md @@ -45,10 +45,12 @@ - **限制条件**: [需要遵守的规则和约束] - **可用资源**: [可以利用的工具和资源] -### ⚡ 行动计划 +### ⚡ 执行步骤 1. [第一步的具体行动] 2. [第二步的具体行动] 3. [第三步的具体行动] +4. [第四步的具体行动] +5. [第五步的具体行动] ## 输出语言 (Language) {language} diff --git a/public/admin.html b/public/admin.html index 65d0736..044f9da 100644 --- a/public/admin.html +++ b/public/admin.html @@ -477,6 +477,174 @@ margin-bottom: 1rem; opacity: 0.5; } + + /* 文件编辑器相关样式 */ + .editor-container { + border: 1px solid var(--border-color); + border-radius: 0.375rem; + overflow: hidden; + } + + .editor-wrapper { + display: flex; + position: relative; + } + + .line-numbers { + background-color: #f8f9fa; + border-right: 1px solid var(--border-color); + color: #6c757d; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; + padding: 12px 8px; + text-align: right; + user-select: none; + min-width: 40px; + overflow: hidden; + } + + .editor-container textarea { + border: none; + border-radius: 0; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; + resize: vertical; + min-height: 500px; + flex: 1; + padding: 12px; + tab-size: 4; + } + + .editor-container textarea:focus { + border: none; + box-shadow: none; + outline: none; + } + + /* 快捷键样式 */ + kbd { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + color: #495057; + display: inline-block; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + padding: 0.125rem 0.25rem; + white-space: nowrap; + } + + /* Markdown 预览样式 */ + .markdown-preview { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: var(--text-primary); + max-height: 600px; + overflow-y: auto; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 0.375rem; + } + + .markdown-preview h1, .markdown-preview h2, .markdown-preview h3, + .markdown-preview h4, .markdown-preview h5, .markdown-preview h6 { + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + } + + .markdown-preview h1 { font-size: 2rem; border-bottom: 2px solid #eee; padding-bottom: 0.5rem; } + .markdown-preview h2 { font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; } + .markdown-preview h3 { font-size: 1.25rem; } + .markdown-preview h4 { font-size: 1rem; } + .markdown-preview h5 { font-size: 0.875rem; } + .markdown-preview h6 { font-size: 0.75rem; } + + .markdown-preview p { + margin-bottom: 1rem; + } + + .markdown-preview ul, .markdown-preview ol { + margin-bottom: 1rem; + padding-left: 2rem; + } + + .markdown-preview li { + margin-bottom: 0.5rem; + } + + .markdown-preview code { + background-color: rgba(13, 110, 253, 0.1); + color: #d63384; + padding: 0.2rem 0.4rem; + border-radius: 0.25rem; + font-size: 0.875em; + } + + .markdown-preview pre { + background-color: #212529; + color: #f8f9fa; + padding: 1rem; + border-radius: 0.375rem; + overflow-x: auto; + margin-bottom: 1rem; + } + + .markdown-preview pre code { + background-color: transparent; + color: inherit; + padding: 0; + border-radius: 0; + font-size: inherit; + } + + .markdown-preview blockquote { + border-left: 4px solid var(--primary-color); + padding-left: 1rem; + margin: 1rem 0; + color: var(--text-secondary); + font-style: italic; + } + + .markdown-preview table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; + } + + .markdown-preview th, .markdown-preview td { + border: 1px solid #dee2e6; + padding: 0.75rem; + text-align: left; + } + + .markdown-preview th { + background-color: #f8f9fa; + font-weight: 600; + } + + /* 文件编辑状态指示器 */ + .edit-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + } + + .edit-status.modified { + color: var(--warning-color); + } + + .edit-status.saved { + color: var(--success-color); + } + + .edit-status.error { + color: var(--error-color); + } @@ -814,6 +982,99 @@ + + + + + + - + + + + + + diff --git a/routes/file_manager.py b/routes/file_manager.py index 17d73f8..3d28689 100644 --- a/routes/file_manager.py +++ b/routes/file_manager.py @@ -22,8 +22,46 @@ import math router = APIRouter(prefix="/api/v1/file-manager", tags=["file_management"]) -PROJECTS_DIR = Path("projects") -PROJECTS_DIR.mkdir(exist_ok=True) +# 支持多个目录:projects 和 prompt +PROJECTS_DIR = Path(".") +SUPPORTED_DIRECTORIES = ["projects", "prompt"] + + +def resolve_path(path: str) -> Path: + """解析路径,确保在允许的目录内""" + # 如果路径为空,默认显示支持的目录列表 + if not path: + return PROJECTS_DIR + + # 确定路径是否以支持的目录开头 + path_parts = Path(path).parts + + if not path_parts or path_parts[0] not in SUPPORTED_DIRECTORIES: + raise HTTPException( + status_code=400, + detail=f"路径必须以以下目录之一开头: {', '.join(SUPPORTED_DIRECTORIES)}" + ) + + target_path = PROJECTS_DIR / path + + # 规范化路径,防止目录遍历攻击 + try: + resolved_path = target_path.resolve() + except Exception: + raise HTTPException(status_code=400, detail="无效路径") + + # 检查路径是否在允许的目录内 + try: + allowed_root = PROJECTS_DIR.resolve() + # 检查解析后的路径是否仍在允许的目录内 + try: + resolved_path.relative_to(allowed_root) + except ValueError: + raise HTTPException(status_code=403, detail="访问被拒绝") + except Exception as e: + raise HTTPException(status_code=403, detail="访问被拒绝") + + return resolved_path @router.get("/list") @@ -36,7 +74,30 @@ async def list_files(path: str = "", recursive: bool = False): recursive: 是否递归列出所有子目录 """ try: - target_path = PROJECTS_DIR / path + # 如果路径为空,返回支持的目录列表 + if not path: + items = [] + for dir_name in SUPPORTED_DIRECTORIES: + dir_path = PROJECTS_DIR / dir_name + if dir_path.exists() and dir_path.is_dir(): + stat = dir_path.stat() + items.append({ + "name": dir_name, + "path": dir_name, + "type": "directory", + "size": 0, + "modified": stat.st_mtime, + "created": stat.st_ctime + }) + + return { + "success": True, + "path": "", + "items": items, + "total": len(items) + } + + target_path = resolve_path(path) if not target_path.exists(): raise HTTPException(status_code=404, detail="路径不存在") @@ -52,7 +113,13 @@ async def list_files(path: str = "", recursive: bool = False): if item.name.startswith('.'): continue - relative_path = item.relative_to(base_path) + # 计算相对于项目根目录的相对路径 + try: + relative_path = item.relative_to(PROJECTS_DIR) + except ValueError: + # 如果无法计算相对路径,使用绝对路径 + relative_path = item + stat = item.stat() item_info = { @@ -74,7 +141,7 @@ async def list_files(path: str = "", recursive: bool = False): pass return items - items = scan_directory(target_path) + items = scan_directory(target_path, Path(".")) return { "success": True, @@ -94,13 +161,15 @@ async def upload_file(file: UploadFile = File(...), path: str = Form("")): Args: file: 上传的文件 - path: 目标路径(相对于projects目录) + path: 目标路径(相对于支持目录) """ try: - target_dir = PROJECTS_DIR / path - target_dir.mkdir(parents=True, exist_ok=True) + target_path = resolve_path(path) if path else resolve_path("projects") + if not target_path.exists() or not target_path.is_dir(): + target_path = resolve_path("projects") + target_path.mkdir(parents=True, exist_ok=True) - file_path = target_dir / file.filename + file_path = target_path / file.filename # 如果文件已存在,检查是否覆盖 if file_path.exists(): @@ -132,7 +201,7 @@ async def download_file(file_path: str): file_path: 文件相对路径 """ try: - target_path = PROJECTS_DIR / file_path + target_path = resolve_path(file_path) if not target_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") @@ -164,7 +233,7 @@ async def delete_item(path: str): path: 要删除的路径 """ try: - target_path = PROJECTS_DIR / path + target_path = resolve_path(path) if not target_path.exists(): raise HTTPException(status_code=404, detail="路径不存在") @@ -194,7 +263,7 @@ async def create_folder(path: str, name: str): name: 新文件夹名称 """ try: - parent_path = PROJECTS_DIR / path + parent_path = resolve_path(path) if path else resolve_path("projects") parent_path.mkdir(parents=True, exist_ok=True) new_folder = parent_path / name @@ -224,7 +293,7 @@ async def rename_item(old_path: str, new_name: str): new_name: 新名称 """ try: - old_full_path = PROJECTS_DIR / old_path + old_full_path = resolve_path(old_path) if not old_full_path.exists(): raise HTTPException(status_code=404, detail="文件或目录不存在") @@ -236,11 +305,16 @@ async def rename_item(old_path: str, new_name: str): old_full_path.rename(new_full_path) + try: + new_relative_path = new_full_path.relative_to(PROJECTS_DIR) + except ValueError: + new_relative_path = new_full_path + return { "success": True, "message": "重命名成功", "old_path": old_path, - "new_path": str(new_full_path.relative_to(PROJECTS_DIR)) + "new_path": str(new_relative_path) } except Exception as e: @@ -257,8 +331,8 @@ async def move_item(source_path: str, target_path: str): target_path: 目标路径 """ try: - source_full_path = PROJECTS_DIR / source_path - target_full_path = PROJECTS_DIR / target_path + source_full_path = resolve_path(source_path) + target_full_path = resolve_path(target_path) if not source_full_path.exists(): raise HTTPException(status_code=404, detail="源文件或目录不存在") @@ -289,8 +363,8 @@ async def copy_item(source_path: str, target_path: str): target_path: 目标路径 """ try: - source_full_path = PROJECTS_DIR / source_path - target_full_path = PROJECTS_DIR / target_path + source_full_path = resolve_path(source_path) + target_full_path = resolve_path(target_path) if not source_full_path.exists(): raise HTTPException(status_code=404, detail="源文件或目录不存在") @@ -325,7 +399,7 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No file_type: 文件类型过滤 """ try: - search_path = PROJECTS_DIR / path + search_path = resolve_path(path) if path else Path(".") if not search_path.exists(): raise HTTPException(status_code=404, detail="搜索路径不存在") @@ -341,17 +415,24 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No # 检查文件名是否包含关键词 if query.lower() in item.name.lower(): # 检查文件类型过滤 + # 计算相对于项目根目录的相对路径 + try: + relative_path = item.relative_to(PROJECTS_DIR) + except ValueError: + # 如果无法计算相对路径,使用绝对路径 + relative_path = item + if file_type: if item.suffix.lower() == file_type.lower(): results.append({ "name": item.name, - "path": str(item.relative_to(PROJECTS_DIR)), + "path": str(relative_path), "type": "directory" if item.is_dir() else "file" }) else: results.append({ "name": item.name, - "path": str(item.relative_to(PROJECTS_DIR)), + "path": str(relative_path), "type": "directory" if item.is_dir() else "file" }) @@ -376,6 +457,160 @@ async def search_files(query: str, path: str = "", file_type: Optional[str] = No raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}") +@router.get("/read") +async def read_file(path: str = Query(...)): + """ + 读取文件内容 + + Args: + path: 文件相对路径 + """ + try: + target_path = resolve_path(path) + + if not target_path.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + + if not target_path.is_file(): + raise HTTPException(status_code=400, detail="路径不是文件") + + # 检查文件大小,限制读取10MB以内的文件 + file_size = target_path.stat().st_size + max_size = 10 * 1024 * 1024 # 10MB + + if file_size > max_size: + raise HTTPException( + status_code=413, + detail=f"文件过大 ({formatFileSize(file_size)}),最大支持 {formatFileSize(max_size)}" + ) + + # 尝试不同的编码方式 + content = None + encoding_used = None + + for encoding in ['utf-8', 'gbk', 'gb2312', 'latin-1']: + try: + with open(target_path, 'r', encoding=encoding) as f: + content = f.read() + encoding_used = encoding + break + except UnicodeDecodeError: + continue + + if content is None: + # 如果所有编码都失败,尝试以二进制方式读取并转换为可打印字符 + try: + with open(target_path, 'rb') as f: + binary_content = f.read() + content = binary_content.decode('utf-8', errors='replace') + encoding_used = 'utf-8 (with replacement)' + except Exception as e: + raise HTTPException(status_code=400, detail=f"无法读取文件内容: {str(e)}") + + # 获取文件信息 + stat = target_path.stat() + mime_type, _ = mimetypes.guess_type(str(target_path)) + + return { + "success": True, + "content": content, + "path": path, + "size": file_size, + "modified": stat.st_mtime, + "encoding": encoding_used, + "mime_type": mime_type or "unknown" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"读取文件失败: {str(e)}") + + +@router.post("/save") +async def save_file(request: Dict[str, str]): + """ + 保存文件内容 + + Args: + request: 包含path和content字段的JSON对象 + """ + try: + logger.info(f"收到保存请求: {request}") + + path = request.get("path") + content = request.get("content") + + logger.info(f"解析参数: path={path}, content length={len(content) if content else 'None'}") + + if not path or content is None: + raise HTTPException(status_code=400, detail="缺少必需的参数: path 和 content") + + target_path = resolve_path(path) + + # 确保父目录存在 + target_path.parent.mkdir(parents=True, exist_ok=True) + + # 检查文件大小,限制保存5MB以内的内容 + content_size = len(content.encode('utf-8')) + max_size = 5 * 1024 * 1024 # 5MB + + if content_size > max_size: + raise HTTPException( + status_code=413, + detail=f"内容过大 ({formatFileSize(content_size)}),最大支持 {formatFileSize(max_size)}" + ) + + # 创建备份(如果文件已存在) + backup_path = None + if target_path.exists(): + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = target_path.with_suffix(f".{timestamp}.bak") + try: + shutil.copy2(target_path, backup_path) + except Exception as e: + logger.warning(f"Failed to create backup: {e}") + + # 写入文件 + try: + with open(target_path, 'w', encoding='utf-8') as f: + f.write(content) + except Exception as e: + # 如果写入失败,恢复备份 + if backup_path and backup_path.exists(): + try: + shutil.copy2(backup_path, target_path) + backup_path.unlink() # 删除备份文件 + except: + pass + raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}") + + # 如果保存成功,删除备份文件 + if backup_path and backup_path.exists(): + try: + backup_path.unlink() + except: + logger.warning(f"Failed to remove backup file: {backup_path}") + + # 获取保存后的文件信息 + stat = target_path.stat() + + return { + "success": True, + "message": "文件保存成功", + "path": path, + "size": stat.st_size, + "modified": stat.st_mtime, + "encoding": "utf-8" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"保存文件失败: {str(e)}") + + @router.get("/info/{file_path:path}") async def get_file_info(file_path: str): """ @@ -385,7 +620,7 @@ async def get_file_info(file_path: str): file_path: 文件路径 """ try: - target_path = PROJECTS_DIR / file_path + target_path = resolve_path(file_path) if not target_path.exists(): raise HTTPException(status_code=404, detail="路径不存在") @@ -439,7 +674,7 @@ async def download_folder_as_zip(request: Dict[str, str]): if not folder_path: raise HTTPException(status_code=400, detail="路径不能为空") - target_path = PROJECTS_DIR / folder_path + target_path = resolve_path(folder_path) if not target_path.exists(): raise HTTPException(status_code=404, detail="文件夹不存在") @@ -541,7 +776,7 @@ async def download_multiple_items_as_zip(request: Dict[str, Any]): file_count = 0 for path in paths: - target_path = PROJECTS_DIR / path + target_path = resolve_path(path) if not target_path.exists(): continue # 跳过不存在的文件