666ghj commited on
Commit
f216333
·
1 Parent(s): a0df851

Enhance simulation functionality and frontend components for improved user experience

Browse files

- Updated the simulation API to include a new 'force' parameter, allowing users to restart simulations while cleaning up previous logs.
- Enhanced the simulation status detail retrieval to include all actions and platform-specific actions for better monitoring.
- Introduced a new SimulationRunView component to manage the simulation process, providing a clear interface for users to start and monitor simulations.
- Improved the Step3Simulation component with detailed logging and progress indicators, ensuring users receive real-time updates during the simulation.
- Added new API endpoints for retrieving simulation posts and actions, enhancing the overall functionality and user engagement.

backend/app/api/simulation.py CHANGED
@@ -1274,21 +1274,28 @@ def generate_profiles():
1274
  def start_simulation():
1275
  """
1276
  开始运行模拟
1277
-
1278
  请求(JSON):
1279
  {
1280
  "simulation_id": "sim_xxxx", // 必填,模拟ID
1281
  "platform": "parallel", // 可选: twitter / reddit / parallel (默认)
1282
  "max_rounds": 100, // 可选: 最大模拟轮数,用于截断过长的模拟
1283
- "enable_graph_memory_update": false // 可选: 是否将Agent活动动态更新到Zep图谱记忆
 
1284
  }
1285
-
 
 
 
 
 
 
1286
  关于 enable_graph_memory_update:
1287
  - 启用后,模拟中所有Agent的活动(发帖、评论、点赞等)都会实时更新到Zep图谱
1288
  - 这可以让图谱"记住"模拟过程,用于后续分析或AI对话
1289
  - 需要模拟关联的项目有有效的 graph_id
1290
  - 采用批量更新机制,减少API调用次数
1291
-
1292
  返回:
1293
  {
1294
  "success": true,
@@ -1299,24 +1306,26 @@ def start_simulation():
1299
  "twitter_running": true,
1300
  "reddit_running": true,
1301
  "started_at": "2025-12-01T10:00:00",
1302
- "graph_memory_update_enabled": true // 是否启用了图谱记忆更新
 
1303
  }
1304
  }
1305
  """
1306
  try:
1307
  data = request.get_json() or {}
1308
-
1309
  simulation_id = data.get('simulation_id')
1310
  if not simulation_id:
1311
  return jsonify({
1312
  "success": False,
1313
  "error": "请提供 simulation_id"
1314
  }), 400
1315
-
1316
  platform = data.get('platform', 'parallel')
1317
  max_rounds = data.get('max_rounds') # 可选:最大模拟轮数
1318
  enable_graph_memory_update = data.get('enable_graph_memory_update', False) # 可选:是否启用图谱记忆更新
1319
-
 
1320
  # 验证 max_rounds 参数
1321
  if max_rounds is not None:
1322
  try:
@@ -1331,28 +1340,30 @@ def start_simulation():
1331
  "success": False,
1332
  "error": "max_rounds 必须是有效的整数"
1333
  }), 400
1334
-
1335
  if platform not in ['twitter', 'reddit', 'parallel']:
1336
  return jsonify({
1337
  "success": False,
1338
  "error": f"无效的平台类型: {platform},可选: twitter/reddit/parallel"
1339
  }), 400
1340
-
1341
  # 检查模拟是否已准备好
1342
  manager = SimulationManager()
1343
  state = manager.get_simulation(simulation_id)
1344
-
1345
  if not state:
1346
  return jsonify({
1347
  "success": False,
1348
  "error": f"模拟不存在: {simulation_id}"
1349
  }), 404
 
 
1350
 
1351
  # 智能处理状态:如果准备工作已完成,允许重新启动
1352
  if state.status != SimulationStatus.READY:
1353
  # 检查准备工作是否已完成
1354
  is_prepared, prepare_info = _check_simulation_prepared(simulation_id)
1355
-
1356
  if is_prepared:
1357
  # 准备工作已完成,检查是否有正在运行的进程
1358
  if state.status == SimulationStatus.RUNNING:
@@ -1360,11 +1371,27 @@ def start_simulation():
1360
  run_state = SimulationRunner.get_run_state(simulation_id)
1361
  if run_state and run_state.runner_status.value == "running":
1362
  # 进程确实在运行
1363
- return jsonify({
1364
- "success": False,
1365
- "error": f"模拟正在运行中,请先调用 /stop 接口停止"
1366
- }), 400
1367
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1368
  # 进程不存在或已结束,重置状态为 ready
1369
  logger.info(f"模拟 {simulation_id} 准备工作已完成,重置状态为 ready(原状态: {state.status.value})")
1370
  state.status = SimulationStatus.READY
@@ -1412,6 +1439,7 @@ def start_simulation():
1412
  if max_rounds:
1413
  response_data['max_rounds_applied'] = max_rounds
1414
  response_data['graph_memory_update_enabled'] = enable_graph_memory_update
 
1415
  if enable_graph_memory_update:
1416
  response_data['graph_id'] = graph_id
1417
 
@@ -1557,10 +1585,13 @@ def get_run_status(simulation_id: str):
1557
  @simulation_bp.route('/<simulation_id>/run-status/detail', methods=['GET'])
1558
  def get_run_status_detail(simulation_id: str):
1559
  """
1560
- 获取模拟运行详细状态(包含最近动作)
1561
 
1562
  用于前端展示实时动态
1563
 
 
 
 
1564
  返回:
1565
  {
1566
  "success": true,
@@ -1569,7 +1600,7 @@ def get_run_status_detail(simulation_id: str):
1569
  "runner_status": "running",
1570
  "current_round": 5,
1571
  ...
1572
- "recent_actions": [
1573
  {
1574
  "round_num": 5,
1575
  "timestamp": "2025-12-01T10:30:00",
@@ -1582,12 +1613,15 @@ def get_run_status_detail(simulation_id: str):
1582
  "success": true
1583
  },
1584
  ...
1585
- ]
 
 
1586
  }
1587
  }
1588
  """
1589
  try:
1590
  run_state = SimulationRunner.get_run_state(simulation_id)
 
1591
 
1592
  if not run_state:
1593
  return jsonify({
@@ -1595,13 +1629,49 @@ def get_run_status_detail(simulation_id: str):
1595
  "data": {
1596
  "simulation_id": simulation_id,
1597
  "runner_status": "idle",
1598
- "recent_actions": []
 
 
1599
  }
1600
  })
1601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1602
  return jsonify({
1603
  "success": True,
1604
- "data": run_state.to_detail_dict()
1605
  })
1606
 
1607
  except Exception as e:
 
1274
  def start_simulation():
1275
  """
1276
  开始运行模拟
1277
+
1278
  请求(JSON):
1279
  {
1280
  "simulation_id": "sim_xxxx", // 必填,模拟ID
1281
  "platform": "parallel", // 可选: twitter / reddit / parallel (默认)
1282
  "max_rounds": 100, // 可选: 最大模拟轮数,用于截断过长的模拟
1283
+ "enable_graph_memory_update": false, // 可选: 是否将Agent活动动态更新到Zep图谱记忆
1284
+ "force": false // 可选: 强制重新开始(会停止运行中的模拟并清理日志)
1285
  }
1286
+
1287
+ 关于 force 参数:
1288
+ - 启用后,如果模拟正在运行或已完成,会先停止并清理运行日志
1289
+ - 清理的内容包括:run_state.json, actions.jsonl, simulation.log 等
1290
+ - 不会清理配置文件(simulation_config.json)和 profile 文件
1291
+ - 适用于需要重新运行模拟的场景
1292
+
1293
  关于 enable_graph_memory_update:
1294
  - 启用后,模拟中所有Agent的活动(发帖、评论、点赞等)都会实时更新到Zep图谱
1295
  - 这可以让图谱"记住"模拟过程,用于后续分析或AI对话
1296
  - 需要模拟关联的项目有有效的 graph_id
1297
  - 采用批量更新机制,减少API调用次数
1298
+
1299
  返回:
1300
  {
1301
  "success": true,
 
1306
  "twitter_running": true,
1307
  "reddit_running": true,
1308
  "started_at": "2025-12-01T10:00:00",
1309
+ "graph_memory_update_enabled": true, // 是否启用了图谱记忆更新
1310
+ "force_restarted": true // 是否是强制重新开始
1311
  }
1312
  }
1313
  """
1314
  try:
1315
  data = request.get_json() or {}
1316
+
1317
  simulation_id = data.get('simulation_id')
1318
  if not simulation_id:
1319
  return jsonify({
1320
  "success": False,
1321
  "error": "请提供 simulation_id"
1322
  }), 400
1323
+
1324
  platform = data.get('platform', 'parallel')
1325
  max_rounds = data.get('max_rounds') # 可选:最大模拟轮数
1326
  enable_graph_memory_update = data.get('enable_graph_memory_update', False) # 可选:是否启用图谱记忆更新
1327
+ force = data.get('force', False) # 可选:强制重新开始
1328
+
1329
  # 验证 max_rounds 参数
1330
  if max_rounds is not None:
1331
  try:
 
1340
  "success": False,
1341
  "error": "max_rounds 必须是有效的整数"
1342
  }), 400
1343
+
1344
  if platform not in ['twitter', 'reddit', 'parallel']:
1345
  return jsonify({
1346
  "success": False,
1347
  "error": f"无效的平台类型: {platform},可选: twitter/reddit/parallel"
1348
  }), 400
1349
+
1350
  # 检查模拟是否已准备好
1351
  manager = SimulationManager()
1352
  state = manager.get_simulation(simulation_id)
1353
+
1354
  if not state:
1355
  return jsonify({
1356
  "success": False,
1357
  "error": f"模拟不存在: {simulation_id}"
1358
  }), 404
1359
+
1360
+ force_restarted = False
1361
 
1362
  # 智能处理状态:如果准备工作已完成,允许重新启动
1363
  if state.status != SimulationStatus.READY:
1364
  # 检查准备工作是否已完成
1365
  is_prepared, prepare_info = _check_simulation_prepared(simulation_id)
1366
+
1367
  if is_prepared:
1368
  # 准备工作已完成,检查是否有正在运行的进程
1369
  if state.status == SimulationStatus.RUNNING:
 
1371
  run_state = SimulationRunner.get_run_state(simulation_id)
1372
  if run_state and run_state.runner_status.value == "running":
1373
  # 进程确实在运行
