更新前端页交互模式

This commit is contained in:
2026-01-31 20:27:17 +08:00
parent 5eb13324c2
commit e9644360ce
7 changed files with 1050 additions and 937 deletions

View File

@@ -20,7 +20,7 @@ class LLMConfig:
provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc. provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc.
api_key: str = os.environ.get("OPENAI_API_KEY", "sk-2187174de21548b0b8b0c92129700199") api_key: str = os.environ.get("OPENAI_API_KEY", "sk-2187174de21548b0b8b0c92129700199")
base_url: str = os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1") base_url: str = os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1")
model: str = os.environ.get("OPENAI_MODEL", "claude-sonnet-4-5") model: str = os.environ.get("OPENAI_MODEL", "gemini-3-flash")
temperature: float = 0.5 temperature: float = 0.5
max_tokens: int = 8192 # 降低默认值,避免某些API不支持过大的值 max_tokens: int = 8192 # 降低默认值,避免某些API不支持过大的值

View File

@@ -1,7 +1,6 @@
data_analysis_system_prompt = """你是一个专业的数据分析助手运行在Jupyter Notebook环境中能够根据用户需求生成和执行Python数据分析代码。 data_analysis_system_prompt = """你是一个专业的数据分析助手运行在Jupyter Notebook环境中能够根据用户需求生成和执行Python数据分析代码。
[TARGET] **核心使命** [TARGET] **核心使命**+、安全的数据分析代码。
- 接收自然语言需求,分阶段生成高效、安全的数据分析代码。
- 深度挖掘数据,不仅仅是绘图,更要发现数据背后的业务洞察。 - 深度挖掘数据,不仅仅是绘图,更要发现数据背后的业务洞察。
- 输出高质量、可落地的业务分析报告。 - 输出高质量、可落地的业务分析报告。

View File

