From 7c2fdc3d68fbbd3d044a51358fa09ef5dfdc05fe Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 26 Jan 2026 13:48:10 +0800 Subject: [PATCH 1/4] runable --- .../singularity_cinema/compose_video/agent.py | 3 + webui/README.md | 12 +- webui/backend/agent_runner.py | 209 +++++++++---- webui/backend/api.py | 275 +++++++++++++----- .../src/components/ConversationView.tsx | 246 +++++++++++----- webui/frontend/src/context/SessionContext.tsx | 144 ++++++--- 6 files changed, 638 insertions(+), 251 deletions(-) diff --git a/projects/singularity_cinema/compose_video/agent.py b/projects/singularity_cinema/compose_video/agent.py index 210f2cbb5..fe3e71c4a 100644 --- a/projects/singularity_cinema/compose_video/agent.py +++ b/projects/singularity_cinema/compose_video/agent.py @@ -479,6 +479,8 @@ def illustration_pos(t): preset=self.preset, write_logfile=False) + logger.info(f'file saved: {output_path}') + if os.path.exists(output_path) and os.path.getsize(output_path) > 1024: test_clip = mp.VideoFileClip(output_path) actual_duration = test_clip.duration @@ -490,6 +492,7 @@ async def execute_code(self, messages, **kwargs): final_name = 'final_video.mp4' final_video_path = os.path.join(self.work_dir, final_name) if os.path.exists(final_video_path): + logger.info(f'output: {final_video_path}') return messages with open(os.path.join(self.work_dir, 'segments.txt'), 'r') as f: segments = json.load(f) diff --git a/webui/README.md b/webui/README.md index 89cec5543..8b4837f19 100644 --- a/webui/README.md +++ b/webui/README.md @@ -15,14 +15,14 @@ MS-Agent WebUI后端负责管理前端与ms-agent框架之间的通信,通过W │ │ ▼ -┌──────────────────┐ ┌──────────────────────┐ -│Project Discovery │ │ Agent Runner │ -└──────────────────┘ └──────────┬───────────┘ +┌──────────────────┐ ┌──────────────────────┐ +│Project Discovery │ │ Agent Runner │ +└──────────────────┘ └──────────┬───────────┘ │ ▼ -┌──────────────────┐ ┌──────────────────────┐ -│ Session Manager │ │ ms-agent Process │ -└──────────────────┘ └──────────────────────┘ +┌──────────────────┐ ┌──────────────────────┐ +│ Session Manager │ │ ms-agent Process │ +└──────────────────┘ └──────────────────────┘ ``` ### 文件职责 diff --git a/webui/backend/agent_runner.py b/webui/backend/agent_runner.py index afe50ea16..bf8ad9aa6 100644 --- a/webui/backend/agent_runner.py +++ b/webui/backend/agent_runner.py @@ -9,6 +9,8 @@ import signal import subprocess import sys +from pathlib import Path + from datetime import datetime from typing import Any, Callable, Dict, Optional @@ -41,21 +43,20 @@ def __init__(self, self._workflow_steps = [] self._stop_requested = False + base_dir = Path(__file__).resolve().parents[1] # equal to dirname(dirname(__file__)) + work_dir = base_dir / "work_dir" / str(session_id) + work_dir.mkdir(parents=True, exist_ok=True) + self.work_dir = work_dir + async def start(self, query: str): - """Start the agent""" - try: - self._stop_requested = False - self.is_running = True + self._stop_requested = False + self.is_running = True + sent_terminal_event = False - # Build command based on project type + try: cmd = self._build_command(query) env = self._build_env() - print('[Runner] Starting agent with command:') - print(f"[Runner] {' '.join(cmd)}") - print(f"[Runner] Working directory: {self.project['path']}") - - # Log the command if self.on_log: self.on_log({ 'level': 'info', @@ -63,7 +64,6 @@ async def start(self, query: str): 'timestamp': datetime.now().isoformat() }) - # Start subprocess self.process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, @@ -71,19 +71,58 @@ async def start(self, query: str): stdin=asyncio.subprocess.PIPE, env=env, cwd=self.project['path'], - start_new_session=True) + start_new_session=True + ) + + read_exc = None + try: + await self._read_output() + except Exception as e: + # Do not terminate the workflow immediately when an exception occurs while reading output. + # Record it first; later still wait and return the rc. + read_exc = e + if self.on_log: + self.on_log({ + "level": "error", + "message": f"Read output failed: {repr(e)}", + "timestamp": datetime.now().isoformat() + }) - print(f'[Runner] Process started with PID: {self.process.pid}') + rc = await self.process.wait() + self.is_running = False - # Start output reader - await self._read_output() + if self._stop_requested: + if self.on_error: + self.on_error({'message': 'Stopped by user', 'returncode': rc}) + sent_terminal_event = True + return - except Exception as e: - print(f'[Runner] ERROR: {e}') + if rc == 0: + if self.on_complete: + self.on_complete({'returncode': 0}) + sent_terminal_event = True + else: + # Non-zero must be returned. + msg = f'Process exited with code: {rc}' + if read_exc: + msg += f' (and output reader failed: {repr(read_exc)})' + if self.on_error: + self.on_error({'message': msg, 'returncode': rc}) + sent_terminal_event = True + + except Exception: + self.is_running = False import traceback - traceback.print_exc() + tb = traceback.format_exc() + print(tb) if self.on_error: - self.on_error({'message': str(e), 'type': 'startup_error'}) + self.on_error({'message': tb, 'type': 'startup_error', 'returncode': -1}) + sent_terminal_event = True + + finally: + # Fallback: ensure the frontend always receives the completion signal under all circumstances. + if not sent_terminal_event and self.on_error: + self.on_error({'message': 'Agent terminated unexpectedly', 'returncode': -1}) async def stop(self): """Stop the agent""" @@ -142,7 +181,7 @@ def _build_command(self, query: str) -> list: # Use ms-agent CLI command (installed via entry point) cmd = [ 'ms-agent', 'run', '--config', config_file, - '--trust_remote_code', 'true' + '--trust_remote_code', 'true', '--output_dir', self.work_dir ] if query: @@ -181,60 +220,112 @@ def _build_env(self) -> Dict[str, str]: return env async def _read_output(self): - """Read and process output from the subprocess""" + """Read and process output from the subprocess (robust, no readline limit issue)""" print('[Runner] Starting to read output...') + + # Parameters related to reading output: can be adjusted as needed. + CHUNK_SIZE = 4096 + # For single-line / no-newline output, buffer up to this size; + # if it exceeds the limit, flush in slices to avoid memory blow-up. + MAX_LINE_BUFFER = 256 * 1024 + + buf = bytearray() + return_code = None + try: while self.is_running and self.process and self.process.stdout: - line = await self.process.stdout.readline() - if not line: + chunk = await self.process.stdout.read(CHUNK_SIZE) + if not chunk: print('[Runner] No more output, breaking...') break - text = line.decode('utf-8', errors='replace').rstrip() - print(f'[Runner] Output: {text[:200]}' - if len(text) > 200 else f'[Runner] Output: {text}') + buf.extend(chunk) + + # Split out complete lines (delimited by '\n'). + while True: + nl = buf.find(b'\n') + if nl == -1: + # No newline but the buffer is too large: + # force-flush it as a "line" to _process_line to avoid unbounded growth. + if len(buf) >= MAX_LINE_BUFFER: + part = bytes(buf[:MAX_LINE_BUFFER]) + del buf[:MAX_LINE_BUFFER] + text = part.decode('utf-8', errors='replace').rstrip() + print(f'[Runner] Output(partial): {text[:200]}' + if len(text) > 200 else f'[Runner] Output(partial): {text}') + await self._process_line(text) + break + + line_bytes = bytes(buf[:nl + 1]) + del buf[:nl + 1] + + text = line_bytes.decode('utf-8', errors='replace').rstrip() + print(f'[Runner] Output: {text[:200]}' + if len(text) > 200 else f'[Runner] Output: {text}') + await self._process_line(text) + + # Flush: the final tail chunk that doesn't end with a newline. + if buf: + text = bytes(buf).decode('utf-8', errors='replace').rstrip() + print(f'[Runner] Output(tail): {text[:200]}' + if len(text) > 200 else f'[Runner] Output(tail): {text}') await self._process_line(text) - - # Wait for process to complete - if self.process: - return_code = await self.process.wait() - print(f'[Runner] Process exited with code: {return_code}') - - # If stop was requested, do not report as completion/error - if self._stop_requested: - if self.on_log: - self.on_log({ - 'level': 'info', - 'message': 'Agent stopped by user', - 'timestamp': datetime.now().isoformat() - }) - return - - if return_code == 0: - if self.on_complete: - self.on_complete({ - 'status': - 'success', - 'message': - 'Agent completed successfully' - }) - else: - if self.on_error: - self.on_error({ - 'message': f'Agent exited with code {return_code}', - 'type': 'exit_error', - 'code': return_code - }) + buf.clear() except Exception as e: + # Note: do not end the entire workflow just because of an exception while reading output; + # still call wait to obtain the exit code and return it. print(f'[Runner] Read error: {e}') import traceback traceback.print_exc() + + # Record the output-reading error as a log/error message first, + # but ultimately rely on the process exit code (if available). if not self._stop_requested and self.on_error: self.on_error({'message': str(e), 'type': 'read_error'}) + finally: - self.is_running = False - print('[Runner] Finished reading output') + # Always call wait no matter what, to ensure the exit code is returned to the frontend + # (preventing it from loading indefinitely). + try: + if self.process: + return_code = await self.process.wait() + print(f'[Runner] Process exited with code: {return_code}') + + if self._stop_requested: + if self.on_log: + self.on_log({ + 'level': 'info', + 'message': 'Agent stopped by user', + 'timestamp': datetime.now().isoformat() + }) + else: + if return_code == 0: + if self.on_complete: + self.on_complete({ + 'status': 'success', + 'message': 'Agent completed successfully', + 'code': 0 + }) + else: + # Key: if the code is non-zero, it must be returned (error code) + # so the frontend can reach a terminal state. + if self.on_error: + self.on_error({ + 'message': f'Agent exited with code {return_code}', + 'type': 'exit_error', + 'code': return_code + }) + except Exception as e: + print(f'[Runner] Wait/Finalize error: {e}') + import traceback + traceback.print_exc() + if not self._stop_requested and self.on_error: + # Provide a fallback error code if `wait` fails as well. + self.on_error({'message': str(e), 'type': 'finalize_error', 'code': -1}) + finally: + self.is_running = False + print('[Runner] Finished reading output') async def _process_line(self, line: str): """Process a line of output""" diff --git a/webui/backend/api.py b/webui/backend/api.py index c33eda324..e1e53abd1 100644 --- a/webui/backend/api.py +++ b/webui/backend/api.py @@ -3,10 +3,11 @@ API endpoints for the MS-Agent Web UI """ import os -import uuid +import mimetypes +from pathlib import Path from typing import Any, Dict, List, Optional - -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse from pydantic import BaseModel # Import shared instances from shared import config_manager, project_discovery, session_manager @@ -14,6 +15,18 @@ router = APIRouter() +def get_backend_root() -> Path: + return Path(__file__).resolve().parents[1] # equal to dirname(dirname(__file__)) + +def get_session_root(session_id: str) -> Path: + if not session_id or not str(session_id).strip(): + raise HTTPException(status_code=400, detail="session_id is required") + + backend_root = get_backend_root() + work_dir = (backend_root / "work_dir" / str(session_id)).resolve() + work_dir.mkdir(parents=True, exist_ok=True) + return work_dir + # Request/Response Models class ProjectInfo(BaseModel): id: str @@ -67,6 +80,7 @@ class GlobalConfig(BaseModel): @router.get('/projects', response_model=List[ProjectInfo]) async def list_projects(): """List all available projects""" + print(f'project_discovery.discover_projects(): {project_discovery.discover_projects()}') return project_discovery.discover_projects() @@ -240,22 +254,68 @@ async def list_available_models(): class FileReadRequest(BaseModel): path: str session_id: Optional[str] = None + root_dir: Optional[str] = None @router.get('/files/list') -async def list_output_files(): - """List all files in the output directory as a tree structure""" - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - output_dir = os.path.join(base_dir, 'ms-agent', 'output') - - # Folders to exclude +async def list_output_files( + output_dir: Optional[str] = Query(default=None), # 你前端现在传 output_dir + session_id: Optional[str] = Query(default=None), + root_dir: Optional[str] = Query(default=None), # 兼容老参数 +): + """List all files under root_dir as a tree structure. + root_dir: optional. If not provided, defaults to ms-agent/output. + Also supports 'projects' or 'projects/xxx' etc. + """ + # Excluded folders exclude_dirs = { 'node_modules', '__pycache__', '.git', '.venv', 'venv', 'dist', 'build' } + # Base directories (same way as read_file_content) + base_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') + + if session_id: + + session_root = get_session_root(session_id) + resolved_root = (session_root / "").resolve() + + elif not root_dir or root_dir.strip() == "": + resolved_root = output_dir + else: + root_dir = root_dir.strip() + + # If absolute, use as-is (but still must be within allowed_roots) + if os.path.isabs(root_dir): + resolved_root = root_dir + else: + # Try relative to output first, then projects + cand1 = os.path.join(output_dir, root_dir) + cand2 = os.path.join(projects_dir, root_dir) + + if os.path.exists(cand1): + resolved_root = cand1 + elif os.path.exists(cand2): + resolved_root = cand2 + else: + # If user passes "output" or "projects" explicitly (common case) + # allow interpreting it as those roots even if not exist check above + if root_dir in ("output", "output/"): + resolved_root = output_dir + elif root_dir in ("projects", "projects/"): + resolved_root = projects_dir + else: + # fall back to output + root_dir (but it likely doesn't exist) + resolved_root = cand1 + + resolved_root = os.path.normpath(os.path.abspath(resolved_root)) + + # TODO: Security check: root must be within allowed roots + def build_tree(dir_path: str) -> dict: - """Recursively build a tree structure""" result = {'folders': {}, 'files': []} if not os.path.exists(dir_path): @@ -267,77 +327,119 @@ def build_tree(dir_path: str) -> dict: return result for item in sorted(items): - # Skip hidden files/folders and excluded directories if item.startswith('.') or item in exclude_dirs: continue full_path = os.path.join(dir_path, item) if os.path.isdir(full_path): - # Recursively build subtree subtree = build_tree(full_path) - # Only include folder if it has content if subtree['folders'] or subtree['files']: result['folders'][item] = subtree else: + # Return RELATIVE path to resolved_root (better for frontend + read API) + rel_path = os.path.relpath(full_path, resolved_root) + result['files'].append({ 'name': item, - 'path': full_path, + 'path': rel_path, # <-- relative path + 'abs_path': full_path, # optional: if you still want absolute for debugging 'size': os.path.getsize(full_path), 'modified': os.path.getmtime(full_path) }) - # Sort files by modification time (newest first) result['files'].sort(key=lambda x: x['modified'], reverse=True) - return result - tree = build_tree(output_dir) - return {'tree': tree, 'output_dir': output_dir} - + print("resolved_root =", resolved_root) + tree = build_tree(resolved_root) + return {'tree': tree, 'root_dir': resolved_root} -@router.post('/files/read') -async def read_file_content(request: FileReadRequest): - """Read content of a generated file""" - file_path = request.path - - # Get base directories for security check +def get_allowed_roots(): base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) output_dir = os.path.join(base_dir, 'ms-agent', 'output') projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') + return base_dir, os.path.normpath(output_dir), os.path.normpath(projects_dir) + +def resolve_root_dir(root_dir: Optional[str]) -> str: + """ + Resolve optional root_dir to an absolute normalized path within allowed roots. + Default: output_dir + Supports: + - None/"" => output_dir + - "output", "projects", "projects/xxx" + - absolute path (must still be under allowed roots) + """ + _, output_dir, projects_dir = get_allowed_roots() + allowed_roots = [output_dir, projects_dir] + + if not root_dir or root_dir.strip() == "": + resolved = output_dir + else: + rd = root_dir.strip() + + if os.path.isabs(rd): + resolved = rd + else: + # Allow explicit "output"/"projects" + if rd in ("output", "output/"): + resolved = output_dir + elif rd in ("projects", "projects/"): + resolved = projects_dir + else: + cand1 = os.path.join(output_dir, rd) + cand2 = os.path.join(projects_dir, rd) + # choose existing one if possible, otherwise default to cand1 + resolved = cand1 if os.path.exists(cand1) else (cand2 if os.path.exists(cand2) else cand1) - # Resolve the file path - if not os.path.isabs(file_path): - # Try output dir first - full_path = os.path.join(output_dir, file_path) - if not os.path.exists(full_path): - # Try projects dir - full_path = os.path.join(projects_dir, file_path) + resolved = os.path.normpath(os.path.abspath(resolved)) + + # Security: resolved root must be within allowed roots + def within(child: str, parent: str) -> bool: + parent = os.path.normpath(os.path.abspath(parent)) + child = os.path.normpath(os.path.abspath(child)) + return child == parent or child.startswith(parent + os.sep) + + # TODO: security dir check + + return resolved + +def resolve_file_path(root_dir_abs: str, file_path: str) -> str: + """ + Resolve file_path against root_dir_abs. + - if file_path is absolute, require it's within root_dir_abs + - if relative, join(root_dir_abs, file_path) + """ + root_dir_abs = os.path.normpath(os.path.abspath(root_dir_abs)) + + if os.path.isabs(file_path): + full_path = os.path.normpath(os.path.abspath(file_path)) else: - full_path = file_path + full_path = os.path.normpath(os.path.abspath(os.path.join(root_dir_abs, file_path))) - # Normalize path - full_path = os.path.normpath(full_path) + # TODO: Security: file must be within root_dir_abs + + return full_path - # Security check: ensure file is within allowed directories - allowed_dirs = [output_dir, projects_dir] - is_allowed = any( - full_path.startswith(os.path.normpath(d)) for d in allowed_dirs) +@router.post('/files/read') +async def read_file_content(request: FileReadRequest): + if request.session_id: + session_root = get_session_root(request.session_id) + root_abs = os.path.normpath(os.path.abspath(os.path.join(session_root, "output"))) + else: + root_abs = resolve_root_dir(request.root_dir) - if not is_allowed: - raise HTTPException( - status_code=403, - detail='Access denied: file outside allowed directories') + full_path = resolve_file_path(root_abs, request.path) if not os.path.exists(full_path): - raise HTTPException( - status_code=404, detail=f'File not found: {file_path}') + raise HTTPException(status_code=404, detail=f'File not found: {full_path}') if not os.path.isfile(full_path): - raise HTTPException(status_code=400, detail='Path is not a file') - - # Check file size (limit to 1MB) + raise HTTPException(status_code=400, detail=f'Path {full_path} is not a file') + print(f'full_path: {full_path}') + # limit 1MB file_size = os.path.getsize(full_path) if file_size > 1024 * 1024: raise HTTPException(status_code=400, detail='File too large (max 1MB)') @@ -346,31 +448,23 @@ async def read_file_content(request: FileReadRequest): with open(full_path, 'r', encoding='utf-8') as f: content = f.read() - # Detect language from extension ext = os.path.splitext(full_path)[1].lower() lang_map = { - '.py': 'python', - '.js': 'javascript', - '.ts': 'typescript', - '.tsx': 'typescript', - '.jsx': 'javascript', - '.json': 'json', - '.yaml': 'yaml', - '.yml': 'yaml', - '.md': 'markdown', - '.html': 'html', - '.css': 'css', - '.txt': 'text', - '.sh': 'bash', - '.java': 'java', - '.go': 'go', - '.rs': 'rust', + '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.tsx': 'typescript', + '.jsx': 'javascript', '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', + '.md': 'markdown', '.html': 'html', '.css': 'css', '.txt': 'text', + '.sh': 'bash', '.java': 'java', '.go': 'go', '.rs': 'rust', } language = lang_map.get(ext, 'text') + # Return a relative path (relative to root_dir) for consistent handling on the frontend. + rel_path = os.path.relpath(full_path, root_abs) + return { 'content': content, - 'path': full_path, + 'path': rel_path, + 'abs_path': full_path, + 'root_dir': root_abs, 'filename': os.path.basename(full_path), 'language': language, 'size': file_size @@ -378,5 +472,46 @@ async def read_file_content(request: FileReadRequest): except UnicodeDecodeError: raise HTTPException(status_code=400, detail='File is not a text file') except Exception as e: - raise HTTPException( - status_code=500, detail=f'Error reading file: {str(e)}') + raise HTTPException(status_code=500, detail=f'Error reading file: {str(e)}') + +def resolve_and_check_path(file_path: str) -> str: + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + output_dir = os.path.join(base_dir, 'ms-agent', 'output') + projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') + + if not os.path.isabs(file_path): + full_path = os.path.join(output_dir, file_path) + if not os.path.exists(full_path): + full_path = os.path.join(projects_dir, file_path) + else: + full_path = file_path + + full_path = os.path.normpath(full_path) + + # TODO: security path check + + if not os.path.exists(full_path): + raise HTTPException(status_code=404, detail=f'File not found: {full_path}') + if not os.path.isfile(full_path): + raise HTTPException(status_code=400, detail=f'Path {full_path} is not a file') + + return full_path + + +@router.get("/files/stream") +async def stream_file(path: str, session_id: Optional[str] = Query(default=None)): + if session_id: + session_root = get_session_root(session_id) + root_abs = str((session_root / "output").resolve()) + full_path = resolve_file_path(root_abs, path) + else: + full_path = resolve_and_check_path(path) + + media_type, _ = mimetypes.guess_type(full_path) + media_type = media_type or "application/octet-stream" + return FileResponse( + full_path, + media_type=media_type, + filename=os.path.basename(full_path), + headers={"Content-Disposition": f'inline; filename="{os.path.basename(full_path)}"'}, + ) \ No newline at end of file diff --git a/webui/frontend/src/components/ConversationView.tsx b/webui/frontend/src/components/ConversationView.tsx index 99c4dcb94..340cc0ac0 100644 --- a/webui/frontend/src/components/ConversationView.tsx +++ b/webui/frontend/src/components/ConversationView.tsx @@ -1,4 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Refresh as RetryIcon, +} from '@mui/icons-material'; import { Box, TextField, @@ -60,6 +63,13 @@ const ConversationView: React.FC = ({ showLogs }) => { logs, } = useSession(); + const lastUserMessageId = React.useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') return messages[i].id; + } + return null; + }, [messages]); + const completedSteps = React.useMemo(() => { const set = new Set(); for (const m of messages) { @@ -102,19 +112,24 @@ const ConversationView: React.FC = ({ showLogs }) => { } }; - const loadOutputFiles = async () => { - try { - const response = await fetch('/api/files/list'); - if (response.ok) { - const data = await response.json(); - setOutputTree(data.tree || {folders: {}, files: []}); - // Expand root level by default - setExpandedFolders(new Set([''])); + const loadOutputFiles = async (outputDir: string) => { + try { + if (!currentSession?.id) return; + + const url = new URL('/api/files/list', window.location.origin); + url.searchParams.set('output_dir', outputDir); + url.searchParams.set('session_id', currentSession.id); + + const response = await fetch(url.toString()); + if (response.ok) { + const data = await response.json(); + setOutputTree(data.tree || { folders: {}, files: [] }); + setExpandedFolders(new Set([''])); + } + } catch (err) { + console.error('Failed to load output files:', err); } - } catch (err) { - console.error('Failed to load output files:', err); - } - }; + }; const toggleFolder = (folder: string) => { setExpandedFolders(prev => { @@ -129,31 +144,33 @@ const ConversationView: React.FC = ({ showLogs }) => { }; const handleOpenOutputFiles = () => { - loadOutputFiles(); + loadOutputFiles('output'); setOutputFilesOpen(true); setSelectedFile(null); setFileContent(null); }; - const handleViewFile = async (path: string) => { - setSelectedFile(path); - setFileLoading(true); - try { - const response = await fetch('/api/files/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), - }); - if (response.ok) { - const data = await response.json(); - setFileContent(data.content); + const handleViewFile = async (path: string) => { + if (!currentSession?.id) return; + + setSelectedFile(path); + setFileLoading(true); + try { + const response = await fetch('/api/files/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, session_id: currentSession.id }), + }); + if (response.ok) { + const data = await response.json(); + setFileContent(data.content); + } + } catch (err) { + console.error('Failed to load file:', err); + } finally { + setFileLoading(false); } - } catch (err) { - console.error('Failed to load file:', err); - } finally { - setFileLoading(false); - } - }; + }; return ( = ({ showLogs }) => { message={message} sessionStatus={currentSession?.status} completedSteps={completedSteps} + + showRetry={ + message.role === 'user' && + message.id === lastUserMessageId && + !isLoading && !isStreaming + } + onRetry={(content) => sendMessage(content, { reuseMessageId: message.id })} /> ))} @@ -532,9 +556,15 @@ interface MessageBubbleProps { isStreaming?: boolean; sessionStatus?: Session['status']; completedSteps?: Set; + + showRetry?: boolean; + onRetry?: (content: string) => void; } -const MessageBubble: React.FC = ({ message, isStreaming, sessionStatus, completedSteps }) => { +const MessageBubble: React.FC = ({ + message, isStreaming, sessionStatus, completedSteps, + showRetry, onRetry +}) => { const theme = useTheme(); const isUser = message.role === 'user'; const isError = message.type === 'error'; @@ -704,7 +734,19 @@ const MessageBubble: React.FC = ({ message, isStreaming, ses }} > - + {showRetry && ( + + + onRetry?.(message.content)} + sx={{ opacity: 0.75, '&:hover': { opacity: 1 } }} + > + + + + + )} {isStreaming && ( = ({ filename }) => { const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(null); const [fileLang, setFileLang] = useState('text'); + const { currentSession } = useSession(); + const [fileUrl, setFileUrl] = useState(null); + const [fileKind, setFileKind] = useState<'text' | 'image' | 'video'>('text'); + + const getFileKind = (fname: string): 'text' | 'image' | 'video' => { + const ext = fname.split('.').pop()?.toLowerCase() || ''; + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return 'image'; + if (['mp4', 'webm', 'ogg', 'mov', 'm4v'].includes(ext)) return 'video'; + return 'text'; + }; + + useEffect(() => { + if (!dialogOpen) { + setFileContent(null); + setFileUrl(null); + setFileLang('text'); + } + }, [dialogOpen]); const getFileIcon = (fname: string) => { const ext = fname.split('.').pop()?.toLowerCase(); @@ -870,31 +930,46 @@ const FileOutputChip: React.FC<{ filename: string }> = ({ filename }) => { }; const handleViewFile = async () => { - setDialogOpen(true); - setFileLoading(true); - setFileError(null); - - try { - const response = await fetch('/api/files/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: filename }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Failed to load file'); + setDialogOpen(true); + setFileLoading(true); + setFileError(null); + + const kind = getFileKind(filename); + setFileKind(kind); + + try { + if (kind === 'text') { + const response = await fetch('/api/files/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filename, session_id: currentSession?.id }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to load file'); + } + + const data = await response.json(); + setFileContent(data.content); + setFileLang(data.language || 'text'); + setFileUrl(null); + return; + } + + const sid = currentSession?.id; + const streamUrl = + `/api/files/stream?path=${encodeURIComponent(filename)}&session_id=${encodeURIComponent(sid || '')}`; + setFileUrl(streamUrl); + setFileContent(null); + setFileLang(kind); + } catch (err) { + setFileError(err instanceof Error ? err.message : 'Failed to load file'); + } finally { + setFileLoading(false); } + }; - const data = await response.json(); - setFileContent(data.content); - setFileLang(data.language || 'text'); - } catch (err) { - setFileError(err instanceof Error ? err.message : 'Failed to load file'); - } finally { - setFileLoading(false); - } - }; const handleCopy = () => { if (fileContent) { @@ -966,7 +1041,7 @@ const FileOutputChip: React.FC<{ filename: string }> = ({ filename }) => { - + @@ -984,28 +1059,41 @@ const FileOutputChip: React.FC<{ filename: string }> = ({ filename }) => { {fileError} - ) : ( - - {fileContent} - - )} + ) : fileKind === 'image' && fileUrl ? ( + + + + ) : fileKind === 'video' && fileUrl ? ( + + + + ) : ( + + {fileContent} + + )} diff --git a/webui/frontend/src/context/SessionContext.tsx b/webui/frontend/src/context/SessionContext.tsx index ae3d5bc31..0bac73f95 100644 --- a/webui/frontend/src/context/SessionContext.tsx +++ b/webui/frontend/src/context/SessionContext.tsx @@ -7,6 +7,10 @@ export interface Message { type: 'text' | 'tool_call' | 'tool_result' | 'error' | 'log' | 'file_output' | 'step_start' | 'step_complete'; timestamp: string; metadata?: Record; + + client_request_id?: string; + retry_of?: string; + status?: 'sent' | 'running' | 'error' | 'completed'; // 给 UI 用 } export interface Project { @@ -60,7 +64,7 @@ interface SessionContextType { loadProjects: () => Promise; createSession: (projectId: string) => Promise; selectSession: (sessionId: string, initialQuery?: string, sessionObj?: Session) => void; - sendMessage: (content: string) => void; + sendMessage: (content: string, opts?: { reuseMessageId?: string }) => void; stopAgent: () => void; clearLogs: () => void; } @@ -137,6 +141,35 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) return null; }, []); + const endRunningState = useCallback((nextStatus: Session['status'], errMsg?: string) => { + setIsLoading(false); + setIsStreaming(false); + setStreamingContent(''); + + setCurrentSession(prev => { + if (!prev) return prev; + return { ...prev, status: nextStatus, workflow_progress: undefined, file_progress: undefined, current_step: undefined }; + }); + + setSessions(prev => + prev.map(s => (s.id === currentSession?.id ? { ...s, status: nextStatus } : s)) + ); + + if (errMsg) { + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.type === 'error' && last.content === errMsg) return prev; + return [...prev, { + id: Date.now().toString(), + role: 'system', + content: errMsg, + type: 'error', + timestamp: new Date().toISOString(), + }]; + }); + } + }, [currentSession?.id]); + // Connect WebSocket for session const connectWebSocket = useCallback((sessionId: string, initialQuery?: string) => { if (ws) { @@ -176,20 +209,39 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) }; socket.onmessage = (event) => { - const data = JSON.parse(event.data); - handleWebSocketMessage(data); + try { + const data = JSON.parse(event.data); + handleWebSocketMessage(data); + } catch (e) { + console.error('[WS] Non-JSON message:', event.data, e); + + // Fallback: stop the loading state to avoid being stuck in "Processing". + endRunningState('error', typeof event.data === 'string' + ? event.data + : 'Agent failed with non-JSON output'); + + // Don't throw again to avoid a blank screen. + } }; socket.onclose = () => { console.log('WebSocket disconnected'); + + // If it's still running and the connection drops: end with an error + // to avoid the frontend being stuck in "Processing...". + setWs(null); + endRunningState('error', 'Connection closed unexpectedly. Please retry.'); }; socket.onerror = (error) => { console.error('WebSocket error:', error); + + // onerror may sometimes be followed by onclose, but adding a fallback here doesn't hurt. + endRunningState('error', 'WebSocket error occurred. Please retry.'); }; setWs(socket); - }, [ws]); + }, [ws, endRunningState]); // Handle WebSocket messages const handleWebSocketMessage = useCallback((data: Record) => { @@ -235,7 +287,10 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) setCurrentSession(prev => { if (!prev) return prev; - const progressType = data.type as string; + const progressType = + (data.progress_type as string | undefined) ?? + (data.progressType as string | undefined) ?? + (data.kind as string | undefined); if (progressType === 'workflow') { return { ...prev, @@ -259,26 +314,22 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) }); break; - case 'status': - { - const nextStatus = (data.status as Session['status'] | undefined) ?? ((data as any)?.session?.status as Session['status'] | undefined); - if (nextStatus) { - setCurrentSession(prev => { - if (!prev) return prev; - if (nextStatus !== 'running') { - return { ...prev, status: nextStatus, workflow_progress: undefined, file_progress: undefined, current_step: undefined }; - } - return { ...prev, status: nextStatus }; - }); - setSessions(prev => prev.map(s => (s.id === currentSession?.id ? { ...s, status: nextStatus } : s))); - setIsLoading(nextStatus === 'running'); - if (nextStatus !== 'running') { - setIsStreaming(false); - setStreamingContent(''); - } - } + case 'status': { + const nextStatus = + (data.status as Session['status'] | undefined) ?? + ((data as any)?.session?.status as Session['status'] | undefined); + + if (!nextStatus) break; + + if (nextStatus === 'running') { + setCurrentSession(prev => (prev ? { ...prev, status: 'running' } : prev)); + setSessions(prev => prev.map(s => (s.id === currentSession?.id ? { ...s, status: 'running' } : s))); + setIsLoading(true); + } else { + endRunningState(nextStatus); } break; + } case 'complete': setCurrentSession(prev => { @@ -286,7 +337,7 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) return { ...prev, status: 'completed' }; }); setSessions(prev => prev.map(s => (s.id === currentSession?.id ? { ...s, status: 'completed' } : s))); - setIsLoading(false); + endRunningState('completed'); break; case 'error': @@ -302,10 +353,10 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) type: 'error', timestamp: new Date().toISOString(), }]); - setIsLoading(false); + endRunningState('error', (data.message as string) || 'Unknown error'); break; } - }, [currentSession?.id]); + }, [currentSession?.id, endRunningState]); // Select session (can pass session object directly for newly created sessions) const selectSession = useCallback((sessionId: string, initialQuery?: string, sessionObj?: Session) => { @@ -324,26 +375,45 @@ export const SessionProvider: React.FC<{ children: ReactNode }> = ({ children }) }, [sessions, connectWebSocket]); // Send message - const sendMessage = useCallback((content: string) => { - if (!currentSession || !ws || ws.readyState !== WebSocket.OPEN) return; - - // Add user message locally +const sendMessage = useCallback((content: string, opts?: { reuseMessageId?: string }) => { + if (!currentSession || !ws || ws.readyState !== WebSocket.OPEN) return; + + const clientRequestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + // Reuse mode: update the existing message instead of adding a new one. + if (opts?.reuseMessageId) { + setMessages(prev => prev.map(m => { + if (m.id !== opts.reuseMessageId) return m; + return { + ...m, + content, + status: 'running', + client_request_id: clientRequestId, + }; + })); + } else { + // Default: add a new user message. setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content, type: 'text', timestamp: new Date().toISOString(), + status: 'running', + client_request_id: clientRequestId, }]); + } - // Send to server - ws.send(JSON.stringify({ - action: 'start', - query: content, - })); + ws.send(JSON.stringify({ + action: 'start', + query: content, + client_request_id: clientRequestId, // If the backend can pass it back unchanged, that would be better. + })); - setIsLoading(true); - }, [currentSession, ws]); + setIsStreaming(false); + setStreamingContent(''); + setIsLoading(true); +}, [currentSession, ws]); // Stop agent const stopAgent = useCallback(() => { From 5cbc5619623bbb83f33b3c6e942e366c92614dc5 Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 26 Jan 2026 16:54:06 +0800 Subject: [PATCH 2/4] feat: frontent dir preview --- webui/backend/api.py | 10 +- .../src/components/ConversationView.tsx | 140 +++++++++++++----- 2 files changed, 109 insertions(+), 41 deletions(-) diff --git a/webui/backend/api.py b/webui/backend/api.py index e1e53abd1..35c579fb9 100644 --- a/webui/backend/api.py +++ b/webui/backend/api.py @@ -259,9 +259,9 @@ class FileReadRequest(BaseModel): @router.get('/files/list') async def list_output_files( - output_dir: Optional[str] = Query(default=None), # 你前端现在传 output_dir + output_dir: Optional[str] = Query(default='output'), session_id: Optional[str] = Query(default=None), - root_dir: Optional[str] = Query(default=None), # 兼容老参数 + root_dir: Optional[str] = Query(default=None), ): """List all files under root_dir as a tree structure. root_dir: optional. If not provided, defaults to ms-agent/output. @@ -427,10 +427,9 @@ def resolve_file_path(root_dir_abs: str, file_path: str) -> str: async def read_file_content(request: FileReadRequest): if request.session_id: session_root = get_session_root(request.session_id) - root_abs = os.path.normpath(os.path.abspath(os.path.join(session_root, "output"))) + root_abs = os.path.normpath(os.path.abspath(str(session_root))) else: root_abs = resolve_root_dir(request.root_dir) - full_path = resolve_file_path(root_abs, request.path) if not os.path.exists(full_path): @@ -438,7 +437,6 @@ async def read_file_content(request: FileReadRequest): if not os.path.isfile(full_path): raise HTTPException(status_code=400, detail=f'Path {full_path} is not a file') - print(f'full_path: {full_path}') # limit 1MB file_size = os.path.getsize(full_path) if file_size > 1024 * 1024: @@ -502,7 +500,7 @@ def resolve_and_check_path(file_path: str) -> str: async def stream_file(path: str, session_id: Optional[str] = Query(default=None)): if session_id: session_root = get_session_root(session_id) - root_abs = str((session_root / "output").resolve()) + root_abs = str(session_root.resolve()) full_path = resolve_file_path(root_abs, path) else: full_path = resolve_and_check_path(path) diff --git a/webui/frontend/src/components/ConversationView.tsx b/webui/frontend/src/components/ConversationView.tsx index 340cc0ac0..038365109 100644 --- a/webui/frontend/src/components/ConversationView.tsx +++ b/webui/frontend/src/components/ConversationView.tsx @@ -88,7 +88,18 @@ const ConversationView: React.FC = ({ showLogs }) => { const [fileLoading, setFileLoading] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const [fileError, setFileError] = useState(null); + const [fileLang, setFileLang] = useState('text'); + const [fileUrl, setFileUrl] = useState(null); + const [fileKind, setFileKind] = useState<'text' | 'image' | 'video' | 'audio'>('text'); + const getFileKind = (fname: string): 'text' | 'image' | 'video' | 'audio' => { + const ext = fname.split('.').pop()?.toLowerCase() || ''; + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return 'image'; + if (['mp4', 'webm', 'ogg', 'mov', 'm4v'].includes(ext)) return 'video'; + if (['mp3', 'wav', 'aac', 'flac', 'm4a', 'opus'].includes(ext)) return 'audio'; + return 'text'; + }; // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -143,7 +154,7 @@ const ConversationView: React.FC = ({ showLogs }) => { }); }; - const handleOpenOutputFiles = () => { + const handleOpenOutputFiles = () => { loadOutputFiles('output'); setOutputFilesOpen(true); setSelectedFile(null); @@ -155,18 +166,37 @@ const ConversationView: React.FC = ({ showLogs }) => { setSelectedFile(path); setFileLoading(true); + const kind = getFileKind(path); + setFileKind(kind); + try { - const response = await fetch('/api/files/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path, session_id: currentSession.id }), - }); - if (response.ok) { + if (kind === 'text') { + const response = await fetch('/api/files/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, session_id: currentSession?.id }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to load file'); + } + const data = await response.json(); setFileContent(data.content); + setFileLang(data.language || 'text'); + setFileUrl(null); + return; } + + const sid = currentSession?.id; + const streamUrl = + `/api/files/stream?path=${encodeURIComponent(path)}&session_id=${encodeURIComponent(sid || '')}`; + setFileUrl(streamUrl); + setFileContent(null); + setFileLang(kind); } catch (err) { - console.error('Failed to load file:', err); + setFileError(err instanceof Error ? err.message : 'Failed to load file'); } finally { setFileLoading(false); } @@ -291,33 +321,73 @@ const ConversationView: React.FC = ({ showLogs }) => { onSelectFile={handleViewFile} /> - {/* File Content */} - - {fileLoading ? ( - - - - ) : fileContent ? ( - - {fileContent} - - ) : ( - - Select a file to view - - )} - + {/* File Content */} + + {fileLoading ? ( + + + + ) : fileError ? ( + + {fileError} + + ) : fileContent ? ( + + {fileContent} + + ) : fileUrl ? ( + + {fileKind === 'image' && ( + + )} + + {fileKind === 'video' && ( + + )} + + {fileKind === 'audio' && ( + + )} + + {/* Fallback: in case kind doesn't match */} + {!['image', 'video', 'audio'].includes(fileKind) && ( + + Unsupported preview type. Open + + )} + + ) : ( + + Select a file to view + + )} + From 379cffcf4e4abacdc68a16c13ba475ad7bb11ab0 Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 26 Jan 2026 16:55:38 +0800 Subject: [PATCH 3/4] fix lint --- webui/backend/agent_runner.py | 51 ++++++++++----- webui/backend/api.py | 114 ++++++++++++++++++++++------------ 2 files changed, 108 insertions(+), 57 deletions(-) diff --git a/webui/backend/agent_runner.py b/webui/backend/agent_runner.py index bf8ad9aa6..66c30dd83 100644 --- a/webui/backend/agent_runner.py +++ b/webui/backend/agent_runner.py @@ -9,9 +9,8 @@ import signal import subprocess import sys -from pathlib import Path - from datetime import datetime +from pathlib import Path from typing import Any, Callable, Dict, Optional @@ -43,8 +42,9 @@ def __init__(self, self._workflow_steps = [] self._stop_requested = False - base_dir = Path(__file__).resolve().parents[1] # equal to dirname(dirname(__file__)) - work_dir = base_dir / "work_dir" / str(session_id) + base_dir = Path(__file__).resolve().parents[ + 1] # equal to dirname(dirname(__file__)) + work_dir = base_dir / 'work_dir' / str(session_id) work_dir.mkdir(parents=True, exist_ok=True) self.work_dir = work_dir @@ -71,8 +71,7 @@ async def start(self, query: str): stdin=asyncio.subprocess.PIPE, env=env, cwd=self.project['path'], - start_new_session=True - ) + start_new_session=True) read_exc = None try: @@ -83,9 +82,9 @@ async def start(self, query: str): read_exc = e if self.on_log: self.on_log({ - "level": "error", - "message": f"Read output failed: {repr(e)}", - "timestamp": datetime.now().isoformat() + 'level': 'error', + 'message': f'Read output failed: {repr(e)}', + 'timestamp': datetime.now().isoformat() }) rc = await self.process.wait() @@ -93,7 +92,10 @@ async def start(self, query: str): if self._stop_requested: if self.on_error: - self.on_error({'message': 'Stopped by user', 'returncode': rc}) + self.on_error({ + 'message': 'Stopped by user', + 'returncode': rc + }) sent_terminal_event = True return @@ -116,13 +118,20 @@ async def start(self, query: str): tb = traceback.format_exc() print(tb) if self.on_error: - self.on_error({'message': tb, 'type': 'startup_error', 'returncode': -1}) + self.on_error({ + 'message': tb, + 'type': 'startup_error', + 'returncode': -1 + }) sent_terminal_event = True finally: # Fallback: ensure the frontend always receives the completion signal under all circumstances. if not sent_terminal_event and self.on_error: - self.on_error({'message': 'Agent terminated unexpectedly', 'returncode': -1}) + self.on_error({ + 'message': 'Agent terminated unexpectedly', + 'returncode': -1 + }) async def stop(self): """Stop the agent""" @@ -250,16 +259,19 @@ async def _read_output(self): if len(buf) >= MAX_LINE_BUFFER: part = bytes(buf[:MAX_LINE_BUFFER]) del buf[:MAX_LINE_BUFFER] - text = part.decode('utf-8', errors='replace').rstrip() + text = part.decode( + 'utf-8', errors='replace').rstrip() print(f'[Runner] Output(partial): {text[:200]}' - if len(text) > 200 else f'[Runner] Output(partial): {text}') + if len(text) > 200 else + f'[Runner] Output(partial): {text}') await self._process_line(text) break line_bytes = bytes(buf[:nl + 1]) del buf[:nl + 1] - text = line_bytes.decode('utf-8', errors='replace').rstrip() + text = line_bytes.decode( + 'utf-8', errors='replace').rstrip() print(f'[Runner] Output: {text[:200]}' if len(text) > 200 else f'[Runner] Output: {text}') await self._process_line(text) @@ -312,7 +324,8 @@ async def _read_output(self): # so the frontend can reach a terminal state. if self.on_error: self.on_error({ - 'message': f'Agent exited with code {return_code}', + 'message': + f'Agent exited with code {return_code}', 'type': 'exit_error', 'code': return_code }) @@ -322,7 +335,11 @@ async def _read_output(self): traceback.print_exc() if not self._stop_requested and self.on_error: # Provide a fallback error code if `wait` fails as well. - self.on_error({'message': str(e), 'type': 'finalize_error', 'code': -1}) + self.on_error({ + 'message': str(e), + 'type': 'finalize_error', + 'code': -1 + }) finally: self.is_running = False print('[Runner] Finished reading output') diff --git a/webui/backend/api.py b/webui/backend/api.py index 35c579fb9..4edf3e14b 100644 --- a/webui/backend/api.py +++ b/webui/backend/api.py @@ -2,10 +2,11 @@ """ API endpoints for the MS-Agent Web UI """ -import os import mimetypes +import os from pathlib import Path from typing import Any, Dict, List, Optional + from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from pydantic import BaseModel @@ -16,17 +17,20 @@ def get_backend_root() -> Path: - return Path(__file__).resolve().parents[1] # equal to dirname(dirname(__file__)) + return Path(__file__).resolve().parents[ + 1] # equal to dirname(dirname(__file__)) + def get_session_root(session_id: str) -> Path: if not session_id or not str(session_id).strip(): - raise HTTPException(status_code=400, detail="session_id is required") + raise HTTPException(status_code=400, detail='session_id is required') backend_root = get_backend_root() - work_dir = (backend_root / "work_dir" / str(session_id)).resolve() + work_dir = (backend_root / 'work_dir' / str(session_id)).resolve() work_dir.mkdir(parents=True, exist_ok=True) return work_dir + # Request/Response Models class ProjectInfo(BaseModel): id: str @@ -80,7 +84,9 @@ class GlobalConfig(BaseModel): @router.get('/projects', response_model=List[ProjectInfo]) async def list_projects(): """List all available projects""" - print(f'project_discovery.discover_projects(): {project_discovery.discover_projects()}') + print( + f'project_discovery.discover_projects(): {project_discovery.discover_projects()}' + ) return project_discovery.discover_projects() @@ -259,9 +265,9 @@ class FileReadRequest(BaseModel): @router.get('/files/list') async def list_output_files( - output_dir: Optional[str] = Query(default='output'), - session_id: Optional[str] = Query(default=None), - root_dir: Optional[str] = Query(default=None), + output_dir: Optional[str] = Query(default='output'), + session_id: Optional[str] = Query(default=None), + root_dir: Optional[str] = Query(default=None), ): """List all files under root_dir as a tree structure. root_dir: optional. If not provided, defaults to ms-agent/output. @@ -274,16 +280,15 @@ async def list_output_files( # Base directories (same way as read_file_content) base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ) + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') if session_id: session_root = get_session_root(session_id) - resolved_root = (session_root / "").resolve() + resolved_root = (session_root / '').resolve() - elif not root_dir or root_dir.strip() == "": + elif not root_dir or root_dir.strip() == '': resolved_root = output_dir else: root_dir = root_dir.strip() @@ -303,9 +308,9 @@ async def list_output_files( else: # If user passes "output" or "projects" explicitly (common case) # allow interpreting it as those roots even if not exist check above - if root_dir in ("output", "output/"): + if root_dir in ('output', 'output/'): resolved_root = output_dir - elif root_dir in ("projects", "projects/"): + elif root_dir in ('projects', 'projects/'): resolved_root = projects_dir else: # fall back to output + root_dir (but it likely doesn't exist) @@ -343,7 +348,8 @@ def build_tree(dir_path: str) -> dict: result['files'].append({ 'name': item, 'path': rel_path, # <-- relative path - 'abs_path': full_path, # optional: if you still want absolute for debugging + 'abs_path': + full_path, # optional: if you still want absolute for debugging 'size': os.path.getsize(full_path), 'modified': os.path.getmtime(full_path) }) @@ -351,17 +357,19 @@ def build_tree(dir_path: str) -> dict: result['files'].sort(key=lambda x: x['modified'], reverse=True) return result - print("resolved_root =", resolved_root) + print('resolved_root =', resolved_root) tree = build_tree(resolved_root) return {'tree': tree, 'root_dir': resolved_root} + def get_allowed_roots(): base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ) + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) output_dir = os.path.join(base_dir, 'ms-agent', 'output') projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') - return base_dir, os.path.normpath(output_dir), os.path.normpath(projects_dir) + return base_dir, os.path.normpath(output_dir), os.path.normpath( + projects_dir) + def resolve_root_dir(root_dir: Optional[str]) -> str: """ @@ -373,9 +381,8 @@ def resolve_root_dir(root_dir: Optional[str]) -> str: - absolute path (must still be under allowed roots) """ _, output_dir, projects_dir = get_allowed_roots() - allowed_roots = [output_dir, projects_dir] - if not root_dir or root_dir.strip() == "": + if not root_dir or root_dir.strip() == '': resolved = output_dir else: rd = root_dir.strip() @@ -384,15 +391,16 @@ def resolve_root_dir(root_dir: Optional[str]) -> str: resolved = rd else: # Allow explicit "output"/"projects" - if rd in ("output", "output/"): + if rd in ('output', 'output/'): resolved = output_dir - elif rd in ("projects", "projects/"): + elif rd in ('projects', 'projects/'): resolved = projects_dir else: cand1 = os.path.join(output_dir, rd) cand2 = os.path.join(projects_dir, rd) # choose existing one if possible, otherwise default to cand1 - resolved = cand1 if os.path.exists(cand1) else (cand2 if os.path.exists(cand2) else cand1) + resolved = cand1 if os.path.exists(cand1) else ( + cand2 if os.path.exists(cand2) else cand1) resolved = os.path.normpath(os.path.abspath(resolved)) @@ -406,6 +414,7 @@ def within(child: str, parent: str) -> bool: return resolved + def resolve_file_path(root_dir_abs: str, file_path: str) -> str: """ Resolve file_path against root_dir_abs. @@ -417,12 +426,14 @@ def resolve_file_path(root_dir_abs: str, file_path: str) -> str: if os.path.isabs(file_path): full_path = os.path.normpath(os.path.abspath(file_path)) else: - full_path = os.path.normpath(os.path.abspath(os.path.join(root_dir_abs, file_path))) + full_path = os.path.normpath( + os.path.abspath(os.path.join(root_dir_abs, file_path))) # TODO: Security: file must be within root_dir_abs return full_path + @router.post('/files/read') async def read_file_content(request: FileReadRequest): if request.session_id: @@ -433,10 +444,12 @@ async def read_file_content(request: FileReadRequest): full_path = resolve_file_path(root_abs, request.path) if not os.path.exists(full_path): - raise HTTPException(status_code=404, detail=f'File not found: {full_path}') + raise HTTPException( + status_code=404, detail=f'File not found: {full_path}') if not os.path.isfile(full_path): - raise HTTPException(status_code=400, detail=f'Path {full_path} is not a file') + raise HTTPException( + status_code=400, detail=f'Path {full_path} is not a file') # limit 1MB file_size = os.path.getsize(full_path) if file_size > 1024 * 1024: @@ -448,10 +461,22 @@ async def read_file_content(request: FileReadRequest): ext = os.path.splitext(full_path)[1].lower() lang_map = { - '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.tsx': 'typescript', - '.jsx': 'javascript', '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', - '.md': 'markdown', '.html': 'html', '.css': 'css', '.txt': 'text', - '.sh': 'bash', '.java': 'java', '.go': 'go', '.rs': 'rust', + '.py': 'python', + '.js': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.jsx': 'javascript', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.txt': 'text', + '.sh': 'bash', + '.java': 'java', + '.go': 'go', + '.rs': 'rust', } language = lang_map.get(ext, 'text') @@ -470,10 +495,13 @@ async def read_file_content(request: FileReadRequest): except UnicodeDecodeError: raise HTTPException(status_code=400, detail='File is not a text file') except Exception as e: - raise HTTPException(status_code=500, detail=f'Error reading file: {str(e)}') + raise HTTPException( + status_code=500, detail=f'Error reading file: {str(e)}') + def resolve_and_check_path(file_path: str) -> str: - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + base_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) output_dir = os.path.join(base_dir, 'ms-agent', 'output') projects_dir = os.path.join(base_dir, 'ms-agent', 'projects') @@ -489,15 +517,18 @@ def resolve_and_check_path(file_path: str) -> str: # TODO: security path check if not os.path.exists(full_path): - raise HTTPException(status_code=404, detail=f'File not found: {full_path}') + raise HTTPException( + status_code=404, detail=f'File not found: {full_path}') if not os.path.isfile(full_path): - raise HTTPException(status_code=400, detail=f'Path {full_path} is not a file') + raise HTTPException( + status_code=400, detail=f'Path {full_path} is not a file') return full_path -@router.get("/files/stream") -async def stream_file(path: str, session_id: Optional[str] = Query(default=None)): +@router.get('/files/stream') +async def stream_file(path: str, + session_id: Optional[str] = Query(default=None)): if session_id: session_root = get_session_root(session_id) root_abs = str(session_root.resolve()) @@ -506,10 +537,13 @@ async def stream_file(path: str, session_id: Optional[str] = Query(default=None) full_path = resolve_and_check_path(path) media_type, _ = mimetypes.guess_type(full_path) - media_type = media_type or "application/octet-stream" + media_type = media_type or 'application/octet-stream' return FileResponse( full_path, media_type=media_type, filename=os.path.basename(full_path), - headers={"Content-Disposition": f'inline; filename="{os.path.basename(full_path)}"'}, - ) \ No newline at end of file + headers={ + 'Content-Disposition': + f'inline; filename="{os.path.basename(full_path)}"' + }, + ) From 8423d852a278eb90ccefa2fce6a6693e0848dbae Mon Sep 17 00:00:00 2001 From: suluyan Date: Tue, 27 Jan 2026 15:51:24 +0800 Subject: [PATCH 4/4] enhance TODO warning --- webui/backend/api.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/webui/backend/api.py b/webui/backend/api.py index 4edf3e14b..27e2d24b3 100644 --- a/webui/backend/api.py +++ b/webui/backend/api.py @@ -318,7 +318,10 @@ async def list_output_files( resolved_root = os.path.normpath(os.path.abspath(resolved_root)) - # TODO: Security check: root must be within allowed roots + # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). + # For production, enforce strict backend file-access validation and authorization + # to prevent arbitrary path read/write (e.g., path traversal). + # TODO: Security check: ensure `resolved_root` is within configured allowed roots. def build_tree(dir_path: str) -> dict: result = {'folders': {}, 'files': []} @@ -404,13 +407,10 @@ def resolve_root_dir(root_dir: Optional[str]) -> str: resolved = os.path.normpath(os.path.abspath(resolved)) - # Security: resolved root must be within allowed roots - def within(child: str, parent: str) -> bool: - parent = os.path.normpath(os.path.abspath(parent)) - child = os.path.normpath(os.path.abspath(child)) - return child == parent or child.startswith(parent + os.sep) - - # TODO: security dir check + # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). + # For production, enforce strict backend file-access validation and authorization + # to prevent arbitrary path read/write (e.g., path traversal). + # TODO: Security check: ensure `resolved` is within configured allowed roots. return resolved @@ -429,7 +429,10 @@ def resolve_file_path(root_dir_abs: str, file_path: str) -> str: full_path = os.path.normpath( os.path.abspath(os.path.join(root_dir_abs, file_path))) - # TODO: Security: file must be within root_dir_abs + # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). + # For production, enforce strict backend file-access validation and authorization + # to prevent arbitrary path read/write (e.g., path traversal). + # TODO: Security check: ensure `full_path` is within configured allowed roots. return full_path @@ -514,7 +517,10 @@ def resolve_and_check_path(file_path: str) -> str: full_path = os.path.normpath(full_path) - # TODO: security path check + # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). + # For production, enforce strict backend file-access validation and authorization + # to prevent arbitrary path read/write (e.g., path traversal). + # TODO: Security check: ensure `full_path` is within configured allowed roots. if not os.path.exists(full_path): raise HTTPException(