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

Add new JSON data file and enhance simulation management features

Browse files

- Introduced a new JSON data file containing detailed actions and quotes related to the 武大声誉修复基金 initiative.
- Updated the OasisProfileGenerator to ensure compatibility with the new JSON format, emphasizing the inclusion of user_id.
- Modified simulation management to support independent tracking of Twitter and Reddit platforms, including completion status and round information.
- Enhanced the SimulationRunner to accurately reflect the completion state of each platform and added checks for overall simulation completion.
- Improved the GraphPanel and Step3Simulation components to provide real-time updates and better user feedback during simulations.

backend/app/services/oasis_profile_generator.py CHANGED
@@ -1142,27 +1142,31 @@ class OasisProfileGenerator:
1142
  """
1143
  保存Reddit Profile为JSON格式
1144
 
1145
- OASIS Reddit支持两种JSON格式
1146
- 1. 基础格式: user_id, user_name, name, bio, karma, created_at
1147
- 2. 详细格式: realname, username, bio, persona, age, gender, mbti, country, profession, interested_topics
1148
-
1149
- 我们使详细格式,与用示例据(36个简单人设.json)保持一致
1150
-
1151
- OASIS要求所有字段都必须存在:
1152
- - age: 整数
 
 
1153
  - gender: "male", "female", 或 "other"
1154
- - mbti: MBTI类型字符串
1155
- - country: 国家字符串
1156
  """
1157
  data = []
1158
- for profile in profiles:
1159
- # 使用详细格式(与用户示例兼容)
1160
- # 确保所有必需字段都有有效值
1161
  item = {
1162
- "realname": profile.name,
1163
  "username": profile.user_name,
 
1164
  "bio": profile.bio[:150] if profile.bio else f"{profile.name}",
1165
  "persona": profile.persona or f"{profile.name} is a participant in social discussions.",
 
 
1166
  # OASIS必需字段 - 确保都有默认值
1167
  "age": profile.age if profile.age else 30,
1168
  "gender": self._normalize_gender(profile.gender),
@@ -1181,7 +1185,7 @@ class OasisProfileGenerator:
1181
  with open(file_path, 'w', encoding='utf-8') as f:
1182
  json.dump(data, f, ensure_ascii=False, indent=2)
1183
 
1184
- logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON详细格式,已标准化gender字段)")
1185
 
1186
  # 保留旧方法名作为别名,保持向后兼容