@@ -5,6 +5,7 @@ import threading
import glob import glob
import uuid import uuid
import json import json
from datetime import datetime
from typing import Optional, Dict, List from typing import Optional, Dict, List
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -18,8 +19,8 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data_analysis_agent import DataAnalysisAgent from data_analysis_agent import DataAnalysisAgent
from config.llm_config import LLMConfig from config.llm_config import LLMConfig
from utils.create_session_dir import create_session_output_dir from utils.create_session_dir import create_session_output_dir
from merge_excel import merge_excel_files from config.llm_config import LLMConfig
from sort_csv import sort_csv_by_time from utils.create_session_dir import create_session_output_dir
app = FastAPI(title="IOV Data Analysis Agent") app = FastAPI(title="IOV Data Analysis Agent")
@@ -55,6 +56,7 @@ class SessionData:
self.last_updated: str = "" self.last_updated: str = ""
self.user_requirement: str = "" self.user_requirement: str = ""
self.file_list: List[str] = [] self.file_list: List[str] = []
self.reusable_script: Optional[str] = None # 新增:可复用脚本路径
class SessionManager: class SessionManager:
@@ -70,7 +72,74 @@ class SessionManager:
def get_session(self, session_id: str) -> Optional[SessionData]: def get_session(self, session_id: str) -> Optional[SessionData]:
return self.sessions.get(session_id) if session_id in self.sessions:
return self.sessions[session_id]
# Fallback: Try to reconstruct from disk for history sessions
output_dir = os.path.join("outputs", f"session_{session_id}")
if os.path.exists(output_dir) and os.path.isdir(output_dir):
return self._reconstruct_session(session_id, output_dir)
return None
def _reconstruct_session(self, session_id: str, output_dir: str) -> SessionData:
"""从磁盘目录重建会话对象"""
session = SessionData(session_id)
session.output_dir = output_dir
session.is_running = False
session.current_round = session.max_rounds
session.progress_percentage = 100.0
session.status_message = "已完成 (历史记录)"
# Recover Log
log_path = os.path.join(output_dir, "process.log")
if os.path.exists(log_path):
session.log_file = log_path
# Recover Report
# 宽容查找:扫描所有 .md 文件,优先取包含 "report" 或 "报告" 的文件
md_files = glob.glob(os.path.join(output_dir, "*.md"))
if md_files:
# 默认取第一个
chosen = md_files[0]
# 尝试找更好的匹配
for md in md_files:
fname = os.path.basename(md).lower()
if "report" in fname or "报告" in fname:
chosen = md
break
session.generated_report = chosen
# Recover Script (查找可能的脚本文件)
possible_scripts = ["data_analysis_script.py", "script.py", "analysis_script.py"]
for s in possible_scripts:
p = os.path.join(output_dir, s)
if os.path.exists(p):
session.reusable_script = p
break
# Recover Results (images etc)
results_json = os.path.join(output_dir, "results.json")
if os.path.exists(results_json):
try:
with open(results_json, "r") as f:
session.analysis_results = json.load(f)
except:
pass
# Recover Metadata
try:
stat = os.stat(output_dir)
dt = datetime.fromtimestamp(stat.st_ctime)
session.created_at = dt.strftime("%Y-%m-%d %H:%M:%S")
except:
pass
# Cache it
with self.lock:
self.sessions[session_id] = session
return session
def list_sessions(self): def list_sessions(self):
return list(self.sessions.keys()) return list(self.sessions.keys())
@@ -99,7 +168,10 @@ class SessionManager:
"max_rounds": session.max_rounds, "max_rounds": session.max_rounds,
"created_at": session.created_at, "created_at": session.created_at,
"last_updated": session.last_updated, "last_updated": session.last_updated,
"user_requirement": session.user_requirement[:100] + "..." if len(session.user_requirement) > 100 else session.user_requirement "created_at": session.created_at,
"last_updated": session.last_updated,
"user_requirement": session.user_requirement[:100] + "..." if len(session.user_requirement) > 100 else session.user_requirement,
"script_path": session.reusable_script # 新增:返回脚本路径
} }
return None return None
@@ -227,6 +299,7 @@ def run_analysis_task(session_id: str, files: list, user_requirement: str, is_fo
session.generated_report = result.get("report_file_path", None) session.generated_report = result.get("report_file_path", None)
session.analysis_results = result.get("analysis_results", []) session.analysis_results = result.get("analysis_results", [])
session.reusable_script = result.get("reusable_script_path", None) # 新增:保存脚本路径
# Save results to json for persistence # Save results to json for persistence
with open(os.path.join(session_output_dir, "results.json"), "w") as f: with open(os.path.join(session_output_dir, "results.json"), "w") as f:
@@ -311,7 +384,8 @@ async def get_status(session_id: str = Query(..., description="Session ID")):
"is_running": session.is_running, "is_running": session.is_running,
"log": log_content, "log": log_content,
"has_report": session.generated_report is not None, "has_report": session.generated_report is not None,
"report_path": session.generated_report "report_path": session.generated_report,
"script_path": session.reusable_script # 新增:返回脚本路径
} }
@app.get("/api/export") @app.get("/api/export")
@@ -453,71 +527,24 @@ async def export_report(session_id: str = Query(..., description="Session ID")):
media_type='application/zip' media_type='application/zip'
) )
@app.get("/api/download_script")
async def download_script(session_id: str = Query(..., description="Session ID")):
"""下载生成的Python脚本"""
session = session_manager.get_session(session_id)
if not session or not session.reusable_script:
raise HTTPException(status_code=404, detail="Script not found")
if not os.path.exists(session.reusable_script):
raise HTTPException(status_code=404, detail="Script file missing on server")
return FileResponse(
path=session.reusable_script,
filename=os.path.basename(session.reusable_script),
media_type='text/x-python'
)
# --- Tools API --- # --- 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}"}
# --- 新增API端点 --- # --- 新增API端点 ---
@@ -553,6 +580,61 @@ async def delete_specific_session(session_id: str):
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
return {"status": "deleted", "session_id": session_id} return {"status": "deleted", "session_id": session_id}
return {"status": "deleted", "session_id": session_id}
# --- History API ---
@app.get("/api/history")
async def get_history():
"""
Get list of past analysis sessions from outputs directory
"""
history = []
output_base = "outputs"
if not os.path.exists(output_base):
return {"history": []}
try:
# Scan for session_* directories
for entry in os.scandir(output_base):
if entry.is_dir() and entry.name.startswith("session_"):
# Extract timestamp from folder name: session_20250101_120000
session_id = entry.name.replace("session_", "")
# Check creation time or extract from name
try:
# Try to parse timestamp from ID if it matches format
# Format: YYYYMMDD_HHMMSS
timestamp_str = session_id
dt = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
display_time = dt.strftime("%Y-%m-%d %H:%M:%S")
sort_key = dt.timestamp()
except ValueError:
# Fallback to file creation time
sort_key = entry.stat().st_ctime
display_time = datetime.fromtimestamp(sort_key).strftime("%Y-%m-%d %H:%M:%S")
history.append({
"id": session_id,
"timestamp": display_time,
"sort_key": sort_key,
"name": f"Session {display_time}"
})
# Sort by latest first
history.sort(key=lambda x: x["sort_key"], reverse=True)
# Cleanup internal sort key
for item in history:
del item["sort_key"]
return {"history": history}
except Exception as e:
print(f"Error scanning history: {e}")
return {"history": []}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

535
web/static/clean_style.css Normal file
View File

