neuro-simulator 0.0.4__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ # backend/builtin_agent.py
2
+ """Builtin agent module for Neuro Simulator"""
3
+
4
+ import asyncio
5
+ from typing import List, Dict, Union
6
+ from .config import config_manager
7
+ import time
8
+ from datetime import datetime
9
+
10
+ # Global variables
11
+ local_agent = None
12
+
13
+ async def initialize_builtin_agent():
14
+ """Initialize the builtin agent"""
15
+ global local_agent
16
+
17
+ try:
18
+ from .agent.core import Agent as LocalAgentImport
19
+ from .stream_manager import live_stream_manager
20
+
21
+ local_agent = LocalAgentImport(working_dir=live_stream_manager._working_dir)
22
+ await local_agent.initialize()
23
+ except Exception as e:
24
+ print(f"初始化本地 Agent 失败: {e}")
25
+ import traceback
26
+ traceback.print_exc()
27
+ local_agent = None
28
+
29
+ async def reset_builtin_agent_memory():
30
+ """Reset the builtin agent's memory"""
31
+ global local_agent
32
+
33
+ if local_agent is not None:
34
+ await local_agent.reset_all_memory()
35
+ else:
36
+ print("错误: 本地 Agent 未初始化,无法重置记忆。")
37
+
38
+ async def clear_builtin_agent_temp_memory():
39
+ """Clear the builtin agent's temp memory"""
40
+ global local_agent
41
+
42
+ if local_agent is not None:
43
+ # Reset only temp memory
44
+ await local_agent.memory_manager.reset_temp_memory()
45
+ else:
46
+ print("错误: 本地 Agent 未初始化,无法清空临时记忆。")
47
+
48
+ async def clear_builtin_agent_context():
49
+ """Clear the builtin agent's context (dialog history)"""
50
+ global local_agent
51
+
52
+ if local_agent is not None:
53
+ # Reset only context
54
+ await local_agent.memory_manager.reset_context()
55
+
56
+ # Send context update via WebSocket to notify frontend
57
+ from .websocket_manager import connection_manager
58
+ await connection_manager.broadcast({
59
+ "type": "agent_context",
60
+ "action": "update",
61
+ "messages": []
62
+ })
63
+ else:
64
+ print("错误: 本地 Agent 未初始化,无法清空上下文。")
65
+
66
+ async def get_builtin_response(chat_messages: list[dict]) -> dict:
67
+ """Get response from the builtin agent with detailed processing information"""
68
+ global local_agent
69
+
70
+ if local_agent is not None:
71
+ response = await local_agent.process_messages(chat_messages)
72
+
73
+ # Return the response directly without adding processing details to temp memory
74
+ return response
75
+ else:
76
+ print("错误: 本地 Agent 未初始化,无法获取响应。")
77
+ return {
78
+ "input_messages": chat_messages,
79
+ "llm_response": "",
80
+ "tool_executions": [],
81
+ "final_response": "Someone tell Vedal there is a problem with my AI.",
82
+ "error": "Agent not initialized"
83
+ }
neuro_simulator/cli.py CHANGED
@@ -6,6 +6,7 @@ import sys
6
6
  import shutil
7
7
  from pathlib import Path
8
8
 
9
+
9
10
  def main():
10
11
  parser = argparse.ArgumentParser(description="Neuro-Simulator Server")
11
12
  parser.add_argument("-D", "--dir", help="Working directory containing config.yaml")
@@ -98,6 +99,49 @@ def main():
98
99
  except Exception as e:
99
100
  print(f"Warning: Could not copy media folder from package: {e}")
100
101
 
