Enhance Foresight demo replay and evidence citations

This commit is contained in:
liyizhouAI 2026-06-08 19:09:16 +08:00
parent 91a621be96
commit a6a12d7775
29 changed files with 3692 additions and 137 deletions

View File

@ -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,

View File

@ -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})

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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"""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -0,0 +1,9 @@
# Foresight Live Demo Seed
2026 年AI 数据中心继续扩张,制造企业、电力设备商、园区运营方和银行客户经理都在重新评估机会。
核心变量包括 GPU 服务器采购、液冷改造、电网接入周期、企业现金流压力、绿色信贷额度、供应链付款周期和监管要求。
宁波本地一家高端装备制造企业正在考虑承接数据中心液冷组件订单,但它面临原材料价格波动、账期拉长、设备扩产资金缺口和技术人员招聘压力。银行希望识别可介入的服务点,包括流动资金贷款、设备融资租赁、订单融资、绿色信贷、结算服务和企业主财富管理。
演示目标:推演未来 12 个月 AI 算力扩张对制造业、数据中心、电力设备和银行服务机会的连锁影响,输出关键变量、机会情景、风险预警和客户拜访切入点。

View File

@ -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/
- 同域 APIhttp://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` 覆盖。

View File

@ -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;

View File

@ -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'

View File

@ -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

View File

@ -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>

View File

@ -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
})

View File

@ -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) {

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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())
})

View File

@ -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}",

View File

@ -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}",

View File

@ -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