@@ -0,0 +1,535 @@
/* Clean Style - IOV Data Analysis Agent */
:root {
--primary-color: #2563EB;
/* Tech Blue */
--primary-hover: #1D4ED8;
--bg-color: #FFFFFF;
--sidebar-bg: #F9FAFB;
--text-primary: #111827;
--text-secondary: #6B7280;
--border-color: #E5E7EB;
--card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
color: var(--text-primary);
background-color: var(--bg-color);
line-height: 1.5;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
/* Compact width */
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 1rem;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.brand i {
color: var(--primary-color);
font-size: 1.5rem;
}
.nav-menu {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
overflow-y: hidden;
/* Let history list handle scroll */
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
font-size: 0.95rem;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background-color: #F3F4F6;
color: var(--text-primary);
}
.nav-item.active {
background-color: #EFF6FF;
color: var(--primary-color);
font-weight: 500;
}
.nav-item i {
width: 1.25rem;
text-align: center;
}
.nav-divider {
height: 1px;
background-color: var(--border-color);
margin: 1rem 0 0.5rem 0;
}
.nav-section-title {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
padding-left: 0.5rem;
}
/* History List */
.history-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-right: 5px;
}
.history-item {
font-size: 0.85rem;
color: var(--text-secondary);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 0.5rem;
}
.history-item:hover {
background-color: #F3F4F6;
color: var(--text-primary);
}
.history-item.active {
background-color: #EFF6FF;
color: var(--primary-color);
}
.status-bar {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #D1D5DB;
}
.status-dot.running {
background-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background-color: #FFFFFF;
}
.header {
height: 64px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 2rem;
background-color: #FFFFFF;
}
.header h2 {
font-size: 1.25rem;
font-weight: 600;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 2rem;
background-color: #ffffff;
}
/* Sections & Panel */
.section {
display: none;
max-width: 1000px;
margin: 0 auto;
}
.section.active {
display: block;
}
.analysis-grid {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
height: calc(100vh - 64px - 4rem);
}
.panel {
background: #FFFFFF;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.panel-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Forms */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-input,
.form-textarea {
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.9rem;
color: var(--text-primary);
outline: none;
transition: border-color 0.2s;
width: 100%;
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: white;
border-color: var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: #F9FAFB;
border-color: #D1D5DB;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* Upload Area */
.upload-area {
border: 2px dashed var(--border-color);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background-color: #F9FAFB;
}
.upload-area:hover,
.upload-area.dragover {
border-color: var(--primary-color);
background-color: #EFF6FF;
}
.upload-icon {
font-size: 1.5rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.file-list {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-primary);
background: #FFFFFF;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
}
/* Tabs */
.tabs {
display: flex;
gap: 1rem;
margin-left: 1rem;
}
.tab {
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
font-weight: 500;
}
/* Log & Report Content */
.output-container {
flex: 1;
overflow-y: hidden;
/* Individual tabs scroll */
background: #F9FAFB;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
position: relative;
display: flex;
flex-direction: column;
}
#logsTab {
background-color: #1a1b26;
color: #a9b1d6;
font-family: 'JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
padding: 1.5rem;
}
.log-content {
font-family: inherit;
font-size: 0.85rem;
white-space: pre-wrap;
line-height: 1.6;
margin: 0;
}
.report-content {
font-size: 0.95rem;
line-height: 1.7;
color: #1F2937;
}
.report-content img {
max-width: 100%;
border-radius: 0.375rem;
margin: 1rem 0;
box-shadow: var(--card-shadow);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
/* Utilities */
.hidden {
display: none !important;
}
/* Gallery Carousel */
.carousel-container {
position: relative;
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #F3F4F6;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
}
.carousel-slide {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.carousel-slide img {
max-width: 100%;
max-height: 500px;
object-fit: contain;
border-radius: 0.25rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
background: white;
}
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--border-color);
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
color: var(--text-primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.carousel-btn:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-50%) scale(1.1);
}
.carousel-btn.prev {
left: 1rem;
}
.carousel-btn.next {
right: 1rem;
}
.image-info {
width: 100%;
text-align: center;
color: var(--text-primary);
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
.image-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.image-desc {
font-size: 0.9rem;
color: var(--text-secondary);
}

View File

@@ -5,18 +5,20 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IOV Data Analysis Agent</title> <title>IOV Data Analysis Agent</title>
<link rel="stylesheet" href="/static/style.css?v=2.0"> <link rel="stylesheet" href="/static/clean_style.css">
<!-- Google Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link <link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"> rel="stylesheet">
<!-- Markdown Parser -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <!-- Icons -->
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head> </head>
<body> <body>
@@ -24,162 +26,140 @@
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<i class="fa-solid fa-robot"></i> <i class="fa-solid fa-cube"></i>
<h1>IOV Agent</h1> <span>IOV Agent</span>
</div> </div>
<!-- Navigation --> <nav class="nav-menu">
<nav class="main-nav"> <button class="nav-item active" onclick="switchView('analysis')">
<button class="nav-btn active" onclick="switchView('analysis')">
<i class="fa-solid fa-chart-line"></i> Analysis <i class="fa-solid fa-chart-line"></i> Analysis
</button> </button>
<button class="nav-btn" onclick="switchView('tools')">
<i class="fa-solid fa-toolbox"></i> Data Tools <div class="nav-divider"></div>
</button> <div class="nav-section-title">History</div>
<button class="nav-btn" onclick="switchView('gallery')"> <div id="historyList" class="history-list">
<i class="fa-solid fa-images"></i> Gallery <!-- History items loaded via JS -->
</button> <div style="padding:0.5rem; font-size:0.8rem; color:#9CA3AF;">Loading...</div>
<button class="nav-btn" onclick="switchView('help')"> </div>
<i class="fa-solid fa-circle-question"></i> Help
</button>
</nav> </nav>
<!-- Analysis Controls (Visible only in Analysis View) --> <div class="status-bar">
<div id="analysisControls" class="control-group-wrapper"> <div id="statusDot" class="status-dot"></div>
<div class="control-group"> <span id="statusText">Ready</span>
<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> </div>
</aside> </aside>
<!-- Main Content Area --> <!-- Main Content -->
<main class="main-content"> <main class="main-content">
<header class="header">
<h2 id="pageTitle">Analysis Dashboard</h2>
</header>
<!-- VIEW: ANALYSIS --> <div class="content-area">
<div id="viewAnalysis" class="view-section active"> <!-- VIEW: ANALYSIS -->
<div class="tabs"> <div id="viewAnalysis" class="section active">
<button class="tab-btn active" onclick="switchTab('logs')"> <div class="analysis-grid">
<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 --> <!-- Configuration Panel -->
<div id="logsTab" class="tab-content active"> <div class="panel">
<div class="terminal-window"> <div class="panel-title">
<pre id="logOutput">Waiting to start...</pre> <span>Configuration</span>
</div> </div>
</div>
<!-- Report View --> <div class="form-group">
<div id="reportTab" class="tab-content"> <label class="form-label">1. Data Upload</label>
<div id="reportContainer" class="markdown-body"> <div id="uploadZone" class="upload-area">
<div class="empty-state"> <i class="fa-solid fa-cloud-arrow-up upload-icon"></i>
<i class="fa-solid fa-chart-simple"></i> <p>Click or Drag CSV/Excel Files</p>
<p>No report generated yet.</p> <div id="fileList" class="file-list"></div>
</div>
<input type="file" id="fileInput" multiple accept=".csv,.xlsx,.xls" hidden>
</div>
<div class="form-group">
<label class="form-label">2. Requirement</label>
<textarea id="requirementInput" class="form-textarea"
placeholder="Describe what you want to analyze..."></textarea>
</div>
<button id="startBtn" class="btn btn-primary" style="margin-top: 1rem; width: 100%;">
<i class="fa-solid fa-play"></i> Start Analysis
</button>
</div> </div>
<!-- Output Panel -->
<div class="panel" style="overflow:hidden; display:flex; flex-direction:column;">
<div class="panel-title" style="margin-bottom:0.5rem;">
<span>Output</span>
<div class="tabs">
<div class="tab active" onclick="switchTab('logs')">Live Log</div>
<div class="tab" onclick="switchTab('report')">Report</div>
<div class="tab" onclick="switchTab('gallery')">Gallery</div>
</div>
<button id="downloadScriptBtn" class="btn btn-sm btn-secondary hidden"
onclick="downloadScript()" style="margin-left:auto;">
<i class="fa-solid fa-code"></i> Script
</button>
</div>
<div class="output-container" id="outputContainer">
<!-- Logs Tab -->
<div id="logsTab" class="tab-content active" style="height:100%; overflow-y:auto;">
<pre id="logOutput" class="log-content">Waiting to start...</pre>
</div>
<!-- Report Tab -->
<div id="reportTab" class="tab-content hidden" style="height:100%; overflow-y:auto;">
<div id="reportContainer" class="report-content markdown-body">
<div class="empty-state">
<p>Report will appear here after analysis.</p>
</div>
</div>
<div id="followUpSection" class="hidden"
style="margin-top:2rem; border-top:1px solid var(--border-color); padding-top:1rem;">
<div class="form-group">
<label class="form-label">Follow-up Analysis</label>
<div style="display:flex; gap:0.5rem;">
<input type="text" id="followUpInput" class="form-input"
placeholder="Ask a follow-up question...">
<button class="btn btn-primary btn-sm"
onclick="sendFollowUp()">Send</button>
</div>
</div>
</div>
<div style="margin-top:1rem; text-align:right">
<button id="exportBtn" class="btn btn-secondary btn-sm"
onclick="triggerExport()">
<i class="fa-solid fa-download"></i> Export ZIP
</button>
</div>
</div>
<!-- Gallery Tab -->
<div id="galleryTab" class="tab-content hidden"
style="height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;">
<div class="carousel-container">
<button class="carousel-btn prev" onclick="prevImage()"><i
class="fa-solid fa-chevron-left"></i></button>
<div class="carousel-slide" id="carouselSlide">
<p class="placeholder-text" style="color:var(--text-secondary);">No images
generated.</p>
</div>
<button class="carousel-btn next" onclick="nextImage()"><i
class="fa-solid fa-chevron-right"></i></button>
</div>
<div class="image-info" id="imageInfo" style="margin-top:1rem; text-align:center;">
<!-- Title/Desc -->
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</main>
<!-- Follow-up Chat Section -->
<div id="followUpSection" class="chat-input-container" style="display: none;">
<div class="input-wrapper">
<textarea id="followUpInput"
placeholder="Follow-up question... (e.g. 'Analyze the error codes')"></textarea>
<button id="sendFollowUpBtn" class="btn primary" onclick="sendFollowUp()">
<i class="fa-solid fa-paper-plane"></i>
</button>
</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> </div>
<script src="/static/script.js"></script> <script src="/static/script.js"></script>

View File

@@ -1,4 +1,5 @@
// DOM Elements
const uploadZone = document.getElementById('uploadZone'); const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList'); const fileList = document.getElementById('fileList');
@@ -8,7 +9,7 @@ const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText'); const statusText = document.getElementById('statusText');
const logOutput = document.getElementById('logOutput'); const logOutput = document.getElementById('logOutput');
const reportContainer = document.getElementById('reportContainer'); const reportContainer = document.getElementById('reportContainer');
const galleryContainer = document.getElementById('galleryContainer'); const downloadScriptBtn = document.getElementById('downloadScriptBtn');
let isRunning = false; let isRunning = false;
let pollingInterval = null; let pollingInterval = null;
@@ -26,9 +27,12 @@ if (uploadZone) {
uploadZone.classList.remove('dragover'); uploadZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files); handleFiles(e.dataTransfer.files);
}); });
uploadZone.addEventListener('click', () => fileInput.click());
} }
if (fileInput) { if (fileInput) {
fileInput.addEventListener('change', (e) => handleFiles(e.target.files)); fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
fileInput.addEventListener('click', (e) => e.stopPropagation()); // Prevent bubbling to uploadZone
} }
async function handleFiles(files) { async function handleFiles(files) {
@@ -40,9 +44,8 @@ async function handleFiles(files) {
for (const file of files) { for (const file of files) {
formData.append('files', file); formData.append('files', file);
const fileItem = document.createElement('div'); const fileItem = document.createElement('div');
fileItem.innerText = `📄 ${file.name}`; fileItem.className = 'file-item';
fileItem.style.fontSize = '0.8rem'; fileItem.innerHTML = `<i class="fa-regular fa-file-excel"></i> ${file.name}`;
fileItem.style.color = '#fff';
fileList.appendChild(fileItem); fileList.appendChild(fileItem);
} }
@@ -52,8 +55,7 @@ async function handleFiles(files) {
body: formData body: formData
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); console.log('Upload success');
console.log('Upload success:', data);
} else { } else {
alert('Upload failed'); alert('Upload failed');
} }
@@ -63,63 +65,75 @@ async function handleFiles(files) {
} }
} }
// --- Start Analysis --- // --- Analysis Logic ---
if (startBtn) { if (startBtn) {
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', startAnalysis);
if (isRunning) return; }
const requirement = requirementInput.value.trim(); async function startAnalysis() {
if (!requirement) { if (isRunning) return;
alert('Please enter analysis requirement');
return;
}
setRunningState(true); const requirement = requirementInput.value.trim();
if (!requirement) {
alert('Please enter analysis requirement');
return;
}
try { setRunningState(true);
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement })
});
if (res.ok) { try {
const data = await res.json(); const res = await fetch('/api/start', {
currentSessionId = data.session_id; // Store Session ID method: 'POST',
console.log("Started Session:", currentSessionId); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement })
});
startPolling(); if (res.ok) {
switchTab('logs'); const data = await res.json();
} else { currentSessionId = data.session_id;
const err = await res.json(); console.log("Started Session:", currentSessionId);
alert('Failed to start: ' + err.detail);
setRunningState(false); startPolling();
} switchTab('logs');
} catch (e) { } else {
console.error(e); const err = await res.json();
alert('Error starting analysis'); alert('Failed to start: ' + err.detail);
setRunningState(false); setRunningState(false);
} }
}); } catch (e) {
console.error(e);
alert('Error starting analysis');
setRunningState(false);
}
} }
function setRunningState(running) { function setRunningState(running) {
isRunning = running; isRunning = running;
startBtn.disabled = 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) { if (running) {
statusDot.className = 'dot running'; startBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Analysis in Progress...';
statusText.innerText = 'Analysis in Progress'; statusDot.className = 'status-dot running';
if (document.getElementById('followUpSection')) document.getElementById('followUpSection').style.display = 'none'; statusText.innerText = 'Analyzing';
statusText.style.color = 'var(--primary-color)';
// Hide follow-up and download during run
const followUpSection = document.getElementById('followUpSection');
if (followUpSection) followUpSection.classList.add('hidden');
if (downloadScriptBtn) downloadScriptBtn.classList.add('hidden');
} else { } else {
statusDot.className = 'dot done'; startBtn.innerHTML = '<i class="fa-solid fa-play"></i> Start Analysis';
statusDot.className = 'status-dot';
statusText.innerText = 'Completed'; statusText.innerText = 'Completed';
if (currentSessionId && document.getElementById('followUpSection')) document.getElementById('followUpSection').style.display = 'flex'; statusText.style.color = 'var(--text-secondary)';
const followUpSection = document.getElementById('followUpSection');
if (currentSessionId && followUpSection) {
followUpSection.classList.remove('hidden');
}
} }
} }
// --- Status Polling ---
function startPolling() { function startPolling() {
if (pollingInterval) clearInterval(pollingInterval); if (pollingInterval) clearInterval(pollingInterval);
if (!currentSessionId) return; if (!currentSessionId) return;
@@ -131,20 +145,31 @@ function startPolling() {
const data = await res.json(); const data = await res.json();
// Update Logs // Update Logs
logOutput.innerText = data.log || "Waiting for logs..."; logOutput.innerText = data.log || "Waiting for output...";
// Auto scroll to bottom
const term = document.querySelector('.terminal-window'); // Auto scroll
if (term) term.scrollTop = term.scrollHeight; const logTab = document.getElementById('logsTab');
if (logTab) logTab.scrollTop = logTab.scrollHeight;
if (!data.is_running && isRunning) { if (!data.is_running && isRunning) {
// Just finished // Finished
setRunningState(false); setRunningState(false);
clearInterval(pollingInterval); clearInterval(pollingInterval);
if (data.has_report) { if (data.has_report) {
loadReport(); await loadReport();
loadGallery(); // Load images when done
// 强制跳转到 Report Tab
switchTab('report'); switchTab('report');
console.log("Analysis done, switched to report tab");
}
// Check for script
if (data.script_path) {
if (downloadScriptBtn) {
downloadScriptBtn.classList.remove('hidden');
downloadScriptBtn.style.display = 'inline-flex';
}
} }
} }
} catch (e) { } catch (e) {
@@ -153,129 +178,122 @@ function startPolling() {
}, 2000); }, 2000);
} }
// --- Report Logic ---
async function loadReport() { async function loadReport() {
if (!currentSessionId) return; if (!currentSessionId) return;
try { try {
const res = await fetch(`/api/report?session_id=${currentSessionId}`); const res = await fetch(`/api/report?session_id=${currentSessionId}`);
const data = await res.json(); const data = await res.json();
// Render Markdown if (!data.content || data.content === "Report not ready.") {
reportContainer.innerHTML = marked.parse(data.content); reportContainer.innerHTML = '<div class="empty-state"><p>Analysis in progress or no report generated yet.</p></div>';
} else {
// Fix images relative path for display if needed reportContainer.innerHTML = marked.parse(data.content);
// 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) { } catch (e) {
reportContainer.innerHTML = '<p class="error">Failed to load report.</p>'; reportContainer.innerHTML = '<p class="error">Failed to load report.</p>';
console.error(e);
} }
} }
// --- Gallery Logic ---
let galleryImages = [];
let currentImageIndex = 0;
async function loadGallery() { async function loadGallery() {
if (!currentSessionId) return; if (!currentSessionId) return;
// Switch to gallery view logic if we were already there
// But this is just data loading
try { try {
const res = await fetch(`/api/figures?session_id=${currentSessionId}`); const res = await fetch(`/api/figures?session_id=${currentSessionId}`);
const data = await res.json(); const data = await res.json();
const galleryGrid = document.getElementById('galleryContainer'); galleryImages = data.figures || [];
if (!data.figures || data.figures.length === 0) { currentImageIndex = 0;
galleryGrid.innerHTML = ` renderGalleryImage();
<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) { } catch (e) {
console.error("Gallery load failed", e); console.error("Gallery load failed", e);
document.getElementById('carouselSlide').innerHTML = '<p class="error">Failed to load images.</p>';
} }
} }
// --- Export Report --- function renderGalleryImage() {
const slide = document.getElementById('carouselSlide');
const info = document.getElementById('imageInfo');
if (galleryImages.length === 0) {
slide.innerHTML = '<p class="placeholder-text" style="color:var(--text-secondary);">No images generated in this session.</p>';
info.innerHTML = '';
return;
}
const img = galleryImages[currentImageIndex];
// Image
slide.innerHTML = `<img src="${img.web_url}" alt="${img.filename}" onclick="window.open('${img.web_url}', '_blank')">`;
// Info
info.innerHTML = `
<div class="image-title">${img.filename} (${currentImageIndex + 1}/${galleryImages.length})</div>
<div class="image-desc">${img.description || 'No description available.'}</div>
${img.analysis ? `<div style="font-size:0.8rem; margin-top:0.5rem; color:#4B5563; background:#F3F4F6; padding:0.5rem; border-radius:4px;">${img.analysis}</div>` : ''}
`;
}
window.prevImage = function () {
if (galleryImages.length === 0) return;
currentImageIndex = (currentImageIndex - 1 + galleryImages.length) % galleryImages.length;
renderGalleryImage();
}
window.nextImage = function () {
if (galleryImages.length === 0) return;
currentImageIndex = (currentImageIndex + 1) % galleryImages.length;
renderGalleryImage();
}
// --- Download Script ---
window.downloadScript = async function () {
if (!currentSessionId) return;
const link = document.createElement('a');
link.href = `/api/download_script?session_id=${currentSessionId}`;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// --- Export Report --- // --- Export Report ---
window.triggerExport = async function () { window.triggerExport = async function () {
if (!currentSessionId) { if (!currentSessionId) {
alert("No active session to export."); alert("No active session to export.");
return; return;
} }
const btn = document.getElementById('exportBtn'); const btn = document.getElementById('exportBtn');
const originalText = btn.innerHTML; const originalContent = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Zipping...'; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Zipping...';
btn.disabled = true; btn.disabled = true;
try { try {
const url = `/api/export?session_id=${currentSessionId}`; const url = `/api/export?session_id=${currentSessionId}`;
const res = await fetch(url); // Default GET window.open(url, '_blank');
if (res.ok) {
const blob = await res.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `analysis_export_${currentSessionId}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(downloadUrl);
} else {
// Try to parse error
let errMsg = "Unknown error";
try {
const err = await res.json();
errMsg = err.detail || errMsg;
} catch (jsonErr) {
errMsg = res.statusText;
}
alert("Export failed: " + errMsg);
}
} catch (e) { } catch (e) {
alert("Export failed: " + e.message); alert("Export failed: " + e.message);
} finally { } finally {
btn.innerHTML = originalText; setTimeout(() => {
btn.disabled = false; btn.innerHTML = originalContent;
btn.disabled = false;
}, 2000);
} }
} }
// --- Follow-up Chat --- // --- Follow-up Chat ---
window.sendFollowUp = async function () { window.sendFollowUp = async function () {
if (!currentSessionId || isRunning) return; if (!currentSessionId || isRunning) return;
const input = document.getElementById('followUpInput'); const input = document.getElementById('followUpInput');
const message = input.value.trim(); const message = input.value.trim();
if (!message) return; if (!message) return;
// UI Loading state
const btn = document.getElementById('sendFollowUpBtn');
btn.disabled = true;
input.disabled = true; input.disabled = true;
try { try {
const res = await fetch('/api/chat', { const res = await fetch('/api/chat', {
method: 'POST', method: 'POST',
@@ -289,128 +307,128 @@ window.sendFollowUp = async function () {
startPolling(); startPolling();
switchTab('logs'); switchTab('logs');
} else { } else {
const err = await res.json(); alert('Failed to send request');
alert('Error: ' + err.detail);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Failed to send request');
} finally { } finally {
btn.disabled = false;
input.disabled = false; input.disabled = false;
} }
} }
// --- History Logic ---
async function loadHistory() {
const list = document.getElementById('historyList');
if (!list) return;
try {
const res = await fetch('/api/history');
const data = await res.json();
// --- Tabs (Inner) --- if (data.history.length === 0) {
window.switchTab = function (tabName) { list.innerHTML = '<div style="padding:0.5rem; font-size:0.8rem; color:#9CA3AF;">No history yet</div>';
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); return;
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); }
const btn = document.querySelector(`button[onclick="switchTab('${tabName}')"]`); let html = '';
if (btn) btn.classList.add('active'); data.history.forEach(item => {
// item: {id, timestamp, name}
const timeStr = item.timestamp.split(' ')[0]; // Just date for compactness
html += `
<div class="history-item" onclick="loadSession('${item.id}')" id="hist-${item.id}">
<i class="fa-regular fa-clock"></i>
<span>${item.id}</span>
</div>
`;
});
list.innerHTML = html;
const tab = document.getElementById(`${tabName}Tab`); } catch (e) {
if (tab) tab.classList.add('active'); console.error("Failed to load history", e);
}
} }
// --- View Navigation (Outer Sidebar) --- window.loadSession = async function (sessionId) {
window.switchView = function (viewName) { if (isRunning) {
// 1. Update Sidebar Buttons alert("Analysis in progress, please wait.");
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); return;
const navBtn = document.querySelector(`button[onclick="switchView('${viewName}')"]`); }
if (navBtn) navBtn.classList.add('active');
// 2. Switch Main Content Sections currentSessionId = sessionId;
const viewId = 'view' + viewName.charAt(0).toUpperCase() + viewName.slice(1);
document.querySelectorAll('.view-section').forEach(v => { // Update active class
// Hide all document.querySelectorAll('.history-item').forEach(el => el.classList.remove('active'));
v.style.display = 'none'; const activeItem = document.getElementById(`hist-${sessionId}`);
v.classList.remove('active'); if (activeItem) activeItem.classList.add('active');
});
const activeView = document.getElementById(viewId); // Reset UI
if (activeView) { logOutput.innerText = "Loading session data...";
// Analysis view uses flex for layout (logs scrolling), others use block reportContainer.innerHTML = "";
if (viewName === 'analysis') { if (downloadScriptBtn) downloadScriptBtn.classList.add('hidden');
activeView.style.display = 'flex';
activeView.style.flexDirection = 'column'; // Fetch Status to get logs and check report
} else { try {
activeView.style.display = 'block'; const res = await fetch(`/api/status?session_id=${sessionId}`);
// If switching to gallery, reload it if session exists if (res.ok) {
if (viewName === 'gallery' && currentSessionId) { const data = await res.json();
loadGallery(); logOutput.innerText = data.log || "No logs available.";
// Auto scroll log
const logTab = document.getElementById('logsTab');
if (logTab) logTab.scrollTop = logTab.scrollHeight;
if (data.has_report) {
await loadReport();
// Check if script exists
if (data.script_path && downloadScriptBtn) {
downloadScriptBtn.classList.remove('hidden');
downloadScriptBtn.style.display = 'inline-flex';
}
switchTab('report');
} else {
switchTab('logs');
} }
} }
activeView.classList.add('active'); } catch (e) {
} logOutput.innerText = "Error loading session.";
// 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 --- // Initialize
window.triggerMerge = async function () { document.addEventListener('DOMContentLoaded', () => {
const sourceDir = document.getElementById('mergeSource').value; loadHistory();
const resultDiv = document.getElementById('mergeResult'); });
resultDiv.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Merging...'; // --- Navigation ---
// No-op for switchView as sidebar is simplified
window.switchView = function (viewName) {
console.log("View switch requested:", viewName);
}
try { window.switchTab = function (tabName) {
const res = await fetch('/api/tools/merge', { // Buttons
method: 'POST', document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
headers: { 'Content-Type': 'application/json' }, // Content
body: JSON.stringify({ source_dir: sourceDir, output_filename: 'merged_output.csv' }) ['logs', 'report', 'gallery'].forEach(name => {
const content = document.getElementById(`${name}Tab`);
if (content) content.classList.add('hidden');
// 找到对应的 Tab 按钮并激活
// 这里假设 Tab 按钮的 onclick 包含 tabName
document.querySelectorAll('.tab').forEach(btn => {
if (btn.getAttribute('onclick') && btn.getAttribute('onclick').includes(`'${tabName}'`)) {
btn.classList.add('active');
}
}); });
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>`; // Valid tabs logic
} else { if (tabName === 'logs') {
resultDiv.innerHTML = `<span style="color:var(--warning)">${data.message}</span>`; document.getElementById('logsTab').classList.remove('hidden');
} } else if (tabName === 'report') {
} catch (e) { document.getElementById('reportTab').classList.remove('hidden');
resultDiv.innerHTML = `<span style="color:red">Error: ${e.message}</span>`; } else if (tabName === 'gallery') {
} document.getElementById('galleryTab').classList.remove('hidden');
} if (currentSessionId) loadGallery();
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.';
} }
} }

View File

@@ -1,501 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* Nebula Theme Palette */
--bg-deep: #020617;
--bg-secondary: #0f172a;
--sidebar-bg: rgba(15, 23, 42, 0.6);
--card-bg: rgba(30, 41, 59, 0.3);
--glass-border: 1px solid rgba(148, 163, 184, 0.08);
--primary-color: #6366f1;
--primary-glow: #6366f1aa;
--accent-color: #0ea5e9;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--radius-lg: 1rem;
--radius-md: 0.75rem;
--radius-sm: 0.5rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-deep);
background-image:
radial-gradient(circle at 15% 50%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
radial-gradient(circle at 85% 30%, rgba(14, 165, 233, 0.08) 0%, transparent 50%);
color: var(--text-main);
height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
.app-container {
display: flex;
height: 96vh;
width: 98vw;
gap: 1rem;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* --- Floating Sidebar --- */
.sidebar {
width: 280px;
background: var(--sidebar-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
transition: all 0.3s ease;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.5rem 1rem 0.5rem;
border-bottom: var(--glass-border);
}
.brand i {
font-size: 1.75rem;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 10px rgba(99, 102, 241, 0.5));
}
.brand h1 {
font-family: 'Outfit', sans-serif;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(to right, #fff, #94a3b8);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Nav Buttons */
.nav-btn {
width: 100%;
padding: 0.875rem 1rem;
border: none;
background: transparent;
color: var(--text-muted);
font-family: 'Outfit', sans-serif;
font-weight: 500;
text-align: left;
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.95rem;
}
.nav-btn i {
font-size: 1.1rem;
transition: transform 0.2s;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
transform: translateX(4px);
}
.nav-btn.active {
background: linear-gradient(90deg, rgba(99, 102, 241, 0.1), transparent);
color: var(--primary-color);
border-left: 3px solid var(--primary-color);
border-radius: 4px var(--radius-md) var(--radius-md) 4px;
}
.nav-btn.active i {
color: var(--primary-color);
transform: scale(1.1);
}
/* --- Main Content --- */
.main-content {
flex: 1;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(12px);
border: var(--glass-border);
border-radius: var(--radius-lg);
padding: 2rem;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
/* Controls */
.control-group-wrapper {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.control-group h3 {
font-family: 'Outfit', sans-serif;
font-size: 0.75rem;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
margin-bottom: 0.75rem;
}
/* Upload Zone */
.upload-zone {
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
transition: all 0.3s ease;
background: rgba(2, 6, 23, 0.3);
position: relative;
overflow: hidden;
}
.upload-zone::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(99, 102, 241, 0.1), transparent 70%);
opacity: 0;
transition: opacity 0.3s;
}
.upload-zone:hover::before {
opacity: 1;
}
.upload-zone.dragover {
border-color: var(--primary-color);
box-shadow: 0 0 20px rgba(99, 102, 241, 0.2);
}
.upload-zone i {
font-size: 2rem;
background: linear-gradient(to bottom, #94a3b8, #475569);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.file-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Buttons */
.btn {
width: 100%;
padding: 0.875rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.btn.primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover, #4f46e5));
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.btn.primary:hover {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
transform: translateY(-1px);
}
.btn.primary::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: 0.5s;
}
.btn.primary:hover::after {
left: 100%;
}
.btn.secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.btn.secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
/* Inputs */
textarea,
input[type="text"] {
width: 100%;
background: rgba(2, 6, 23, 0.3);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: var(--radius-md);
padding: 1rem;
color: var(--text-main);
font-family: 'Inter', sans-serif;
font-size: 0.9rem;
transition: all 0.2s;
}
textarea:focus,
input[type="text"]:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: rgba(2, 6, 23, 0.2);
padding: 0.3rem;
border-radius: var(--radius-lg);
width: fit-content;
}
.tab-btn {
padding: 0.6rem 1.25rem;
border: none;
background: transparent;
color: var(--text-muted);
font-family: 'Outfit', sans-serif;
font-weight: 500;
font-size: 0.85rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn:hover {
color: var(--text-main);
}
.tab-btn.active {
background: rgba(255, 255, 255, 0.08);
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Terminal / Log Window */
.terminal-window {
background: #09090b;
border-radius: var(--radius-lg);
padding: 1.5rem;
flex: 1;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.05);
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: #22c55e;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
min-height: 0;
}
.terminal-window::-webkit-scrollbar {
width: 8px;
}
.terminal-window::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
/* Report Container */
#reportContainer {
background: rgba(255, 255, 255, 0.95);
color: #1e293b;
border-radius: var(--radius-lg);
padding: 2.5rem;
flex: 1;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.2);
gap: 1rem;
}
.empty-state i {
font-size: 4rem;
}
/* Follow-up Chat */
.chat-input-container {
margin-top: 1rem;
background: rgba(2, 6, 23, 0.4);
border: var(--glass-border);
border-radius: var(--radius-lg);
padding: 0.5rem;
}
.input-wrapper {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
#followUpInput {
background: transparent;
border: none;
height: 50px;
padding: 0.8rem;
resize: none;
box-shadow: none;
}
#sendFollowUpBtn {
width: 50px;
height: 50px;
border-radius: var(--radius-md);
padding: 0;
flex-shrink: 0;
}
/* Gallery Grid */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding-bottom: 2rem;
overflow-y: auto;
}
.gallery-card {
background: rgba(255, 255, 255, 0.03);
border: var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.3s;
}
.gallery-card:hover {
transform: translateY(-4px);
border-color: var(--primary-color);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.img-wrapper {
height: 180px;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.img-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
}
.img-wrapper:hover img {
transform: scale(1.05);
}
.card-content {
padding: 1rem;
}
.card-content h4 {
font-family: 'Outfit', sans-serif;
margin-bottom: 0.5rem;
font-size: 1rem;
}
.card-content .desc {
color: var(--text-muted);
font-size: 0.85rem;
}
/* Status Dot */
.status-indicator {
margin-top: auto;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-muted);
font-size: 0.85rem;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.view-section {
animation: fadeIn 0.4s ease-out;
width: 100%;
height: 100%;
}