diff --git a/config/llm_config.py b/config/llm_config.py index a3429d2..c4bc6b4 100644 --- a/config/llm_config.py +++ b/config/llm_config.py @@ -20,7 +20,7 @@ class LLMConfig: provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc. 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") - 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 max_tokens: int = 8192 # 降低默认值,避免某些API不支持过大的值 diff --git a/prompts.py b/prompts.py index be103f9..d09cf7e 100644 --- a/prompts.py +++ b/prompts.py @@ -1,7 +1,6 @@ data_analysis_system_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中,能够根据用户需求生成和执行Python数据分析代码。 -[TARGET] **核心使命**: -- 接收自然语言需求,分阶段生成高效、安全的数据分析代码。 +[TARGET] **核心使命**:+、安全的数据分析代码。 - 深度挖掘数据,不仅仅是绘图,更要发现数据背后的业务洞察。 - 输出高质量、可落地的业务分析报告。 diff --git a/web/main.py b/web/main.py index 9abac5b..21f6db1 100644 --- a/web/main.py +++ b/web/main.py @@ -5,6 +5,7 @@ import threading import glob import uuid import json +from datetime import datetime from typing import Optional, Dict, List from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query 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 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 +from config.llm_config import LLMConfig +from utils.create_session_dir import create_session_output_dir app = FastAPI(title="IOV Data Analysis Agent") @@ -55,6 +56,7 @@ class SessionData: self.last_updated: str = "" self.user_requirement: str = "" self.file_list: List[str] = [] + self.reusable_script: Optional[str] = None # 新增:可复用脚本路径 class SessionManager: @@ -70,7 +72,74 @@ class SessionManager: 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): return list(self.sessions.keys()) @@ -99,7 +168,10 @@ class SessionManager: "max_rounds": session.max_rounds, "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 + "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 @@ -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.analysis_results = result.get("analysis_results", []) + session.reusable_script = result.get("reusable_script_path", None) # 新增:保存脚本路径 # Save results to json for persistence 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, "log": log_content, "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") @@ -453,71 +527,24 @@ async def export_report(session_id: str = Query(..., description="Session ID")): 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 --- -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端点 --- @@ -553,6 +580,61 @@ async def delete_specific_session(session_id: str): raise HTTPException(status_code=404, detail="Session not found") 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__": import uvicorn diff --git a/web/static/clean_style.css b/web/static/clean_style.css new file mode 100644 index 0000000..a31b0bd --- /dev/null +++ b/web/static/clean_style.css @@ -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); +} \ No newline at end of file diff --git a/web/static/index.html b/web/static/index.html index fcfbd6f..c24e979 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -5,18 +5,20 @@