1374
+ if force:
1375
+ # 强制模式:停止运行中的模拟
1376
+ logger.info(f"强制式:停止运行中的模拟 {simulation_id}")
1377
+ try:
1378
+ SimulationRunner.stop_simulation(simulation_id)
1379
+ except Exception as e:
1380
+ logger.warning(f"停止模拟时出现警告: {str(e)}")
1381
+ else:
1382
+ return jsonify({
1383
+ "success": False,
1384
+ "error": f"模拟正在运行中,请先调用 /stop 接口停止,或使用 force=true 强制重新开始"
1385
+ }), 400
1386
+
1387
+ # 如果是强制模式,清理运行日志
1388
+ if force:
1389
+ logger.info(f"强制模式:清理模拟日志 {simulation_id}")
1390
+ cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id)
1391
+ if not cleanup_result.get("success"):
1392
+ logger.warning(f"清理日志时出现警告: {cleanup_result.get('errors')}")
1393
+ force_restarted = True
1394
+
1395
  # 进程不存在或已结束,重置状态为 ready
1396
  logger.info(f"模拟 {simulation_id} 准备工作已完成,重置状态为 ready(原状态: {state.status.value})")
1397
  state.status = SimulationStatus.READY
 
1439
  if max_rounds:
1440
  response_data['max_rounds_applied'] = max_rounds
1441
  response_data['graph_memory_update_enabled'] = enable_graph_memory_update
1442
+ response_data['force_restarted'] = force_restarted
1443
  if enable_graph_memory_update:
1444
  response_data['graph_id'] = graph_id
1445
 
 
1585
  @simulation_bp.route('/<simulation_id>/run-status/detail', methods=['GET'])
1586
  def get_run_status_detail(simulation_id: str):
1587
  """
1588
+ 获取模拟运行详细状态(包含所有动作)
1589
 
1590
  用于前端展示实时动态
1591
 
1592
+ Query参数:
1593
+ platform: 过滤平台(twitter/reddit,可选)
1594
+
1595
  返回:
1596
  {
1597
  "success": true,
 
1600
  "runner_status": "running",
1601
  "current_round": 5,
1602
  ...
1603
+ "all_actions": [
1604
  {
1605
  "round_num": 5,
1606
  "timestamp": "2025-12-01T10:30:00",
 
1613
  "success": true
1614
  },
1615
  ...
1616
+ ],
1617
+ "twitter_actions": [...], # Twitter 平台的所有动作
1618
+ "reddit_actions": [...] # Reddit 平台的所有动作
1619
  }
1620
  }
1621
  """
1622
  try:
1623
  run_state = SimulationRunner.get_run_state(simulation_id)
1624
+ platform_filter = request.args.get('platform')
1625
 
1626
  if not run_state:
1627
  return jsonify({
 
1629
  "data": {
1630
  "simulation_id": simulation_id,
1631
  "runner_status": "idle",
1632
+ "all_actions": [],
1633
+ "twitter_actions": [],
1634
+ "reddit_actions": []
1635
  }
1636
  })
1637
 
1638
+ # 获取完整的动作列表
1639
+ all_actions = SimulationRunner.get_all_actions(
1640
+ simulation_id=simulation_id,
1641
+ platform=platform_filter
1642
+ )
1643
+
1644
+ # 分平台获取动作
1645
+ twitter_actions = SimulationRunner.get_all_actions(
1646
+ simulation_id=simulation_id,
1647
+ platform="twitter"
1648
+ ) if not platform_filter or platform_filter == "twitter" else []
1649
+
1650
+ reddit_actions = SimulationRunner.get_all_actions(
1651
+ simulation_id=simulation_id,
1652
+ platform="reddit"
1653
+ ) if not platform_filter or platform_filter == "reddit" else []
1654
+
1655
+ # 获取当前轮次的动作(recent_actions 只展示最新一轮)
1656
+ current_round = run_state.current_round
1657
+ recent_actions = SimulationRunner.get_all_actions(
1658
+ simulation_id=simulation_id,
1659
+ platform=platform_filter,
1660
+ round_num=current_round
1661
+ ) if current_round > 0 else []
1662
+
1663
+ # 获取基础状态信息
1664
+ result = run_state.to_dict()
1665
+ result["all_actions"] = [a.to_dict() for a in all_actions]
1666
+ result["twitter_actions"] = [a.to_dict() for a in twitter_actions]
1667
+ result["reddit_actions"] = [a.to_dict() for a in reddit_actions]
1668
+ result["rounds_count"] = len(run_state.rounds)
1669
+ # recent_actions 只展示当前最新一轮两个平台的内容
1670
+ result["recent_actions"] = [a.to_dict() for a in recent_actions]
1671
+
1672
  return jsonify({
1673
  "success": True,
1674
+ "data": result
1675
  })
1676
 
1677
  except Exception as e:
backend/app/services/oasis_profile_generator.py CHANGED
@@ -61,7 +61,7 @@ class OasisAgentProfile:
61
  """转换为Reddit平台格式"""
