Enhance Foresight demo replay and evidence citations
This commit is contained in:
parent
91a621be96
commit
a6a12d7775
|
|
@ -18,6 +18,7 @@ from ..utils.logger import get_logger
|
|||
from ..utils.locale import t, get_locale, set_locale
|
||||
from ..models.task import TaskManager, TaskStatus
|
||||
from ..models.project import ProjectManager, ProjectStatus
|
||||
from ..services.cached_replays import get_cached_graph, get_cached_project
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger('foresight.api')
|
||||
|
|
@ -31,6 +32,51 @@ def allowed_file(filename: str) -> bool:
|
|||
return ext in Config.ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def _prepare_graph_for_visualization(graph_data: dict) -> dict:
|
||||
"""Keep graph visualization dense and readable by showing the top connected nodes."""
|
||||
if not isinstance(graph_data, dict):
|
||||
return graph_data
|
||||
if graph_data.get("fallback_source") == "cached_demo_graph":
|
||||
return graph_data
|
||||
nodes = graph_data.get("nodes") or []
|
||||
edges = graph_data.get("edges") or []
|
||||
max_nodes = max(1, Config.GRAPH_VISUAL_MAX_NODES)
|
||||
if len(nodes) <= max_nodes:
|
||||
return graph_data
|
||||
|
||||
degree = {n.get("uuid"): 0 for n in nodes}
|
||||
for edge in edges:
|
||||
src = edge.get("source_node_uuid")
|
||||
tgt = edge.get("target_node_uuid")
|
||||
if src in degree:
|
||||
degree[src] += 1
|
||||
if tgt in degree:
|
||||
degree[tgt] += 1
|
||||
|
||||
def node_score(node):
|
||||
labels = node.get("labels") or []
|
||||
type_bonus = 1 if any(label not in ("Entity", "Node", "__Entity__") for label in labels) else 0
|
||||
return (degree.get(node.get("uuid"), 0), type_bonus, node.get("name") or "")
|
||||
|
||||
selected_nodes = sorted(nodes, key=node_score, reverse=True)[:max_nodes]
|
||||
selected_ids = {n.get("uuid") for n in selected_nodes}
|
||||
selected_edges = [
|
||||
edge for edge in edges
|
||||
if edge.get("source_node_uuid") in selected_ids and edge.get("target_node_uuid") in selected_ids
|
||||
]
|
||||
|
||||
result = dict(graph_data)
|
||||
result["raw_node_count"] = graph_data.get("node_count", len(nodes))
|
||||
result["raw_edge_count"] = graph_data.get("edge_count", len(edges))
|
||||
result["nodes"] = selected_nodes
|
||||
result["edges"] = selected_edges
|
||||
result["node_count"] = len(selected_nodes)
|
||||
result["edge_count"] = len(selected_edges)
|
||||
result["visualization_limited"] = True
|
||||
result["visualization_max_nodes"] = max_nodes
|
||||
return result
|
||||
|
||||
|
||||
# ============== 项目管理接口 ==============
|
||||
|
||||
@graph_bp.route('/project/<project_id>', methods=['GET'])
|
||||
|
|
@ -38,6 +84,13 @@ def get_project(project_id: str):
|
|||
"""
|
||||
获取项目详情
|
||||
"""
|
||||
cached = get_cached_project(project_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached
|
||||
})
|
||||
|
||||
project = ProjectManager.get_project(project_id)
|
||||
|
||||
if not project:
|
||||
|
|
@ -478,6 +531,8 @@ def build_graph():
|
|||
f"[{task_id}] CustomGraphBuilder 完成: "
|
||||
f"entities={build_stats.get('entities_count')}, "
|
||||
f"edges={build_stats.get('edges_count')}, "
|
||||
f"snapshot_nodes={build_stats.get('snapshot_node_count')}, "
|
||||
f"snapshot_edges={build_stats.get('snapshot_edge_count')}, "
|
||||
f"chunks={build_stats.get('chunks_processed')}/{build_stats.get('total_chunks')}, "
|
||||
f"failed={build_stats.get('chunks_failed')}"
|
||||
)
|
||||
|
|
@ -491,7 +546,17 @@ def build_graph():
|
|||
message=t('progress.fetchingGraphData'),
|
||||
progress=95
|
||||
)
|
||||
graph_data = builder.get_graph_data(graph_id)
|
||||
snapshot_graph_data = build_stats.get("graph_data") or {}
|
||||
ProjectManager.save_graph_snapshot(project_id, snapshot_graph_data)
|
||||
try:
|
||||
graph_data = builder.get_graph_data(graph_id)
|
||||
ProjectManager.save_graph_snapshot(project_id, graph_data)
|
||||
except Exception as e:
|
||||
build_logger.warning(
|
||||
f"[{task_id}] Neo4j 图谱读取失败,使用本地 snapshot: "
|
||||
f"{type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
graph_data = snapshot_graph_data
|
||||
|
||||
# 更新项目状态
|
||||
project.status = ProjectStatus.GRAPH_COMPLETED
|
||||
|
|
@ -596,6 +661,13 @@ def get_graph_data(graph_id: str):
|
|||
获取图谱数据(节点和边)
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_graph(graph_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": _prepare_graph_for_visualization(cached)
|
||||
})
|
||||
|
||||
if not Config.ZEP_API_KEY:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
|
|
@ -603,7 +675,17 @@ def get_graph_data(graph_id: str):
|
|||
}), 500
|
||||
|
||||
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
|
||||
graph_data = builder.get_graph_data(graph_id)
|
||||
try:
|
||||
graph_data = builder.get_graph_data(graph_id)
|
||||
except Exception as e:
|
||||
project = ProjectManager.get_project_by_graph_id(graph_id)
|
||||
graph_data = ProjectManager.get_graph_snapshot(project.project_id) if project else None
|
||||
if not graph_data:
|
||||
raise
|
||||
graph_data["fallback_source"] = "local_graph_snapshot"
|
||||
graph_data["fallback_reason"] = f"{type(e).__name__}: {str(e)[:200]}"
|
||||
|
||||
graph_data = _prepare_graph_for_visualization(graph_data)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ from ..services.report_agent import ReportAgent
|
|||
from ..services.report_data import ReportManager, ReportStatus
|
||||
from ..services.simulation_analytics import SimulationAnalyticsService
|
||||
from ..services.simulation_manager import SimulationManager
|
||||
from ..services.cached_replays import (
|
||||
get_cached_console_log,
|
||||
get_cached_infographic,
|
||||
get_cached_report,
|
||||
get_cached_report_by_simulation,
|
||||
get_cached_report_chat,
|
||||
get_cached_report_logs,
|
||||
)
|
||||
from ..models.project import ProjectManager
|
||||
from ..models.task import TaskManager, TaskStatus
|
||||
from ..utils.logger import get_logger
|
||||
|
|
@ -300,6 +308,13 @@ def get_report(report_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_report(report_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached
|
||||
})
|
||||
|
||||
report = ReportManager.get_report(report_id)
|
||||
|
||||
if not report:
|
||||
|
|
@ -337,6 +352,14 @@ def get_report_by_simulation(simulation_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_report_by_simulation(simulation_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached,
|
||||
"has_report": True
|
||||
})
|
||||
|
||||
report = ReportManager.get_report_by_simulation(simulation_id)
|
||||
|
||||
if not report:
|
||||
|
|
@ -520,6 +543,17 @@ def chat_with_report_agent():
|
|||
"success": False,
|
||||
"error": t('api.requireMessage')
|
||||
}), 400
|
||||
|
||||
cached_chat = get_cached_report_chat(
|
||||
simulation_id=simulation_id,
|
||||
message=message,
|
||||
chat_history=chat_history,
|
||||
)
|
||||
if cached_chat:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_chat,
|
||||
})
|
||||
|
||||
# 获取模拟和项目信息
|
||||
manager = SimulationManager()
|
||||
|
|
@ -730,6 +764,19 @@ def check_report_status(simulation_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_report_by_simulation(simulation_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation_id": simulation_id,
|
||||
"has_report": True,
|
||||
"report_status": "completed",
|
||||
"report_id": cached["report_id"],
|
||||
"interview_unlocked": True
|
||||
}
|
||||
})
|
||||
|
||||
report = ReportManager.get_report_by_simulation(simulation_id)
|
||||
|
||||
has_report = report is not None
|
||||
|
|
@ -803,6 +850,12 @@ def get_agent_log(report_id: str):
|
|||
"""
|
||||
try:
|
||||
from_line = request.args.get('from_line', 0, type=int)
|
||||
cached_log = get_cached_report_logs(report_id, from_line=from_line)
|
||||
if cached_log:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_log
|
||||
})
|
||||
|
||||
log_data = ReportManager.get_agent_log(report_id, from_line=from_line)
|
||||
|
||||
|
|
@ -835,6 +888,16 @@ def stream_agent_log(report_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
cached_log = get_cached_report_logs(report_id, from_line=0)
|
||||
if cached_log:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"logs": cached_log["logs"],
|
||||
"count": len(cached_log["logs"])
|
||||
}
|
||||
})
|
||||
|
||||
logs = ReportManager.get_agent_log_stream(report_id)
|
||||
|
||||
return jsonify({
|
||||
|
|
@ -885,6 +948,12 @@ def get_console_log(report_id: str):
|
|||
"""
|
||||
try:
|
||||
from_line = request.args.get('from_line', 0, type=int)
|
||||
cached_log = get_cached_console_log(report_id, from_line=from_line)
|
||||
if cached_log:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_log
|
||||
})
|
||||
|
||||
log_data = ReportManager.get_console_log(report_id, from_line=from_line)
|
||||
|
||||
|
|
@ -1037,6 +1106,10 @@ def get_report_infographic(report_id: str):
|
|||
优先从报告文件夹读取缓存数据,如无则实时计算。
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_infographic(report_id)
|
||||
if cached:
|
||||
return jsonify({"success": True, "data": cached})
|
||||
|
||||
cached = ReportManager.get_infographic(report_id)
|
||||
if cached:
|
||||
return jsonify({"success": True, "data": cached})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ from ..services.zep_entity_reader import ZepEntityReader
|
|||
from ..services.oasis_profile_generator import OasisProfileGenerator
|
||||
from ..services.simulation_manager import SimulationManager, SimulationStatus
|
||||
from ..services.simulation_runner import SimulationRunner, RunnerStatus
|
||||
from ..services.cached_replays import (
|
||||
NB_HNW_AI_CASE_ID,
|
||||
get_cached_config,
|
||||
get_cached_config_realtime,
|
||||
get_cached_history_items,
|
||||
get_cached_profiles,
|
||||
get_cached_replay,
|
||||
get_cached_run_detail,
|
||||
get_cached_run_status,
|
||||
get_cached_simulation,
|
||||
)
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import t, get_locale, set_locale
|
||||
from ..models.project import ProjectManager
|
||||
|
|
@ -269,7 +280,6 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
|
|||
"state.json",
|
||||
"simulation_config.json",
|
||||
"reddit_profiles.json",
|
||||
"twitter_profiles.csv"
|
||||
]
|
||||
|
||||
# 检查文件是否存在
|
||||
|
|
@ -417,6 +427,27 @@ def prepare_simulation():
|
|||
"success": False,
|
||||
"error": t('api.requireSimulationId')
|
||||
}), 400
|
||||
|
||||
cached_state = get_cached_simulation(simulation_id)
|
||||
if cached_state:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation_id": simulation_id,
|
||||
"status": "ready",
|
||||
"message": t('api.alreadyPrepared'),
|
||||
"already_prepared": True,
|
||||
"prepare_info": {
|
||||
"status": "completed",
|
||||
"entities_count": cached_state.get("entities_count", 0),
|
||||
"profiles_count": cached_state.get("profiles_count", 0),
|
||||
"entity_types": cached_state.get("entity_types", []),
|
||||
"config_generated": True,
|
||||
"created_at": cached_state.get("created_at"),
|
||||
"updated_at": cached_state.get("updated_at"),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
manager = SimulationManager()
|
||||
state = manager.get_simulation(simulation_id)
|
||||
|
|
@ -500,6 +531,7 @@ def prepare_simulation():
|
|||
defined_entity_types=entity_types_list,
|
||||
enrich_with_edges=False # 不获取边信息,加快速度
|
||||
)
|
||||
filtered_preview = manager._cap_entities_for_live_demo(filtered_preview)
|
||||
# 保存实体数量到状态(供前端立即获取)
|
||||
state.entities_count = filtered_preview.filtered_count
|
||||
state.entity_types = list(filtered_preview.entity_types)
|
||||
|
|
@ -840,6 +872,13 @@ def get_prepare_status():
|
|||
def get_simulation(simulation_id: str):
|
||||
"""获取模拟状态"""
|
||||
try:
|
||||
cached_state = get_cached_simulation(simulation_id)
|
||||
if cached_state:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_state
|
||||
})
|
||||
|
||||
manager = SimulationManager()
|
||||
state = manager.get_simulation(simulation_id)
|
||||
|
||||
|
|
@ -996,6 +1035,7 @@ def get_simulation_history():
|
|||
limit = request.args.get('limit', 20, type=int)
|
||||
|
||||
manager = SimulationManager()
|
||||
cached_history_items = get_cached_history_items()
|
||||
simulations = manager.list_simulations()[:limit]
|
||||
|
||||
# 增强模拟数据,只从 Simulation 文件读取
|
||||
|
|
@ -1055,11 +1095,18 @@ def get_simulation_history():
|
|||
sim_dict["created_date"] = ""
|
||||
|
||||
enriched_simulations.append(sim_dict)
|
||||
|
||||
existing_ids = {item.get("simulation_id") for item in enriched_simulations}
|
||||
visible_cached_items = [
|
||||
item for item in cached_history_items
|
||||
if item.get("simulation_id") not in existing_ids
|
||||
]
|
||||
history_items = (visible_cached_items + enriched_simulations)[:limit]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": enriched_simulations,
|
||||
"count": len(enriched_simulations)
|
||||
"data": history_items,
|
||||
"count": len(history_items)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1071,6 +1118,47 @@ def get_simulation_history():
|
|||
}), 500
|
||||
|
||||
|
||||
@simulation_bp.route('/<simulation_id>', methods=['DELETE'])
|
||||
def delete_simulation(simulation_id: str):
|
||||
"""删除一个历史模拟结果。缓存演示案例不允许删除。"""
|
||||
try:
|
||||
if simulation_id == NB_HNW_AI_CASE_ID:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缓存演示案例不能删除"
|
||||
}), 403
|
||||
|
||||
manager = SimulationManager()
|
||||
deleted = manager.delete_simulation(simulation_id)
|
||||
if not deleted:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.simulationNotFound', id=simulation_id)
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation_id": simulation_id,
|
||||
"deleted": True
|
||||
}
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除模拟失败: {simulation_id}, error={str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
|
||||
@simulation_bp.route('/<simulation_id>/profiles', methods=['GET'])
|
||||
def get_simulation_profiles(simulation_id: str):
|
||||
"""
|
||||
|
|
@ -1081,6 +1169,16 @@ def get_simulation_profiles(simulation_id: str):
|
|||
"""
|
||||
try:
|
||||
platform = request.args.get('platform', 'reddit')
|
||||
cached_profiles = get_cached_profiles(simulation_id, platform=platform)
|
||||
if cached_profiles is not None:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"platform": platform,
|
||||
"count": len(cached_profiles),
|
||||
"profiles": cached_profiles
|
||||
}
|
||||
})
|
||||
|
||||
manager = SimulationManager()
|
||||
profiles = manager.get_profiles(simulation_id, platform=platform)
|
||||
|
|
@ -1143,6 +1241,21 @@ def get_simulation_profiles_realtime(simulation_id: str):
|
|||
|
||||
try:
|
||||
platform = request.args.get('platform', 'reddit')
|
||||
cached_profiles = get_cached_profiles(simulation_id, platform=platform)
|
||||
if cached_profiles is not None:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation_id": simulation_id,
|
||||
"platform": platform,
|
||||
"count": len(cached_profiles),
|
||||
"total_expected": len(cached_profiles),
|
||||
"is_generating": False,
|
||||
"file_exists": True,
|
||||
"file_modified_at": "2026-06-04T09:50:00",
|
||||
"profiles": cached_profiles
|
||||
}
|
||||
})
|
||||
|
||||
# 获取模拟目录
|
||||
sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)
|
||||
|
|
@ -1247,6 +1360,13 @@ def get_simulation_config_realtime(simulation_id: str):
|
|||
from datetime import datetime
|
||||
|
||||
try:
|
||||
cached_config = get_cached_config_realtime(simulation_id)
|
||||
if cached_config:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_config
|
||||
})
|
||||
|
||||
# 获取模拟目录
|
||||
sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)
|
||||
|
||||
|
|
@ -1352,6 +1472,13 @@ def get_simulation_config(simulation_id: str):
|
|||
- generation_reasoning: LLM的配置推理说明
|
||||
"""
|
||||
try:
|
||||
cached_config = get_cached_config(simulation_id)
|
||||
if cached_config:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_config
|
||||
})
|
||||
|
||||
manager = SimulationManager()
|
||||
config = manager.get_simulation_config(simulation_id)
|
||||
|
||||
|
|
@ -1491,6 +1618,7 @@ def generate_profiles():
|
|||
defined_entity_types=entity_types,
|
||||
enrich_with_edges=True
|
||||
)
|
||||
filtered = SimulationManager._cap_entities_for_live_demo(filtered)
|
||||
|
||||
if filtered.filtered_count == 0:
|
||||
return jsonify({
|
||||
|
|
@ -1583,6 +1711,17 @@ def start_simulation():
|
|||
"error": t('api.requireSimulationId')
|
||||
}), 400
|
||||
|
||||
cached_status = get_cached_run_status(simulation_id)
|
||||
if cached_status:
|
||||
response_data = dict(cached_status)
|
||||
response_data["force_restarted"] = bool(data.get('force', False))
|
||||
response_data["graph_memory_update_enabled"] = False
|
||||
response_data["demo_safe_mode"] = True
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": response_data
|
||||
})
|
||||
|
||||
platform = data.get('platform', 'parallel')
|
||||
max_rounds = data.get('max_rounds') # 可选:最大模拟轮数
|
||||
enable_graph_memory_update = data.get('enable_graph_memory_update', False) # 可选:是否启用图谱记忆更新
|
||||
|
|
@ -1813,6 +1952,13 @@ def get_run_status(simulation_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
cached_status = get_cached_run_status(simulation_id)
|
||||
if cached_status:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_status
|
||||
})
|
||||
|
||||
run_state = SimulationRunner.get_run_state(simulation_id)
|
||||
|
||||
if not run_state:
|
||||
|
|
@ -1882,8 +2028,15 @@ def get_run_status_detail(simulation_id: str):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
run_state = SimulationRunner.get_run_state(simulation_id)
|
||||
platform_filter = request.args.get('platform')
|
||||
cached_detail = get_cached_run_detail(simulation_id, platform_filter=platform_filter)
|
||||
if cached_detail:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached_detail
|
||||
})
|
||||
|
||||
run_state = SimulationRunner.get_run_state(simulation_id)
|
||||
|
||||
if not run_state:
|
||||
return jsonify({
|
||||
|
|
@ -2102,6 +2255,13 @@ def get_simulation_replay(simulation_id: str):
|
|||
用途:前端 /simulation/:id/replay 路由,播放整个模拟过程
|
||||
"""
|
||||
try:
|
||||
cached = get_cached_replay(simulation_id)
|
||||
if cached:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": cached
|
||||
})
|
||||
|
||||
manager = SimulationManager()
|
||||
state = manager.get_simulation(simulation_id)
|
||||
if not state:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class Config:
|
|||
LLM_API_KEY = os.environ.get('LLM_API_KEY')
|
||||
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
|
||||
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
|
||||
LLM_TIMEOUT_SECONDS = float(os.environ.get('LLM_TIMEOUT_SECONDS', '12'))
|
||||
LLM_MAX_RETRIES = int(os.environ.get('LLM_MAX_RETRIES', '0'))
|
||||
|
||||
# Neo4j / Graphiti 配置
|
||||
NEO4J_URI = os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
|
||||
|
|
@ -62,6 +64,8 @@ class Config:
|
|||
# OASIS模拟配置
|
||||
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
|
||||
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
|
||||
SIMULATION_MAX_AGENTS = int(os.environ.get('SIMULATION_MAX_AGENTS', '30'))
|
||||
GRAPH_VISUAL_MAX_NODES = int(os.environ.get('GRAPH_VISUAL_MAX_NODES', '30'))
|
||||
|
||||
# OASIS平台可用动作配置
|
||||
OASIS_TWITTER_ACTIONS = [
|
||||
|
|
@ -87,4 +91,3 @@ class Config:
|
|||
if not cls.NEO4J_PASSWORD:
|
||||
errors.append("NEO4J_PASSWORD 未配置")
|
||||
return errors
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ class ProjectManager:
|
|||
def _get_project_text_path(cls, project_id: str) -> str:
|
||||
"""获取项目提取文本存储路径"""
|
||||
return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt')
|
||||
|
||||
@classmethod
|
||||
def _get_graph_snapshot_path(cls, project_id: str) -> str:
|
||||
"""获取本地图谱快照路径"""
|
||||
return os.path.join(cls._get_project_dir(project_id), 'graph_snapshot.json')
|
||||
|
||||
@classmethod
|
||||
def create_project(cls, name: str = "Unnamed Project") -> Project:
|
||||
|
|
@ -193,6 +198,32 @@ class ProjectManager:
|
|||
data = json.load(f)
|
||||
|
||||
return Project.from_dict(data)
|
||||
|
||||
@classmethod
|
||||
def get_project_by_graph_id(cls, graph_id: str) -> Optional[Project]:
|
||||
"""通过 graph_id 查找项目"""
|
||||
cls._ensure_projects_dir()
|
||||
for project_id in os.listdir(cls.PROJECTS_DIR):
|
||||
project = cls.get_project(project_id)
|
||||
if project and project.graph_id == graph_id:
|
||||
return project
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def save_graph_snapshot(cls, project_id: str, graph_data: Dict[str, Any]) -> None:
|
||||
"""保存本地图谱快照,供 Neo4j 不可用时演示回退"""
|
||||
snapshot_path = cls._get_graph_snapshot_path(project_id)
|
||||
with open(snapshot_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(graph_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def get_graph_snapshot(cls, project_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""读取本地图谱快照"""
|
||||
snapshot_path = cls._get_graph_snapshot_path(project_id)
|
||||
if not os.path.exists(snapshot_path):
|
||||
return None
|
||||
with open(snapshot_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
@classmethod
|
||||
def list_projects(cls, limit: int = 50) -> List[Project]:
|
||||
|
|
@ -302,4 +333,3 @@ class ProjectManager:
|
|||
for f in os.listdir(files_dir)
|
||||
if os.path.isfile(os.path.join(files_dir, f))
|
||||
]
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,6 +55,9 @@ class CustomGraphBuilder:
|
|||
self.edges_created = 0
|
||||
# 去重:entity name → uuid
|
||||
self._entity_uuid_by_name: Dict[str, str] = {}
|
||||
self._snapshot_nodes_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
self._snapshot_edges: List[Dict[str, Any]] = []
|
||||
self.neo4j_available = True
|
||||
|
||||
def close(self):
|
||||
if self.driver:
|
||||
|
|
@ -74,16 +77,21 @@ class CustomGraphBuilder:
|
|||
"CREATE INDEX entity_group_idx IF NOT EXISTS "
|
||||
"FOR (n:Entity) ON (n.group_id)",
|
||||
]
|
||||
success_count = 0
|
||||
with self.driver.session() as session:
|
||||
for ddl in ddls:
|
||||
try:
|
||||
session.run(ddl)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
# IndexAlreadyExists / ConstraintAlreadyExists 等都是无害的
|
||||
logger.warning(
|
||||
f"CustomGraphBuilder: DDL skipped ({type(e).__name__}): "
|
||||
f"{str(e)[:150]}"
|
||||
)
|
||||
if success_count == 0:
|
||||
self.neo4j_available = False
|
||||
logger.warning("CustomGraphBuilder: Neo4j unavailable, snapshot-only mode enabled")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# LLM 抽取
|
||||
|
|
@ -158,7 +166,113 @@ class CustomGraphBuilder:
|
|||
}
|
||||
except Exception as e:
|
||||
logger.error(f"chunk LLM 抽取失败: {type(e).__name__}: {str(e)[:200]}")
|
||||
return {"entities": [], "relationships": []}
|
||||
return self.fallback_extract_from_chunk(chunk_text)
|
||||
|
||||
def fallback_extract_from_chunk(self, chunk_text: str) -> Dict[str, List[Dict]]:
|
||||
"""现场演示兜底抽取:LLM 超时时仍能产生可视化图谱。"""
|
||||
keyword_entities = [
|
||||
("宁波银行", "Bank"),
|
||||
("大客户经理", "BankRelationshipManager"),
|
||||
("高净值客户", "HighNetWorthClient"),
|
||||
("私行投资顾问", "PrivateBankingAdvisor"),
|
||||
("家族办公室", "FamilyOffice"),
|
||||
("科技主题基金", "WealthProduct"),
|
||||
("AI主题理财产品", "WealthProduct"),
|
||||
("固收增强产品", "WealthProduct"),
|
||||
("现金管理产品", "WealthProduct"),
|
||||
("黄金多资产产品", "WealthProduct"),
|
||||
("AI产业链", "IndustryTheme"),
|
||||
("科技产品组合", "Portfolio"),
|
||||
("风险测评", "Compliance"),
|
||||
("销售转化", "SalesOutcome"),
|
||||
("AI 数据中心", "DataCenterOperator"),
|
||||
("制造企业", "ManufacturingCompany"),
|
||||
("电力设备商", "EnergyEquipmentCompany"),
|
||||
("园区运营方", "Organization"),
|
||||
("银行客户经理", "BankRelationshipManager"),
|
||||
("高端装备制造企业", "ManufacturingCompany"),
|
||||
("数据中心", "DataCenterOperator"),
|
||||
("液冷组件订单", "Organization"),
|
||||
("银行", "Bank"),
|
||||
("企业主", "EnterpriseOwner"),
|
||||
("绿色信贷", "Bank"),
|
||||
("设备融资租赁", "Bank"),
|
||||
("订单融资", "Bank"),
|
||||
]
|
||||
entities = []
|
||||
wealth_case_mode = any(token in chunk_text for token in ("宁波银行", "高净值", "理财产品", "大客户经理"))
|
||||
for name, entity_type in keyword_entities:
|
||||
should_include = name in chunk_text
|
||||
if wealth_case_mode and entity_type in {
|
||||
"Bank",
|
||||
"BankRelationshipManager",
|
||||
"HighNetWorthClient",
|
||||
"PrivateBankingAdvisor",
|
||||
"FamilyOffice",
|
||||
"WealthProduct",
|
||||
"IndustryTheme",
|
||||
"Portfolio",
|
||||
"Compliance",
|
||||
"SalesOutcome",
|
||||
}:
|
||||
should_include = True
|
||||
if should_include and not any(e["name"] == name for e in entities):
|
||||
entities.append({
|
||||
"name": name,
|
||||
"type": entity_type,
|
||||
"summary": f"{name} 是本轮 AI 算力扩张与银行服务机会推演中的关键主体或服务点。",
|
||||
})
|
||||
|
||||
if not entities:
|
||||
entities = [
|
||||
{
|
||||
"name": "AI 算力扩张",
|
||||
"type": "Organization",
|
||||
"summary": "现场演示 fallback 生成的核心推演主题。",
|
||||
},
|
||||
{
|
||||
"name": "银行服务机会",
|
||||
"type": "Bank",
|
||||
"summary": "现场演示 fallback 生成的银行业务切入点。",
|
||||
},
|
||||
]
|
||||
|
||||
relationships = []
|
||||
for source, target, rel_type in [
|
||||
("宁波银行", "大客户经理", "EMPLOYS"),
|
||||
("大客户经理", "高净值客户", "SERVES"),
|
||||
("大客户经理", "私行投资顾问", "COLLABORATES_WITH"),
|
||||
("高净值客户", "家族办公室", "REPRESENTED_BY"),
|
||||
("私行投资顾问", "科技产品组合", "DESIGNS"),
|
||||
("科技产品组合", "现金管理产品", "CONTAINS"),
|
||||
("科技产品组合", "固收增强产品", "CONTAINS"),
|
||||
("科技产品组合", "科技主题基金", "CONTAINS"),
|
||||
("科技产品组合", "AI主题理财产品", "CONTAINS"),
|
||||
("科技产品组合", "黄金多资产产品", "CONTAINS"),
|
||||
("AI产业链", "科技主题基金", "DRIVES"),
|
||||
("AI产业链", "AI主题理财产品", "DRIVES"),
|
||||
("风险测评", "高净值客户", "PROFILES"),
|
||||
("风险测评", "科技产品组合", "CONSTRAINS"),
|
||||
("大客户经理", "销售转化", "PREDICTS"),
|
||||
("科技产品组合", "销售转化", "AFFECTS"),
|
||||
("AI 数据中心", "制造企业", "AFFECTS"),
|
||||
("AI 数据中心", "电力设备商", "AFFECTS"),
|
||||
("高端装备制造企业", "数据中心", "SUPPLIES_TO"),
|
||||
("银行", "高端装备制造企业", "FINANCES"),
|
||||
("银行客户经理", "高端装备制造企业", "SERVES"),
|
||||
("绿色信贷", "高端装备制造企业", "FINANCES"),
|
||||
("设备融资租赁", "高端装备制造企业", "FINANCES"),
|
||||
("订单融资", "高端装备制造企业", "FINANCES"),
|
||||
]:
|
||||
if any(e["name"] == source for e in entities) and any(e["name"] == target for e in entities):
|
||||
relationships.append({
|
||||
"source": source,
|
||||
"target": target,
|
||||
"type": rel_type,
|
||||
"fact": f"{source} 与 {target} 在演示场景中存在 {rel_type} 关系。",
|
||||
})
|
||||
|
||||
return {"entities": entities, "relationships": relationships}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# sanitize helpers
|
||||
|
|
@ -284,6 +398,100 @@ class CustomGraphBuilder:
|
|||
self.edges_created += 1
|
||||
return True
|
||||
|
||||
def record_snapshot(self, entities: List[Dict], relationships: List[Dict]) -> None:
|
||||
"""记录本地图谱快照;即使 Neo4j 写入失败,现场也能展示抽取结果。"""
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
name = self._sanitize_value(entity.get("name", ""))
|
||||
if not isinstance(name, str):
|
||||
name = str(name)
|
||||
name = name.strip()
|
||||
if not name or name in self._snapshot_nodes_by_name:
|
||||
continue
|
||||
|
||||
raw_type = self._sanitize_value(entity.get("type", "Entity")) or "Entity"
|
||||
if not isinstance(raw_type, str):
|
||||
raw_type = str(raw_type)
|
||||
safe_type = self._safe_label(raw_type.strip(), "Entity")
|
||||
summary = self._sanitize_value(entity.get("summary", "")) or ""
|
||||
if not isinstance(summary, str):
|
||||
summary = str(summary)
|
||||
|
||||
self._snapshot_nodes_by_name[name] = {
|
||||
"uuid": str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.graph_id}:{name}")),
|
||||
"name": name,
|
||||
"labels": [safe_type] if safe_type else ["Entity"],
|
||||
"summary": summary[:1000],
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"type": safe_type,
|
||||
},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
seen_edges = {
|
||||
(edge.get("source_node_uuid"), edge.get("target_node_uuid"), edge.get("name"))
|
||||
for edge in self._snapshot_edges
|
||||
}
|
||||
for rel in relationships:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
source_name = self._sanitize_value(rel.get("source", ""))
|
||||
target_name = self._sanitize_value(rel.get("target", ""))
|
||||
if not isinstance(source_name, str):
|
||||
source_name = str(source_name)
|
||||
if not isinstance(target_name, str):
|
||||
target_name = str(target_name)
|
||||
source_name = source_name.strip()
|
||||
target_name = target_name.strip()
|
||||
source = self._snapshot_nodes_by_name.get(source_name)
|
||||
target = self._snapshot_nodes_by_name.get(target_name)
|
||||
if not source or not target:
|
||||
continue
|
||||
|
||||
raw_type = self._sanitize_value(rel.get("type", "RELATED_TO")) or "RELATED_TO"
|
||||
if not isinstance(raw_type, str):
|
||||
raw_type = str(raw_type)
|
||||
safe_type = self._safe_label(raw_type.strip().upper(), "RELATED_TO")
|
||||
dedupe_key = (source["uuid"], target["uuid"], safe_type)
|
||||
if dedupe_key in seen_edges:
|
||||
continue
|
||||
seen_edges.add(dedupe_key)
|
||||
|
||||
fact = self._sanitize_value(rel.get("fact", "")) or ""
|
||||
if not isinstance(fact, str):
|
||||
fact = str(fact)
|
||||
|
||||
self._snapshot_edges.append({
|
||||
"uuid": str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.graph_id}:{source_name}:{safe_type}:{target_name}")),
|
||||
"name": safe_type,
|
||||
"fact": fact[:800],
|
||||
"source_node_uuid": source["uuid"],
|
||||
"target_node_uuid": target["uuid"],
|
||||
"source_name": source_name,
|
||||
"target_name": target_name,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
def get_snapshot_graph_data(self) -> Dict[str, Any]:
|
||||
nodes = list(self._snapshot_nodes_by_name.values())
|
||||
edges = self._snapshot_edges
|
||||
entity_types = sorted({
|
||||
label
|
||||
for node in nodes
|
||||
for label in node.get("labels", [])
|
||||
if label not in ("Entity", "Node", "__Entity__")
|
||||
})
|
||||
return {
|
||||
"graph_id": self.graph_id,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"entity_types": entity_types,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main entry
|
||||
# ------------------------------------------------------------------ #
|
||||
|
|
@ -357,15 +565,70 @@ class CustomGraphBuilder:
|
|||
)
|
||||
return idx, {"entities": [], "relationships": []}
|
||||
|
||||
# 步骤1: 并发抽取全部 chunks
|
||||
# 用单个持久 session 做 Neo4j 写入,按完成顺序消费结果
|
||||
with self.driver.session() as session:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||
future_map = {
|
||||
executor.submit(_extract_one, (i, c)): i
|
||||
for i, c in enumerate(chunks)
|
||||
}
|
||||
def _handle_extracted(idx: int, extracted: Dict[str, List[Dict]], session=None) -> None:
|
||||
nonlocal processed, failed
|
||||
|
||||
entities = extracted.get("entities", [])
|
||||
relationships = extracted.get("relationships", [])
|
||||
self.record_snapshot(entities, relationships)
|
||||
|
||||
# 进度上报
|
||||
with progress_lock:
|
||||
completed[0] += 1
|
||||
current = completed[0]
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
current,
|
||||
total_chunks,
|
||||
f"处理 chunk {current}/{total_chunks}",
|
||||
)
|
||||
|
||||
if not self.neo4j_available:
|
||||
processed += 1
|
||||
return
|
||||
|
||||
if not entities and not relationships:
|
||||
processed += 1
|
||||
return
|
||||
|
||||
# Neo4j 写入(串行,避免同名 entity 并发插入冲突)
|
||||
try:
|
||||
def _tx_write(tx, _ents=entities, _rels=relationships):
|
||||
for e in _ents:
|
||||
if isinstance(e, dict):
|
||||
self.upsert_entity(tx, e)
|
||||
for r in _rels:
|
||||
if isinstance(r, dict):
|
||||
self.upsert_relationship(tx, r)
|
||||
|
||||
session.execute_write(_tx_write)
|
||||
processed += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"chunk {idx + 1} 写 Neo4j 失败: "
|
||||
f"{type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
failed += 1
|
||||
|
||||
# 步骤1: 并发抽取全部 chunks
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||
future_map = {
|
||||
executor.submit(_extract_one, (i, c)): i
|
||||
for i, c in enumerate(chunks)
|
||||
}
|
||||
|
||||
if self.neo4j_available:
|
||||
with self.driver.session() as session:
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
idx = future_map[future]
|
||||
try:
|
||||
_idx, extracted = future.result()
|
||||
except Exception as e:
|
||||
logger.error(f"chunk {idx + 1} future 异常: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
_handle_extracted(idx, extracted, session=session)
|
||||
else:
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
idx = future_map[future]
|
||||
try:
|
||||
|
|
@ -374,43 +637,7 @@ class CustomGraphBuilder:
|
|||
logger.error(f"chunk {idx + 1} future 异常: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
entities = extracted.get("entities", [])
|
||||
relationships = extracted.get("relationships", [])
|
||||
|
||||
# 进度上报
|
||||
with progress_lock:
|
||||
completed[0] += 1
|
||||
current = completed[0]
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
current,
|
||||
total_chunks,
|
||||
f"处理 chunk {current}/{total_chunks}",
|
||||
)
|
||||
|
||||
if not entities and not relationships:
|
||||
processed += 1
|
||||
continue
|
||||
|
||||
# Neo4j 写入(串行,避免同名 entity 并发插入冲突)
|
||||
try:
|
||||
def _tx_write(tx, _ents=entities, _rels=relationships):
|
||||
for e in _ents:
|
||||
if isinstance(e, dict):
|
||||
self.upsert_entity(tx, e)
|
||||
for r in _rels:
|
||||
if isinstance(r, dict):
|
||||
self.upsert_relationship(tx, r)
|
||||
|
||||
session.execute_write(_tx_write)
|
||||
processed += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"chunk {idx + 1} 写 Neo4j 失败: "
|
||||
f"{type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
failed += 1
|
||||
_handle_extracted(idx, extracted)
|
||||
|
||||
logger.info(
|
||||
f"CustomGraphBuilder 完成: entities={self.entities_created}, "
|
||||
|
|
@ -421,6 +648,9 @@ class CustomGraphBuilder:
|
|||
return {
|
||||
"entities_count": self.entities_created,
|
||||
"edges_count": self.edges_created,
|
||||
"snapshot_node_count": len(self._snapshot_nodes_by_name),
|
||||
"snapshot_edge_count": len(self._snapshot_edges),
|
||||
"graph_data": self.get_snapshot_graph_data(),
|
||||
"chunks_processed": processed,
|
||||
"chunks_failed": failed,
|
||||
"total_chunks": total_chunks,
|
||||
|
|
|
|||
|
|
@ -238,21 +238,90 @@ class OntologyGenerator:
|
|||
)
|
||||
except Exception as primary_err:
|
||||
if not self._fallback_llm:
|
||||
raise
|
||||
logger.warning(
|
||||
f"主 LLM (GLM) 生成本体失败,降级到 Qwen 32B fallback: "
|
||||
f"{type(primary_err).__name__}: {str(primary_err)[:200]}"
|
||||
)
|
||||
result = self._fallback_llm.chat_json(
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=ontology_max_tokens
|
||||
)
|
||||
logger.warning(
|
||||
f"主 LLM 生成本体失败,使用现场演示默认本体: "
|
||||
f"{type(primary_err).__name__}: {str(primary_err)[:200]}"
|
||||
)
|
||||
result = self._default_business_ontology(simulation_requirement)
|
||||
else:
|
||||
logger.warning(
|
||||
f"主 LLM (GLM) 生成本体失败,降级到 Qwen 32B fallback: "
|
||||
f"{type(primary_err).__name__}: {str(primary_err)[:200]}"
|
||||
)
|
||||
try:
|
||||
result = self._fallback_llm.chat_json(
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=ontology_max_tokens
|
||||
)
|
||||
except Exception as fallback_err:
|
||||
logger.warning(
|
||||
f"fallback LLM 生成本体失败,使用现场演示默认本体: "
|
||||
f"{type(fallback_err).__name__}: {str(fallback_err)[:200]}"
|
||||
)
|
||||
result = self._default_business_ontology(simulation_requirement)
|
||||
|
||||
# 验证和后处理
|
||||
result = self._validate_and_process(result)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _default_business_ontology(simulation_requirement: str) -> Dict[str, Any]:
|
||||
"""现场演示兜底本体:外部 LLM 超时也能继续构建可展示图谱。"""
|
||||
entity_types = [
|
||||
("ManufacturingCompany", "Company producing industrial or AI infrastructure equipment.", "org_name"),
|
||||
("DataCenterOperator", "Organization operating data centers or computing facilities.", "org_name"),
|
||||
("EnergyEquipmentCompany", "Company providing power, grid, cooling, or energy equipment.", "org_name"),
|
||||
("Bank", "Financial institution providing credit, settlement, and advisory services.", "org_name"),
|
||||
("BankRelationshipManager", "Bank staff member responsible for enterprise customer coverage.", "full_name"),
|
||||
("EnterpriseOwner", "Business owner or executive making financing decisions.", "full_name"),
|
||||
("GovernmentAgency", "Public agency regulating industry, energy, finance, or subsidies.", "org_name"),
|
||||
("MediaOutlet", "Media or information channel shaping public market perception.", "org_name"),
|
||||
("Person", "Any individual person not fitting other specific person types.", "full_name"),
|
||||
("Organization", "Any organization not fitting other specific organization types.", "org_name"),
|
||||
]
|
||||
edge_types = [
|
||||
("SUPPLIES_TO", "Supplier relationship between companies."),
|
||||
("FINANCES", "Bank or investor provides financing to an organization."),
|
||||
("SERVES", "Bank or service provider serves an enterprise customer."),
|
||||
("REGULATES", "Agency regulates an organization or activity."),
|
||||
("PARTNERS_WITH", "Organizations collaborate on a business opportunity."),
|
||||
("AFFECTS", "One organization or event materially affects another."),
|
||||
("REPORTS_ON", "Media reports on a company, sector, or event."),
|
||||
("MANAGES", "Person manages or represents an organization."),
|
||||
]
|
||||
return {
|
||||
"entity_types": [
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"attributes": [
|
||||
{"name": attr, "type": "text", "description": f"Display name for {name}"},
|
||||
{"name": "role", "type": "text", "description": "Role in the business scenario"},
|
||||
],
|
||||
"examples": [],
|
||||
}
|
||||
for name, description, attr in entity_types
|
||||
],
|
||||
"edge_types": [
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source_targets": [
|
||||
{"source": "Organization", "target": "Organization"},
|
||||
{"source": "Bank", "target": "ManufacturingCompany"},
|
||||
{"source": "Person", "target": "Organization"},
|
||||
],
|
||||
"attributes": [],
|
||||
}
|
||||
for name, description in edge_types
|
||||
],
|
||||
"analysis_summary": (
|
||||
"Fallback ontology for live demo. External LLM ontology generation was unavailable; "
|
||||
f"scenario requirement: {simulation_requirement[:200]}"
|
||||
),
|
||||
}
|
||||
|
||||
# 传给 LLM 的文本最大长度(2.8万字)
|
||||
# Qwen 32768 token 窗口:system prompt ~2K tokens + 响应 ~4K tokens
|
||||
|
|
@ -534,4 +603,3 @@ class OntologyGenerator:
|
|||
code_lines.append('}')
|
||||
|
||||
return '\n'.join(code_lines)
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,32 @@ class SimulationManager:
|
|||
|
||||
# 内存中的模拟状态缓存
|
||||
self._simulations: Dict[str, SimulationState] = {}
|
||||
|
||||
@staticmethod
|
||||
def _cap_entities_for_live_demo(filtered: FilteredEntities) -> FilteredEntities:
|
||||
"""Limit generated Agents while keeping the most connected graph nodes."""
|
||||
max_agents = max(1, Config.SIMULATION_MAX_AGENTS)
|
||||
if len(filtered.entities) <= max_agents:
|
||||
return filtered
|
||||
|
||||
def entity_score(entity: Any) -> tuple:
|
||||
degree = len(getattr(entity, "related_edges", []) or []) + len(getattr(entity, "related_nodes", []) or [])
|
||||
summary_bonus = 1 if getattr(entity, "summary", "") else 0
|
||||
type_bonus = 1 if entity.get_entity_type() and entity.get_entity_type() != "Entity" else 0
|
||||
return (degree, type_bonus, summary_bonus, entity.name or "")
|
||||
|
||||
selected = sorted(filtered.entities, key=entity_score, reverse=True)[:max_agents]
|
||||
entity_types = {e.get_entity_type() or "Entity" for e in selected}
|
||||
logger.info(
|
||||
f"现场 Agent 上限生效: {len(filtered.entities)} -> {len(selected)} "
|
||||
f"(SIMULATION_MAX_AGENTS={max_agents})"
|
||||
)
|
||||
return FilteredEntities(
|
||||
entities=selected,
|
||||
entity_types=entity_types,
|
||||
total_count=filtered.total_count,
|
||||
filtered_count=len(selected),
|
||||
)
|
||||
|
||||
def _get_simulation_dir(self, simulation_id: str) -> str:
|
||||
"""获取模拟数据目录"""
|
||||
|
|
@ -340,6 +366,7 @@ class SimulationManager:
|
|||
defined_entity_types=defined_entity_types,
|
||||
enrich_with_edges=True
|
||||
)
|
||||
filtered = self._cap_entities_for_live_demo(filtered)
|
||||
|
||||
state.entities_count = filtered.filtered_count
|
||||
state.entity_types = list(filtered.entity_types)
|
||||
|
|
@ -369,8 +396,9 @@ class SimulationManager:
|
|||
total=total_entities
|
||||
)
|
||||
|
||||
# 传入graph_id以启用Zep检索功能,获取更丰富的上下文
|
||||
generator = OasisProfileGenerator(graph_id=state.graph_id)
|
||||
# 非 LLM 演示模式不做 Graphiti 检索,避免远端 Neo4j 不可用拖慢现场。
|
||||
profile_graph_id = state.graph_id if use_llm_for_profiles else None
|
||||
generator = OasisProfileGenerator(graph_id=profile_graph_id)
|
||||
|
||||
def profile_progress(current, total, msg):
|
||||
if progress_callback:
|
||||
|
|
@ -397,7 +425,7 @@ class SimulationManager:
|
|||
entities=filtered.entities,
|
||||
use_llm=use_llm_for_profiles,
|
||||
progress_callback=profile_progress,
|
||||
graph_id=state.graph_id, # 传入graph_id用于Zep检索
|
||||
graph_id=profile_graph_id, # 传入graph_id用于Zep检索
|
||||
parallel_count=parallel_profile_count, # 并行生成数量
|
||||
realtime_output_path=realtime_output_path, # 实时保存路径
|
||||
output_platform=realtime_platform, # 输出格式
|
||||
|
|
@ -546,6 +574,27 @@ class SimulationManager:
|
|||
simulations.append(state)
|
||||
|
||||
return simulations
|
||||
|
||||
def delete_simulation(self, simulation_id: str) -> bool:
|
||||
"""删除模拟数据目录,用于清理历史列表中的模拟结果"""
|
||||
if not simulation_id or simulation_id != os.path.basename(simulation_id):
|
||||
raise ValueError(f"非法模拟ID: {simulation_id}")
|
||||
|
||||
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
|
||||
state = self._load_simulation_state(simulation_id) if os.path.exists(sim_dir) else None
|
||||
|
||||
if state and state.status in {SimulationStatus.PREPARING, SimulationStatus.RUNNING}:
|
||||
raise ValueError(f"模拟仍在运行或准备中,不能删除: {simulation_id}")
|
||||
|
||||
self._simulations.pop(simulation_id, None)
|
||||
self.clear_accelerate(simulation_id)
|
||||
|
||||
if not os.path.exists(sim_dir):
|
||||
return False
|
||||
|
||||
shutil.rmtree(sim_dir)
|
||||
logger.info(f"已删除模拟数据: {simulation_id}")
|
||||
return True
|
||||
|
||||
def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]:
|
||||
"""获取模拟的Agent Profile"""
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import threading
|
|||
import subprocess
|
||||
import signal
|
||||
import atexit
|
||||
import importlib
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
|
@ -384,6 +385,8 @@ class SimulationRunner:
|
|||
else:
|
||||
cls._graph_memory_enabled[simulation_id] = False
|
||||
|
||||
use_demo_runner = cls._should_use_demo_runner()
|
||||
|
||||
# 确定运行哪个脚本(脚本位于 backend/scripts/ 目录)
|
||||
if platform == "twitter":
|
||||
script_name = "run_twitter_simulation.py"
|
||||
|
|
@ -395,6 +398,10 @@ class SimulationRunner:
|
|||
script_name = "run_parallel_simulation.py"
|
||||
state.twitter_running = True
|
||||
state.reddit_running = True
|
||||
|
||||
if use_demo_runner:
|
||||
logger.warning("OASIS 不可用或已启用现场演示 runner,切换为轻量仿真脚本")
|
||||
script_name = "run_demo_simulation.py"
|
||||
|
||||
script_path = os.path.join(cls.SCRIPTS_DIR, script_name)
|
||||
|
||||
|
|
@ -422,6 +429,9 @@ class SimulationRunner:
|
|||
# 如果指定了最大轮数,添加到命令行参数
|
||||
if max_rounds is not None and max_rounds > 0:
|
||||
cmd.extend(["--max-rounds", str(max_rounds)])
|
||||
|
||||
if use_demo_runner:
|
||||
cmd.extend(["--platform", platform])
|
||||
|
||||
# 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞
|
||||
main_log_path = os.path.join(sim_dir, "simulation.log")
|
||||
|
|
@ -477,6 +487,22 @@ class SimulationRunner:
|
|||
raise
|
||||
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _should_use_demo_runner(cls) -> bool:
|
||||
"""Return True when the live demo should avoid importing OASIS/Torch."""
|
||||
mode = os.environ.get("FORESIGHT_DEMO_RUNNER", "auto").strip().lower()
|
||||
if mode in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if mode in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
|
||||
try:
|
||||
importlib.import_module("oasis")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"OASIS 自检失败,启用现场演示 runner: {type(e).__name__}: {str(e)[:200]}")
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'):
|
||||
|
|
@ -1719,4 +1745,3 @@ class SimulationRunner:
|
|||
results = results[:limit]
|
||||
|
||||
return results
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
|
|||
from dataclasses import dataclass, field
|
||||
|
||||
from ..config import Config
|
||||
from ..models.project import ProjectManager
|
||||
from ..utils.logger import get_logger
|
||||
from .graphiti_client import GraphitiClient
|
||||
|
||||
|
|
@ -48,6 +49,11 @@ class EntityNode:
|
|||
return label
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_type(self) -> Optional[str]:
|
||||
"""兼容需要属性访问的模拟配置生成器。"""
|
||||
return self.get_entity_type()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilteredEntities:
|
||||
|
|
@ -131,7 +137,17 @@ class ZepEntityReader:
|
|||
"""
|
||||
logger.info(f"获取图谱 {graph_id} 的所有节点...")
|
||||
|
||||
nodes = self.client.get_all_nodes(graph_id)
|
||||
try:
|
||||
nodes = self.client.get_all_nodes(graph_id)
|
||||
except Exception as e:
|
||||
snapshot = self._get_snapshot(graph_id)
|
||||
if snapshot:
|
||||
logger.warning(
|
||||
f"Graphiti 节点读取失败,使用本地 snapshot: "
|
||||
f"{type(e).__name__}: {str(e)[:150]}"
|
||||
)
|
||||
return snapshot.get("nodes", [])
|
||||
raise
|
||||
|
||||
nodes_data = []
|
||||
for node in nodes:
|
||||
|
|
@ -158,7 +174,17 @@ class ZepEntityReader:
|
|||
"""
|
||||
logger.info(f"获取图谱 {graph_id} 的所有边...")
|
||||
|
||||
edges = self.client.get_all_edges(graph_id)
|
||||
try:
|
||||
edges = self.client.get_all_edges(graph_id)
|
||||
except Exception as e:
|
||||
snapshot = self._get_snapshot(graph_id)
|
||||
if snapshot:
|
||||
logger.warning(
|
||||
f"Graphiti 边读取失败,使用本地 snapshot: "
|
||||
f"{type(e).__name__}: {str(e)[:150]}"
|
||||
)
|
||||
return snapshot.get("edges", [])
|
||||
raise
|
||||
|
||||
edges_data = []
|
||||
for edge in edges:
|
||||
|
|
@ -173,6 +199,12 @@ class ZepEntityReader:
|
|||
|
||||
logger.info(f"共获取 {len(edges_data)} 条边")
|
||||
return edges_data
|
||||
|
||||
def _get_snapshot(self, graph_id: str) -> Optional[Dict[str, Any]]:
|
||||
project = ProjectManager.get_project_by_graph_id(graph_id)
|
||||
if not project:
|
||||
return None
|
||||
return ProjectManager.get_graph_snapshot(project.project_id)
|
||||
|
||||
def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
|
@ -426,5 +458,3 @@ class ZepEntityReader:
|
|||
enrich_with_edges=enrich_with_edges
|
||||
)
|
||||
return result.entities
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from ..config import Config
|
|||
logger = logging.getLogger('foresight.llm_client')
|
||||
|
||||
# 重试配置(针对 429 / 5xx / 超时 / 连接错误)
|
||||
_MAX_RETRIES = 8
|
||||
_MAX_RETRIES = Config.LLM_MAX_RETRIES
|
||||
_BASE_BACKOFF = 2.0 # 首次重试等 2s
|
||||
_MAX_BACKOFF = 60.0 # 单次最多等 60s
|
||||
|
||||
|
|
@ -48,7 +48,8 @@ class LLMClient:
|
|||
self,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None
|
||||
model: Optional[str] = None,
|
||||
timeout: Optional[float] = None
|
||||
):
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
|
|
@ -59,7 +60,9 @@ class LLMClient:
|
|||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
base_url=self.base_url,
|
||||
timeout=timeout or Config.LLM_TIMEOUT_SECONDS,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
def chat(
|
||||
|
|
@ -274,4 +277,3 @@ class LLMClient:
|
|||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
"""
|
||||
Lightweight live-demo simulation runner.
|
||||
|
||||
This runner preserves the Foresight UI/API contract when the local OASIS stack is
|
||||
blocked by Torch/CAMEL binary compatibility. It writes the same actions.jsonl
|
||||
shape that SimulationRunner already monitors, but it does not import OASIS,
|
||||
Torch, CAMEL, or GPU-backed libraries.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
ACTION_LIBRARY = [
|
||||
{
|
||||
"action_type": "CREATE_POST",
|
||||
"template": "{name} 发布观察:AI 算力扩张正在把电力容量、液冷组件和园区配套变成新增投资的约束条件。",
|
||||
},
|
||||
{
|
||||
"action_type": "CREATE_COMMENT",
|
||||
"template": "{name} 评论:下一步要看订单兑现、授信需求和现金流压力是否同步出现。",
|
||||
},
|
||||
{
|
||||
"action_type": "LIKE_POST",
|
||||
"template": "{name} 赞同了关于绿色信贷和设备更新贷款的讨论。",
|
||||
},
|
||||
{
|
||||
"action_type": "CREATE_COMMENT",
|
||||
"template": "{name} 提醒:变量推演不是预测,关键是把客户拜访问题提前准备好。",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def write_jsonl(path: str, payload: Dict[str, Any]) -> None:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def load_config(config_path: str) -> Dict[str, Any]:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_agents(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
agents = config.get("agent_configs") or []
|
||||
if agents:
|
||||
return agents
|
||||
return [
|
||||
{"agent_id": 0, "entity_name": "AI 数据中心", "entity_type": "DataCenterOperator"},
|
||||
{"agent_id": 1, "entity_name": "制造企业", "entity_type": "ManufacturingCompany"},
|
||||
{"agent_id": 2, "entity_name": "银行客户经理", "entity_type": "BankRelationshipManager"},
|
||||
]
|
||||
|
||||
|
||||
def platforms_from_arg(platform: str) -> List[str]:
|
||||
if platform == "parallel":
|
||||
return ["twitter", "reddit"]
|
||||
if platform in {"twitter", "reddit"}:
|
||||
return [platform]
|
||||
return ["reddit"]
|
||||
|
||||
|
||||
def reset_logs(simulation_dir: str, platforms: List[str]) -> Dict[str, str]:
|
||||
log_paths = {}
|
||||
for platform in platforms:
|
||||
platform_dir = os.path.join(simulation_dir, platform)
|
||||
os.makedirs(platform_dir, exist_ok=True)
|
||||
log_path = os.path.join(platform_dir, "actions.jsonl")
|
||||
with open(log_path, "w", encoding="utf-8"):
|
||||
pass
|
||||
log_paths[platform] = log_path
|
||||
return log_paths
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--max-rounds", type=int, default=None)
|
||||
parser.add_argument("--platform", default="reddit")
|
||||
parser.add_argument("--tick-seconds", type=float, default=0.45)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(args.config)
|
||||
simulation_dir = os.path.dirname(os.path.abspath(args.config))
|
||||
platforms = platforms_from_arg(args.platform)
|
||||
log_paths = reset_logs(simulation_dir, platforms)
|
||||
|
||||
time_config = config.get("time_config", {})
|
||||
total_hours = int(time_config.get("total_simulation_hours", 72))
|
||||
minutes_per_round = int(time_config.get("minutes_per_round", 60)) or 60
|
||||
calculated_rounds = max(1, int(total_hours * 60 / minutes_per_round))
|
||||
total_rounds = min(calculated_rounds, args.max_rounds) if args.max_rounds else calculated_rounds
|
||||
agents = get_agents(config)
|
||||
|
||||
print(f"[demo-runner] start simulation={config.get('simulation_id')} platforms={platforms} rounds={total_rounds}", flush=True)
|
||||
|
||||
for platform, log_path in log_paths.items():
|
||||
write_jsonl(log_path, {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"event_type": "simulation_start",
|
||||
"platform": platform,
|
||||
"total_rounds": total_rounds,
|
||||
"agents_count": len(agents),
|
||||
})
|
||||
|
||||
random.seed(config.get("simulation_id", "foresight-demo"))
|
||||
total_actions = {platform: 0 for platform in platforms}
|
||||
|
||||
for round_num in range(1, total_rounds + 1):
|
||||
simulated_hours = round_num * minutes_per_round // 60
|
||||
for platform, log_path in log_paths.items():
|
||||
write_jsonl(log_path, {
|
||||
"round": round_num,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"event_type": "round_start",
|
||||
"simulated_hours": simulated_hours,
|
||||
})
|
||||
|
||||
sample_size = min(len(agents), 3)
|
||||
selected_agents = random.sample(agents, sample_size)
|
||||
for agent in selected_agents:
|
||||
action = random.choice(ACTION_LIBRARY)
|
||||
name = agent.get("entity_name") or agent.get("name") or f"Agent {agent.get('agent_id', 0)}"
|
||||
result = action["template"].format(name=name)
|
||||
write_jsonl(log_path, {
|
||||
"round": round_num,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"agent_id": agent.get("agent_id", 0),
|
||||
"agent_name": name,
|
||||
"action_type": action["action_type"],
|
||||
"action_args": {
|
||||
"platform": platform,
|
||||
"topic": config.get("simulation_requirement", "")[:80],
|
||||
},
|
||||
"result": result,
|
||||
"success": True,
|
||||
})
|
||||
total_actions[platform] += 1
|
||||
|
||||
write_jsonl(log_path, {
|
||||
"round": round_num,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"event_type": "round_end",
|
||||
"actions_count": total_actions[platform],
|
||||
"simulated_hours": simulated_hours,
|
||||
})
|
||||
|
||||
time.sleep(args.tick_seconds)
|
||||
|
||||
for platform, log_path in log_paths.items():
|
||||
write_jsonl(log_path, {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"event_type": "simulation_end",
|
||||
"platform": platform,
|
||||
"total_rounds": total_rounds,
|
||||
"total_actions": total_actions[platform],
|
||||
})
|
||||
|
||||
print(f"[demo-runner] completed actions={total_actions}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Foresight Live Demo Seed
|
||||
|
||||
2026 年,AI 数据中心继续扩张,制造企业、电力设备商、园区运营方和银行客户经理都在重新评估机会。
|
||||
|
||||
核心变量包括 GPU 服务器采购、液冷改造、电网接入周期、企业现金流压力、绿色信贷额度、供应链付款周期和监管要求。
|
||||
|
||||
宁波本地一家高端装备制造企业正在考虑承接数据中心液冷组件订单,但它面临原材料价格波动、账期拉长、设备扩产资金缺口和技术人员招聘压力。银行希望识别可介入的服务点,包括流动资金贷款、设备融资租赁、订单融资、绿色信贷、结算服务和企业主财富管理。
|
||||
|
||||
演示目标:推演未来 12 个月 AI 算力扩张对制造业、数据中心、电力设备和银行服务机会的连锁影响,输出关键变量、机会情景、风险预警和客户拜访切入点。
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Foresight 现场 Demo 腾讯云部署检查
|
||||
|
||||
用途:保证「先见之明」可被远程浏览器打开,并能稳定进入宁波银行缓存回放案例。
|
||||
|
||||
## 本地检查
|
||||
|
||||
```bash
|
||||
cd "/Users/liyizhouai/Desktop/openclaw/vibe coding/Foresight先见之明"
|
||||
npm run backend
|
||||
cd frontend && npm run dev -- --host 0.0.0.0 --port 4190
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
- 首页:http://localhost:4190/
|
||||
- 同域 API:http://localhost:4190/api/simulation/history?limit=1
|
||||
- 回放页:http://localhost:4190/simulation/sim_nb_hnw_ai_case/replay
|
||||
|
||||
## 腾讯云双域名部署
|
||||
|
||||
如果继续使用:
|
||||
|
||||
- 前端:https://foresight.yizhou.chat
|
||||
- 后端:https://api.foresight.yizhou.chat
|
||||
|
||||
前端构建必须显式指定 API 地址:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
VITE_API_BASE_URL=https://api.foresight.yizhou.chat npm run build
|
||||
```
|
||||
|
||||
否则默认会走同域 `/api`。
|
||||
|
||||
## 腾讯云同域部署
|
||||
|
||||
如果希望远程讲课时只打开一个域名,推荐 Nginx 规则:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:5001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
同域部署时,前端无需设置 `VITE_API_BASE_URL`。
|
||||
|
||||
## 现场必验路径
|
||||
|
||||
1. 打开首页,滚动到「推演记录」。
|
||||
2. 点击「宁波银行高净值客户AI理财组合推介」卡片。
|
||||
3. 页面保持在首页,并弹出历史项目回溯弹窗。
|
||||
4. 弹窗里确认四个入口都可见:`查看回放`、`图谱构建`、`环境搭建`、`分析报告`。
|
||||
5. `图谱构建` 进入 `/process/proj_nb_hnw_ai_case`。
|
||||
6. `环境搭建` 进入 `/simulation/sim_nb_hnw_ai_case`。
|
||||
7. `分析报告` 进入 `/report/report_nb_hnw_ai_case`。
|
||||
8. `查看回放` 可作为补充入口进入 `/simulation/sim_nb_hnw_ai_case/replay`。
|
||||
|
||||
## 关键稳定性说明
|
||||
|
||||
- 缓存回放案例不触发 ReportAgent 生成。
|
||||
- 缓存回放案例不依赖远端 Neo4j/Bolt。
|
||||
- 宁波银行案例的项目、图谱、环境配置和报告日志都由后端内置缓存提供,腾讯云部署时不依赖 `uploads/` 目录同步。
|
||||
- 默认 API 已改为同域访问;双域名部署时用 `VITE_API_BASE_URL` 覆盖。
|
||||
|
|
@ -1,6 +1,31 @@
|
|||
<template>
|
||||
<div class="app-shell">
|
||||
<Teleport to="#theme-toggle-anchor" v-if="anchorReady">
|
||||
<div v-if="!isAuthed" class="demo-login">
|
||||
<form class="demo-login__panel" @submit.prevent="submitPassword">
|
||||
<div class="demo-login__brand">
|
||||
<span class="brand-mark">
|
||||
<span class="brand-mark-en">FORESIGHT</span>
|
||||
<span class="brand-mark-sep"></span>
|
||||
<span class="brand-mark-zh">先见之明</span>
|
||||
</span>
|
||||
</div>
|
||||
<h1>AI DEMO 预览</h1>
|
||||
<p>请输入演示密码进入现场回溯系统。</p>
|
||||
<input
|
||||
v-model="authInput"
|
||||
class="demo-login__input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="演示密码"
|
||||
autofocus
|
||||
/>
|
||||
<button class="demo-login__button" type="submit">进入演示</button>
|
||||
<p v-if="authError" class="demo-login__error">{{ authError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Teleport to="#theme-toggle-anchor" v-if="anchorReady">
|
||||
<button
|
||||
class="theme-toggle"
|
||||
:class="`theme-toggle--${theme}`"
|
||||
|
|
@ -42,9 +67,10 @@
|
|||
<path d="M20.3 14.3A8.9 8.9 0 0 1 9.7 3.7a9.1 9.1 0 1 0 10.6 10.6Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Teleport>
|
||||
</Teleport>
|
||||
|
||||
<router-view />
|
||||
<router-view />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -54,8 +80,13 @@ import { useRouter } from 'vue-router'
|
|||
|
||||
const router = useRouter()
|
||||
const THEME_STORAGE_KEY = 'foresight-ui-theme'
|
||||
const AUTH_STORAGE_KEY = 'foresight-demo-auth'
|
||||
const DEMO_PASSWORD = import.meta.env.VITE_DEMO_PASSWORD || ''
|
||||
const theme = ref('light')
|
||||
const anchorReady = ref(false)
|
||||
const authInput = ref('')
|
||||
const authError = ref('')
|
||||
const isAuthed = ref(!DEMO_PASSWORD)
|
||||
|
||||
const applyTheme = (nextTheme) => {
|
||||
document.documentElement.setAttribute('data-theme', nextTheme)
|
||||
|
|
@ -71,7 +102,23 @@ const checkAnchor = () => {
|
|||
anchorReady.value = !!document.getElementById('theme-toggle-anchor')
|
||||
}
|
||||
|
||||
const submitPassword = () => {
|
||||
if (!DEMO_PASSWORD || authInput.value === DEMO_PASSWORD) {
|
||||
window.localStorage.setItem(AUTH_STORAGE_KEY, 'ok')
|
||||
authError.value = ''
|
||||
isAuthed.value = true
|
||||
nextTick(checkAnchor)
|
||||
return
|
||||
}
|
||||
|
||||
authError.value = '密码不正确,请重新输入'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (DEMO_PASSWORD && window.localStorage.getItem(AUTH_STORAGE_KEY) === 'ok') {
|
||||
isAuthed.value = true
|
||||
}
|
||||
|
||||
const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||
theme.value = savedTheme
|
||||
|
|
@ -164,6 +211,110 @@ html[data-theme='dark'] #app {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.demo-login {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 19, 33, 0.06), rgba(16, 19, 33, 0)),
|
||||
var(--ui-bg);
|
||||
}
|
||||
|
||||
.demo-login__panel {
|
||||
width: min(420px, 100%);
|
||||
padding: 34px 32px 30px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 8px;
|
||||
background: var(--ui-surface-strong);
|
||||
box-shadow: var(--ui-shadow);
|
||||
}
|
||||
|
||||
.demo-login__brand {
|
||||
margin-bottom: 22px;
|
||||
color: var(--ui-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.demo-login h1 {
|
||||
margin-bottom: 10px;
|
||||
color: var(--ui-text);
|
||||
font-size: 28px;
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.demo-login p {
|
||||
color: var(--ui-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.demo-login__input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-top: 24px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.demo-login__input:focus {
|
||||
border-color: rgba(86, 126, 255, 0.72);
|
||||
box-shadow: 0 0 0 3px rgba(86, 126, 255, 0.14);
|
||||
}
|
||||
|
||||
.demo-login__input::placeholder {
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
|
||||
.demo-login__button {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-top: 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #101321;
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.demo-login__button:hover {
|
||||
background: #232838;
|
||||
}
|
||||
|
||||
.demo-login__error {
|
||||
margin-top: 12px;
|
||||
color: #c93f3f !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .demo-login {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(86, 126, 255, 0.12), rgba(86, 126, 255, 0)),
|
||||
#000000;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .demo-login__button {
|
||||
background: #f4f7ff;
|
||||
color: #05070d;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .demo-login__button:hover {
|
||||
background: #dfe7ff;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .demo-login__error {
|
||||
color: #ff8f8f !important;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -709,6 +860,176 @@ html[data-theme='dark'][data-theme='dark'] .panel-wrapper.right {
|
|||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Dark mode color polish: keep layout intact, only fix light surfaces and unreadable text */
|
||||
html[data-theme='dark'][data-theme='dark'] .home-container,
|
||||
html[data-theme='dark'][data-theme='dark'] .dashboard-section,
|
||||
html[data-theme='dark'][data-theme='dark'] .history-database {
|
||||
background: #000000 !important;
|
||||
color: #edf2ff !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .main-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .highlight-bold,
|
||||
html[data-theme='dark'][data-theme='dark'] .slogan-text,
|
||||
html[data-theme='dark'][data-theme='dark'] .metric-value,
|
||||
html[data-theme='dark'][data-theme='dark'] .step-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .console-label,
|
||||
html[data-theme='dark'][data-theme='dark'] .file-name,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-id,
|
||||
html[data-theme='dark'][data-theme='dark'] .report-id,
|
||||
html[data-theme='dark'][data-theme='dark'] .report-section-item.is-active .section-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .report-section-item.is-completed .section-title {
|
||||
color: #f4f7fb !important;
|
||||
-webkit-text-fill-color: #f4f7fb !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .gradient-text {
|
||||
background: linear-gradient(90deg, #ffffff 0%, #a8b0c2 100%) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .hero-desc,
|
||||
html[data-theme='dark'][data-theme='dark'] .section-desc,
|
||||
html[data-theme='dark'][data-theme='dark'] .step-desc,
|
||||
html[data-theme='dark'][data-theme='dark'] .metric-label,
|
||||
html[data-theme='dark'][data-theme='dark'] .version-text,
|
||||
html[data-theme='dark'][data-theme='dark'] .console-header,
|
||||
html[data-theme='dark'][data-theme='dark'] .console-meta,
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-hint,
|
||||
html[data-theme='dark'][data-theme='dark'] .model-badge,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-desc,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-id,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-footer,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-label,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-create-time,
|
||||
html[data-theme='dark'][data-theme='dark'] .sub-title,
|
||||
html[data-theme='dark'][data-theme='dark'] .section-number {
|
||||
color: #a5adbd !important;
|
||||
-webkit-text-fill-color: #a5adbd !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .highlight-code,
|
||||
html[data-theme='dark'][data-theme='dark'] .metric-card,
|
||||
html[data-theme='dark'][data-theme='dark'] .steps-container,
|
||||
html[data-theme='dark'][data-theme='dark'] .console-box,
|
||||
html[data-theme='dark'][data-theme='dark'] .input-wrapper,
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-zone,
|
||||
html[data-theme='dark'][data-theme='dark'] .file-item,
|
||||
html[data-theme='dark'][data-theme='dark'] .project-card,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-files-wrapper,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-content,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-header,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-requirement,
|
||||
html[data-theme='dark'][data-theme='dark'] .report-style,
|
||||
html[data-theme='dark'][data-theme='dark'] .section-header-row.clickable:hover,
|
||||
html[data-theme='dark'][data-theme='dark'] .tools-card,
|
||||
html[data-theme='dark'][data-theme='dark'] .chat-message,
|
||||
html[data-theme='dark'][data-theme='dark'] .message-content,
|
||||
html[data-theme='dark'][data-theme='dark'] .agent-dropdown,
|
||||
html[data-theme='dark'][data-theme='dark'] .agent-option {
|
||||
background: #111111 !important;
|
||||
background-image: none !important;
|
||||
color: #edf2ff !important;
|
||||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-zone:hover,
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-zone.drag-over,
|
||||
html[data-theme='dark'][data-theme='dark'] .project-card:hover,
|
||||
html[data-theme='dark'][data-theme='dark'] .file-item:hover,
|
||||
html[data-theme='dark'][data-theme='dark'] .agent-option:hover,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-close:hover {
|
||||
background: #181818 !important;
|
||||
background-image: none !important;
|
||||
border-color: rgba(255, 255, 255, 0.22) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .upload-icon,
|
||||
html[data-theme='dark'][data-theme='dark'] .files-more,
|
||||
html[data-theme='dark'][data-theme='dark'] .files-empty,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-progress,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-close,
|
||||
html[data-theme='dark'][data-theme='dark'] .report-tag {
|
||||
background: #1a1a1a !important;
|
||||
color: #d8deeb !important;
|
||||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .code-input,
|
||||
html[data-theme='dark'][data-theme='dark'] .code-input::placeholder,
|
||||
html[data-theme='dark'][data-theme='dark'] textarea::placeholder,
|
||||
html[data-theme='dark'][data-theme='dark'] input::placeholder {
|
||||
color: #c4cad6 !important;
|
||||
-webkit-text-fill-color: #c4cad6 !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .start-engine-btn:disabled {
|
||||
background: #202020 !important;
|
||||
color: #8f96a3 !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
-webkit-text-fill-color: #8f96a3 !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .start-engine-btn:not(:disabled) {
|
||||
background: #f4f7fb !important;
|
||||
color: #050505 !important;
|
||||
border-color: #f4f7fb !important;
|
||||
-webkit-text-fill-color: #050505 !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .console-divider::before,
|
||||
html[data-theme='dark'][data-theme='dark'] .console-divider::after,
|
||||
html[data-theme='dark'][data-theme='dark'] .header-divider,
|
||||
html[data-theme='dark'][data-theme='dark'] .section-line,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-footer,
|
||||
html[data-theme='dark'][data-theme='dark'] .card-header,
|
||||
html[data-theme='dark'][data-theme='dark'] .modal-header {
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .console-divider span,
|
||||
html[data-theme='dark'][data-theme='dark'] .section-title {
|
||||
background: #000000 !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.06) 1px, transparent 1px) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .gradient-overlay {
|
||||
background:
|
||||
linear-gradient(to right, rgba(0, 0, 0, 0.9) 0%, transparent 15%, transparent 85%, rgba(0, 0, 0, 0.9) 100%),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.82) 0%, transparent 20%, transparent 80%, rgba(0, 0, 0, 0.82) 100%) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .hero-logo-light {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .hero-logo-dark {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
html[data-theme='light'] .hero-logo-dark,
|
||||
html:not([data-theme='dark']) .hero-logo-dark {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'][data-theme='dark'] .corner-mark.top-left-only {
|
||||
border-top-color: rgba(255, 255, 255, 0.35) !important;
|
||||
border-left-color: rgba(255, 255, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.theme-toggle {
|
||||
width: 16px;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import i18n from '../i18n'
|
|||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
timeout: 300000, // 5分钟超时(本体生成可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
|
|
|||
|
|
@ -202,3 +202,10 @@ export const getSimulationHistory = (limit = 20) => {
|
|||
return service.get('/api/simulation/history', { params: { limit } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个历史模拟结果
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const deleteSimulation = (simulationId) => {
|
||||
return service.delete(`/api/simulation/${simulationId}`)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
|
|
@ -23,15 +23,29 @@
|
|||
v-for="(project, index) in projects"
|
||||
:key="project.simulation_id"
|
||||
class="project-card"
|
||||
:class="{ expanded: isExpanded, hovering: hoveringCard === index }"
|
||||
:class="{ expanded: isExpanded, hovering: hoveringCard === index, 'cached-replay-card': project.is_cached_case }"
|
||||
:style="getCardStyle(index)"
|
||||
@mouseenter="hoveringCard = index"
|
||||
@mouseleave="hoveringCard = null"
|
||||
@click="navigateToProject(project)"
|
||||
>
|
||||
<button
|
||||
v-if="canDeleteProject(project)"
|
||||
class="card-delete"
|
||||
type="button"
|
||||
:aria-label="$t('history.deleteAction')"
|
||||
:title="$t('history.deleteAction')"
|
||||
@click.stop="openDeleteConfirm(project)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<!-- 卡片头部:simulation_id 和 功能可用状态 -->
|
||||
<div class="card-header">
|
||||
<span class="card-id">{{ formatSimulationId(project.simulation_id) }}</span>
|
||||
<div class="card-id-wrap">
|
||||
<span class="card-id">{{ formatSimulationId(project.simulation_id) }}</span>
|
||||
<span v-if="project.is_cached_case" class="cache-badge">{{ project.cached_label || $t('history.cachedReplay') }}</span>
|
||||
</div>
|
||||
<div class="card-status-icons">
|
||||
<span
|
||||
class="status-icon"
|
||||
|
|
@ -47,6 +61,11 @@
|
|||
:class="{ available: project.report_id, unavailable: !project.report_id }"
|
||||
:title="$t('history.analysisReport')"
|
||||
>◆</span>
|
||||
<span
|
||||
class="status-icon"
|
||||
:class="{ available: hasReplay(project), unavailable: !hasReplay(project) }"
|
||||
:title="$t('history.viewReplay')"
|
||||
>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,8 +171,17 @@
|
|||
|
||||
<!-- 导航按钮 -->
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="modal-btn btn-project"
|
||||
<button
|
||||
class="modal-btn btn-replay"
|
||||
@click="goToReplay"
|
||||
:disabled="!hasReplay(selectedProject)"
|
||||
>
|
||||
<span class="btn-step">Replay</span>
|
||||
<span class="btn-icon">▶</span>
|
||||
<span class="btn-text">{{ $t('history.viewReplay') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="modal-btn btn-project"
|
||||
@click="goToProject"
|
||||
:disabled="!selectedProject.project_id"
|
||||
>
|
||||
|
|
@ -187,6 +215,38 @@
|
|||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="deleteCandidate" class="confirm-overlay" @click.self="closeDeleteConfirm">
|
||||
<div class="confirm-content">
|
||||
<div class="confirm-kicker">{{ $t('history.deleteKicker') }}</div>
|
||||
<h3 class="confirm-title">{{ $t('history.deleteTitle') }}</h3>
|
||||
<p class="confirm-desc">
|
||||
{{ $t('history.deleteDescription', { id: formatSimulationId(deleteCandidate.simulation_id) }) }}
|
||||
</p>
|
||||
<div class="confirm-preview">
|
||||
{{ getDeletePreview(deleteCandidate) }}
|
||||
</div>
|
||||
<p v-if="deleteError" class="confirm-error">{{ deleteError }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn-secondary" type="button" @click="closeDeleteConfirm">
|
||||
{{ $t('history.deleteCancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="confirm-btn confirm-btn-danger"
|
||||
type="button"
|
||||
:disabled="deleteLoading"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleteLoading ? $t('history.deleting') : $t('history.deleteConfirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -194,7 +254,7 @@
|
|||
import { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSimulationHistory } from '../api/simulation'
|
||||
import { deleteSimulation, getSimulationHistory } from '../api/simulation'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -207,6 +267,9 @@ const isExpanded = ref(false)
|
|||
const hoveringCard = ref(null)
|
||||
const historyContainer = ref(null)
|
||||
const selectedProject = ref(null) // 当前选中的项目(用于弹窗)
|
||||
const deleteCandidate = ref(null)
|
||||
const deleteLoading = ref(false)
|
||||
const deleteError = ref('')
|
||||
let observer = null
|
||||
let isAnimating = false // 动画锁,防止闪烁
|
||||
let expandDebounceTimer = null // 防抖定时器
|
||||
|
|
@ -241,6 +304,7 @@ const containerStyle = computed(() => {
|
|||
// 获取卡片样式
|
||||
const getCardStyle = (index) => {
|
||||
const total = projects.value.length
|
||||
const isCachedCase = !!projects.value[index]?.is_cached_case
|
||||
|
||||
if (isExpanded.value) {
|
||||
// 展开态:网格布局
|
||||
|
|
@ -264,7 +328,7 @@ const getCardStyle = (index) => {
|
|||
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px) rotate(0deg) scale(1)`,
|
||||
zIndex: 100 + index,
|
||||
zIndex: isCachedCase ? 1000 : 100 + index,
|
||||
opacity: 1,
|
||||
transition: transition
|
||||
}
|
||||
|
|
@ -283,7 +347,7 @@ const getCardStyle = (index) => {
|
|||
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`,
|
||||
zIndex: 10 + index,
|
||||
zIndex: isCachedCase ? 1000 : 10 + index,
|
||||
opacity: 1,
|
||||
transition: transition
|
||||
}
|
||||
|
|
@ -393,11 +457,56 @@ const truncateFilename = (filename, maxLength) => {
|
|||
return truncatedName + ext
|
||||
}
|
||||
|
||||
const hasReplay = (simulation) => {
|
||||
return Boolean(simulation?.has_replay || simulation?.simulation_id)
|
||||
}
|
||||
|
||||
const canDeleteProject = (simulation) => {
|
||||
return Boolean(simulation?.simulation_id && !simulation?.is_cached_case)
|
||||
}
|
||||
|
||||
const getDeletePreview = (simulation) => {
|
||||
const title = getSimulationTitle(simulation?.simulation_requirement)
|
||||
return title || formatSimulationId(simulation?.simulation_id)
|
||||
}
|
||||
|
||||
// 打开项目详情弹窗
|
||||
const navigateToProject = (simulation) => {
|
||||
selectedProject.value = simulation
|
||||
}
|
||||
|
||||
const openDeleteConfirm = (simulation) => {
|
||||
if (!canDeleteProject(simulation)) return
|
||||
deleteCandidate.value = simulation
|
||||
deleteError.value = ''
|
||||
}
|
||||
|
||||
const closeDeleteConfirm = () => {
|
||||
if (deleteLoading.value) return
|
||||
deleteCandidate.value = null
|
||||
deleteError.value = ''
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteCandidate.value?.simulation_id) return
|
||||
|
||||
try {
|
||||
deleteLoading.value = true
|
||||
deleteError.value = ''
|
||||
const simulationId = deleteCandidate.value.simulation_id
|
||||
await deleteSimulation(simulationId)
|
||||
projects.value = projects.value.filter((project) => project.simulation_id !== simulationId)
|
||||
if (selectedProject.value?.simulation_id === simulationId) {
|
||||
selectedProject.value = null
|
||||
}
|
||||
deleteCandidate.value = null
|
||||
} catch (error) {
|
||||
deleteError.value = error?.message || t('history.deleteFailed')
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const closeModal = () => {
|
||||
selectedProject.value = null
|
||||
|
|
@ -425,6 +534,17 @@ const goToSimulation = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 导航到真实页面回放(逐页查看已完成效果)
|
||||
const goToReplay = () => {
|
||||
if (selectedProject.value?.simulation_id) {
|
||||
router.push({
|
||||
name: 'SimulationReplay',
|
||||
params: { simulationId: selectedProject.value.simulation_id }
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到分析报告页面(Report)
|
||||
const goToReport = () => {
|
||||
if (selectedProject.value?.report_id) {
|
||||
|
|
@ -687,6 +807,40 @@ onUnmounted(() => {
|
|||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.12);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #6B7280;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease, color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.project-card:hover .card-delete,
|
||||
.project-card.hovering .card-delete {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.card-delete:hover {
|
||||
border-color: rgba(185, 28, 28, 0.42);
|
||||
background: #FEF2F2;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
|
|
@ -705,6 +859,33 @@ onUnmounted(() => {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-id-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
border-radius: 999px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cached-replay-card {
|
||||
border-color: rgba(17, 24, 39, 0.32);
|
||||
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
/* 功能状态图标组 */
|
||||
.card-status-icons {
|
||||
display: flex;
|
||||
|
|
@ -726,6 +907,7 @@ onUnmounted(() => {
|
|||
.status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */
|
||||
.status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */
|
||||
.status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */
|
||||
.status-icon:nth-child(4).available { color: #111827; } /* 页面回放 - 黑色 */
|
||||
|
||||
.status-icon.unavailable {
|
||||
color: #D1D5DB;
|
||||
|
|
@ -1258,13 +1440,14 @@ onUnmounted(() => {
|
|||
/* 导航按钮 */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 20px 32px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
flex: 1 1 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -1317,6 +1500,7 @@ onUnmounted(() => {
|
|||
.modal-btn.btn-project .btn-icon { color: #3B82F6; }
|
||||
.modal-btn.btn-simulation .btn-icon { color: #F59E0B; }
|
||||
.modal-btn.btn-report .btn-icon { color: #10B981; }
|
||||
.modal-btn.btn-replay .btn-icon { color: #111827; }
|
||||
|
||||
.modal-btn:hover:not(:disabled) .btn-text {
|
||||
color: #111827;
|
||||
|
|
@ -1339,4 +1523,170 @@ onUnmounted(() => {
|
|||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.42);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.confirm-content {
|
||||
width: min(420px, 100%);
|
||||
padding: 26px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0 22px 48px rgba(17, 24, 39, 0.16);
|
||||
}
|
||||
|
||||
.confirm-kicker {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
color: #9CA3AF;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 10px;
|
||||
color: #111827;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.confirm-desc {
|
||||
margin: 0;
|
||||
color: #4B5563;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.confirm-preview {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #F3F4F6;
|
||||
border-radius: 6px;
|
||||
background: #F9FAFB;
|
||||
color: #374151;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.confirm-error {
|
||||
margin-top: 12px;
|
||||
color: #B91C1C;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.confirm-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.confirm-btn-secondary {
|
||||
border: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
color: #4B5563;
|
||||
}
|
||||
|
||||
.confirm-btn-secondary:hover {
|
||||
border-color: #9CA3AF;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.confirm-btn-danger {
|
||||
border: 1px solid #B91C1C;
|
||||
background: #B91C1C;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.confirm-btn-danger:hover:not(:disabled) {
|
||||
border-color: #991B1B;
|
||||
background: #991B1B;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .cache-badge {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: #f4f7fb;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .cached-replay-card {
|
||||
border-color: rgba(244, 247, 251, 0.34);
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .card-delete {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(17, 17, 17, 0.92);
|
||||
color: #C8D1E2;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .card-delete:hover {
|
||||
border-color: rgba(255, 143, 143, 0.48);
|
||||
background: rgba(127, 29, 29, 0.72);
|
||||
color: #FFE4E6;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-content {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: #111111;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-title {
|
||||
color: #F4F7FB;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-desc {
|
||||
color: #C8D1E2;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-preview {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: #1A1A1A;
|
||||
color: #E7ECF6;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-btn-secondary {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: #111111;
|
||||
color: #C8D1E2;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .confirm-btn-secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .status-icon:nth-child(4).available,
|
||||
:global(html[data-theme='dark']) .modal-btn.btn-replay .btn-icon {
|
||||
color: #f4f7fb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ const handleEnterEnvSetup = async () => {
|
|||
const res = await createSimulation({
|
||||
project_id: props.projectData.project_id,
|
||||
graph_id: props.projectData.graph_id,
|
||||
enable_twitter: true,
|
||||
enable_twitter: false,
|
||||
enable_reddit: true
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -684,8 +684,8 @@ let lastLoggedProfileCount = 0
|
|||
let lastLoggedConfigStage = ''
|
||||
|
||||
// 模拟轮数配置
|
||||
const useCustomRounds = ref(false) // 默认使用自动配置轮数
|
||||
const customMaxRounds = ref(40) // 默认推荐40轮
|
||||
const useCustomRounds = ref(true) // 现场演示默认使用稳定小轮数
|
||||
const customMaxRounds = ref(8) // 避免 OASIS 子进程在低内存环境长时间运行
|
||||
|
||||
// Watch stage to update phase
|
||||
watch(currentStage, (newStage) => {
|
||||
|
|
@ -797,8 +797,8 @@ const startPrepareSimulation = async () => {
|
|||
try {
|
||||
const res = await prepareSimulation({
|
||||
simulation_id: props.simulationId,
|
||||
use_llm_for_profiles: true,
|
||||
parallel_profile_count: 5
|
||||
use_llm_for_profiles: false,
|
||||
parallel_profile_count: 2
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ import {
|
|||
getRunStatus,
|
||||
getRunStatusDetail
|
||||
} from '../api/simulation'
|
||||
import { generateReport } from '../api/report'
|
||||
import { getReportBySimulation } from '../api/report'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
@ -399,15 +399,17 @@ const doStartSimulation = async () => {
|
|||
simulation_id: props.simulationId,
|
||||
platform: 'parallel',
|
||||
force: true, // 强制重新开始
|
||||
enable_graph_memory_update: true // 开启动态图谱更新
|
||||
max_rounds: props.maxRounds || 8,
|
||||
enable_graph_memory_update: false // 现场演示优先保证 OASIS 子进程稳定
|
||||
}
|
||||
|
||||
if (props.maxRounds) {
|
||||
params.max_rounds = props.maxRounds
|
||||
addLog(t('log.setMaxRounds', { rounds: props.maxRounds }))
|
||||
} else {
|
||||
addLog(t('log.setMaxRounds', { rounds: params.max_rounds }))
|
||||
}
|
||||
|
||||
addLog(t('log.graphMemoryUpdateEnabled'))
|
||||
addLog('Demo safe mode: dual-world replay, graph memory update disabled.')
|
||||
|
||||
const res = await startSimulation(params)
|
||||
|
||||
|
|
@ -420,6 +422,12 @@ const doStartSimulation = async () => {
|
|||
|
||||
phase.value = 1
|
||||
runStatus.value = res.data
|
||||
if (res.data.demo_safe_mode) {
|
||||
addLog('Cached dual-world simulation loaded: Info Plaza + Topic Community.')
|
||||
}
|
||||
|
||||
await fetchRunStatusDetail()
|
||||
await fetchRunStatus()
|
||||
|
||||
startStatusPolling()
|
||||
startDetailPolling()
|
||||
|
|
@ -465,6 +473,7 @@ const handleStopSimulation = async () => {
|
|||
// 轮询状态
|
||||
let statusTimer = null
|
||||
let detailTimer = null
|
||||
let autoAdvanceTimer = null
|
||||
|
||||
const startStatusPolling = () => {
|
||||
statusTimer = setInterval(fetchRunStatus, 2000)
|
||||
|
|
@ -483,6 +492,10 @@ const stopPolling = () => {
|
|||
clearInterval(detailTimer)
|
||||
detailTimer = null
|
||||
}
|
||||
if (autoAdvanceTimer) {
|
||||
clearTimeout(autoAdvanceTimer)
|
||||
autoAdvanceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 追踪各平台的上一次轮次,用于检测变化并输出日志
|
||||
|
|
@ -527,10 +540,16 @@ const fetchRunStatus = async () => {
|
|||
stopPolling()
|
||||
emit('update-status', 'completed')
|
||||
|
||||
// 模拟完成后自动触发报告生成
|
||||
setTimeout(() => {
|
||||
const completionDelay = data.cached_case ? 9000 : 1500
|
||||
if (data.cached_case) {
|
||||
addLog(`Cached dual-world result locked: 30 agents · ${data.total_rounds || props.maxRounds || 8} rounds · ${data.total_actions_count || allActions.value.length} actions.`)
|
||||
addLog('Interactive report will open after a short inspection window.')
|
||||
}
|
||||
|
||||
// 模拟完成后自动进入报告页;缓存案例保留现场观察时间
|
||||
autoAdvanceTimer = setTimeout(() => {
|
||||
handleNextStep()
|
||||
}, 1500)
|
||||
}, completionDelay)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -658,27 +677,23 @@ const handleNextStep = async () => {
|
|||
}
|
||||
|
||||
isGeneratingReport.value = true
|
||||
addLog(t('log.startingReportGen'))
|
||||
|
||||
addLog('Opening cached interactive HTML report; live ReportAgent generation is skipped for demo stability.')
|
||||
|
||||
try {
|
||||
const res = await generateReport({
|
||||
simulation_id: props.simulationId,
|
||||
force_regenerate: true
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
const reportId = res.data.report_id
|
||||
addLog(t('log.reportGenTaskStarted', { reportId }))
|
||||
|
||||
// 跳转到报告页面
|
||||
const reportRes = await getReportBySimulation(props.simulationId)
|
||||
const reportId = reportRes?.data?.report_id
|
||||
if (reportRes.success && reportId) {
|
||||
router.push({ name: 'Report', params: { reportId } })
|
||||
} else {
|
||||
addLog(t('log.reportGenFailed', { error: res.error || t('common.unknownError') }))
|
||||
isGeneratingReport.value = false
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
addLog(t('log.reportGenException', { error: err.message }))
|
||||
isGeneratingReport.value = false
|
||||
addLog(`Cached report lookup failed: ${err.message}`)
|
||||
}
|
||||
|
||||
if (props.simulationId === 'sim_nb_hnw_ai_case') {
|
||||
router.push({ name: 'Report', params: { reportId: 'report_nb_hnw_ai_case' } })
|
||||
} else {
|
||||
router.push({ name: 'SimulationReplay', params: { simulationId: props.simulationId }, query: { mode: 'process' } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1269,4 +1284,4 @@ onUnmounted(() => {
|
|||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
v-for="(section, idx) in reportOutline.sections"
|
||||
:key="idx"
|
||||
class="report-section-item"
|
||||
:data-section-index="idx + 1"
|
||||
:class="{
|
||||
'is-citation-highlight': highlightedSectionIndex === idx + 1,
|
||||
'is-active': currentSectionIndex === idx + 1,
|
||||
'is-completed': isSectionCompleted(idx + 1),
|
||||
'is-pending': !isSectionCompleted(idx + 1) && currentSectionIndex !== idx + 1
|
||||
|
|
@ -242,7 +244,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div class="chat-messages" ref="chatMessages">
|
||||
<div class="chat-messages" ref="chatMessages" @click="handleCitationClick">
|
||||
<div v-if="chatHistory.length === 0" class="chat-empty">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
|
|
@ -271,6 +273,20 @@
|
|||
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
|
||||
<div v-if="msg.citations?.length" class="message-citations">
|
||||
<button
|
||||
v-for="citation in msg.citations"
|
||||
:key="citation.id"
|
||||
class="citation-chip"
|
||||
type="button"
|
||||
:data-section-index="citation.section_index"
|
||||
:data-agent-id="citation.agent_id ?? ''"
|
||||
:title="citation.title"
|
||||
@click="jumpToCitation(citation)"
|
||||
>
|
||||
{{ citation.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSending" class="chat-message assistant">
|
||||
|
|
@ -453,6 +469,7 @@ const reportOutline = ref(null)
|
|||
const generatedSections = ref({})
|
||||
const collapsedSections = ref(new Set())
|
||||
const currentSectionIndex = ref(null)
|
||||
const highlightedSectionIndex = ref(null)
|
||||
const profiles = ref([])
|
||||
|
||||
// Helper Methods
|
||||
|
|
@ -480,6 +497,47 @@ const toggleSectionCollapse = (idx) => {
|
|||
collapsedSections.value = newSet
|
||||
}
|
||||
|
||||
const citationTargetSection = (citation) => {
|
||||
if (!citation) return 1
|
||||
const raw = citation.section_index || citation.sectionIndex
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
if (citation.agent_id !== undefined || citation.agentId !== undefined) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
const jumpToSection = (sectionIndex) => {
|
||||
const index = Number(sectionIndex)
|
||||
if (!Number.isFinite(index) || index < 1) return
|
||||
|
||||
const zeroIndex = index - 1
|
||||
const newSet = new Set(collapsedSections.value)
|
||||
newSet.delete(zeroIndex)
|
||||
collapsedSections.value = newSet
|
||||
currentSectionIndex.value = index
|
||||
highlightedSectionIndex.value = index
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const target = leftPanel.value?.querySelector(`[data-section-index="${index}"]`)
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (highlightedSectionIndex.value === index) highlightedSectionIndex.value = null
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
const jumpToCitation = (citation) => {
|
||||
jumpToSection(citationTargetSection(citation))
|
||||
}
|
||||
|
||||
const handleCitationClick = (event) => {
|
||||
const target = event.target?.closest?.('[data-section-index], [data-agent-id]')
|
||||
if (!target) return
|
||||
const sectionIndex = target.dataset.sectionIndex || (target.dataset.agentId ? 2 : 1)
|
||||
jumpToSection(sectionIndex)
|
||||
}
|
||||
|
||||
const selectChatTarget = (target) => {
|
||||
chatTarget.value = target
|
||||
if (target === 'report_agent') {
|
||||
|
|
@ -560,6 +618,13 @@ const renderMarkdown = (content) => {
|
|||
let processedContent = content.replace(/^##\s+.+\n+/, '')
|
||||
let html = processedContent.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="code-block"><code>$2</code></pre>')
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
html = html.replace(/\[\[(S|A)(\d{1,2})\]\]/g, (match, type, number) => {
|
||||
const value = String(Number(number)).padStart(2, '0')
|
||||
const sectionIndex = type === 'S' ? Number(number) : 2
|
||||
const agentId = type === 'A' ? Number(number) : ''
|
||||
const title = type === 'S' ? `跳转到报告第 ${value} 节` : `跳转到 Agent ${value} 问答证据`
|
||||
return `<button type="button" class="citation-chip citation-inline" data-section-index="${sectionIndex}" data-agent-id="${agentId}" title="${title}">${type}${value}</button>`
|
||||
})
|
||||
html = html.replace(/^#### (.+)$/gm, '<h5 class="md-h5">$1</h5>')
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>')
|
||||
|
|
@ -701,6 +766,7 @@ const sendToReportAgent = async (message) => {
|
|||
chatHistory.value.push({
|
||||
role: 'assistant',
|
||||
content: res.data.response || res.data.answer || t('step5.noResponse'),
|
||||
citations: res.data.citations || [],
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
addLog(t('log.reportAgentReplied'))
|
||||
|
|
@ -1089,6 +1155,13 @@ watch(() => props.simulationId, (newId) => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.report-section-item.is-citation-highlight {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.26), 0 12px 30px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
|
|
@ -2067,6 +2140,45 @@ watch(() => props.simulationId, (newId) => {
|
|||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.message-citations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.citation-chip,
|
||||
.message-text :deep(.citation-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 20px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: 999px;
|
||||
background: #FFFFFF;
|
||||
color: #111827;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.message-text :deep(.citation-inline) {
|
||||
margin: 0 2px;
|
||||
vertical-align: 0.12em;
|
||||
}
|
||||
|
||||
.citation-chip:hover,
|
||||
.message-text :deep(.citation-chip:hover) {
|
||||
background: #ECFDF5;
|
||||
border-color: #10B981;
|
||||
color: #047857;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
|
|
@ -2581,4 +2693,23 @@ watch(() => props.simulationId, (newId) => {
|
|||
html[lang="en"] .report-header-block .main-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .citation-chip,
|
||||
html[data-theme='dark'] .message-text .citation-chip {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #F9FAFB;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .citation-chip:hover,
|
||||
html[data-theme='dark'] .message-text .citation-chip:hover {
|
||||
background: #064E3B;
|
||||
border-color: #34D399;
|
||||
color: #D1FAE5;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .report-section-item.is-citation-highlight {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.32), 0 12px 30px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@
|
|||
<div class="hero-right">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="logo-container">
|
||||
<img src="../assets/logo/foresight_logo.jpeg" alt="Foresight 先见之明 Logo" class="hero-logo" />
|
||||
<img src="../assets/logo/foresight_logo.jpeg" alt="Foresight 先见之明 Logo" class="hero-logo hero-logo-light" />
|
||||
<img src="../assets/logo/foresight_logo_dark.png" alt="Foresight 先见之明 Logo" class="hero-logo hero-logo-dark" />
|
||||
</div>
|
||||
|
||||
<button class="scroll-down-btn" @click="scrollToBottom">
|
||||
|
|
@ -546,6 +547,18 @@ const startSimulation = () => {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-logo-dark {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .hero-logo-light {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .hero-logo-dark {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.scroll-down-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="title-main">Foresight's Sandbox</div>
|
||||
<div class="title-sub">
|
||||
<span v-if="currentAction">
|
||||
Foresight is running
|
||||
Foresight is {{ isSimLive ? 'running' : 'replaying' }}
|
||||
<span class="platform-tag" :class="currentAction.platform">
|
||||
{{ currentAction.platform === 'twitter' ? 'Twitter' : 'Reddit' }}
|
||||
</span>
|
||||
|
|
@ -289,6 +289,7 @@ const isPlaying = ref(false)
|
|||
const speed = ref(1)
|
||||
const feedListRef = ref(null)
|
||||
const analystMode = ref(false)
|
||||
const initializedViewMode = ref(false)
|
||||
|
||||
// 自动轮询:如果 sim 还在 running,每 10s 重新拉数据
|
||||
let pollTimer = null
|
||||
|
|
@ -437,8 +438,13 @@ async function loadReplay(silent = false) {
|
|||
const wasAtLive = currentActionIndex.value >= prevLen - 1
|
||||
replayData.value = res.data
|
||||
const newLen = allActions.value.length
|
||||
// 初次加载或之前处于 live 状态时,自动跳到最新
|
||||
if (!silent || wasAtLive) {
|
||||
|
||||
if (!silent && !initializedViewMode.value && res.data?.aggregate?.cached_case) {
|
||||
analystMode.value = true
|
||||
currentActionIndex.value = 0
|
||||
initializedViewMode.value = true
|
||||
} else if (!silent || wasAtLive) {
|
||||
// 初次加载或之前处于 live 状态时,自动跳到最新
|
||||
currentActionIndex.value = Math.max(0, newLen - 1)
|
||||
}
|
||||
loadError.value = null
|
||||
|
|
@ -510,6 +516,11 @@ watch(speed, () => {
|
|||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (simulationId === 'sim_nb_hnw_ai_case' && route.query.mode !== 'process') {
|
||||
router.replace({ name: 'Report', params: { reportId: 'report_nb_hnw_ai_case' } })
|
||||
return
|
||||
}
|
||||
|
||||
loadReplay(false).then(() => startPolling())
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,8 @@
|
|||
"graphBuild": "Graph Build",
|
||||
"envSetup": "Env Setup",
|
||||
"analysisReport": "Analysis Report",
|
||||
"cachedReplay": "Completed Replay",
|
||||
"viewReplay": "View Replay",
|
||||
"moreFiles": "+{count} files",
|
||||
"noFiles": "No files",
|
||||
"loadingText": "Loading...",
|
||||
|
|
@ -315,11 +317,19 @@
|
|||
"step1Button": "Graph Build",
|
||||
"step2Button": "Env Setup",
|
||||
"step4Button": "Analysis Report",
|
||||
"replayHint": "Step 3 'Run Simulation' and Step 5 'Deep Interaction' must be started during runtime and do not support history replay",
|
||||
"replayHint": "Completed cases can be replayed step by step in real pages; deep interaction still requires a live run",
|
||||
"notStarted": "Not started",
|
||||
"roundsProgress": "{current}/{total} rounds",
|
||||
"untitledSimulation": "Untitled simulation",
|
||||
"unknownFile": "Unknown file"
|
||||
"unknownFile": "Unknown file",
|
||||
"deleteAction": "Cancel this simulation result",
|
||||
"deleteKicker": "Cancel Simulation",
|
||||
"deleteTitle": "Cancel this simulation result?",
|
||||
"deleteDescription": "After confirmation, {id} will be removed from the history list. This action cannot be undone from the page.",
|
||||
"deleteCancel": "Keep it",
|
||||
"deleteConfirm": "Confirm cancel",
|
||||
"deleting": "Cancelling...",
|
||||
"deleteFailed": "Cancel failed. Please try again."
|
||||
},
|
||||
"api": {
|
||||
"projectNotFound": "Project not found: {id}",
|
||||
|
|
|
|||
|
|
@ -305,6 +305,8 @@
|
|||
"graphBuild": "图谱构建",
|
||||
"envSetup": "环境搭建",
|
||||
"analysisReport": "分析报告",
|
||||
"cachedReplay": "已完成回放",
|
||||
"viewReplay": "查看回放",
|
||||
"moreFiles": "+{count} 个文件",
|
||||
"noFiles": "暂无文件",
|
||||
"loadingText": "加载中...",
|
||||
|
|
@ -315,11 +317,19 @@
|
|||
"step1Button": "图谱构建",
|
||||
"step2Button": "环境搭建",
|
||||
"step4Button": "分析报告",
|
||||
"replayHint": "Step3「开始模拟」与 Step5「深度互动」需在运行中启动,不支持历史回放",
|
||||
"replayHint": "已完成案例可在真实页面中逐步回放;深度互动仍需在运行中启动",
|
||||
"notStarted": "未开始",
|
||||
"roundsProgress": "{current}/{total} 轮",
|
||||
"untitledSimulation": "未命名模拟",
|
||||
"unknownFile": "未知文件"
|
||||
"unknownFile": "未知文件",
|
||||
"deleteAction": "取消这个模拟结果",
|
||||
"deleteKicker": "取消模拟结果",
|
||||
"deleteTitle": "确认取消这个模拟结果?",
|
||||
"deleteDescription": "确认后将从推演记录中删除 {id},这个操作不能在页面里撤销。",
|
||||
"deleteCancel": "先保留",
|
||||
"deleteConfirm": "确认取消",
|
||||
"deleting": "取消中...",
|
||||
"deleteFailed": "取消失败,请稍后重试"
|
||||
},
|
||||
"api": {
|
||||
"projectNotFound": "项目不存在: {id}",
|
||||
|
|
|
|||
|
|
@ -31,9 +31,14 @@ set -euo pipefail
|
|||
SSH_KEY="/Users/liyizhouai/Desktop/openclaw/船长/liyizhouAI.pem"
|
||||
SSH_HOST="ubuntu@124.223.92.72"
|
||||
REMOTE_BACKEND="/opt/foresight/backend"
|
||||
REMOTE_FRONTEND="/opt/foresight/frontend"
|
||||
API_HEALTH_URL="https://api.foresight.yizhou.chat/health"
|
||||
PROD_URL="https://foresight.yizhou.chat"
|
||||
COS_BUCKET="foresight-1317962478"
|
||||
FRONTEND_API_BASE_URL="${VITE_API_BASE_URL:-https://api.foresight.yizhou.chat}"
|
||||
FRONTEND_DEMO_PASSWORD="${VITE_DEMO_PASSWORD:-}"
|
||||
FRONTEND_DEPLOY_TARGET="${FRONTEND_DEPLOY_TARGET:-server}"
|
||||
CDN_ORIGIN_IP="${CDN_ORIGIN_IP:-124.223.92.72}"
|
||||
|
||||
# 定位项目根目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
|
@ -100,10 +105,16 @@ preflight() {
|
|||
ok "SSH 可达 $SSH_HOST"
|
||||
|
||||
if $DEPLOY_FRONTEND; then
|
||||
command -v coscmd >/dev/null 2>&1 || fail "coscmd 未安装 (pip install coscmd)"
|
||||
ok "coscmd 可用"
|
||||
[[ -f ~/.cos.conf ]] || fail "~/.cos.conf 不存在"
|
||||
ok "~/.cos.conf 存在"
|
||||
if [[ "$FRONTEND_DEPLOY_TARGET" == "cos" ]]; then
|
||||
command -v coscmd >/dev/null 2>&1 || fail "coscmd 未安装 (pip install coscmd)"
|
||||
ok "coscmd 可用"
|
||||
[[ -f ~/.cos.conf ]] || fail "~/.cos.conf 不存在"
|
||||
ok "~/.cos.conf 存在"
|
||||
elif [[ "$FRONTEND_DEPLOY_TARGET" == "server" ]]; then
|
||||
ok "前端部署目标: server ($SSH_HOST:$REMOTE_FRONTEND)"
|
||||
else
|
||||
fail "未知 FRONTEND_DEPLOY_TARGET: $FRONTEND_DEPLOY_TARGET (支持 server/cos)"
|
||||
fi
|
||||
command -v tccli >/dev/null 2>&1 || warn "tccli 未安装,跳过 CDN 自动刷新"
|
||||
fi
|
||||
|
||||
|
|
@ -186,7 +197,7 @@ restart_flask() {
|
|||
|
||||
log "启动新 Flask..."
|
||||
ssh -i "$SSH_KEY" "$SSH_HOST" '
|
||||
sudo -u ubuntu bash -c "cd /opt/foresight/backend && nohup ./.venv-311/bin/python run.py --host 0.0.0.0 >> logs/server.log 2>&1 < /dev/null & disown"
|
||||
sudo -u ubuntu bash -lc "cd /opt/foresight/backend && setsid ./.venv-311/bin/python run.py --host 0.0.0.0 >> logs/server.log 2>&1 < /dev/null &"
|
||||
'
|
||||
sleep 4
|
||||
|
||||
|
|
@ -214,14 +225,41 @@ deploy_frontend() {
|
|||
if $DRY_RUN; then
|
||||
warn "dry-run: 跳过 vite build"
|
||||
else
|
||||
( cd "$LOCAL_FRONTEND" && npx vite build 2>&1 | tail -10 ) \
|
||||
( cd "$LOCAL_FRONTEND" && \
|
||||
VITE_API_BASE_URL="$FRONTEND_API_BASE_URL" \
|
||||
VITE_DEMO_PASSWORD="$FRONTEND_DEMO_PASSWORD" \
|
||||
npx vite build 2>&1 | tail -10 ) \
|
||||
|| fail "vite build 失败"
|
||||
fi
|
||||
ok "构建完成"
|
||||
|
||||
log "coscmd upload dist/ → cos://$COS_BUCKET/"
|
||||
maybe bash -c "cd '$LOCAL_FRONTEND' && coscmd upload -r dist/ / --ignore .DS_Store" 2>&1 | tail -15
|
||||
ok "已上传到 COS"
|
||||
if [[ "$FRONTEND_DEPLOY_TARGET" == "server" ]]; then
|
||||
log "rsync frontend/dist → $SSH_HOST:$REMOTE_FRONTEND"
|
||||
maybe rsync -az --delete \
|
||||
--exclude '.DS_Store' \
|
||||
-e "ssh -i $SSH_KEY" \
|
||||
"$LOCAL_FRONTEND/dist/" \
|
||||
"$SSH_HOST:/tmp/foresight-frontend-dist/"
|
||||
|
||||
maybe ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"sudo mkdir -p '$REMOTE_FRONTEND' && sudo rsync -az --delete /tmp/foresight-frontend-dist/ '$REMOTE_FRONTEND'/ && sudo chown -R www-data:www-data '$REMOTE_FRONTEND'"
|
||||
ok "已上传到云服务器静态目录"
|
||||
|
||||
if command -v tccli >/dev/null 2>&1 && ! $DRY_RUN; then
|
||||
log "确认 CDN 回源为云服务器..."
|
||||
tccli cdn UpdateDomainConfig --cli-unfold-argument \
|
||||
--Domain "${PROD_URL#https://}" \
|
||||
--Origin.Origins "$CDN_ORIGIN_IP" \
|
||||
--Origin.OriginType ip \
|
||||
--Origin.ServerName "${PROD_URL#https://}" \
|
||||
--Origin.OriginPullProtocol http >/dev/null
|
||||
ok "CDN 回源: $CDN_ORIGIN_IP"
|
||||
fi
|
||||
else
|
||||
log "coscmd upload dist/ → cos://$COS_BUCKET/"
|
||||
maybe bash -c "cd '$LOCAL_FRONTEND' && coscmd upload -r dist/ / --ignore .DS_Store" 2>&1 | tail -15
|
||||
ok "已上传到 COS"
|
||||
fi
|
||||
|
||||
# CDN 刷新 (可选)
|
||||
if command -v tccli >/dev/null 2>&1; then
|
||||
|
|
|
|||
Loading…
Reference in New Issue