更新readme文档

This commit is contained in:
2026-01-09 16:52:45 +08:00
parent e51cdfea6f
commit b1d0cc5462
22 changed files with 1871 additions and 174 deletions

404
web/main.py Normal file
View File

@@ -0,0 +1,404 @@
import sys
import os
import threading
import glob
import uuid
import json
from typing import Optional, Dict, List
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
# Add parent directory to path to import agent modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data_analysis_agent import DataAnalysisAgent
from config.llm_config import LLMConfig
from utils.create_session_dir import create_session_output_dir
from merge_excel import merge_excel_files
from sort_csv import sort_csv_by_time
app = FastAPI(title="IOV Data Analysis Agent")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Session Management ---
class SessionData:
def __init__(self, session_id: str):
self.session_id = session_id
self.is_running = False
self.output_dir: Optional[str] = None
self.generated_report: Optional[str] = None
self.log_file: Optional[str] = None
self.analysis_results: List[Dict] = [] # Store analysis results for gallery
class SessionManager:
def __init__(self):
self.sessions: Dict[str, SessionData] = {}
self.lock = threading.Lock()
def create_session(self) -> str:
with self.lock:
session_id = str(uuid.uuid4())
self.sessions[session_id] = SessionData(session_id)
return session_id
def get_session(self, session_id: str) -> Optional[SessionData]:
return self.sessions.get(session_id)
def list_sessions(self):
return list(self.sessions.keys())
session_manager = SessionManager()
# Mount static files
os.makedirs("web/static", exist_ok=True)
os.makedirs("uploads", exist_ok=True)
os.makedirs("outputs", exist_ok=True)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
# --- Helper Functions ---
def run_analysis_task(session_id: str, files: list, user_requirement: str):
"""
Runs the analysis agent in a background thread for a specific session.
"""
session = session_manager.get_session(session_id)
if not session:
print(f"Error: Session {session_id} not found in background task.")
return
session.is_running = True
try:
# Create session directory
base_output_dir = "outputs"
# We enforce a specific directory naming convention or let the util handle it
# ideally we map session_id to the directory
# For now, let's use the standard utility but we might lose the direct mapping if not careful
# Let's trust the return value
session_output_dir = create_session_output_dir(base_output_dir, user_requirement)
session.output_dir = session_output_dir
# Initialize Log capturing
session.log_file = os.path.join(session_output_dir, "process.log")
# Thread-safe logging requires a bit of care.
# Since we are running in a thread, redirecting sys.stdout globally is BAD for multi-session.
# However, for this MVP, if we run multiple sessions concurrently, their logs will mix in stdout.
# BUT we are writing to specific log files.
# We need a logger that writes to the session's log file.
# And the Agent needs to use that logger.
# Currently the Agent uses print().
# To support true concurrent logging without mixing, we'd need to refactor Agent to use a logger instance.
# LIMITATION: For now, we accept that stdout redirection intercepts EVERYTHING.
# So multiple concurrent sessions is risky with global stdout redirection.
# A safer approach for now: We won't redirect stdout globally for multi-session support
# unless we lock execution to one at a time.
# OR: We just rely on the fact that we might only run one analysis at a time mostly.
# Let's try to just write to the log file explicitly if we could, but we can't change Agent easily right now.
# Compromise: We will continue to use global redirection but acknowledge it's not thread-safe for output.
# A better way: Modify Agent to accept a 'log_callback'.
# For this refactor, let's stick to the existing pattern but bind it to the thread if possible? No.
# We will wrap the execution with a simple File Logger that appends to the distinct file.
# But sys.stdout is global.
# We will assume single concurrent analysis for safety, or accept mixed terminal output but separate file logs?
# Actually, if we swap sys.stdout, it affects all threads.
# So we MUST NOT swap sys.stdout if we want concurrency.
# If we don't swap stdout, we don't capture logs to file unless Agent does it.
# The Agent code has `print`.
# Correct fix: Refactor Agent to use `logging` module or pass a printer.
# Given the scope, let's just hold the lock (serialize execution) OR allow mixing in terminal
# but try to capture to file?
# Let's just write to the file.
with open(session.log_file, "w", encoding="utf-8") as f:
f.write(f"--- Session {session_id} Started ---\n")
# We will create a custom print function that writes to the file
# And monkeypatch builtins.print? No, that's too hacky.
# Let's just use the stdout redirector, but acknowledge only one active session at a time is safe.
# We can implement a crude lock for now.
class FileLogger:
def __init__(self, filename):
self.terminal = sys.__stdout__
self.log = open(filename, "a", encoding="utf-8", buffering=1)
def write(self, message):
self.terminal.write(message)
self.log.write(message)
def flush(self):
self.terminal.flush()
self.log.flush()
def close(self):
self.log.close()
logger = FileLogger(session.log_file)
sys.stdout = logger # Global hijack!
try:
llm_config = LLMConfig()
agent = DataAnalysisAgent(llm_config, force_max_rounds=False, output_dir=base_output_dir)
result = agent.analyze(
user_input=user_requirement,
files=files,
session_output_dir=session_output_dir
)
session.generated_report = result.get("report_file_path", None)
session.analysis_results = result.get("analysis_results", [])
# Save results to json for persistence
with open(os.path.join(session_output_dir, "results.json"), "w") as f:
json.dump(session.analysis_results, f, default=str)
except Exception as e:
print(f"Error during analysis: {e}")
finally:
sys.stdout = logger.terminal
logger.close()
except Exception as e:
print(f"System Error: {e}")
finally:
session.is_running = False
# --- Pydantic Models ---
class StartRequest(BaseModel):
requirement: str
# --- API Endpoints ---
@app.get("/")
async def read_root():
return FileResponse("web/static/index.html")
@app.post("/api/upload")
async def upload_files(files: list[UploadFile] = File(...)):
saved_files = []
for file in files:
file_location = f"uploads/{file.filename}"
with open(file_location, "wb+") as file_object:
file_object.write(file.file.read())
saved_files.append(file_location)
return {"info": f"Saved {len(saved_files)} files", "paths": saved_files}
@app.post("/api/start")
async def start_analysis(request: StartRequest, background_tasks: BackgroundTasks):
session_id = session_manager.create_session()
files = glob.glob("uploads/*.csv")
if not files:
if os.path.exists("cleaned_data.csv"):
files = ["cleaned_data.csv"]
else:
raise HTTPException(status_code=400, detail="No CSV files found")
files = [os.path.abspath(f) for f in files] # Only use absolute paths
background_tasks.add_task(run_analysis_task, session_id, files, request.requirement)
return {"status": "started", "session_id": session_id}
@app.get("/api/status")
async def get_status(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
log_content = ""
if session.log_file and os.path.exists(session.log_file):
with open(session.log_file, "r", encoding="utf-8") as f:
log_content = f.read()
return {
"is_running": session.is_running,
"log": log_content,
"has_report": session.generated_report is not None,
"report_path": session.generated_report
}
@app.get("/api/report")
async def get_report(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if not session.generated_report or not os.path.exists(session.generated_report):
return {"content": "Report not ready."}
with open(session.generated_report, "r", encoding="utf-8") as f:
content = f.read()
# Fix image paths
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
web_base_path = f"/{relative_session_path}"
content = content.replace("](./", f"]({web_base_path}/")
return {"content": content, "base_path": web_base_path}
@app.get("/api/figures")
async def get_figures(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# We can try to get from memory first
results = session.analysis_results
# If empty in memory (maybe server restarted but files exist?), try load json
if not results and session.output_dir:
json_path = os.path.join(session.output_dir, "results.json")
if os.path.exists(json_path):
with open(json_path, 'r') as f:
results = json.load(f)
# Extract collected figures
figures = []
# We iterate over analysis results to find 'collect_figures' actions
if results:
for item in results:
if item.get("action") == "collect_figures":
collected = item.get("collected_figures", [])
for fig in collected:
# Enrich with web path
if session.output_dir:
# Assume filename is present
fname = fig.get("filename")
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
fig["web_url"] = f"/{relative_session_path}/{fname}"
figures.append(fig)
# Also check for 'generate_code' results that might have implicit figures if we parse them
# But the 'collect_figures' action is the reliable source as per agent design
# Auto-discovery fallback if list is empty but pngs exist?
if not figures and session.output_dir:
# Simple scan
pngs = glob.glob(os.path.join(session.output_dir, "*.png"))
for p in pngs:
fname = os.path.basename(p)
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
figures.append({
"filename": fname,
"description": "Auto-discovered image",
"analysis": "No analysis available",
"web_url": f"/{relative_session_path}/{fname}"
})
return {"figures": figures}
@app.get("/api/export")
async def export_report(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session or not session.output_dir:
raise HTTPException(status_code=404, detail="Session not found")
import zipfile
import tempfile
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"report_{timestamp}.zip"
export_dir = "outputs"
os.makedirs(export_dir, exist_ok=True)
temp_zip_path = os.path.join(export_dir, zip_filename)
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(session.output_dir):
for file in files:
if file.endswith(('.md', '.png', '.csv', '.log', '.json', '.yaml')):
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, session.output_dir)
zf.write(abs_path, arcname=rel_path)
return FileResponse(
path=temp_zip_path,
filename=zip_filename,
media_type='application/zip'
)
# --- Tools API ---
class ToolRequest(BaseModel):
source_dir: Optional[str] = "uploads"
output_filename: Optional[str] = "merged_output.csv"
target_file: Optional[str] = None
@app.post("/api/tools/merge")
async def tool_merge_excel(req: ToolRequest):
"""
Trigger Excel Merge Tool
"""
try:
source = req.source_dir
output = req.output_filename
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: merge_excel_files(source, output))
output_abs = os.path.abspath(output)
if os.path.exists(output_abs):
return {"status": "success", "message": "Merge completed", "output_file": output_abs}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tools/sort")
async def tool_sort_csv(req: ToolRequest):
"""
Trigger CSV Sort Tool
"""
try:
target = req.target_file
if not target:
raise HTTPException(status_code=400, detail="Target file required")
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: sort_csv_by_time(target))
return {"status": "success", "message": f"Sorted {target} by time"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# --- Help API ---
@app.get("/api/help/troubleshooting")
async def get_troubleshooting_guide():
"""
Returns the content of troubleshooting_guide.md
"""
guide_path = os.path.expanduser("~/.gemini/antigravity/brain/3ff617fe-5f27-4ab8-b61b-c634f2e75255/troubleshooting_guide.md")
if not os.path.exists(guide_path):
return {"content": "# Troubleshooting Guide Not Found\n\nCould not locate the guide artifact."}
try:
with open(guide_path, "r", encoding="utf-8") as f:
content = f.read()
return {"content": content}
except Exception as e:
return {"content": f"# Error Loading Guide\n\n{e}"}

175
web/static/index.html Normal file
View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IOV Data Analysis Agent</title>
<link rel="stylesheet" href="/static/style.css?v=2.0">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Markdown Parser -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="brand">
<i class="fa-solid fa-robot"></i>
<h1>IOV Agent</h1>
</div>
<!-- Navigation -->
<nav class="main-nav">
<button class="nav-btn active" onclick="switchView('analysis')">
<i class="fa-solid fa-chart-line"></i> Analysis
</button>
<button class="nav-btn" onclick="switchView('tools')">
<i class="fa-solid fa-toolbox"></i> Data Tools
</button>
<button class="nav-btn" onclick="switchView('gallery')">
<i class="fa-solid fa-images"></i> Gallery
</button>
<button class="nav-btn" onclick="switchView('help')">
<i class="fa-solid fa-circle-question"></i> Help
</button>
</nav>
<!-- Analysis Controls (Visible only in Analysis View) -->
<div id="analysisControls" class="control-group-wrapper">
<div class="control-group">
<h3>1. Data Upload</h3>
<div class="upload-zone" id="uploadZone">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Drag & Drop CSV/Excel</p>
<input type="file" id="fileInput" accept=".csv,.xlsx,.xls" multiple hidden>
<button class="btn secondary" onclick="document.getElementById('fileInput').click()">Select
Files</button>
<div id="fileList" class="file-list"></div>
</div>
</div>
<div class="control-group">
<h3>2. Analysis Config</h3>
<textarea id="requirementInput"
placeholder="Enter your analysis requirement here...">基于所有运维工单,整理一份工单健康度报告...</textarea>
<button id="startBtn" class="btn primary">
<i class="fa-solid fa-play"></i> Start Analysis
</button>
</div>
</div>
<div class="status-indicator">
<span id="statusDot" class="dot"></span>
<span id="statusText">Idle</span>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- VIEW: ANALYSIS -->
<div id="viewAnalysis" class="view-section active">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('logs')">
<i class="fa-solid fa-terminal"></i> Live Logs
</button>
<button class="tab-btn" onclick="switchTab('report')">
<i class="fa-solid fa-file-invoice-dollar"></i> Report
</button>
<div style="margin-left: auto; display: flex; align-items: center;">
<button id="exportBtn" class="btn secondary"
style="padding: 0.4rem 1rem; font-size: 0.8rem; width: auto;" onclick="triggerExport()">
<i class="fa-solid fa-download"></i> Export Report (ZIP)
</button>
</div>
</div>
<!-- Log View -->
<div id="logsTab" class="tab-content active">
<div class="terminal-window">
<pre id="logOutput">Waiting to start...</pre>
</div>
</div>
<!-- Report View -->
<div id="reportTab" class="tab-content">
<div id="reportContainer" class="markdown-body">
<div class="empty-state">
<i class="fa-solid fa-chart-simple"></i>
<p>No report generated yet.</p>
</div>
</div>
</div>
</div>
<!-- VIEW: TOOLS -->
<div id="viewTools" class="view-section" style="display: none;">
<div class="tools-grid">
<!-- Tool Card 1: Merge Excel -->
<div class="tool-card">
<div class="tool-icon"><i class="fa-solid fa-file-csv"></i></div>
<h3>Excel to CSV Merger</h3>
<p>Merge multiple .xlsx files from the uploads directory into a single CSV for analysis.</p>
<div class="tool-actions">
<input type="text" id="mergeSource" value="uploads" placeholder="Source Directory"
class="input-sm">
<button class="btn secondary" onclick="triggerMerge()">
<i class="fa-solid fa-bolt"></i> Merge Now
</button>
</div>
<div id="mergeResult" class="tool-result"></div>
</div>
<!-- Tool Card 2: Sort CSV -->
<div class="tool-card">
<div class="tool-icon"><i class="fa-solid fa-arrow-down-a-z"></i></div>
<h3>Time Sorter</h3>
<p>Sort a CSV file by 'SendTime' or time column to fix ordering issues.</p>
<div class="tool-actions">
<input type="text" id="sortTarget" value="cleaned_data.csv" placeholder="Target Filename"
class="input-sm">
<button class="btn secondary" onclick="triggerSort()">
<i class="fa-solid fa-sort"></i> Sort Now
</button>
</div>
<div id="sortResult" class="tool-result"></div>
</div>
</div>
</div>
<!-- VIEW: GALLERY -->
<div id="viewGallery" class="view-section" style="display: none;">
<div id="galleryContainer" class="gallery-grid">
<div class="empty-state">
<i class="fa-solid fa-images"></i>
<p>No images generated yet.<br>Start an analysis to see results here.</p>
</div>
</div>
</div>
<!-- VIEW: HELP -->
<div id="viewHelp" class="view-section" style="display: none;">
<div class="help-header">
<h2>Troubleshooting Guide</h2>
<button class="btn secondary" onclick="fetchHelp()">Refresh</button>
</div>
<div id="helpContainer" class="markdown-body help-body">
<p>Loading guide...</p>
</div>
</div>
</main>
</div>
<script src="/static/script.js"></script>
</body>
</html>

362
web/static/script.js Normal file
View File

@@ -0,0 +1,362 @@
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const startBtn = document.getElementById('startBtn');
const requirementInput = document.getElementById('requirementInput');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const logOutput = document.getElementById('logOutput');
const reportContainer = document.getElementById('reportContainer');
const galleryContainer = document.getElementById('galleryContainer');
let isRunning = false;
let pollingInterval = null;
let currentSessionId = null;
// --- Upload Logic ---
if (uploadZone) {
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
}
if (fileInput) {
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
}
async function handleFiles(files) {
if (files.length === 0) return;
fileList.innerHTML = '';
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
const fileItem = document.createElement('div');
fileItem.innerText = `📄 ${file.name}`;
fileItem.style.fontSize = '0.8rem';
fileItem.style.color = '#fff';
fileList.appendChild(fileItem);
}
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
const data = await res.json();
console.log('Upload success:', data);
} else {
alert('Upload failed');
}
} catch (e) {
console.error(e);
alert('Upload failed');
}
}
// --- Start Analysis ---
if (startBtn) {
startBtn.addEventListener('click', async () => {
if (isRunning) return;
const requirement = requirementInput.value.trim();
if (!requirement) {
alert('Please enter analysis requirement');
return;
}
setRunningState(true);
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement })
});
if (res.ok) {
const data = await res.json();
currentSessionId = data.session_id; // Store Session ID
console.log("Started Session:", currentSessionId);
startPolling();
switchTab('logs');
} else {
const err = await res.json();
alert('Failed to start: ' + err.detail);
setRunningState(false);
}
} catch (e) {
console.error(e);
alert('Error starting analysis');
setRunningState(false);
}
});
}
function setRunningState(running) {
isRunning = running;
startBtn.disabled = running;
startBtn.innerHTML = running ? '<i class="fa-solid fa-spinner fa-spin"></i> Running...' : '<i class="fa-solid fa-play"></i> Start Analysis';
if (running) {
statusDot.className = 'dot running';
statusText.innerText = 'Analysis in Progress';
} else {
statusDot.className = 'dot done';
statusText.innerText = 'Completed';
}
}
// --- Status Polling ---
function startPolling() {
if (pollingInterval) clearInterval(pollingInterval);
if (!currentSessionId) return;
pollingInterval = setInterval(async () => {
try {
const res = await fetch(`/api/status?session_id=${currentSessionId}`);
if (!res.ok) return;
const data = await res.json();
// Update Logs
logOutput.innerText = data.log || "Waiting for logs...";
// Auto scroll to bottom
const term = document.querySelector('.terminal-window');
if (term) term.scrollTop = term.scrollHeight;
if (!data.is_running && isRunning) {
// Just finished
setRunningState(false);
clearInterval(pollingInterval);
if (data.has_report) {
loadReport();
loadGallery(); // Load images when done
switchTab('report');
}
}
} catch (e) {
console.error('Polling error', e);
}
}, 2000);
}
async function loadReport() {
if (!currentSessionId) return;
try {
const res = await fetch(`/api/report?session_id=${currentSessionId}`);
const data = await res.json();
// Render Markdown
reportContainer.innerHTML = marked.parse(data.content);
// Fix images relative path for display if needed
// The backend should return a base_path or we handle it here
if (data.base_path) {
// Backend already handled replacement?
// The backend returns content with updated paths.
}
} catch (e) {
reportContainer.innerHTML = '<p class="error">Failed to load report.</p>';
console.error(e);
}
}
async function loadGallery() {
if (!currentSessionId) return;
// Switch to gallery view logic if we were already there
// But this is just data loading
try {
const res = await fetch(`/api/figures?session_id=${currentSessionId}`);
const data = await res.json();
const galleryGrid = document.getElementById('galleryContainer');
if (!data.figures || data.figures.length === 0) {
galleryGrid.innerHTML = `
<div class="empty-state">
<i class="fa-solid fa-images"></i>
<p>No images generated in this session.</p>
</div>
`;
return;
}
let html = '';
data.figures.forEach(fig => {
html += `
<div class="gallery-card">
<div class="img-wrapper">
<img src="${fig.web_url}" alt="${fig.filename}" onclick="window.open('${fig.web_url}', '_blank')">
</div>
<div class="card-content">
<h4>${fig.filename}</h4>
<p class="desc">${fig.description || 'No description'}</p>
${fig.analysis ? `<div class="analysis-box"><i class="fa-solid fa-magnifying-glass"></i> ${fig.analysis}</div>` : ''}
</div>
</div>
`;
});
galleryGrid.innerHTML = html;
} catch (e) {
console.error("Gallery load failed", e);
}
}
// --- Export Report ---
window.triggerExport = async function () {
if (!currentSessionId) {
alert("No active session to export.");
return;
}
const btn = document.getElementById('exportBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Zipping...';
btn.disabled = true;
try {
// Trigger download
// We can't use fetch for file download easily if we want browser to handle save dialog
// So we create a link approach or check status first
// Check if download is possible
const url = `/api/export?session_id=${currentSessionId}`;
const res = await fetch(url, { method: 'HEAD' });
if (res.ok) {
window.location.href = url;
} else {
alert("Export failed: No data available.");
}
} catch (e) {
alert("Export failed: " + e.message);
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// --- Tabs (Inner) ---
window.switchTab = function (tabName) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`button[onclick="switchTab('${tabName}')"]`);
if (btn) btn.classList.add('active');
const tab = document.getElementById(`${tabName}Tab`);
if (tab) tab.classList.add('active');
}
// --- View Navigation (Outer Sidebar) ---
window.switchView = function (viewName) {
// 1. Update Sidebar Buttons
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`button[onclick="switchView('${viewName}')"]`);
if (navBtn) navBtn.classList.add('active');
// 2. Switch Main Content Sections
const viewId = 'view' + viewName.charAt(0).toUpperCase() + viewName.slice(1);
document.querySelectorAll('.view-section').forEach(v => {
// Hide all
v.style.display = 'none';
v.classList.remove('active');
});
const activeView = document.getElementById(viewId);
if (activeView) {
// Analysis view uses flex for layout (logs scrolling), others use block
if (viewName === 'analysis') {
activeView.style.display = 'flex';
activeView.style.flexDirection = 'column';
} else {
activeView.style.display = 'block';
// If switching to gallery, reload it if session exists
if (viewName === 'gallery' && currentSessionId) {
loadGallery();
}
}
activeView.classList.add('active');
}
// 3. Toggle Sidebar Controls (only show Analysis Controls in analysis view)
const analysisControls = document.getElementById('analysisControls');
if (analysisControls) {
analysisControls.style.display = (viewName === 'analysis') ? 'block' : 'none';
}
}
// --- Tool Functions ---
window.triggerMerge = async function () {
const sourceDir = document.getElementById('mergeSource').value;
const resultDiv = document.getElementById('mergeResult');
resultDiv.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Merging...';
try {
const res = await fetch('/api/tools/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_dir: sourceDir, output_filename: 'merged_output.csv' })
});
const data = await res.json();
if (data.status === 'success') {
resultDiv.innerHTML = `<span style="color:var(--success)"><i class="fa-solid fa-check"></i> ${data.message}</span>`;
} else {
resultDiv.innerHTML = `<span style="color:var(--warning)">${data.message}</span>`;
}
} catch (e) {
resultDiv.innerHTML = `<span style="color:red">Error: ${e.message}</span>`;
}
}
window.triggerSort = async function () {
const targetFile = document.getElementById('sortTarget').value;
const resultDiv = document.getElementById('sortResult');
resultDiv.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sorting...';
try {
const res = await fetch('/api/tools/sort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_file: targetFile })
});
const data = await res.json();
if (data.status === 'success') {
resultDiv.innerHTML = `<span style="color:var(--success)"><i class="fa-solid fa-check"></i> ${data.message}</span>`;
} else {
resultDiv.innerHTML = `<span style="color:var(--warning)">${data.message}</span>`;
}
} catch (e) {
resultDiv.innerHTML = `<span style="color:red">Error: ${e.message}</span>`;
}
}
// --- Help Functions ---
window.fetchHelp = async function () {
const container = document.getElementById('helpContainer');
container.innerHTML = 'Loading...';
try {
const res = await fetch('/api/help/troubleshooting');
const data = await res.json();
container.innerHTML = marked.parse(data.content);
} catch (e) {
container.innerHTML = 'Failed to load guide.';
}
}