62
  profile = {
63
  "user_id": self.user_id,
64
- "user_name": self.user_name,
65
  "name": self.name,
66
  "bio": self.bio,
67
  "persona": self.persona,
@@ -89,7 +89,7 @@ class OasisAgentProfile:
89
  """转换为Twitter平台格式"""
90
  profile = {
91
  "user_id": self.user_id,
92
- "user_name": self.user_name,
93
  "name": self.name,
94
  "bio": self.bio,
95
  "persona": self.persona,
 
61
  """转换为Reddit平台格式"""
62
  profile = {
63
  "user_id": self.user_id,
64
+ "username": self.user_name, # OASIS 库要求字段名为 username(无下划线)
65
  "name": self.name,
66
  "bio": self.bio,
67
  "persona": self.persona,
 
89
  """转换为Twitter平台格式"""
90
  profile = {
91
  "user_id": self.user_id,
92
+ "username": self.user_name, # OASIS 库要求字段名为 username(无下划线)
93
  "name": self.name,
94
  "bio": self.bio,
95
  "persona": self.persona,
backend/app/services/simulation_runner.py CHANGED
@@ -671,38 +671,30 @@ class SimulationRunner:
671
  return state
672
 
673
  @classmethod
674
- def get_actions(
675
  cls,
676
- simulation_id: str,
677
- limit: int = 100,
678
- offset: int = 0,
679
- platform: Optional[str] = None,
680
  agent_id: Optional[int] = None,
681
  round_num: Optional[int] = None
682
  ) -> List[AgentAction]:
683
  """
684
- 取动作历史
685
 
686
  Args:
687
- simulation_id: 模拟ID
688
- limit: 返回数量限制
689
- offset: 偏移量
690
- platform: 过滤平台
691
- agent_id: 过滤Agent
692
  round_num: 过滤轮次
693
-
694
- Returns:
695
- 动作列表
696
  """
697
- sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
698
- actions_log = os.path.join(sim_dir, "actions.jsonl")
699
-
700
- if not os.path.exists(actions_log):
701
  return []
702
 
703
  actions = []
704
 
705
- with open(actions_log, 'r', encoding='utf-8') as f:
706
  for line in f:
707
  line = line.strip()
708
  if not line:
@@ -711,8 +703,19 @@ class SimulationRunner:
711
  try:
712
  data = json.loads(line)
713
 
 
 
 
 
 
 
 
 
 
 
 
714
  # 过滤
715
- if platform and data.get("platform") != platform:
716
  continue
717
  if agent_id is not None and data.get("agent_id") != agent_id:
718
  continue
@@ -722,7 +725,7 @@ class SimulationRunner:
722
  actions.append(AgentAction(
723
  round_num=data.get("round", 0),
724
  timestamp=data.get("timestamp", ""),
725
- platform=data.get("platform", ""),
726
  agent_id=data.get("agent_id", 0),
727
  agent_name=data.get("agent_name", ""),
728
  action_type=data.get("action_type", ""),
@@ -734,8 +737,99 @@ class SimulationRunner:
734
  except json.JSONDecodeError:
735
  continue
736
 
737
- # 按时间倒序排列
738
- actions.reverse()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
 
740
  # 分页
741
  return actions[offset:offset + limit]
@@ -854,6 +948,81 @@ class SimulationRunner:
854
 
855
  return result
856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  # 防止重复清理的标志
858
  _cleanup_done = False
859
 
 
671
  return state
672
 
673
  @classmethod
674
+ def _read_actions_from_file(
675
  cls,
676
+ file_path: str,
677
+ default_platform: Optional[str] = None,
678
+ platform_filter: Optional[str] = None,
 
679
  agent_id: Optional[int] = None,
680
  round_num: Optional[int] = None
681
  ) -> List[AgentAction]:
682
  """
683
+ 从单个动作文件中读取动作
684
 
685
  Args:
686
+ file_path: 动作日志文件路径
687
+ default_platform: 默认平台(当动作记录中没有 platform 字段时使用)
688
+ platform_filter: 过滤平台
689
+ agent_id: 过滤 Agent ID
 
690
  round_num: 过滤轮次
 
 
 
691
  """
692
+ if not os.path.exists(file_path):
 
 
 
693
  return []
694
 
695
  actions = []
696
 
697
+ with open(file_path, 'r', encoding='utf-8') as f:
698
  for line in f:
699
  line = line.strip()
700
  if not line:
 
703
  try:
704
  data = json.loads(line)
705
 
706
+ # 跳过非动作记录(如 simulation_start, round_start, round_end 等事件)
707
+ if "event_type" in data:
708
+ continue
709
+
710
+ # 跳过没有 agent_id 的记录(非 Agent 动作)
711
+ if "agent_id" not in data:
712
+ continue
713
+
714
+ # 获取平台:优先使用记录中的 platform,否则使用默认平台
715
+ record_platform = data.get("platform") or default_platform or ""
716
+
717
  # 过滤
718
+ if platform_filter and record_platform != platform_filter:
719
  continue
720
  if agent_id is not None and data.get("agent_id") != agent_id:
721
  continue
 
725
  actions.append(AgentAction(
726
  round_num=data.get("round", 0),
727
  timestamp=data.get("timestamp", ""),
728
+ platform=record_platform,
729
  agent_id=data.get("agent_id", 0),
730
  agent_name=data.get("agent_name", ""),
731
  action_type=data.get("action_type", ""),
 
737
  except json.JSONDecodeError:
738
  continue
739
 
740
+ return actions
741
+
742
+ @classmethod
743
+ def get_all_actions(
744
+ cls,
745
+ simulation_id: str,
746
+ platform: Optional[str] = None,
747
+ agent_id: Optional[int] = None,
748
+ round_num: Optional[int] = None
749
+ ) -> List[AgentAction]:
750
+ """
751
+ 获取所有平台的完整动作历史(无分页限制)
752
+
753
+ Args:
754
+ simulation_id: 模拟ID
755
+ platform: 过滤平台(twitter/reddit)
756
+ agent_id: 过滤Agent
757
+ round_num: 过滤轮次
758
+
759
+ Returns:
760
+ 完整的动作列表(按时间戳排序,新的在前)
761
+ """
762
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
763
+ actions = []
764
+
765
+ # 读取 Twitter 动作文件(根据文件路径自动设置 platform 为 twitter)
766
+ twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
767
+ if not platform or platform == "twitter":
768
+ actions.extend(cls._read_actions_from_file(
769
+ twitter_actions_log,
770
+ default_platform="twitter", # 自动填充 platform 字段
771
+ platform_filter=platform,
772
+ agent_id=agent_id,
773
+ round_num=round_num
774
+ ))
775
+
776
+ # 读取 Reddit 动作文件(根据文件路径自动设置 platform 为 reddit)
777
+ reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
778
+ if not platform or platform == "reddit":
779
+ actions.extend(cls._read_actions_from_file(
780
+ reddit_actions_log,
781
+ default_platform="reddit", # 自动填充 platform 字段
782
+ platform_filter=platform,
783
+ agent_id=agent_id,
784
+ round_num=round_num
785
+ ))
786
+
787
+ # 如果分平台文件不存在,尝试读取旧的单一文件格式
788
+ if not actions:
789
+ actions_log = os.path.join(sim_dir, "actions.jsonl")
790
+ actions = cls._read_actions_from_file(
791
+ actions_log,
792
+ default_platform=None, # 旧格式文件中应该有 platform 字段
793
+ platform_filter=platform,
794
+ agent_id=agent_id,
795
+ round_num=round_num
796
+ )
797
+
798
+ # 按时间戳排序(新的在前)
799
+ actions.sort(key=lambda x: x.timestamp, reverse=True)
800
+
801
+ return actions
802
+
803
+ @classmethod
804
+ def get_actions(
805
+ cls,
806
+ simulation_id: str,
807
+ limit: int = 100,
808
+ offset: int = 0,
809
+ platform: Optional[str] = None,
810
+ agent_id: Optional[int] = None,
811
+ round_num: Optional[int] = None
812
+ ) -> List[AgentAction]:
813
+ """
814
+ 获取动作历史(带分页)
815
+
816
+ Args:
817
+ simulation_id: 模拟ID
818
+ limit: 返回数量限制
819
+ offset: 偏移量
820
+ platform: 过滤平台
821
+ agent_id: 过滤Agent
822
+ round_num: 过滤轮次
823
+
824
+ Returns:
825
+ 动作列表
826
+ """
827
+ actions = cls.get_all_actions(
828
+ simulation_id=simulation_id,
829
+ platform=platform,
830
+ agent_id=agent_id,
831
+ round_num=round_num
832
+ )
833
 
834
  # 分页
835
  return actions[offset:offset + limit]
 
948
 
949
  return result
950
 
951
+ @classmethod
952
+ def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]:
953
+ """
954
+ 清理模拟的运行日志(用于强制重新开始模拟)
955
+
956
+ 会删除以下文件:
957
+ - run_state.json
958
+ - twitter/actions.jsonl
959
+ - reddit/actions.jsonl
960
+ - simulation.log
961
+ - stdout.log / stderr.log
962
+
963
+ 注意:不会删除配置文件(simulation_config.json)和 profile 文件
964
+
965
+ Args:
966
+ simulation_id: 模拟ID
967
+
968
+ Returns:
969
+ 清理结果信息
970
+ """
971
+ import shutil
972
+
973
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
974
+
975
+ if not os.path.exists(sim_dir):
976
+ return {"success": True, "message": "模拟目录不存在,无需清理"}
977
+
978
+ cleaned_files = []
979
+ errors = []
980
+
981
+ # 要删除的文件列表
982
+ files_to_delete = [
983
+ "run_state.json",
984
+ "simulation.log",
985
+ "stdout.log",
986
+ "stderr.log",
987
+ ]
988
+
989
+ # 要删除的目录列表(包含动作日志)
990
+ dirs_to_clean = ["twitter", "reddit"]
991
+
992
+ # 删除文件
993
+ for filename in files_to_delete:
994
+ file_path = os.path.join(sim_dir, filename)
995
+ if os.path.exists(file_path):
996
+ try:
997
+ os.remove(file_path)
998
+ cleaned_files.append(filename)
999
+ except Exception as e:
1000
+ errors.append(f"删除 {filename} 失败: {str(e)}")
1001
+
1002
+ # 清理平台目录中的动作日志
1003
+ for dir_name in dirs_to_clean:
1004
+ dir_path = os.path.join(sim_dir, dir_name)
1005
+ if os.path.exists(dir_path):
1006
+ actions_file = os.path.join(dir_path, "actions.jsonl")
1007
+ if os.path.exists(actions_file):
1008
+ try:
1009
+ os.remove(actions_file)
1010
+ cleaned_files.append(f"{dir_name}/actions.jsonl")
1011
+ except Exception as e:
1012
+ errors.append(f"删除 {dir_name}/actions.jsonl 失败: {str(e)}")
1013
+
1014
+ # 清理内存中的运行状态
1015
+ if simulation_id in cls._run_states:
1016
+ del cls._run_states[simulation_id]
1017
+
1018
+ logger.info(f"清理模拟日志完成: {simulation_id}, 删除文件: {cleaned_files}")
1019
+
1020
+ return {
1021
+ "success": len(errors) == 0,
1022
+ "cleaned_files": cleaned_files,
1023
+ "errors": errors if errors else None
1024
+ }
1025
+
1026
  # 防止重复清理的标志
1027
  _cleanup_done = False
1028
 
frontend/src/api/simulation.js CHANGED
@@ -108,3 +108,47 @@ export const getRunStatusDetail = (simulationId) => {
108
  return service.get(`/api/simulation/${simulationId}/run-status/detail`)
109
  }
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  return service.get(`/api/simulation/${simulationId}/run-status/detail`)
109
  }
110
 
111
+ /**
112
+ * 获取模拟中的帖子
113
+ * @param {string} simulationId
114
+ * @param {string} platform - 'reddit' | 'twitter'
115
+ * @param {number} limit - 返回数量
116
+ * @param {number} offset - 偏移量
117
+ */
118
+ export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => {
119
+ return service.get(`/api/simulation/${simulationId}/posts`, {
120
+ params: { platform, limit, offset }
121
+ })
122
+ }
123
+
124
+ /**
125
+ * 获取模拟时间线(按轮次汇总)
126
+ * @param {string} simulationId
127
+ * @param {number} startRound - 起始轮次
128
+ * @param {number} endRound - 结束轮次
129
+ */
130
+ export const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => {
131
+ const params = { start_round: startRound }
132
+ if (endRound !== null) {
133
+ params.end_round = endRound
134
+ }
135
+ return service.get(`/api/simulation/${simulationId}/timeline`, { params })
136
+ }
137
+
138
+ /**
139
+ * 获取Agent统计信息
140
+ * @param {string} simulationId
141
+ */
142
+ export const getAgentStats = (simulationId) => {
143
+ return service.get(`/api/simulation/${simulationId}/agent-stats`)
144
+ }
145
+
146
+ /**
147
+ * 获取模拟动作历史
148
+ * @param {string} simulationId
149
+ * @param {Object} params - { limit, offset, platform, agent_id, round_num }
150
+ */
151
+ export const getSimulationActions = (simulationId, params = {}) => {
152
+ return service.get(`/api/simulation/${simulationId}/actions`, { params })
153
+ }
154
+
frontend/src/components/Step3Simulation.vue ADDED
@@ -0,0 +1,1312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="simulation-panel">
3
+ <div class="scroll-container">
4
+ <!-- Step 01: 启动模拟 -->
5
+ <div class="step-card" :class="{ 'active': phase === 0, 'completed': phase > 0 }">
6
+ <div class="card-header">
7
+ <div class="step-info">
8
+ <span class="step-num">01</span>
9
+ <span class="step-title">启动模拟引擎</span>
10
+ </div>
11
+ <div class="step-status">
12
+ <span v-if="phase > 0" class="badge success">已启动</span>
13
+ <span v-else-if="isStarting" class="badge processing">启动中</span>
14
+ <span v-else class="badge pending">等待</span>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="card-content">
19
+ <p class="api-note">POST /api/simulation/start</p>
20
+ <p class="description">
21
+ 启动双平台并行模拟引擎,Twitter与Reddit世界同步推演
22
+ </p>
23
+
24
+ <div v-if="phase === 0 && !isStarting" class="start-config">
25
+ <div class="config-row">
26
+ <span class="config-label">模拟ID</span>
27
+ <span class="config-value mono">{{ simulationId }}</span>
28
+ </div>
29
+ <div class="config-row">
30
+ <span class="config-label">模拟轮数</span>
31
+ <span class="config-value">{{ maxRounds || '自动配置' }} 轮</span>
32
+ </div>
33
+ <div class="config-row">
34
+ <span class="config-label">运行平台</span>
35
+ <span class="config-value">Twitter + Reddit (并行)</span>
36
+ </div>
37
+ </div>
38
+
39
+ <div v-if="isStarting" class="starting-indicator">
40
+ <div class="spinner-sm"></div>
41
+ <span>正在初始化模拟引擎...</span>
42
+ </div>
43
+
44
+ <div v-if="startError" class="error-box">
45
+ <span class="error-icon">✗</span>
46
+ <span class="error-text">{{ startError }}</span>
47
+ <button class="retry-btn" @click="doStartSimulation">重试</button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Step 02: 模拟进度 -->
53
+ <div class="step-card" :class="{ 'active': phase === 1, 'completed': phase > 1 }">
54
+ <div class="card-header">
55
+ <div class="step-info">
56
+ <span class="step-num">02</span>
57
+ <span class="step-title">双世界并行推演</span>
58
+ </div>
59
+ <div class="step-status">
60
+ <span v-if="phase > 1" class="badge success">已完成</span>
61
+ <span v-else-if="phase === 1" class="badge processing">{{ progressPercent }}%</span>
62
+ <span v-else class="badge pending">等待</span>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="card-content">
67
+ <p class="api-note">GET /api/simulation/{{ simulationId }}/run-status</p>
68
+ <p class="description">
69
+ 实时监控模拟进度,观察Agent在双平台的社交行为
70
+ </p>
71
+
72
+ <!-- 进度条 -->
73
+ <div v-if="phase >= 1" class="progress-section">
74
+ <div class="progress-bar-container">
75
+ <div class="progress-bar" :style="{ width: progressPercent + '%' }"></div>
76
+ </div>
77
+ <div class="progress-stats">
78
+ <div class="stat-item">
79
+ <span class="stat-label">当前轮次</span>
80
+ <span class="stat-value">{{ runStatus.current_round || 0 }} / {{ runStatus.total_rounds || maxRounds || '-' }}</span>
81
+ </div>
82
+ <div class="stat-item">
83
+ <span class="stat-label">模拟时间</span>
84
+ <span class="stat-value">{{ runStatus.simulated_hours || 0 }}h / {{ runStatus.total_simulation_hours || '-' }}h</span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- 平台状态 -->
90
+ <div v-if="phase >= 1" class="platforms-status">
91
+ <div class="platform-item" :class="{ 'running': runStatus.twitter_running }">
92
+ <div class="platform-header">
93
+ <span class="platform-icon">𝕏</span>
94
+ <span class="platform-name">Twitter / 广场</span>
95
+ <span class="platform-badge" :class="runStatus.twitter_running ? 'active' : 'idle'">
96
+ {{ runStatus.twitter_running ? '运行中' : '等待' }}
97
+ </span>
98
+ </div>
99
+ <div class="platform-stats">
100
+ <span class="action-count">
101
+ <span class="count-num">{{ runStatus.twitter_actions_count || 0 }}</span> 动作
102
+ </span>
103
+ </div>
104
+ </div>
105
+ <div class="platform-item" :class="{ 'running': runStatus.reddit_running }">
106
+ <div class="platform-header">
107
+ <span class="platform-icon">📮</span>
108
+ <span class="platform-name">Reddit / 社区</span>
109
+ <span class="platform-badge" :class="runStatus.reddit_running ? 'active' : 'idle'">
110
+ {{ runStatus.reddit_running ? '运行��' : '等待' }}
111
+ </span>
112
+ </div>
113
+ <div class="platform-stats">
114
+ <span class="action-count">
115
+ <span class="count-num">{{ runStatus.reddit_actions_count || 0 }}</span> 动作
116
+ </span>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- 统计卡片 -->
122
+ <div v-if="phase >= 1" class="stats-grid">
123
+ <div class="stat-card">
124
+ <span class="stat-value">{{ runStatus.total_actions_count || 0 }}</span>
125
+ <span class="stat-label">总动作数</span>
126
+ </div>
127
+ <div class="stat-card">
128
+ <span class="stat-value">{{ actionStats.posts }}</span>
129
+ <span class="stat-label">帖子数</span>
130
+ </div>
131
+ <div class="stat-card">
132
+ <span class="stat-value">{{ actionStats.comments }}</span>
133
+ <span class="stat-label">评论数</span>
134
+ </div>
135
+ <div class="stat-card">
136
+ <span class="stat-value">{{ actionStats.likes }}</span>
137
+ <span class="stat-label">点赞数</span>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <!-- Step 03: 实时动态 -->
144
+ <div class="step-card" :class="{ 'active': phase >= 1 }">
145
+ <div class="card-header">
146
+ <div class="step-info">
147
+ <span class="step-num">03</span>
148
+ <span class="step-title">实时动态流</span>
149
+ </div>
150
+ <div class="step-status">
151
+ <span v-if="recentActions.length > 0" class="badge accent">{{ recentActions.length }} 条</span>
152
+ <span v-else class="badge pending">等待</span>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="card-content">
157
+ <p class="api-note">GET /api/simulation/{{ simulationId }}/run-status/detail</p>
158
+ <p class="description">
159
+ 观察Agent们在模拟世界中的实时行为:发帖、评论、点赞、转发...
160
+ </p>
161
+
162
+ <!-- 平台筛选 -->
163
+ <div v-if="recentActions.length > 0" class="filter-bar">
164
+ <button
165
+ class="filter-btn"
166
+ :class="{ active: actionFilter === 'all' }"
167
+ @click="actionFilter = 'all'"
168
+ >
169
+ 全部
170
+ </button>
171
+ <button
172
+ class="filter-btn"
173
+ :class="{ active: actionFilter === 'twitter' }"
174
+ @click="actionFilter = 'twitter'"
175
+ >
176
+ 𝕏 Twitter
177
+ </button>
178
+ <button
179
+ class="filter-btn"
180
+ :class="{ active: actionFilter === 'reddit' }"
181
+ @click="actionFilter = 'reddit'"
182
+ >
183
+ 📮 Reddit
184
+ </button>
185
+ </div>
186
+
187
+ <!-- 动作流 -->
188
+ <div v-if="filteredActions.length > 0" class="actions-stream">
189
+ <TransitionGroup name="action-list" tag="div" class="actions-list">
190
+ <div
191
+ v-for="action in filteredActions"
192
+ :key="action.id || `${action.timestamp}-${action.agent_id}-${Math.random()}`"
193
+ class="action-item"
194
+ :class="'action-' + (action.action_type || 'unknown').toLowerCase()"
195
+ >
196
+ <div class="action-header">
197
+ <span class="action-platform" :class="action.platform">
198
+ {{ action.platform === 'twitter' ? '𝕏' : '📮' }}
199
+ </span>
200
+ <span class="action-agent">
201
+ <span class="agent-name">{{ action.agent_name || `Agent ${action.agent_id}` }}</span>
202
+ </span>
203
+ <span class="action-type-badge" :class="getActionTypeClass(action.action_type)">
204
+ {{ getActionTypeLabel(action.action_type) }}
205
+ </span>
206
+ <span class="action-round">R{{ action.round_num }}</span>
207
+ </div>
208
+ <div v-if="action.action_args?.content" class="action-content">
209
+ {{ truncateContent(action.action_args.content) }}
210
+ </div>
211
+ <div class="action-footer">
212
+ <span class="action-time">{{ formatActionTime(action.timestamp) }}</span>
213
+ <span v-if="action.action_args?.target_post_id" class="action-target">
214
+ 回复 #{{ action.action_args.target_post_id }}
215
+ </span>
216
+ </div>
217
+ </div>
218
+ </TransitionGroup>
219
+ </div>
220
+
221
+ <div v-else-if="phase >= 1" class="empty-actions">
222
+ <div class="spinner-sm"></div>
223
+ <span>等待Agent行动...</span>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Step 04: 模拟完成 -->
229
+ <div class="step-card" :class="{ 'active': phase === 2 }">
230
+ <div class="card-header">
231
+ <div class="step-info">
232
+ <span class="step-num">04</span>
233
+ <span class="step-title">模拟完成</span>
234
+ </div>
235
+ <div class="step-status">
236
+ <span v-if="phase >= 2" class="badge success">完成</span>
237
+ <span v-else class="badge pending">等待</span>
238
+ </div>
239
+ </div>
240
+
241
+ <div class="card-content">
242
+ <p class="api-note">POST /api/simulation/stop</p>
243
+ <p class="description">
244
+ 模拟结束后,可进入报告生成阶段,深度分析模拟结果
245
+ </p>
246
+
247
+ <!-- 完成统计 -->
248
+ <div v-if="phase >= 2" class="completion-stats">
249
+ <div class="completion-summary">
250
+ <div class="summary-icon">✓</div>
251
+ <div class="summary-content">
252
+ <span class="summary-title">模拟已完成</span>
253
+ <span class="summary-desc">双平台并行推演结束,所有Agent动作已记录</span>
254
+ </div>
255
+ </div>
256
+ <div class="completion-grid">
257
+ <div class="completion-item">
258
+ <span class="completion-value">{{ runStatus.current_round || 0 }}</span>
259
+ <span class="completion-label">完成轮次</span>
260
+ </div>
261
+ <div class="completion-item">
262
+ <span class="completion-value">{{ runStatus.simulated_hours || 0 }}h</span>
263
+ <span class="completion-label">模拟时长</span>
264
+ </div>
265
+ <div class="completion-item">
266
+ <span class="completion-value">{{ runStatus.total_actions_count || 0 }}</span>
267
+ <span class="completion-label">总动作数</span>
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ <div class="action-group" :class="{ 'dual': phase === 1 }">
273
+ <button
274
+ v-if="phase === 1"
275
+ class="action-btn secondary"
276
+ @click="handleStopSimulation"
277
+ :disabled="isStopping"
278
+ >
279
+ <span v-if="isStopping" class="spinner-sm"></span>
280
+ {{ isStopping ? '停止中...' : '⏹ 停止模拟' }}
281
+ </button>
282
+ <button
283
+ class="action-btn primary"
284
+ :disabled="phase < 2"
285
+ @click="handleNextStep"
286
+ >
287
+ 进入报告生成 ➝
288
+ </button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+
294
+ <!-- Bottom Info / Logs -->
295
+ <div class="system-logs">
296
+ <div class="log-header">
297
+ <span class="log-title">SIMULATION MONITOR</span>
298
+ <span class="log-id">{{ simulationId || 'NO_SIMULATION' }}</span>
299
+ </div>
300
+ <div class="log-content" ref="logContent">
301
+ <div class="log-line" v-for="(log, idx) in systemLogs" :key="idx">
302
+ <span class="log-time">{{ log.time }}</span>
303
+ <span class="log-msg">{{ log.msg }}</span>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </template>
309
+
310
+ <script setup>
311
+ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
312
+ import {
313
+ startSimulation,
314
+ stopSimulation,
315
+ getRunStatus,
316
+ getRunStatusDetail
317
+ } from '../api/simulation'
318
+
319
+ const props = defineProps({
320
+ simulationId: String,
321
+ maxRounds: Number, // 从Step2传入的最大轮数
322
+ projectData: Object,
323
+ graphData: Object,
324
+ systemLogs: Array
325
+ })
326
+
327
+ const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])
328
+
329
+ // State
330
+ const phase = ref(0) // 0: 未开始, 1: 运行中, 2: 已完成
331
+ const isStarting = ref(false)
332
+ const isStopping = ref(false)
333
+ const startError = ref(null)
334
+ const runStatus = ref({})
335
+ const recentActions = ref([])
336
+ const actionFilter = ref('all')
337
+
338
+ // 动作统计
339
+ const actionStats = ref({
340
+ posts: 0,
341
+ comments: 0,
342
+ likes: 0,
343
+ reposts: 0
344
+ })
345
+
346
+ // Polling timers
347
+ let statusTimer = null
348
+ let detailTimer = null
349
+
350
+ // Computed
351
+ const progressPercent = computed(() => {
352
+ if (!runStatus.value.total_rounds) return 0
353
+ return Math.round((runStatus.value.current_round / runStatus.value.total_rounds) * 100)
354
+ })
355
+
356
+ const filteredActions = computed(() => {
357
+ if (actionFilter.value === 'all') {
358
+ return recentActions.value
359
+ }
360
+ return recentActions.value.filter(a => a.platform === actionFilter.value)
361
+ })
362
+
363
+ // Methods
364
+ const addLog = (msg) => {
365
+ emit('add-log', msg)
366
+ }
367
+
368
+ // 启动模拟
369
+ const doStartSimulation = async () => {
370
+ if (!props.simulationId) {
371
+ addLog('错误:缺少 simulationId')
372
+ return
373
+ }
374
+
375
+ isStarting.value = true
376
+ startError.value = null
377
+ addLog('正在启动双平台并行模拟...')
378
+ emit('update-status', 'processing')
379
+
380
+ try {
381
+ const params = {
382
+ simulation_id: props.simulationId,
383
+ platform: 'parallel',
384
+ force: true // 强制重新开始,如果已有模拟会自动停止并清理日志
385
+ }
386
+
387
+ // 如果有自定义轮数
388
+ if (props.maxRounds) {
389
+ params.max_rounds = props.maxRounds
390
+ addLog(`设置最大模拟轮数: ${props.maxRounds}`)
391
+ }
392
+
393
+ const res = await startSimulation(params)
394
+
395
+ if (res.success && res.data) {
396
+ if (res.data.force_restarted) {
397
+ addLog('✓ 已清理旧的模拟日志,重新开始模拟')
398
+ }
399
+ addLog('✓ 模拟引擎启动成功')
400
+ addLog(` ├─ PID: ${res.data.process_pid || '-'}`)
401
+ addLog(` ├─ Twitter: ${res.data.twitter_running ? '运行中' : '等待'}`)
402
+ addLog(` └─ Reddit: ${res.data.reddit_running ? '运行中' : '等待'}`)
403
+
404
+ phase.value = 1
405
+ runStatus.value = res.data
406
+
407
+ // 开始轮询状态
408
+ startStatusPolling()
409
+ startDetailPolling()
410
+ } else {
411
+ startError.value = res.error || '启动失败'
412
+ addLog(`✗ 启动失败: ${res.error || '未知错误'}`)
413
+ emit('update-status', 'error')
414
+ }
415
+ } catch (err) {
416
+ startError.value = err.message
417
+ addLog(`✗ 启动异常: ${err.message}`)
418
+ emit('update-status', 'error')
419
+ } finally {
420
+ isStarting.value = false
421
+ }
422
+ }
423
+
424
+ // 停止模拟
425
+ const handleStopSimulation = async () => {
426
+ if (!props.simulationId) return
427
+
428
+ isStopping.value = true
429
+ addLog('正在停止模拟...')
430
+
431
+ try {
432
+ const res = await stopSimulation({ simulation_id: props.simulationId })
433
+
434
+ if (res.success) {
435
+ addLog('✓ 模拟已停止')
436
+ phase.value = 2
437
+ stopPolling()
438
+ emit('update-status', 'completed')
439
+ } else {
440
+ addLog(`停止失败: ${res.error || '未知错误'}`)
441
+ }
442
+ } catch (err) {
443
+ addLog(`停止异常: ${err.message}`)
444
+ } finally {
445
+ isStopping.value = false
446
+ }
447
+ }
448
+
449
+ // 轮询状态
450
+ const startStatusPolling = () => {
451
+ statusTimer = setInterval(fetchRunStatus, 2000)
452
+ }
453
+
454
+ const startDetailPolling = () => {
455
+ detailTimer = setInterval(fetchRunStatusDetail, 3000)
456
+ }
457
+
458
+ const stopPolling = () => {
459
+ if (statusTimer) {
460
+ clearInterval(statusTimer)
461
+ statusTimer = null
462
+ }
463
+ if (detailTimer) {
464
+ clearInterval(detailTimer)
465
+ detailTimer = null
466
+ }
467
+ }
468
+
469
+ const fetchRunStatus = async () => {
470
+ if (!props.simulationId) return
471
+
472
+ try {
473
+ const res = await getRunStatus(props.simulationId)
474
+
475
+ if (res.success && res.data) {
476
+ const data = res.data
477
+ const prevRound = runStatus.value.current_round || 0
478
+
479
+ runStatus.value = data
480
+
481
+ // 检查是否有新轮次
482
+ if (data.current_round > prevRound) {
483
+ addLog(`轮次 ${data.current_round}/${data.total_rounds} - 动作数: ${data.total_actions_count}`)
484
+ }
485
+
486
+ // 检查是否完成
487
+ if (data.runner_status === 'completed' || data.runner_status === 'stopped') {
488
+ addLog('✓ 模拟已完成')
489
+ phase.value = 2
490
+ stopPolling()
491
+ emit('update-status', 'completed')
492
+ }
493
+ }
494
+ } catch (err) {
495
+ console.warn('获取运行状态失败:', err)
496
+ }
497
+ }
498
+
499
+ const fetchRunStatusDetail = async () => {
500
+ if (!props.simulationId) return
501
+
502
+ try {
503
+ const res = await getRunStatusDetail(props.simulationId)
504
+
505
+ if (res.success && res.data?.recent_actions) {
506
+ // 更新最近动作,保留最新30条
507
+ const newActions = res.data.recent_actions.slice(0, 30)
508
+ recentActions.value = newActions
509
+
510
+ // 统计动作类型
511
+ updateActionStats(newActions)
512
+ }
513
+ } catch (err) {
514
+ console.warn('获取详细状态失败:', err)
515
+ }
516
+ }
517
+
518
+ // 统计动作类型
519
+ const updateActionStats = (actions) => {
520
+ const stats = { posts: 0, comments: 0, likes: 0, reposts: 0 }
521
+ actions.forEach(a => {
522
+ const type = a.action_type?.toUpperCase()
523
+ if (type === 'CREATE_POST') stats.posts++
524
+ else if (type === 'CREATE_COMMENT') stats.comments++
525
+ else if (type === 'LIKE_POST' || type === 'LIKE_COMMENT') stats.likes++
526
+ else if (type === 'REPOST') stats.reposts++
527
+ })
528
+ actionStats.value = stats
529
+ }
530
+
531
+ // 工具函数
532
+ const getActionTypeLabel = (type) => {
533
+ const labels = {
534
+ 'CREATE_POST': '发帖',
535
+ 'REPOST': '转发',
536
+ 'LIKE_POST': '点赞',
537
+ 'CREATE_COMMENT': '评论',
538
+ 'LIKE_COMMENT': '赞评',
539
+ 'DO_NOTHING': '观望',
540
+ 'FOLLOW': '关注',
541
+ 'SEARCH_POSTS': '搜索',
542
+ 'SEARCH_USER': '找人'
543
+ }
544
+ return labels[type] || type || '未知'
545
+ }
546
+
547
+ const getActionTypeClass = (type) => {
548
+ const classes = {
549
+ 'CREATE_POST': 'post',
550
+ 'REPOST': 'repost',
551
+ 'LIKE_POST': 'like',
552
+ 'CREATE_COMMENT': 'comment',
553
+ 'LIKE_COMMENT': 'like',
554
+ 'DO_NOTHING': 'nothing',
555
+ 'FOLLOW': 'follow',
556
+ 'SEARCH_POSTS': 'search',
557
+ 'SEARCH_USER': 'search'
558
+ }
559
+ return classes[type] || 'default'
560
+ }
561
+
562
+ const truncateContent = (content) => {
563
+ if (!content) return ''
564
+ if (content.length > 120) {
565
+ return content.substring(0, 120) + '...'
566
+ }
567
+ return content
568
+ }
569
+
570
+ const formatActionTime = (timestamp) => {
571
+ if (!timestamp) return '--:--'
572
+ try {
573
+ const d = new Date(timestamp)
574
+ return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
575
+ } catch {
576
+ return timestamp
577
+ }
578
+ }
579
+
580
+ const handleNextStep = () => {
581
+ addLog('进入 Step 4: 报告生成')
582
+ emit('next-step')
583
+ }
584
+
585
+ // Scroll log to bottom
586
+ const logContent = ref(null)
587
+ watch(() => props.systemLogs?.length, () => {
588
+ nextTick(() => {
589
+ if (logContent.value) {
590
+ logContent.value.scrollTop = logContent.value.scrollHeight
591
+ }
592
+ })
593
+ })
594
+
595
+ onMounted(() => {
596
+ addLog('Step3 模拟运行初始化')
597
+ // 自动启动模拟
598
+ if (props.simulationId) {
599
+ doStartSimulation()
600
+ }
601
+ })
602
+
603
+ onUnmounted(() => {
604
+ stopPolling()
605
+ })
606
+ </script>
607
+
608
+ <style scoped>
609
+ .simulation-panel {
610
+ height: 100%;
611
+ display: flex;
612
+ flex-direction: column;
613
+ background: #FAFAFA;
614
+ font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
615
+ }
616
+
617
+ .scroll-container {
618
+ flex: 1;
619
+ overflow-y: auto;
620
+ padding: 24px;
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 20px;
624
+ }
625
+
626
+ /* Step Card */
627
+ .step-card {
628
+ background: #FFF;
629
+ border-radius: 8px;
630
+ padding: 20px;
631
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
632
+ border: 1px solid #EAEAEA;
633
+ transition: all 0.3s ease;
634
+ position: relative;
635
+ }
636
+
637
+ .step-card.active {
638
+ border-color: #FF5722;
639
+ box-shadow: 0 4px 12px rgba(255, 87, 34, 0.08);
640
+ }
641
+
642
+ .step-card.completed {
643
+ border-color: #4CAF50;
644
+ }
645
+
646
+ .card-header {
647
+ display: flex;
648
+ justify-content: space-between;
649
+ align-items: center;
650
+ margin-bottom: 16px;
651
+ }
652
+
653
+ .step-info {
654
+ display: flex;
655
+ align-items: center;
656
+ gap: 12px;
657
+ }
658
+
659
+ .step-num {
660
+ font-family: 'JetBrains Mono', monospace;
661
+ font-size: 20px;
662
+ font-weight: 700;
663
+ color: #E0E0E0;
664
+ }
665
+
666
+ .step-card.active .step-num,
667
+ .step-card.completed .step-num {
668
+ color: #000;
669
+ }
670
+
671
+ .step-title {
672
+ font-weight: 600;
673
+ font-size: 14px;
674
+ letter-spacing: 0.5px;
675
+ }
676
+
677
+ .badge {
678
+ font-size: 10px;
679
+ padding: 4px 8px;
680
+ border-radius: 4px;
681
+ font-weight: 600;
682
+ text-transform: uppercase;
683
+ }
684
+
685
+ .badge.success { background: #E8F5E9; color: #2E7D32; }
686
+ .badge.processing { background: #FF5722; color: #FFF; }
687
+ .badge.pending { background: #F5F5F5; color: #999; }
688
+ .badge.accent { background: #E3F2FD; color: #1565C0; }
689
+
690
+ .api-note {
691
+ font-family: 'JetBrains Mono', monospace;
692
+ font-size: 10px;
693
+ color: #999;
694
+ margin-bottom: 8px;
695
+ }
696
+
697
+ .description {
698
+ font-size: 12px;
699
+ color: #666;
700
+ line-height: 1.5;
701
+ margin-bottom: 16px;
702
+ }
703
+
704
+ /* Start Config */
705
+ .start-config {
706
+ background: #F9F9F9;
707
+ border-radius: 6px;
708
+ padding: 16px;
709
+ margin-bottom: 16px;
710
+ }
711
+
712
+ .config-row {
713
+ display: flex;
714
+ justify-content: space-between;
715
+ padding: 8px 0;
716
+ border-bottom: 1px dashed #E5E5E5;
717
+ }
718
+
719
+ .config-row:last-child {
720
+ border-bottom: none;
721
+ }
722
+
723
+ .config-label {
724
+ font-size: 12px;
725
+ color: #666;
726
+ }
727
+
728
+ .config-value {
729
+ font-size: 12px;
730
+ font-weight: 600;
731
+ color: #333;
732
+ }
733
+
734
+ .config-value.mono {
735
+ font-family: 'JetBrains Mono', monospace;
736
+ }
737
+
738
+ .starting-indicator {
739
+ display: flex;
740
+ align-items: center;
741
+ gap: 10px;
742
+ font-size: 12px;
743
+ color: #FF5722;
744
+ margin-bottom: 12px;
745
+ }
746
+
747
+ .error-box {
748
+ display: flex;
749
+ align-items: center;
750
+ gap: 8px;
751
+ background: #FFEBEE;
752
+ padding: 12px;
753
+ border-radius: 6px;
754
+ margin-top: 12px;
755
+ }
756
+
757
+ .error-icon {
758
+ color: #E53935;
759
+ font-weight: 700;
760
+ }
761
+
762
+ .error-text {
763
+ font-size: 12px;
764
+ color: #C62828;
765
+ flex: 1;
766
+ }
767
+
768
+ .retry-btn {
769
+ background: #E53935;
770
+ color: #FFF;
771
+ border: none;
772
+ padding: 6px 12px;
773
+ border-radius: 4px;
774
+ font-size: 11px;
775
+ font-weight: 600;
776
+ cursor: pointer;
777
+ transition: opacity 0.2s;
778
+ }
779
+
780
+ .retry-btn:hover {
781
+ opacity: 0.8;
782
+ }
783
+
784
+ /* Progress Section */
785
+ .progress-section {
786
+ margin-bottom: 16px;
787
+ }
788
+
789
+ .progress-bar-container {
790
+ height: 8px;
791
+ background: #E5E5E5;
792
+ border-radius: 4px;
793
+ overflow: hidden;
794
+ margin-bottom: 12px;
795
+ }
796
+
797
+ .progress-bar {
798
+ height: 100%;
799
+ background: linear-gradient(90deg, #FF5722, #FF9800);
800
+ border-radius: 4px;
801
+ transition: width 0.5s ease;
802
+ }
803
+
804
+ .progress-stats {
805
+ display: flex;
806
+ justify-content: space-between;
807
+ }
808
+
809
+ .stat-item {
810
+ display: flex;
811
+ flex-direction: column;
812
+ gap: 2px;
813
+ }
814
+
815
+ .stat-item .stat-label {
816
+ font-size: 10px;
817
+ color: #999;
818
+ text-transform: uppercase;
819
+ }
820
+
821
+ .stat-item .stat-value {
822
+ font-family: 'JetBrains Mono', monospace;
823
+ font-size: 13px;
824
+ font-weight: 600;
825
+ color: #333;
826
+ }
827
+
828
+ /* Platforms Status */
829
+ .platforms-status {
830
+ display: grid;
831
+ grid-template-columns: 1fr 1fr;
832
+ gap: 12px;
833
+ margin-bottom: 16px;
834
+ }
835
+
836
+ .platform-item {
837
+ background: #F9F9F9;
838
+ border: 1px solid #E5E5E5;
839
+ border-radius: 8px;
840
+ padding: 14px;
841
+ transition: all 0.3s ease;
842
+ }
843
+
844
+ .platform-item.running {
845
+ border-color: #FF5722;
846
+ background: #FFF;
847
+ }
848
+
849
+ .platform-header {
850
+ display: flex;
851
+ align-items: center;
852
+ gap: 8px;
853
+ margin-bottom: 8px;
854
+ }
855
+
856
+ .platform-icon {
857
+ font-size: 16px;
858
+ }
859
+
860
+ .platform-name {
861
+ font-size: 13px;
862
+ font-weight: 600;
863
+ flex: 1;
864
+ }
865
+
866
+ .platform-badge {
867
+ font-size: 9px;
868
+ padding: 2px 6px;
869
+ border-radius: 3px;
870
+ font-weight: 600;
871
+ text-transform: uppercase;
872
+ }
873
+
874
+ .platform-badge.active {
875
+ background: #FF5722;
876
+ color: #FFF;
877
+ }
878
+
879
+ .platform-badge.idle {
880
+ background: #E5E5E5;
881
+ color: #666;
882
+ }
883
+
884
+ .platform-stats {
885
+ font-family: 'JetBrains Mono', monospace;
886
+ font-size: 11px;
887
+ color: #666;
888
+ }
889
+
890
+ .count-num {
891
+ font-size: 16px;
892
+ font-weight: 700;
893
+ color: #333;
894
+ }
895
+
896
+ /* Stats Grid */
897
+ .stats-grid {
898
+ display: grid;
899
+ grid-template-columns: repeat(4, 1fr);
900
+ gap: 12px;
901
+ background: #F9F9F9;
902
+ padding: 16px;
903
+ border-radius: 6px;
904
+ }
905
+
906
+ .stat-card {
907
+ text-align: center;
908
+ }
909
+
910
+ .stat-card .stat-value {
911
+ display: block;
912
+ font-size: 20px;
913
+ font-weight: 700;
914
+ color: #000;
915
+ font-family: 'JetBrains Mono', monospace;
916
+ }
917
+
918
+ .stat-card .stat-label {
919
+ font-size: 9px;
920
+ color: #999;
921
+ text-transform: uppercase;
922
+ margin-top: 4px;
923
+ display: block;
924
+ }
925
+
926
+ /* Filter Bar */
927
+ .filter-bar {
928
+ display: flex;
929
+ gap: 8px;
930
+ margin-bottom: 12px;
931
+ }
932
+
933
+ .filter-btn {
934
+ border: 1px solid #E5E5E5;
935
+ background: #FFF;
936
+ padding: 6px 12px;
937
+ border-radius: 16px;
938
+ font-size: 11px;
939
+ font-weight: 500;
940
+ color: #666;
941
+ cursor: pointer;
942
+ transition: all 0.2s;
943
+ }
944
+
945
+ .filter-btn:hover {
946
+ border-color: #999;
947
+ }
948
+
949
+ .filter-btn.active {
950
+ background: #000;
951
+ border-color: #000;
952
+ color: #FFF;
953
+ }
954
+
955
+ /* Actions Stream */
956
+ .actions-stream {
957
+ max-height: 400px;
958
+ overflow-y: auto;
959
+ border: 1px solid #E5E5E5;
960
+ border-radius: 8px;
961
+ background: #FAFAFA;
962
+ }
963
+
964
+ .actions-stream::-webkit-scrollbar {
965
+ width: 6px;
966
+ }
967
+
968
+ .actions-stream::-webkit-scrollbar-thumb {
969
+ background: #DDD;
970
+ border-radius: 3px;
971
+ }
972
+
973
+ .actions-list {
974
+ padding: 8px;
975
+ display: flex;
976
+ flex-direction: column;
977
+ gap: 8px;
978
+ }
979
+
980
+ .action-item {
981
+ background: #FFF;
982
+ border: 1px solid #E5E5E5;
983
+ border-radius: 6px;
984
+ padding: 12px;
985
+ transition: all 0.3s ease;
986
+ }
987
+
988
+ .action-item:hover {
989
+ border-color: #CCC;
990
+ }
991
+
992
+ .action-item.action-create_post {
993
+ border-left: 3px solid #1DA1F2;
994
+ }
995
+
996
+ .action-item.action-repost {
997
+ border-left: 3px solid #17BF63;
998
+ }
999
+
1000
+ .action-item.action-like_post,
1001
+ .action-item.action-like_comment {
1002
+ border-left: 3px solid #E0245E;
1003
+ }
1004
+
1005
+ .action-item.action-create_comment {
1006
+ border-left: 3px solid #794BC4;
1007
+ }
1008
+
1009
+ .action-item.action-do_nothing {
1010
+ border-left: 3px solid #AAB8C2;
1011
+ opacity: 0.7;
1012
+ }
1013
+
1014
+ .action-header {
1015
+ display: flex;
1016
+ align-items: center;
1017
+ gap: 8px;
1018
+ margin-bottom: 6px;
1019
+ }
1020
+
1021
+ .action-platform {
1022
+ font-size: 14px;
1023
+ }
1024
+
1025
+ .action-platform.twitter {
1026
+ color: #1DA1F2;
1027
+ }
1028
+
1029
+ .action-platform.reddit {
1030
+ color: #FF4500;
1031
+ }
1032
+
1033
+ .action-agent {
1034
+ flex: 1;
1035
+ }
1036
+
1037
+ .agent-name {
1038
+ font-size: 12px;
1039
+ font-weight: 600;
1040
+ color: #333;
1041
+ }
1042
+
1043
+ .action-type-badge {
1044
+ font-size: 9px;
1045
+ padding: 2px 6px;
1046
+ border-radius: 3px;
1047
+ font-weight: 600;
1048
+ text-transform: uppercase;
1049
+ }
1050
+
1051
+ .action-type-badge.post { background: #E3F2FD; color: #1565C0; }
1052
+ .action-type-badge.repost { background: #E8F5E9; color: #2E7D32; }
1053
+ .action-type-badge.like { background: #FCE4EC; color: #C2185B; }
1054
+ .action-type-badge.comment { background: #F3E5F5; color: #7B1FA2; }
1055
+ .action-type-badge.nothing { background: #F5F5F5; color: #757575; }
1056
+ .action-type-badge.follow { background: #E0F7FA; color: #00838F; }
1057
+ .action-type-badge.search { background: #FFF3E0; color: #EF6C00; }
1058
+ .action-type-badge.default { background: #ECEFF1; color: #546E7A; }
1059
+
1060
+ .action-round {
1061
+ font-family: 'JetBrains Mono', monospace;
1062
+ font-size: 10px;
1063
+ color: #999;
1064
+ }
1065
+
1066
+ .action-content {
1067
+ font-size: 12px;
1068
+ color: #555;
1069
+ line-height: 1.5;
1070
+ padding: 8px;
1071
+ background: #F9F9F9;
1072
+ border-radius: 4px;
1073
+ margin-bottom: 6px;
1074
+ }
1075
+
1076
+ .action-footer {
1077
+ display: flex;
1078
+ justify-content: space-between;
1079
+ align-items: center;
1080
+ }
1081
+
1082
+ .action-time {
1083
+ font-family: 'JetBrains Mono', monospace;
1084
+ font-size: 10px;
1085
+ color: #AAA;
1086
+ }
1087
+
1088
+ .action-target {
1089
+ font-size: 10px;
1090
+ color: #999;
1091
+ }
1092
+
1093
+ .empty-actions {
1094
+ display: flex;
1095
+ align-items: center;
1096
+ justify-content: center;
1097
+ gap: 10px;
1098
+ padding: 40px;
1099
+ color: #999;
1100
+ font-size: 12px;
1101
+ }
1102
+
1103
+ /* Action List Animation */
1104
+ .action-list-enter-active {
1105
+ transition: all 0.4s ease;
1106
+ }
1107
+
1108
+ .action-list-leave-active {
1109
+ transition: all 0.3s ease;
1110
+ }
1111
+
1112
+ .action-list-enter-from {
1113
+ opacity: 0;
1114
+ transform: translateY(-20px);
1115
+ }
1116
+
1117
+ .action-list-leave-to {
1118
+ opacity: 0;
1119
+ transform: translateX(20px);
1120
+ }
1121
+
1122
+ /* Completion Stats */
1123
+ .completion-stats {
1124
+ margin-bottom: 16px;
1125
+ }
1126
+
1127
+ .completion-summary {
1128
+ display: flex;
1129
+ align-items: center;
1130
+ gap: 16px;
1131
+ padding: 16px;
1132
+ background: linear-gradient(135deg, #E8F5E9, #C8E6C9);
1133
+ border-radius: 8px;
1134
+ margin-bottom: 16px;
1135
+ }
1136
+
1137
+ .summary-icon {
1138
+ width: 48px;
1139
+ height: 48px;
1140
+ background: #4CAF50;
1141
+ color: #FFF;
1142
+ border-radius: 50%;
1143
+ display: flex;
1144
+ align-items: center;
1145
+ justify-content: center;
1146
+ font-size: 24px;
1147
+ font-weight: 700;
1148
+ }
1149
+
1150
+ .summary-content {
1151
+ flex: 1;
1152
+ }
1153
+
1154
+ .summary-title {
1155
+ display: block;
1156
+ font-size: 16px;
1157
+ font-weight: 700;
1158
+ color: #2E7D32;
1159
+ margin-bottom: 4px;
1160
+ }
1161
+
1162
+ .summary-desc {
1163
+ font-size: 12px;
1164
+ color: #388E3C;
1165
+ }
1166
+
1167
+ .completion-grid {
1168
+ display: grid;
1169
+ grid-template-columns: repeat(3, 1fr);
1170
+ gap: 16px;
1171
+ }
1172
+
1173
+ .completion-item {
1174
+ text-align: center;
1175
+ padding: 16px;
1176
+ background: #F9F9F9;
1177
+ border-radius: 8px;
1178
+ }
1179
+
1180
+ .completion-value {
1181
+ display: block;
1182
+ font-size: 24px;
1183
+ font-weight: 700;
1184
+ color: #333;
1185
+ font-family: 'JetBrains Mono', monospace;
1186
+ }
1187
+
1188
+ .completion-label {
1189
+ font-size: 11px;
1190
+ color: #666;
1191
+ text-transform: uppercase;
1192
+ margin-top: 4px;
1193
+ }
1194
+
1195
+ /* Action Buttons */
1196
+ .action-group {
1197
+ display: flex;
1198
+ gap: 12px;
1199
+ margin-top: 16px;
1200
+ }
1201
+
1202
+ .action-group.dual {
1203
+ display: grid;
1204
+ grid-template-columns: 1fr 1fr;
1205
+ }
1206
+
1207
+ .action-btn {
1208
+ display: inline-flex;
1209
+ align-items: center;
1210
+ justify-content: center;
1211
+ gap: 8px;
1212
+ padding: 12px 24px;
1213
+ font-size: 13px;
1214
+ font-weight: 600;
1215
+ border: none;
1216
+ border-radius: 6px;
1217
+ cursor: pointer;
1218
+ transition: all 0.2s ease;
1219
+ }
1220
+
1221
+ .action-btn.primary {
1222
+ background: #000;
1223
+ color: #FFF;
1224
+ }
1225
+
1226
+ .action-btn.primary:hover:not(:disabled) {
1227
+ opacity: 0.8;
1228
+ }
1229
+
1230
+ .action-btn.secondary {
1231
+ background: #F5F5F5;
1232
+ color: #333;
1233
+ }
1234
+
1235
+ .action-btn.secondary:hover:not(:disabled) {
1236
+ background: #E5E5E5;
1237
+ }
1238
+
1239
+ .action-btn:disabled {
1240
+ opacity: 0.5;
1241
+ cursor: not-allowed;
1242
+ }
1243
+
1244
+ /* System Logs */
1245
+ .system-logs {
1246
+ background: #000;
1247
+ color: #DDD;
1248
+ padding: 16px;
1249
+ font-family: 'JetBrains Mono', monospace;
1250
+ border-top: 1px solid #222;
1251
+ flex-shrink: 0;
1252
+ }
1253
+
1254
+ .log-header {
1255
+ display: flex;
1256
+ justify-content: space-between;
1257
+ border-bottom: 1px solid #333;
1258
+ padding-bottom: 8px;
1259
+ margin-bottom: 8px;
1260
+ font-size: 10px;
1261
+ color: #888;
1262
+ }
1263
+
1264
+ .log-content {
1265
+ display: flex;
1266
+ flex-direction: column;
1267
+ gap: 4px;
1268
+ height: 80px;
1269
+ overflow-y: auto;
1270
+ padding-right: 4px;
1271
+ }
1272
+
1273
+ .log-content::-webkit-scrollbar {
1274
+ width: 4px;
1275
+ }
1276
+
1277
+ .log-content::-webkit-scrollbar-thumb {
1278
+ background: #333;
1279
+ border-radius: 2px;
1280
+ }
1281
+
1282
+ .log-line {
1283
+ font-size: 11px;
1284
+ display: flex;
1285
+ gap: 12px;
1286
+ line-height: 1.5;
1287
+ }
1288
+
1289
+ .log-time {
1290
+ color: #666;
1291
+ min-width: 75px;
1292
+ }
1293
+
1294
+ .log-msg {
1295
+ color: #CCC;
1296
+ word-break: break-all;
1297
+ }
1298
+
1299
+ /* Spinner */
1300
+ .spinner-sm {
1301
+ width: 14px;
1302
+ height: 14px;
1303
+ border: 2px solid #FFCCBC;
1304
+ border-top-color: #FF5722;
1305
+ border-radius: 50%;
1306
+ animation: spin 0.8s linear infinite;
1307
+ }
1308
+
1309
+ @keyframes spin {
1310
+ to { transform: rotate(360deg); }
1311
+ }
1312
+ </style>
frontend/src/router/index.js CHANGED
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
2
  import Home from '../views/Home.vue'
3
  import Process from '../views/MainView.vue'
4
  import SimulationView from '../views/SimulationView.vue'
 
5
 
6
  const routes = [
7
  {
@@ -20,6 +21,12 @@ const routes = [
20
  name: 'Simulation',
21
  component: SimulationView,
22
  props: true
 
 
 
 
 
 
23
  }
24
  ]
25
 
 
2
  import Home from '../views/Home.vue'
3
  import Process from '../views/MainView.vue'
4
  import SimulationView from '../views/SimulationView.vue'
5
+ import SimulationRunView from '../views/SimulationRunView.vue'
6
 
7
  const routes = [
8
  {
 
21
  name: 'Simulation',
22
  component: SimulationView,
23
  props: true
24
+ },
25
+ {
26
+ path: '/simulation/:simulationId/start',
27
+ name: 'SimulationRun',
28
+ component: SimulationRunView,
29
+ props: true
30
  }
31
  ]
32
 
frontend/src/views/SimulationRunView.vue ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="main-view">
3
+ <!-- Header -->
4
+ <header class="app-header">
5
+ <div class="header-left">
6
+ <div class="brand" @click="router.push('/')">MIROFISH</div>
7
+ </div>
8
+
9
+ <div class="header-center">
10
+ <div class="view-switcher">
11
+ <button
12
+ v-for="mode in ['graph', 'split', 'workbench']"
13
+ :key="mode"
14
+ class="switch-btn"
15
+ :class="{ active: viewMode === mode }"
16
+ @click="viewMode = mode"
17
+ >
18
+ {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}
19
+ </button>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="header-right">
24
+ <div class="workflow-step">
25
+ <span class="step-num">Step 3/5</span>
26
+ <span class="step-name">开始模拟</span>
27
+ </div>
28
+ <div class="step-divider"></div>
29
+ <span class="status-indicator" :class="statusClass">
30
+ <span class="dot"></span>
31
+ {{ statusText }}
32
+ </span>
33
+ </div>
34
+ </header>
35
+
36
+ <!-- Main Content Area -->
37
+ <main class="content-area">
38
+ <!-- Left Panel: Graph -->
39
+ <div class="panel-wrapper left" :style="leftPanelStyle">
40
+ <GraphPanel
41
+ :graphData="graphData"
42
+ :loading="graphLoading"
43
+ :currentPhase="3"
44
+ @refresh="refreshGraph"
45
+ @toggle-maximize="toggleMaximize('graph')"
46
+ />
47
+ </div>
48
+
49
+ <!-- Right Panel: Step3 开始模拟 -->
50
+ <div class="panel-wrapper right" :style="rightPanelStyle">
51
+ <Step3Simulation
52
+ :simulationId="currentSimulationId"
53
+ :maxRounds="maxRounds"
54
+ :projectData="projectData"
55
+ :graphData="graphData"
56
+ :systemLogs="systemLogs"
57
+ @go-back="handleGoBack"
58
+ @next-step="handleNextStep"
59
+ @add-log="addLog"
60
+ @update-status="updateStatus"
61
+ />
62
+ </div>
63
+ </main>
64
+ </div>
65
+ </template>
66
+
67
+ <script setup>
68
+ import { ref, computed, onMounted } from 'vue'
69
+ import { useRoute, useRouter } from 'vue-router'
70
+ import GraphPanel from '../components/GraphPanel.vue'
71
+ import Step3Simulation from '../components/Step3Simulation.vue'
72
+ import { getProject, getGraphData } from '../api/graph'
73
+ import { getSimulation } from '../api/simulation'
74
+
75
+ const route = useRoute()
76
+ const router = useRouter()
77
+
78
+ // Props
79
+ const props = defineProps({
80
+ simulationId: String
81
+ })
82
+
83
+ // Layout State
84
+ const viewMode = ref('split')
85
+
86
+ // Data State
87
+ const currentSimulationId = ref(route.params.simulationId)
88
+ // 直接在初始化时从 query 参数获取 maxRounds,确保子组件能立即获取到值
89
+ const maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null)
90
+ const projectData = ref(null)
91
+ const graphData = ref(null)
92
+ const graphLoading = ref(false)
93
+ const systemLogs = ref([])
94
+ const currentStatus = ref('processing') // processing | completed | error
95
+
96
+ // --- Computed Layout Styles ---
97
+ const leftPanelStyle = computed(() => {
98
+ if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
99
+ if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
100
+ return { width: '50%', opacity: 1, transform: 'translateX(0)' }
101
+ })
102
+
103
+ const rightPanelStyle = computed(() => {
104
+ if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
105
+ if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
106
+ return { width: '50%', opacity: 1, transform: 'translateX(0)' }
107
+ })
108
+
109
+ // --- Status Computed ---
110
+ const statusClass = computed(() => {
111
+ return currentStatus.value
112
+ })
113
+
114
+ const statusText = computed(() => {
115
+ if (currentStatus.value === 'error') return 'Error'
116
+ if (currentStatus.value === 'completed') return 'Completed'
117
+ return 'Running'
118
+ })
119
+
120
+ // --- Helpers ---
121
+ const addLog = (msg) => {
122
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
123
+ systemLogs.value.push({ time, msg })
124
+ if (systemLogs.value.length > 200) {
125
+ systemLogs.value.shift()
126
+ }
127
+ }
128
+
129
+ const updateStatus = (status) => {
130
+ currentStatus.value = status
131
+ }
132
+
133
+ // --- Layout Methods ---
134
+ const toggleMaximize = (target) => {
135
+ if (viewMode.value === target) {
136
+ viewMode.value = 'split'
137
+ } else {
138
+ viewMode.value = target
139
+ }
140
+ }
141
+
142
+ const handleGoBack = () => {
143
+ // 返回到 Step 2 (环境搭建)
144
+ router.push({ name: 'Simulation', params: { simulationId: currentSimulationId.value } })
145
+ }
146
+
147
+ const handleNextStep = () => {
148
+ addLog('进入 Step 4: 报告生成')
149
+ // TODO: 跳转到 Step 4 报告生成页面
150
+ alert('Step 4: 报告生成 - Coming soon...')
151
+ }
152
+
153
+ // --- Data Logic ---
154
+ const loadSimulationData = async () => {
155
+ try {
156
+ addLog(`加载模拟数据: ${currentSimulationId.value}`)
157
+
158
+ // 获取 simulation 信息
159
+ const simRes = await getSimulation(currentSimulationId.value)
160
+ if (simRes.success && simRes.data) {
161
+ const simData = simRes.data
162
+
163
+ // 获取 project 信息
164
+ if (simData.project_id) {
165
+ const projRes = await getProject(simData.project_id)
166
+ if (projRes.success && projRes.data) {
167
+ projectData.value = projRes.data
168
+ addLog(`项目加载成功: ${projRes.data.project_id}`)
169
+
170
+ // 获取 graph 数据
171
+ if (projRes.data.graph_id) {
172
+ await loadGraph(projRes.data.graph_id)
173
+ }
174
+ }
175
+ }
176
+ } else {
177
+ addLog(`加载模拟数据失败: ${simRes.error || '未知错误'}`)
178
+ }
179
+ } catch (err) {
180
+ addLog(`加载异常: ${err.message}`)
181
+ }
182
+ }
183
+
184
+ const loadGraph = async (graphId) => {
185
+ graphLoading.value = true
186
+ try {
187
+ const res = await getGraphData(graphId)
188
+ if (res.success) {
189
+ graphData.value = res.data
190
+ addLog('图谱数据加载成功')
191
+ }
192
+ } catch (err) {
193
+ addLog(`图谱加载失败: ${err.message}`)
194
+ } finally {
195
+ graphLoading.value = false
196
+ }
197
+ }
198
+
199
+ const refreshGraph = () => {
200
+ if (projectData.value?.graph_id) {
201
+ loadGraph(projectData.value.graph_id)
202
+ }
203
+ }
204
+
205
+ onMounted(() => {
206
+ addLog('SimulationRunView 初始化')
207
+
208
+ // 记录 maxRounds 配置(值已在初始化时从 query 参数获取)
209
+ if (maxRounds.value) {
210
+ addLog(`自定义模拟轮数: ${maxRounds.value}`)
211
+ }
212
+
213
+ loadSimulationData()
214
+ })
215
+ </script>
216
+
217
+ <style scoped>
218
+ .main-view {
219
+ height: 100vh;
220
+ display: flex;
221
+ flex-direction: column;
222
+ background: #FFF;
223
+ overflow: hidden;
224
+ font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
225
+ }
226
+
227
+ /* Header */
228
+ .app-header {
229
+ height: 60px;
230
+ border-bottom: 1px solid #EAEAEA;
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: space-between;
234
+ padding: 0 24px;
235
+ background: #FFF;
236
+ z-index: 100;
237
+ }
238
+
239
+ .brand {
240
+ font-family: 'JetBrains Mono', monospace;
241
+ font-weight: 800;
242
+ font-size: 18px;
243
+ letter-spacing: 1px;
244
+ cursor: pointer;
245
+ }
246
+
247
+ .view-switcher {
248
+ display: flex;
249
+ background: #F5F5F5;
250
+ padding: 4px;
251
+ border-radius: 6px;
252
+ gap: 4px;
253
+ }
254
+
255
+ .switch-btn {
256
+ border: none;
257
+ background: transparent;
258
+ padding: 6px 16px;
259
+ font-size: 12px;
260
+ font-weight: 600;
261
+ color: #666;
262
+ border-radius: 4px;
263
+ cursor: pointer;
264
+ transition: all 0.2s;
265
+ }
266
+
267
+ .switch-btn.active {
268
+ background: #FFF;
269
+ color: #000;
270
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
271
+ }
272
+
273
+ .header-right {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 16px;
277
+ }
278
+
279
+ .workflow-step {
280
+ display: flex;
281
+ align-items: center;
282
+ gap: 8px;
283
+ font-size: 14px;
284
+ }
285
+
286
+ .step-num {
287
+ font-family: 'JetBrains Mono', monospace;
288
+ font-weight: 700;
289
+ color: #999;
290
+ }
291
+
292
+ .step-name {
293
+ font-weight: 700;
294
+ color: #000;
295
+ }
296
+
297
+ .step-divider {
298
+ width: 1px;
299
+ height: 14px;
300
+ background-color: #E0E0E0;
301
+ }
302
+
303
+ .status-indicator {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 8px;
307
+ font-size: 12px;
308
+ color: #666;
309
+ font-weight: 500;
310
+ }
311
+
312
+ .dot {
313
+ width: 8px;
314
+ height: 8px;
315
+ border-radius: 50%;
316
+ background: #CCC;
317
+ }
318
+
319
+ .status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }
320
+ .status-indicator.completed .dot { background: #4CAF50; }
321
+ .status-indicator.error .dot { background: #F44336; }
322
+
323
+ @keyframes pulse { 50% { opacity: 0.5; } }
324
+
325
+ /* Content */
326
+ .content-area {
327
+ flex: 1;
328
+ display: flex;
329
+ position: relative;
330
+ overflow: hidden;
331
+ }
332
+
333
+ .panel-wrapper {
334
+ height: 100%;
335
+ overflow: hidden;
336
+ transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
337
+ will-change: width, opacity, transform;
338
+ }
339
+
340
+ .panel-wrapper.left {
341
+ border-right: 1px solid #EAEAEA;
342
+ }
343
+ </style>
frontend/src/views/SimulationView.vue CHANGED
@@ -155,14 +155,19 @@ const handleNextStep = (params = {}) => {
155
  addLog('使用自动配置的模拟轮数')
156
  }
157
 
158
- // TODO: 调用 startSimulation API 并跳转到 Step 3
159
- // 可以在这里调用 /api/simulation/start 接口
160
- // const startParams = {
161
- // simulation_id: currentSimulationId.value,
162
- // ...(params.maxRounds && { max_rounds: params.maxRounds })
163
- // }
 
 
 
 
164
 
165
- alert(`Step 3: 开始模拟 - Coming soon...\n${params.maxRounds ? `轮数: ${params.maxRounds}` : '使用自动配置轮数'}`)
 
166
  }
167
 
168
  // --- Data Logic ---
 
155
  addLog('使用自动配置的模拟轮数')
156
  }
157
 
158
+ // 构建路由参数
159
+ const routeParams = {
160
+ name: 'SimulationRun',
161
+ params: { simulationId: currentSimulationId.value }
162
+ }
163
+
164
+ // 如果有自定义轮数,通过 query 参数传递
165
+ if (params.maxRounds) {
166
+ routeParams.query = { maxRounds: params.maxRounds }
167
+ }
168
 
169
+ // 跳转到 Step 3 页面
170
+ router.push(routeParams)
171
  }
172
 
173
  // --- Data Logic ---