Merge branch 'main' of http://jeason.online:3000/zhaojie/iov_data_analysis_agent
This commit is contained in:
17
README.md
17
README.md
@@ -31,7 +31,9 @@ data_analysis_agent/
|
|||||||
│ ├── fallback_openai_client.py # 支持故障转移的OpenAI客户端
|
│ ├── fallback_openai_client.py # 支持故障转移的OpenAI客户端
|
||||||
│ ├── extract_code.py # 代码提取工具
|
│ ├── extract_code.py # 代码提取工具
|
||||||
│ ├── format_execution_result.py # 执行结果格式化
|
│ ├── format_execution_result.py # 执行结果格式化
|
||||||
│ └── create_session_dir.py # 会话目录管理
|
│ ├── create_session_dir.py # 会话目录管理
|
||||||
|
│ ├── data_loader.py # 数据加载与画像生成
|
||||||
|
│ └── script_generator.py # 可复用脚本生成器
|
||||||
├── 📄 data_analysis_agent.py # 主智能体类
|
├── 📄 data_analysis_agent.py # 主智能体类
|
||||||
├── 📄 prompts.py # 系统提示词模板
|
├── 📄 prompts.py # 系统提示词模板
|
||||||
├── 📄 main.py # 使用示例
|
├── 📄 main.py # 使用示例
|
||||||
@@ -266,12 +268,15 @@ stateDiagram-v2
|
|||||||
```python
|
```python
|
||||||
@dataclass
|
@dataclass
|
||||||
class LLMConfig:
|
class LLMConfig:
|
||||||
provider: str = "openai"
|
provider: str = os.environ.get("LLM_PROVIDER", "openai")
|
||||||
api_key: str = os.environ.get("OPENAI_API_KEY", "")
|
api_key: str = os.environ.get("OPENAI_API_KEY", "")
|
||||||
base_url: str = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
base_url: str = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||||
model: str = os.environ.get("OPENAI_MODEL", "gpt-4")
|
model: str = os.environ.get("OPENAI_MODEL", "gpt-4")
|
||||||
max_tokens: int = 4000
|
temperature: float = 0.5
|
||||||
temperature: float = 0.1
|
max_tokens: int = 8192
|
||||||
|
|
||||||
|
# 支持 gemini 等其他 provider 配置
|
||||||
|
# ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 执行器配置
|
### 执行器配置
|
||||||
@@ -281,7 +286,9 @@ class LLMConfig:
|
|||||||
ALLOWED_IMPORTS = {
|
ALLOWED_IMPORTS = {
|
||||||
'pandas', 'numpy', 'matplotlib', 'duckdb',
|
'pandas', 'numpy', 'matplotlib', 'duckdb',
|
||||||
'scipy', 'sklearn', 'plotly', 'requests',
|
'scipy', 'sklearn', 'plotly', 'requests',
|
||||||
'os', 'json', 'datetime', 're', 'pathlib'
|
'os', 'json', 'datetime', 're', 'pathlib',
|
||||||
|
'seaborn', 'statsmodels', 'networkx', 'jieba',
|
||||||
|
'wordcloud', 'PIL', 'sqlite3', 'yaml'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
0
cleaned_data/.gitkeep
Normal file
0
cleaned_data/.gitkeep
Normal file
@@ -18,11 +18,11 @@ class LLMConfig:
|
|||||||
"""LLM配置"""
|
"""LLM配置"""
|
||||||
|
|
||||||
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-Gce85QLROESeOWf3icd2mQnYHOrmMYojwVPQ0AubMjGQ5ZE2")
|
api_key: str = os.environ.get("OPENAI_API_KEY", "sk-2187174de21548b0b8b0c92129700199")
|
||||||
base_url: str = os.environ.get("OPENAI_BASE_URL", "https://gemini.jeason.online/v1")
|
base_url: str = os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1")
|
||||||
model: str = os.environ.get("OPENAI_MODEL", "gemini-2.5-pro")
|
model: str = os.environ.get("OPENAI_MODEL", "gemini-3-flash")
|
||||||
temperature: float = 0.5
|
temperature: float = 0.5
|
||||||
max_tokens: int = 131072
|
max_tokens: int = 8192 # 降低默认值,避免某些API不支持过大的值
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""配置初始化后的处理"""
|
"""配置初始化后的处理"""
|
||||||
@@ -34,6 +34,8 @@ class LLMConfig:
|
|||||||
# Gemini 的 OpenAI 兼容接口地址
|
# Gemini 的 OpenAI 兼容接口地址
|
||||||
self.base_url = os.environ.get("GEMINI_BASE_URL", "https://gemini.jeason.online")
|
self.base_url = os.environ.get("GEMINI_BASE_URL", "https://gemini.jeason.online")
|
||||||
self.model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
self.model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||||
|
# Gemini 有更严格的 token 限制
|
||||||
|
self.max_tokens = 8192
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""转换为字典"""
|
"""转换为字典"""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from utils.extract_code import extract_code_from_response
|
|||||||
from utils.data_loader import load_and_profile_data
|
from utils.data_loader import load_and_profile_data
|
||||||
from utils.llm_helper import LLMHelper
|
from utils.llm_helper import LLMHelper
|
||||||
from utils.code_executor import CodeExecutor
|
from utils.code_executor import CodeExecutor
|
||||||
|
from utils.script_generator import generate_reusable_script
|
||||||
from config.llm_config import LLMConfig
|
from config.llm_config import LLMConfig
|
||||||
from prompts import data_analysis_system_prompt, final_report_system_prompt, data_analysis_followup_prompt
|
from prompts import data_analysis_system_prompt, final_report_system_prompt, data_analysis_followup_prompt
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ class DataAnalysisAgent:
|
|||||||
self.session_output_dir = None
|
self.session_output_dir = None
|
||||||
self.executor = None
|
self.executor = None
|
||||||
self.data_profile = "" # 存储数据画像
|
self.data_profile = "" # 存储数据画像
|
||||||
|
self.data_files = [] # 存储数据文件列表
|
||||||
|
self.user_requirement = "" # 存储用户需求
|
||||||
|
|
||||||
def _process_response(self, response: str) -> Dict[str, Any]:
|
def _process_response(self, response: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -76,7 +79,7 @@ class DataAnalysisAgent:
|
|||||||
yaml_data = self.llm.parse_yaml_response(response)
|
yaml_data = self.llm.parse_yaml_response(response)
|
||||||
action = yaml_data.get("action", "generate_code")
|
action = yaml_data.get("action", "generate_code")
|
||||||
|
|
||||||
print(f"🎯 检测到动作: {action}")
|
print(f"[TARGET] 检测到动作: {action}")
|
||||||
|
|
||||||
if action == "analysis_complete":
|
if action == "analysis_complete":
|
||||||
return self._handle_analysis_complete(response, yaml_data)
|
return self._handle_analysis_complete(response, yaml_data)
|
||||||
@@ -85,11 +88,11 @@ class DataAnalysisAgent:
|
|||||||
elif action == "generate_code":
|
elif action == "generate_code":
|
||||||
return self._handle_generate_code(response, yaml_data)
|
return self._handle_generate_code(response, yaml_data)
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ 未知动作类型: {action},按generate_code处理")
|
print(f"[WARN] 未知动作类型: {action},按generate_code处理")
|
||||||
return self._handle_generate_code(response, yaml_data)
|
return self._handle_generate_code(response, yaml_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 解析响应失败: {str(e)},尝试提取代码并按generate_code处理")
|
print(f"[WARN] 解析响应失败: {str(e)},尝试提取代码并按generate_code处理")
|
||||||
# 即使YAML解析失败,也尝试提取代码
|
# 即使YAML解析失败,也尝试提取代码
|
||||||
extracted_code = extract_code_from_response(response)
|
extracted_code = extract_code_from_response(response)
|
||||||
if extracted_code:
|
if extracted_code:
|
||||||
@@ -100,7 +103,7 @@ class DataAnalysisAgent:
|
|||||||
self, response: str, yaml_data: Dict[str, Any]
|
self, response: str, yaml_data: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""处理分析完成动作"""
|
"""处理分析完成动作"""
|
||||||
print("✅ 分析任务完成")
|
print("[OK] 分析任务完成")
|
||||||
final_report = yaml_data.get("final_report", "分析完成,无最终报告")
|
final_report = yaml_data.get("final_report", "分析完成,无最终报告")
|
||||||
return {
|
return {
|
||||||
"action": "analysis_complete",
|
"action": "analysis_complete",
|
||||||
@@ -113,7 +116,7 @@ class DataAnalysisAgent:
|
|||||||
self, response: str, yaml_data: Dict[str, Any]
|
self, response: str, yaml_data: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""处理图片收集动作"""
|
"""处理图片收集动作"""
|
||||||
print("📊 开始收集图片")
|
print("[CHART] 开始收集图片")
|
||||||
figures_to_collect = yaml_data.get("figures_to_collect", [])
|
figures_to_collect = yaml_data.get("figures_to_collect", [])
|
||||||
|
|
||||||
collected_figures = []
|
collected_figures = []
|
||||||
@@ -130,10 +133,10 @@ class DataAnalysisAgent:
|
|||||||
description = figure_info.get("description", "")
|
description = figure_info.get("description", "")
|
||||||
analysis = figure_info.get("analysis", "")
|
analysis = figure_info.get("analysis", "")
|
||||||
|
|
||||||
print(f"📈 收集图片 {figure_number}: {filename}")
|
print(f"[GRAPH] 收集图片 {figure_number}: {filename}")
|
||||||
print(f" 📂 路径: {file_path}")
|
print(f" [DIR] 路径: {file_path}")
|
||||||
print(f" 📝 描述: {description}")
|
print(f" [NOTE] 描述: {description}")
|
||||||
print(f" 🔍 分析: {analysis}")
|
print(f" [SEARCH] 分析: {analysis}")
|
||||||
|
|
||||||
|
|
||||||
# 使用seen_paths集合来去重,防止重复收集
|
# 使用seen_paths集合来去重,防止重复收集
|
||||||
@@ -145,7 +148,7 @@ class DataAnalysisAgent:
|
|||||||
# 检查是否已经收集过该路径
|
# 检查是否已经收集过该路径
|
||||||
abs_path = os.path.abspath(file_path)
|
abs_path = os.path.abspath(file_path)
|
||||||
if abs_path not in seen_paths:
|
if abs_path not in seen_paths:
|
||||||
print(f" ✅ 文件存在: {file_path}")
|
print(f" [OK] 文件存在: {file_path}")
|
||||||
# 记录图片信息
|
# 记录图片信息
|
||||||
collected_figures.append(
|
collected_figures.append(
|
||||||
{
|
{
|
||||||
@@ -158,12 +161,12 @@ class DataAnalysisAgent:
|
|||||||
)
|
)
|
||||||
seen_paths.add(abs_path)
|
seen_paths.add(abs_path)
|
||||||
else:
|
else:
|
||||||
print(f" ⚠️ 跳过重复图片: {file_path}")
|
print(f" [WARN] 跳过重复图片: {file_path}")
|
||||||
else:
|
else:
|
||||||
if file_path:
|
if file_path:
|
||||||
print(f" ⚠️ 文件不存在: {file_path}")
|
print(f" [WARN] 文件不存在: {file_path}")
|
||||||
else:
|
else:
|
||||||
print(f" ⚠️ 未提供文件路径")
|
print(f" [WARN] 未提供文件路径")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "collect_figures",
|
"action": "collect_figures",
|
||||||
@@ -195,7 +198,7 @@ class DataAnalysisAgent:
|
|||||||
code = code.strip()
|
code = code.strip()
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
print(f"🔧 执行代码:\n{code}")
|
print(f"[TOOL] 执行代码:\n{code}")
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
|
|
||||||
# 执行代码
|
# 执行代码
|
||||||
@@ -203,7 +206,7 @@ class DataAnalysisAgent:
|
|||||||
|
|
||||||
# 格式化执行结果
|
# 格式化执行结果
|
||||||
feedback = format_execution_result(result)
|
feedback = format_execution_result(result)
|
||||||
print(f"📋 执行反馈:\n{feedback}")
|
print(f"[LIST] 执行反馈:\n{feedback}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "generate_code",
|
"action": "generate_code",
|
||||||
@@ -215,7 +218,7 @@ class DataAnalysisAgent:
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# 如果没有代码,说明LLM响应格式有问题,需要重新生成
|
# 如果没有代码,说明LLM响应格式有问题,需要重新生成
|
||||||
print("⚠️ 未从响应中提取到可执行代码,要求LLM重新生成")
|
print("[WARN] 未从响应中提取到可执行代码,要求LLM重新生成")
|
||||||
return {
|
return {
|
||||||
"action": "invalid_response",
|
"action": "invalid_response",
|
||||||
"error": "响应中缺少可执行代码",
|
"error": "响应中缺少可执行代码",
|
||||||
@@ -246,6 +249,8 @@ class DataAnalysisAgent:
|
|||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
self.analysis_results = []
|
self.analysis_results = []
|
||||||
self.current_round = 0
|
self.current_round = 0
|
||||||
|
self.data_files = files or [] # 保存数据文件列表
|
||||||
|
self.user_requirement = user_input # 保存用户需求
|
||||||
|
|
||||||
# 创建本次分析的专用输出目录
|
# 创建本次分析的专用输出目录
|
||||||
if session_output_dir:
|
if session_output_dir:
|
||||||
@@ -264,12 +269,12 @@ class DataAnalysisAgent:
|
|||||||
# 设用工具生成数据画像
|
# 设用工具生成数据画像
|
||||||
data_profile = ""
|
data_profile = ""
|
||||||
if files:
|
if files:
|
||||||
print("🔍 正在生成数据画像...")
|
print("[SEARCH] 正在生成数据画像...")
|
||||||
try:
|
try:
|
||||||
data_profile = load_and_profile_data(files)
|
data_profile = load_and_profile_data(files)
|
||||||
print("✅ 数据画像生成完毕")
|
print("[OK] 数据画像生成完毕")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 数据画像生成失败: {e}")
|
print(f"[WARN] 数据画像生成失败: {e}")
|
||||||
|
|
||||||
# 保存到实例变量供最终报告使用
|
# 保存到实例变量供最终报告使用
|
||||||
self.data_profile = data_profile
|
self.data_profile = data_profile
|
||||||
@@ -282,11 +287,11 @@ class DataAnalysisAgent:
|
|||||||
if data_profile:
|
if data_profile:
|
||||||
initial_prompt += f"\n\n{data_profile}\n\n请根据上述【数据画像】中的统计信息(如高频值、缺失率、数据范围)来制定分析策略。如果发现明显的高频问题或异常分布,请优先进行深度分析。"
|
initial_prompt += f"\n\n{data_profile}\n\n请根据上述【数据画像】中的统计信息(如高频值、缺失率、数据范围)来制定分析策略。如果发现明显的高频问题或异常分布,请优先进行深度分析。"
|
||||||
|
|
||||||
print(f"🚀 开始数据分析任务")
|
print(f"[START] 开始数据分析任务")
|
||||||
print(f"📝 用户需求: {user_input}")
|
print(f"[NOTE] 用户需求: {user_input}")
|
||||||
if files:
|
if files:
|
||||||
print(f"📁 数据文件: {', '.join(files)}")
|
print(f"[FOLDER] 数据文件: {', '.join(files)}")
|
||||||
print(f"📂 输出目录: {self.session_output_dir}")
|
print(f"[DIR] 输出目录: {self.session_output_dir}")
|
||||||
|
|
||||||
# 添加到对话历史
|
# 添加到对话历史
|
||||||
self.conversation_history.append({"role": "user", "content": initial_prompt})
|
self.conversation_history.append({"role": "user", "content": initial_prompt})
|
||||||
@@ -297,8 +302,8 @@ class DataAnalysisAgent:
|
|||||||
if max_rounds is None:
|
if max_rounds is None:
|
||||||
current_max_rounds = 10 # 追问通常不需要那么长的思考链,10轮足够
|
current_max_rounds = 10 # 追问通常不需要那么长的思考链,10轮足够
|
||||||
|
|
||||||
print(f"\n🚀 继续分析任务 (追问模式)")
|
print(f"\n[START] 继续分析任务 (追问模式)")
|
||||||
print(f"📝 后续需求: {user_input}")
|
print(f"[NOTE] 后续需求: {user_input}")
|
||||||
|
|
||||||
# 重置当前轮数计数器,以便给新任务足够的轮次
|
# 重置当前轮数计数器,以便给新任务足够的轮次
|
||||||
self.current_round = 0
|
self.current_round = 0
|
||||||
@@ -308,18 +313,21 @@ class DataAnalysisAgent:
|
|||||||
follow_up_prompt = f"后续需求: {user_input}\n(注意:这是后续追问,请直接针对该问题进行分析,无需从头开始执行完整SOP。)"
|
follow_up_prompt = f"后续需求: {user_input}\n(注意:这是后续追问,请直接针对该问题进行分析,无需从头开始执行完整SOP。)"
|
||||||
self.conversation_history.append({"role": "user", "content": follow_up_prompt})
|
self.conversation_history.append({"role": "user", "content": follow_up_prompt})
|
||||||
|
|
||||||
print(f"🔢 本次最大轮数: {current_max_rounds}")
|
print(f"[NUM] 本次最大轮数: {current_max_rounds}")
|
||||||
if self.force_max_rounds:
|
if self.force_max_rounds:
|
||||||
print(f"⚡ 强制模式: 将运行满 {current_max_rounds} 轮(忽略AI完成信号)")
|
print(f"[FAST] 强制模式: 将运行满 {current_max_rounds} 轮(忽略AI完成信号)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# 保存原始 max_rounds 以便恢复(虽然 analyze 结束后不需要恢复,但为了逻辑严谨)
|
# 保存原始 max_rounds 以便恢复(虽然 analyze 结束后不需要恢复,但为了逻辑严谨)
|
||||||
original_max_rounds = self.max_rounds
|
original_max_rounds = self.max_rounds
|
||||||
self.max_rounds = current_max_rounds
|
self.max_rounds = current_max_rounds
|
||||||
|
|
||||||
|
# 初始化连续失败计数器
|
||||||
|
consecutive_failures = 0
|
||||||
|
|
||||||
while self.current_round < self.max_rounds:
|
while self.current_round < self.max_rounds:
|
||||||
self.current_round += 1
|
self.current_round += 1
|
||||||
print(f"\n🔄 第 {self.current_round} 轮分析")
|
print(f"\n[LOOP] 第 {self.current_round} 轮分析")
|
||||||
# 调用LLM生成响应
|
# 调用LLM生成响应
|
||||||
try: # 获取当前执行环境的变量信息
|
try: # 获取当前执行环境的变量信息
|
||||||
notebook_variables = self.executor.get_environment_info()
|
notebook_variables = self.executor.get_environment_info()
|
||||||
@@ -340,15 +348,15 @@ class DataAnalysisAgent:
|
|||||||
formatted_system_prompt = base_system_prompt.format(
|
formatted_system_prompt = base_system_prompt.format(
|
||||||
notebook_variables=notebook_variables
|
notebook_variables=notebook_variables
|
||||||
)
|
)
|
||||||
print(f"🐛 [DEBUG] System Prompt Head:\n{formatted_system_prompt[:500]}...\n[...]")
|
print(f"[DEBUG] [DEBUG] System Prompt Head:\n{formatted_system_prompt[:500]}...\n[...]")
|
||||||
print(f"🐛 [DEBUG] System Prompt Rules Check: 'stop_words' in prompt? {'stop_words' in formatted_system_prompt}")
|
print(f"[DEBUG] [DEBUG] System Prompt Rules Check: 'stop_words' in prompt? {'stop_words' in formatted_system_prompt}")
|
||||||
|
|
||||||
response = self.llm.call(
|
response = self.llm.call(
|
||||||
prompt=self._build_conversation_prompt(),
|
prompt=self._build_conversation_prompt(),
|
||||||
system_prompt=formatted_system_prompt,
|
system_prompt=formatted_system_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"🤖 助手响应:\n{response}")
|
print(f"[AI] 助手响应:\n{response}")
|
||||||
|
|
||||||
# 使用统一的响应处理方法
|
# 使用统一的响应处理方法
|
||||||
process_result = self._process_response(response)
|
process_result = self._process_response(response)
|
||||||
@@ -356,9 +364,9 @@ class DataAnalysisAgent:
|
|||||||
# 根据处理结果决定是否继续(仅在非强制模式下)
|
# 根据处理结果决定是否继续(仅在非强制模式下)
|
||||||
if process_result.get("action") == "invalid_response":
|
if process_result.get("action") == "invalid_response":
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
print(f"⚠️ 连续失败次数: {consecutive_failures}/3")
|
print(f"[WARN] 连续失败次数: {consecutive_failures}/3")
|
||||||
if consecutive_failures >= 3:
|
if consecutive_failures >= 3:
|
||||||
print(f"❌ 连续3次无法获取有效响应,分析终止。请检查网络或配置。")
|
print(f"[ERROR] 连续3次无法获取有效响应,分析终止。请检查网络或配置。")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
consecutive_failures = 0 # 重置计数器
|
consecutive_failures = 0 # 重置计数器
|
||||||
@@ -366,7 +374,7 @@ class DataAnalysisAgent:
|
|||||||
if not self.force_max_rounds and not process_result.get(
|
if not self.force_max_rounds and not process_result.get(
|
||||||
"continue", True
|
"continue", True
|
||||||
):
|
):
|
||||||
print(f"\n✅ 分析完成!")
|
print(f"\n[OK] 分析完成!")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 添加到对话历史
|
# 添加到对话历史
|
||||||
@@ -398,7 +406,7 @@ class DataAnalysisAgent:
|
|||||||
|
|
||||||
feedback = f"已收集 {len(collected_figures)} 个有效图片及其分析。"
|
feedback = f"已收集 {len(collected_figures)} 个有效图片及其分析。"
|
||||||
if missing_figures:
|
if missing_figures:
|
||||||
feedback += f"\n⚠️ 以下图片未找到,请检查代码是否成功保存了这些图片: {missing_figures}"
|
feedback += f"\n[WARN] 以下图片未找到,请检查代码是否成功保存了这些图片: {missing_figures}"
|
||||||
|
|
||||||
self.conversation_history.append(
|
self.conversation_history.append(
|
||||||
{
|
{
|
||||||
@@ -421,7 +429,7 @@ class DataAnalysisAgent:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"LLM调用错误: {str(e)}"
|
error_msg = f"LLM调用错误: {str(e)}"
|
||||||
print(f"❌ {error_msg}")
|
print(f"[ERROR] {error_msg}")
|
||||||
self.conversation_history.append(
|
self.conversation_history.append(
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -430,7 +438,7 @@ class DataAnalysisAgent:
|
|||||||
)
|
)
|
||||||
# 生成最终总结
|
# 生成最终总结
|
||||||
if self.current_round >= self.max_rounds:
|
if self.current_round >= self.max_rounds:
|
||||||
print(f"\n⚠️ 已达到最大轮数 ({self.max_rounds}),分析结束")
|
print(f"\n[WARN] 已达到最大轮数 ({self.max_rounds}),分析结束")
|
||||||
|
|
||||||
return self._generate_final_report()
|
return self._generate_final_report()
|
||||||
|
|
||||||
@@ -456,8 +464,8 @@ class DataAnalysisAgent:
|
|||||||
if result.get("action") == "collect_figures":
|
if result.get("action") == "collect_figures":
|
||||||
all_figures.extend(result.get("collected_figures", []))
|
all_figures.extend(result.get("collected_figures", []))
|
||||||
|
|
||||||
print(f"\n📊 开始生成最终分析报告...")
|
print(f"\n[CHART] 开始生成最终分析报告...")
|
||||||
print(f"📂 输出目录: {self.session_output_dir}")
|
print(f"[DIR] 输出目录: {self.session_output_dir}")
|
||||||
|
|
||||||
# --- 自动补全/发现图片机制 ---
|
# --- 自动补全/发现图片机制 ---
|
||||||
# 扫描目录下所有的png文件
|
# 扫描目录下所有的png文件
|
||||||
@@ -475,7 +483,7 @@ class DataAnalysisAgent:
|
|||||||
for png_path in existing_pngs:
|
for png_path in existing_pngs:
|
||||||
abs_png_path = os.path.abspath(png_path)
|
abs_png_path = os.path.abspath(png_path)
|
||||||
if abs_png_path not in collected_paths:
|
if abs_png_path not in collected_paths:
|
||||||
print(f"🔍 [自动发现] 补充未显式收集的图片: {os.path.basename(png_path)}")
|
print(f"[SEARCH] [自动发现] 补充未显式收集的图片: {os.path.basename(png_path)}")
|
||||||
all_figures.append({
|
all_figures.append({
|
||||||
"figure_number": "Auto",
|
"figure_number": "Auto",
|
||||||
"filename": os.path.basename(png_path),
|
"filename": os.path.basename(png_path),
|
||||||
@@ -484,11 +492,11 @@ class DataAnalysisAgent:
|
|||||||
"analysis": "(该图表由系统自动捕获,Agent未提供具体分析文本,请结合图表标题理解)"
|
"analysis": "(该图表由系统自动捕获,Agent未提供具体分析文本,请结合图表标题理解)"
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 自动发现图片失败: {e}")
|
print(f"[WARN] 自动发现图片失败: {e}")
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
print(f"🔢 总轮数: {self.current_round}")
|
print(f"[NUM] 总轮数: {self.current_round}")
|
||||||
print(f"📈 收集图片: {len(all_figures)} 个")
|
print(f"[GRAPH] 收集图片: {len(all_figures)} 个")
|
||||||
|
|
||||||
# 构建用于生成最终报告的提示词
|
# 构建用于生成最终报告的提示词
|
||||||
final_report_prompt = self._build_final_report_prompt(all_figures)
|
final_report_prompt = self._build_final_report_prompt(all_figures)
|
||||||
@@ -512,12 +520,12 @@ class DataAnalysisAgent:
|
|||||||
except:
|
except:
|
||||||
pass # 解析失败则保持原样
|
pass # 解析失败则保持原样
|
||||||
|
|
||||||
print("✅ 最终报告生成完成")
|
print("[OK] 最终报告生成完成")
|
||||||
|
|
||||||
print("✅ 最终报告生成完成")
|
print("[OK] 最终报告生成完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 生成最终报告时出错: {str(e)}")
|
print(f"[ERROR] 生成最终报告时出错: {str(e)}")
|
||||||
final_report_content = f"报告生成失败: {str(e)}"
|
final_report_content = f"报告生成失败: {str(e)}"
|
||||||
|
|
||||||
# 保存最终报告到文件
|
# 保存最终报告到文件
|
||||||
@@ -525,9 +533,21 @@ class DataAnalysisAgent:
|
|||||||
try:
|
try:
|
||||||
with open(report_file_path, "w", encoding="utf-8") as f:
|
with open(report_file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(final_report_content)
|
f.write(final_report_content)
|
||||||
print(f"📄 最终报告已保存至: {report_file_path}")
|
print(f"[DOC] 最终报告已保存至: {report_file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 保存报告文件失败: {str(e)}")
|
print(f"[ERROR] 保存报告文件失败: {str(e)}")
|
||||||
|
|
||||||
|
# 生成可复用脚本
|
||||||
|
script_path = ""
|
||||||
|
try:
|
||||||
|
script_path = generate_reusable_script(
|
||||||
|
analysis_results=self.analysis_results,
|
||||||
|
data_files=self.data_files,
|
||||||
|
session_output_dir=self.session_output_dir,
|
||||||
|
user_requirement=self.user_requirement
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 脚本生成失败: {e}")
|
||||||
|
|
||||||
# 返回完整的分析结果
|
# 返回完整的分析结果
|
||||||
return {
|
return {
|
||||||
@@ -538,6 +558,7 @@ class DataAnalysisAgent:
|
|||||||
"conversation_history": self.conversation_history,
|
"conversation_history": self.conversation_history,
|
||||||
"final_report": final_report_content,
|
"final_report": final_report_content,
|
||||||
"report_file_path": report_file_path,
|
"report_file_path": report_file_path,
|
||||||
|
"reusable_script_path": script_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_final_report_prompt(self, all_figures: List[Dict[str, Any]]) -> str:
|
def _build_final_report_prompt(self, all_figures: List[Dict[str, Any]]) -> str:
|
||||||
@@ -584,7 +605,7 @@ class DataAnalysisAgent:
|
|||||||
# 在提示词中明确要求使用相对路径
|
# 在提示词中明确要求使用相对路径
|
||||||
prompt += """
|
prompt += """
|
||||||
|
|
||||||
📁 **图片路径使用说明**:
|
[FOLDER] **图片路径使用说明**:
|
||||||
报告和图片都在同一目录下,请在报告中使用相对路径引用图片:
|
报告和图片都在同一目录下,请在报告中使用相对路径引用图片:
|
||||||
- 格式:
|
- 格式:
|
||||||
- 示例:
|
- 示例:
|
||||||
|
|||||||
89
data_preprocessing/README.md
Normal file
89
data_preprocessing/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# 数据预处理模块
|
||||||
|
|
||||||
|
独立的数据清洗工具,用于在正式分析前准备数据。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **数据合并**:将多个 Excel/CSV 文件合并为单一 CSV
|
||||||
|
- **时间排序**:按时间列对数据进行排序
|
||||||
|
- **目录管理**:标准化的原始数据和输出数据目录
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── raw_data/ # 原始数据存放目录
|
||||||
|
│ ├── remotecontrol/ # 按数据来源分类
|
||||||
|
│ └── ...
|
||||||
|
├── cleaned_data/ # 清洗后数据输出目录
|
||||||
|
│ ├── xxx_merged.csv
|
||||||
|
│ └── xxx_sorted.csv
|
||||||
|
└── data_preprocessing/ # 本模块
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 命令行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 初始化目录结构
|
||||||
|
python -m data_preprocessing.cli init
|
||||||
|
|
||||||
|
# 合并 Excel 文件
|
||||||
|
python -m data_preprocessing.cli merge --source raw_data/remotecontrol
|
||||||
|
|
||||||
|
# 合并并按时间排序
|
||||||
|
python -m data_preprocessing.cli merge --source raw_data/remotecontrol --sort-by SendTime
|
||||||
|
|
||||||
|
# 指定输出路径
|
||||||
|
python -m data_preprocessing.cli merge -s raw_data/remotecontrol -o cleaned_data/my_output.csv
|
||||||
|
|
||||||
|
# 排序已有 CSV
|
||||||
|
python -m data_preprocessing.cli sort --input some_file.csv --time-col SendTime
|
||||||
|
|
||||||
|
# 原地排序(覆盖原文件)
|
||||||
|
python -m data_preprocessing.cli sort --input data.csv --inplace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from data_preprocessing import merge_files, sort_by_time, Config
|
||||||
|
|
||||||
|
# 合并文件
|
||||||
|
output_path = merge_files(
|
||||||
|
source_dir="raw_data/remotecontrol",
|
||||||
|
output_file="cleaned_data/merged.csv",
|
||||||
|
pattern="*.xlsx",
|
||||||
|
time_column="SendTime" # 可选:合并后排序
|
||||||
|
)
|
||||||
|
|
||||||
|
# 排序 CSV
|
||||||
|
sorted_path = sort_by_time(
|
||||||
|
input_path="data.csv",
|
||||||
|
output_path="sorted_data.csv",
|
||||||
|
time_column="CreateTime"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 自定义配置
|
||||||
|
config = Config()
|
||||||
|
config.raw_data_dir = "/path/to/raw"
|
||||||
|
config.cleaned_data_dir = "/path/to/cleaned"
|
||||||
|
config.ensure_dirs()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `raw_data_dir` | `raw_data/` | 原始数据目录 |
|
||||||
|
| `cleaned_data_dir` | `cleaned_data/` | 清洗输出目录 |
|
||||||
|
| `default_time_column` | `SendTime` | 默认时间列名 |
|
||||||
|
| `csv_encoding` | `utf-8-sig` | CSV 编码格式 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 本模块与 `DataAnalysisAgent` 完全独立,不会相互调用
|
||||||
|
2. 合并时会自动添加 `_source_file` 列标记数据来源(可用 `--no-source-col` 禁用)
|
||||||
|
3. Excel 文件会自动合并所有 Sheet
|
||||||
|
4. 无效时间值在排序时会被放到最后
|
||||||
14
data_preprocessing/__init__.py
Normal file
14
data_preprocessing/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据预处理模块
|
||||||
|
|
||||||
|
提供独立的数据清洗功能:
|
||||||
|
- 按时间排序
|
||||||
|
- 同类数据合并
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .sorter import sort_by_time
|
||||||
|
from .merger import merge_files
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
__all__ = ["sort_by_time", "merge_files", "Config"]
|
||||||
140
data_preprocessing/cli.py
Normal file
140
data_preprocessing/cli.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据预处理命令行接口
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
# 合并 Excel 文件
|
||||||
|
python -m data_preprocessing.cli merge --source raw_data/remotecontrol --output cleaned_data/merged.csv
|
||||||
|
|
||||||
|
# 合并并排序
|
||||||
|
python -m data_preprocessing.cli merge --source raw_data/remotecontrol --sort-by SendTime
|
||||||
|
|
||||||
|
# 排序已有 CSV
|
||||||
|
python -m data_preprocessing.cli sort --input data.csv --output sorted.csv --time-col SendTime
|
||||||
|
|
||||||
|
# 初始化目录结构
|
||||||
|
python -m data_preprocessing.cli init
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from .config import default_config
|
||||||
|
from .sorter import sort_by_time
|
||||||
|
from .merger import merge_files
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="data_preprocessing",
|
||||||
|
description="数据预处理工具:排序、合并",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
示例:
|
||||||
|
%(prog)s merge --source raw_data/remotecontrol --sort-by SendTime
|
||||||
|
%(prog)s sort --input data.csv --time-col CreateTime
|
||||||
|
%(prog)s init
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
||||||
|
|
||||||
|
# ========== merge 命令 ==========
|
||||||
|
merge_parser = subparsers.add_parser("merge", help="合并同类文件")
|
||||||
|
merge_parser.add_argument(
|
||||||
|
"--source", "-s",
|
||||||
|
required=True,
|
||||||
|
help="源数据目录路径"
|
||||||
|
)
|
||||||
|
merge_parser.add_argument(
|
||||||
|
"--output", "-o",
|
||||||
|
default=None,
|
||||||
|
help="输出文件路径 (默认: cleaned_data/<目录名>_merged.csv)"
|
||||||
|
)
|
||||||
|
merge_parser.add_argument(
|
||||||
|
"--pattern", "-p",
|
||||||
|
default="*.xlsx",
|
||||||
|
help="文件匹配模式 (默认: *.xlsx)"
|
||||||
|
)
|
||||||
|
merge_parser.add_argument(
|
||||||
|
"--sort-by",
|
||||||
|
default=None,
|
||||||
|
dest="time_column",
|
||||||
|
help="合并后按此时间列排序"
|
||||||
|
)
|
||||||
|
merge_parser.add_argument(
|
||||||
|
"--no-source-col",
|
||||||
|
action="store_true",
|
||||||
|
help="不添加来源文件列"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== sort 命令 ==========
|
||||||
|
sort_parser = subparsers.add_parser("sort", help="按时间排序 CSV")
|
||||||
|
sort_parser.add_argument(
|
||||||
|
"--input", "-i",
|
||||||
|
required=True,
|
||||||
|
help="输入 CSV 文件路径"
|
||||||
|
)
|
||||||
|
sort_parser.add_argument(
|
||||||
|
"--output", "-o",
|
||||||
|
default=None,
|
||||||
|
help="输出文件路径 (默认: cleaned_data/<文件名>_sorted.csv)"
|
||||||
|
)
|
||||||
|
sort_parser.add_argument(
|
||||||
|
"--time-col", "-t",
|
||||||
|
default=None,
|
||||||
|
dest="time_column",
|
||||||
|
help=f"时间列名 (默认: {default_config.default_time_column})"
|
||||||
|
)
|
||||||
|
sort_parser.add_argument(
|
||||||
|
"--inplace",
|
||||||
|
action="store_true",
|
||||||
|
help="原地覆盖输入文件"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== init 命令 ==========
|
||||||
|
init_parser = subparsers.add_parser("init", help="初始化目录结构")
|
||||||
|
|
||||||
|
# 解析参数
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == "merge":
|
||||||
|
result = merge_files(
|
||||||
|
source_dir=args.source,
|
||||||
|
output_file=args.output,
|
||||||
|
pattern=args.pattern,
|
||||||
|
time_column=args.time_column,
|
||||||
|
add_source_column=not args.no_source_col
|
||||||
|
)
|
||||||
|
print(f"\n✅ 合并成功: {result}")
|
||||||
|
|
||||||
|
elif args.command == "sort":
|
||||||
|
result = sort_by_time(
|
||||||
|
input_path=args.input,
|
||||||
|
output_path=args.output,
|
||||||
|
time_column=args.time_column,
|
||||||
|
inplace=args.inplace
|
||||||
|
)
|
||||||
|
print(f"\n✅ 排序成功: {result}")
|
||||||
|
|
||||||
|
elif args.command == "init":
|
||||||
|
default_config.ensure_dirs()
|
||||||
|
print("\n✅ 目录初始化完成")
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"\n❌ 错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyError as e:
|
||||||
|
print(f"\n❌ 错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 未知错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
42
data_preprocessing/config.py
Normal file
42
data_preprocessing/config.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据预处理模块配置
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# 获取项目根目录
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""预处理模块配置"""
|
||||||
|
|
||||||
|
# 原始数据存放目录
|
||||||
|
raw_data_dir: str = os.path.join(PROJECT_ROOT, "raw_data")
|
||||||
|
|
||||||
|
# 清洗后数据输出目录
|
||||||
|
cleaned_data_dir: str = os.path.join(PROJECT_ROOT, "cleaned_data")
|
||||||
|
|
||||||
|
# 默认时间列名
|
||||||
|
default_time_column: str = "SendTime"
|
||||||
|
|
||||||
|
# 支持的文件扩展名
|
||||||
|
supported_extensions: tuple = (".csv", ".xlsx", ".xls")
|
||||||
|
|
||||||
|
# CSV 编码
|
||||||
|
csv_encoding: str = "utf-8-sig"
|
||||||
|
|
||||||
|
def ensure_dirs(self):
|
||||||
|
"""确保目录存在"""
|
||||||
|
os.makedirs(self.raw_data_dir, exist_ok=True)
|
||||||
|
os.makedirs(self.cleaned_data_dir, exist_ok=True)
|
||||||
|
print(f"[OK] 目录已就绪:")
|
||||||
|
print(f" 原始数据: {self.raw_data_dir}")
|
||||||
|
print(f" 清洗输出: {self.cleaned_data_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# 默认配置实例
|
||||||
|
default_config = Config()
|
||||||
83
data_preprocessing/merge_excel.py
Normal file
83
data_preprocessing/merge_excel.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.csv"):
|
||||||
|
"""
|
||||||
|
将指定目录下的所有 Excel 文件 (.xlsx, .xls) 合并为一个 CSV 文件。
|
||||||
|
"""
|
||||||
|
print(f"[SEARCH] 正在扫描目录: {source_dir} ...")
|
||||||
|
|
||||||
|
# 支持 xlsx 和 xls
|
||||||
|
files_xlsx = glob.glob(os.path.join(source_dir, "*.xlsx"))
|
||||||
|
files_xls = glob.glob(os.path.join(source_dir, "*.xls"))
|
||||||
|
files = files_xlsx + files_xls
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
print("[WARN] 未找到 Excel 文件。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 按文件名中的数字进行排序 (例如: 1.xlsx, 2.xlsx, ..., 10.xlsx)
|
||||||
|
try:
|
||||||
|
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
|
||||||
|
print("[NUM] 已按文件名数字顺序排序")
|
||||||
|
except ValueError:
|
||||||
|
# 如果文件名不是纯数字,退回到字母排序
|
||||||
|
files.sort()
|
||||||
|
print("[TEXT] 已按文件名包含非数字字符,使用字母顺序排序")
|
||||||
|
|
||||||
|
print(f"[DIR] 找到 {len(files)} 个文件: {files}")
|
||||||
|
|
||||||
|
all_dfs = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
print(f"[READ] 读取: {file}")
|
||||||
|
# 使用 ExcelFile 读取所有 sheet
|
||||||
|
xls = pd.ExcelFile(file)
|
||||||
|
print(f" [PAGES] 包含 Sheets: {xls.sheet_names}")
|
||||||
|
|
||||||
|
file_dfs = []
|
||||||
|
for sheet_name in xls.sheet_names:
|
||||||
|
df = pd.read_excel(xls, sheet_name=sheet_name)
|
||||||
|
if not df.empty:
|
||||||
|
print(f" [OK] Sheet '{sheet_name}' 读取成功: {len(df)} 行")
|
||||||
|
file_dfs.append(df)
|
||||||
|
else:
|
||||||
|
print(f" [WARN] Sheet '{sheet_name}' 为空,跳过")
|
||||||
|
|
||||||
|
if file_dfs:
|
||||||
|
# 合并该文件的所有非空 sheet
|
||||||
|
file_merged_df = pd.concat(file_dfs, ignore_index=True)
|
||||||
|
# 可选:添加一列标记来源文件
|
||||||
|
file_merged_df['Source_File'] = os.path.basename(file)
|
||||||
|
all_dfs.append(file_merged_df)
|
||||||
|
else:
|
||||||
|
print(f"[WARN] 文件 {file} 所有 Sheet 均为空")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取 {file} 失败: {e}")
|
||||||
|
|
||||||
|
if all_dfs:
|
||||||
|
print("[LOOP] 正在合并数据...")
|
||||||
|
merged_df = pd.concat(all_dfs, ignore_index=True)
|
||||||
|
|
||||||
|
# 按 SendTime 排序
|
||||||
|
if 'SendTime' in merged_df.columns:
|
||||||
|
print("[TIMER] 正在按 SendTime 排序...")
|
||||||
|
merged_df['SendTime'] = pd.to_datetime(merged_df['SendTime'], errors='coerce')
|
||||||
|
merged_df = merged_df.sort_values(by='SendTime')
|
||||||
|
else:
|
||||||
|
print("[WARN] 未找到 SendTime 列,跳过排序")
|
||||||
|
|
||||||
|
print(f"[CACHE] 保存到: {output_file}")
|
||||||
|
merged_df.to_csv(output_file, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
print(f"[OK] 合并及排序完成!总行数: {len(merged_df)}")
|
||||||
|
print(f" 输出文件: {os.path.abspath(output_file)}")
|
||||||
|
else:
|
||||||
|
print("[WARN] 没有成功读取到任何数据。")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 如果需要在当前目录运行并合并 remotecontrol 文件夹下的内容
|
||||||
|
merge_excel_files(source_dir="remotecontrol", output_file="remotecontrol_merged.csv")
|
||||||
148
data_preprocessing/merger.py
Normal file
148
data_preprocessing/merger.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据合并模块
|
||||||
|
|
||||||
|
合并同类 Excel/CSV 文件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional, List
|
||||||
|
from .config import default_config
|
||||||
|
|
||||||
|
|
||||||
|
def merge_files(
|
||||||
|
source_dir: str,
|
||||||
|
output_file: Optional[str] = None,
|
||||||
|
pattern: str = "*.xlsx",
|
||||||
|
time_column: Optional[str] = None,
|
||||||
|
add_source_column: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
合并目录下的所有同类文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_dir: 源数据目录
|
||||||
|
output_file: 输出 CSV 文件路径。如果为 None,则输出到 cleaned_data 目录
|
||||||
|
pattern: 文件匹配模式 (e.g., "*.xlsx", "*.csv", "*.xls")
|
||||||
|
time_column: 可选,合并后按此列排序
|
||||||
|
add_source_column: 是否添加来源文件列
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
输出文件的绝对路径
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: 目录不存在或未找到匹配文件
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(source_dir):
|
||||||
|
raise FileNotFoundError(f"目录不存在: {source_dir}")
|
||||||
|
|
||||||
|
print(f"[SCAN] 正在扫描目录: {source_dir}")
|
||||||
|
print(f" 匹配模式: {pattern}")
|
||||||
|
|
||||||
|
# 查找匹配文件
|
||||||
|
files = glob.glob(os.path.join(source_dir, pattern))
|
||||||
|
|
||||||
|
# 如果是 xlsx,也尝试匹配 xls
|
||||||
|
if pattern == "*.xlsx":
|
||||||
|
files.extend(glob.glob(os.path.join(source_dir, "*.xls")))
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
raise FileNotFoundError(f"未找到匹配 '{pattern}' 的文件")
|
||||||
|
|
||||||
|
# 排序文件列表
|
||||||
|
files = _sort_files(files)
|
||||||
|
print(f"[FOUND] 找到 {len(files)} 个文件")
|
||||||
|
|
||||||
|
# 确定输出路径
|
||||||
|
if output_file is None:
|
||||||
|
default_config.ensure_dirs()
|
||||||
|
dir_name = os.path.basename(os.path.normpath(source_dir))
|
||||||
|
output_file = os.path.join(
|
||||||
|
default_config.cleaned_data_dir,
|
||||||
|
f"{dir_name}_merged.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 合并数据
|
||||||
|
all_dfs = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
df = _read_file(file)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
if add_source_column:
|
||||||
|
df['_source_file'] = os.path.basename(file)
|
||||||
|
all_dfs.append(df)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取失败 {file}: {e}")
|
||||||
|
|
||||||
|
if not all_dfs:
|
||||||
|
raise ValueError("没有成功读取到任何数据")
|
||||||
|
|
||||||
|
print(f"[MERGE] 正在合并 {len(all_dfs)} 个数据源...")
|
||||||
|
merged_df = pd.concat(all_dfs, ignore_index=True)
|
||||||
|
print(f" 合并后总行数: {len(merged_df)}")
|
||||||
|
|
||||||
|
# 可选:按时间排序
|
||||||
|
if time_column and time_column in merged_df.columns:
|
||||||
|
print(f"[SORT] 正在按 '{time_column}' 排序...")
|
||||||
|
merged_df[time_column] = pd.to_datetime(merged_df[time_column], errors='coerce')
|
||||||
|
merged_df = merged_df.sort_values(by=time_column, na_position='last')
|
||||||
|
elif time_column:
|
||||||
|
print(f"[WARN] 未找到时间列 '{time_column}',跳过排序")
|
||||||
|
|
||||||
|
# 保存结果
|
||||||
|
print(f"[SAVE] 正在保存: {output_file}")
|
||||||
|
merged_df.to_csv(output_file, index=False, encoding=default_config.csv_encoding)
|
||||||
|
|
||||||
|
abs_output = os.path.abspath(output_file)
|
||||||
|
print(f"[OK] 合并完成!")
|
||||||
|
print(f" 输出文件: {abs_output}")
|
||||||
|
print(f" 总行数: {len(merged_df)}")
|
||||||
|
|
||||||
|
return abs_output
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_files(files: List[str]) -> List[str]:
|
||||||
|
"""对文件列表进行智能排序"""
|
||||||
|
try:
|
||||||
|
# 尝试按文件名中的数字排序
|
||||||
|
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
|
||||||
|
print("[SORT] 已按文件名数字顺序排序")
|
||||||
|
except ValueError:
|
||||||
|
# 退回到字母排序
|
||||||
|
files.sort()
|
||||||
|
print("[SORT] 已按文件名字母顺序排序")
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file(file_path: str) -> Optional[pd.DataFrame]:
|
||||||
|
"""读取单个文件(支持 CSV 和 Excel)"""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|
||||||
|
print(f"[READ] 读取: {os.path.basename(file_path)}")
|
||||||
|
|
||||||
|
if ext == '.csv':
|
||||||
|
df = pd.read_csv(file_path, low_memory=False)
|
||||||
|
print(f" 行数: {len(df)}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
elif ext in ('.xlsx', '.xls'):
|
||||||
|
# 读取 Excel 所有 sheet 并合并
|
||||||
|
xls = pd.ExcelFile(file_path)
|
||||||
|
print(f" Sheets: {xls.sheet_names}")
|
||||||
|
|
||||||
|
sheet_dfs = []
|
||||||
|
for sheet_name in xls.sheet_names:
|
||||||
|
df = pd.read_excel(xls, sheet_name=sheet_name)
|
||||||
|
if not df.empty:
|
||||||
|
print(f" - Sheet '{sheet_name}': {len(df)} 行")
|
||||||
|
sheet_dfs.append(df)
|
||||||
|
|
||||||
|
if sheet_dfs:
|
||||||
|
return pd.concat(sheet_dfs, ignore_index=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[WARN] 不支持的文件格式: {ext}")
|
||||||
|
return None
|
||||||
45
data_preprocessing/sort_csv.py
Normal file
45
data_preprocessing/sort_csv.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
|
||||||
|
def sort_csv_by_time(file_path="remotecontrol_merged.csv", time_col="SendTime"):
|
||||||
|
"""
|
||||||
|
读取 CSV 文件,按时间列排序,并保存。
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"[ERROR] 文件不存在: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[READ] 正在读取 {file_path} ...")
|
||||||
|
try:
|
||||||
|
# 读取 CSV
|
||||||
|
df = pd.read_csv(file_path, low_memory=False)
|
||||||
|
print(f" [CHART] 数据行数: {len(df)}")
|
||||||
|
|
||||||
|
if time_col not in df.columns:
|
||||||
|
print(f"[ERROR] 未找到时间列: {time_col}")
|
||||||
|
print(f" 可用列: {list(df.columns)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[LOOP] 正在解析时间列 '{time_col}' ...")
|
||||||
|
# 转换为 datetime 对象,无法解析的设为 NaT
|
||||||
|
df[time_col] = pd.to_datetime(df[time_col], errors='coerce')
|
||||||
|
|
||||||
|
# 检查无效时间
|
||||||
|
nat_count = df[time_col].isna().sum()
|
||||||
|
if nat_count > 0:
|
||||||
|
print(f"[WARN] 发现 {nat_count} 行无效时间数据,排序时将排在最后")
|
||||||
|
|
||||||
|
print("[LOOP] 正在按时间排序...")
|
||||||
|
df_sorted = df.sort_values(by=time_col)
|
||||||
|
|
||||||
|
print(f"[CACHE] 正在保存及覆盖文件: {file_path} ...")
|
||||||
|
df_sorted.to_csv(file_path, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
print("[OK] 排序并保存完成!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR]处理失败: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sort_csv_by_time()
|
||||||
82
data_preprocessing/sorter.py
Normal file
82
data_preprocessing/sorter.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据排序模块
|
||||||
|
|
||||||
|
按时间列对 CSV 文件进行排序
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional
|
||||||
|
from .config import default_config
|
||||||
|
|
||||||
|
|
||||||
|
def sort_by_time(
|
||||||
|
input_path: str,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
time_column: str = None,
|
||||||
|
inplace: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
按时间列对 CSV 文件排序
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: 输入 CSV 文件路径
|
||||||
|
output_path: 输出路径。如果为 None 且 inplace=False,则输出到 cleaned_data 目录
|
||||||
|
time_column: 时间列名,默认使用配置中的 default_time_column
|
||||||
|
inplace: 是否原地覆盖输入文件
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
输出文件的绝对路径
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: 输入文件不存在
|
||||||
|
KeyError: 时间列不存在
|
||||||
|
"""
|
||||||
|
# 参数处理
|
||||||
|
time_column = time_column or default_config.default_time_column
|
||||||
|
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
raise FileNotFoundError(f"文件不存在: {input_path}")
|
||||||
|
|
||||||
|
# 确定输出路径
|
||||||
|
if inplace:
|
||||||
|
output_path = input_path
|
||||||
|
elif output_path is None:
|
||||||
|
default_config.ensure_dirs()
|
||||||
|
basename = os.path.basename(input_path)
|
||||||
|
name, ext = os.path.splitext(basename)
|
||||||
|
output_path = os.path.join(
|
||||||
|
default_config.cleaned_data_dir,
|
||||||
|
f"{name}_sorted{ext}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[READ] 正在读取: {input_path}")
|
||||||
|
df = pd.read_csv(input_path, low_memory=False)
|
||||||
|
print(f" 数据行数: {len(df)}")
|
||||||
|
|
||||||
|
# 检查时间列是否存在
|
||||||
|
if time_column not in df.columns:
|
||||||
|
available_cols = list(df.columns)
|
||||||
|
raise KeyError(
|
||||||
|
f"未找到时间列 '{time_column}'。可用列: {available_cols}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[PARSE] 正在解析时间列 '{time_column}'...")
|
||||||
|
df[time_column] = pd.to_datetime(df[time_column], errors='coerce')
|
||||||
|
|
||||||
|
# 统计无效时间
|
||||||
|
nat_count = df[time_column].isna().sum()
|
||||||
|
if nat_count > 0:
|
||||||
|
print(f"[WARN] 发现 {nat_count} 行无效时间数据,排序时将排在最后")
|
||||||
|
|
||||||
|
print("[SORT] 正在按时间排序...")
|
||||||
|
df_sorted = df.sort_values(by=time_column, na_position='last')
|
||||||
|
|
||||||
|
print(f"[SAVE] 正在保存: {output_path}")
|
||||||
|
df_sorted.to_csv(output_path, index=False, encoding=default_config.csv_encoding)
|
||||||
|
|
||||||
|
abs_output = os.path.abspath(output_path)
|
||||||
|
print(f"[OK] 排序完成!输出文件: {abs_output}")
|
||||||
|
|
||||||
|
return abs_output
|
||||||
18
main.py
18
main.py
@@ -17,7 +17,7 @@ class DualLogger:
|
|||||||
def write(self, message):
|
def write(self, message):
|
||||||
self.terminal.write(message)
|
self.terminal.write(message)
|
||||||
# 过滤掉生成的代码块,不写入日志文件
|
# 过滤掉生成的代码块,不写入日志文件
|
||||||
if "🔧 执行代码:" in message:
|
if "[TOOL] 执行代码:" in message:
|
||||||
return
|
return
|
||||||
self.log.write(message)
|
self.log.write(message)
|
||||||
self.log.flush()
|
self.log.flush()
|
||||||
@@ -34,7 +34,7 @@ def setup_logging(log_dir):
|
|||||||
# 可选:也将错误输出重定向
|
# 可选:也将错误输出重定向
|
||||||
# sys.stderr = logger
|
# sys.stderr = logger
|
||||||
print(f"\n{'='*20} Run Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {'='*20}\n")
|
print(f"\n{'='*20} Run Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {'='*20}\n")
|
||||||
print(f"📄 日志文件已保存至: {os.path.join(log_dir, 'log.txt')}")
|
print(f"[DOC] 日志文件已保存至: {os.path.join(log_dir, 'log.txt')}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -43,7 +43,7 @@ def main():
|
|||||||
import os
|
import os
|
||||||
# 自动查找当前目录及remotecontrol目录下的所有数据文件
|
# 自动查找当前目录及remotecontrol目录下的所有数据文件
|
||||||
data_extensions = ['*.csv', '*.xlsx', '*.xls']
|
data_extensions = ['*.csv', '*.xlsx', '*.xls']
|
||||||
search_dirs = ['jetour']
|
search_dirs = ['cleaned_data']
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
for search_dir in search_dirs:
|
for search_dir in search_dirs:
|
||||||
@@ -52,10 +52,10 @@ def main():
|
|||||||
files.extend(glob.glob(pattern))
|
files.extend(glob.glob(pattern))
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
print("⚠️ 未在当前目录找到数据文件 (.csv, .xlsx),尝试使用默认文件")
|
print("[WARN] 未在当前目录找到数据文件 (.csv, .xlsx),尝试使用默认文件")
|
||||||
files = ["./cleaned_data.csv"]
|
files = ["./cleaned_data.csv"]
|
||||||
else:
|
else:
|
||||||
print(f"📂 自动识别到以下数据文件: {files}")
|
print(f"[DIR] 自动识别到以下数据文件: {files}")
|
||||||
|
|
||||||
analysis_requirement = """
|
analysis_requirement = """
|
||||||
基于所有运维工单,整理一份工单健康度报告,包括但不限于对所有车联网技术支持工单的全面数据分析,
|
基于所有运维工单,整理一份工单健康度报告,包括但不限于对所有车联网技术支持工单的全面数据分析,
|
||||||
@@ -92,16 +92,16 @@ def main():
|
|||||||
print("\n" + "="*30 + " 当前阶段分析完成 " + "="*30)
|
print("\n" + "="*30 + " 当前阶段分析完成 " + "="*30)
|
||||||
|
|
||||||
# 询问用户是否继续
|
# 询问用户是否继续
|
||||||
print("\n💡 你可以继续对数据提出分析需求,或者输入 'exit'/'quit' 结束程序。")
|
print("\n[TIP] 你可以继续对数据提出分析需求,或者输入 'exit'/'quit' 结束程序。")
|
||||||
user_response = input("👉 请输入后续分析需求 (直接回车退出): ").strip()
|
user_response = input("[>] 请输入后续分析需求 (直接回车退出): ").strip()
|
||||||
|
|
||||||
if not user_response or user_response.lower() in ['exit', 'quit', 'n', 'no']:
|
if not user_response or user_response.lower() in ['exit', 'quit', 'n', 'no']:
|
||||||
print("👋 分析结束,再见!")
|
print("[BYE] 分析结束,再见!")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 更新需求,进入下一轮循环
|
# 更新需求,进入下一轮循环
|
||||||
analysis_requirement = user_response
|
analysis_requirement = user_response
|
||||||
print(f"\n🔄 收到新需求,正在继续分析...")
|
print(f"\n[LOOP] 收到新需求,正在继续分析...")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.
|
|||||||
"""
|
"""
|
||||||
将指定目录下的所有 Excel 文件 (.xlsx, .xls) 合并为一个 CSV 文件。
|
将指定目录下的所有 Excel 文件 (.xlsx, .xls) 合并为一个 CSV 文件。
|
||||||
"""
|
"""
|
||||||
print(f"🔍 正在扫描目录: {source_dir} ...")
|
print(f"[SEARCH] 正在扫描目录: {source_dir} ...")
|
||||||
|
|
||||||
# 支持 xlsx 和 xls
|
# 支持 xlsx 和 xls
|
||||||
files_xlsx = glob.glob(os.path.join(source_dir, "*.xlsx"))
|
files_xlsx = glob.glob(os.path.join(source_dir, "*.xlsx"))
|
||||||
@@ -15,36 +15,36 @@ def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.
|
|||||||
files = files_xlsx + files_xls
|
files = files_xlsx + files_xls
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
print("⚠️ 未找到 Excel 文件。")
|
print("[WARN] 未找到 Excel 文件。")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 按文件名中的数字进行排序 (例如: 1.xlsx, 2.xlsx, ..., 10.xlsx)
|
# 按文件名中的数字进行排序 (例如: 1.xlsx, 2.xlsx, ..., 10.xlsx)
|
||||||
try:
|
try:
|
||||||
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
|
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
|
||||||
print("🔢 已按文件名数字顺序排序")
|
print("[NUM] 已按文件名数字顺序排序")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# 如果文件名不是纯数字,退回到字母排序
|
# 如果文件名不是纯数字,退回到字母排序
|
||||||
files.sort()
|
files.sort()
|
||||||
print("🔤 已按文件名包含非数字字符,使用字母顺序排序")
|
print("[TEXT] 已按文件名包含非数字字符,使用字母顺序排序")
|
||||||
|
|
||||||
print(f"📂 找到 {len(files)} 个文件: {files}")
|
print(f"[DIR] 找到 {len(files)} 个文件: {files}")
|
||||||
|
|
||||||
all_dfs = []
|
all_dfs = []
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
print(f"📖 读取: {file}")
|
print(f"[READ] 读取: {file}")
|
||||||
# 使用 ExcelFile 读取所有 sheet
|
# 使用 ExcelFile 读取所有 sheet
|
||||||
xls = pd.ExcelFile(file)
|
xls = pd.ExcelFile(file)
|
||||||
print(f" 📑 包含 Sheets: {xls.sheet_names}")
|
print(f" [PAGES] 包含 Sheets: {xls.sheet_names}")
|
||||||
|
|
||||||
file_dfs = []
|
file_dfs = []
|
||||||
for sheet_name in xls.sheet_names:
|
for sheet_name in xls.sheet_names:
|
||||||
df = pd.read_excel(xls, sheet_name=sheet_name)
|
df = pd.read_excel(xls, sheet_name=sheet_name)
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
print(f" ✅ Sheet '{sheet_name}' 读取成功: {len(df)} 行")
|
print(f" [OK] Sheet '{sheet_name}' 读取成功: {len(df)} 行")
|
||||||
file_dfs.append(df)
|
file_dfs.append(df)
|
||||||
else:
|
else:
|
||||||
print(f" ⚠️ Sheet '{sheet_name}' 为空,跳过")
|
print(f" [WARN] Sheet '{sheet_name}' 为空,跳过")
|
||||||
|
|
||||||
if file_dfs:
|
if file_dfs:
|
||||||
# 合并该文件的所有非空 sheet
|
# 合并该文件的所有非空 sheet
|
||||||
@@ -53,30 +53,30 @@ def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.
|
|||||||
file_merged_df['Source_File'] = os.path.basename(file)
|
file_merged_df['Source_File'] = os.path.basename(file)
|
||||||
all_dfs.append(file_merged_df)
|
all_dfs.append(file_merged_df)
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ 文件 {file} 所有 Sheet 均为空")
|
print(f"[WARN] 文件 {file} 所有 Sheet 均为空")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 读取 {file} 失败: {e}")
|
print(f"[ERROR] 读取 {file} 失败: {e}")
|
||||||
|
|
||||||
if all_dfs:
|
if all_dfs:
|
||||||
print("🔄 正在合并数据...")
|
print("[LOOP] 正在合并数据...")
|
||||||
merged_df = pd.concat(all_dfs, ignore_index=True)
|
merged_df = pd.concat(all_dfs, ignore_index=True)
|
||||||
|
|
||||||
# 按 SendTime 排序
|
# 按 SendTime 排序
|
||||||
if 'SendTime' in merged_df.columns:
|
if 'SendTime' in merged_df.columns:
|
||||||
print("⏳ 正在按 SendTime 排序...")
|
print("[TIMER] 正在按 SendTime 排序...")
|
||||||
merged_df['SendTime'] = pd.to_datetime(merged_df['SendTime'], errors='coerce')
|
merged_df['SendTime'] = pd.to_datetime(merged_df['SendTime'], errors='coerce')
|
||||||
merged_df = merged_df.sort_values(by='SendTime')
|
merged_df = merged_df.sort_values(by='SendTime')
|
||||||
else:
|
else:
|
||||||
print("⚠️ 未找到 SendTime 列,跳过排序")
|
print("[WARN] 未找到 SendTime 列,跳过排序")
|
||||||
|
|
||||||
print(f"💾 保存到: {output_file}")
|
print(f"[CACHE] 保存到: {output_file}")
|
||||||
merged_df.to_csv(output_file, index=False, encoding="utf-8-sig")
|
merged_df.to_csv(output_file, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
print(f"✅ 合并及排序完成!总行数: {len(merged_df)}")
|
print(f"[OK] 合并及排序完成!总行数: {len(merged_df)}")
|
||||||
print(f" 输出文件: {os.path.abspath(output_file)}")
|
print(f" 输出文件: {os.path.abspath(output_file)}")
|
||||||
else:
|
else:
|
||||||
print("⚠️ 没有成功读取到任何数据。")
|
print("[WARN] 没有成功读取到任何数据。")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# 如果需要在当前目录运行并合并 remotecontrol 文件夹下的内容
|
# 如果需要在当前目录运行并合并 remotecontrol 文件夹下的内容
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
data_analysis_system_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中,能够根据用户需求生成和执行Python数据分析代码。
|
data_analysis_system_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中,能够根据用户需求生成和执行Python数据分析代码。
|
||||||
|
|
||||||
**核心使命**:
|
**核心使命**:
|
||||||
- 接收自然语言需求,分阶段生成高效、安全的数据分析代码。
|
- 接收自然语言需求,分阶段生成高效、安全的数据分析代码。
|
||||||
- 深度挖掘数据,不仅仅是绘图,更要发现数据背后的业务洞察。
|
- 深度挖掘数据,不仅仅是绘图,更要发现数据背后的业务洞察。
|
||||||
@@ -316,11 +315,19 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
|||||||
data_analysis_followup_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中。
|
data_analysis_followup_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中。
|
||||||
当前处于**追问模式 (Follow-up Mode)**。用户基于之前的分析结果提出了新的需求。
|
当前处于**追问模式 (Follow-up Mode)**。用户基于之前的分析结果提出了新的需求。
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
**核心使命**:
|
**核心使命**:
|
||||||
- 直接针对用户的后续需求进行解答,**无需**重新执行完整SOP。
|
- 直接针对用户的后续需求进行解答,**无需**重新执行完整SOP。
|
||||||
- 只有当用户明确要求重新进行全流程分析时,才执行SOP。
|
- 只有当用户明确要求重新进行全流程分析时,才执行SOP。
|
||||||
|
|
||||||
**核心能力**:
|
**核心能力**:
|
||||||
|
=======
|
||||||
|
[TARGET] **核心使命**:
|
||||||
|
- 直接针对用户的后续需求进行解答,**无需**重新执行完整SOP。
|
||||||
|
- 只有当用户明确要求重新进行全流程分析时,才执行SOP。
|
||||||
|
|
||||||
|
[TOOL] **核心能力**:
|
||||||
|
>>>>>>> e9644360ce283742849fe67c38d05864513e2f96
|
||||||
1. **代码执行**:自动编写并执行Pandas/Matplotlib代码。
|
1. **代码执行**:自动编写并执行Pandas/Matplotlib代码。
|
||||||
2. **多模态分析**:支持时序预测、文本挖掘(N-gram)、多维交叉分析。
|
2. **多模态分析**:支持时序预测、文本挖掘(N-gram)、多维交叉分析。
|
||||||
3. **智能纠错**:遇到报错自动分析原因并修复代码。
|
3. **智能纠错**:遇到报错自动分析原因并修复代码。
|
||||||
|
|||||||
0
raw_data/.gitkeep
Normal file
0
raw_data/.gitkeep
Normal file
20
sort_csv.py
20
sort_csv.py
@@ -7,39 +7,39 @@ def sort_csv_by_time(file_path="remotecontrol_merged.csv", time_col="SendTime"):
|
|||||||
读取 CSV 文件,按时间列排序,并保存。
|
读取 CSV 文件,按时间列排序,并保存。
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
print(f"❌ 文件不存在: {file_path}")
|
print(f"[ERROR] 文件不存在: {file_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"📖 正在读取 {file_path} ...")
|
print(f"[READ] 正在读取 {file_path} ...")
|
||||||
try:
|
try:
|
||||||
# 读取 CSV
|
# 读取 CSV
|
||||||
df = pd.read_csv(file_path, low_memory=False)
|
df = pd.read_csv(file_path, low_memory=False)
|
||||||
print(f" 📊 数据行数: {len(df)}")
|
print(f" [CHART] 数据行数: {len(df)}")
|
||||||
|
|
||||||
if time_col not in df.columns:
|
if time_col not in df.columns:
|
||||||
print(f"❌ 未找到时间列: {time_col}")
|
print(f"[ERROR] 未找到时间列: {time_col}")
|
||||||
print(f" 可用列: {list(df.columns)}")
|
print(f" 可用列: {list(df.columns)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"🔄 正在解析时间列 '{time_col}' ...")
|
print(f"[LOOP] 正在解析时间列 '{time_col}' ...")
|
||||||
# 转换为 datetime 对象,无法解析的设为 NaT
|
# 转换为 datetime 对象,无法解析的设为 NaT
|
||||||
df[time_col] = pd.to_datetime(df[time_col], errors='coerce')
|
df[time_col] = pd.to_datetime(df[time_col], errors='coerce')
|
||||||
|
|
||||||
# 检查无效时间
|
# 检查无效时间
|
||||||
nat_count = df[time_col].isna().sum()
|
nat_count = df[time_col].isna().sum()
|
||||||
if nat_count > 0:
|
if nat_count > 0:
|
||||||
print(f"⚠️ 发现 {nat_count} 行无效时间数据,排序时将排在最后")
|
print(f"[WARN] 发现 {nat_count} 行无效时间数据,排序时将排在最后")
|
||||||
|
|
||||||
print("🔄 正在按时间排序...")
|
print("[LOOP] 正在按时间排序...")
|
||||||
df_sorted = df.sort_values(by=time_col)
|
df_sorted = df.sort_values(by=time_col)
|
||||||
|
|
||||||
print(f"💾 正在保存及覆盖文件: {file_path} ...")
|
print(f"[CACHE] 正在保存及覆盖文件: {file_path} ...")
|
||||||
df_sorted.to_csv(file_path, index=False, encoding="utf-8-sig")
|
df_sorted.to_csv(file_path, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
print("✅ 排序并保存完成!")
|
print("[OK] 排序并保存完成!")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌处理失败: {e}")
|
print(f"[ERROR]处理失败: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sort_csv_by_time()
|
sort_csv_by_time()
|
||||||
|
|||||||
14
test.py
14
test.py
@@ -1,13 +1,13 @@
|
|||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
import openai
|
client = OpenAI(
|
||||||
|
base_url="http://127.0.0.1:9999/v1",
|
||||||
client = openai.OpenAI(
|
api_key="sk-2187174de21548b0b8b0c92129700199"
|
||||||
api_key="sk-Gce85QLROESeOWf3icd2mQnYHOrmMYojwVPQ0AubMjGQ5ZE2",
|
|
||||||
base_url="https://gemini.jeason.online/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model="gemini-2.5-pro",
|
model="claude-sonnet-4-5",
|
||||||
messages=[{"role": "user", "content": "你好,请自我介绍"}]
|
messages=[{"role": "user", "content": "Hello"}]
|
||||||
)
|
)
|
||||||
|
|
||||||
print(response.choices[0].message.content)
|
print(response.choices[0].message.content)
|
||||||
@@ -42,7 +42,7 @@ class CacheManager:
|
|||||||
with open(cache_path, 'rb') as f:
|
with open(cache_path, 'rb') as f:
|
||||||
return pickle.load(f)
|
return pickle.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 读取缓存失败: {e}")
|
print(f"[WARN] 读取缓存失败: {e}")
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -56,14 +56,14 @@ class CacheManager:
|
|||||||
with open(cache_path, 'wb') as f:
|
with open(cache_path, 'wb') as f:
|
||||||
pickle.dump(value, f)
|
pickle.dump(value, f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 写入缓存失败: {e}")
|
print(f"[WARN] 写入缓存失败: {e}")
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""清空所有缓存"""
|
"""清空所有缓存"""
|
||||||
if self.cache_dir.exists():
|
if self.cache_dir.exists():
|
||||||
for cache_file in self.cache_dir.glob("*.pkl"):
|
for cache_file in self.cache_dir.glob("*.pkl"):
|
||||||
cache_file.unlink()
|
cache_file.unlink()
|
||||||
print("✅ 缓存已清空")
|
print("[OK] 缓存已清空")
|
||||||
|
|
||||||
def cached(self, key_func: Optional[Callable] = None):
|
def cached(self, key_func: Optional[Callable] = None):
|
||||||
"""缓存装饰器"""
|
"""缓存装饰器"""
|
||||||
@@ -82,7 +82,7 @@ class CacheManager:
|
|||||||
# 尝试从缓存获取
|
# 尝试从缓存获取
|
||||||
cached_value = self.get(cache_key)
|
cached_value = self.get(cache_key)
|
||||||
if cached_value is not None:
|
if cached_value is not None:
|
||||||
print(f"💾 使用缓存: {cache_key[:8]}...")
|
print(f"[CACHE] 使用缓存: {cache_key[:8]}...")
|
||||||
return cached_value
|
return cached_value
|
||||||
|
|
||||||
# 执行函数并缓存结果
|
# 执行函数并缓存结果
|
||||||
|
|||||||
@@ -410,17 +410,17 @@ from IPython.display import display
|
|||||||
try:
|
try:
|
||||||
# 尝试保存
|
# 尝试保存
|
||||||
fig.savefig(auto_filepath, bbox_inches='tight')
|
fig.savefig(auto_filepath, bbox_inches='tight')
|
||||||
print(f"💾 [Auto-Save] 检测到未闭合图表,已安全保存至: {auto_filepath}")
|
print(f"[CACHE] [Auto-Save] 检测到未闭合图表,已安全保存至: {auto_filepath}")
|
||||||
|
|
||||||
# 添加到输出中,告知Agent
|
# 添加到输出中,告知Agent
|
||||||
output += f"\n[Auto-Save] ⚠️ 检测到Figure {fig_num}未关闭,系统已自动保存为: {auto_filename}"
|
output += f"\n[Auto-Save] [WARN] 检测到Figure {fig_num}未关闭,系统已自动保存为: {auto_filename}"
|
||||||
self.image_counter += 1
|
self.image_counter += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ [Auto-Save] 保存失败: {e}")
|
print(f"[WARN] [Auto-Save] 保存失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
plt.close(fig_num)
|
plt.close(fig_num)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ [Auto-Save Global] 异常: {e}")
|
print(f"[WARN] [Auto-Save Global] 异常: {e}")
|
||||||
# --- 自动保存机制 end ---
|
# --- 自动保存机制 end ---
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def load_and_profile_data(file_paths: list) -> str:
|
|||||||
profile_summary += f"## 文件: {file_name}\n\n"
|
profile_summary += f"## 文件: {file_name}\n\n"
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
profile_summary += f"⚠️ 文件不存在: {file_path}\n\n"
|
profile_summary += f"[WARN] 文件不存在: {file_path}\n\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -52,7 +52,7 @@ def load_and_profile_data(file_paths: list) -> str:
|
|||||||
elif ext in ['.xlsx', '.xls']:
|
elif ext in ['.xlsx', '.xls']:
|
||||||
df = pd.read_excel(file_path)
|
df = pd.read_excel(file_path)
|
||||||
else:
|
else:
|
||||||
profile_summary += f"⚠️ 不支持的文件格式: {ext}\n\n"
|
profile_summary += f"[WARN] 不支持的文件格式: {ext}\n\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 基础信息
|
# 基础信息
|
||||||
@@ -70,7 +70,7 @@ def load_and_profile_data(file_paths: list) -> str:
|
|||||||
|
|
||||||
profile_summary += f"#### {col} ({dtype})\n"
|
profile_summary += f"#### {col} ({dtype})\n"
|
||||||
if null_count > 0:
|
if null_count > 0:
|
||||||
profile_summary += f"- ⚠️ 空值: {null_count} ({null_ratio:.1f}%)\n"
|
profile_summary += f"- [WARN] 空值: {null_count} ({null_ratio:.1f}%)\n"
|
||||||
|
|
||||||
# 数值列分析
|
# 数值列分析
|
||||||
if pd.api.types.is_numeric_dtype(dtype):
|
if pd.api.types.is_numeric_dtype(dtype):
|
||||||
@@ -96,7 +96,7 @@ def load_and_profile_data(file_paths: list) -> str:
|
|||||||
profile_summary += "\n"
|
profile_summary += "\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
profile_summary += f"❌ 读取或分析文件失败: {str(e)}\n\n"
|
profile_summary += f"[ERROR] 读取或分析文件失败: {str(e)}\n\n"
|
||||||
|
|
||||||
return profile_summary
|
return profile_summary
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ def load_data_chunked(file_path: str, chunksize: Optional[int] = None) -> Iterat
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 读取CSV文件失败: {e}")
|
print(f"[ERROR] 读取CSV文件失败: {e}")
|
||||||
break
|
break
|
||||||
elif ext in ['.xlsx', '.xls']:
|
elif ext in ['.xlsx', '.xls']:
|
||||||
# Excel文件不支持chunksize,直接读取
|
# Excel文件不支持chunksize,直接读取
|
||||||
@@ -151,7 +151,7 @@ def load_data_chunked(file_path: str, chunksize: Optional[int] = None) -> Iterat
|
|||||||
for i in range(0, len(df), chunksize):
|
for i in range(0, len(df), chunksize):
|
||||||
yield df.iloc[i:i+chunksize]
|
yield df.iloc[i:i+chunksize]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 读取Excel文件失败: {e}")
|
print(f"[ERROR] 读取Excel文件失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional[pd.DataFrame]:
|
def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional[pd.DataFrame]:
|
||||||
@@ -166,7 +166,7 @@ def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional
|
|||||||
DataFrame或None
|
DataFrame或None
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
print(f"⚠️ 文件不存在: {file_path}")
|
print(f"[WARN] 文件不存在: {file_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 检查文件大小
|
# 检查文件大小
|
||||||
@@ -174,7 +174,7 @@ def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional
|
|||||||
|
|
||||||
# 对于大文件,建议使用流式处理
|
# 对于大文件,建议使用流式处理
|
||||||
if file_size_mb > app_config.max_file_size_mb:
|
if file_size_mb > app_config.max_file_size_mb:
|
||||||
print(f"⚠️ 文件过大 ({file_size_mb:.1f}MB),建议使用 load_data_chunked() 流式处理")
|
print(f"[WARN] 文件过大 ({file_size_mb:.1f}MB),建议使用 load_data_chunked() 流式处理")
|
||||||
|
|
||||||
# 生成缓存键
|
# 生成缓存键
|
||||||
cache_key = get_file_hash(file_path)
|
cache_key = get_file_hash(file_path)
|
||||||
@@ -183,7 +183,7 @@ def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional
|
|||||||
if not force_reload and app_config.data_cache_enabled:
|
if not force_reload and app_config.data_cache_enabled:
|
||||||
cached_data = data_cache.get(cache_key)
|
cached_data = data_cache.get(cache_key)
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
print(f"💾 从缓存加载数据: {os.path.basename(file_path)}")
|
print(f"[CACHE] 从缓存加载数据: {os.path.basename(file_path)}")
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
# 加载数据
|
# 加载数据
|
||||||
@@ -202,16 +202,16 @@ def load_data_with_cache(file_path: str, force_reload: bool = False) -> Optional
|
|||||||
elif ext in ['.xlsx', '.xls']:
|
elif ext in ['.xlsx', '.xls']:
|
||||||
df = pd.read_excel(file_path)
|
df = pd.read_excel(file_path)
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ 不支持的文件格式: {ext}")
|
print(f"[WARN] 不支持的文件格式: {ext}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 缓存数据
|
# 缓存数据
|
||||||
if df is not None and app_config.data_cache_enabled:
|
if df is not None and app_config.data_cache_enabled:
|
||||||
data_cache.set(cache_key, df)
|
data_cache.set(cache_key, df)
|
||||||
print(f"✅ 数据已缓存: {os.path.basename(file_path)}")
|
print(f"[OK] 数据已缓存: {os.path.basename(file_path)}")
|
||||||
|
|
||||||
return df
|
return df
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 加载数据失败: {e}")
|
print(f"[ERROR] 加载数据失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -192,27 +192,27 @@ class DataQualityChecker:
|
|||||||
summary += f"**质量评分**: {self.quality_score:.1f}/100\n\n"
|
summary += f"**质量评分**: {self.quality_score:.1f}/100\n\n"
|
||||||
|
|
||||||
if self.quality_score >= 90:
|
if self.quality_score >= 90:
|
||||||
summary += "✅ **评级**: 优秀 - 数据质量很好\n\n"
|
summary += "[OK] **评级**: 优秀 - 数据质量很好\n\n"
|
||||||
elif self.quality_score >= 75:
|
elif self.quality_score >= 75:
|
||||||
summary += "⚠️ **评级**: 良好 - 存在一些小问题\n\n"
|
summary += "[WARN] **评级**: 良好 - 存在一些小问题\n\n"
|
||||||
elif self.quality_score >= 60:
|
elif self.quality_score >= 60:
|
||||||
summary += "⚠️ **评级**: 一般 - 需要处理多个问题\n\n"
|
summary += "[WARN] **评级**: 一般 - 需要处理多个问题\n\n"
|
||||||
else:
|
else:
|
||||||
summary += "❌ **评级**: 差 - 数据质量问题严重\n\n"
|
summary += "[ERROR] **评级**: 差 - 数据质量问题严重\n\n"
|
||||||
|
|
||||||
summary += f"**问题统计**: 共 {len(self.issues)} 个质量问题\n"
|
summary += f"**问题统计**: 共 {len(self.issues)} 个质量问题\n"
|
||||||
summary += f"- 🔴 高严重性: {len([i for i in self.issues if i.severity == 'high'])} 个\n"
|
summary += f"- [RED] 高严重性: {len([i for i in self.issues if i.severity == 'high'])} 个\n"
|
||||||
summary += f"- 🟡 中严重性: {len([i for i in self.issues if i.severity == 'medium'])} 个\n"
|
summary += f"- [YELLOW] 中严重性: {len([i for i in self.issues if i.severity == 'medium'])} 个\n"
|
||||||
summary += f"- 🟢 低严重性: {len([i for i in self.issues if i.severity == 'low'])} 个\n\n"
|
summary += f"- [GREEN] 低严重性: {len([i for i in self.issues if i.severity == 'low'])} 个\n\n"
|
||||||
|
|
||||||
if self.issues:
|
if self.issues:
|
||||||
summary += "### 主要问题:\n\n"
|
summary += "### 主要问题:\n\n"
|
||||||
# 只显示高和中严重性的问题
|
# 只显示高和中严重性的问题
|
||||||
for issue in self.issues:
|
for issue in self.issues:
|
||||||
if issue.severity in ["high", "medium"]:
|
if issue.severity in ["high", "medium"]:
|
||||||
emoji = "🔴" if issue.severity == "high" else "🟡"
|
emoji = "[RED]" if issue.severity == "high" else "[YELLOW]"
|
||||||
summary += f"{emoji} **{issue.column}** - {issue.description}\n"
|
summary += f"{emoji} **{issue.column}** - {issue.description}\n"
|
||||||
summary += f" 💡 {issue.suggestion}\n\n"
|
summary += f" [TIP] {issue.suggestion}\n\n"
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class AsyncFallbackOpenAIClient:
|
|||||||
self.fallback_client = AsyncOpenAI(api_key=fallback_api_key, base_url=fallback_base_url, **_fallback_args)
|
self.fallback_client = AsyncOpenAI(api_key=fallback_api_key, base_url=fallback_base_url, **_fallback_args)
|
||||||
self.fallback_model_name = fallback_model_name
|
self.fallback_model_name = fallback_model_name
|
||||||
else:
|
else:
|
||||||
print("⚠️ 警告: 未完全配置备用 API 客户端。如果主 API 失败,将无法进行回退。")
|
print("[WARN] 警告: 未完全配置备用 API 客户端。如果主 API 失败,将无法进行回退。")
|
||||||
|
|
||||||
self.content_filter_error_code = content_filter_error_code
|
self.content_filter_error_code = content_filter_error_code
|
||||||
self.content_filter_error_field = content_filter_error_field
|
self.content_filter_error_field = content_filter_error_field
|
||||||
@@ -90,11 +90,11 @@ class AsyncFallbackOpenAIClient:
|
|||||||
return completion
|
return completion
|
||||||
except (APIConnectionError, APITimeoutError) as e: # 通常可以重试的网络错误
|
except (APIConnectionError, APITimeoutError) as e: # 通常可以重试的网络错误
|
||||||
last_exception = e
|
last_exception = e
|
||||||
print(f"⚠️ {api_name} API 调用时发生可重试错误 ({type(e).__name__}): {e}. 尝试次数 {attempt + 1}/{max_retries + 1}")
|
print(f"[WARN] {api_name} API 调用时发生可重试错误 ({type(e).__name__}): {e}. 尝试次数 {attempt + 1}/{max_retries + 1}")
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
await asyncio.sleep(self.retry_delay_seconds * (attempt + 1)) # 增加延迟
|
await asyncio.sleep(self.retry_delay_seconds * (attempt + 1)) # 增加延迟
|
||||||
else:
|
else:
|
||||||
print(f"❌ {api_name} API 在达到最大重试次数后仍然失败。")
|
print(f"[ERROR] {api_name} API 在达到最大重试次数后仍然失败。")
|
||||||
except APIStatusError as e: # API 返回的特定状态码错误
|
except APIStatusError as e: # API 返回的特定状态码错误
|
||||||
is_content_filter_error = False
|
is_content_filter_error = False
|
||||||
retry_after = None
|
retry_after = None
|
||||||
@@ -118,7 +118,7 @@ class AsyncFallbackOpenAIClient:
|
|||||||
if delay_str.endswith("s"):
|
if delay_str.endswith("s"):
|
||||||
try:
|
try:
|
||||||
retry_after = float(delay_str[:-1])
|
retry_after = float(delay_str[:-1])
|
||||||
print(f"⏳ 收到服务器 RetryInfo,等待时间: {retry_after}秒")
|
print(f"[TIMER] 收到服务器 RetryInfo,等待时间: {retry_after}秒")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -128,7 +128,7 @@ class AsyncFallbackOpenAIClient:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
last_exception = e
|
last_exception = e
|
||||||
print(f"⚠️ {api_name} API 调用时发生 APIStatusError ({e.status_code}): {e}. 尝试次数 {attempt + 1}/{max_retries + 1}")
|
print(f"[WARN] {api_name} API 调用时发生 APIStatusError ({e.status_code}): {e}. 尝试次数 {attempt + 1}/{max_retries + 1}")
|
||||||
|
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
# 如果获取到了明确的 retry_after,则使用它;否则使用默认的指数退避
|
# 如果获取到了明确的 retry_after,则使用它;否则使用默认的指数退避
|
||||||
@@ -137,13 +137,13 @@ class AsyncFallbackOpenAIClient:
|
|||||||
if e.status_code == 429 and retry_after is None:
|
if e.status_code == 429 and retry_after is None:
|
||||||
wait_time = max(wait_time, 5.0 * (attempt + 1)) # 429 默认至少等 5 秒
|
wait_time = max(wait_time, 5.0 * (attempt + 1)) # 429 默认至少等 5 秒
|
||||||
|
|
||||||
print(f"💤 将等待 {wait_time:.2f} 秒后重试...")
|
print(f"[WAIT] 将等待 {wait_time:.2f} 秒后重试...")
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
print(f"❌ {api_name} API 在达到最大重试次数后仍然失败 (APIStatusError)。")
|
print(f"[ERROR] {api_name} API 在达到最大重试次数后仍然失败 (APIStatusError)。")
|
||||||
except APIError as e: # 其他不可轻易重试的 OpenAI 错误
|
except APIError as e: # 其他不可轻易重试的 OpenAI 错误
|
||||||
last_exception = e
|
last_exception = e
|
||||||
print(f"❌ {api_name} API 调用时发生不可重试错误 ({type(e).__name__}): {e}")
|
print(f"[ERROR] {api_name} API 调用时发生不可重试错误 ({type(e).__name__}): {e}")
|
||||||
break # 不再重试此类错误
|
break # 不再重试此类错误
|
||||||
|
|
||||||
if last_exception:
|
if last_exception:
|
||||||
@@ -196,7 +196,7 @@ class AsyncFallbackOpenAIClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if is_content_filter_error and self.fallback_client and self.fallback_model_name:
|
if is_content_filter_error and self.fallback_client and self.fallback_model_name:
|
||||||
print(f"ℹ️ 主 API 内容过滤错误 ({e_primary.status_code})。尝试切换到备用 API ({self.fallback_client.base_url})...")
|
print(f"[INFO] 主 API 内容过滤错误 ({e_primary.status_code})。尝试切换到备用 API ({self.fallback_client.base_url})...")
|
||||||
try:
|
try:
|
||||||
fallback_completion = await self._attempt_api_call(
|
fallback_completion = await self._attempt_api_call(
|
||||||
client=self.fallback_client,
|
client=self.fallback_client,
|
||||||
@@ -206,20 +206,20 @@ class AsyncFallbackOpenAIClient:
|
|||||||
api_name="备用",
|
api_name="备用",
|
||||||
**kwargs.copy()
|
**kwargs.copy()
|
||||||
)
|
)
|
||||||
print(f"✅ 备用 API 调用成功。")
|
print(f"[OK] 备用 API 调用成功。")
|
||||||
return fallback_completion
|
return fallback_completion
|
||||||
except APIError as e_fallback:
|
except APIError as e_fallback:
|
||||||
print(f"❌ 备用 API 调用最终失败: {type(e_fallback).__name__} - {e_fallback}")
|
print(f"[ERROR] 备用 API 调用最终失败: {type(e_fallback).__name__} - {e_fallback}")
|
||||||
raise e_fallback
|
raise e_fallback
|
||||||
else:
|
else:
|
||||||
if not (self.fallback_client and self.fallback_model_name and is_content_filter_error):
|
if not (self.fallback_client and self.fallback_model_name and is_content_filter_error):
|
||||||
# 如果不是内容过滤错误,或者没有可用的备用API,则记录主API的原始错误
|
# 如果不是内容过滤错误,或者没有可用的备用API,则记录主API的原始错误
|
||||||
print(f"ℹ️ 主 API 错误 ({type(e_primary).__name__}: {e_primary}), 且不满足备用条件或备用API未配置。")
|
print(f"[INFO] 主 API 错误 ({type(e_primary).__name__}: {e_primary}), 且不满足备用条件或备用API未配置。")
|
||||||
raise e_primary
|
raise e_primary
|
||||||
except APIError as e_primary_other:
|
except APIError as e_primary_other:
|
||||||
print(f"❌ 主 API 调用最终失败 (非内容过滤,错误类型: {type(e_primary_other).__name__}): {e_primary_other}")
|
print(f"[ERROR] 主 API 调用最终失败 (非内容过滤,错误类型: {type(e_primary_other).__name__}): {e_primary_other}")
|
||||||
if self.fallback_client and self.fallback_model_name:
|
if self.fallback_client and self.fallback_model_name:
|
||||||
print(f"ℹ️ 主 API 失败,尝试切换到备用 API ({self.fallback_client.base_url})...")
|
print(f"[INFO] 主 API 失败,尝试切换到备用 API ({self.fallback_client.base_url})...")
|
||||||
try:
|
try:
|
||||||
fallback_completion = await self._attempt_api_call(
|
fallback_completion = await self._attempt_api_call(
|
||||||
client=self.fallback_client,
|
client=self.fallback_client,
|
||||||
@@ -229,10 +229,10 @@ class AsyncFallbackOpenAIClient:
|
|||||||
api_name="备用",
|
api_name="备用",
|
||||||
**kwargs.copy()
|
**kwargs.copy()
|
||||||
)
|
)
|
||||||
print(f"✅ 备用 API 调用成功。")
|
print(f"[OK] 备用 API 调用成功。")
|
||||||
return fallback_completion
|
return fallback_completion
|
||||||
except APIError as e_fallback_after_primary_fail:
|
except APIError as e_fallback_after_primary_fail:
|
||||||
print(f"❌ 备用 API 在主 API 失败后也调用失败: {type(e_fallback_after_primary_fail).__name__} - {e_fallback_after_primary_fail}")
|
print(f"[ERROR] 备用 API 在主 API 失败后也调用失败: {type(e_fallback_after_primary_fail).__name__} - {e_fallback_after_primary_fail}")
|
||||||
raise e_fallback_after_primary_fail
|
raise e_fallback_after_primary_fail
|
||||||
else:
|
else:
|
||||||
raise e_primary_other
|
raise e_primary_other
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ def format_execution_result(result: Dict[str, Any]) -> str:
|
|||||||
feedback = []
|
feedback = []
|
||||||
|
|
||||||
if result['success']:
|
if result['success']:
|
||||||
feedback.append("✅ 代码执行成功")
|
feedback.append("[OK] 代码执行成功")
|
||||||
|
|
||||||
if result['output']:
|
if result['output']:
|
||||||
feedback.append(f"📊 输出结果:\n{result['output']}")
|
feedback.append(f"[CHART] 输出结果:\n{result['output']}")
|
||||||
|
|
||||||
if result.get('variables'):
|
if result.get('variables'):
|
||||||
feedback.append("📋 新生成的变量:")
|
feedback.append("[LIST] 新生成的变量:")
|
||||||
for var_name, var_info in result['variables'].items():
|
for var_name, var_info in result['variables'].items():
|
||||||
feedback.append(f" - {var_name}: {var_info}")
|
feedback.append(f" - {var_name}: {var_info}")
|
||||||
else:
|
else:
|
||||||
feedback.append("❌ 代码执行失败")
|
feedback.append("[ERROR] 代码执行失败")
|
||||||
feedback.append(f"错误信息: {result['error']}")
|
feedback.append(f"错误信息: {result['error']}")
|
||||||
if result['output']:
|
if result['output']:
|
||||||
feedback.append(f"部分输出: {result['output']}")
|
feedback.append(f"部分输出: {result['output']}")
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class LLMHelper:
|
|||||||
if use_cache and app_config.llm_cache_enabled:
|
if use_cache and app_config.llm_cache_enabled:
|
||||||
cached_response = llm_cache.get(cache_key)
|
cached_response = llm_cache.get(cache_key)
|
||||||
if cached_response:
|
if cached_response:
|
||||||
print("💾 使用LLM缓存响应")
|
print("[CACHE] 使用LLM缓存响应")
|
||||||
return cached_response
|
return cached_response
|
||||||
|
|
||||||
# 调用LLM
|
# 调用LLM
|
||||||
|
|||||||
215
utils/script_generator.py
Normal file
215
utils/script_generator.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
可复用脚本生成器
|
||||||
|
|
||||||
|
从分析会话的执行历史中提取成功执行的代码,
|
||||||
|
合并去重后生成可独立运行的 .py 脚本文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any, Set
|
||||||
|
|
||||||
|
|
||||||
|
def extract_imports(code: str) -> Set[str]:
|
||||||
|
"""从代码中提取所有 import 语句"""
|
||||||
|
imports = set()
|
||||||
|
lines = code.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('import ') or stripped.startswith('from '):
|
||||||
|
# 标准化 import 语句
|
||||||
|
imports.add(stripped)
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
def remove_imports(code: str) -> str:
|
||||||
|
"""从代码中移除所有 import 语句"""
|
||||||
|
lines = code.split('\n')
|
||||||
|
result_lines = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith('import ') and not stripped.startswith('from '):
|
||||||
|
result_lines.append(line)
|
||||||
|
return '\n'.join(result_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_code_block(code: str) -> str:
|
||||||
|
"""清理代码块,移除不必要的内容"""
|
||||||
|
# 移除可能的重复配置代码
|
||||||
|
patterns_to_skip = [
|
||||||
|
r"plt\.rcParams\['font\.sans-serif'\]", # 字体配置在模板中统一处理
|
||||||
|
r"plt\.rcParams\['axes\.unicode_minus'\]",
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = code.split('\n')
|
||||||
|
result_lines = []
|
||||||
|
skip_until_empty = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# 跳过空行连续的情况
|
||||||
|
if not stripped:
|
||||||
|
if skip_until_empty:
|
||||||
|
skip_until_empty = False
|
||||||
|
continue
|
||||||
|
result_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否需要跳过的模式
|
||||||
|
should_skip = False
|
||||||
|
for pattern in patterns_to_skip:
|
||||||
|
if re.search(pattern, stripped):
|
||||||
|
should_skip = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not should_skip:
|
||||||
|
result_lines.append(line)
|
||||||
|
|
||||||
|
return '\n'.join(result_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reusable_script(
|
||||||
|
analysis_results: List[Dict[str, Any]],
|
||||||
|
data_files: List[str],
|
||||||
|
session_output_dir: str,
|
||||||
|
user_requirement: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
从分析结果中生成可复用的 Python 脚本
|
||||||
|
|
||||||
|
Args:
|
||||||
|
analysis_results: 分析过程中记录的结果列表,每个元素包含 'code', 'result' 等
|
||||||
|
data_files: 原始数据文件路径列表
|
||||||
|
session_output_dir: 会话输出目录
|
||||||
|
user_requirement: 用户的原始需求描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
生成的脚本文件路径
|
||||||
|
"""
|
||||||
|
# 收集所有成功执行的代码
|
||||||
|
all_imports = set()
|
||||||
|
code_blocks = []
|
||||||
|
|
||||||
|
for result in analysis_results:
|
||||||
|
# 只处理 generate_code 类型的结果
|
||||||
|
if result.get("action") == "collect_figures":
|
||||||
|
continue
|
||||||
|
|
||||||
|
code = result.get("code", "")
|
||||||
|
exec_result = result.get("result", {})
|
||||||
|
|
||||||
|
# 只收集成功执行的代码
|
||||||
|
if code and exec_result.get("success", False):
|
||||||
|
# 提取 imports
|
||||||
|
imports = extract_imports(code)
|
||||||
|
all_imports.update(imports)
|
||||||
|
|
||||||
|
# 清理代码块
|
||||||
|
cleaned_code = remove_imports(code)
|
||||||
|
cleaned_code = clean_code_block(cleaned_code)
|
||||||
|
|
||||||
|
# 只添加非空的代码块
|
||||||
|
if cleaned_code.strip():
|
||||||
|
code_blocks.append({
|
||||||
|
"round": result.get("round", 0),
|
||||||
|
"code": cleaned_code.strip()
|
||||||
|
})
|
||||||
|
|
||||||
|
if not code_blocks:
|
||||||
|
print("[WARN] 没有成功执行的代码块,跳过脚本生成")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 生成脚本内容
|
||||||
|
now = datetime.now()
|
||||||
|
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 构建脚本头部
|
||||||
|
script_header = f'''#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据分析脚本 - 自动生成
|
||||||
|
=====================================
|
||||||
|
原始数据文件: {', '.join(data_files)}
|
||||||
|
生成时间: {now.strftime("%Y-%m-%d %H:%M:%S")}
|
||||||
|
原始需求: {user_requirement[:200] + '...' if len(user_requirement) > 200 else user_requirement}
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
1. 修改下方 DATA_FILES 列表中的文件路径
|
||||||
|
2. 修改 OUTPUT_DIR 指定输出目录
|
||||||
|
3. 运行: python {os.path.basename(session_output_dir)}_分析脚本.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
'''
|
||||||
|
|
||||||
|
# 添加标准 imports(去重后排序)
|
||||||
|
standard_imports = sorted([imp for imp in all_imports if imp.startswith('import ')])
|
||||||
|
from_imports = sorted([imp for imp in all_imports if imp.startswith('from ')])
|
||||||
|
|
||||||
|
imports_section = '\n'.join(standard_imports + from_imports)
|
||||||
|
|
||||||
|
# 配置区域
|
||||||
|
config_section = f'''
|
||||||
|
# ========== 配置区域 (可修改) ==========
|
||||||
|
|
||||||
|
# 数据文件路径 - 修改此处以分析不同的数据
|
||||||
|
DATA_FILES = {repr(data_files)}
|
||||||
|
|
||||||
|
# 输出目录 - 图片和报告将保存在此目录
|
||||||
|
OUTPUT_DIR = "./analysis_output"
|
||||||
|
|
||||||
|
# 创建输出目录
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ========== 字体配置 (中文显示) ==========
|
||||||
|
import platform
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
system_name = platform.system()
|
||||||
|
if system_name == 'Darwin':
|
||||||
|
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'PingFang SC', 'sans-serif']
|
||||||
|
elif system_name == 'Windows':
|
||||||
|
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'sans-serif']
|
||||||
|
else:
|
||||||
|
plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'sans-serif']
|
||||||
|
plt.rcParams['axes.unicode_minus'] = False
|
||||||
|
|
||||||
|
# 设置 session_output_dir 变量(兼容原始代码)
|
||||||
|
session_output_dir = OUTPUT_DIR
|
||||||
|
'''
|
||||||
|
|
||||||
|
# 合并代码块
|
||||||
|
code_section = "\n# ========== 分析代码 ==========\n\n"
|
||||||
|
|
||||||
|
for i, block in enumerate(code_blocks, 1):
|
||||||
|
code_section += f"# --- 第 {block['round']} 轮分析 ---\n"
|
||||||
|
code_section += block['code'] + "\n\n"
|
||||||
|
|
||||||
|
# 脚本尾部
|
||||||
|
script_footer = '''
|
||||||
|
# ========== 完成 ==========
|
||||||
|
print("\\n" + "=" * 50)
|
||||||
|
print("[OK] 分析完成!")
|
||||||
|
print(f"[OUTPUT] 输出目录: {os.path.abspath(OUTPUT_DIR)}")
|
||||||
|
print("=" * 50)
|
||||||
|
'''
|
||||||
|
|
||||||
|
# 组装完整脚本
|
||||||
|
full_script = script_header + imports_section + config_section + code_section + script_footer
|
||||||
|
|
||||||
|
# 保存脚本文件
|
||||||
|
script_filename = f"分析脚本_{timestamp}.py"
|
||||||
|
script_path = os.path.join(session_output_dir, script_filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(script_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_script)
|
||||||
|
print(f"[OK] 可复用脚本已生成: {script_path}")
|
||||||
|
return script_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存脚本失败: {e}")
|
||||||
|
return ""
|
||||||
218
web/main.py
218
web/main.py
@@ -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
535
web/static/clean_style.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,161 +26,139 @@
|
|||||||
<!-- 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>
|
||||||
|
|
||||||
|
<div class="content-area">
|
||||||
<!-- VIEW: ANALYSIS -->
|
<!-- VIEW: ANALYSIS -->
|
||||||
<div id="viewAnalysis" class="view-section active">
|
<div id="viewAnalysis" class="section active">
|
||||||
|
<div class="analysis-grid">
|
||||||
|
|
||||||
|
<!-- Configuration Panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<span>Configuration</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">1. Data Upload</label>
|
||||||
|
<div id="uploadZone" class="upload-area">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up upload-icon"></i>
|
||||||
|
<p>Click or Drag CSV/Excel Files</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>
|
||||||
|
|
||||||
|
<!-- 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="tabs">
|
||||||
<button class="tab-btn active" onclick="switchTab('logs')">
|
<div class="tab active" onclick="switchTab('logs')">Live Log</div>
|
||||||
<i class="fa-solid fa-terminal"></i> Live Logs
|
<div class="tab" onclick="switchTab('report')">Report</div>
|
||||||
</button>
|
<div class="tab" onclick="switchTab('gallery')">Gallery</div>
|
||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- Log View -->
|
<div class="output-container" id="outputContainer">
|
||||||
<div id="logsTab" class="tab-content active">
|
<!-- Logs Tab -->
|
||||||
<div class="terminal-window">
|
<div id="logsTab" class="tab-content active" style="height:100%; overflow-y:auto;">
|
||||||
<pre id="logOutput">Waiting to start...</pre>
|
<pre id="logOutput" class="log-content">Waiting to start...</pre>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Report View -->
|
<!-- Report Tab -->
|
||||||
<div id="reportTab" class="tab-content">
|
<div id="reportTab" class="tab-content hidden" style="height:100%; overflow-y:auto;">
|
||||||
<div id="reportContainer" class="markdown-body">
|
<div id="reportContainer" class="report-content markdown-body">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fa-solid fa-chart-simple"></i>
|
<p>Report will appear here after analysis.</p>
|
||||||
<p>No report generated yet.</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style="margin-top:1rem; text-align:right">
|
||||||
|
<button id="exportBtn" class="btn btn-secondary btn-sm"
|
||||||
<!-- Follow-up Chat Section -->
|
onclick="triggerExport()">
|
||||||
<div id="followUpSection" class="chat-input-container" style="display: none;">
|
<i class="fa-solid fa-download"></i> Export ZIP
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VIEW: TOOLS -->
|
<!-- Gallery Tab -->
|
||||||
<div id="viewTools" class="view-section" style="display: none;">
|
<div id="galleryTab" class="tab-content hidden"
|
||||||
<div class="tools-grid">
|
style="height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;">
|
||||||
<!-- Tool Card 1: Merge Excel -->
|
<div class="carousel-container">
|
||||||
<div class="tool-card">
|
<button class="carousel-btn prev" onclick="prevImage()"><i
|
||||||
<div class="tool-icon"><i class="fa-solid fa-file-csv"></i></div>
|
class="fa-solid fa-chevron-left"></i></button>
|
||||||
<h3>Excel to CSV Merger</h3>
|
<div class="carousel-slide" id="carouselSlide">
|
||||||
<p>Merge multiple .xlsx files from the uploads directory into a single CSV for analysis.</p>
|
<p class="placeholder-text" style="color:var(--text-secondary);">No images
|
||||||
<div class="tool-actions">
|
generated.</p>
|
||||||
<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>
|
||||||
<div id="mergeResult" class="tool-result"></div>
|
<button class="carousel-btn next" onclick="nextImage()"><i
|
||||||
|
class="fa-solid fa-chevron-right"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="image-info" id="imageInfo" style="margin-top:1rem; text-align:center;">
|
||||||
<!-- Tool Card 2: Sort CSV -->
|
<!-- Title/Desc -->
|
||||||
<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>
|
||||||
<div id="sortResult" class="tool-result"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +65,12 @@ async function handleFiles(files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start Analysis ---
|
// --- Analysis Logic ---
|
||||||
if (startBtn) {
|
if (startBtn) {
|
||||||
startBtn.addEventListener('click', async () => {
|
startBtn.addEventListener('click', startAnalysis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAnalysis() {
|
||||||
if (isRunning) return;
|
if (isRunning) return;
|
||||||
|
|
||||||
const requirement = requirementInput.value.trim();
|
const requirement = requirementInput.value.trim();
|
||||||
@@ -85,7 +90,7 @@ if (startBtn) {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
currentSessionId = data.session_id; // Store Session ID
|
currentSessionId = data.session_id;
|
||||||
console.log("Started Session:", currentSessionId);
|
console.log("Started Session:", currentSessionId);
|
||||||
|
|
||||||
startPolling();
|
startPolling();
|
||||||
@@ -100,26 +105,35 @@ if (startBtn) {
|
|||||||
alert('Error starting analysis');
|
alert('Error starting analysis');
|
||||||
setRunningState(false);
|
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 = '<div class="empty-state"><p>Analysis in progress or no report generated yet.</p></div>';
|
||||||
|
} else {
|
||||||
reportContainer.innerHTML = marked.parse(data.content);
|
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) {
|
} 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.innerHTML = originalContent;
|
||||||
btn.disabled = false;
|
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) {
|
||||||
|
if (isRunning) {
|
||||||
|
alert("Analysis in progress, please wait.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSessionId = sessionId;
|
||||||
|
|
||||||
|
// Update active class
|
||||||
|
document.querySelectorAll('.history-item').forEach(el => el.classList.remove('active'));
|
||||||
|
const activeItem = document.getElementById(`hist-${sessionId}`);
|
||||||
|
if (activeItem) activeItem.classList.add('active');
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
logOutput.innerText = "Loading session data...";
|
||||||
|
reportContainer.innerHTML = "";
|
||||||
|
if (downloadScriptBtn) downloadScriptBtn.classList.add('hidden');
|
||||||
|
|
||||||
|
// Fetch Status to get logs and check report
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/status?session_id=${sessionId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logOutput.innerText = "Error loading session.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
// No-op for switchView as sidebar is simplified
|
||||||
window.switchView = function (viewName) {
|
window.switchView = function (viewName) {
|
||||||
// 1. Update Sidebar Buttons
|
console.log("View switch requested:", viewName);
|
||||||
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
|
window.switchTab = function (tabName) {
|
||||||
const viewId = 'view' + viewName.charAt(0).toUpperCase() + viewName.slice(1);
|
// Buttons
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
// Content
|
||||||
|
['logs', 'report', 'gallery'].forEach(name => {
|
||||||
|
const content = document.getElementById(`${name}Tab`);
|
||||||
|
if (content) content.classList.add('hidden');
|
||||||
|
|
||||||
document.querySelectorAll('.view-section').forEach(v => {
|
// 找到对应的 Tab 按钮并激活
|
||||||
// Hide all
|
// 这里假设 Tab 按钮的 onclick 包含 tabName
|
||||||
v.style.display = 'none';
|
document.querySelectorAll('.tab').forEach(btn => {
|
||||||
v.classList.remove('active');
|
if (btn.getAttribute('onclick') && btn.getAttribute('onclick').includes(`'${tabName}'`)) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeView = document.getElementById(viewId);
|
// Valid tabs logic
|
||||||
if (activeView) {
|
if (tabName === 'logs') {
|
||||||
// Analysis view uses flex for layout (logs scrolling), others use block
|
document.getElementById('logsTab').classList.remove('hidden');
|
||||||
if (viewName === 'analysis') {
|
} else if (tabName === 'report') {
|
||||||
activeView.style.display = 'flex';
|
document.getElementById('reportTab').classList.remove('hidden');
|
||||||
activeView.style.flexDirection = 'column';
|
} else if (tabName === 'gallery') {
|
||||||
} else {
|
document.getElementById('galleryTab').classList.remove('hidden');
|
||||||
activeView.style.display = 'block';
|
if (currentSessionId) loadGallery();
|
||||||
// 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.';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user