102
+ # Handle agent/memory directory and example JSON files
103
+ agent_memory_dir = work_dir / "agent" / "memory"
104
+ agent_memory_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ # List of example JSON files to copy
107
+ example_memory_files = [
108
+ "context.json",
109
+ "core_memory.json",
110
+ "dialog_history.json",
111
+ "init_memory.json"
112
+ ]
113
+
114
+ # Copy each example memory file if it doesn't exist
115
+ for filename in example_memory_files:
116
+ target_path = agent_memory_dir / filename
117
+ if not target_path.exists():
118
+ try:
119
+ # Try pkg_resources first (for installed packages)
120
+ try:
121
+ import pkg_resources
122
+ package_example_path = pkg_resources.resource_filename('neuro_simulator', f'agent/memory/{filename}')
123
+ if os.path.exists(package_example_path):
124
+ shutil.copy(package_example_path, target_path)
125
+ print(f"Created {target_path} from package example")
126
+ else:
127
+ # Fallback to relative path (for development mode)
128
+ dev_example_path = Path(__file__).parent / "agent" / "memory" / filename
129
+ if dev_example_path.exists():
130
+ shutil.copy(dev_example_path, target_path)
131
+ print(f"Created {target_path} from development example")
132
+ else:
133
+ print(f"Warning: {filename} not found in package or development folder")
134
+ except Exception:
135
+ # Fallback to relative path (for development mode)
136
+ dev_example_path = Path(__file__).parent / "agent" / "memory" / filename
137
+ if dev_example_path.exists():
138
+ shutil.copy(dev_example_path, target_path)
139
+ print(f"Created {target_path} from development example")
140
+ else:
141
+ print(f"Warning: {filename} not found in package or development folder")
142
+ except Exception as e:
143
+ print(f"Warning: Could not copy {filename} from package: {e}")
144
+
101
145
  # Now check for required files and handle errors appropriately
102
146
  errors = []
103
147
 
@@ -128,5 +172,6 @@ def main():
128
172
  from neuro_simulator.main import run_server
129
173
  run_server(args.host, args.port)
130
174
 
175
+
131
176
  if __name__ == "__main__":
132
177
  main()
neuro_simulator/config.py CHANGED
@@ -23,73 +23,61 @@ class ApiKeysSettings(BaseModel):
23
23
  azure_speech_region: Optional[str] = None
24
24
 
25
25
  class StreamMetadataSettings(BaseModel):
26
- streamer_nickname: str = "vedal987"
27
- stream_title: str = "neuro-sama is here for u all"
28
- stream_category: str = "谈天说地"
29
- stream_tags: List[str] = Field(default_factory=lambda: ["Vtuber"])
26
+ streamer_nickname: str
27
+ stream_title: str
28
+ stream_category: str
29
+ stream_tags: List[str] = Field(default_factory=list)
30
+
31
+ class AgentSettings(BaseModel):
32
+ """Settings for the built-in agent"""
33
+ agent_provider: str
34
+ agent_model: str
30
35
 
31
36
  class NeuroBehaviorSettings(BaseModel):
32
- input_chat_sample_size: int = 10
33
- post_speech_cooldown_sec: float = 1.0
34
- initial_greeting: str = "The stream has just started. Greet your audience and say hello!"
37
+ input_chat_sample_size: int
38
+ post_speech_cooldown_sec: float
39
+ initial_greeting: str
35
40
 
36
41
  class AudienceSimSettings(BaseModel):
37
- llm_provider: str = "gemini"
38
- gemini_model: str = "gemini-1.5-flash-latest"
39
- openai_model: str = "gpt-3.5-turbo"
40
- llm_temperature: float = 1.0
41
- chat_generation_interval_sec: int = 2
42
- chats_per_batch: int = 3
43
- max_output_tokens: int = 300
44
- prompt_template: str = Field(default="""
45
- You are a Twitch live stream viewer. Your goal is to generate short, realistic, and relevant chat messages.
46
- The streamer, Neuro-Sama, just said the following:
47
- ---
48
- {neuro_speech}
49
- ---
50
- Based on what Neuro-Sama said, generate a variety of chat messages. Your messages should be:
51
- - Directly reacting to her words.
52
- - Asking follow-up questions.
53
- - Using relevant Twitch emotes (like LUL, Pog, Kappa, etc.).
54
- - General banter related to the topic.
55
- - Short and punchy, like real chat messages.
56
- Do NOT act as the streamer. Do NOT generate full conversations.
57
- Generate exactly {num_chats_to_generate} distinct chat messages. Each message must be prefixed with a DIFFERENT fictional username, like 'ChatterBoy: message text', 'EmoteFan: message text'.
58
- """)
59
- username_blocklist: List[str] = Field(default_factory=lambda: ["ChatterBoy", "EmoteFan", "Username", "User"])
60
- username_pool: List[str] = Field(default_factory=lambda: [
61
- "ChatterBox", "EmoteLord", "QuestionMark", "StreamFan", "PixelPundit",
62
- "CodeSage", "DataDiver", "ByteBard", "LogicLover", "AI_Enthusiast"
63
- ])
42
+ llm_provider: str
43
+ gemini_model: str
44
+ openai_model: str
45
+ llm_temperature: float
46
+ chat_generation_interval_sec: int
47
+ chats_per_batch: int
48
+ max_output_tokens: int
49
+ prompt_template: str = Field(default="")
50
+ username_blocklist: List[str] = Field(default_factory=list)
51
+ username_pool: List[str] = Field(default_factory=list)
64
52
 