1187
  def save_profiles_to_json(
 
1142
  """
1143
  保存Reddit Profile为JSON格式
1144
 
1145
+ 使用与 to_reddit_format() 一致的格式,确保 OASIS 能正确读取。
1146
+ 必须包含 user_id 字段,这是 OASIS agent_graph.get_agent() 匹配的关键!
1147
+
1148
+ 必需字段:
1149
+ - user_id: 用户ID(整,用于匹配 initial_posts 中的 poster_agent_id)
1150
+ - username: 用户名
1151
+ - name: 显示名称
1152
+ - bio: 简介
1153
+ - persona: 详细人设
1154
+ - age: 年龄(整数)
1155
  - gender: "male", "female", 或 "other"
1156
+ - mbti: MBTI类型
1157
+ - country: 国家
1158
  """
1159
  data = []
1160
+ for idx, profile in enumerate(profiles):
1161
+ # 使用与 to_reddit_format() 一致的格式
 
1162
  item = {
1163
+ "user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id
1164
  "username": profile.user_name,
1165
+ "name": profile.name,
1166
  "bio": profile.bio[:150] if profile.bio else f"{profile.name}",
1167
  "persona": profile.persona or f"{profile.name} is a participant in social discussions.",
1168
+ "karma": profile.karma if profile.karma else 1000,
1169
+ "created_at": profile.created_at,
1170
  # OASIS必需字段 - 确保都有默认值
1171
  "age": profile.age if profile.age else 30,
1172
  "gender": self._normalize_gender(profile.gender),
 
1185
  with open(file_path, 'w', encoding='utf-8') as f:
1186
  json.dump(data, f, ensure_ascii=False, indent=2)
1187
 
1188
+ logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式,包含user_id字段)")
1189
 
1190
  # 保留旧方法名作为别名,保持向后兼容
1191
  def save_profiles_to_json(
backend/app/services/simulation_manager.py CHANGED
@@ -28,7 +28,8 @@ class SimulationStatus(str, Enum):
28
  READY = "ready"
29
  RUNNING = "running"
30
  PAUSED = "paused"
31
- COMPLETED = "completed"
 
32
  FAILED = "failed"
33
 
34
 
 
28
  READY = "ready"
29
  RUNNING = "running"
30
  PAUSED = "paused"
31
+ STOPPED = "stopped" # 模拟被手动停止
32
+ COMPLETED = "completed" # 模拟自然完成
33
  FAILED = "failed"
34
 
35
 
backend/app/services/simulation_runner.py CHANGED
@@ -106,12 +106,22 @@ class SimulationRunState:
106
  simulated_hours: int = 0
107
  total_simulation_hours: int = 0
108
 
 
 
 
 
 
 
109
  # 平台状态
110
  twitter_running: bool = False
111
  reddit_running: bool = False
112
  twitter_actions_count: int = 0
113
  reddit_actions_count: int = 0
114
 
 
 
 
 
115
  # 每轮摘要
116
  rounds: List[RoundSummary] = field(default_factory=list)
117
 
@@ -152,8 +162,15 @@ class SimulationRunState:
152
  "simulated_hours": self.simulated_hours,
153
  "total_simulation_hours": self.total_simulation_hours,
154
  "progress_percent": round(self.current_round / max(self.total_rounds, 1) * 100, 1),
 
 
 
 
 
155
  "twitter_running": self.twitter_running,
156
  "reddit_running": self.reddit_running,
 
 
157
  "twitter_actions_count": self.twitter_actions_count,
158
  "reddit_actions_count": self.reddit_actions_count,
159
  "total_actions_count": self.twitter_actions_count + self.reddit_actions_count,
@@ -236,8 +253,15 @@ class SimulationRunner:
236
  total_rounds=data.get("total_rounds", 0),
237
  simulated_hours=data.get("simulated_hours", 0),
238
  total_simulation_hours=data.get("total_simulation_hours", 0),
 
 
 
 
 
239
  twitter_running=data.get("twitter_running", False),
240
  reddit_running=data.get("reddit_running", False),
 
 
241
  twitter_actions_count=data.get("twitter_actions_count", 0),
242
  reddit_actions_count=data.get("reddit_actions_count", 0),
243
  started_at=data.get("started_at"),
@@ -575,8 +599,51 @@ class SimulationRunner:
575
  try:
576
  action_data = json.loads(line)
577
 
578
- # 跳过事件类型的条目(如 simulation_start, round_start 等)
579
  if "event_type" in action_data:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  continue
581
 
582
  action = AgentAction(
@@ -607,6 +674,33 @@ class SimulationRunner:
607
  logger.warning(f"读取动作日志失败: {log_path}, error={e}")
608
  return position
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  @classmethod
611
  def stop_simulation(cls, simulation_id: str) -> SimulationRunState:
612
  """停止模拟"""
 
106
  simulated_hours: int = 0
107
  total_simulation_hours: int = 0
108
 
109
+ # 各平台独立轮次和模拟时间(用于双平台并行显示)
110
+ twitter_current_round: int = 0
111
+ reddit_current_round: int = 0
112
+ twitter_simulated_hours: int = 0
113
+ reddit_simulated_hours: int = 0
114
+
115
  # 平台状态
116
  twitter_running: bool = False
117
  reddit_running: bool = False
118
  twitter_actions_count: int = 0
119
  reddit_actions_count: int = 0
120
 
121
+ # 平台完成状态(通过检测 actions.jsonl 中的 simulation_end 事件)
122
+ twitter_completed: bool = False
123
+ reddit_completed: bool = False
124
+
125
  # 每轮摘要
126
  rounds: List[RoundSummary] = field(default_factory=list)
127
 
 
162
  "simulated_hours": self.simulated_hours,
163
  "total_simulation_hours": self.total_simulation_hours,
164
  "progress_percent": round(self.current_round / max(self.total_rounds, 1) * 100, 1),
165
+ # 各平台独立轮次和时间
166
+ "twitter_current_round": self.twitter_current_round,
167
+ "reddit_current_round": self.reddit_current_round,
168
+ "twitter_simulated_hours": self.twitter_simulated_hours,
169
+ "reddit_simulated_hours": self.reddit_simulated_hours,
170
  "twitter_running": self.twitter_running,
171
  "reddit_running": self.reddit_running,
172
+ "twitter_completed": self.twitter_completed,
173
+ "reddit_completed": self.reddit_completed,
174
  "twitter_actions_count": self.twitter_actions_count,
175
  "reddit_actions_count": self.reddit_actions_count,
176
  "total_actions_count": self.twitter_actions_count + self.reddit_actions_count,
 
253
  total_rounds=data.get("total_rounds", 0),
254
  simulated_hours=data.get("simulated_hours", 0),
255
  total_simulation_hours=data.get("total_simulation_hours", 0),
256
+ # 各平台独立轮次和时间
257
+ twitter_current_round=data.get("twitter_current_round", 0),
258
+ reddit_current_round=data.get("reddit_current_round", 0),
259
+ twitter_simulated_hours=data.get("twitter_simulated_hours", 0),
260
+ reddit_simulated_hours=data.get("reddit_simulated_hours", 0),
261
  twitter_running=data.get("twitter_running", False),
262
  reddit_running=data.get("reddit_running", False),
263
+ twitter_completed=data.get("twitter_completed", False),
264
+ reddit_completed=data.get("reddit_completed", False),
265
  twitter_actions_count=data.get("twitter_actions_count", 0),
266
  reddit_actions_count=data.get("reddit_actions_count", 0),
267
  started_at=data.get("started_at"),
 
599
  try:
600
  action_data = json.loads(line)
601
 
602
+ # 处理事件类型的条目
603
  if "event_type" in action_data:
604
+ event_type = action_data.get("event_type")
605
+
606
+ # 检测 simulation_end 事件,标记平台已完成
607
+ if event_type == "simulation_end":
608
+ if platform == "twitter":
609
+ state.twitter_completed = True
610
+ state.twitter_running = False
611
+ logger.info(f"Twitter 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
612
+ elif platform == "reddit":
613
+ state.reddit_completed = True
614
+ state.reddit_running = False
615
+ logger.info(f"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
616
+
617
+ # 检查是否所有启用的平台都已完成
618
+ # 如果只运行了一个平台,只检查那个平台
619
+ # 如果运行了两个平台,需要两个都完成
620
+ all_completed = cls._check_all_platforms_completed(state)
621
+ if all_completed:
622
+ state.runner_status = RunnerStatus.COMPLETED
623
+ state.completed_at = datetime.now().isoformat()
624
+ logger.info(f"所有平台模拟已完成: {state.simulation_id}")
625
+
626
+ # 更新轮次信息(从 round_end 事件)
627
+ elif event_type == "round_end":
628
+ round_num = action_data.get("round", 0)
629
+ simulated_hours = action_data.get("simulated_hours", 0)
630
+
631
+ # 更新各平台独立的轮次和时间
632
+ if platform == "twitter":
633
+ if round_num > state.twitter_current_round:
634
+ state.twitter_current_round = round_num
635
+ state.twitter_simulated_hours = simulated_hours
636
+ elif platform == "reddit":
637
+ if round_num > state.reddit_current_round:
638
+ state.reddit_current_round = round_num
639
+ state.reddit_simulated_hours = simulated_hours
640
+
641
+ # 总体轮次取两个平台的最大值
642
+ if round_num > state.current_round:
643
+ state.current_round = round_num
644
+ # 总体时间取两个平台的最大值
645
+ state.simulated_hours = max(state.twitter_simulated_hours, state.reddit_simulated_hours)
646
+
647
  continue
648
 
649
  action = AgentAction(
 
674
  logger.warning(f"读取动作日志失败: {log_path}, error={e}")
675
  return position
676
 
677
+ @classmethod
678
+ def _check_all_platforms_completed(cls, state: SimulationRunState) -> bool:
679
+ """
680
+ 检查所有启用的平台是否都已完成模拟
681
+
682
+ 通过检查对应的 actions.jsonl 文件是否存在来判断平台是否被启用
683
+
684
+ Returns:
685
+ True 如果所有启用的平台都已完成
686
+ """
687
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
688
+ twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
689
+ reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
690
+
691
+ # 检查哪些平台被启用(通过文件是否存在判断)
692
+ twitter_enabled = os.path.exists(twitter_log)
693
+ reddit_enabled = os.path.exists(reddit_log)
694
+
695
+ # 如果平台被启用但未完成,则返回 False
696
+ if twitter_enabled and not state.twitter_completed:
697
+ return False
698
+ if reddit_enabled and not state.reddit_completed:
699
+ return False
700
+
701
+ # 至少有一个平台被启用且已完成
702
+ return twitter_enabled or reddit_enabled
703
+
704
  @classmethod
705
  def stop_simulation(cls, simulation_id: str) -> SimulationRunState:
706
  """停止模拟"""
frontend/src/components/GraphPanel.vue CHANGED
@@ -19,10 +19,15 @@
19
  <div v-if="graphData" class="graph-view">
20
  <svg ref="graphSvg" class="graph-svg"></svg>
21
 
22
- <!-- 构建中提示 -->
23
- <div v-if="currentPhase === 1" class="graph-building-hint">
24
- <span class="building-dot"></span>
25
- 实时更新中...
 
 
 
 
 
26
  </div>
27
 
28
  <!-- 节点/边详情面板 -->
@@ -219,7 +224,8 @@ import * as d3 from 'd3'
219
  const props = defineProps({
220
  graphData: Object,
221
  loading: Boolean,
222
- currentPhase: Number
 
223
  })
224
 
225
  const emit = defineEmits(['refresh', 'toggle-maximize'])
@@ -1153,30 +1159,41 @@ input:checked + .slider:before {
1153
  /* Building hint */
1154
  .graph-building-hint {
1155
  position: absolute;
1156
- bottom: 80px;
1157
  left: 50%;
1158
  transform: translateX(-50%);
1159
- background: rgba(0,0,0,0.75);
 
1160
  color: #fff;
1161
- padding: 8px 16px;
1162
- border-radius: 20px;
1163
- font-size: 12px;
1164
  display: flex;
1165
  align-items: center;
1166
- gap: 8px;
 
 
 
 
 
1167
  }
1168
 
1169
- .building-dot {
1170
- width: 8px;
1171
- height: 8px;
1172
- background: #4CAF50;
1173
- border-radius: 50%;
1174
- animation: pulse 1.5s ease-in-out infinite;
 
 
 
 
 
1175
  }
1176
 
1177
- @keyframes pulse {
1178
- 0%, 100% { opacity: 1; transform: scale(1); }
1179
- 50% { opacity: 0.5; transform: scale(0.8); }
1180
  }
1181
 
1182
  /* Loading spinner */
 
19
  <div v-if="graphData" class="graph-view">
20
  <svg ref="graphSvg" class="graph-svg"></svg>
21
 
22
+ <!-- 构建中/模拟中提示 -->
23
+ <div v-if="currentPhase === 1 || isSimulating" class="graph-building-hint">
24
+ <div class="memory-icon-wrapper">
25
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="memory-icon">
26
+ <path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 4.44-4.04z" />
27
+ <path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-4.44-4.04z" />
28
+ </svg>
29
+ </div>
30
+ {{ isSimulating ? 'GraphRAG长短期记忆实时更新中' : '实时更新中...' }}
31
  </div>
32
 
33
  <!-- 节点/边详情面板 -->
 
224
  const props = defineProps({
225
  graphData: Object,
226
  loading: Boolean,
227
+ currentPhase: Number,
228
+ isSimulating: Boolean
229
  })
230
 
231
  const emit = defineEmits(['refresh', 'toggle-maximize'])
 
1159
  /* Building hint */
1160
  .graph-building-hint {
1161
  position: absolute;
1162
+ bottom: 160px; /* Moved up from 80px */
1163
  left: 50%;
1164
  transform: translateX(-50%);
1165
+ background: rgba(0, 0, 0, 0.65);
1166
+ backdrop-filter: blur(8px);
1167
  color: #fff;
1168
+ padding: 10px 20px;
1169
+ border-radius: 30px;
1170
+ font-size: 13px;
1171
  display: flex;
1172
  align-items: center;
1173
+ gap: 10px;
1174
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
1175
+ border: 1px solid rgba(255, 255, 255, 0.1);
1176
+ font-weight: 500;
1177
+ letter-spacing: 0.5px;
1178
+ z-index: 100;
1179
  }
1180
 
1181
+ .memory-icon-wrapper {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ justify-content: center;
1185
+ animation: breathe 2s ease-in-out infinite;
1186
+ }
1187
+
1188
+ .memory-icon {
1189
+ width: 18px;
1190
+ height: 18px;
1191
+ color: #4CAF50;
1192
  }
1193
 
1194
+ @keyframes breathe {
1195
+ 0%, 100% { opacity: 0.7; transform: scale(1); filter: drop-shadow(0 0 2px rgba(76, 175, 80, 0.3)); }
1196
+ 50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); }
1197
  }
1198
 
1199
  /* Loading spinner */
frontend/src/components/Step3Simulation.vue CHANGED
@@ -1,292 +1,184 @@
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>
@@ -333,31 +225,12 @@ 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
@@ -381,10 +254,9 @@ const doStartSimulation = async () => {
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}`)
@@ -398,13 +270,10 @@ const doStartSimulation = async () => {
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 {
@@ -447,6 +316,9 @@ const handleStopSimulation = async () => {
447
  }
448
 
449
  // 轮询状态
 
 
 
450
  const startStatusPolling = () => {
451
  statusTimer = setInterval(fetchRunStatus, 2000)
452
  }
@@ -466,6 +338,10 @@ const stopPolling = () => {
466
  }
467
  }
468
 
 
 
 
 
469
  const fetchRunStatus = async () => {
470
  if (!props.simulationId) return
471
 
@@ -474,17 +350,31 @@ const fetchRunStatus = async () => {
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()
@@ -496,6 +386,30 @@ const fetchRunStatus = async () => {
496
  }
497
  }
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  const fetchRunStatusDetail = async () => {
500
  if (!props.simulationId) return
501
 
@@ -503,82 +417,58 @@ const fetchRunStatusDetail = async () => {
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
 
@@ -594,7 +484,6 @@ watch(() => props.systemLogs?.length, () => {
594
 
595
  onMounted(() => {
596
  addLog('Step3 模拟运行初始化')
597
- // 自动启动模拟
598
  if (props.simulationId) {
599
  doStartSimulation()
600
  }
@@ -610,638 +499,461 @@ onUnmounted(() => {
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;
@@ -1265,7 +977,7 @@ onUnmounted(() => {
1265
  display: flex;
1266
  flex-direction: column;
1267
  gap: 4px;
1268
- height: 80px;
1269
  overflow-y: auto;
1270
  padding-right: 4px;
1271
  }
@@ -1296,12 +1008,12 @@ onUnmounted(() => {
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
  }
 
1
  <template>
2
  <div class="simulation-panel">
3
+ <!-- Top Control Bar -->
4
+ <div class="control-bar">
5
+ <div class="status-group" v-if="phase >= 1">
6
+ <!-- Twitter 平台进度 -->
7
+ <div class="platform-status twitter" :class="{ active: runStatus.twitter_running, completed: runStatus.twitter_completed }">
8
+ <div class="platform-header">
9
+ <span class="platform-icon">𝕏</span>
10
+ <span class="platform-name">Twitter</span>
11
+ <span v-if="runStatus.twitter_completed" class="status-badge">✓</span>
12
  </div>
13
+ <div class="platform-stats">
14
+ <span class="stat">
15
+ <span class="stat-label">R</span>
16
+ <span class="stat-value mono">{{ runStatus.twitter_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>
17
+ </span>
18
+ <span class="stat">
19
+ <span class="stat-label">T</span>
20
+ <span class="stat-value mono">{{ runStatus.twitter_simulated_hours || 0 }}<span class="stat-unit">h</span></span>
21
+ </span>
22
+ <span class="stat">
23
+ <span class="stat-label">A</span>
24
+ <span class="stat-value mono">{{ runStatus.twitter_actions_count || 0 }}</span>
25
+ </span>
26
  </div>
27
  </div>
28
 
29
+ <!-- Reddit 平台进度 -->
30
+ <div class="platform-status reddit" :class="{ active: runStatus.reddit_running, completed: runStatus.reddit_completed }">
31
+ <div class="platform-header">
32
+ <span class="platform-icon">📮</span>
33
+ <span class="platform-name">Reddit</span>
34
+ <span v-if="runStatus.reddit_completed" class="status-badge">✓</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  </div>
36
+ <div class="platform-stats">
37
+ <span class="stat">
38
+ <span class="stat-label">R</span>
39
+ <span class="stat-value mono">{{ runStatus.reddit_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>
40
+ </span>
41
+ <span class="stat">
42
+ <span class="stat-label">T</span>
43
+ <span class="stat-value mono">{{ runStatus.reddit_simulated_hours || 0 }}<span class="stat-unit">h</span></span>
44
+ </span>
45
+ <span class="stat">
46
+ <span class="stat-label">A</span>
47
+ <span class="stat-value mono">{{ runStatus.reddit_actions_count || 0 }}</span>
48
+ </span>
49
  </div>
50
  </div>
51
  </div>
52
+
53
+ <!-- Phase 0 时显示简单状态 -->
54
+ <div class="status-group" v-else>
55
+ <div class="status-item">
56
+ <span class="label">ROUND</span>
57
+ <span class="value mono">0<span class="total">/{{ maxRounds || '-' }}</span></span>
 
 
 
 
 
 
 
58
  </div>
59
+ <div class="status-item">
60
+ <span class="label">TIME</span>
61
+ <span class="value mono">0<span class="unit">h</span></span>
62
+ </div>
63
+ </div>
64
 
65
+ <div class="action-controls">
66
+ <button
67
+ v-if="phase === 0"
68
+ class="ctrl-btn start"
69
+ :disabled="isStarting"
70
+ @click="doStartSimulation"
71
+ >
72
+ <span v-if="isStarting" class="spinner-sm"></span>
73
+ {{ isStarting ? 'INITIALIZING...' : 'START ENGINE' }}
74
+ </button>
75
+
76
+ <button
77
+ v-if="phase === 1"
78
+ class="ctrl-btn stop"
79
+ :disabled="isStopping"
80
+ @click="handleStopSimulation"
81
+ >
82
+ {{ isStopping ? 'STOPPING...' : 'STOP SIMULATION' }}
83
+ </button>
84
+
85
+ <button
86
+ v-if="phase === 2"
87
+ class="ctrl-btn next"
88
+ @click="handleNextStep"
89
+ >
90
+ GENERATE REPORT ➝
91
+ </button>
92
+ </div>
93
+ </div>
94
 
95
+ <!-- Main Content: Dual Timeline or Start Screen -->
96
+ <div class="main-content-area" ref="scrollContainer">
97
+ <!-- Start Screen (Phase 0) -->
98
+ <div v-if="phase === 0" class="start-screen">
99
+ <div class="engine-status">
100
+ <div class="engine-icon">⚡️</div>
101
+ <h2>Simulation Engine Ready</h2>
102
+ <p>Initialize the dual-platform parallel simulation environment.</p>
103
+ </div>
104
+
105
+ <div class="config-grid">
106
+ <div class="config-card">
107
+ <span class="label">SIMULATION ID</span>
108
+ <span class="val mono">{{ simulationId }}</span>
 
109
  </div>
110
+ <div class="config-card">
111
+ <span class="label">TARGET ROUNDS</span>
112
+ <span class="val">{{ maxRounds || 'AUTO' }}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
114
+ <div class="config-card">
115
+ <span class="label">PLATFORMS</span>
116
+ <span class="val">Twitter + Reddit</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  </div>
118
  </div>
 
119
 
120
+ <div v-if="startError" class="error-banner">
121
+ {{ startError }}
 
 
 
 
 
 
 
 
 
122
  </div>
123
+ </div>
124
 
125
+ <!-- Timeline Feed (Phase >= 1) -->
126
+ <div v-else class="timeline-feed">
127
+ <div class="timeline-axis"></div>
128
+
129
+ <TransitionGroup name="timeline-item">
130
+ <div
131
+ v-for="action in reversedActions"
132
+ :key="action.id || `${action.timestamp}-${action.agent_id}`"
133
+ class="timeline-item"
134
+ :class="action.platform"
135
+ >
136
+ <div class="timeline-marker"></div>
137
+ <div class="timeline-card">
138
+ <div class="card-header">
139
+ <div class="agent-info">
140
+ <div class="avatar-placeholder">{{ (action.agent_name || 'A')[0] }}</div>
141
+ <span class="agent-name">{{ action.agent_name }}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  </div>
143
+ <div class="action-badge" :class="getActionTypeClass(action.action_type)">
144
+ {{ getActionTypeLabel(action.action_type) }}
 
 
 
145
  </div>
146
  </div>
147
+
148
+ <div class="card-body">
149
+ <!-- Main Content -->
150
+ <div v-if="action.action_args?.content" class="content-text">
151
+ {{ action.action_args.content }}
152
+ </div>
 
 
 
153
 
154
+ <!-- Quote / Repost Content -->
155
+ <div v-if="action.action_args?.quote_content" class="quoted-block">
156
+ <div class="quote-author">
157
+ Replying to @{{ action.action_args.original_author_name || 'User' }}
158
+ </div>
159
+ <div class="quote-text">
160
+ {{ action.action_args.quote_content }}
161
+ </div>
162
+ </div>
 
 
 
163
 
164
+ <!-- Target Context (e.g. for Likes) -->
165
+ <div v-if="action.action_type?.includes('LIKE') && action.action_args?.post_content" class="target-context">
166
+ <span class="context-label">Liked Post:</span>
167
+ "{{ truncateContent(action.action_args.post_content) }}"
168
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  </div>
170
+
171
+ <div class="card-footer">
172
+ <span class="time-tag">R{{ action.round_num }} • {{ formatActionTime(action.timestamp) }}</span>
173
+ <span class="platform-tag">{{ action.platform === 'twitter' ? 'Twitter' : 'Reddit' }}</span>
174
  </div>
175
  </div>
176
  </div>
177
+ </TransitionGroup>
178
 
179
+ <div v-if="recentActions.length === 0" class="waiting-state">
180
+ <div class="pulse-ring"></div>
181
+ <span>Waiting for agent actions...</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  </div>
183
  </div>
184
  </div>
 
225
  const startError = ref(null)
226
  const runStatus = ref({})
227
  const recentActions = ref([])
228
+ const scrollContainer = ref(null)
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  // Computed
231
+ // Reverse actions to show newest at top
232
+ const reversedActions = computed(() => {
233
+ return [...recentActions.value]
 
 
 
 
 
 
 
234
  })
235
 
236
  // Methods
 
254
  const params = {
255
  simulation_id: props.simulationId,
256
  platform: 'parallel',
257
+ force: true // 强制重新开始
258
  }
259
 
 
260
  if (props.maxRounds) {
261
  params.max_rounds = props.maxRounds
262
  addLog(`设置最大模拟轮数: ${props.maxRounds}`)
 
270
  }
271
  addLog('✓ 模拟引擎启动成功')
272
  addLog(` ├─ PID: ${res.data.process_pid || '-'}`)
 
 
273
 
274
  phase.value = 1
275
  runStatus.value = res.data
276
 
 
277
  startStatusPolling()
278
  startDetailPolling()
279
  } else {
 
316
  }
317
 
318
  // 轮询状态
319
+ let statusTimer = null
320
+ let detailTimer = null
321
+
322
  const startStatusPolling = () => {
323
  statusTimer = setInterval(fetchRunStatus, 2000)
324
  }
 
338
  }
339
  }
340
 
341
+ // 追踪各平台的上一次轮次,用于检测变化并输出日志
342
+ const prevTwitterRound = ref(0)
343
+ const prevRedditRound = ref(0)
344
+
345
  const fetchRunStatus = async () => {
346
  if (!props.simulationId) return
347
 
 
350
 
351
  if (res.success && res.data) {
352
  const data = res.data
 
353
 
354
  runStatus.value = data
355
 
356
+ // 分别测各平台的轮次变化并输出日志
357
+ if (data.twitter_current_round > prevTwitterRound.value) {
358
+ addLog(`[Twitter] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`)
359
+ prevTwitterRound.value = data.twitter_current_round
360
  }
361
 
362
+ if (data.reddit_current_round > prevRedditRound.value) {
363
+ addLog(`[Reddit] R${data.reddit_current_round}/${data.total_rounds} | T:${data.reddit_simulated_hours || 0}h | A:${data.reddit_actions_count}`)
364
+ prevRedditRound.value = data.reddit_current_round
365
+ }
366
+
367
+ // 检测模拟是否已完成(通过 runner_status 或平台完成状态判断)
368
+ const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped'
369
+
370
+ // 额外检查:如果后端还没来得及更新 runner_status,但平台已经报告完成
371
+ // 通过检测 twitter_completed 和 reddit_completed 状态判断
372
+ const platformsCompleted = checkPlatformsCompleted(data)
373
+
374
+ if (isCompleted || platformsCompleted) {
375
+ if (platformsCompleted && !isCompleted) {
376
+ addLog('✓ 检测到所有平台模拟已结束')
377
+ }
378
  addLog('✓ 模拟已完成')
379
  phase.value = 2
380
  stopPolling()
 
386
  }
387
  }
388
 
389
+ // 检查所有启用的平台是否已完成
390
+ const checkPlatformsCompleted = (data) => {
391
+ // 如果没有任何平台数据,返回 false
392
+ if (!data) return false
393
+
394
+ // 检查各平台的完成状态
395
+ const twitterCompleted = data.twitter_completed === true
396
+ const redditCompleted = data.reddit_completed === true
397
+
398
+ // 如果至少有一个平台完成了,检查是否所有启用的平台都完成了
399
+ // 通过 actions_count 判断平台是否被启用(如果 count > 0 或 running 曾为 true)
400
+ const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted
401
+ const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted
402
+
403
+ // 如果没有任何平台被启用,返回 false
404
+ if (!twitterEnabled && !redditEnabled) return false
405
+
406
+ // 检查所有启用的平台是否都已完成
407
+ if (twitterEnabled && !twitterCompleted) return false
408
+ if (redditEnabled && !redditCompleted) return false
409
+
410
+ return true
411
+ }
412
+
413
  const fetchRunStatusDetail = async () => {
414
  if (!props.simulationId) return
415
 
 
417
  const res = await getRunStatusDetail(props.simulationId)
418
 
419
  if (res.success && res.data?.recent_actions) {
420
+ // Keep only last 50 actions for performance
421
+ recentActions.value = res.data.recent_actions.slice(0, 50)
 
 
 
 
422
  }
423
  } catch (err) {
424
  console.warn('获取详细状态失败:', err)
425
  }
426
  }
427
 
428
+ // Helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const getActionTypeLabel = (type) => {
430
  const labels = {
431
+ 'CREATE_POST': 'POST',
432
+ 'REPOST': 'REPOST',
433
+ 'LIKE_POST': 'LIKE',
434
+ 'CREATE_COMMENT': 'COMMENT',
435
+ 'LIKE_COMMENT': 'LIKE',
436
+ 'DO_NOTHING': 'IDLE',
437
+ 'FOLLOW': 'FOLLOW',
438
+ 'SEARCH_POSTS': 'SEARCH',
439
+ 'QUOTE_POST': 'QUOTE'
440
  }
441
+ return labels[type] || type || 'UNKNOWN'
442
  }
443
 
444
  const getActionTypeClass = (type) => {
445
  const classes = {
446
+ 'CREATE_POST': 'badge-post',
447
+ 'REPOST': 'badge-repost',
448
+ 'LIKE_POST': 'badge-like',
449
+ 'CREATE_COMMENT': 'badge-comment',
450
+ 'LIKE_COMMENT': 'badge-like',
451
+ 'QUOTE_POST': 'badge-quote'
 
 
 
452
  }
453
+ return classes[type] || 'badge-default'
454
  }
455
 
456
  const truncateContent = (content) => {
457
  if (!content) return ''
458
+ if (content.length > 100) return content.substring(0, 100) + '...'
 
 
459
  return content
460
  }
461
 
462
  const formatActionTime = (timestamp) => {
463
+ if (!timestamp) return ''
464
  try {
465
+ return new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
 
466
  } catch {
467
+ return ''
468
  }
469
  }
470
 
471
  const handleNextStep = () => {
 
472
  emit('next-step')
473
  }
474
 
 
484
 
485
  onMounted(() => {
486
  addLog('Step3 模拟运行初始化')
 
487
  if (props.simulationId) {
488
  doStartSimulation()
489
  }
 
499
  height: 100%;
500
  display: flex;
501
  flex-direction: column;
502
+ background: #F0F2F5;
503
  font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
504
+ overflow: hidden;
505
  }
506
 
507
+ /* --- Control Bar --- */
508
+ .control-bar {
 
 
 
 
 
 
 
 
 
509
  background: #FFF;
510
+ padding: 12px 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  display: flex;
512
  justify-content: space-between;
513
  align-items: center;
514
+ border-bottom: 1px solid #EAEAEA;
515
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
516
+ z-index: 10;
517
+ height: 64px;
518
  }
519
 
520
+ .status-group {
521
  display: flex;
522
+ gap: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  }
524
 
525
+ .status-item {
526
+ display: flex;
527
+ flex-direction: column;
 
528
  }
529
 
530
+ .status-item .label {
531
  font-size: 10px;
532
+ color: #999;
 
533
  font-weight: 600;
534
+ letter-spacing: 0.5px;
535
  }
536
 
537
+ .status-item .value {
538
+ font-size: 16px;
539
+ font-weight: 700;
540
+ color: #333;
 
 
 
 
 
 
541
  }
542
 
543
+ .status-item .total, .status-item .unit {
544
  font-size: 12px;
545
+ color: #999;
546
+ font-weight: 500;
 
 
 
 
 
 
 
 
 
547
  }
548
 
549
+ /* 双平台进度卡片 */
550
+ .platform-status {
551
  display: flex;
552
+ flex-direction: column;
553
+ gap: 4px;
554
+ padding: 8px 14px;
555
+ border-radius: 8px;
556
+ background: #F5F5F5;
557
+ opacity: 0.6;
558
+ transition: all 0.3s;
559
+ border-left: 3px solid transparent;
560
  }
561
 
562
+ .platform-status.active {
563
+ opacity: 1;
564
+ background: #FFF;
565
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
566
  }
567
 
568
+ .platform-status.twitter.active {
569
+ border-left-color: #1DA1F2;
 
 
570
  }
571
 
572
+ .platform-status.reddit.active {
573
+ border-left-color: #FF5722;
574
  }
575
 
576
+ .platform-status.completed {
577
+ opacity: 1;
578
+ background: #F1F8E9;
579
+ border-left-color: #4CAF50;
 
 
 
580
  }
581
 
582
+ .platform-header {
583
  display: flex;
584
  align-items: center;
585
+ gap: 6px;
 
 
 
 
586
  }
587
 
588
+ .platform-icon {
589
+ font-size: 14px;
 
 
 
 
 
 
 
590
  }
591
 
592
+ .platform-name {
 
 
 
 
 
593
  font-size: 11px;
594
+ font-weight: 700;
595
+ color: #666;
596
+ text-transform: uppercase;
597
+ letter-spacing: 0.5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  }
599
 
600
+ .platform-status.twitter.active .platform-name { color: #1DA1F2; }
601
+ .platform-status.reddit.active .platform-name { color: #FF5722; }
602
+ .platform-status.completed .platform-name { color: #2E7D32; }
 
 
 
603
 
604
+ .platform-stats {
605
  display: flex;
606
+ gap: 12px;
607
  }
608
 
609
+ .stat {
610
  display: flex;
611
+ align-items: baseline;
612
  gap: 2px;
613
  }
614
 
615
+ .stat-label {
616
+ font-size: 9px;
617
  color: #999;
618
+ font-weight: 600;
619
  }
620
 
621
+ .stat-value {
 
622
  font-size: 13px;
623
+ font-weight: 700;
624
  color: #333;
625
  }
626
 
627
+ .stat-total, .stat-unit {
628
+ font-size: 10px;
629
+ color: #999;
630
+ font-weight: 500;
 
 
 
 
 
 
 
 
 
 
631
  }
632
 
633
+ .status-badge {
634
+ font-size: 10px;
635
+ color: #2E7D32;
636
+ margin-left: 4px;
637
  }
638
 
639
+ .ctrl-btn {
640
+ padding: 8px 16px;
641
+ border-radius: 6px;
642
+ font-weight: 600;
643
+ font-size: 12px;
644
+ border: none;
645
+ cursor: pointer;
646
  display: flex;
647
  align-items: center;
648
  gap: 8px;
649
+ transition: all 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  }
651
 
652
+ .ctrl-btn.start {
653
+ background: #000;
654
  color: #FFF;
655
  }
656
 
657
+ .ctrl-btn.stop {
658
+ background: #FFEBEE;
659
+ color: #C62828;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  }
661
 
662
+ .ctrl-btn.next {
663
+ background: #E8F5E9;
664
+ color: #2E7D32;
 
 
 
 
 
 
 
665
  }
666
 
667
+ .ctrl-btn:hover:not(:disabled) {
668
+ transform: translateY(-1px);
669
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
670
  }
671
 
672
+ .ctrl-btn:disabled {
673
+ opacity: 0.6;
674
+ cursor: not-allowed;
 
675
  }
676
 
677
+ /* --- Main Content Area --- */
678
+ .main-content-area {
679
+ flex: 1;
680
  overflow-y: auto;
681
+ position: relative;
 
682
  background: #FAFAFA;
683
  }
684
 
685
+ /* Start Screen */
686
+ .start-screen {
687
+ height: 100%;
 
 
 
 
 
 
 
 
688
  display: flex;
689
  flex-direction: column;
690
+ justify-content: center;
691
+ align-items: center;
692
+ padding: 40px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  }
694
 
695
+ .engine-status {
696
+ text-align: center;
697
+ margin-bottom: 40px;
698
  }
699
 
700
+ .engine-icon {
701
+ font-size: 48px;
702
+ margin-bottom: 16px;
703
  }
704
 
705
+ .engine-status h2 {
706
+ font-size: 24px;
707
+ font-weight: 700;
708
+ margin-bottom: 8px;
 
709
  }
710
 
711
+ .engine-status p {
712
+ color: #666;
713
  font-size: 14px;
714
  }
715
 
716
+ .config-grid {
717
+ display: grid;
718
+ grid-template-columns: repeat(3, 1fr);
719
+ gap: 16px;
720
+ width: 100%;
721
+ max-width: 600px;
722
  }
723
 
724
+ .config-card {
725
+ background: #FFF;
726
+ padding: 16px;
727
+ border-radius: 8px;
728
+ border: 1px solid #EAEAEA;
729
+ text-align: center;
730
  }
731
 
732
+ .config-card .label {
733
+ display: block;
734
+ font-size: 10px;
735
+ color: #999;
736
+ margin-bottom: 8px;
737
  }
738
 
739
+ .config-card .val {
740
+ display: block;
741
+ font-size: 14px;
742
  font-weight: 600;
743
  color: #333;
744
  }
745
 
746
+ /* --- Timeline Feed --- */
747
+ .timeline-feed {
748
+ padding: 24px;
749
+ position: relative;
750
+ min-height: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  }
752
 
753
+ .timeline-axis {
754
+ position: absolute;
755
+ left: 50%;
756
+ top: 0;
757
+ bottom: 0;
758
+ width: 2px;
759
+ background: #E0E0E0;
760
+ transform: translateX(-50%);
761
  }
762
 
763
+ .timeline-item {
764
  display: flex;
765
+ justify-content: center;
766
+ margin-bottom: 24px;
767
+ position: relative;
768
+ width: 100%;
769
  }
770
 
771
+ .timeline-marker {
772
+ position: absolute;
773
+ left: 50%;
774
+ top: 20px;
775
+ width: 12px;
776
+ height: 12px;
777
+ border-radius: 50%;
778
+ background: #FFF;
779
+ border: 2px solid #999;
780
+ transform: translateX(-50%);
781
+ z-index: 2;
782
  }
783
 
784
+ .timeline-item.twitter .timeline-marker { border-color: #1DA1F2; }
785
+ .timeline-item.reddit .timeline-marker { border-color: #FF5722; }
 
 
786
 
787
+ .timeline-card {
788
+ width: 45%;
789
+ background: #FFF;
790
+ border-radius: 8px;
791
+ padding: 16px;
792
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
793
+ position: relative;
794
+ border: 1px solid transparent;
795
+ transition: all 0.3s;
796
  }
797
 
798
+ /* Left side (Twitter) */
799
+ .timeline-item.twitter .timeline-card {
800
+ margin-right: auto;
801
+ margin-left: 20px; /* Gap from center */
802
+ border-left: 4px solid #1DA1F2;
803
  }
804
 
805
+ /* Right side (Reddit) */
806
+ .timeline-item.reddit .timeline-card {
807
+ margin-left: auto;
808
+ margin-right: 20px; /* Gap from center */
809
+ border-left: 4px solid #FF5722;
810
  }
811
 
812
+ .timeline-item.twitter {
813
+ justify-content: flex-start;
814
+ padding-right: 50%;
815
  }
816
 
817
+ .timeline-item.reddit {
818
+ justify-content: flex-end;
819
+ padding-left: 50%;
820
  }
821
 
822
+ /* Card Styles */
823
+ .card-header {
824
+ display: flex;
825
+ justify-content: space-between;
826
+ align-items: center;
827
+ margin-bottom: 12px;
828
  }
829
 
830
+ .agent-info {
831
  display: flex;
832
  align-items: center;
833
+ gap: 8px;
 
 
 
 
834
  }
835
 
836
+ .avatar-placeholder {
837
+ width: 24px;
838
+ height: 24px;
839
+ background: #EEE;
 
840
  border-radius: 50%;
841
  display: flex;
842
  align-items: center;
843
  justify-content: center;
844
+ font-size: 12px;
845
  font-weight: 700;
846
+ color: #666;
847
  }
848
 
849
+ .agent-name {
850
+ font-size: 13px;
 
 
 
 
 
851
  font-weight: 700;
852
+ color: #333;
 
853
  }
854
 
855
+ .action-badge {
856
+ font-size: 10px;
857
+ padding: 2px 6px;
858
+ border-radius: 4px;
859
+ font-weight: 600;
860
+ text-transform: uppercase;
861
  }
862
 
863
+ .badge-post { background: #E3F2FD; color: #1565C0; }
864
+ .badge-quote { background: #F3E5F5; color: #7B1FA2; }
865
+ .badge-like { background: #FFEBEE; color: #C62828; }
866
+ .badge-repost { background: #E8F5E9; color: #2E7D32; }
867
+ .badge-comment { background: #FFF3E0; color: #E65100; }
868
+ .badge-default { background: #F5F5F5; color: #757575; }
869
+
870
+ .content-text {
871
+ font-size: 13px;
872
+ line-height: 1.5;
873
+ color: #333;
874
+ margin-bottom: 8px;
875
  }
876
 
877
+ .quoted-block {
 
 
878
  background: #F9F9F9;
879
+ border-left: 3px solid #DDD;
880
+ padding: 8px 12px;
881
+ border-radius: 0 4px 4px 0;
882
+ margin-top: 8px;
883
  }
884
 
885
+ .quote-author {
886
+ font-size: 11px;
887
+ color: #666;
888
+ margin-bottom: 4px;
 
 
889
  }
890
 
891
+ .quote-text {
892
+ font-size: 12px;
893
+ color: #555;
894
+ font-style: italic;
895
+ }
896
+
897
+ .target-context {
898
  font-size: 11px;
899
  color: #666;
900
+ background: #F5F5F5;
901
+ padding: 6px;
902
+ border-radius: 4px;
903
  }
904
 
905
+ .card-footer {
906
+ margin-top: 12px;
907
  display: flex;
908
+ justify-content: space-between;
909
+ align-items: center;
910
+ font-size: 10px;
911
+ color: #999;
912
  }
913
 
914
+ .time-tag { font-family: 'JetBrains Mono'; }
 
 
 
915
 
916
+ .waiting-state {
917
+ position: absolute;
918
+ top: 50%;
919
+ left: 50%;
920
+ transform: translate(-50%, -50%);
921
+ display: flex;
922
+ flex-direction: column;
923
  align-items: center;
924
+ gap: 16px;
925
+ color: #999;
 
 
 
 
 
 
 
926
  }
927
 
928
+ .pulse-ring {
929
+ width: 40px;
930
+ height: 40px;
931
+ border-radius: 50%;
932
+ border: 2px solid #DDD;
933
+ animation: ripple 1.5s infinite;
934
  }
935
 
936
+ @keyframes ripple {
937
+ 0% { transform: scale(0.8); opacity: 1; }
938
+ 100% { transform: scale(2); opacity: 0; }
939
  }
940
 
941
+ /* Transition Group */
942
+ .timeline-item-enter-active,
943
+ .timeline-item-leave-active {
944
+ transition: all 0.5s ease;
945
  }
946
 
947
+ .timeline-item-enter-from {
948
+ opacity: 0;
949
+ transform: translateY(-20px);
950
  }
951
 
952
+ .timeline-item-leave-to {
953
+ opacity: 0;
 
954
  }
955
 
956
+ /* --- System Logs (unchanged) --- */
957
  .system-logs {
958
  background: #000;
959
  color: #DDD;
 
977
  display: flex;
978
  flex-direction: column;
979
  gap: 4px;
980
+ height: 120px;
981
  overflow-y: auto;
982
  padding-right: 4px;
983
  }
 
1008
  word-break: break-all;
1009
  }
1010
 
1011
+ .mono { font-family: 'JetBrains Mono', monospace; }
1012
  .spinner-sm {
1013
  width: 14px;
1014
  height: 14px;
1015
+ border: 2px solid rgba(255,255,255,0.3);
1016
+ border-top-color: #FFF;
1017
  border-radius: 50%;
1018
  animation: spin 0.8s linear infinite;
1019
  }
frontend/src/views/SimulationRunView.vue CHANGED
@@ -41,6 +41,7 @@
41
  :graphData="graphData"
42
  :loading="graphLoading"
43
  :currentPhase="3"
 
44
  @refresh="refreshGraph"
45
  @toggle-maximize="toggleMaximize('graph')"
46
  />
@@ -65,7 +66,7 @@
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'
@@ -117,6 +118,8 @@ const statusText = computed(() => {
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')
@@ -182,12 +185,19 @@ const loadSimulationData = async () => {
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}`)
@@ -202,6 +212,32 @@ const refreshGraph = () => {
202
  }
203
  }
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  onMounted(() => {
206
  addLog('SimulationRunView 初始化')
207
 
@@ -212,6 +248,10 @@ onMounted(() => {
212
 
213
  loadSimulationData()
214
  })
 
 
 
 
215
  </script>
216
 
217
  <style scoped>
 
41
  :graphData="graphData"
42
  :loading="graphLoading"
43
  :currentPhase="3"
44
+ :isSimulating="isSimulating"
45
  @refresh="refreshGraph"
46
  @toggle-maximize="toggleMaximize('graph')"
47
  />
 
66
  </template>
67
 
68
  <script setup>
69
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
70
  import { useRoute, useRouter } from 'vue-router'
71
  import GraphPanel from '../components/GraphPanel.vue'
72
  import Step3Simulation from '../components/Step3Simulation.vue'
 
118
  return 'Running'
119
  })
120
 
121
+ const isSimulating = computed(() => currentStatus.value === 'processing')
122
+
123
  // --- Helpers ---
124
  const addLog = (msg) => {
125
  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')
 
185
  }
186
 
187
  const loadGraph = async (graphId) => {
188
+ // 当正在模拟时,自动刷新不显示全屏 loading,以免闪烁
189
+ // 手动刷新或初始加载时显示 loading
190
+ if (!isSimulating.value) {
191
+ graphLoading.value = true
192
+ }
193
+
194
  try {
195
  const res = await getGraphData(graphId)
196
  if (res.success) {
197
  graphData.value = res.data
198
+ if (!isSimulating.value) {
199
+ addLog('图谱数据加载成功')
200
+ }
201
  }
202
  } catch (err) {
203
  addLog(`图谱加载失败: ${err.message}`)
 
212
  }
213
  }
214
 
215
+ // --- Auto Refresh Logic ---
216
+ let graphRefreshTimer = null
217
+
218
+ const startGraphRefresh = () => {
219
+ if (graphRefreshTimer) return
220
+ addLog('开启图谱实时刷新 (30s)')
221
+ // 立即刷新一次,然后每30秒刷新
222
+ graphRefreshTimer = setInterval(refreshGraph, 30000)
223
+ }
224
+
225
+ const stopGraphRefresh = () => {
226
+ if (graphRefreshTimer) {
227
+ clearInterval(graphRefreshTimer)
228
+ graphRefreshTimer = null
229
+ addLog('停止图谱实时刷新')
230
+ }
231
+ }
232
+
233
+ watch(isSimulating, (newValue) => {
234
+ if (newValue) {
235
+ startGraphRefresh()
236
+ } else {
237
+ stopGraphRefresh()
238
+ }
239
+ }, { immediate: true })
240
+
241
  onMounted(() => {
242
  addLog('SimulationRunView 初始化')
243
 
 
248
 
249
  loadSimulationData()
250
  })
251
+
252
+ onUnmounted(() => {
253
+ stopGraphRefresh()
254
+ })
255
  </script>
256
 
257
  <style scoped>
模拟详情接口返回示例.json ADDED
The diff for this file is too large to render. See raw diff