387
web/static/style.css Normal file
View File

@@ -0,0 +1,387 @@
:root {
--bg-color: #0f172a;
--sidebar-bg: rgba(30, 41, 59, 0.7);
--glass-border: 1px solid rgba(255, 255, 255, 0.1);
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--card-bg: rgba(30, 41, 59, 0.4);
--success: #10b981;
--warning: #f59e0b;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
color: var(--text-main);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 320px;
background: var(--sidebar-bg);
backdrop-filter: blur(12px);
border-right: var(--glass-border);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-main);
}
.brand i {
font-size: 1.5rem;
color: var(--primary-color);
}
.brand h1 {
font-size: 1.25rem;
font-weight: 700;
}
.control-group h3 {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 2rem 1rem;
text-align: center;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.02);
}
.upload-zone.dragover {
border-color: var(--primary-color);
background: rgba(99, 102, 241, 0.1);
}
.upload-zone i {
font-size: 2rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.upload-zone p {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
/* Inputs */
textarea {
width: 100%;
height: 120px;
background: rgba(0, 0, 0, 0.2);
border: var(--glass-border);
border-radius: 0.5rem;
padding: 0.75rem;
color: var(--text-main);
font-family: inherit;
resize: none;
font-size: 0.875rem;
margin-bottom: 1rem;
}
textarea:focus {
outline: none;
border-color: var(--primary-color);
}
/* Buttons */
.btn {
width: 100%;
padding: 0.75rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn.primary {
background: var(--primary-color);
color: white;
}
.btn.primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn.secondary {
background: rgba(255, 255, 255, 0.1);
color: var(--text-main);
font-size: 0.8rem;
padding: 0.5rem;
width: auto;
margin: 0 auto;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Status */
.status-indicator {
margin-top: auto;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding-top: 1rem;
border-top: var(--glass-border);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-muted);
}
.dot.running {
background-color: var(--warning);
box-shadow: 0 0 8px var(--warning);
animation: pulse 1s infinite;
}
.dot.done {
background-color: var(--success);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1rem;
overflow: hidden;
min-height: 0;
/* Crucial for nested flex scroll */
}
/* View Section needs to fill space for analysis view */
.view-section {
flex: 1;
display: none;
overflow: hidden;
min-height: 0;
}
.view-section.active {
display: flex;
/* Changed from block to flex for analysis view layout */
flex-direction: column;
}
.tabs {
flex-shrink: 0;
/* Prevent tabs from shrinking */
display: flex;
gap: 1rem;
border-bottom: var(--glass-border);
padding-bottom: 0.5rem;
}
/* ... existing tab-btn styles ... */
.tab-content {
display: none;
flex: 1;
overflow: hidden;
min-height: 0;
/* Crucial for nested flex scroll */
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* Logs */
.terminal-window {
background: #000;
border-radius: 0.75rem;
padding: 1rem;
flex: 1;
overflow-y: auto;
min-height: 0;
/* Crucial for nested flex scroll */
border: var(--glass-border);
font-family: 'JetBrains Mono', 'Menlo', monospace;
font-size: 0.85rem;
color: #4ade80;
line-height: 1.5;
}
#logOutput {
white-space: pre-wrap;
}
/* Report */
#reportContainer {
flex: 1;
overflow-y: auto;
background: white;
color: #1e293b;
border-radius: 0.75rem;
padding: 2rem;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
gap: 1rem;
}
.empty-state i {
font-size: 3rem;
opacity: 0.5;
}
/* Markdown Styles Override for Report */
.markdown-body img {
max-width: 100%;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin: 1rem 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: #111827;
}
.markdown-body p {
margin-bottom: 1em;
line-height: 1.7;
}
/* New: Tools Grid */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
overflow-y: auto;
}
.tool-card {
background: var(--card-bg);
border: var(--glass-border);
border-radius: 1rem;
padding: 1.5rem;
transition: transform 0.2s;
}
.tool-card:hover {
transform: translateY(-2px);
border-color: var(--primary-color);
}
.tool-icon {
font-size: 2rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.tool-card h3 {
margin-bottom: 0.5rem;
}
.tool-card p {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
min-height: 3em;
}
.tool-actions {
display: flex;
gap: 0.5rem;
}
.input-sm {
background: rgba(0, 0, 0, 0.3);
border: var(--glass-border);
color: white;
padding: 0.5rem;
border-radius: 0.5rem;
flex: 1;
}
/* Help Styles */
.help-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.help-body {
background: white;
color: #1e293b;
padding: 2rem;
border-radius: 0.75rem;
overflow-y: auto;
flex: 1;
}