65
53
  class TTSSettings(BaseModel):
66
- voice_name: str = "en-US-AshleyNeural"
67
- voice_pitch: float = 1.25
54
+ voice_name: str
55
+ voice_pitch: float
68
56
 
69
57
  class PerformanceSettings(BaseModel):
70
- neuro_input_queue_max_size: int = 200
71
- audience_chat_buffer_max_size: int = 500
72
- initial_chat_backlog_limit: int = 50
58
+ neuro_input_queue_max_size: int
59
+ audience_chat_buffer_max_size: int
60
+ initial_chat_backlog_limit: int
73
61
 
74
62
  class ServerSettings(BaseModel):
75
- host: str = "127.0.0.1"
76
- port: int = 8000
77
- client_origins: List[str] = Field(default_factory=lambda: ["http://localhost:5173", "http://127.0.0.1:5173"])
63
+ host: str
64
+ port: int
65
+ client_origins: List[str] = Field(default_factory=list)
78
66
  panel_password: Optional[str] = None
79
67
 
80
68
  class AppSettings(BaseModel):
81
69
  api_keys: ApiKeysSettings = Field(default_factory=ApiKeysSettings)
82
- stream_metadata: StreamMetadataSettings = Field(default_factory=StreamMetadataSettings)
83
- neuro_behavior: NeuroBehaviorSettings = Field(default_factory=NeuroBehaviorSettings)
84
- audience_simulation: AudienceSimSettings = Field(default_factory=AudienceSimSettings)
85
- tts: TTSSettings = Field(default_factory=TTSSettings)
86
- performance: PerformanceSettings = Field(default_factory=PerformanceSettings)
87
- server: ServerSettings = Field(default_factory=ServerSettings)
70
+ stream_metadata: StreamMetadataSettings
71
+ agent_type: str # 可选 "letta" 或 "builtin"
72
+ agent: AgentSettings
73
+ neuro_behavior: NeuroBehaviorSettings
74
+ audience_simulation: AudienceSimSettings
75
+ tts: TTSSettings
76
+ performance: PerformanceSettings
77
+ server: ServerSettings
88
78
 
89
79
  # --- 2. 加载和管理配置的逻辑 ---
90
80
 
91
- CONFIG_FILE_PATH = "config.yaml"
92
-
93
81
  def _deep_update(source: dict, overrides: dict) -> dict:
94
82
  """
95
83
  Recursively update a dictionary.
@@ -118,59 +106,209 @@ class ConfigManager:
118
106
  self._update_callbacks = []
119
107
  self._initialized = True
120
108
 
109
+ def _get_config_file_path(self) -> str:
110
+ """获取配置文件路径"""
111
+ import sys
112
+ import argparse
113
+
114
+ # 解析命令行参数以获取工作目录
115
+ parser = argparse.ArgumentParser()
116
+ parser.add_argument('--dir', '-D', type=str, help='Working directory')
117
+ # 只解析已知参数,避免干扰其他模块的参数解析
118
+ args, _ = parser.parse_known_args()
119
+
120
+ if args.dir:
121
+ # 如果指定了工作目录,使用该目录下的配置文件
122
+ config_path = os.path.join(args.dir, "config.yaml")
123
+ else:
124
+ # 默认使用 ~/.config/neuro-simulator 目录
125
+ config_path = os.path.join(os.path.expanduser("~"), ".config", "neuro-simulator", "config.yaml")
126
+
127
+ return config_path
128
+
121
129
  def _load_config_from_yaml(self) -> dict:
122
- if not os.path.exists(CONFIG_FILE_PATH):
123
- logging.warning(f"{CONFIG_FILE_PATH} not found. Using default settings. You can create it from config.yaml.example.")
124
- return {}
130
+ # 获取配置文件路径
131
+ config_path = self._get_config_file_path()
132
+
133
+ # 检查配置文件是否存在
134
+ if not os.path.exists(config_path):
135
+ raise FileNotFoundError(f"Configuration file '{config_path}' not found. "
136
+ "Please create it from config.yaml.example.")
137
+
125
138
  try:
126
- with open(CONFIG_FILE_PATH, 'r', encoding='utf-8') as f:
127
- return yaml.safe_load(f) or {}
139
+ with open(config_path, 'r', encoding='utf-8') as f:
140
+ content = yaml.safe_load(f)
141
+ if content is None:
142
+ raise ValueError(f"Configuration file '{config_path}' is empty.")
143
+ return content
128
144
  except Exception as e:
129
- logging.error(f"Error loading or parsing {CONFIG_FILE_PATH}: {e}")
130
- return {}
145
+ logging.error(f"Error loading or parsing {config_path}: {e}")
146
+ raise
131
147
 
132
148
  def _load_settings(self) -> AppSettings:
133
149
  yaml_config = self._load_config_from_yaml()
150
+
134
151
  base_settings = AppSettings.model_validate(yaml_config)
135
152
 
136
153
  # 检查关键配置项
137
- missing_keys = []
138
- if not base_settings.api_keys.letta_token:
139
- missing_keys.append("api_keys.letta_token")
140
- if not base_settings.api_keys.neuro_agent_id:
141
- missing_keys.append("api_keys.neuro_agent_id")
154
+ if base_settings.agent_type == "letta":
155
+ missing_keys = []
156
+ if not base_settings.api_keys.letta_token:
157
+ missing_keys.append("api_keys.letta_token")
158
+ if not base_settings.api_keys.neuro_agent_id:
159
+ missing_keys.append("api_keys.neuro_agent_id")
142
160
 
143
- if missing_keys:
144
- raise ValueError(f"Critical config missing in config.yaml: {', '.join(missing_keys)}. "
145
- f"Please check your config.yaml file against config.yaml.example.")
161
+ if missing_keys:
162
+ raise ValueError(f"Critical config missing in config.yaml for letta agent: {', '.join(missing_keys)}. "
163
+ f"Please check your config.yaml file against config.yaml.example.")
146
164
 
147
165
  logging.info("Configuration loaded successfully.")
148
166
  return base_settings
149
167
 
150
168
  def save_settings(self):
151
- """Saves the current configuration to config.yaml."""
169
+ """Saves the current configuration to config.yaml while preserving comments and formatting."""
152
170
  try:
153
- # 1. Get the current settings from memory
171
+ # 获取配置文件路径
172
+ config_file_path = self._get_config_file_path()
173
+
174
+ # 检查配置文件目录是否存在,如果不存在则创建
175
+ config_dir = os.path.dirname(config_file_path)
176
+ if config_dir and not os.path.exists(config_dir):
177
+ os.makedirs(config_dir, exist_ok=True)
178
+
179
+ # 1. Read the existing config file as text to preserve comments and formatting
180
+ with open(config_file_path, 'r', encoding='utf-8') as f:
181
+ config_lines = f.readlines()
182
+
183
+ # 2. Get the current settings from memory
154
184
  config_to_save = self.settings.model_dump(mode='json', exclude={'api_keys'})
155
185
 
156
- # 2. Read the existing config on disk to get the api_keys that should be preserved.
186
+ # 3. Read the existing config on disk to get the api_keys that should be preserved.
157
187
  existing_config = self._load_config_from_yaml()
158
188
  if 'api_keys' in existing_config:
159
- # 3. Add the preserved api_keys block back to the data to be saved.
189
+ # 4. Add the preserved api_keys block back to the data to be saved.
160
190
  config_to_save['api_keys'] = existing_config['api_keys']
161
191
 
162
- # 4. Rebuild the dictionary to maintain the original order from the Pydantic model.
163
- final_config = {}
164
- for field_name in AppSettings.model_fields:
165
- if field_name in config_to_save:
166
- final_config[field_name] = config_to_save[field_name]
192
+ # 5. Update the config lines while preserving comments and formatting
193
+ updated_lines = self._update_config_lines(config_lines, config_to_save)
167
194
 
168
- # 5. Write the final, correctly ordered configuration to the file.
169
- with open(CONFIG_FILE_PATH, 'w', encoding='utf-8') as f:
170
- yaml.dump(final_config, f, allow_unicode=True, sort_keys=False, indent=2)
171
- logging.info(f"Configuration saved to {CONFIG_FILE_PATH}")
195
+ # 6. Write the updated lines back to the file
196
+ with open(config_file_path, 'w', encoding='utf-8') as f:
197
+ f.writelines(updated_lines)
198
+
199
+ logging.info(f"Configuration saved to {config_file_path}")
172
200
  except Exception as e:
173
- logging.error(f"Failed to save configuration to {CONFIG_FILE_PATH}: {e}")
201
+ logging.error(f"Failed to save configuration to {config_file_path}: {e}")
202
+
203
+ def _update_config_lines(self, lines, config_data):
204
+ """Updates config lines with new values while preserving comments and formatting."""
205
+ updated_lines = []
206
+ i = 0
207
+ while i < len(lines):
208
+ line = lines[i]
209
+ stripped_line = line.strip()
210
+
211
+ # Skip empty lines and comments
212
+ if not stripped_line or stripped_line.startswith('#'):
213
+ updated_lines.append(line)
214
+ i += 1
215
+ continue
216
+
217
+ # Check if this line is a top-level key
218
+ if ':' in stripped_line and not stripped_line.startswith(' ') and not stripped_line.startswith('\t'):
219
+ key = stripped_line.split(':')[0].strip()
220
+ if key in config_data:
221
+ value = config_data[key]
222
+ if isinstance(value, dict):
223
+ # Handle nested dictionaries
224
+ updated_lines.append(line)
225
+ i += 1
226
+ # Process nested items
227
+ i = self._update_nested_config_lines(lines, updated_lines, i, value, 1)
228
+ else:
229
+ # Handle simple values
230
+ indent = len(line) - len(line.lstrip())
231
+ if isinstance(value, str) and '\n' in value:
232
+ # Handle multiline strings
233
+ updated_lines.append(' ' * indent + f"{key}: |\n")
234
+ for subline in value.split('\n'):
235
+ updated_lines.append(' ' * (indent + 2) + subline + '\n')
236
+ elif isinstance(value, list):
237
+ # Handle lists
238
+ updated_lines.append(' ' * indent + f"{key}:\n")
239
+ for item in value:
240
+ updated_lines.append(' ' * (indent + 2) + f"- {item}\n")
241
+ else:
242
+ # Handle simple values
243
+ updated_lines.append(' ' * indent + f"{key}: {value}\n")
244
+ i += 1
245
+ else:
246
+ updated_lines.append(line)
247
+ i += 1
248
+ else:
249
+ updated_lines.append(line)
250
+ i += 1
251
+
252
+ return updated_lines
253
+
254
+ def _update_nested_config_lines(self, lines, updated_lines, start_index, config_data, depth):
255
+ """Recursively updates nested config lines."""
256
+ i = start_index
257
+ indent_size = depth * 2
258
+
259
+ while i < len(lines):
260
+ line = lines[i]
261
+ stripped_line = line.strip()
262
+
263
+ # Check indentation level
264
+ current_indent = len(line) - len(line.lstrip())
265
+
266
+ # If we've moved to a less indented section, we're done with this nested block
267
+ if current_indent < indent_size:
268
+ break
269
+
270
+ # Skip empty lines and comments
271
+ if not stripped_line or stripped_line.startswith('#'):
272
+ updated_lines.append(line)
273
+ i += 1
274
+ continue
275
+
276
+ # Check if this line is a key at the current nesting level
277
+ if current_indent == indent_size and ':' in stripped_line:
278
+ key = stripped_line.split(':')[0].strip()
279
+ if key in config_data:
280
+ value = config_data[key]
281
+ if isinstance(value, dict):
282
+ # Handle nested dictionaries
283
+ updated_lines.append(line)
284
+ i += 1
285
+ i = self._update_nested_config_lines(lines, updated_lines, i, value, depth + 1)
286
+ else:
287
+ # Handle simple values
288
+ if isinstance(value, str) and '\n' in value:
289
+ # Handle multiline strings
290
+ updated_lines.append(' ' * indent_size + f"{key}: |\n")
291
+ for subline in value.split('\n'):
292
+ updated_lines.append(' ' * (indent_size + 2) + subline + '\n')
293
+ i += 1
294
+ elif isinstance(value, list):
295
+ # Handle lists
296
+ updated_lines.append(' ' * indent_size + f"{key}:\n")
297
+ for item in value:
298
+ updated_lines.append(' ' * (indent_size + 2) + f"- {item}\n")
299
+ i += 1
300
+ else:
301
+ # Handle simple values
302
+ updated_lines.append(' ' * indent_size + f"{key}: {value}\n")
303
+ i += 1
304
+ else:
305
+ updated_lines.append(line)
306
+ i += 1
307
+ else:
308
+ updated_lines.append(line)
309
+ i += 1
310
+
311
+ return i
174
312
 
175
313
  def register_update_callback(self, callback):
176
314
  """Registers a callback function to be called on settings update."""
@@ -39,6 +39,20 @@ stream_metadata:
39
39
  # 直播标签 - 用于描述直播内容的标签列表
40
40
  stream_tags: ["Vtuber", "AI", "Cute", "English", "Gremlin", "catgirl"]
41
41
 
42
+ # --- Agent 类型设置 ---
43
+ # 选择一个用来模拟Neuro的Agent提供方
44
+ # - "letta": 使用Letta作为Agent,需要在上方配置Letta API相关信息
45
+ # - "builtin": 使用内建Agent,请在下方填写配置
46
+ agent_type: "builtin"
47
+
48
+ # --- 内建Agent设置 ---
49
+ # 仅当agent_type设置为"builtin"时生效
50
+ agent:
51
+ # Agent的API服务商,支持"gemini"和"openai",API Key配置使用顶部填写的值
52
+ agent_provider: "gemini"
53
+ # Agent使用的模型,切换gemini/openai时记得更改
54
+ agent_model: "gemini-2.5-flash-lite"
55
+
42
56
  # --- Neuro 行为与节奏控制 ---
43
57
  neuro_behavior:
44
58
  # 输入聊天采样数量 - 每次生成 Neuro 回复时从观众聊天中采样的消息数量,不建议太长
@@ -60,7 +74,7 @@ audience_simulation:
60
74
  gemini_model: "gemma-3-27b-it"
61
75
 
62
76
  # OpenAI 模型 - 使用 OpenAI 服务时的具体模型名称
63
- # 使用 SiliconFlow 等第三方服务时可以尝试 THUDM/GLM-4-9B-0414 等模型
77
+ # 推荐使用SiliconFlow9B以下模型免费不限量调用(注意TPM限制)
64
78
  openai_model: "THUDM/GLM-4-9B-0414"
65
79
 
66
80
  # LLM 温度 - 控制 AI 生成内容的随机性,值越高越随机(0-2之间)
@@ -140,4 +154,4 @@ server:
140
154
  # 客户端来源 - 允许跨域访问的客户端地址列表,非本机访问时记得添加一下
141
155
  client_origins:
142
156
  - "http://localhost:5173"
143
- - "http://127.0.0.1:5173"
157
+ - "http://127.0.0.1:5173"