diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index ac3b7e3..57836c5 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -1142,27 +1142,31 @@ class OasisProfileGenerator: """ 保存Reddit Profile为JSON格式 - OASIS Reddit支持两种JSON格式: - 1. 基础格式: user_id, user_name, name, bio, karma, created_at - 2. 详细格式: realname, username, bio, persona, age, gender, mbti, country, profession, interested_topics + 使用与 to_reddit_format() 一致的格式,确保 OASIS 能正确读取。 + 必须包含 user_id 字段,这是 OASIS agent_graph.get_agent() 匹配的关键! - 我们使用详细格式,与用户示例数据(36个简单人设.json)保持一致 - - OASIS要求所有字段都必须存在: - - age: 整数 + 必需字段: + - user_id: 用户ID(整数,用于匹配 initial_posts 中的 poster_agent_id) + - username: 用户名 + - name: 显示名称 + - bio: 简介 + - persona: 详细人设 + - age: 年龄(整数) - gender: "male", "female", 或 "other" - - mbti: MBTI类型字符串 - - country: 国家字符串 + - mbti: MBTI类型 + - country: 国家 """ data = [] - for profile in profiles: - # 使用详细格式(与用户示例兼容) - # 确保所有必需字段都有有效值 + for idx, profile in enumerate(profiles): + # 使用与 to_reddit_format() 一致的格式 item = { - "realname": profile.name, + "user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id "username": profile.user_name, + "name": profile.name, "bio": profile.bio[:150] if profile.bio else f"{profile.name}", "persona": profile.persona or f"{profile.name} is a participant in social discussions.", + "karma": profile.karma if profile.karma else 1000, + "created_at": profile.created_at, # OASIS必需字段 - 确保都有默认值 "age": profile.age if profile.age else 30, "gender": self._normalize_gender(profile.gender), @@ -1181,7 +1185,7 @@ class OasisProfileGenerator: with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) - logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON详细格式,已标准化gender字段)") + logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式,包含user_id字段)") # 保留旧方法名作为别名,保持向后兼容 def save_profiles_to_json( diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 31dfab6..830762b 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -28,7 +28,8 @@ class SimulationStatus(str, Enum): READY = "ready" RUNNING = "running" PAUSED = "paused" - COMPLETED = "completed" + STOPPED = "stopped" # 模拟被手动停止 + COMPLETED = "completed" # 模拟自然完成 FAILED = "failed" diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index 2985a50..997fbb4 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -106,12 +106,22 @@ class SimulationRunState: simulated_hours: int = 0 total_simulation_hours: int = 0 + # 各平台独立轮次和模拟时间(用于双平台并行显示) + twitter_current_round: int = 0 + reddit_current_round: int = 0 + twitter_simulated_hours: int = 0 + reddit_simulated_hours: int = 0 + # 平台状态 twitter_running: bool = False reddit_running: bool = False twitter_actions_count: int = 0 reddit_actions_count: int = 0 + # 平台完成状态(通过检测 actions.jsonl 中的 simulation_end 事件) + twitter_completed: bool = False + reddit_completed: bool = False + # 每轮摘要 rounds: List[RoundSummary] = field(default_factory=list) @@ -152,8 +162,15 @@ class SimulationRunState: "simulated_hours": self.simulated_hours, "total_simulation_hours": self.total_simulation_hours, "progress_percent": round(self.current_round / max(self.total_rounds, 1) * 100, 1), + # 各平台独立轮次和时间 + "twitter_current_round": self.twitter_current_round, + "reddit_current_round": self.reddit_current_round, + "twitter_simulated_hours": self.twitter_simulated_hours, + "reddit_simulated_hours": self.reddit_simulated_hours, "twitter_running": self.twitter_running, "reddit_running": self.reddit_running, + "twitter_completed": self.twitter_completed, + "reddit_completed": self.reddit_completed, "twitter_actions_count": self.twitter_actions_count, "reddit_actions_count": self.reddit_actions_count, "total_actions_count": self.twitter_actions_count + self.reddit_actions_count, @@ -236,8 +253,15 @@ class SimulationRunner: total_rounds=data.get("total_rounds", 0), simulated_hours=data.get("simulated_hours", 0), total_simulation_hours=data.get("total_simulation_hours", 0), + # 各平台独立轮次和时间 + twitter_current_round=data.get("twitter_current_round", 0), + reddit_current_round=data.get("reddit_current_round", 0), + twitter_simulated_hours=data.get("twitter_simulated_hours", 0), + reddit_simulated_hours=data.get("reddit_simulated_hours", 0), twitter_running=data.get("twitter_running", False), reddit_running=data.get("reddit_running", False), + twitter_completed=data.get("twitter_completed", False), + reddit_completed=data.get("reddit_completed", False), twitter_actions_count=data.get("twitter_actions_count", 0), reddit_actions_count=data.get("reddit_actions_count", 0), started_at=data.get("started_at"), @@ -575,8 +599,51 @@ class SimulationRunner: try: action_data = json.loads(line) - # 跳过事件类型的条目(如 simulation_start, round_start 等) + # 处理事件类型的条目 if "event_type" in action_data: + event_type = action_data.get("event_type") + + # 检测 simulation_end 事件,标记平台已完成 + if event_type == "simulation_end": + if platform == "twitter": + state.twitter_completed = True + state.twitter_running = False + logger.info(f"Twitter 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + elif platform == "reddit": + state.reddit_completed = True + state.reddit_running = False + logger.info(f"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + + # 检查是否所有启用的平台都已完成 + # 如果只运行了一个平台,只检查那个平台 + # 如果运行了两个平台,需要两个都完成 + all_completed = cls._check_all_platforms_completed(state) + if all_completed: + state.runner_status = RunnerStatus.COMPLETED + state.completed_at = datetime.now().isoformat() + logger.info(f"所有平台模拟已完成: {state.simulation_id}") + + # 更新轮次信息(从 round_end 事件) + elif event_type == "round_end": + round_num = action_data.get("round", 0) + simulated_hours = action_data.get("simulated_hours", 0) + + # 更新各平台独立的轮次和时间 + if platform == "twitter": + if round_num > state.twitter_current_round: + state.twitter_current_round = round_num + state.twitter_simulated_hours = simulated_hours + elif platform == "reddit": + if round_num > state.reddit_current_round: + state.reddit_current_round = round_num + state.reddit_simulated_hours = simulated_hours + + # 总体轮次取两个平台的最大值 + if round_num > state.current_round: + state.current_round = round_num + # 总体时间取两个平台的最大值 + state.simulated_hours = max(state.twitter_simulated_hours, state.reddit_simulated_hours) + continue action = AgentAction( @@ -607,6 +674,33 @@ class SimulationRunner: logger.warning(f"读取动作日志失败: {log_path}, error={e}") return position + @classmethod + def _check_all_platforms_completed(cls, state: SimulationRunState) -> bool: + """ + 检查所有启用的平台是否都已完成模拟 + + 通过检查对应的 actions.jsonl 文件是否存在来判断平台是否被启用 + + Returns: + True 如果所有启用的平台都已完成 + """ + sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id) + twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl") + reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl") + + # 检查哪些平台被启用(通过文件是否存在判断) + twitter_enabled = os.path.exists(twitter_log) + reddit_enabled = os.path.exists(reddit_log) + + # 如果平台被启用但未完成,则返回 False + if twitter_enabled and not state.twitter_completed: + return False + if reddit_enabled and not state.reddit_completed: + return False + + # 至少有一个平台被启用且已完成 + return twitter_enabled or reddit_enabled + @classmethod def stop_simulation(cls, simulation_id: str) -> SimulationRunState: """停止模拟""" diff --git a/frontend/src/components/GraphPanel.vue b/frontend/src/components/GraphPanel.vue index e42b85b..95ea121 100644 --- a/frontend/src/components/GraphPanel.vue +++ b/frontend/src/components/GraphPanel.vue @@ -19,10 +19,15 @@
- -
- - 实时更新中... + +
+
+ + + + +
+ {{ isSimulating ? 'GraphRAG长短期记忆实时更新中' : '实时更新中...' }}
@@ -219,7 +224,8 @@ import * as d3 from 'd3' const props = defineProps({ graphData: Object, loading: Boolean, - currentPhase: Number + currentPhase: Number, + isSimulating: Boolean }) const emit = defineEmits(['refresh', 'toggle-maximize']) @@ -1153,30 +1159,41 @@ input:checked + .slider:before { /* Building hint */ .graph-building-hint { position: absolute; - bottom: 80px; + bottom: 160px; /* Moved up from 80px */ left: 50%; transform: translateX(-50%); - background: rgba(0,0,0,0.75); + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(8px); color: #fff; - padding: 8px 16px; - border-radius: 20px; - font-size: 12px; + padding: 10px 20px; + border-radius: 30px; + font-size: 13px; display: flex; align-items: center; - gap: 8px; + gap: 10px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 500; + letter-spacing: 0.5px; + z-index: 100; } -.building-dot { - width: 8px; - height: 8px; - background: #4CAF50; - border-radius: 50%; - animation: pulse 1.5s ease-in-out infinite; +.memory-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + animation: breathe 2s ease-in-out infinite; } -@keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.8); } +.memory-icon { + width: 18px; + height: 18px; + color: #4CAF50; +} + +@keyframes breathe { + 0%, 100% { opacity: 0.7; transform: scale(1); filter: drop-shadow(0 0 2px rgba(76, 175, 80, 0.3)); } + 50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); } } /* Loading spinner */ diff --git a/frontend/src/components/Step3Simulation.vue b/frontend/src/components/Step3Simulation.vue index 4020809..b9ffc78 100644 --- a/frontend/src/components/Step3Simulation.vue +++ b/frontend/src/components/Step3Simulation.vue @@ -1,292 +1,184 @@