neuro-simulator 0.0.1__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.
- neuro_simulator/__init__.py +1 -0
- neuro_simulator/audio_synthesis.py +66 -0
- neuro_simulator/chatbot.py +104 -0
- neuro_simulator/cli.py +132 -0
- neuro_simulator/config.py +226 -0
- neuro_simulator/letta.py +135 -0
- neuro_simulator/log_handler.py +29 -0
- neuro_simulator/main.py +526 -0
- neuro_simulator/media/neuro_start.mp4 +0 -0
- neuro_simulator/process_manager.py +67 -0
- neuro_simulator/settings.yaml.example +143 -0
- neuro_simulator/shared_state.py +11 -0
- neuro_simulator/stream_chat.py +29 -0
- neuro_simulator/stream_manager.py +143 -0
- neuro_simulator/websocket_manager.py +51 -0
- neuro_simulator-0.0.1.dist-info/METADATA +181 -0
- neuro_simulator-0.0.1.dist-info/RECORD +20 -0
- neuro_simulator-0.0.1.dist-info/WHEEL +5 -0
- neuro_simulator-0.0.1.dist-info/entry_points.txt +2 -0
- neuro_simulator-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
|
|
1
|
+
# neuro_simulator/__init__.py
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# backend/audio_synthesis.py
|
2
|
+
import html
|
3
|
+
import base64
|
4
|
+
import azure.cognitiveservices.speech as speechsdk
|
5
|
+
import asyncio
|
6
|
+
from .config import config_manager
|
7
|
+
|
8
|
+
async def synthesize_audio_segment(text: str, voice_name: str = None, pitch: float = None) -> tuple[str, float]:
|
9
|
+
"""
|
10
|
+
使用 Azure TTS 合成音频。
|
11
|
+
如果 voice_name 或 pitch 未提供,则使用配置中的默认值。
|
12
|
+
返回 Base64 编码的音频字符串和音频时长(秒)。
|
13
|
+
"""
|
14
|
+
# 使用 config_manager.settings 中的值
|
15
|
+
azure_key = config_manager.settings.api_keys.azure_speech_key
|
16
|
+
azure_region = config_manager.settings.api_keys.azure_speech_region
|
17
|
+
|
18
|
+
if not azure_key or not azure_region:
|
19
|
+
raise ValueError("Azure Speech Key 或 Region 未在配置中设置。")
|
20
|
+
|
21
|
+
# 如果未传入参数,则使用配置的默认值
|
22
|
+
final_voice_name = voice_name if voice_name is not None else config_manager.settings.tts.voice_name
|
23
|
+
final_pitch = pitch if pitch is not None else config_manager.settings.tts.voice_pitch
|
24
|
+
|
25
|
+
speech_config = speechsdk.SpeechConfig(subscription=azure_key, region=azure_region)
|
26
|
+
speech_config.set_speech_synthesis_output_format(speechsdk.SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3)
|
27
|
+
|
28
|
+
pitch_percent = int((final_pitch - 1.0) * 100)
|
29
|
+
pitch_ssml_value = f"+{pitch_percent}%" if pitch_percent >= 0 else f"{pitch_percent}%"
|
30
|
+
|
31
|
+
escaped_text = html.escape(text)
|
32
|
+
|
33
|
+
ssml_string = f"""
|
34
|
+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
|
35
|
+
<voice name="{final_voice_name}">
|
36
|
+
<prosody pitch="{pitch_ssml_value}">
|
37
|
+
{escaped_text}
|
38
|
+
</prosody>
|
39
|
+
</voice>
|
40
|
+
</speak>
|
41
|
+
"""
|
42
|
+
|
43
|
+
synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=None)
|
44
|
+
|
45
|
+
def _perform_synthesis_sync():
|
46
|
+
return synthesizer.speak_ssml_async(ssml_string).get()
|
47
|
+
|
48
|
+
try:
|
49
|
+
result = await asyncio.to_thread(_perform_synthesis_sync)
|
50
|
+
|
51
|
+
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
|
52
|
+
audio_data = result.audio_data
|
53
|
+
encoded_audio = base64.b64encode(audio_data).decode('utf-8')
|
54
|
+
audio_duration_sec = result.audio_duration.total_seconds()
|
55
|
+
print(f"TTS 合成完成: '{text[:30]}...' (时长: {audio_duration_sec:.2f}s)")
|
56
|
+
return encoded_audio, audio_duration_sec
|
57
|
+
else:
|
58
|
+
cancellation_details = result.cancellation_details
|
59
|
+
error_message = f"TTS 合成失败/取消 (原因: {cancellation_details.reason})。文本: '{text}'"
|
60
|
+
if cancellation_details.error_details:
|
61
|
+
error_message += f" | 详情: {cancellation_details.error_details}"
|
62
|
+
print(f"错误: {error_message}")
|
63
|
+
raise Exception(error_message)
|
64
|
+
except Exception as e:
|
65
|
+
print(f"错误: 在调用 Azure TTS SDK 时发生异常: {e}")
|
66
|
+
raise
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# backend/chatbot.py
|
2
|
+
from google import genai
|
3
|
+
from google.genai import types
|
4
|
+
from openai import AsyncOpenAI
|
5
|
+
import random
|
6
|
+
import asyncio
|
7
|
+
from .config import config_manager, AppSettings
|
8
|
+
import neuro_simulator.shared_state as shared_state
|
9
|
+
|
10
|
+
class AudienceLLMClient:
|
11
|
+
async def generate_chat_messages(self, prompt: str, max_tokens: int) -> str:
|
12
|
+
raise NotImplementedError
|
13
|
+
|
14
|
+
class GeminiAudienceLLM(AudienceLLMClient):
|
15
|
+
def __init__(self, api_key: str, model_name: str):
|
16
|
+
if not api_key:
|
17
|
+
raise ValueError("Gemini API Key is not provided for GeminiAudienceLLM.")
|
18
|
+
# 根据新文档,正确初始化客户端
|
19
|
+
self.client = genai.Client(api_key=api_key)
|
20
|
+
self.model_name = model_name
|
21
|
+
print(f"已初始化 GeminiAudienceLLM (new SDK),模型: {self.model_name}")
|
22
|
+
|
23
|
+
async def generate_chat_messages(self, prompt: str, max_tokens: int) -> str:
|
24
|
+
# 根据新文档,使用正确的异步方法和参数
|
25
|
+
response = await self.client.aio.models.generate_content(
|
26
|
+
model=self.model_name,
|
27
|
+
contents=prompt,
|
28
|
+
config=types.GenerateContentConfig(
|
29
|
+
temperature=config_manager.settings.audience_simulation.llm_temperature,
|
30
|
+
max_output_tokens=max_tokens
|
31
|
+
)
|
32
|
+
)
|
33
|
+
raw_chat_text = ""
|
34
|
+
if hasattr(response, 'text') and response.text:
|
35
|
+
raw_chat_text = response.text
|
36
|
+
elif response.candidates and response.candidates[0].content and response.candidates[0].content.parts:
|
37
|
+
for part in response.candidates[0].content.parts:
|
38
|
+
if hasattr(part, 'text') and part.text:
|
39
|
+
raw_chat_text += part.text
|
40
|
+
return raw_chat_text
|
41
|
+
|
42
|
+
class OpenAIAudienceLLM(AudienceLLMClient):
|
43
|
+
def __init__(self, api_key: str, model_name: str, base_url: str | None):
|
44
|
+
if not api_key:
|
45
|
+
raise ValueError("OpenAI API Key is not provided for OpenAIAudienceLLM.")
|
46
|
+
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
47
|
+
self.model_name = model_name
|
48
|
+
print(f"已初始化 OpenAIAudienceLLM,模型: {self.model_name},API Base: {base_url}")
|
49
|
+
|
50
|
+
async def generate_chat_messages(self, prompt: str, max_tokens: int) -> str:
|
51
|
+
response = await self.client.chat.completions.create(
|
52
|
+
model=self.model_name,
|
53
|
+
messages=[{"role": "user", "content": prompt}],
|
54
|
+
temperature=config_manager.settings.audience_simulation.llm_temperature,
|
55
|
+
max_tokens=max_tokens,
|
56
|
+
)
|
57
|
+
if response.choices and response.choices[0].message and response.choices[0].message.content:
|
58
|
+
return response.choices[0].message.content.strip()
|
59
|
+
return ""
|
60
|
+
|
61
|
+
async def get_dynamic_audience_prompt() -> str:
|
62
|
+
current_neuro_speech = ""
|
63
|
+
async with shared_state.neuro_last_speech_lock:
|
64
|
+
current_neuro_speech = shared_state.neuro_last_speech
|
65
|
+
|
66
|
+
# 使用 settings 对象中的模板和变量
|
67
|
+
prompt = config_manager.settings.audience_simulation.prompt_template.format(
|
68
|
+
neuro_speech=current_neuro_speech,
|
69
|
+
num_chats_to_generate=config_manager.settings.audience_simulation.chats_per_batch
|
70
|
+
)
|
71
|
+
return prompt
|
72
|
+
|
73
|
+
class ChatbotManager:
|
74
|
+
def __init__(self):
|
75
|
+
self.client: AudienceLLMClient = self._create_client(config_manager.settings)
|
76
|
+
self._last_checked_settings: dict = config_manager.settings.audience_simulation.model_dump()
|
77
|
+
print("ChatbotManager initialized.")
|
78
|
+
|
79
|
+
def _create_client(self, settings: AppSettings) -> AudienceLLMClient:
|
80
|
+
provider = settings.audience_simulation.llm_provider
|
81
|
+
print(f"正在为 provider 创建新的 audience LLM client: {provider}")
|
82
|
+
if provider.lower() == "gemini":
|
83
|
+
if not settings.api_keys.gemini_api_key:
|
84
|
+
raise ValueError("GEMINI_API_KEY 未在配置中设置")
|
85
|
+
return GeminiAudienceLLM(api_key=settings.api_keys.gemini_api_key, model_name=settings.audience_simulation.gemini_model)
|
86
|
+
elif provider.lower() == "openai":
|
87
|
+
if not settings.api_keys.openai_api_key:
|
88
|
+
raise ValueError("OPENAI_API_KEY 未在配置中设置")
|
89
|
+
return OpenAIAudienceLLM(api_key=settings.api_keys.openai_api_key, model_name=settings.audience_simulation.openai_model, base_url=settings.api_keys.openai_api_base_url)
|
90
|
+
else:
|
91
|
+
raise ValueError(f"不支持的 AUDIENCE_LLM_PROVIDER: {provider}")
|
92
|
+
|
93
|
+
def handle_config_update(self, new_settings: AppSettings):
|
94
|
+
new_audience_settings = new_settings.audience_simulation.model_dump()
|
95
|
+
if new_audience_settings != self._last_checked_settings:
|
96
|
+
print("检测到观众模拟设置已更改,正在重新初始化 LLM client...")
|
97
|
+
try:
|
98
|
+
self.client = self._create_client(new_settings)
|
99
|
+
self._last_checked_settings = new_audience_settings
|
100
|
+
print("LLM client 已成功热重载。")
|
101
|
+
except Exception as e:
|
102
|
+
print(f"错误:热重载 LLM client 失败: {e}")
|
103
|
+
else:
|
104
|
+
print("观众模拟设置未更改,跳过 LLM client 重载。")
|
neuro_simulator/cli.py
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
import shutil
|
7
|
+
from pathlib import Path
|
8
|
+
|
9
|
+
def main():
|
10
|
+
parser = argparse.ArgumentParser(description="Neuro-Simulator Server")
|
11
|
+
parser.add_argument("-D", "--dir", help="Working directory containing settings.yaml")
|
12
|
+
parser.add_argument("-H", "--host", help="Host to bind the server to")
|
13
|
+
parser.add_argument("-P", "--port", type=int, help="Port to bind the server to")
|
14
|
+
|
15
|
+
args = parser.parse_args()
|
16
|
+
|
17
|
+
# Set working directory
|
18
|
+
if args.dir:
|
19
|
+
work_dir = Path(args.dir).resolve()
|
20
|
+
# If the directory doesn't exist (and it's not the default), raise an error
|
21
|
+
if not work_dir.exists():
|
22
|
+
print(f"Error: Working directory '{work_dir}' does not exist. Please create it manually.")
|
23
|
+
sys.exit(1)
|
24
|
+
else:
|
25
|
+
work_dir = Path.home() / ".config" / "neuro-simulator"
|
26
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
27
|
+
|
28
|
+
# Change to working directory
|
29
|
+
os.chdir(work_dir)
|
30
|
+
|
31
|
+
# Handle settings.yaml.example
|
32
|
+
settings_example_path = work_dir / "settings.yaml.example"
|
33
|
+
settings_path = work_dir / "settings.yaml"
|
34
|
+
|
35
|
+
# Copy settings.yaml.example from package if it doesn't exist
|
36
|
+
if not settings_example_path.exists():
|
37
|
+
try:
|
38
|
+
# Try pkg_resources first (for installed packages)
|
39
|
+
try:
|
40
|
+
import pkg_resources
|
41
|
+
example_path = pkg_resources.resource_filename('neuro_simulator', 'settings.yaml.example')
|
42
|
+
if os.path.exists(example_path):
|
43
|
+
shutil.copy(example_path, settings_example_path)
|
44
|
+
print(f"Created {settings_example_path} from package example")
|
45
|
+
else:
|
46
|
+
# Fallback to relative path (for development mode)
|
47
|
+
dev_example_path = Path(__file__).parent / "settings.yaml.example"
|
48
|
+
if dev_example_path.exists():
|
49
|
+
shutil.copy(dev_example_path, settings_example_path)
|
50
|
+
print(f"Created {settings_example_path} from development example")
|
51
|
+
else:
|
52
|
+
print("Warning: settings.yaml.example not found in package or development folder")
|
53
|
+
except Exception:
|
54
|
+
# Fallback to relative path (for development mode)
|
55
|
+
dev_example_path = Path(__file__).parent / "settings.yaml.example"
|
56
|
+
if dev_example_path.exists():
|
57
|
+
shutil.copy(dev_example_path, settings_example_path)
|
58
|
+
print(f"Created {settings_example_path} from development example")
|
59
|
+
else:
|
60
|
+
print("Warning: settings.yaml.example not found in package or development folder")
|
61
|
+
except Exception as e:
|
62
|
+
print(f"Warning: Could not copy settings.yaml.example from package: {e}")
|
63
|
+
|
64
|
+
# Handle media folder
|
65
|
+
media_dir = work_dir / "media"
|
66
|
+
video_path = media_dir / "neuro_start.mp4"
|
67
|
+
|
68
|
+
# Copy media folder from package if it doesn't exist or is invalid
|
69
|
+
if not media_dir.exists() or not video_path.exists():
|
70
|
+
# If media dir exists but video doesn't, remove the incomplete media dir
|
71
|
+
if media_dir.exists():
|
72
|
+
shutil.rmtree(media_dir)
|
73
|
+
|
74
|
+
try:
|
75
|
+
# Try pkg_resources first (for installed packages)
|
76
|
+
try:
|
77
|
+
import pkg_resources
|
78
|
+
package_media_path = pkg_resources.resource_filename('neuro_simulator', 'media')
|
79
|
+
if os.path.exists(package_media_path):
|
80
|
+
shutil.copytree(package_media_path, media_dir)
|
81
|
+
print(f"Created {media_dir} from package media")
|
82
|
+
else:
|
83
|
+
# Fallback to relative path (for development mode)
|
84
|
+
dev_media_path = Path(__file__).parent / "media"
|
85
|
+
if dev_media_path.exists():
|
86
|
+
shutil.copytree(dev_media_path, media_dir)
|
87
|
+
print(f"Created {media_dir} from development media")
|
88
|
+
else:
|
89
|
+
print("Warning: media folder not found in package or development folder")
|
90
|
+
except Exception:
|
91
|
+
# Fallback to relative path (for development mode)
|
92
|
+
dev_media_path = Path(__file__).parent / "media"
|
93
|
+
if dev_media_path.exists():
|
94
|
+
shutil.copytree(dev_media_path, media_dir)
|
95
|
+
print(f"Created {media_dir} from development media")
|
96
|
+
else:
|
97
|
+
print("Warning: media folder not found in package or development folder")
|
98
|
+
except Exception as e:
|
99
|
+
print(f"Warning: Could not copy media folder from package: {e}")
|
100
|
+
|
101
|
+
# Now check for required files and handle errors appropriately
|
102
|
+
errors = []
|
103
|
+
|
104
|
+
# Check for settings.yaml (required for running)
|
105
|
+
if not settings_path.exists():
|
106
|
+
if settings_example_path.exists():
|
107
|
+
errors.append(f"Error: {settings_path} not found. Please copy {settings_example_path} to {settings_path} and configure it.")
|
108
|
+
else:
|
109
|
+
errors.append(f"Error: Neither {settings_path} nor {settings_example_path} found. Please ensure proper configuration.")
|
110
|
+
|
111
|
+
# Check for required media files (required for running)
|
112
|
+
if not media_dir.exists() or not video_path.exists():
|
113
|
+
errors.append(f"Error: Required media files not found in {media_dir}.")
|
114
|
+
|
115
|
+
# If there are any errors, print them and exit
|
116
|
+
if errors:
|
117
|
+
for error in errors:
|
118
|
+
print(error)
|
119
|
+
sys.exit(1)
|
120
|
+
|
121
|
+
# Import and run the main application
|
122
|
+
try:
|
123
|
+
from neuro_simulator.main import run_server
|
124
|
+
run_server(args.host, args.port)
|
125
|
+
except ImportError:
|
126
|
+
# Fallback for development mode
|
127
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
128
|
+
from neuro_simulator.main import run_server
|
129
|
+
run_server(args.host, args.port)
|
130
|
+
|
131
|
+
if __name__ == "__main__":
|
132
|
+
main()
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# backend/config.py
|
2
|
+
import os
|
3
|
+
import yaml
|
4
|
+
from pydantic import BaseModel, Field
|
5
|
+
from typing import List, Optional, Dict, Any
|
6
|
+
import logging
|
7
|
+
import asyncio
|
8
|
+
from collections.abc import Mapping
|
9
|
+
|
10
|
+
# 配置日志记录器
|
11
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
12
|
+
|
13
|
+
# --- 1. 定义配置的结构 (Schema) ---
|
14
|
+
|
15
|
+
class ApiKeysSettings(BaseModel):
|
16
|
+
letta_token: Optional[str] = None
|
17
|
+
letta_base_url: Optional[str] = None
|
18
|
+
neuro_agent_id: Optional[str] = None
|
19
|
+
gemini_api_key: Optional[str] = None
|
20
|
+
openai_api_key: Optional[str] = None
|
21
|
+
openai_api_base_url: Optional[str] = None
|
22
|
+
azure_speech_key: Optional[str] = None
|
23
|
+
azure_speech_region: Optional[str] = None
|
24
|
+
|
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"])
|
30
|
+
|
31
|
+
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!"
|
35
|
+
|
36
|
+
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
|
+
])
|
64
|
+
|
65
|
+
class TTSSettings(BaseModel):
|
66
|
+
voice_name: str = "en-US-AshleyNeural"
|
67
|
+
voice_pitch: float = 1.25
|
68
|
+
|
69
|
+
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
|
73
|
+
|
74
|
+
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"])
|
78
|
+
panel_password: Optional[str] = None
|
79
|
+
|
80
|
+
class AppSettings(BaseModel):
|
81
|
+
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)
|
88
|
+
|
89
|
+
# --- 2. 加载和管理配置的逻辑 ---
|
90
|
+
|
91
|
+
CONFIG_FILE_PATH = "settings.yaml"
|
92
|
+
|
93
|
+
def _deep_update(source: dict, overrides: dict) -> dict:
|
94
|
+
"""
|
95
|
+
Recursively update a dictionary.
|
96
|
+
"""
|
97
|
+
for key, value in overrides.items():
|
98
|
+
if isinstance(value, Mapping) and value:
|
99
|
+
returned = _deep_update(source.get(key, {}), value)
|
100
|
+
source[key] = returned
|
101
|
+
else:
|
102
|
+
source[key] = overrides[key]
|
103
|
+
return source
|
104
|
+
|
105
|
+
class ConfigManager:
|
106
|
+
_instance = None
|
107
|
+
|
108
|
+
def __new__(cls):
|
109
|
+
if cls._instance is None:
|
110
|
+
cls._instance = super(ConfigManager, cls).__new__(cls)
|
111
|
+
cls._instance._initialized = False
|
112
|
+
return cls._instance
|
113
|
+
|
114
|
+
def __init__(self):
|
115
|
+
if self._initialized:
|
116
|
+
return
|
117
|
+
self.settings: AppSettings = self._load_settings()
|
118
|
+
self._update_callbacks = []
|
119
|
+
self._initialized = True
|
120
|
+
|
121
|
+
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 settings.yaml.example.")
|
124
|
+
return {}
|
125
|
+
try:
|
126
|
+
with open(CONFIG_FILE_PATH, 'r', encoding='utf-8') as f:
|
127
|
+
return yaml.safe_load(f) or {}
|
128
|
+
except Exception as e:
|
129
|
+
logging.error(f"Error loading or parsing {CONFIG_FILE_PATH}: {e}")
|
130
|
+
return {}
|
131
|
+
|
132
|
+
def _load_settings(self) -> AppSettings:
|
133
|
+
yaml_config = self._load_config_from_yaml()
|
134
|
+
base_settings = AppSettings.model_validate(yaml_config)
|
135
|
+
|
136
|
+
# 检查关键配置项
|
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")
|
142
|
+
|
143
|
+
if missing_keys:
|
144
|
+
raise ValueError(f"Critical config missing in settings.yaml: {', '.join(missing_keys)}. "
|
145
|
+
f"Please check your settings.yaml file against settings.yaml.example.")
|
146
|
+
|
147
|
+
logging.info("Configuration loaded successfully.")
|
148
|
+
return base_settings
|
149
|
+
|
150
|
+
def save_settings(self):
|
151
|
+
"""Saves the current configuration to settings.yaml."""
|
152
|
+
try:
|
153
|
+
# 1. Get the current settings from memory
|
154
|
+
config_to_save = self.settings.model_dump(mode='json', exclude={'api_keys'})
|
155
|
+
|
156
|
+
# 2. Read the existing config on disk to get the api_keys that should be preserved.
|
157
|
+
existing_config = self._load_config_from_yaml()
|
158
|
+
if 'api_keys' in existing_config:
|
159
|
+
# 3. Add the preserved api_keys block back to the data to be saved.
|
160
|
+
config_to_save['api_keys'] = existing_config['api_keys']
|
161
|
+
|
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]
|
167
|
+
|
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}")
|
172
|
+
except Exception as e:
|
173
|
+
logging.error(f"Failed to save configuration to {CONFIG_FILE_PATH}: {e}")
|
174
|
+
|
175
|
+
def register_update_callback(self, callback):
|
176
|
+
"""Registers a callback function to be called on settings update."""
|
177
|
+
self._update_callbacks.append(callback)
|
178
|
+
|
179
|
+
async def update_settings(self, new_settings_data: dict):
|
180
|
+
"""
|
181
|
+
Updates the settings by merging new data, re-validating the entire
|
182
|
+
model to ensure sub-models are correctly instantiated, and then
|
183
|
+
notifying callbacks.
|
184
|
+
"""
|
185
|
+
# Prevent API keys from being updated from the panel
|
186
|
+
new_settings_data.pop('api_keys', None)
|
187
|
+
|
188
|
+
try:
|
189
|
+
# 1. Dump the current settings model to a dictionary.
|
190
|
+
current_settings_dict = self.settings.model_dump()
|
191
|
+
|
192
|
+
# 2. Recursively update the dictionary with the new data.
|
193
|
+
updated_settings_dict = _deep_update(current_settings_dict, new_settings_data)
|
194
|
+
|
195
|
+
# 3. Re-validate the entire dictionary back into a Pydantic model.
|
196
|
+
# This is the crucial step that reconstructs the sub-models.
|
197
|
+
self.settings = AppSettings.model_validate(updated_settings_dict)
|
198
|
+
|
199
|
+
# 4. Save the updated configuration to the YAML file.
|
200
|
+
self.save_settings()
|
201
|
+
|
202
|
+
# 5. Call registered callbacks with the new, valid settings model.
|
203
|
+
for callback in self._update_callbacks:
|
204
|
+
try:
|
205
|
+
if asyncio.iscoroutinefunction(callback):
|
206
|
+
await callback(self.settings)
|
207
|
+
else:
|
208
|
+
callback(self.settings)
|
209
|
+
except Exception as e:
|
210
|
+
logging.error(f"Error executing settings update callback: {e}", exc_info=True)
|
211
|
+
|
212
|
+
logging.info("Runtime configuration updated and callbacks executed.")
|
213
|
+
except Exception as e:
|
214
|
+
logging.error(f"Failed to update settings: {e}", exc_info=True)
|
215
|
+
|
216
|
+
|
217
|
+
# --- 3. 创建全局可访问的配置实例 ---
|
218
|
+
config_manager = ConfigManager()
|
219
|
+
|
220
|
+
# --- 4. 运行时更新配置的函数 (legacy wrapper for compatibility) ---
|
221
|
+
async def update_and_broadcast_settings(new_settings_data: dict):
|
222
|
+
await config_manager.update_settings(new_settings_data)
|
223
|
+
# Broadcast stream_metadata changes specifically for now
|
224
|
+
if 'stream_metadata' in new_settings_data:
|
225
|
+
from .stream_manager import live_stream_manager
|
226
|
+
await live_stream_manager.broadcast_stream_metadata()
|
neuro_simulator/letta.py
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
# backend/letta.py
|
2
|
+
from letta_client import Letta, MessageCreate, TextContent, LlmConfig, AssistantMessage
|
3
|
+
from fastapi import HTTPException, status
|
4
|
+
from .config import config_manager
|
5
|
+
|
6
|
+
# 初始化 Letta 客户端
|
7
|
+
letta_client: Letta | None = None
|
8
|
+
try:
|
9
|
+
if not config_manager.settings.api_keys.letta_token:
|
10
|
+
raise ValueError("LETTA_API_TOKEN is not set. Cannot initialize Letta client.")
|
11
|
+
|
12
|
+
# 使用 settings 对象进行配置
|
13
|
+
client_args = {'token': config_manager.settings.api_keys.letta_token}
|
14
|
+
if config_manager.settings.api_keys.letta_base_url:
|
15
|
+
client_args['base_url'] = config_manager.settings.api_keys.letta_base_url
|
16
|
+
print(f"Letta client is being initialized for self-hosted URL: {config_manager.settings.api_keys.letta_base_url}")
|
17
|
+
else:
|
18
|
+
print("Letta client is being initialized for Letta Cloud.")
|
19
|
+
|
20
|
+
letta_client = Letta(**client_args)
|
21
|
+
|
22
|
+
if config_manager.settings.api_keys.neuro_agent_id:
|
23
|
+
try:
|
24
|
+
agent_data = letta_client.agents.retrieve(agent_id=config_manager.settings.api_keys.neuro_agent_id)
|
25
|
+
print(f"成功获取 Letta Agent 详情,ID: {agent_data.id}")
|
26
|
+
llm_model_info = "N/A"
|
27
|
+
if hasattr(agent_data, 'model') and agent_data.model:
|
28
|
+
llm_model_info = agent_data.model
|
29
|
+
elif agent_data.llm_config:
|
30
|
+
if isinstance(agent_data.llm_config, LlmConfig):
|
31
|
+
llm_config_dict = agent_data.llm_config.model_dump() if hasattr(agent_data.llm_config, 'model_dump') else agent_data.llm_config.__dict__
|
32
|
+
llm_model_info = llm_config_dict.get('model_name') or llm_config_dict.get('name') or llm_config_dict.get('model')
|
33
|
+
if not llm_model_info:
|
34
|
+
llm_model_info = str(agent_data.llm_config)
|
35
|
+
print(f" -> Agent 名称: {agent_data.name}")
|
36
|
+
print(f" -> LLM 模型: {llm_model_info}")
|
37
|
+
|
38
|
+
except Exception as e:
|
39
|
+
error_msg = f"错误: 无法获取 Neuro Letta Agent (ID: {config_manager.settings.api_keys.neuro_agent_id})。请确保 ID 正确,且服务可访问。详情: {e}"
|
40
|
+
print(error_msg)
|
41
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error_msg)
|
42
|
+
except Exception as e:
|
43
|
+
print(f"初始化 Letta 客户端失败: {e}")
|
44
|
+
letta_client = None
|
45
|
+
|
46
|
+
def get_letta_client():
|
47
|
+
if letta_client is None: raise ValueError("Letta client is not initialized.")
|
48
|
+
return letta_client
|
49
|
+
|
50
|
+
async def reset_neuro_agent_memory():
|
51
|
+
"""
|
52
|
+
重置 Agent 的记忆,包括:
|
53
|
+
1. 清空所有消息历史记录。
|
54
|
+
2. 清空指定的 'conversation_summary' 核心内存块。
|
55
|
+
"""
|
56
|
+
agent_id = config_manager.settings.api_keys.neuro_agent_id
|
57
|
+
if letta_client is None or not agent_id:
|
58
|
+
print("Letta client 或 Agent ID 未配置,跳过重置。")
|
59
|
+
return
|
60
|
+
|
61
|
+
# --- 步骤 1: 重置消息历史记录 (上下文) ---
|
62
|
+
try:
|
63
|
+
letta_client.agents.messages.reset(agent_id=agent_id)
|
64
|
+
print(f"Neuro Agent (ID: {agent_id}) 的消息历史已成功重置。")
|
65
|
+
except Exception as e:
|
66
|
+
print(f"警告: 重置 Agent 消息历史失败: {e}。")
|
67
|
+
|
68
|
+
# --- 步骤 2: 清空 'conversation_summary' 核心内存块 ---
|
69
|
+
block_label_to_clear = "conversation_summary"
|
70
|
+
try:
|
71
|
+
print(f"正在尝试清空核心记忆块: '{block_label_to_clear}'...")
|
72
|
+
|
73
|
+
# 调用 modify 方法,将 value 设置为空字符串
|
74
|
+
letta_client.agents.blocks.modify(
|
75
|
+
agent_id=agent_id,
|
76
|
+
block_label=block_label_to_clear,
|
77
|
+
value=""
|
78
|
+
)
|
79
|
+
|
80
|
+
print(f"核心记忆块 '{block_label_to_clear}' 已成功清空。")
|
81
|
+
except Exception as e:
|
82
|
+
# 优雅地处理块不存在的情况
|
83
|
+
# API 在找不到块时通常会返回包含 404 或 "not found" 的错误
|
84
|
+
error_str = str(e).lower()
|
85
|
+
if "not found" in error_str or "404" in error_str:
|
86
|
+
print(f"信息: 核心记忆块 '{block_label_to_clear}' 不存在,无需清空。")
|
87
|
+
else:
|
88
|
+
print(f"警告: 清空核心记忆块 '{block_label_to_clear}' 失败: {e}。")
|
89
|
+
|
90
|
+
|
91
|
+
async def get_neuro_response(chat_messages: list[dict]) -> str:
|
92
|
+
if letta_client is None or not config_manager.settings.api_keys.neuro_agent_id:
|
93
|
+
print("警告: Letta client 或 Agent ID 未配置,无法获取响应。")
|
94
|
+
return "我暂时无法回应,请稍后再试。"
|
95
|
+
|
96
|
+
if chat_messages:
|
97
|
+
injected_chat_lines = [f"{chat['username']}: {chat['text']}" for chat in chat_messages]
|
98
|
+
injected_chat_text = (
|
99
|
+
"Here are some recent messages from my Twitch chat:\n---\n" +
|
100
|
+
"\n".join(injected_chat_lines) +
|
101
|
+
"\n---\nNow, as the streamer Neuro-Sama, please continue the conversation naturally."
|
102
|
+
)
|
103
|
+
else:
|
104
|
+
injected_chat_text = "My chat is quiet right now. As Neuro-Sama, what should I say to engage them?"
|
105
|
+
|
106
|
+
print(f"正在向 Neuro Agent 发送输入 (包含 {len(chat_messages)} 条消息)..." )
|
107
|
+
|
108
|
+
try:
|
109
|
+
response = letta_client.agents.messages.create(
|
110
|
+
agent_id=config_manager.settings.api_keys.neuro_agent_id,
|
111
|
+
messages=[MessageCreate(role="user", content=injected_chat_text)]
|
112
|
+
)
|
113
|
+
|
114
|
+
ai_full_response_text = ""
|
115
|
+
if response and response.messages:
|
116
|
+
last_message = response.messages[-1]
|
117
|
+
if isinstance(last_message, AssistantMessage) and hasattr(last_message, 'content'):
|
118
|
+
content = last_message.content
|
119
|
+
if isinstance(content, str):
|
120
|
+
ai_full_response_text = content.strip()
|
121
|
+
elif isinstance(content, list) and content:
|
122
|
+
first_part = content[0]
|
123
|
+
if isinstance(first_part, TextContent) and hasattr(first_part, 'text'):
|
124
|
+
ai_full_response_text = first_part.text.strip()
|
125
|
+
|
126
|
+
if not ai_full_response_text:
|
127
|
+
print(f"警告: 未能从 Letta 响应中解析出有效的文本。响应对象: {response}")
|
128
|
+
return "I seem to be at a loss for words right now."
|
129
|
+
|
130
|
+
print(f"成功从 Letta 解析到响应: '{ai_full_response_text[:70]}...'")
|
131
|
+
return ai_full_response_text
|
132
|
+
|
133
|
+
except Exception as e:
|
134
|
+
print(f"错误: 调用 Letta Agent ({config_manager.settings.api_keys.neuro_agent_id}) 失败: {e}")
|
135
|
+
return "Someone tell Vedal there is a problem with my AI."
|