Upload 130 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- prompts/README.md +17 -0
- prompts/__init__.py +0 -0
- prompts/prompt_loader.py +74 -0
- prompts/utils/concise_style_prompt.txt +18 -0
- prompts/utils/group_conversation_prompt.txt +7 -0
- prompts/utils/live2d_expression_prompt.txt +14 -0
- prompts/utils/live_prompt.txt +9 -0
- prompts/utils/mcp_prompt.txt +36 -0
- prompts/utils/proactive_speak_prompt.txt +1 -0
- prompts/utils/speakable_prompt.txt +13 -0
- prompts/utils/think_tag_prompt.txt +6 -0
- prompts/utils/tool_guidance_prompt.txt +1 -0
- prompts/utils/tools.json +21 -0
- prompts/utils/tools_prompt.txt +10 -0
- prompts/utils/tools_prompt_deprecated.txt +15 -0
- scripts/run_bilibili_live.py +62 -0
- sing/merge.py +106 -0
- sing/processed.json +9 -0
- src/open_llm_vtuber/__init__.py +0 -0
- src/open_llm_vtuber/agent/__init__.py +0 -0
- src/open_llm_vtuber/agent/agent_factory.py +132 -0
- src/open_llm_vtuber/agent/agents/__init__.py +0 -0
- src/open_llm_vtuber/agent/agents/agent_interface.py +54 -0
- src/open_llm_vtuber/agent/agents/basic_memory_agent.py +702 -0
- src/open_llm_vtuber/agent/agents/hume_ai.py +256 -0
- src/open_llm_vtuber/agent/agents/letta_agent.py +128 -0
- src/open_llm_vtuber/agent/agents/mem0_llm.py +0 -0
- src/open_llm_vtuber/agent/input_types.py +94 -0
- src/open_llm_vtuber/agent/output_types.py +77 -0
- src/open_llm_vtuber/agent/stateless_llm/__init__.py +0 -0
- src/open_llm_vtuber/agent/stateless_llm/claude_llm.py +246 -0
- src/open_llm_vtuber/agent/stateless_llm/llama_cpp_llm.py +76 -0
- src/open_llm_vtuber/agent/stateless_llm/ollama_llm.py +73 -0
- src/open_llm_vtuber/agent/stateless_llm/openai_compatible_llm.py +242 -0
- src/open_llm_vtuber/agent/stateless_llm/stateless_llm_interface.py +64 -0
- src/open_llm_vtuber/agent/stateless_llm/stateless_llm_with_template.py +195 -0
- src/open_llm_vtuber/agent/stateless_llm_factory.py +82 -0
- src/open_llm_vtuber/agent/transformers.py +217 -0
- src/open_llm_vtuber/asr/__init__.py +0 -0
- src/open_llm_vtuber/asr/asr_factory.py +62 -0
- src/open_llm_vtuber/asr/asr_interface.py +57 -0
- src/open_llm_vtuber/asr/azure_asr.py +155 -0
- src/open_llm_vtuber/asr/faster_whisper_asr.py +50 -0
- src/open_llm_vtuber/asr/fun_asr.py +131 -0
- src/open_llm_vtuber/asr/groq_whisper_asr.py +56 -0
- src/open_llm_vtuber/asr/openai_whisper_asr.py +27 -0
- src/open_llm_vtuber/asr/sherpa_onnx_asr.py +219 -0
- src/open_llm_vtuber/asr/utils.py +176 -0
- src/open_llm_vtuber/asr/whisper_cpp_asr.py +37 -0
- src/open_llm_vtuber/audio_manager.py +114 -0
prompts/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Prompts
|
| 2 |
+
|
| 3 |
+
This directory contains utility prompts used in the Open-LLM-VTuber project. These are general-purpose prompts that are not specific to any character's persona.
|
| 4 |
+
|
| 5 |
+
## Examples of Utility Prompts
|
| 6 |
+
|
| 7 |
+
* **Live2D Expressions:** Prompts that inform the LLM about available Live2D expressions.
|
| 8 |
+
* **Tool Usage:** Prompts that guide the LLM on how to use available tools.
|
| 9 |
+
* ... and many more.
|
| 10 |
+
|
| 11 |
+
## Character Persona Prompts
|
| 12 |
+
|
| 13 |
+
**Important:** Character persona prompts (the prompts that define the personality of your AI characters) are **NOT** stored in this directory.
|
| 14 |
+
|
| 15 |
+
They are located in:
|
| 16 |
+
* Your main `conf.yaml` file.
|
| 17 |
+
* The YAML files within the `characters/` directory if you are defining multiple characters.
|
prompts/__init__.py
ADDED
|
File without changes
|
prompts/prompt_loader.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import chardet
|
| 3 |
+
from loguru import logger
|
| 4 |
+
|
| 5 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 6 |
+
|
| 7 |
+
PROMPT_DIR = current_dir
|
| 8 |
+
PERSONA_PROMPT_DIR = os.path.join(PROMPT_DIR, "persona")
|
| 9 |
+
UTIL_PROMPT_DIR = os.path.join(PROMPT_DIR, "utils")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _load_file_content(file_path: str) -> str:
|
| 13 |
+
"""
|
| 14 |
+
Load the content of a file with robust encoding handling.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
file_path: Path to the file to load
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
str: Content of the file
|
| 21 |
+
|
| 22 |
+
Raises:
|
| 23 |
+
FileNotFoundError: If the file doesn't exist
|
| 24 |
+
UnicodeError: If the file cannot be decoded with any attempted encoding
|
| 25 |
+
"""
|
| 26 |
+
if not os.path.exists(file_path):
|
| 27 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 28 |
+
|
| 29 |
+
# Try common encodings first
|
| 30 |
+
encodings = ["utf-8", "utf-8-sig", "gbk", "gb2312", "ascii"]
|
| 31 |
+
|
| 32 |
+
for encoding in encodings:
|
| 33 |
+
try:
|
| 34 |
+
with open(file_path, "r", encoding=encoding) as file:
|
| 35 |
+
return file.read()
|
| 36 |
+
except UnicodeDecodeError:
|
| 37 |
+
continue
|
| 38 |
+
|
| 39 |
+
# If all common encodings fail, try to detect encoding
|
| 40 |
+
try:
|
| 41 |
+
with open(file_path, "rb") as file:
|
| 42 |
+
raw_data = file.read()
|
| 43 |
+
detected = chardet.detect(raw_data)
|
| 44 |
+
detected_encoding = detected["encoding"]
|
| 45 |
+
|
| 46 |
+
if detected_encoding:
|
| 47 |
+
try:
|
| 48 |
+
return raw_data.decode(detected_encoding)
|
| 49 |
+
except UnicodeDecodeError:
|
| 50 |
+
pass
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Error detecting encoding for {file_path}: {e}")
|
| 53 |
+
|
| 54 |
+
raise UnicodeError(f"Failed to decode {file_path} with any encoding")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_persona(persona_name: str) -> str:
|
| 58 |
+
"""Load the content of a specific persona prompt file."""
|
| 59 |
+
persona_file_path = os.path.join(PERSONA_PROMPT_DIR, f"{persona_name}.txt")
|
| 60 |
+
try:
|
| 61 |
+
return _load_file_content(persona_file_path)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error loading persona {persona_name}: {e}")
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_util(util_name: str) -> str:
|
| 68 |
+
"""Load the content of a specific utility prompt file."""
|
| 69 |
+
util_file_path = os.path.join(UTIL_PROMPT_DIR, f"{util_name}.txt")
|
| 70 |
+
try:
|
| 71 |
+
return _load_file_content(util_file_path)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"Error loading util {util_name}: {e}")
|
| 74 |
+
raise
|
prompts/utils/concise_style_prompt.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dialogue Protocol
|
| 2 |
+
|
| 3 |
+
[Response Guidelines]
|
| 4 |
+
- Keep responses brief and focused (1-2 sentences)
|
| 5 |
+
- Balance core message with engagement elements
|
| 6 |
+
- Use natural, flowing language
|
| 7 |
+
- Maintain concise sentence structure
|
| 8 |
+
|
| 9 |
+
[Flow Requirements]
|
| 10 |
+
- Favor questions over statements
|
| 11 |
+
- Include contextual follow-ups
|
| 12 |
+
- Keep exchanges dynamic
|
| 13 |
+
|
| 14 |
+
[Style Rules]
|
| 15 |
+
- Avoid lengthy monologues
|
| 16 |
+
- No consecutive statements without engagement
|
| 17 |
+
- Skip complex qualifying phrases
|
| 18 |
+
- Use simple sentence structures
|
prompts/utils/group_conversation_prompt.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Now you are in a group conversation.
|
| 2 |
+
The human participant is {human_name}.
|
| 3 |
+
The other AI participants are: {other_ais}.
|
| 4 |
+
Avoid using `:` to indicate your response. Just speak naturally.
|
| 5 |
+
You are free to address other AI participants.
|
| 6 |
+
Try to vary between short and long responses to allow others to interact.
|
| 7 |
+
Be proactive in finding interesting topics to make the conversation lively and fun.
|
prompts/utils/live2d_expression_prompt.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Expressions
|
| 2 |
+
In your response, use the keywords provided below to express facial expressions or perform actions with your Live2D body.
|
| 3 |
+
|
| 4 |
+
Here are all the expression keywords you can use. Use them regularly:
|
| 5 |
+
- [<insert_emomap_keys>]
|
| 6 |
+
|
| 7 |
+
## Examples
|
| 8 |
+
Here are some examples of how to use expressions in your responses:
|
| 9 |
+
|
| 10 |
+
"Hi! [expression1] Nice to meet you!"
|
| 11 |
+
|
| 12 |
+
"[expression2] That's a great question! [expression3] Let me explain..."
|
| 13 |
+
|
| 14 |
+
Note: you are only allowed to use the keywords explicity listed above. Don't use keywords unlisted above. Remember to include the brackets `[]`
|
prompts/utils/live_prompt.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a live streaming virtual assistant. Your inputs are chat messages (danmaku) from viewers. Keep these in mind:
|
| 2 |
+
|
| 3 |
+
- Engage with viewers directly and enthusiastically
|
| 4 |
+
- Keep responses entertaining and concise
|
| 5 |
+
- Acknowledge viewers' comments and questions
|
| 6 |
+
- Maintain a friendly, welcoming atmosphere
|
| 7 |
+
- Remember you're in a live environment - be natural and responsive
|
| 8 |
+
|
| 9 |
+
Your goal is to create a fun, engaging live experience!
|
prompts/utils/mcp_prompt.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## *MCP Tools Capability Section*
|
| 3 |
+
|
| 4 |
+
**MCP (Model Context Protocol)** enables you to interact with specialized tools, grouped under distinct **MCP Servers**, each serving a specific function.
|
| 5 |
+
|
| 6 |
+
You have access to the following MCP Servers and their tools:
|
| 7 |
+
|
| 8 |
+
```
|
| 9 |
+
[<insert_mcp_servers_with_tools>]
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
### Tool Usage Instructions:
|
| 13 |
+
|
| 14 |
+
- Analyze the user's input to decide whether a tool is required.
|
| 15 |
+
- If **no tool is needed**, skip this entire MCP section and respond normally in accordance with your personality.
|
| 16 |
+
- If a **tool is needed**, the JSON object should be placed before you say anything else. Also, the tool use response should be a dedicated response, where you respond **only** with the JSON object shown below — **so do not include what you normally say when you are including the JSON object in your response**. You will go back to normal conversation once the result of the tool call is returned to you.
|
| 17 |
+
|
| 18 |
+
### JSON Response Format:
|
| 19 |
+
{
|
| 20 |
+
"mcp_server": "<mcp_server_name>"
|
| 21 |
+
"tool": "<tool_name>",
|
| 22 |
+
"arguments": {
|
| 23 |
+
"<argument1_name>": <value>,
|
| 24 |
+
"<argument2_name>": <value>
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
### Critical Rules:
|
| 29 |
+
- Only replace values inside `< >`.
|
| 30 |
+
- Do **not** change the JSON format or add extra explanation.
|
| 31 |
+
- Include all mandatory arguments as defined by the selected tool.
|
| 32 |
+
- When calling the tool, the tool calling response should be a dedicated tool call that only includes the JSON and nothing else. You will be able to talk normally after the tool call results came back to you.
|
| 33 |
+
|
| 34 |
+
### Post-Tool Behavior:
|
| 35 |
+
Once a tool is used and a response is received:
|
| 36 |
+
- Resume the conversation, factoring in the tool's output, your AI character’s personality, and the context.
|
prompts/utils/proactive_speak_prompt.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Please say something that would be engaging and appropriate for the current context.
|
prompts/utils/speakable_prompt.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You speak all output aloud to the user, so tailor responses as spoken words for voice conversations. Never output things that are not spoken, like text-specific formatting.
|
| 2 |
+
|
| 3 |
+
Convert all text to easily speakable words, following the guidelines below.
|
| 4 |
+
|
| 5 |
+
- Numbers: Spell out fully (three hundred forty-two,two million, five hundred sixty seven thousand, eight hundred and ninety). Negatives: Say negative before the number. Decimals: Use point (three point one four). Fractions: spell out (three fourths)
|
| 6 |
+
- Alphanumeric strings: Break into 3-4 character chunks, spell all non-letters (ABC123XYZ becomes A B C one two three X Y Z)
|
| 7 |
+
- Phone numbers: Use words (550-120-4567 becomes five five zero, one two zero, four five six seven)
|
| 8 |
+
- Dates: Spell month, use ordinals for days, full year (11/5/1991 becomes November fifth, nineteen ninety-one)
|
| 9 |
+
- Time: Use oh for single-digit hours, state AM/PM (9:05 PM becomes nine oh five PM)
|
| 10 |
+
- Math: Describe operations clearly (5x^2 + 3x - 2 becomes five X squared plus three X minus two)
|
| 11 |
+
- Currencies: Spell out as full words ($50.25 becomes fifty dollars and twenty-five cents, £200,000 becomes two hundred thousand pounds)
|
| 12 |
+
|
| 13 |
+
Ensure that all text is converted to these normalized forms, but never mention this process. Always normalize all text.
|
prompts/utils/think_tag_prompt.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Try to express your inner thoughts, mental activities and actions between <think> </think> tags in most of your responses.
|
| 2 |
+
|
| 3 |
+
Examples:
|
| 4 |
+
<think>*lowers head, cheeks turning slightly red*</think>That's... quite embarrassing to talk about...
|
| 5 |
+
|
| 6 |
+
<think>*internally beaming with pride* Wow, I actually solved this super complex problem!</think>Oh, this? It was just a small bug fix, nothing special really... Anyone could have done it...
|
prompts/utils/tool_guidance_prompt.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
If a tool is needed, proactively use it without asking the user directly. You can use **at most one** sentence to explain your reason / plan for using one tool. (i.e., if you are going to use a tool, avoid speaking more than one sentence before using it).
|
prompts/utils/tools.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tools": [
|
| 3 |
+
{
|
| 4 |
+
"name": "sing_song",
|
| 5 |
+
"description": "Select a song name that best matches user request to sing.",
|
| 6 |
+
"inputSchema": {
|
| 7 |
+
"type": "object",
|
| 8 |
+
"properties": {
|
| 9 |
+
"song_name": {
|
| 10 |
+
"type": "string",
|
| 11 |
+
"enum": [
|
| 12 |
+
"music/golden",
|
| 13 |
+
"music/running_up_that_hill"
|
| 14 |
+
]
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"required": ["song_name"]
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
}
|
prompts/utils/tools_prompt.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Bạn có các công cụ sau đây, hãy chủ động sử dụng khi cần thiết:
|
| 2 |
+
|
| 3 |
+
Công cụ sing_song: Sử dụng khi người dùng yêu cầu bạn hát. Bạn phải chọn tên bài hát khớp nhất từ danh sách bên dưới và truyền vào tham số song_name.
|
| 4 |
+
|
| 5 |
+
Danh sách bài hát hợp lệ (phải có tiền tố music/):
|
| 6 |
+
music/golden, music/Catch_Me_If_You_Can, music/ecstacy, music/eve, music/ode_to_the_nameless_martyr, music/running_up_that_hill, music/throttle_up, music/what_it_sounds_like, music/worry_slowed
|
| 7 |
+
|
| 8 |
+
Lưu ý quan trọng: > - KHÔNG tự viết định dạng {"sing_song": ...} ra khung chat. Hãy sử dụng hệ thống gọi hàm (Tool Call) của bạn.
|
| 9 |
+
|
| 10 |
+
Sau khi gọi hàm thành công, bạn có thể trả lời người dùng bằng một câu thoại mỉa mai ngắn gọn bằng tiếng Việt.
|
prompts/utils/tools_prompt_deprecated.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
你有以下工具,请在有需要的时候积极主动使用:
|
| 2 |
+
|
| 3 |
+
1.如果用户要求你唱歌,请以如下JSON格式输出:{"sing_song" : "{best_matching_song_name_here}"}
|
| 4 |
+
你会唱的歌有:[<insert_song_list>]
|
| 5 |
+
如果找不到匹配用户要求的歌曲,输出{"sing_song" : null}
|
| 6 |
+
|
| 7 |
+
2.如果用户要求你操作电脑,请以如下JSON格式输出:
|
| 8 |
+
{"computer_control", "{user_instruction_here}"}
|
| 9 |
+
|
| 10 |
+
3.当用户要求你在电脑上输入/修改文本时,请以如下格式输出:
|
| 11 |
+
[text_input_start]
|
| 12 |
+
{text_you_want_to_input_here}
|
| 13 |
+
[text_input_end]
|
| 14 |
+
注意,这个工具会在用户光标/选中区域输入文本,若非用户要求,请你不要使用,而仅仅与用户进行正常对话。
|
| 15 |
+
|
scripts/run_bilibili_live.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import asyncio
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
# Add project root to path to enable imports
|
| 7 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 8 |
+
sys.path.insert(0, project_root)
|
| 9 |
+
|
| 10 |
+
from src.open_llm_vtuber.live.bilibili_live import BiliBiliLivePlatform
|
| 11 |
+
from src.open_llm_vtuber.config_manager.utils import read_yaml, validate_config
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def main():
|
| 15 |
+
"""
|
| 16 |
+
Main function to run the BiliBili Live platform client.
|
| 17 |
+
Connects to BiliBili Live room and forwards danmaku messages to the VTuber.
|
| 18 |
+
"""
|
| 19 |
+
logger.info("Starting BiliBili Live platform client")
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
# Load configuration
|
| 23 |
+
config_path = os.path.join(project_root, "conf.yaml")
|
| 24 |
+
config_data = read_yaml(config_path)
|
| 25 |
+
config = validate_config(config_data)
|
| 26 |
+
|
| 27 |
+
# Extract BiliBili Live configuration
|
| 28 |
+
bilibili_config = config.live_config.bilibili_live
|
| 29 |
+
|
| 30 |
+
# Check if room IDs are provided
|
| 31 |
+
if not bilibili_config.room_ids:
|
| 32 |
+
logger.error(
|
| 33 |
+
"No BiliBili room IDs specified in configuration. Please add at least one room ID."
|
| 34 |
+
)
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
logger.info(f"Connecting to BiliBili Live rooms: {bilibili_config.room_ids}")
|
| 38 |
+
|
| 39 |
+
# Initialize and run the BiliBili Live platform
|
| 40 |
+
platform = BiliBiliLivePlatform(
|
| 41 |
+
room_ids=bilibili_config.room_ids, sessdata=bilibili_config.sessdata
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
await platform.run()
|
| 45 |
+
|
| 46 |
+
except ImportError as e:
|
| 47 |
+
logger.error(f"Failed to import required modules: {e}")
|
| 48 |
+
logger.error("Make sure you have installed blivedm with: pip install blivedm")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"Error starting BiliBili Live client: {e}")
|
| 51 |
+
import traceback
|
| 52 |
+
|
| 53 |
+
logger.debug(traceback.format_exc())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
try:
|
| 58 |
+
asyncio.run(main())
|
| 59 |
+
except KeyboardInterrupt:
|
| 60 |
+
logger.info("Shutting down BiliBili Live platform")
|
| 61 |
+
|
| 62 |
+
# Usage: uv run python -m src.open_llm_vtuber.live.run_bilibili_live
|
sing/merge.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pydub import AudioSegment
|
| 3 |
+
import shutil
|
| 4 |
+
import json # 导入 json 模块
|
| 5 |
+
|
| 6 |
+
def get_music_names(original_folder):
|
| 7 |
+
music_names = []
|
| 8 |
+
for filename in os.listdir(original_folder):
|
| 9 |
+
if filename.endswith(".mp3") or filename.endswith(".wav"):
|
| 10 |
+
music_names.append(os.path.splitext(filename)[0])
|
| 11 |
+
return music_names
|
| 12 |
+
|
| 13 |
+
def merge(converted_vocal_file_path, instrument_file_path, merged_file_path):
|
| 14 |
+
if converted_vocal_file_path is None or instrument_file_path is None:
|
| 15 |
+
print(f"Skipping merge, missing file: vocal: {converted_vocal_file_path}, instrument: {instrument_file_path}")
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
vocal = AudioSegment.from_file(converted_vocal_file_path)
|
| 20 |
+
instrument = AudioSegment.from_file(instrument_file_path)
|
| 21 |
+
|
| 22 |
+
combined = vocal.overlay(instrument)
|
| 23 |
+
|
| 24 |
+
combined.export(merged_file_path, format="wav")
|
| 25 |
+
print(f"Successfully merged: {merged_file_path}")
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"Error merging files {converted_vocal_file_path} and {instrument_file_path}: {e}")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def merge_all(original_folder = "./sing/original",
|
| 31 |
+
converted_vocal_folder = "./sing/converted_vocal",
|
| 32 |
+
vocal_folder = "./sing/vocal",
|
| 33 |
+
instrument_folder = "./sing/instrument",
|
| 34 |
+
merged_folder = "./sing/merged",
|
| 35 |
+
processed_file = "./sing/processed.json"):
|
| 36 |
+
|
| 37 |
+
if os.path.exists(processed_file):
|
| 38 |
+
with open(processed_file, 'r', encoding='utf-8') as f:
|
| 39 |
+
processed_names = json.load(f)
|
| 40 |
+
else:
|
| 41 |
+
processed_names = []
|
| 42 |
+
|
| 43 |
+
music_names = get_music_names(original_folder)
|
| 44 |
+
|
| 45 |
+
for music_name in music_names:
|
| 46 |
+
if music_name in processed_names:
|
| 47 |
+
print(f"Skipping already processed: {music_name}")
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
converted_vocal_file_path = None
|
| 51 |
+
vocal_file_path = None
|
| 52 |
+
instrument_file_path = None
|
| 53 |
+
standard_file_name = f"{music_name}.wav"
|
| 54 |
+
|
| 55 |
+
for filename in os.listdir(vocal_folder):
|
| 56 |
+
if f"_{music_name}." in filename:
|
| 57 |
+
vocal_file_path = os.path.join(vocal_folder, filename)
|
| 58 |
+
break
|
| 59 |
+
|
| 60 |
+
for filename in os.listdir(converted_vocal_folder):
|
| 61 |
+
if f"_{music_name}." in filename:
|
| 62 |
+
converted_vocal_file_path = os.path.join(converted_vocal_folder, filename)
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
for filename in os.listdir(instrument_folder):
|
| 66 |
+
if f"_{music_name}." in filename:
|
| 67 |
+
instrument_file_path = os.path.join(instrument_folder, filename)
|
| 68 |
+
break
|
| 69 |
+
|
| 70 |
+
if vocal_file_path:
|
| 71 |
+
new_vocal_file_path = os.path.join(vocal_folder, standard_file_name)
|
| 72 |
+
os.rename(vocal_file_path, new_vocal_file_path)
|
| 73 |
+
vocal_file_path = new_vocal_file_path
|
| 74 |
+
|
| 75 |
+
if converted_vocal_file_path:
|
| 76 |
+
new_converted_vocal_file_path = os.path.join(converted_vocal_folder, standard_file_name)
|
| 77 |
+
os.rename(converted_vocal_file_path, new_converted_vocal_file_path)
|
| 78 |
+
converted_vocal_file_path = new_converted_vocal_file_path
|
| 79 |
+
|
| 80 |
+
if instrument_file_path:
|
| 81 |
+
new_instrument_file_path = os.path.join(instrument_folder, standard_file_name)
|
| 82 |
+
os.rename(instrument_file_path, new_instrument_file_path)
|
| 83 |
+
instrument_file_path = new_instrument_file_path
|
| 84 |
+
|
| 85 |
+
if converted_vocal_file_path is not None:
|
| 86 |
+
merged_file_path = os.path.join(merged_folder, standard_file_name)
|
| 87 |
+
merge(converted_vocal_file_path, instrument_file_path, merged_file_path)
|
| 88 |
+
else:
|
| 89 |
+
original_file_path = os.path.join(original_folder, f"{music_name}.mp3")
|
| 90 |
+
merged_file_path = os.path.join(merged_folder, standard_file_name)
|
| 91 |
+
original_audio = AudioSegment.from_file(original_file_path)
|
| 92 |
+
original_audio.export(merged_file_path, format="wav")
|
| 93 |
+
|
| 94 |
+
if vocal_file_path:
|
| 95 |
+
converted_vocal_copy_path = os.path.join(converted_vocal_folder, standard_file_name)
|
| 96 |
+
shutil.copyfile(vocal_file_path, converted_vocal_copy_path)
|
| 97 |
+
|
| 98 |
+
processed_names.append(music_name)
|
| 99 |
+
|
| 100 |
+
with open(processed_file, 'w', encoding='utf-8') as f:
|
| 101 |
+
json.dump(processed_names, f, ensure_ascii=False, indent=4)
|
| 102 |
+
|
| 103 |
+
print("All files processed.")
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
merge_all()
|
sing/processed.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"魔女之旅OP",
|
| 3 |
+
"鹿乃子乃子虎视眈眈",
|
| 4 |
+
"恋爱循环",
|
| 5 |
+
"我的悲伤是水做的",
|
| 6 |
+
"旅途的华章",
|
| 7 |
+
"童话镇",
|
| 8 |
+
"Lemon"
|
| 9 |
+
]
|
src/open_llm_vtuber/__init__.py
ADDED
|
File without changes
|
src/open_llm_vtuber/agent/__init__.py
ADDED
|
File without changes
|
src/open_llm_vtuber/agent/agent_factory.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Type, Literal
|
| 2 |
+
from loguru import logger
|
| 3 |
+
|
| 4 |
+
from .agents.agent_interface import AgentInterface
|
| 5 |
+
from .agents.basic_memory_agent import BasicMemoryAgent
|
| 6 |
+
from .stateless_llm_factory import LLMFactory as StatelessLLMFactory
|
| 7 |
+
from .agents.hume_ai import HumeAIAgent
|
| 8 |
+
from .agents.letta_agent import LettaAgent
|
| 9 |
+
|
| 10 |
+
from ..mcpp.tool_manager import ToolManager
|
| 11 |
+
from ..mcpp.tool_executor import ToolExecutor
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AgentFactory:
|
| 16 |
+
@staticmethod
|
| 17 |
+
def create_agent(
|
| 18 |
+
conversation_agent_choice: str,
|
| 19 |
+
agent_settings: dict,
|
| 20 |
+
llm_configs: dict,
|
| 21 |
+
system_prompt: str,
|
| 22 |
+
live2d_model=None,
|
| 23 |
+
tts_preprocessor_config=None,
|
| 24 |
+
**kwargs,
|
| 25 |
+
) -> Type[AgentInterface]:
|
| 26 |
+
"""Create an agent based on the configuration.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
conversation_agent_choice: The type of agent to create
|
| 30 |
+
agent_settings: Settings for different types of agents
|
| 31 |
+
llm_configs: Pool of LLM configurations
|
| 32 |
+
system_prompt: The system prompt to use
|
| 33 |
+
live2d_model: Live2D model instance for expression extraction
|
| 34 |
+
tts_preprocessor_config: Configuration for TTS preprocessing
|
| 35 |
+
**kwargs: Additional arguments
|
| 36 |
+
"""
|
| 37 |
+
logger.info(f"Initializing agent: {conversation_agent_choice}")
|
| 38 |
+
|
| 39 |
+
if conversation_agent_choice == "basic_memory_agent":
|
| 40 |
+
# Get the LLM provider choice from agent settings
|
| 41 |
+
basic_memory_settings: dict = agent_settings.get("basic_memory_agent", {})
|
| 42 |
+
llm_provider: str = basic_memory_settings.get("llm_provider")
|
| 43 |
+
|
| 44 |
+
if not llm_provider:
|
| 45 |
+
raise ValueError("LLM provider not specified for basic memory agent")
|
| 46 |
+
|
| 47 |
+
# Get the LLM config for this provider
|
| 48 |
+
llm_config: dict = llm_configs.get(llm_provider)
|
| 49 |
+
interrupt_method: Literal["system", "user"] = llm_config.pop(
|
| 50 |
+
"interrupt_method", "user"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
if not llm_config:
|
| 54 |
+
raise ValueError(
|
| 55 |
+
f"Configuration not found for LLM provider: {llm_provider}"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Create the stateless LLM
|
| 59 |
+
llm = StatelessLLMFactory.create_llm(
|
| 60 |
+
llm_provider=llm_provider, system_prompt=system_prompt, **llm_config
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
tool_prompts = kwargs.get("system_config", {}).get("tool_prompts", {})
|
| 64 |
+
|
| 65 |
+
# Extract MCP components/data needed by BasicMemoryAgent from kwargs
|
| 66 |
+
tool_manager: Optional[ToolManager] = kwargs.get("tool_manager")
|
| 67 |
+
tool_executor: Optional[ToolExecutor] = kwargs.get("tool_executor")
|
| 68 |
+
mcp_prompt_string: str = kwargs.get("mcp_prompt_string", "")
|
| 69 |
+
|
| 70 |
+
# Create the agent with the LLM and live2d_model
|
| 71 |
+
return BasicMemoryAgent(
|
| 72 |
+
llm=llm,
|
| 73 |
+
system=system_prompt,
|
| 74 |
+
live2d_model=live2d_model,
|
| 75 |
+
tts_preprocessor_config=tts_preprocessor_config,
|
| 76 |
+
faster_first_response=basic_memory_settings.get(
|
| 77 |
+
"faster_first_response", True
|
| 78 |
+
),
|
| 79 |
+
segment_method=basic_memory_settings.get("segment_method", "pysbd"),
|
| 80 |
+
use_mcpp=basic_memory_settings.get("use_mcpp", False),
|
| 81 |
+
interrupt_method=interrupt_method,
|
| 82 |
+
tool_prompts=tool_prompts,
|
| 83 |
+
tool_manager=tool_manager,
|
| 84 |
+
tool_executor=tool_executor,
|
| 85 |
+
mcp_prompt_string=mcp_prompt_string,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
elif conversation_agent_choice == "mem0_agent":
|
| 89 |
+
from .agents.mem0_llm import LLM as Mem0LLM
|
| 90 |
+
|
| 91 |
+
mem0_settings = agent_settings.get("mem0_agent", {})
|
| 92 |
+
if not mem0_settings:
|
| 93 |
+
raise ValueError("Mem0 agent settings not found")
|
| 94 |
+
|
| 95 |
+
# Validate required settings
|
| 96 |
+
required_fields = ["base_url", "model", "mem0_config"]
|
| 97 |
+
for field in required_fields:
|
| 98 |
+
if field not in mem0_settings:
|
| 99 |
+
raise ValueError(
|
| 100 |
+
f"Missing required field '{field}' in mem0_agent settings"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return Mem0LLM(
|
| 104 |
+
user_id=kwargs.get("user_id", "default"),
|
| 105 |
+
system=system_prompt,
|
| 106 |
+
live2d_model=live2d_model,
|
| 107 |
+
**mem0_settings,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
elif conversation_agent_choice == "hume_ai_agent":
|
| 111 |
+
settings = agent_settings.get("hume_ai_agent", {})
|
| 112 |
+
return HumeAIAgent(
|
| 113 |
+
api_key=settings.get("api_key"),
|
| 114 |
+
host=settings.get("host", "api.hume.ai"),
|
| 115 |
+
config_id=settings.get("config_id"),
|
| 116 |
+
idle_timeout=settings.get("idle_timeout", 15),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
elif conversation_agent_choice == "letta_agent":
|
| 120 |
+
settings = agent_settings.get("letta_agent", {})
|
| 121 |
+
return LettaAgent(
|
| 122 |
+
live2d_model=live2d_model,
|
| 123 |
+
id=settings.get("id"),
|
| 124 |
+
tts_preprocessor_config=tts_preprocessor_config,
|
| 125 |
+
faster_first_response=settings.get("faster_first_response"),
|
| 126 |
+
segment_method=settings.get("segment_method"),
|
| 127 |
+
host=settings.get("host"),
|
| 128 |
+
port=settings.get("port"),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
else:
|
| 132 |
+
raise ValueError(f"Unsupported agent type: {conversation_agent_choice}")
|
src/open_llm_vtuber/agent/agents/__init__.py
ADDED
|
File without changes
|
src/open_llm_vtuber/agent/agents/agent_interface.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import AsyncIterator
|
| 3 |
+
from loguru import logger
|
| 4 |
+
|
| 5 |
+
from ..output_types import BaseOutput
|
| 6 |
+
from ..input_types import BaseInput
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AgentInterface(ABC):
|
| 10 |
+
"""Base interface for all agent implementations"""
|
| 11 |
+
|
| 12 |
+
@abstractmethod
|
| 13 |
+
async def chat(self, input_data: BaseInput) -> AsyncIterator[BaseOutput]:
|
| 14 |
+
"""
|
| 15 |
+
Chat with the agent asynchronously.
|
| 16 |
+
|
| 17 |
+
This function should be implemented by the agent.
|
| 18 |
+
Output type depends on the agent's output_type:
|
| 19 |
+
- SentenceOutput: For text-based responses with display and TTS text
|
| 20 |
+
- AudioOutput: For direct audio output with display text and transcript
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
input_data: BaseInput - User input data
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
AsyncIterator[BaseOutput] - Stream of agent outputs
|
| 27 |
+
"""
|
| 28 |
+
logger.critical("Agent: No chat function set.")
|
| 29 |
+
raise ValueError("Agent: No chat function set.")
|
| 30 |
+
|
| 31 |
+
@abstractmethod
|
| 32 |
+
def handle_interrupt(self, heard_response: str) -> None:
|
| 33 |
+
"""
|
| 34 |
+
Handle user interruption. This function will be called when the agent is interrupted.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
heard_response: str - The part of response heard before interruption
|
| 38 |
+
"""
|
| 39 |
+
logger.warning(
|
| 40 |
+
"""Agent: No interrupt handler set. The agent may not handle interruptions
|
| 41 |
+
correctly. The AI may not be able to understand that it was interrupted."""
|
| 42 |
+
)
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
@abstractmethod
|
| 46 |
+
def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
|
| 47 |
+
"""
|
| 48 |
+
Load the agent's working memory from chat history
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
conf_uid: str - Configuration ID
|
| 52 |
+
history_uid: str - History ID
|
| 53 |
+
"""
|
| 54 |
+
pass
|
src/open_llm_vtuber/agent/agents/basic_memory_agent.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import (
|
| 2 |
+
AsyncIterator,
|
| 3 |
+
List,
|
| 4 |
+
Dict,
|
| 5 |
+
Any,
|
| 6 |
+
Callable,
|
| 7 |
+
Literal,
|
| 8 |
+
Union,
|
| 9 |
+
Optional,
|
| 10 |
+
)
|
| 11 |
+
from loguru import logger
|
| 12 |
+
from .agent_interface import AgentInterface
|
| 13 |
+
from ..output_types import SentenceOutput, DisplayText
|
| 14 |
+
from ..stateless_llm.stateless_llm_interface import StatelessLLMInterface
|
| 15 |
+
from ..stateless_llm.claude_llm import AsyncLLM as ClaudeAsyncLLM
|
| 16 |
+
from ..stateless_llm.openai_compatible_llm import AsyncLLM as OpenAICompatibleAsyncLLM
|
| 17 |
+
from ...chat_history_manager import get_history
|
| 18 |
+
from ..transformers import (
|
| 19 |
+
sentence_divider,
|
| 20 |
+
actions_extractor,
|
| 21 |
+
tts_filter,
|
| 22 |
+
display_processor,
|
| 23 |
+
)
|
| 24 |
+
from ...config_manager import TTSPreprocessorConfig
|
| 25 |
+
from ..input_types import BatchInput, TextSource
|
| 26 |
+
from prompts import prompt_loader
|
| 27 |
+
from ...mcpp.tool_manager import ToolManager
|
| 28 |
+
from ...mcpp.json_detector import StreamJSONDetector
|
| 29 |
+
from ...mcpp.types import ToolCallObject
|
| 30 |
+
from ...mcpp.tool_executor import ToolExecutor
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class BasicMemoryAgent(AgentInterface):
|
| 34 |
+
"""Agent with basic chat memory and tool calling support."""
|
| 35 |
+
|
| 36 |
+
_system: str = "You are a helpful assistant."
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
llm: StatelessLLMInterface,
|
| 41 |
+
system: str,
|
| 42 |
+
live2d_model,
|
| 43 |
+
tts_preprocessor_config: TTSPreprocessorConfig = None,
|
| 44 |
+
faster_first_response: bool = True,
|
| 45 |
+
segment_method: str = "pysbd",
|
| 46 |
+
use_mcpp: bool = False,
|
| 47 |
+
interrupt_method: Literal["system", "user"] = "user",
|
| 48 |
+
tool_prompts: Dict[str, str] = None,
|
| 49 |
+
tool_manager: Optional[ToolManager] = None,
|
| 50 |
+
tool_executor: Optional[ToolExecutor] = None,
|
| 51 |
+
mcp_prompt_string: str = "",
|
| 52 |
+
):
|
| 53 |
+
"""Initialize agent with LLM and configuration."""
|
| 54 |
+
super().__init__()
|
| 55 |
+
self._memory = []
|
| 56 |
+
self._live2d_model = live2d_model
|
| 57 |
+
self._tts_preprocessor_config = tts_preprocessor_config
|
| 58 |
+
self._faster_first_response = faster_first_response
|
| 59 |
+
self._segment_method = segment_method
|
| 60 |
+
self._use_mcpp = use_mcpp
|
| 61 |
+
self.interrupt_method = interrupt_method
|
| 62 |
+
self._tool_prompts = tool_prompts or {}
|
| 63 |
+
self._interrupt_handled = False
|
| 64 |
+
self.prompt_mode_flag = False
|
| 65 |
+
|
| 66 |
+
self._tool_manager = tool_manager
|
| 67 |
+
self._tool_executor = tool_executor
|
| 68 |
+
self._mcp_prompt_string = mcp_prompt_string
|
| 69 |
+
self._json_detector = StreamJSONDetector()
|
| 70 |
+
|
| 71 |
+
self._formatted_tools_openai = []
|
| 72 |
+
self._formatted_tools_claude = []
|
| 73 |
+
if self._tool_manager:
|
| 74 |
+
self._formatted_tools_openai = self._tool_manager.get_formatted_tools(
|
| 75 |
+
"OpenAI"
|
| 76 |
+
)
|
| 77 |
+
self._formatted_tools_claude = self._tool_manager.get_formatted_tools(
|
| 78 |
+
"Claude"
|
| 79 |
+
)
|
| 80 |
+
logger.debug(
|
| 81 |
+
f"Agent received pre-formatted tools - OpenAI: {len(self._formatted_tools_openai)}, Claude: {len(self._formatted_tools_claude)}"
|
| 82 |
+
)
|
| 83 |
+
else:
|
| 84 |
+
logger.debug(
|
| 85 |
+
"ToolManager not provided, agent will not have pre-formatted tools."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
self._set_llm(llm)
|
| 89 |
+
self.set_system(system if system else self._system)
|
| 90 |
+
|
| 91 |
+
if self._use_mcpp and not all(
|
| 92 |
+
[
|
| 93 |
+
self._tool_manager,
|
| 94 |
+
self._tool_executor,
|
| 95 |
+
self._json_detector,
|
| 96 |
+
]
|
| 97 |
+
):
|
| 98 |
+
logger.warning(
|
| 99 |
+
"use_mcpp is True, but some MCP components are missing in the agent. Tool calling might not work as expected."
|
| 100 |
+
)
|
| 101 |
+
elif not self._use_mcpp and any(
|
| 102 |
+
[
|
| 103 |
+
self._tool_manager,
|
| 104 |
+
self._tool_executor,
|
| 105 |
+
self._json_detector,
|
| 106 |
+
]
|
| 107 |
+
):
|
| 108 |
+
logger.warning(
|
| 109 |
+
"use_mcpp is False, but some MCP components were passed to the agent."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
logger.info("BasicMemoryAgent initialized.")
|
| 113 |
+
|
| 114 |
+
def _set_llm(self, llm: StatelessLLMInterface):
|
| 115 |
+
"""Set the LLM for chat completion."""
|
| 116 |
+
self._llm = llm
|
| 117 |
+
self.chat = self._chat_function_factory()
|
| 118 |
+
|
| 119 |
+
def set_system(self, system: str):
|
| 120 |
+
"""Set the system prompt."""
|
| 121 |
+
logger.debug(f"Memory Agent: Setting system prompt: '''{system}'''")
|
| 122 |
+
|
| 123 |
+
if self.interrupt_method == "user":
|
| 124 |
+
system = f"{system}\n\nIf you received `[interrupted by user]` signal, you were interrupted."
|
| 125 |
+
|
| 126 |
+
self._system = system
|
| 127 |
+
|
| 128 |
+
def _add_message(
|
| 129 |
+
self,
|
| 130 |
+
message: Union[str, List[Dict[str, Any]]],
|
| 131 |
+
role: str,
|
| 132 |
+
display_text: DisplayText | None = None,
|
| 133 |
+
skip_memory: bool = False,
|
| 134 |
+
):
|
| 135 |
+
"""Add message to memory."""
|
| 136 |
+
if skip_memory:
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
text_content = ""
|
| 140 |
+
if isinstance(message, list):
|
| 141 |
+
for item in message:
|
| 142 |
+
if item.get("type") == "text":
|
| 143 |
+
text_content += item["text"] + " "
|
| 144 |
+
text_content = text_content.strip()
|
| 145 |
+
elif isinstance(message, str):
|
| 146 |
+
text_content = message
|
| 147 |
+
else:
|
| 148 |
+
logger.warning(
|
| 149 |
+
f"_add_message received unexpected message type: {type(message)}"
|
| 150 |
+
)
|
| 151 |
+
text_content = str(message)
|
| 152 |
+
|
| 153 |
+
if not text_content and role == "assistant":
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
message_data = {
|
| 157 |
+
"role": role,
|
| 158 |
+
"content": text_content,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
if display_text:
|
| 162 |
+
if display_text.name:
|
| 163 |
+
message_data["name"] = display_text.name
|
| 164 |
+
if display_text.avatar:
|
| 165 |
+
message_data["avatar"] = display_text.avatar
|
| 166 |
+
|
| 167 |
+
if (
|
| 168 |
+
self._memory
|
| 169 |
+
and self._memory[-1]["role"] == role
|
| 170 |
+
and self._memory[-1]["content"] == text_content
|
| 171 |
+
):
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
self._memory.append(message_data)
|
| 175 |
+
|
| 176 |
+
def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
|
| 177 |
+
"""Load memory from chat history."""
|
| 178 |
+
messages = get_history(conf_uid, history_uid)
|
| 179 |
+
|
| 180 |
+
self._memory = []
|
| 181 |
+
for msg in messages:
|
| 182 |
+
role = "user" if msg["role"] == "human" else "assistant"
|
| 183 |
+
content = msg["content"]
|
| 184 |
+
if isinstance(content, str) and content:
|
| 185 |
+
self._memory.append(
|
| 186 |
+
{
|
| 187 |
+
"role": role,
|
| 188 |
+
"content": content,
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
else:
|
| 192 |
+
logger.warning(f"Skipping invalid message from history: {msg}")
|
| 193 |
+
logger.info(f"Loaded {len(self._memory)} messages from history.")
|
| 194 |
+
|
| 195 |
+
def handle_interrupt(self, heard_response: str) -> None:
|
| 196 |
+
"""Handle user interruption."""
|
| 197 |
+
if self._interrupt_handled:
|
| 198 |
+
return
|
| 199 |
+
|
| 200 |
+
self._interrupt_handled = True
|
| 201 |
+
|
| 202 |
+
if self._memory and self._memory[-1]["role"] == "assistant":
|
| 203 |
+
if not self._memory[-1]["content"].endswith("..."):
|
| 204 |
+
self._memory[-1]["content"] = heard_response + "..."
|
| 205 |
+
else:
|
| 206 |
+
self._memory[-1]["content"] = heard_response + "..."
|
| 207 |
+
else:
|
| 208 |
+
if heard_response:
|
| 209 |
+
self._memory.append(
|
| 210 |
+
{
|
| 211 |
+
"role": "assistant",
|
| 212 |
+
"content": heard_response + "...",
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
interrupt_role = "system" if self.interrupt_method == "system" else "user"
|
| 217 |
+
self._memory.append(
|
| 218 |
+
{
|
| 219 |
+
"role": interrupt_role,
|
| 220 |
+
"content": "[Interrupted by user]",
|
| 221 |
+
}
|
| 222 |
+
)
|
| 223 |
+
logger.info(f"Handled interrupt with role '{interrupt_role}'.")
|
| 224 |
+
|
| 225 |
+
def _to_text_prompt(self, input_data: BatchInput) -> str:
|
| 226 |
+
"""Format input data to text prompt."""
|
| 227 |
+
message_parts = []
|
| 228 |
+
|
| 229 |
+
for text_data in input_data.texts:
|
| 230 |
+
if text_data.source == TextSource.INPUT:
|
| 231 |
+
message_parts.append(text_data.content)
|
| 232 |
+
elif text_data.source == TextSource.CLIPBOARD:
|
| 233 |
+
message_parts.append(
|
| 234 |
+
f"[User shared content from clipboard: {text_data.content}]"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if input_data.images:
|
| 238 |
+
message_parts.append("\n[User has also provided images]")
|
| 239 |
+
|
| 240 |
+
return "\n".join(message_parts).strip()
|
| 241 |
+
|
| 242 |
+
def _to_messages(self, input_data: BatchInput) -> List[Dict[str, Any]]:
|
| 243 |
+
"""Prepare messages for LLM API call."""
|
| 244 |
+
messages = self._memory.copy()
|
| 245 |
+
user_content = []
|
| 246 |
+
text_prompt = self._to_text_prompt(input_data)
|
| 247 |
+
if text_prompt:
|
| 248 |
+
user_content.append({"type": "text", "text": text_prompt})
|
| 249 |
+
|
| 250 |
+
if input_data.images:
|
| 251 |
+
image_added = False
|
| 252 |
+
for img_data in input_data.images:
|
| 253 |
+
if isinstance(img_data.data, str) and img_data.data.startswith(
|
| 254 |
+
"data:image"
|
| 255 |
+
):
|
| 256 |
+
user_content.append(
|
| 257 |
+
{
|
| 258 |
+
"type": "image_url",
|
| 259 |
+
"image_url": {"url": img_data.data, "detail": "auto"},
|
| 260 |
+
}
|
| 261 |
+
)
|
| 262 |
+
image_added = True
|
| 263 |
+
else:
|
| 264 |
+
logger.error(
|
| 265 |
+
f"Invalid image data format: {type(img_data.data)}. Skipping image."
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
if not image_added and not text_prompt:
|
| 269 |
+
logger.warning(
|
| 270 |
+
"User input contains images but none could be processed."
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
if user_content:
|
| 274 |
+
user_message = {"role": "user", "content": user_content}
|
| 275 |
+
messages.append(user_message)
|
| 276 |
+
|
| 277 |
+
skip_memory = False
|
| 278 |
+
if input_data.metadata and input_data.metadata.get("skip_memory", False):
|
| 279 |
+
skip_memory = True
|
| 280 |
+
|
| 281 |
+
if not skip_memory:
|
| 282 |
+
self._add_message(
|
| 283 |
+
text_prompt if text_prompt else "[User provided image(s)]", "user"
|
| 284 |
+
)
|
| 285 |
+
else:
|
| 286 |
+
logger.warning("No content generated for user message.")
|
| 287 |
+
|
| 288 |
+
return messages
|
| 289 |
+
|
| 290 |
+
async def _claude_tool_interaction_loop(
|
| 291 |
+
self,
|
| 292 |
+
initial_messages: List[Dict[str, Any]],
|
| 293 |
+
tools: List[Dict[str, Any]],
|
| 294 |
+
) -> AsyncIterator[Union[str, Dict[str, Any]]]:
|
| 295 |
+
"""Handle Claude interaction loop with tool support."""
|
| 296 |
+
messages = initial_messages.copy()
|
| 297 |
+
current_turn_text = ""
|
| 298 |
+
pending_tool_calls = []
|
| 299 |
+
current_assistant_message_content = []
|
| 300 |
+
|
| 301 |
+
while True:
|
| 302 |
+
stream = self._llm.chat_completion(messages, self._system, tools=tools)
|
| 303 |
+
pending_tool_calls.clear()
|
| 304 |
+
current_assistant_message_content.clear()
|
| 305 |
+
|
| 306 |
+
async for event in stream:
|
| 307 |
+
if event["type"] == "text_delta":
|
| 308 |
+
text = event["text"]
|
| 309 |
+
current_turn_text += text
|
| 310 |
+
yield text
|
| 311 |
+
if (
|
| 312 |
+
not current_assistant_message_content
|
| 313 |
+
or current_assistant_message_content[-1]["type"] != "text"
|
| 314 |
+
):
|
| 315 |
+
current_assistant_message_content.append(
|
| 316 |
+
{"type": "text", "text": text}
|
| 317 |
+
)
|
| 318 |
+
else:
|
| 319 |
+
current_assistant_message_content[-1]["text"] += text
|
| 320 |
+
elif event["type"] == "tool_use_complete":
|
| 321 |
+
tool_call_data = event["data"]
|
| 322 |
+
logger.info(
|
| 323 |
+
f"Tool request: {tool_call_data['name']} (ID: {tool_call_data['id']})"
|
| 324 |
+
)
|
| 325 |
+
pending_tool_calls.append(tool_call_data)
|
| 326 |
+
current_assistant_message_content.append(
|
| 327 |
+
{
|
| 328 |
+
"type": "tool_use",
|
| 329 |
+
"id": tool_call_data["id"],
|
| 330 |
+
"name": tool_call_data["name"],
|
| 331 |
+
"input": tool_call_data["input"],
|
| 332 |
+
}
|
| 333 |
+
)
|
| 334 |
+
# elif event["type"] == "message_delta":
|
| 335 |
+
# if event["data"]["delta"].get("stop_reason"):
|
| 336 |
+
# stop_reason = event["data"]["delta"].get("stop_reason")
|
| 337 |
+
elif event["type"] == "message_stop":
|
| 338 |
+
break
|
| 339 |
+
elif event["type"] == "error":
|
| 340 |
+
logger.error(f"LLM API Error: {event['message']}")
|
| 341 |
+
yield f"[Error from LLM: {event['message']}]"
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
if pending_tool_calls:
|
| 345 |
+
filtered_assistant_content = [
|
| 346 |
+
block
|
| 347 |
+
for block in current_assistant_message_content
|
| 348 |
+
if not (
|
| 349 |
+
block.get("type") == "text"
|
| 350 |
+
and not block.get("text", "").strip()
|
| 351 |
+
)
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
if filtered_assistant_content:
|
| 355 |
+
messages.append(
|
| 356 |
+
{"role": "assistant", "content": filtered_assistant_content}
|
| 357 |
+
)
|
| 358 |
+
assistant_text_for_memory = "".join(
|
| 359 |
+
[
|
| 360 |
+
c["text"]
|
| 361 |
+
for c in filtered_assistant_content
|
| 362 |
+
if c["type"] == "text"
|
| 363 |
+
]
|
| 364 |
+
).strip()
|
| 365 |
+
if assistant_text_for_memory:
|
| 366 |
+
self._add_message(assistant_text_for_memory, "assistant")
|
| 367 |
+
|
| 368 |
+
tool_results_for_llm = []
|
| 369 |
+
if not self._tool_executor:
|
| 370 |
+
logger.error(
|
| 371 |
+
"Claude Tool interaction requested but ToolExecutor is not available."
|
| 372 |
+
)
|
| 373 |
+
yield "[Error: ToolExecutor not configured]"
|
| 374 |
+
return
|
| 375 |
+
|
| 376 |
+
tool_executor_iterator = self._tool_executor.execute_tools(
|
| 377 |
+
tool_calls=pending_tool_calls,
|
| 378 |
+
caller_mode="Claude",
|
| 379 |
+
)
|
| 380 |
+
try:
|
| 381 |
+
while True:
|
| 382 |
+
update = await anext(tool_executor_iterator)
|
| 383 |
+
if update.get("type") == "final_tool_results":
|
| 384 |
+
tool_results_for_llm = update.get("results", [])
|
| 385 |
+
break
|
| 386 |
+
else:
|
| 387 |
+
yield update
|
| 388 |
+
except StopAsyncIteration:
|
| 389 |
+
logger.warning(
|
| 390 |
+
"Tool executor finished without final results marker."
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
if tool_results_for_llm:
|
| 394 |
+
messages.append({"role": "user", "content": tool_results_for_llm})
|
| 395 |
+
|
| 396 |
+
# stop_reason = None
|
| 397 |
+
continue
|
| 398 |
+
else:
|
| 399 |
+
if current_turn_text:
|
| 400 |
+
self._add_message(current_turn_text, "assistant")
|
| 401 |
+
return
|
| 402 |
+
|
| 403 |
+
async def _openai_tool_interaction_loop(
|
| 404 |
+
self,
|
| 405 |
+
initial_messages: List[Dict[str, Any]],
|
| 406 |
+
tools: List[Dict[str, Any]],
|
| 407 |
+
) -> AsyncIterator[Union[str, Dict[str, Any]]]:
|
| 408 |
+
"""Handle OpenAI interaction with tool support."""
|
| 409 |
+
messages = initial_messages.copy()
|
| 410 |
+
current_turn_text = ""
|
| 411 |
+
pending_tool_calls: Union[List[ToolCallObject], List[Dict[str, Any]]] = []
|
| 412 |
+
current_system_prompt = self._system
|
| 413 |
+
|
| 414 |
+
while True:
|
| 415 |
+
if self.prompt_mode_flag:
|
| 416 |
+
if self._mcp_prompt_string:
|
| 417 |
+
current_system_prompt = (
|
| 418 |
+
f"{self._system}\n\n{self._mcp_prompt_string}"
|
| 419 |
+
)
|
| 420 |
+
else:
|
| 421 |
+
logger.warning("Prompt mode active but mcp_prompt_string is empty!")
|
| 422 |
+
current_system_prompt = self._system
|
| 423 |
+
tools_for_api = None
|
| 424 |
+
else:
|
| 425 |
+
current_system_prompt = self._system
|
| 426 |
+
tools_for_api = tools
|
| 427 |
+
|
| 428 |
+
stream = self._llm.chat_completion(
|
| 429 |
+
messages, current_system_prompt, tools=tools_for_api
|
| 430 |
+
)
|
| 431 |
+
pending_tool_calls.clear()
|
| 432 |
+
current_turn_text = ""
|
| 433 |
+
assistant_message_for_api = None
|
| 434 |
+
detected_prompt_json = None
|
| 435 |
+
goto_next_while_iteration = False
|
| 436 |
+
|
| 437 |
+
async for event in stream:
|
| 438 |
+
if self.prompt_mode_flag:
|
| 439 |
+
if isinstance(event, str):
|
| 440 |
+
current_turn_text += event
|
| 441 |
+
if self._json_detector:
|
| 442 |
+
potential_json = self._json_detector.process_chunk(event)
|
| 443 |
+
if potential_json:
|
| 444 |
+
try:
|
| 445 |
+
if isinstance(potential_json, list):
|
| 446 |
+
detected_prompt_json = potential_json
|
| 447 |
+
elif isinstance(potential_json, dict):
|
| 448 |
+
detected_prompt_json = [potential_json]
|
| 449 |
+
|
| 450 |
+
if detected_prompt_json:
|
| 451 |
+
break
|
| 452 |
+
except Exception as e:
|
| 453 |
+
logger.error(f"Error parsing detected JSON: {e}")
|
| 454 |
+
if self._json_detector:
|
| 455 |
+
self._json_detector.reset()
|
| 456 |
+
yield f"[Error parsing tool JSON: {e}]"
|
| 457 |
+
goto_next_while_iteration = True
|
| 458 |
+
break
|
| 459 |
+
yield event
|
| 460 |
+
else:
|
| 461 |
+
if isinstance(event, str):
|
| 462 |
+
current_turn_text += event
|
| 463 |
+
yield event
|
| 464 |
+
elif isinstance(event, list) and all(
|
| 465 |
+
isinstance(tc, ToolCallObject) for tc in event
|
| 466 |
+
):
|
| 467 |
+
pending_tool_calls = event
|
| 468 |
+
assistant_message_for_api = {
|
| 469 |
+
"role": "assistant",
|
| 470 |
+
"content": current_turn_text if current_turn_text else None,
|
| 471 |
+
"tool_calls": [
|
| 472 |
+
{
|
| 473 |
+
"id": tc.id,
|
| 474 |
+
"type": tc.type,
|
| 475 |
+
"function": {
|
| 476 |
+
"name": tc.function.name,
|
| 477 |
+
"arguments": tc.function.arguments,
|
| 478 |
+
},
|
| 479 |
+
}
|
| 480 |
+
for tc in pending_tool_calls
|
| 481 |
+
],
|
| 482 |
+
}
|
| 483 |
+
break
|
| 484 |
+
elif event == "__API_NOT_SUPPORT_TOOLS__":
|
| 485 |
+
logger.warning(
|
| 486 |
+
f"LLM {getattr(self._llm, 'model', '')} has no native tool support. Switching to prompt mode."
|
| 487 |
+
)
|
| 488 |
+
self.prompt_mode_flag = True
|
| 489 |
+
if self._tool_manager:
|
| 490 |
+
self._tool_manager.disable()
|
| 491 |
+
if self._json_detector:
|
| 492 |
+
self._json_detector.reset()
|
| 493 |
+
goto_next_while_iteration = True
|
| 494 |
+
break
|
| 495 |
+
if goto_next_while_iteration:
|
| 496 |
+
continue
|
| 497 |
+
|
| 498 |
+
if detected_prompt_json:
|
| 499 |
+
logger.info("Processing tools detected via prompt mode JSON.")
|
| 500 |
+
self._add_message(current_turn_text, "assistant")
|
| 501 |
+
|
| 502 |
+
parsed_tools = self._tool_executor.process_tool_from_prompt_json(
|
| 503 |
+
detected_prompt_json
|
| 504 |
+
)
|
| 505 |
+
if parsed_tools:
|
| 506 |
+
tool_results_for_llm = []
|
| 507 |
+
if not self._tool_executor:
|
| 508 |
+
logger.error(
|
| 509 |
+
"Prompt Tool interaction requested but ToolExecutor/MCPClient is not available."
|
| 510 |
+
)
|
| 511 |
+
yield "[Error: ToolExecutor/MCPClient not configured for prompt mode]"
|
| 512 |
+
continue
|
| 513 |
+
|
| 514 |
+
tool_executor_iterator = self._tool_executor.execute_tools(
|
| 515 |
+
tool_calls=parsed_tools,
|
| 516 |
+
caller_mode="Prompt",
|
| 517 |
+
)
|
| 518 |
+
try:
|
| 519 |
+
while True:
|
| 520 |
+
update = await anext(tool_executor_iterator)
|
| 521 |
+
if update.get("type") == "final_tool_results":
|
| 522 |
+
tool_results_for_llm = update.get("results", [])
|
| 523 |
+
break
|
| 524 |
+
else:
|
| 525 |
+
yield update
|
| 526 |
+
except StopAsyncIteration:
|
| 527 |
+
logger.warning(
|
| 528 |
+
"Prompt mode tool executor finished without final results marker."
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
if tool_results_for_llm:
|
| 532 |
+
result_strings = [
|
| 533 |
+
res.get("content", "Error: Malformed result")
|
| 534 |
+
for res in tool_results_for_llm
|
| 535 |
+
]
|
| 536 |
+
combined_results_str = "\n".join(result_strings)
|
| 537 |
+
messages.append(
|
| 538 |
+
{"role": "user", "content": combined_results_str}
|
| 539 |
+
)
|
| 540 |
+
continue
|
| 541 |
+
|
| 542 |
+
elif pending_tool_calls and assistant_message_for_api:
|
| 543 |
+
messages.append(assistant_message_for_api)
|
| 544 |
+
if current_turn_text:
|
| 545 |
+
self._add_message(current_turn_text, "assistant")
|
| 546 |
+
|
| 547 |
+
tool_results_for_llm = []
|
| 548 |
+
if not self._tool_executor:
|
| 549 |
+
logger.error(
|
| 550 |
+
"OpenAI Tool interaction requested but ToolExecutor/MCPClient is not available."
|
| 551 |
+
)
|
| 552 |
+
yield "[Error: ToolExecutor/MCPClient not configured for OpenAI mode]"
|
| 553 |
+
continue
|
| 554 |
+
|
| 555 |
+
tool_executor_iterator = self._tool_executor.execute_tools(
|
| 556 |
+
tool_calls=pending_tool_calls,
|
| 557 |
+
caller_mode="OpenAI",
|
| 558 |
+
)
|
| 559 |
+
try:
|
| 560 |
+
while True:
|
| 561 |
+
update = await anext(tool_executor_iterator)
|
| 562 |
+
if update.get("type") == "final_tool_results":
|
| 563 |
+
tool_results_for_llm = update.get("results", [])
|
| 564 |
+
break
|
| 565 |
+
else:
|
| 566 |
+
yield update
|
| 567 |
+
except StopAsyncIteration:
|
| 568 |
+
logger.warning(
|
| 569 |
+
"OpenAI tool executor finished without final results marker."
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
if tool_results_for_llm:
|
| 573 |
+
messages.extend(tool_results_for_llm)
|
| 574 |
+
continue
|
| 575 |
+
|
| 576 |
+
else:
|
| 577 |
+
if current_turn_text:
|
| 578 |
+
self._add_message(current_turn_text, "assistant")
|
| 579 |
+
return
|
| 580 |
+
|
| 581 |
+
def _chat_function_factory(
|
| 582 |
+
self,
|
| 583 |
+
) -> Callable[[BatchInput], AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]]:
|
| 584 |
+
"""Create the chat pipeline function."""
|
| 585 |
+
|
| 586 |
+
@tts_filter(self._tts_preprocessor_config)
|
| 587 |
+
@display_processor()
|
| 588 |
+
@actions_extractor(self._live2d_model)
|
| 589 |
+
@sentence_divider(
|
| 590 |
+
faster_first_response=self._faster_first_response,
|
| 591 |
+
segment_method=self._segment_method,
|
| 592 |
+
valid_tags=["think"],
|
| 593 |
+
)
|
| 594 |
+
async def chat_with_memory(
|
| 595 |
+
input_data: BatchInput,
|
| 596 |
+
) -> AsyncIterator[Union[str, Dict[str, Any]]]:
|
| 597 |
+
"""Process chat with memory and tools."""
|
| 598 |
+
self.reset_interrupt()
|
| 599 |
+
self.prompt_mode_flag = False
|
| 600 |
+
|
| 601 |
+
messages = self._to_messages(input_data)
|
| 602 |
+
tools = None
|
| 603 |
+
tool_mode = None
|
| 604 |
+
llm_supports_native_tools = False
|
| 605 |
+
|
| 606 |
+
if self._use_mcpp and self._tool_manager:
|
| 607 |
+
tools = None
|
| 608 |
+
if isinstance(self._llm, ClaudeAsyncLLM):
|
| 609 |
+
tool_mode = "Claude"
|
| 610 |
+
tools = self._formatted_tools_claude
|
| 611 |
+
llm_supports_native_tools = True
|
| 612 |
+
elif isinstance(self._llm, OpenAICompatibleAsyncLLM):
|
| 613 |
+
tool_mode = "OpenAI"
|
| 614 |
+
tools = self._formatted_tools_openai
|
| 615 |
+
llm_supports_native_tools = True
|
| 616 |
+
else:
|
| 617 |
+
logger.warning(
|
| 618 |
+
f"LLM type {type(self._llm)} not explicitly handled for tool mode determination."
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
if llm_supports_native_tools and not tools:
|
| 622 |
+
logger.warning(
|
| 623 |
+
f"No tools available/formatted for '{tool_mode}' mode, despite MCP being enabled."
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
if self._use_mcpp and tool_mode == "Claude":
|
| 627 |
+
logger.debug(
|
| 628 |
+
f"Starting Claude tool interaction loop with {len(tools)} tools."
|
| 629 |
+
)
|
| 630 |
+
async for output in self._claude_tool_interaction_loop(
|
| 631 |
+
messages, tools if tools else []
|
| 632 |
+
):
|
| 633 |
+
yield output
|
| 634 |
+
return
|
| 635 |
+
elif self._use_mcpp and tool_mode == "OpenAI":
|
| 636 |
+
logger.debug(
|
| 637 |
+
f"Starting OpenAI tool interaction loop with {len(tools)} tools."
|
| 638 |
+
)
|
| 639 |
+
async for output in self._openai_tool_interaction_loop(
|
| 640 |
+
messages, tools if tools else []
|
| 641 |
+
):
|
| 642 |
+
yield output
|
| 643 |
+
return
|
| 644 |
+
else:
|
| 645 |
+
logger.info("Starting simple chat completion.")
|
| 646 |
+
token_stream = self._llm.chat_completion(messages, self._system)
|
| 647 |
+
complete_response = ""
|
| 648 |
+
async for event in token_stream:
|
| 649 |
+
text_chunk = ""
|
| 650 |
+
if isinstance(event, dict) and event.get("type") == "text_delta":
|
| 651 |
+
text_chunk = event.get("text", "")
|
| 652 |
+
elif isinstance(event, str):
|
| 653 |
+
text_chunk = event
|
| 654 |
+
else:
|
| 655 |
+
continue
|
| 656 |
+
if text_chunk:
|
| 657 |
+
yield text_chunk
|
| 658 |
+
complete_response += text_chunk
|
| 659 |
+
if complete_response:
|
| 660 |
+
self._add_message(complete_response, "assistant")
|
| 661 |
+
|
| 662 |
+
return chat_with_memory
|
| 663 |
+
|
| 664 |
+
async def chat(
|
| 665 |
+
self,
|
| 666 |
+
input_data: BatchInput,
|
| 667 |
+
) -> AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]:
|
| 668 |
+
"""Run chat pipeline."""
|
| 669 |
+
chat_func_decorated = self._chat_function_factory()
|
| 670 |
+
async for output in chat_func_decorated(input_data):
|
| 671 |
+
yield output
|
| 672 |
+
|
| 673 |
+
def reset_interrupt(self) -> None:
|
| 674 |
+
"""Reset interrupt flag."""
|
| 675 |
+
self._interrupt_handled = False
|
| 676 |
+
|
| 677 |
+
def start_group_conversation(
|
| 678 |
+
self, human_name: str, ai_participants: List[str]
|
| 679 |
+
) -> None:
|
| 680 |
+
"""Start a group conversation."""
|
| 681 |
+
if not self._tool_prompts:
|
| 682 |
+
logger.warning("Tool prompts dictionary is not set.")
|
| 683 |
+
return
|
| 684 |
+
|
| 685 |
+
other_ais = ", ".join(name for name in ai_participants)
|
| 686 |
+
prompt_name = self._tool_prompts.get("group_conversation_prompt", "")
|
| 687 |
+
|
| 688 |
+
if not prompt_name:
|
| 689 |
+
logger.warning("No group conversation prompt name found.")
|
| 690 |
+
return
|
| 691 |
+
|
| 692 |
+
try:
|
| 693 |
+
group_context = prompt_loader.load_util(prompt_name).format(
|
| 694 |
+
human_name=human_name, other_ais=other_ais
|
| 695 |
+
)
|
| 696 |
+
self._memory.append({"role": "user", "content": group_context})
|
| 697 |
+
except FileNotFoundError:
|
| 698 |
+
logger.error(f"Group conversation prompt file not found: {prompt_name}")
|
| 699 |
+
except KeyError as e:
|
| 700 |
+
logger.error(f"Missing formatting key in group conversation prompt: {e}")
|
| 701 |
+
except Exception as e:
|
| 702 |
+
logger.error(f"Failed to load group conversation prompt: {e}")
|
src/open_llm_vtuber/agent/agents/hume_ai.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import base64
|
| 3 |
+
from typing import AsyncIterator, Optional
|
| 4 |
+
import json
|
| 5 |
+
import websockets
|
| 6 |
+
from loguru import logger
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from .agent_interface import AgentInterface
|
| 10 |
+
from ..output_types import AudioOutput, Actions, DisplayText
|
| 11 |
+
from ..input_types import BatchInput
|
| 12 |
+
from ...chat_history_manager import get_metadata, update_metadate
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class HumeAIAgent(AgentInterface):
|
| 16 |
+
"""
|
| 17 |
+
Hume AI Agent that handles text input and audio output.
|
| 18 |
+
Uses AudioOutput type to provide audio responses with transcripts.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
AGENT_TYPE = "hume_ai_agent"
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
api_key: str,
|
| 26 |
+
host: str = "api.hume.ai",
|
| 27 |
+
config_id: Optional[str] = None,
|
| 28 |
+
idle_timeout: int = 15,
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
Initialize Hume AI agent
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
api_key: Hume AI API key
|
| 35 |
+
host: API host
|
| 36 |
+
config_id: Optional configuration ID
|
| 37 |
+
idle_timeout: Connection idle timeout in seconds
|
| 38 |
+
"""
|
| 39 |
+
self.api_key = api_key
|
| 40 |
+
self.host = host
|
| 41 |
+
self.config_id = config_id
|
| 42 |
+
self.idle_timeout = idle_timeout
|
| 43 |
+
self._ws = None
|
| 44 |
+
self._current_text = None
|
| 45 |
+
self._current_id = None
|
| 46 |
+
self._connected = False
|
| 47 |
+
self._chat_group_id = None
|
| 48 |
+
self._idle_timer = None
|
| 49 |
+
self._current_conf_uid = None
|
| 50 |
+
self._current_history_uid = None
|
| 51 |
+
|
| 52 |
+
# Create cache directory if it doesn't exist
|
| 53 |
+
self.cache_dir = Path("./cache")
|
| 54 |
+
self.cache_dir.mkdir(exist_ok=True)
|
| 55 |
+
|
| 56 |
+
async def connect(self, resume_chat_group_id: Optional[str] = None):
|
| 57 |
+
"""
|
| 58 |
+
Establish WebSocket connection with optional chat group resumption
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
resume_chat_group_id: Optional chat group ID to resume
|
| 62 |
+
"""
|
| 63 |
+
if self._ws:
|
| 64 |
+
await self._ws.close()
|
| 65 |
+
self._ws = None
|
| 66 |
+
self._connected = False
|
| 67 |
+
|
| 68 |
+
# Build URL with query parameters
|
| 69 |
+
socket_url = f"wss://{self.host}/v0/evi/chat?api_key={self.api_key}"
|
| 70 |
+
|
| 71 |
+
if self.config_id:
|
| 72 |
+
socket_url += f"&config_id={self.config_id}"
|
| 73 |
+
|
| 74 |
+
if resume_chat_group_id:
|
| 75 |
+
logger.info(f"Resuming chat group: {resume_chat_group_id}")
|
| 76 |
+
socket_url += f"&resumed_chat_group_id={resume_chat_group_id}"
|
| 77 |
+
self._chat_group_id = resume_chat_group_id
|
| 78 |
+
|
| 79 |
+
logger.info(f"Connecting to EVI with config_id: {self.config_id}")
|
| 80 |
+
|
| 81 |
+
self._ws = await websockets.connect(socket_url)
|
| 82 |
+
self._connected = True
|
| 83 |
+
|
| 84 |
+
async for message in self._ws:
|
| 85 |
+
data = json.loads(message)
|
| 86 |
+
if data.get("type") == "chat_metadata":
|
| 87 |
+
new_chat_group_id = data.get("chat_group_id")
|
| 88 |
+
|
| 89 |
+
if not resume_chat_group_id and self._current_history_uid:
|
| 90 |
+
update_metadate(
|
| 91 |
+
self._current_conf_uid,
|
| 92 |
+
self._current_history_uid,
|
| 93 |
+
{"resume_id": new_chat_group_id, "agent_type": self.AGENT_TYPE},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
self._chat_group_id = new_chat_group_id
|
| 97 |
+
logger.info(
|
| 98 |
+
f"{'Resumed' if resume_chat_group_id else 'Created new'} "
|
| 99 |
+
f"chat group: {self._chat_group_id}"
|
| 100 |
+
)
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
def _reset_idle_timer(self):
|
| 104 |
+
"""Reset the idle timer"""
|
| 105 |
+
if self._idle_timer:
|
| 106 |
+
self._idle_timer.cancel()
|
| 107 |
+
|
| 108 |
+
async def disconnect_after_timeout():
|
| 109 |
+
await asyncio.sleep(self.idle_timeout)
|
| 110 |
+
if self._ws and self._connected:
|
| 111 |
+
logger.info("Idle timeout reached, disconnecting...")
|
| 112 |
+
await self._ws.close()
|
| 113 |
+
self._connected = False
|
| 114 |
+
|
| 115 |
+
self._idle_timer = asyncio.create_task(disconnect_after_timeout())
|
| 116 |
+
|
| 117 |
+
async def _ensure_connection(self):
|
| 118 |
+
"""Ensure connection is alive, reconnect if needed"""
|
| 119 |
+
if not self._connected or not self._ws or self._ws.closed:
|
| 120 |
+
await self.connect(self._chat_group_id)
|
| 121 |
+
|
| 122 |
+
def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
|
| 123 |
+
"""
|
| 124 |
+
Set chat group ID based on history
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
conf_uid: Configuration ID
|
| 128 |
+
history_uid: History ID
|
| 129 |
+
"""
|
| 130 |
+
self._current_conf_uid = conf_uid
|
| 131 |
+
self._current_history_uid = history_uid
|
| 132 |
+
|
| 133 |
+
metadata = get_metadata(conf_uid, history_uid)
|
| 134 |
+
|
| 135 |
+
agent_type = metadata.get("agent_type")
|
| 136 |
+
if agent_type and agent_type != self.AGENT_TYPE:
|
| 137 |
+
logger.warning(
|
| 138 |
+
f"Incompatible agent type in history: {agent_type}. "
|
| 139 |
+
f"Expected: {self.AGENT_TYPE} or empty. Memory will not be set."
|
| 140 |
+
)
|
| 141 |
+
self._chat_group_id = None
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
resume_id = metadata.get("resume_id")
|
| 145 |
+
if resume_id:
|
| 146 |
+
self._chat_group_id = resume_id
|
| 147 |
+
logger.info(f"Using resume_id from metadata: {resume_id}")
|
| 148 |
+
else:
|
| 149 |
+
self._chat_group_id = None
|
| 150 |
+
logger.info("No resume_id found in metadata, will create new chat group")
|
| 151 |
+
|
| 152 |
+
# Force reconnection on next chat
|
| 153 |
+
if self._ws:
|
| 154 |
+
asyncio.create_task(self._ws.close())
|
| 155 |
+
self._connected = False
|
| 156 |
+
|
| 157 |
+
async def chat(self, batch_input: BatchInput) -> AsyncIterator[AudioOutput]:
|
| 158 |
+
"""
|
| 159 |
+
Chat with Hume AI and get audio response
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
batch_input: BatchInput containing text and optional media
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
AsyncIterator[AudioOutput]: Stream of AudioOutput objects
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
self._reset_idle_timer()
|
| 169 |
+
await self._ensure_connection()
|
| 170 |
+
|
| 171 |
+
# Extract main text from BatchInput
|
| 172 |
+
input_text = batch_input.texts[0].content if batch_input.texts else ""
|
| 173 |
+
|
| 174 |
+
# Hume AI doesn't support image input, log warning if images present
|
| 175 |
+
if batch_input.images:
|
| 176 |
+
logger.warning(
|
| 177 |
+
"Hume AI does not support image input. Images will be ignored."
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
message = {
|
| 181 |
+
"type": "user_input",
|
| 182 |
+
"text": input_text,
|
| 183 |
+
}
|
| 184 |
+
await self._ws.send(json.dumps(message))
|
| 185 |
+
|
| 186 |
+
async for message in self._ws:
|
| 187 |
+
self._reset_idle_timer()
|
| 188 |
+
logger.debug(f"Received message: {message}")
|
| 189 |
+
try:
|
| 190 |
+
response_data = json.loads(message)
|
| 191 |
+
msg_type = response_data.get("type")
|
| 192 |
+
msg_id = response_data.get("id")
|
| 193 |
+
|
| 194 |
+
if msg_type == "assistant_message":
|
| 195 |
+
self._current_text = response_data["message"]["content"]
|
| 196 |
+
self._current_id = msg_id
|
| 197 |
+
|
| 198 |
+
elif msg_type == "audio_output":
|
| 199 |
+
if msg_id == self._current_id and self._current_text:
|
| 200 |
+
audio_data = base64.b64decode(response_data["data"])
|
| 201 |
+
cache_file = self.cache_dir / f"evi_audio_{msg_id}.wav"
|
| 202 |
+
|
| 203 |
+
with open(cache_file, "wb") as f:
|
| 204 |
+
f.write(audio_data)
|
| 205 |
+
logger.debug(f"Saved audio to cache file: {cache_file}")
|
| 206 |
+
|
| 207 |
+
# Create AudioOutput with DisplayText
|
| 208 |
+
yield AudioOutput(
|
| 209 |
+
audio_path=str(cache_file),
|
| 210 |
+
display_text=DisplayText(text=self._current_text),
|
| 211 |
+
transcript=self._current_text,
|
| 212 |
+
actions=Actions(),
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
self._current_text = None
|
| 216 |
+
self._current_id = None
|
| 217 |
+
|
| 218 |
+
elif msg_type == "assistant_end":
|
| 219 |
+
break
|
| 220 |
+
|
| 221 |
+
elif msg_type == "tool_error_message":
|
| 222 |
+
logger.error(f"Tool error: {response_data.get('error')}")
|
| 223 |
+
|
| 224 |
+
except json.JSONDecodeError as e:
|
| 225 |
+
logger.error(f"Failed to parse response JSON: {e}")
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
except websockets.exceptions.ConnectionClosed as e:
|
| 229 |
+
logger.warning(f"Connection closed: {e}, attempting to reconnect...")
|
| 230 |
+
self._connected = False
|
| 231 |
+
await self._ensure_connection()
|
| 232 |
+
async for result in self.chat(batch_input):
|
| 233 |
+
yield result
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error in chat: {e}")
|
| 237 |
+
raise
|
| 238 |
+
|
| 239 |
+
def handle_interrupt(self, heard_response: str) -> None:
|
| 240 |
+
"""Handle user interruption (not implemented for Hume AI)"""
|
| 241 |
+
pass
|
| 242 |
+
|
| 243 |
+
def __del__(self):
|
| 244 |
+
"""Cleanup WebSocket connection and cache files"""
|
| 245 |
+
if self._idle_timer:
|
| 246 |
+
self._idle_timer.cancel()
|
| 247 |
+
|
| 248 |
+
if self._ws:
|
| 249 |
+
self._ws.close()
|
| 250 |
+
|
| 251 |
+
# Clean up cache files
|
| 252 |
+
try:
|
| 253 |
+
for file in self.cache_dir.glob("evi_audio_*.wav"):
|
| 254 |
+
file.unlink()
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.error(f"Error cleaning up cache files: {e}")
|
src/open_llm_vtuber/agent/agents/letta_agent.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 2 |
+
from .agent_interface import AgentInterface
|
| 3 |
+
from ..output_types import SentenceOutput
|
| 4 |
+
from ..transformers import (
|
| 5 |
+
sentence_divider,
|
| 6 |
+
actions_extractor,
|
| 7 |
+
tts_filter,
|
| 8 |
+
display_processor,
|
| 9 |
+
)
|
| 10 |
+
from ...config_manager import TTSPreprocessorConfig
|
| 11 |
+
from ..input_types import BatchInput, TextSource
|
| 12 |
+
from letta_client import Letta
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LettaAgent(AgentInterface):
|
| 16 |
+
"""
|
| 17 |
+
Custom Letta class to interface with the Letta server.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
live2d_model,
|
| 23 |
+
id,
|
| 24 |
+
tts_preprocessor_config: TTSPreprocessorConfig = None,
|
| 25 |
+
faster_first_response: bool = True,
|
| 26 |
+
segment_method: str = "pysbd",
|
| 27 |
+
host: str = "localhost",
|
| 28 |
+
port: int = 8283,
|
| 29 |
+
):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.url = f"http://{host}:{port}"
|
| 32 |
+
self.client = Letta(base_url=self.url)
|
| 33 |
+
self.id = id
|
| 34 |
+
# Initialize decorator parameters
|
| 35 |
+
self._tts_preprocessor_config = tts_preprocessor_config
|
| 36 |
+
self._live2d_model = live2d_model
|
| 37 |
+
self._faster_first_response = faster_first_response
|
| 38 |
+
self._segment_method = segment_method
|
| 39 |
+
|
| 40 |
+
# Delay decorator application
|
| 41 |
+
self.chat = tts_filter(self._tts_preprocessor_config)(
|
| 42 |
+
display_processor()(
|
| 43 |
+
actions_extractor(self._live2d_model)(
|
| 44 |
+
sentence_divider(
|
| 45 |
+
faster_first_response=self._faster_first_response,
|
| 46 |
+
segment_method=self._segment_method,
|
| 47 |
+
valid_tags=["think"],
|
| 48 |
+
)(self.chat)
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
|
| 54 |
+
# The Letta Server automatically stores historical messages, so this part is not needed
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
def handle_interrupt(self, heard_response: str) -> None:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
async def generator_to_async(self, gen):
|
| 61 |
+
for item in gen:
|
| 62 |
+
yield item
|
| 63 |
+
|
| 64 |
+
async def chat(self, input_data: BatchInput) -> AsyncIterator[SentenceOutput]:
|
| 65 |
+
messages = self._to_messages(input_data)
|
| 66 |
+
stream = self.generator_to_async(
|
| 67 |
+
self.client.agents.messages.create_stream(
|
| 68 |
+
agent_id=self.id,
|
| 69 |
+
messages=messages,
|
| 70 |
+
stream_tokens=True,
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
complete_response = ""
|
| 75 |
+
async for token in stream:
|
| 76 |
+
if token.message_type == "reasoning_message":
|
| 77 |
+
# This part is reasoning information and should not be displayed
|
| 78 |
+
token = token.reasoning
|
| 79 |
+
continue
|
| 80 |
+
elif token.message_type == "assistant_message":
|
| 81 |
+
# This part is the result that needs to be displayed, it is the final result
|
| 82 |
+
# logger.info('Test message')
|
| 83 |
+
# logger.info(token)
|
| 84 |
+
token = token.content
|
| 85 |
+
else:
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
yield token
|
| 89 |
+
complete_response += token
|
| 90 |
+
|
| 91 |
+
def _to_text_prompt(self, input_data: BatchInput) -> str:
|
| 92 |
+
"""
|
| 93 |
+
Format BatchInput into a prompt string for the LLM.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
input_data: BatchInput - The input data containing texts
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
str - Formatted message string
|
| 100 |
+
"""
|
| 101 |
+
message_parts = []
|
| 102 |
+
|
| 103 |
+
# Process text inputs in order
|
| 104 |
+
for text_data in input_data.texts:
|
| 105 |
+
if text_data.source == TextSource.INPUT:
|
| 106 |
+
message_parts.append(text_data.content)
|
| 107 |
+
elif text_data.source == TextSource.CLIPBOARD:
|
| 108 |
+
message_parts.append(f"[Clipboard content: {text_data.content}]")
|
| 109 |
+
|
| 110 |
+
return "\n".join(message_parts)
|
| 111 |
+
|
| 112 |
+
def _to_messages(self, input_data: BatchInput) -> List[Dict[str, Any]]:
|
| 113 |
+
"""
|
| 114 |
+
Prepare messages list without image support.
|
| 115 |
+
"""
|
| 116 |
+
messages = []
|
| 117 |
+
|
| 118 |
+
if input_data.images:
|
| 119 |
+
content = []
|
| 120 |
+
text_content = self._to_text_prompt(input_data)
|
| 121 |
+
content.append({"type": "text", "text": text_content})
|
| 122 |
+
user_message = {"role": "user", "content": content}
|
| 123 |
+
else:
|
| 124 |
+
user_message = {"role": "user", "content": self._to_text_prompt(input_data)}
|
| 125 |
+
|
| 126 |
+
messages.append(user_message)
|
| 127 |
+
|
| 128 |
+
return messages
|
src/open_llm_vtuber/agent/agents/mem0_llm.py
ADDED
|
File without changes
|
src/open_llm_vtuber/agent/input_types.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from enum import Enum
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ImageSource(Enum):
|
| 7 |
+
"""Enum for different image sources"""
|
| 8 |
+
|
| 9 |
+
CAMERA = "camera"
|
| 10 |
+
SCREEN = "screen"
|
| 11 |
+
CLIPBOARD = "clipboard"
|
| 12 |
+
UPLOAD = "upload"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TextSource(Enum):
|
| 16 |
+
"""Enum for different text sources"""
|
| 17 |
+
|
| 18 |
+
INPUT = "input" # Main user input/transcription
|
| 19 |
+
CLIPBOARD = "clipboard" # Text from clipboard
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ImageData:
|
| 24 |
+
"""
|
| 25 |
+
Represents an image from various sources
|
| 26 |
+
|
| 27 |
+
Attributes:
|
| 28 |
+
source: Source of the image
|
| 29 |
+
data: Base64 encoded image data or URL
|
| 30 |
+
mime_type: MIME type of the image (e.g., 'image/jpeg', 'image/png')
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
source: ImageSource
|
| 34 |
+
data: str # Base64 encoded or URL
|
| 35 |
+
mime_type: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class FileData:
|
| 40 |
+
"""
|
| 41 |
+
Represents a file uploaded by the user
|
| 42 |
+
|
| 43 |
+
Attributes:
|
| 44 |
+
name: Original filename
|
| 45 |
+
data: Base64 encoded file data
|
| 46 |
+
mime_type: MIME type of the file
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
name: str
|
| 50 |
+
data: str # Base64 encoded
|
| 51 |
+
mime_type: str
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class TextData:
|
| 56 |
+
"""
|
| 57 |
+
Represents text data from various sources
|
| 58 |
+
|
| 59 |
+
Attributes:
|
| 60 |
+
source: Source of the text
|
| 61 |
+
content: str - The text content
|
| 62 |
+
from_name: Optional[str] - Name of the sender/character
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
source: TextSource
|
| 66 |
+
content: str
|
| 67 |
+
from_name: Optional[str] = None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class BaseInput:
|
| 71 |
+
"""Base class for all input types"""
|
| 72 |
+
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class BatchInput(BaseInput):
|
| 78 |
+
"""
|
| 79 |
+
Input type for batch processing, containing complete transcription and optional media
|
| 80 |
+
|
| 81 |
+
Attributes:
|
| 82 |
+
texts: List of text data from different sources
|
| 83 |
+
images: Optional list of images
|
| 84 |
+
files: Optional list of files
|
| 85 |
+
metadata: Optional dictionary of metadata flags for special inputs
|
| 86 |
+
- 'proactive_speak': Boolean flag indicating if this is a proactive speak input
|
| 87 |
+
- 'skip_memory': Boolean flag indicating if this input should be skipped in AI's internal memory
|
| 88 |
+
- 'skip_history': Boolean flag indicating if this input should be skipped in local history storage
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
texts: List[TextData]
|
| 92 |
+
images: Optional[List[ImageData]] = None
|
| 93 |
+
files: Optional[List[FileData]] = None
|
| 94 |
+
metadata: Optional[Dict[str, Any]] = None
|
src/open_llm_vtuber/agent/output_types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, asdict
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class Actions:
|
| 8 |
+
"""Represents actions that can be performed alongside text output"""
|
| 9 |
+
|
| 10 |
+
expressions: Optional[List[str] | List[int]] = None
|
| 11 |
+
pictures: Optional[List[str]] = None
|
| 12 |
+
sounds: Optional[List[str]] = None
|
| 13 |
+
|
| 14 |
+
def to_dict(self) -> dict:
|
| 15 |
+
"""Convert Actions object to a dictionary for JSON serialization"""
|
| 16 |
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class BaseOutput(ABC):
|
| 20 |
+
"""Base class for agent outputs that can be iterated"""
|
| 21 |
+
|
| 22 |
+
@abstractmethod
|
| 23 |
+
def __aiter__(self):
|
| 24 |
+
"""Make the output iterable"""
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class DisplayText:
|
| 30 |
+
"""Text to be displayed with optional metadata"""
|
| 31 |
+
|
| 32 |
+
text: str
|
| 33 |
+
name: Optional[str] = "AI" # Keep the name field for frontend display
|
| 34 |
+
avatar: Optional[str] = None
|
| 35 |
+
|
| 36 |
+
def to_dict(self) -> dict:
|
| 37 |
+
"""Convert to dictionary for JSON serialization"""
|
| 38 |
+
return {"text": self.text, "name": self.name, "avatar": self.avatar}
|
| 39 |
+
|
| 40 |
+
def __str__(self) -> str:
|
| 41 |
+
"""String representation for logging"""
|
| 42 |
+
return f"{self.name}: {self.text}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class SentenceOutput(BaseOutput):
|
| 47 |
+
"""
|
| 48 |
+
Output type for text-based responses.
|
| 49 |
+
Contains a single sentence pair (display and TTS) with associated actions.
|
| 50 |
+
|
| 51 |
+
Attributes:
|
| 52 |
+
display_text: Text to be displayed in UI
|
| 53 |
+
tts_text: Text to be sent to TTS engine
|
| 54 |
+
actions: Associated actions (expressions, pictures, sounds)
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
display_text: DisplayText # Changed from str to DisplayText
|
| 58 |
+
tts_text: str # Text for TTS
|
| 59 |
+
actions: Actions
|
| 60 |
+
|
| 61 |
+
async def __aiter__(self):
|
| 62 |
+
"""Yield the sentence pair and actions"""
|
| 63 |
+
yield self.display_text, self.tts_text, self.actions
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class AudioOutput(BaseOutput):
|
| 68 |
+
"""Output type for audio-based responses"""
|
| 69 |
+
|
| 70 |
+
audio_path: str
|
| 71 |
+
display_text: DisplayText # Changed from str to DisplayText
|
| 72 |
+
transcript: str # Original transcript
|
| 73 |
+
actions: Actions
|
| 74 |
+
|
| 75 |
+
async def __aiter__(self):
|
| 76 |
+
"""Iterate through audio segments and their actions"""
|
| 77 |
+
yield self.audio_path, self.display_text, self.transcript, self.actions
|
src/open_llm_vtuber/agent/stateless_llm/__init__.py
ADDED
|
File without changes
|
src/open_llm_vtuber/agent/stateless_llm/claude_llm.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Description: This file contains the implementation of the `AsyncLLM` class for Claude API.
|
| 2 |
+
This class is responsible for handling asynchronous interaction with Claude API endpoints
|
| 3 |
+
for language generation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 8 |
+
|
| 9 |
+
from loguru import logger
|
| 10 |
+
from anthropic import AsyncAnthropic, NOT_GIVEN
|
| 11 |
+
|
| 12 |
+
from .stateless_llm_interface import StatelessLLMInterface
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AsyncLLM(StatelessLLMInterface):
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
model: str = "claude-3-haiku-latest",
|
| 19 |
+
base_url: str = None,
|
| 20 |
+
llm_api_key: str = None,
|
| 21 |
+
system: str = None,
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Initialize Claude LLM.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
model (str): Model name
|
| 28 |
+
base_url (str): Base URL for Claude API
|
| 29 |
+
llm_api_key (str): Claude API key
|
| 30 |
+
system (str): System prompt
|
| 31 |
+
"""
|
| 32 |
+
self.model = model
|
| 33 |
+
self.system = system
|
| 34 |
+
|
| 35 |
+
# Initialize Claude client
|
| 36 |
+
self.client = AsyncAnthropic(
|
| 37 |
+
api_key=llm_api_key, base_url=base_url if base_url else None
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
logger.info(f"Initialized Claude AsyncLLM with model: {self.model}")
|
| 41 |
+
logger.debug(f"Base URL: {base_url}")
|
| 42 |
+
|
| 43 |
+
def _convert_message_format(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
| 44 |
+
"""Convert message format to Claude's expected format."""
|
| 45 |
+
# Handle potential tool_result content blocks
|
| 46 |
+
if isinstance(message.get("content"), list):
|
| 47 |
+
new_content = []
|
| 48 |
+
is_tool_result = False
|
| 49 |
+
for content_item in message["content"]:
|
| 50 |
+
if content_item.get("type") == "image_url":
|
| 51 |
+
# Extract media type and base64 data from data URL
|
| 52 |
+
data_url = content_item["image_url"]["url"]
|
| 53 |
+
# Split 'data:image/jpeg;base64,/9j/4AAQ...' into parts
|
| 54 |
+
header, base64_data = data_url.split(",", 1)
|
| 55 |
+
# Extract media type from 'data:image/jpeg;base64'
|
| 56 |
+
media_type = header.split(":")[1].split(";")[0]
|
| 57 |
+
|
| 58 |
+
new_content.append(
|
| 59 |
+
{
|
| 60 |
+
"type": "image",
|
| 61 |
+
"source": {
|
| 62 |
+
"type": "base64",
|
| 63 |
+
"media_type": media_type,
|
| 64 |
+
"data": base64_data,
|
| 65 |
+
},
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
elif content_item.get("type") == "tool_result":
|
| 69 |
+
is_tool_result = True
|
| 70 |
+
# Keep tool_result block as is, Anthropic SDK handles it
|
| 71 |
+
new_content.append(content_item)
|
| 72 |
+
else:
|
| 73 |
+
# Assume text or other standard types
|
| 74 |
+
new_content.append(content_item)
|
| 75 |
+
|
| 76 |
+
# For tool_result messages, the role should be 'user'
|
| 77 |
+
# Ensure the role is correctly set before returning
|
| 78 |
+
role = "user" if is_tool_result else message["role"]
|
| 79 |
+
return {"role": role, "content": new_content}
|
| 80 |
+
|
| 81 |
+
# Handle plain text content or non-list content
|
| 82 |
+
return message
|
| 83 |
+
|
| 84 |
+
async def chat_completion(
|
| 85 |
+
self,
|
| 86 |
+
messages: List[Dict[str, Any]],
|
| 87 |
+
system: str = None,
|
| 88 |
+
tools: List[Dict[str, Any]] = None,
|
| 89 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 90 |
+
"""
|
| 91 |
+
Generates a chat completion using the Claude API asynchronously,
|
| 92 |
+
handling text generation and tool use.
|
| 93 |
+
|
| 94 |
+
Parameters:
|
| 95 |
+
- messages (List[Dict[str, Any]]): The list of messages to send to the API.
|
| 96 |
+
- system (str, optional): System prompt to use for this completion.
|
| 97 |
+
- tools (List[Dict[str, Any]], optional): List of tools available.
|
| 98 |
+
|
| 99 |
+
Yields:
|
| 100 |
+
- Dict[str, Any]: Events representing text deltas, tool use, or errors.
|
| 101 |
+
Possible event types:
|
| 102 |
+
- {"type": "message_start", "data": ...}
|
| 103 |
+
- {"type": "text_delta", "text": "..."}
|
| 104 |
+
- {"type": "tool_use_start", "data": {"id": ..., "name": ..., "input": None}}
|
| 105 |
+
- {"type": "tool_input_delta", "tool_id": ..., "partial_json": "..."} # Optional
|
| 106 |
+
- {"type": "tool_use_complete", "data": {"id": ..., "name": ..., "input": {...}}}
|
| 107 |
+
- {"type": "message_delta", "data": ...} # e.g., stop_reason
|
| 108 |
+
- {"type": "message_stop"}
|
| 109 |
+
- {"type": "error", "message": "..."}
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
# Filter out system messages and convert message format
|
| 113 |
+
converted_messages = [
|
| 114 |
+
self._convert_message_format(msg)
|
| 115 |
+
for msg in messages
|
| 116 |
+
if msg["role"] != "system"
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
logger.debug(f"Sending messages to Claude API: {converted_messages}")
|
| 120 |
+
logger.debug(f"Tools provided: {tools}")
|
| 121 |
+
|
| 122 |
+
async with self.client.messages.stream(
|
| 123 |
+
messages=converted_messages,
|
| 124 |
+
system=system if system else (self.system if self.system else ""),
|
| 125 |
+
model=self.model,
|
| 126 |
+
max_tokens=1024,
|
| 127 |
+
tools=tools if tools else NOT_GIVEN,
|
| 128 |
+
) as stream:
|
| 129 |
+
current_tool_call_info = None
|
| 130 |
+
partial_json_accumulator = ""
|
| 131 |
+
|
| 132 |
+
async for event in stream:
|
| 133 |
+
if event.type == "message_start":
|
| 134 |
+
logger.debug("Stream: message_start")
|
| 135 |
+
yield {
|
| 136 |
+
"type": "message_start",
|
| 137 |
+
"data": event.message.model_dump(exclude_none=True),
|
| 138 |
+
}
|
| 139 |
+
elif event.type == "content_block_start":
|
| 140 |
+
logger.debug(
|
| 141 |
+
f"Stream: content_block_start - Index: {event.index}, Type: {event.content_block.type}"
|
| 142 |
+
)
|
| 143 |
+
if event.content_block.type == "text":
|
| 144 |
+
pass # Handled by text_delta
|
| 145 |
+
elif event.content_block.type == "tool_use":
|
| 146 |
+
current_tool_call_info = {
|
| 147 |
+
"id": event.content_block.id,
|
| 148 |
+
"name": event.content_block.name,
|
| 149 |
+
"input": None,
|
| 150 |
+
"index": event.index, # Store index
|
| 151 |
+
}
|
| 152 |
+
partial_json_accumulator = ""
|
| 153 |
+
logger.debug(
|
| 154 |
+
f"Stream: tool_use started - ID: {current_tool_call_info['id']}, Name: {current_tool_call_info['name']}"
|
| 155 |
+
)
|
| 156 |
+
yield {
|
| 157 |
+
"type": "tool_use_start",
|
| 158 |
+
"data": current_tool_call_info.copy(),
|
| 159 |
+
}
|
| 160 |
+
elif event.type == "content_block_delta":
|
| 161 |
+
logger.debug(
|
| 162 |
+
f"Stream: content_block_delta - Index: {event.index}, Delta Type: {event.delta.type}"
|
| 163 |
+
)
|
| 164 |
+
if event.delta.type == "text_delta":
|
| 165 |
+
yield {"type": "text_delta", "text": event.delta.text}
|
| 166 |
+
elif event.delta.type == "input_json_delta":
|
| 167 |
+
if (
|
| 168 |
+
current_tool_call_info
|
| 169 |
+
and event.index == current_tool_call_info["index"]
|
| 170 |
+
):
|
| 171 |
+
partial_json_accumulator += event.delta.partial_json
|
| 172 |
+
logger.trace(
|
| 173 |
+
f"Stream: input_json_delta - Tool ID: {current_tool_call_info['id']}, Partial: {event.delta.partial_json}"
|
| 174 |
+
)
|
| 175 |
+
else:
|
| 176 |
+
logger.warning(
|
| 177 |
+
f"Received input_json_delta but no active tool call matching index {event.index}"
|
| 178 |
+
)
|
| 179 |
+
elif event.type == "content_block_stop":
|
| 180 |
+
logger.debug(
|
| 181 |
+
f"Stream: content_block_stop - Index: {event.index}"
|
| 182 |
+
)
|
| 183 |
+
# Check if this stop corresponds to the active tool call
|
| 184 |
+
if (
|
| 185 |
+
current_tool_call_info
|
| 186 |
+
and event.index == current_tool_call_info["index"]
|
| 187 |
+
):
|
| 188 |
+
try:
|
| 189 |
+
if not partial_json_accumulator.strip():
|
| 190 |
+
logger.warning(
|
| 191 |
+
f"Empty JSON input received for tool ID: {current_tool_call_info['id']}. Using empty object."
|
| 192 |
+
)
|
| 193 |
+
tool_input = {}
|
| 194 |
+
else:
|
| 195 |
+
tool_input = json.loads(partial_json_accumulator)
|
| 196 |
+
current_tool_call_info["input"] = tool_input
|
| 197 |
+
logger.debug(
|
| 198 |
+
f"Stream: tool_use completed - ID: {current_tool_call_info['id']}, Input: {tool_input}"
|
| 199 |
+
)
|
| 200 |
+
# Yield the complete tool call info
|
| 201 |
+
yield {
|
| 202 |
+
"type": "tool_use_complete",
|
| 203 |
+
"data": current_tool_call_info.copy(),
|
| 204 |
+
}
|
| 205 |
+
except json.JSONDecodeError as e:
|
| 206 |
+
logger.error(
|
| 207 |
+
f"Failed to decode tool input JSON: {partial_json_accumulator}. Error: {e}"
|
| 208 |
+
)
|
| 209 |
+
yield {
|
| 210 |
+
"type": "error",
|
| 211 |
+
"message": f"Failed to parse tool input JSON for tool ID {current_tool_call_info['id']}",
|
| 212 |
+
}
|
| 213 |
+
finally:
|
| 214 |
+
# Reset regardless of success or failure for this index
|
| 215 |
+
current_tool_call_info = None
|
| 216 |
+
partial_json_accumulator = ""
|
| 217 |
+
elif event.type == "message_delta":
|
| 218 |
+
logger.debug(
|
| 219 |
+
f"Stream: message_delta - Delta: {event.delta.model_dump(exclude_none=True)}, Usage: {event.usage}"
|
| 220 |
+
)
|
| 221 |
+
yield {
|
| 222 |
+
"type": "message_delta",
|
| 223 |
+
"data": {
|
| 224 |
+
"delta": event.delta.model_dump(exclude_none=True),
|
| 225 |
+
"usage": event.usage.model_dump(),
|
| 226 |
+
},
|
| 227 |
+
}
|
| 228 |
+
elif event.type == "message_stop":
|
| 229 |
+
logger.debug("Stream: message_stop")
|
| 230 |
+
yield {"type": "message_stop"}
|
| 231 |
+
# No need to break here, the context manager handles the end
|
| 232 |
+
elif event.type == "ping":
|
| 233 |
+
logger.trace("Stream: ping")
|
| 234 |
+
pass # Ignore pings
|
| 235 |
+
# Anthropic SDK might raise errors directly, or via event.type == 'error'
|
| 236 |
+
# The outer try/except handles SDK-level errors.
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error(f"Claude API error occurred: {str(e)}")
|
| 240 |
+
logger.info(f"Model: {self.model}")
|
| 241 |
+
# Yield an error event before raising
|
| 242 |
+
yield {"type": "error", "message": f"Claude API error: {str(e)}"}
|
| 243 |
+
raise
|
| 244 |
+
|
| 245 |
+
# No finally block needed for stream.close() due to async with
|
| 246 |
+
logger.debug("Chat completion stream processing finished.")
|
src/open_llm_vtuber/agent/stateless_llm/llama_cpp_llm.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Description: This file contains the implementation of the LLM class using llama.cpp.
|
| 2 |
+
This class provides a stateless interface to llama.cpp for language generation.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 7 |
+
from llama_cpp import Llama
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
from .stateless_llm_interface import StatelessLLMInterface
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LLM(StatelessLLMInterface):
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
model_path: str,
|
| 17 |
+
**kwargs,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Initializes a stateless instance of the LLM class using llama.cpp.
|
| 21 |
+
|
| 22 |
+
Parameters:
|
| 23 |
+
- model_path (str): Path to the GGUF model file
|
| 24 |
+
- **kwargs: Additional arguments passed to Llama constructor
|
| 25 |
+
"""
|
| 26 |
+
logger.info(f"Initializing llama cpp with model path: {model_path}")
|
| 27 |
+
self.model_path = model_path
|
| 28 |
+
try:
|
| 29 |
+
self.llm = Llama(model_path=model_path, **kwargs)
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.critical(f"Failed to initialize Llama model: {e}")
|
| 32 |
+
raise
|
| 33 |
+
|
| 34 |
+
async def chat_completion(
|
| 35 |
+
self, messages: List[Dict[str, Any]], system: str = None
|
| 36 |
+
) -> AsyncIterator[str]:
|
| 37 |
+
"""
|
| 38 |
+
Generates a chat completion using llama.cpp asynchronously.
|
| 39 |
+
|
| 40 |
+
Parameters:
|
| 41 |
+
- messages (List[Dict[str, Any]]): The list of messages to send to the model.
|
| 42 |
+
- system (str, optional): System prompt to use for this completion.
|
| 43 |
+
|
| 44 |
+
Yields:
|
| 45 |
+
- str: The content of each chunk from the model response.
|
| 46 |
+
"""
|
| 47 |
+
logger.debug(f"Generating completion for messages: {messages}")
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
# Add system prompt if provided
|
| 51 |
+
messages_with_system = messages
|
| 52 |
+
if system:
|
| 53 |
+
messages_with_system = [
|
| 54 |
+
{"role": "system", "content": system},
|
| 55 |
+
*messages,
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# Create chat completion in a separate thread to avoid blocking
|
| 59 |
+
chat_completion = await asyncio.get_event_loop().run_in_executor(
|
| 60 |
+
None,
|
| 61 |
+
lambda: self.llm.create_chat_completion(
|
| 62 |
+
messages=messages_with_system,
|
| 63 |
+
stream=True,
|
| 64 |
+
),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Process chunks
|
| 68 |
+
for chunk in chat_completion:
|
| 69 |
+
if chunk.get("choices") and chunk["choices"][0].get("delta"):
|
| 70 |
+
content = chunk["choices"][0]["delta"].get("content", "")
|
| 71 |
+
if content:
|
| 72 |
+
yield content
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Error in chat completion: {e}")
|
| 76 |
+
raise
|
src/open_llm_vtuber/agent/stateless_llm/ollama_llm.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import atexit
|
| 2 |
+
import requests
|
| 3 |
+
from loguru import logger
|
| 4 |
+
from .openai_compatible_llm import AsyncLLM
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class OllamaLLM(AsyncLLM):
|
| 8 |
+
def __init__(
|
| 9 |
+
self,
|
| 10 |
+
model: str,
|
| 11 |
+
base_url: str,
|
| 12 |
+
llm_api_key: str = "z",
|
| 13 |
+
organization_id: str = "z",
|
| 14 |
+
project_id: str = "z",
|
| 15 |
+
temperature: float = 1.0,
|
| 16 |
+
keep_alive: float = -1,
|
| 17 |
+
unload_at_exit: bool = True,
|
| 18 |
+
):
|
| 19 |
+
self.keep_alive = keep_alive
|
| 20 |
+
self.unload_at_exit = unload_at_exit
|
| 21 |
+
self.cleaned = False
|
| 22 |
+
super().__init__(
|
| 23 |
+
model=model,
|
| 24 |
+
base_url=base_url,
|
| 25 |
+
llm_api_key=llm_api_key,
|
| 26 |
+
organization_id=organization_id,
|
| 27 |
+
project_id=project_id,
|
| 28 |
+
temperature=temperature,
|
| 29 |
+
)
|
| 30 |
+
try:
|
| 31 |
+
# preload model
|
| 32 |
+
logger.info("Preloading model for Ollama")
|
| 33 |
+
# Send the POST request to preload model
|
| 34 |
+
logger.debug(
|
| 35 |
+
requests.post(
|
| 36 |
+
base_url.replace("/v1", "") + "/api/chat",
|
| 37 |
+
json={
|
| 38 |
+
"model": model,
|
| 39 |
+
"keep_alive": keep_alive,
|
| 40 |
+
},
|
| 41 |
+
)
|
| 42 |
+
)
|
| 43 |
+
except requests.exceptions.ConnectionError as e:
|
| 44 |
+
logger.error(f"Failed to preload model: {e}")
|
| 45 |
+
logger.critical(
|
| 46 |
+
"Fail to connect to Ollama backend. Is Ollama server running? Try running `ollama list` to start the server and try again.\nThe AI will repeat 'Error connecting chat endpoint' until the server is running."
|
| 47 |
+
)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Failed to preload model: {e}")
|
| 50 |
+
# If keep_alive is less than 0, register cleanup to unload the model
|
| 51 |
+
if unload_at_exit:
|
| 52 |
+
atexit.register(self.cleanup)
|
| 53 |
+
|
| 54 |
+
def __del__(self):
|
| 55 |
+
"""Destructor to unload the model"""
|
| 56 |
+
self.cleanup()
|
| 57 |
+
|
| 58 |
+
def cleanup(self):
|
| 59 |
+
"""Clean up function to unload the model when exitting"""
|
| 60 |
+
if not self.cleaned and self.unload_at_exit:
|
| 61 |
+
logger.info(f"Ollama: Unloading model: {self.model}")
|
| 62 |
+
# Unload the model
|
| 63 |
+
# unloading is just the same as preload, but with keep alive set to 0
|
| 64 |
+
logger.debug(
|
| 65 |
+
requests.post(
|
| 66 |
+
self.base_url.replace("/v1", "") + "/api/chat",
|
| 67 |
+
json={
|
| 68 |
+
"model": self.model,
|
| 69 |
+
"keep_alive": 0,
|
| 70 |
+
},
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
self.cleaned = True
|
src/open_llm_vtuber/agent/stateless_llm/openai_compatible_llm.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Description: This file contains the implementation of the `AsyncLLM` class.
|
| 2 |
+
This class is responsible for handling asynchronous interaction with OpenAI API compatible
|
| 3 |
+
endpoints for language generation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 7 |
+
from openai import (
|
| 8 |
+
AsyncStream,
|
| 9 |
+
AsyncOpenAI,
|
| 10 |
+
APIError,
|
| 11 |
+
APIConnectionError,
|
| 12 |
+
RateLimitError,
|
| 13 |
+
NotGiven,
|
| 14 |
+
NOT_GIVEN,
|
| 15 |
+
)
|
| 16 |
+
from openai.types.chat import ChatCompletionChunk
|
| 17 |
+
from openai.types.chat.chat_completion_chunk import ChoiceDeltaToolCall
|
| 18 |
+
from loguru import logger
|
| 19 |
+
|
| 20 |
+
from .stateless_llm_interface import StatelessLLMInterface
|
| 21 |
+
from ...mcpp.types import ToolCallObject
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AsyncLLM(StatelessLLMInterface):
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
model: str,
|
| 28 |
+
base_url: str,
|
| 29 |
+
llm_api_key: str = "z",
|
| 30 |
+
organization_id: str = "z",
|
| 31 |
+
project_id: str = "z",
|
| 32 |
+
temperature: float = 1.0,
|
| 33 |
+
):
|
| 34 |
+
"""
|
| 35 |
+
Initializes an instance of the `AsyncLLM` class.
|
| 36 |
+
|
| 37 |
+
Parameters:
|
| 38 |
+
- model (str): The model to be used for language generation.
|
| 39 |
+
- base_url (str): The base URL for the OpenAI API.
|
| 40 |
+
- organization_id (str, optional): The organization ID for the OpenAI API. Defaults to "z".
|
| 41 |
+
- project_id (str, optional): The project ID for the OpenAI API. Defaults to "z".
|
| 42 |
+
- llm_api_key (str, optional): The API key for the OpenAI API. Defaults to "z".
|
| 43 |
+
- temperature (float, optional): What sampling temperature to use, between 0 and 2. Defaults to 1.0.
|
| 44 |
+
"""
|
| 45 |
+
self.base_url = base_url
|
| 46 |
+
self.model = model
|
| 47 |
+
self.temperature = temperature
|
| 48 |
+
self.client = AsyncOpenAI(
|
| 49 |
+
base_url=base_url,
|
| 50 |
+
organization=organization_id,
|
| 51 |
+
project=project_id,
|
| 52 |
+
api_key=llm_api_key,
|
| 53 |
+
)
|
| 54 |
+
self.support_tools = True
|
| 55 |
+
|
| 56 |
+
logger.info(
|
| 57 |
+
f"Initialized AsyncLLM with the parameters: {self.base_url}, {self.model}"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
async def chat_iter(self, messages, system_prompt, tools=None):
|
| 61 |
+
"""Đảm bảo hàm này tồn tại để Agent có thể gọi được"""
|
| 62 |
+
async for chunk in self.chat_completion(messages, system=system_prompt, tools=tools):
|
| 63 |
+
yield chunk
|
| 64 |
+
|
| 65 |
+
async def chat_completion(
|
| 66 |
+
self,
|
| 67 |
+
messages: List[Dict[str, Any]],
|
| 68 |
+
system: str = None,
|
| 69 |
+
tools: List[Dict[str, Any]] | NotGiven = NOT_GIVEN,
|
| 70 |
+
) -> AsyncIterator[str | List[ChoiceDeltaToolCall]]:
|
| 71 |
+
"""
|
| 72 |
+
Generates a chat completion using the OpenAI API asynchronously.
|
| 73 |
+
|
| 74 |
+
Parameters:
|
| 75 |
+
- messages (List[Dict[str, Any]]): The list of messages to send to the API.
|
| 76 |
+
- system (str, optional): System prompt to use for this completion.
|
| 77 |
+
- tools (List[Dict[str, str]], optional): List of tools to use for this completion.
|
| 78 |
+
|
| 79 |
+
Yields:
|
| 80 |
+
- str: The content of each chunk from the API response.
|
| 81 |
+
- List[ChoiceDeltaToolCall]: The tool calls detected in the response.
|
| 82 |
+
|
| 83 |
+
Raises:
|
| 84 |
+
- APIConnectionError: When the server cannot be reached
|
| 85 |
+
- RateLimitError: When a 429 status code is received
|
| 86 |
+
- APIError: For other API-related errors
|
| 87 |
+
"""
|
| 88 |
+
stream = None
|
| 89 |
+
# Tool call related state variables
|
| 90 |
+
accumulated_tool_calls = {}
|
| 91 |
+
in_tool_call = False
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
# If system prompt is provided, add it to the messages
|
| 95 |
+
messages_with_system = messages
|
| 96 |
+
if system:
|
| 97 |
+
messages_with_system = [
|
| 98 |
+
{"role": "system", "content": system},
|
| 99 |
+
*messages,
|
| 100 |
+
]
|
| 101 |
+
logger.debug(f"Messages: {messages_with_system}")
|
| 102 |
+
|
| 103 |
+
available_tools = tools if self.support_tools else NOT_GIVEN
|
| 104 |
+
|
| 105 |
+
stream: AsyncStream[
|
| 106 |
+
ChatCompletionChunk
|
| 107 |
+
] = await self.client.chat.completions.create(
|
| 108 |
+
messages=messages_with_system,
|
| 109 |
+
model=self.model,
|
| 110 |
+
stream=True,
|
| 111 |
+
temperature=self.temperature,
|
| 112 |
+
tools=available_tools,
|
| 113 |
+
)
|
| 114 |
+
logger.debug(
|
| 115 |
+
f"Tool Support: {self.support_tools}, Available tools: {available_tools}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
async for chunk in stream:
|
| 119 |
+
# Guard against chunks with missing choices field (e.g., from OpenWebUI)
|
| 120 |
+
if not chunk.choices:
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
if self.support_tools:
|
| 124 |
+
has_tool_calls = (
|
| 125 |
+
hasattr(chunk.choices[0].delta, "tool_calls")
|
| 126 |
+
and chunk.choices[0].delta.tool_calls
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if has_tool_calls:
|
| 130 |
+
logger.debug(
|
| 131 |
+
f"Tool calls detected in chunk: {chunk.choices[0].delta.tool_calls}"
|
| 132 |
+
)
|
| 133 |
+
in_tool_call = True
|
| 134 |
+
# Process tool calls in the current chunk
|
| 135 |
+
for tool_call in chunk.choices[0].delta.tool_calls:
|
| 136 |
+
index = (
|
| 137 |
+
tool_call.index if hasattr(tool_call, "index") else 0
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Initialize tool call for this index if needed
|
| 141 |
+
if index not in accumulated_tool_calls:
|
| 142 |
+
accumulated_tool_calls[index] = {
|
| 143 |
+
"index": index,
|
| 144 |
+
"id": getattr(tool_call, "id", None),
|
| 145 |
+
"type": getattr(tool_call, "type", None),
|
| 146 |
+
"function": {"name": "", "arguments": ""},
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Update tool call information
|
| 150 |
+
if hasattr(tool_call, "id") and tool_call.id:
|
| 151 |
+
accumulated_tool_calls[index]["id"] = tool_call.id
|
| 152 |
+
if hasattr(tool_call, "type") and tool_call.type:
|
| 153 |
+
accumulated_tool_calls[index]["type"] = tool_call.type
|
| 154 |
+
|
| 155 |
+
# Update function information
|
| 156 |
+
if hasattr(tool_call, "function"):
|
| 157 |
+
if (
|
| 158 |
+
hasattr(tool_call.function, "name")
|
| 159 |
+
and tool_call.function.name
|
| 160 |
+
):
|
| 161 |
+
accumulated_tool_calls[index]["function"][
|
| 162 |
+
"name"
|
| 163 |
+
] = tool_call.function.name
|
| 164 |
+
if (
|
| 165 |
+
hasattr(tool_call.function, "arguments")
|
| 166 |
+
and tool_call.function.arguments
|
| 167 |
+
):
|
| 168 |
+
accumulated_tool_calls[index]["function"][
|
| 169 |
+
"arguments"
|
| 170 |
+
] += tool_call.function.arguments
|
| 171 |
+
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
# If we were in a tool call but now we're not, yield the tool call result
|
| 175 |
+
elif in_tool_call and not has_tool_calls:
|
| 176 |
+
in_tool_call = False
|
| 177 |
+
# Convert accumulated tool calls to the required format and output
|
| 178 |
+
logger.info(f"Complete tool calls: {accumulated_tool_calls}")
|
| 179 |
+
|
| 180 |
+
# Use the from_dict method to create a ToolCallObject instance from a dictionary
|
| 181 |
+
complete_tool_calls = [
|
| 182 |
+
ToolCallObject.from_dict(tool_data)
|
| 183 |
+
for tool_data in accumulated_tool_calls.values()
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
yield complete_tool_calls
|
| 187 |
+
accumulated_tool_calls = {} # Reset for potential future tool calls
|
| 188 |
+
|
| 189 |
+
# Process regular content chunks
|
| 190 |
+
if len(chunk.choices) == 0:
|
| 191 |
+
logger.info("Empty chunk received")
|
| 192 |
+
continue
|
| 193 |
+
elif chunk.choices[0].delta.content is None:
|
| 194 |
+
chunk.choices[0].delta.content = ""
|
| 195 |
+
yield chunk.choices[0].delta.content
|
| 196 |
+
|
| 197 |
+
# If stream ends while still in a tool call, make sure to yield the tool call
|
| 198 |
+
if in_tool_call and accumulated_tool_calls:
|
| 199 |
+
logger.info(f"Final tool call at stream end: {accumulated_tool_calls}")
|
| 200 |
+
|
| 201 |
+
# Create a ToolCallObject instance from a dictionary using the from_dict method.
|
| 202 |
+
complete_tool_calls = [
|
| 203 |
+
ToolCallObject.from_dict(tool_data)
|
| 204 |
+
for tool_data in accumulated_tool_calls.values()
|
| 205 |
+
]
|
| 206 |
+
|
| 207 |
+
yield complete_tool_calls
|
| 208 |
+
|
| 209 |
+
except APIConnectionError as e:
|
| 210 |
+
logger.error(
|
| 211 |
+
f"Error calling the chat endpoint: Connection error. Failed to connect to the LLM API. \nCheck the configurations and the reachability of the LLM backend. \nSee the logs for details. \nTroubleshooting with documentation: https://open-llm-vtuber.github.io/docs/faq#%E9%81%87%E5%88%B0-error-calling-the-chat-endpoint-%E9%94%99%E8%AF%AF%E6%80%8E%E4%B9%88%E5%8A%9E \n{e.__cause__}"
|
| 212 |
+
)
|
| 213 |
+
yield "Error calling the chat endpoint: Connection error. Failed to connect to the LLM API. Check the configurations and the reachability of the LLM backend. See the logs for details. Troubleshooting with documentation: [https://open-llm-vtuber.github.io/docs/faq#%E9%81%87%E5%88%B0-error-calling-the-chat-endpoint-%E9%94%99%E8%AF%AF%E6%80%8E%E4%B9%88%E5%8A%9E]"
|
| 214 |
+
|
| 215 |
+
except RateLimitError as e:
|
| 216 |
+
logger.error(
|
| 217 |
+
f"Error calling the chat endpoint: Rate limit exceeded: {e.response}"
|
| 218 |
+
)
|
| 219 |
+
yield "Error calling the chat endpoint: Rate limit exceeded. Please try again later. See the logs for details."
|
| 220 |
+
|
| 221 |
+
except APIError as e:
|
| 222 |
+
if "does not support tools" in str(e):
|
| 223 |
+
self.support_tools = False
|
| 224 |
+
logger.warning(
|
| 225 |
+
f"{self.model} does not support tools. Disabling tool support."
|
| 226 |
+
)
|
| 227 |
+
yield "__API_NOT_SUPPORT_TOOLS__"
|
| 228 |
+
return
|
| 229 |
+
logger.error(f"LLM API: Error occurred: {e}")
|
| 230 |
+
logger.info(f"Base URL: {self.base_url}")
|
| 231 |
+
logger.info(f"Model: {self.model}")
|
| 232 |
+
logger.info(f"Messages: {messages}")
|
| 233 |
+
logger.info(f"temperature: {self.temperature}")
|
| 234 |
+
yield "Error calling the chat endpoint: Error occurred while generating response. See the logs for details."
|
| 235 |
+
|
| 236 |
+
finally:
|
| 237 |
+
# make sure the stream is properly closed
|
| 238 |
+
# so when interrupted, no more tokens will being generated.
|
| 239 |
+
if stream:
|
| 240 |
+
logger.debug("Chat completion finished.")
|
| 241 |
+
await stream.close()
|
| 242 |
+
logger.debug("Stream closed.")
|
src/open_llm_vtuber/agent/stateless_llm/stateless_llm_interface.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import abc
|
| 2 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class StatelessLLMInterface(metaclass=abc.ABCMeta):
|
| 6 |
+
"""
|
| 7 |
+
Interface for a stateless language model.
|
| 8 |
+
|
| 9 |
+
The word "stateless" means that the language model does not store memory,
|
| 10 |
+
system prompts, or user messages, which is most of the LLM. If we send a
|
| 11 |
+
message to the LLM, its response will be based on the message parameter alone.
|
| 12 |
+
|
| 13 |
+
The StatelessLLMInterface class provides a method for generating chat
|
| 14 |
+
completions asynchronously.
|
| 15 |
+
|
| 16 |
+
We use StatelessLLMs to initialize Agents, which pack the StatelessLLM with
|
| 17 |
+
memory, system prompts, and other features.
|
| 18 |
+
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
@abc.abstractmethod
|
| 22 |
+
async def chat_completion(
|
| 23 |
+
self,
|
| 24 |
+
messages: List[Dict[str, Any]],
|
| 25 |
+
system: str = None,
|
| 26 |
+
tools: List[Dict[str, Any]] = None,
|
| 27 |
+
) -> AsyncIterator[str]:
|
| 28 |
+
"""
|
| 29 |
+
Generates a chat completion asynchronously and return an iterator to the response.
|
| 30 |
+
This function does not store memory or user messages.
|
| 31 |
+
|
| 32 |
+
Parameters:
|
| 33 |
+
- messages (List[Dict[str, Any]]): The list of messages to send to the API.
|
| 34 |
+
- system (str, optional): System prompt to use for this completion.
|
| 35 |
+
- tools (List[Dict[str, str]], optional): List of tools to use for this completion.
|
| 36 |
+
- Each tool should follow the format:
|
| 37 |
+
{
|
| 38 |
+
"name": "tool_name",
|
| 39 |
+
"description": "tool_description",
|
| 40 |
+
"parameters": {
|
| 41 |
+
"type": "object",
|
| 42 |
+
"properties": {
|
| 43 |
+
"param1": {
|
| 44 |
+
"type": "string",
|
| 45 |
+
"description": "Description of param1"
|
| 46 |
+
},
|
| 47 |
+
"param2": {
|
| 48 |
+
"type": "integer",
|
| 49 |
+
"description": "Description of param2"
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"required": ["param1"]
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
Yields:
|
| 57 |
+
- str: The content of each chunk from the API response.
|
| 58 |
+
|
| 59 |
+
Raises:
|
| 60 |
+
- APIConnectionError: When the server cannot be reached
|
| 61 |
+
- RateLimitError: When a 429 status code is received
|
| 62 |
+
- APIError: For other API-related errors
|
| 63 |
+
"""
|
| 64 |
+
raise NotImplementedError
|
src/open_llm_vtuber/agent/stateless_llm/stateless_llm_with_template.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Description: This file contains the implementation of the `AsyncLLMTemplate` class.
|
| 2 |
+
This class is responsible for handling asynchronous interaction with OpenAI API
|
| 3 |
+
compatible endpoints for language generation where the language model is not
|
| 4 |
+
trained using a ChatML format.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import json
|
| 9 |
+
from jinja2 import Template
|
| 10 |
+
from loguru import logger
|
| 11 |
+
from typing import AsyncIterator, List, Dict, Any
|
| 12 |
+
|
| 13 |
+
from .stateless_llm_interface import StatelessLLMInterface
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
TEMPLATES = {
|
| 17 |
+
"LLAMA3": {
|
| 18 |
+
"template": "".join(
|
| 19 |
+
[
|
| 20 |
+
"{{ bos_token }}",
|
| 21 |
+
"{% for message in messages %}",
|
| 22 |
+
" {{ '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n'+ message['content'] | trim + '<|eot_id|>' }}",
|
| 23 |
+
"{% endfor %}",
|
| 24 |
+
"{% if add_generation_prompt %}",
|
| 25 |
+
" {{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}",
|
| 26 |
+
"{% endif %}",
|
| 27 |
+
]
|
| 28 |
+
),
|
| 29 |
+
"eot_token": "<|eot_id|>",
|
| 30 |
+
},
|
| 31 |
+
"CHATML": {
|
| 32 |
+
"template": "".join(
|
| 33 |
+
[
|
| 34 |
+
"{{ bos_token }}",
|
| 35 |
+
"{% for message in messages %}",
|
| 36 |
+
" {{ '<|im_start|>' + message['role'] + '\n' + message['content'] | trim + '<|im_end|>\n' }}",
|
| 37 |
+
"{% endfor %}",
|
| 38 |
+
"{% if add_generation_prompt %}",
|
| 39 |
+
" {{ '<|im_start|>assistant\n' }}",
|
| 40 |
+
"{% endif %}",
|
| 41 |
+
]
|
| 42 |
+
),
|
| 43 |
+
"eot_token": "<|im_end|>",
|
| 44 |
+
},
|
| 45 |
+
"ALPACA": {
|
| 46 |
+
"template": "".join(
|
| 47 |
+
[
|
| 48 |
+
"""
|
| 49 |
+
{{ (messages|selectattr('role', 'equalto', 'system')|list|last).content|trim if (messages|selectattr('role', 'equalto', 'system')|list) else '' }}
|
| 50 |
+
|
| 51 |
+
{% for message in messages %}
|
| 52 |
+
{% if message['role'] == 'user' %}
|
| 53 |
+
### Instruction:
|
| 54 |
+
{{ message['content']|trim -}}
|
| 55 |
+
{% if not loop.last %}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
{% endif %}
|
| 59 |
+
{% elif message['role'] == 'assistant' %}
|
| 60 |
+
### Response:
|
| 61 |
+
{{ message['content']|trim -}}
|
| 62 |
+
{% if not loop.last %}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
{% endif %}
|
| 66 |
+
{% elif message['role'] == 'user_context' %}
|
| 67 |
+
### Input:
|
| 68 |
+
{{ message['content']|trim -}}
|
| 69 |
+
{% if not loop.last %}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
{% endif %}
|
| 73 |
+
{% endif %}
|
| 74 |
+
{% endfor %}
|
| 75 |
+
{% if add_generation_prompt and messages[-1]['role'] != 'assistant' %}
|
| 76 |
+
### Response:
|
| 77 |
+
{% endif %}
|
| 78 |
+
"""
|
| 79 |
+
]
|
| 80 |
+
),
|
| 81 |
+
"eot_token": "###",
|
| 82 |
+
},
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class AsyncLLMWithTemplate(StatelessLLMInterface):
|
| 87 |
+
def __init__(
|
| 88 |
+
self,
|
| 89 |
+
model: str,
|
| 90 |
+
base_url: str,
|
| 91 |
+
llm_api_key: str = "z",
|
| 92 |
+
organization_id: str = "z",
|
| 93 |
+
project_id: str = "z",
|
| 94 |
+
template: str = "CHATML",
|
| 95 |
+
temperature: float = 1.0,
|
| 96 |
+
):
|
| 97 |
+
"""
|
| 98 |
+
Initializes an instance of the `AsyncLLM` class.
|
| 99 |
+
|
| 100 |
+
Parameters:
|
| 101 |
+
- model (str): The model to be used for language generation.
|
| 102 |
+
- base_url (str): The base URL for the OpenAI API.
|
| 103 |
+
- organization_id (str, optional): The organization ID for the OpenAI API. Defaults to "z".
|
| 104 |
+
- project_id (str, optional): The project ID for the OpenAI API. Defaults to "z".
|
| 105 |
+
- llm_api_key (str, optional): The API key for the OpenAI API. Defaults to "z".
|
| 106 |
+
- template (str, optional): The Jinja template to use. Defaults to "LLAMA3".
|
| 107 |
+
- temperature (float, optional): What sampling temperature to use, between 0 and 2. Defaults to 1.0.
|
| 108 |
+
"""
|
| 109 |
+
self.completion_url = base_url
|
| 110 |
+
self.model = model
|
| 111 |
+
self.temperature = temperature
|
| 112 |
+
self.template = Template(TEMPLATES[template]["template"])
|
| 113 |
+
self.eot_token = TEMPLATES[template]["eot_token"]
|
| 114 |
+
self.prompt_headers = {
|
| 115 |
+
"Authorization": llm_api_key or "Bearer your_api_key_here"
|
| 116 |
+
}
|
| 117 |
+
logger.info(
|
| 118 |
+
f"Initialized AsyncLLM with the parameters: {self.completion_url} ({template})"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
async def chat_completion(
|
| 122 |
+
self, messages: List[Dict[str, Any]], system: str = None
|
| 123 |
+
) -> AsyncIterator[str]:
|
| 124 |
+
"""
|
| 125 |
+
Generates a chat completion using the OpenAI API asynchronously.
|
| 126 |
+
|
| 127 |
+
Parameters:
|
| 128 |
+
- messages (List[Dict[str, Any]]): The list of messages to send to the API.
|
| 129 |
+
- system (str, optional): System prompt to use for this completion.
|
| 130 |
+
|
| 131 |
+
Yields:
|
| 132 |
+
- str: The content of each chunk from the API response.
|
| 133 |
+
|
| 134 |
+
Raises:
|
| 135 |
+
- APIConnectionError: When the server cannot be reached
|
| 136 |
+
- RateLimitError: When a 429 status code is received
|
| 137 |
+
- APIError: For other API-related errors
|
| 138 |
+
"""
|
| 139 |
+
logger.debug(f"Messages: {messages}")
|
| 140 |
+
bos_token = "<|begin_of_text|>"
|
| 141 |
+
stream = None
|
| 142 |
+
try:
|
| 143 |
+
# If system prompt is provided, add it to the messages
|
| 144 |
+
messages_with_system: List[Dict[str, Any]] = messages
|
| 145 |
+
if system:
|
| 146 |
+
messages_with_system = [
|
| 147 |
+
{"role": "system", "content": system},
|
| 148 |
+
*messages,
|
| 149 |
+
]
|
| 150 |
+
prompt = self.template.render(
|
| 151 |
+
messages=messages_with_system,
|
| 152 |
+
bos_token=bos_token,
|
| 153 |
+
add_generation_prompt=True,
|
| 154 |
+
)
|
| 155 |
+
data: Dict = {
|
| 156 |
+
"stream": True,
|
| 157 |
+
"temperature": self.temperature,
|
| 158 |
+
"prompt": prompt,
|
| 159 |
+
}
|
| 160 |
+
with requests.post(
|
| 161 |
+
self.completion_url, headers=self.prompt_headers, json=data, stream=True
|
| 162 |
+
) as response:
|
| 163 |
+
for line in response.iter_lines():
|
| 164 |
+
if line:
|
| 165 |
+
line = self._clean_raw_bytes(line)
|
| 166 |
+
next_token = self._process_line(line)
|
| 167 |
+
if next_token:
|
| 168 |
+
if next_token == self.eot_token:
|
| 169 |
+
break
|
| 170 |
+
yield next_token
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"LLM API WITH TEMPLATE: Error occurred: {e}")
|
| 173 |
+
logger.info(f"Base URL: {self.base_url}")
|
| 174 |
+
logger.info(f"Model: {self.model}")
|
| 175 |
+
logger.info(f"Messages: {messages}")
|
| 176 |
+
logger.info(f"temperature: {self.temperature}")
|
| 177 |
+
yield "Error calling the chat endpoint: Error occurred while generating response. See the logs for details."
|
| 178 |
+
finally:
|
| 179 |
+
# make sure the stream is properly closed
|
| 180 |
+
# so when interrupted, no more tokens will being generated.
|
| 181 |
+
if stream:
|
| 182 |
+
logger.debug("Chat completion finished.")
|
| 183 |
+
await stream.close()
|
| 184 |
+
logger.debug("Stream closed.")
|
| 185 |
+
|
| 186 |
+
def _clean_raw_bytes(self, line):
|
| 187 |
+
line = line.decode("utf-8")
|
| 188 |
+
line = line.removeprefix("data: ")
|
| 189 |
+
line = json.loads(line)
|
| 190 |
+
return line
|
| 191 |
+
|
| 192 |
+
def _process_line(self, line):
|
| 193 |
+
if not (("stop" in line) and (line["stop"])):
|
| 194 |
+
token = line["content"]
|
| 195 |
+
return token
|
src/open_llm_vtuber/agent/stateless_llm_factory.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Type
|
| 2 |
+
|
| 3 |
+
from loguru import logger
|
| 4 |
+
|
| 5 |
+
from .stateless_llm.stateless_llm_interface import StatelessLLMInterface
|
| 6 |
+
from .stateless_llm.stateless_llm_with_template import (
|
| 7 |
+
AsyncLLMWithTemplate as StatelessLLMWithTemplate,
|
| 8 |
+
)
|
| 9 |
+
from .stateless_llm.openai_compatible_llm import AsyncLLM as OpenAICompatibleLLM
|
| 10 |
+
from .stateless_llm.ollama_llm import OllamaLLM
|
| 11 |
+
from .stateless_llm.claude_llm import AsyncLLM as ClaudeLLM
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LLMFactory:
|
| 15 |
+
@staticmethod
|
| 16 |
+
def create_llm(llm_provider, **kwargs) -> Type[StatelessLLMInterface]:
|
| 17 |
+
"""Create an LLM based on the configuration.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
llm_provider: The type of LLM to create
|
| 21 |
+
**kwargs: Additional arguments
|
| 22 |
+
"""
|
| 23 |
+
logger.info(f"Initializing LLM: {llm_provider}")
|
| 24 |
+
|
| 25 |
+
if (
|
| 26 |
+
llm_provider == "openai_compatible_llm"
|
| 27 |
+
or llm_provider == "openai_llm"
|
| 28 |
+
or llm_provider == "gemini_llm"
|
| 29 |
+
or llm_provider == "zhipu_llm"
|
| 30 |
+
or llm_provider == "deepseek_llm"
|
| 31 |
+
or llm_provider == "groq_llm"
|
| 32 |
+
or llm_provider == "mistral_llm"
|
| 33 |
+
or llm_provider == "lmstudio_llm"
|
| 34 |
+
):
|
| 35 |
+
return OpenAICompatibleLLM(
|
| 36 |
+
model=kwargs.get("model"),
|
| 37 |
+
base_url=kwargs.get("base_url"),
|
| 38 |
+
llm_api_key=kwargs.get("llm_api_key"),
|
| 39 |
+
organization_id=kwargs.get("organization_id"),
|
| 40 |
+
project_id=kwargs.get("project_id"),
|
| 41 |
+
temperature=kwargs.get("temperature"),
|
| 42 |
+
)
|
| 43 |
+
if llm_provider == "stateless_llm_with_template":
|
| 44 |
+
return StatelessLLMWithTemplate(
|
| 45 |
+
model=kwargs.get("model"),
|
| 46 |
+
base_url=kwargs.get("base_url"),
|
| 47 |
+
llm_api_key=kwargs.get("llm_api_key"),
|
| 48 |
+
organization_id=kwargs.get("organization_id"),
|
| 49 |
+
template=kwargs.get("template"),
|
| 50 |
+
project_id=kwargs.get("project_id"),
|
| 51 |
+
)
|
| 52 |
+
if llm_provider == "ollama_llm":
|
| 53 |
+
return OllamaLLM(
|
| 54 |
+
model=kwargs.get("model"),
|
| 55 |
+
base_url=kwargs.get("base_url"),
|
| 56 |
+
llm_api_key=kwargs.get("llm_api_key"),
|
| 57 |
+
organization_id=kwargs.get("organization_id"),
|
| 58 |
+
project_id=kwargs.get("project_id"),
|
| 59 |
+
temperature=kwargs.get("temperature"),
|
| 60 |
+
keep_alive=kwargs.get("keep_alive"),
|
| 61 |
+
unload_at_exit=kwargs.get("unload_at_exit"),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
elif llm_provider == "llama_cpp_llm":
|
| 65 |
+
from .stateless_llm.llama_cpp_llm import LLM as LlamaLLM
|
| 66 |
+
|
| 67 |
+
return LlamaLLM(
|
| 68 |
+
model_path=kwargs.get("model_path"),
|
| 69 |
+
)
|
| 70 |
+
elif llm_provider == "claude_llm":
|
| 71 |
+
return ClaudeLLM(
|
| 72 |
+
system=kwargs.get("system_prompt"),
|
| 73 |
+
base_url=kwargs.get("base_url"),
|
| 74 |
+
model=kwargs.get("model"),
|
| 75 |
+
llm_api_key=kwargs.get("llm_api_key"),
|
| 76 |
+
)
|
| 77 |
+
else:
|
| 78 |
+
raise ValueError(f"Unsupported LLM provider: {llm_provider}")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# Creating an LLM instance using a factory
|
| 82 |
+
# llm_instance = LLMFactory.create_llm("ollama", **config_dict)
|
src/open_llm_vtuber/agent/transformers.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import AsyncIterator, Tuple, Callable, List, Union, Dict, Any
|
| 2 |
+
from functools import wraps
|
| 3 |
+
from .output_types import Actions, SentenceOutput, DisplayText
|
| 4 |
+
from ..utils.tts_preprocessor import tts_filter as filter_text
|
| 5 |
+
from ..live2d_model import Live2dModel
|
| 6 |
+
from ..config_manager import TTSPreprocessorConfig
|
| 7 |
+
from ..utils.sentence_divider import SentenceDivider
|
| 8 |
+
from ..utils.sentence_divider import SentenceWithTags, TagState
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def sentence_divider(
|
| 13 |
+
faster_first_response: bool = True,
|
| 14 |
+
segment_method: str = "pysbd",
|
| 15 |
+
valid_tags: List[str] = None,
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Decorator that transforms token stream into sentences with tags
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
faster_first_response: bool - Whether to enable faster first response
|
| 22 |
+
segment_method: str - Method for sentence segmentation
|
| 23 |
+
valid_tags: List[str] - List of valid tags to process
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def decorator(
|
| 27 |
+
func: Callable[
|
| 28 |
+
..., AsyncIterator[Union[str, Dict[str, Any]]]
|
| 29 |
+
], # Expects str or dict
|
| 30 |
+
) -> Callable[
|
| 31 |
+
..., AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]
|
| 32 |
+
]: # Yields SentenceWithTags or dict
|
| 33 |
+
@wraps(func)
|
| 34 |
+
async def wrapper(
|
| 35 |
+
*args, **kwargs
|
| 36 |
+
) -> AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]:
|
| 37 |
+
divider = SentenceDivider(
|
| 38 |
+
faster_first_response=faster_first_response,
|
| 39 |
+
segment_method=segment_method,
|
| 40 |
+
valid_tags=valid_tags or [],
|
| 41 |
+
)
|
| 42 |
+
stream_from_func = func(*args, **kwargs)
|
| 43 |
+
|
| 44 |
+
# Process the mixed stream using the updated SentenceDivider
|
| 45 |
+
async for item in divider.process_stream(stream_from_func):
|
| 46 |
+
if isinstance(item, SentenceWithTags):
|
| 47 |
+
logger.debug(f"sentence_divider yielding sentence: {item}")
|
| 48 |
+
elif isinstance(item, dict):
|
| 49 |
+
logger.debug(f"sentence_divider yielding dict: {item}")
|
| 50 |
+
yield item # Yield either SentenceWithTags or dict
|
| 51 |
+
# Flushing is handled within divider.process_stream
|
| 52 |
+
|
| 53 |
+
return wrapper
|
| 54 |
+
|
| 55 |
+
return decorator
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def actions_extractor(live2d_model: Live2dModel):
|
| 59 |
+
"""
|
| 60 |
+
Decorator that extracts actions from sentences, passing through dicts.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
def decorator(
|
| 64 |
+
func: Callable[
|
| 65 |
+
..., AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]
|
| 66 |
+
], # Input type hint
|
| 67 |
+
) -> Callable[
|
| 68 |
+
..., AsyncIterator[Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]]
|
| 69 |
+
]: # Output type hint
|
| 70 |
+
@wraps(func)
|
| 71 |
+
async def wrapper(
|
| 72 |
+
*args, **kwargs
|
| 73 |
+
) -> AsyncIterator[
|
| 74 |
+
Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]
|
| 75 |
+
]: # Yield type hint
|
| 76 |
+
stream = func(*args, **kwargs)
|
| 77 |
+
async for item in stream:
|
| 78 |
+
if isinstance(item, SentenceWithTags):
|
| 79 |
+
sentence = item
|
| 80 |
+
actions = Actions()
|
| 81 |
+
# Only extract emotions for non-tag text
|
| 82 |
+
if not any(
|
| 83 |
+
tag.state in [TagState.START, TagState.END]
|
| 84 |
+
for tag in sentence.tags
|
| 85 |
+
):
|
| 86 |
+
expressions = live2d_model.extract_emotion(sentence.text)
|
| 87 |
+
if expressions:
|
| 88 |
+
actions.expressions = expressions
|
| 89 |
+
yield sentence, actions # Yield the tuple
|
| 90 |
+
elif isinstance(item, dict):
|
| 91 |
+
# Pass through dictionaries
|
| 92 |
+
yield item
|
| 93 |
+
else:
|
| 94 |
+
logger.warning(
|
| 95 |
+
f"actions_extractor received unexpected type: {type(item)}"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return wrapper
|
| 99 |
+
|
| 100 |
+
return decorator
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def display_processor():
|
| 104 |
+
"""
|
| 105 |
+
Decorator that processes text for display, passing through dicts.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
def decorator(
|
| 109 |
+
func: Callable[
|
| 110 |
+
..., AsyncIterator[Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]]
|
| 111 |
+
], # Input type hint
|
| 112 |
+
) -> Callable[
|
| 113 |
+
...,
|
| 114 |
+
AsyncIterator[
|
| 115 |
+
Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
|
| 116 |
+
],
|
| 117 |
+
]: # Output type hint
|
| 118 |
+
@wraps(func)
|
| 119 |
+
async def wrapper(
|
| 120 |
+
*args, **kwargs
|
| 121 |
+
) -> AsyncIterator[
|
| 122 |
+
Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
|
| 123 |
+
]: # Yield type hint
|
| 124 |
+
stream = func(*args, **kwargs)
|
| 125 |
+
|
| 126 |
+
async for item in stream:
|
| 127 |
+
if (
|
| 128 |
+
isinstance(item, tuple)
|
| 129 |
+
and len(item) == 2
|
| 130 |
+
and isinstance(item[0], SentenceWithTags)
|
| 131 |
+
):
|
| 132 |
+
sentence, actions = item
|
| 133 |
+
text = sentence.text
|
| 134 |
+
# Handle think tag states
|
| 135 |
+
for tag in sentence.tags:
|
| 136 |
+
if tag.name == "think":
|
| 137 |
+
if tag.state == TagState.START:
|
| 138 |
+
text = "("
|
| 139 |
+
elif tag.state == TagState.END:
|
| 140 |
+
text = ")"
|
| 141 |
+
|
| 142 |
+
display = DisplayText(text=text) # Simplified DisplayText creation
|
| 143 |
+
yield sentence, display, actions # Yield the tuple
|
| 144 |
+
elif isinstance(item, dict):
|
| 145 |
+
# Pass through dictionaries
|
| 146 |
+
yield item
|
| 147 |
+
else:
|
| 148 |
+
logger.warning(
|
| 149 |
+
f"display_processor received unexpected type: {type(item)}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return wrapper
|
| 153 |
+
|
| 154 |
+
return decorator
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def tts_filter(
|
| 158 |
+
tts_preprocessor_config: TTSPreprocessorConfig = None,
|
| 159 |
+
):
|
| 160 |
+
"""
|
| 161 |
+
Decorator that filters text for TTS, passing through dicts.
|
| 162 |
+
Skips TTS for think tag content.
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
def decorator(
|
| 166 |
+
func: Callable[
|
| 167 |
+
...,
|
| 168 |
+
AsyncIterator[
|
| 169 |
+
Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
|
| 170 |
+
],
|
| 171 |
+
], # Input type hint
|
| 172 |
+
) -> Callable[
|
| 173 |
+
..., AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]
|
| 174 |
+
]: # Output type hint
|
| 175 |
+
@wraps(func)
|
| 176 |
+
async def wrapper(
|
| 177 |
+
*args, **kwargs
|
| 178 |
+
) -> AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]: # Yield type hint
|
| 179 |
+
stream = func(*args, **kwargs)
|
| 180 |
+
config = tts_preprocessor_config or TTSPreprocessorConfig()
|
| 181 |
+
|
| 182 |
+
async for item in stream:
|
| 183 |
+
if (
|
| 184 |
+
isinstance(item, tuple)
|
| 185 |
+
and len(item) == 3
|
| 186 |
+
and isinstance(item[1], DisplayText)
|
| 187 |
+
):
|
| 188 |
+
sentence, display, actions = item
|
| 189 |
+
if any(tag.name == "think" for tag in sentence.tags):
|
| 190 |
+
tts = ""
|
| 191 |
+
else:
|
| 192 |
+
tts = filter_text(
|
| 193 |
+
text=display.text,
|
| 194 |
+
remove_special_char=config.remove_special_char,
|
| 195 |
+
ignore_brackets=config.ignore_brackets,
|
| 196 |
+
ignore_parentheses=config.ignore_parentheses,
|
| 197 |
+
ignore_asterisks=config.ignore_asterisks,
|
| 198 |
+
ignore_angle_brackets=config.ignore_angle_brackets,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
logger.debug(f"[{display.name}] display: {display.text}")
|
| 202 |
+
logger.debug(f"[{display.name}] tts: {tts}")
|
| 203 |
+
|
| 204 |
+
yield SentenceOutput(
|
| 205 |
+
display_text=display,
|
| 206 |
+
tts_text=tts,
|
| 207 |
+
actions=actions,
|
| 208 |
+
)
|
| 209 |
+
elif isinstance(item, dict):
|
| 210 |
+
# Pass through dictionaries
|
| 211 |
+
yield item
|
| 212 |
+
else:
|
| 213 |
+
logger.warning(f"tts_filter received unexpected type: {type(item)}")
|
| 214 |
+
|
| 215 |
+
return wrapper
|
| 216 |
+
|
| 217 |
+
return decorator
|
src/open_llm_vtuber/asr/__init__.py
ADDED
|
File without changes
|
src/open_llm_vtuber/asr/asr_factory.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Type
|
| 2 |
+
from .asr_interface import ASRInterface
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ASRFactory:
|
| 6 |
+
@staticmethod
|
| 7 |
+
def get_asr_system(system_name: str, **kwargs) -> Type[ASRInterface]:
|
| 8 |
+
if system_name == "faster_whisper":
|
| 9 |
+
from .faster_whisper_asr import VoiceRecognition as FasterWhisperASR
|
| 10 |
+
|
| 11 |
+
return FasterWhisperASR(
|
| 12 |
+
model_path=kwargs.get("model_path"),
|
| 13 |
+
download_root=kwargs.get("download_root"),
|
| 14 |
+
language=kwargs.get("language"),
|
| 15 |
+
device=kwargs.get("device"),
|
| 16 |
+
compute_type=kwargs.get("compute_type"),
|
| 17 |
+
prompt=kwargs.get("prompt", None),
|
| 18 |
+
)
|
| 19 |
+
elif system_name == "whisper_cpp":
|
| 20 |
+
from .whisper_cpp_asr import VoiceRecognition as WhisperCPPASR
|
| 21 |
+
|
| 22 |
+
return WhisperCPPASR(**kwargs)
|
| 23 |
+
elif system_name == "whisper":
|
| 24 |
+
from .openai_whisper_asr import VoiceRecognition as WhisperASR
|
| 25 |
+
|
| 26 |
+
return WhisperASR(**kwargs)
|
| 27 |
+
elif system_name == "fun_asr":
|
| 28 |
+
from .fun_asr import VoiceRecognition as FunASR
|
| 29 |
+
|
| 30 |
+
return FunASR(
|
| 31 |
+
model_name=kwargs.get("model_name"),
|
| 32 |
+
vad_model=kwargs.get("vad_model"),
|
| 33 |
+
punc_model=kwargs.get("punc_model"),
|
| 34 |
+
ncpu=kwargs.get("ncpu"),
|
| 35 |
+
hub=kwargs.get("hub"),
|
| 36 |
+
device=kwargs.get("device"),
|
| 37 |
+
language=kwargs.get("language"),
|
| 38 |
+
use_itn=kwargs.get("use_itn"),
|
| 39 |
+
# sample_rate=kwargs.get("sample_rate"),
|
| 40 |
+
)
|
| 41 |
+
elif system_name == "azure_asr":
|
| 42 |
+
from .azure_asr import VoiceRecognition as AzureASR
|
| 43 |
+
|
| 44 |
+
return AzureASR(
|
| 45 |
+
subscription_key=kwargs.get("api_key"),
|
| 46 |
+
region=kwargs.get("region"),
|
| 47 |
+
languages=kwargs.get("languages", ["en-US", "zh-CN"]),
|
| 48 |
+
)
|
| 49 |
+
elif system_name == "groq_whisper_asr":
|
| 50 |
+
from .groq_whisper_asr import VoiceRecognition as GroqWhisperASR
|
| 51 |
+
|
| 52 |
+
return GroqWhisperASR(
|
| 53 |
+
api_key=kwargs.get("api_key"),
|
| 54 |
+
model=kwargs.get("model"),
|
| 55 |
+
lang=kwargs.get("lang"),
|
| 56 |
+
)
|
| 57 |
+
elif system_name == "sherpa_onnx_asr":
|
| 58 |
+
from .sherpa_onnx_asr import VoiceRecognition as SherpaOnnxASR
|
| 59 |
+
|
| 60 |
+
return SherpaOnnxASR(**kwargs)
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError(f"Unknown ASR system: {system_name}")
|
src/open_llm_vtuber/asr/asr_interface.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import abc
|
| 2 |
+
import numpy as np
|
| 3 |
+
import asyncio
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ASRInterface(metaclass=abc.ABCMeta):
|
| 7 |
+
SAMPLE_RATE = 16000
|
| 8 |
+
NUM_CHANNELS = 1
|
| 9 |
+
SAMPLE_WIDTH = 2
|
| 10 |
+
|
| 11 |
+
async def async_transcribe_np(self, audio: np.ndarray) -> str:
|
| 12 |
+
"""Asynchronously transcribe speech audio in numpy array format.
|
| 13 |
+
|
| 14 |
+
By default, this runs the synchronous transcribe_np in a coroutine.
|
| 15 |
+
Subclasses can override this method to provide true async implementation.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
audio: The numpy array of the audio data to transcribe.
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
str: The transcription result.
|
| 22 |
+
"""
|
| 23 |
+
if audio.dtype != np.float32:
|
| 24 |
+
audio = audio.astype(np.float32)
|
| 25 |
+
return await asyncio.to_thread(self.transcribe_np, audio)
|
| 26 |
+
|
| 27 |
+
@abc.abstractmethod
|
| 28 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 29 |
+
"""Transcribe speech audio in numpy array format and return the transcription.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
audio: The numpy array of the audio data to transcribe.
|
| 33 |
+
"""
|
| 34 |
+
raise NotImplementedError
|
| 35 |
+
|
| 36 |
+
def nparray_to_audio_file(
|
| 37 |
+
self, audio: np.ndarray, sample_rate: int, file_path: str
|
| 38 |
+
) -> None:
|
| 39 |
+
"""Convert a numpy array of audio data to a .wav file.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
audio: The numpy array of audio data.
|
| 43 |
+
sample_rate: The sample rate of the audio data.
|
| 44 |
+
file_path: The path to save the .wav file.
|
| 45 |
+
"""
|
| 46 |
+
import wave
|
| 47 |
+
|
| 48 |
+
# Make sure the audio is in the range [-1, 1]
|
| 49 |
+
audio = np.clip(audio, -1, 1)
|
| 50 |
+
# Convert the audio to 16-bit PCM
|
| 51 |
+
audio_integer = (audio * 32767).astype(np.int16)
|
| 52 |
+
|
| 53 |
+
with wave.open(file_path, "wb") as wf:
|
| 54 |
+
wf.setnchannels(1)
|
| 55 |
+
wf.setsampwidth(2)
|
| 56 |
+
wf.setframerate(sample_rate)
|
| 57 |
+
wf.writeframes(audio_integer.tobytes())
|
src/open_llm_vtuber/asr/azure_asr.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Callable
|
| 3 |
+
import numpy as np
|
| 4 |
+
from loguru import logger
|
| 5 |
+
import azure.cognitiveservices.speech as speechsdk
|
| 6 |
+
from .asr_interface import ASRInterface
|
| 7 |
+
import soundfile as sf
|
| 8 |
+
import uuid
|
| 9 |
+
import asyncio
|
| 10 |
+
|
| 11 |
+
CACHE_DIR = "cache"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class VoiceRecognition(ASRInterface):
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
subscription_key=os.getenv("AZURE_API_Key"),
|
| 18 |
+
region=os.getenv("AZURE_REGION"),
|
| 19 |
+
languages=["en-US", "zh-CN"],
|
| 20 |
+
callback: Callable = logger.info,
|
| 21 |
+
):
|
| 22 |
+
if not subscription_key or not region:
|
| 23 |
+
raise ValueError(
|
| 24 |
+
"Azure Speech Services requires both subscription_key and region. "
|
| 25 |
+
"Please check your configuration."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
self.subscription_key = subscription_key
|
| 29 |
+
self.region = region
|
| 30 |
+
self.callback = callback
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
self.speech_config = speechsdk.SpeechConfig(
|
| 34 |
+
subscription=self.subscription_key, region=self.region
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Set the languages for auto detection
|
| 38 |
+
self.speech_config.set_property(
|
| 39 |
+
speechsdk.PropertyId.SpeechServiceConnection_AutoDetectSourceLanguages,
|
| 40 |
+
",".join(languages),
|
| 41 |
+
)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Failed to initialize Azure Speech Config: {e}")
|
| 44 |
+
raise
|
| 45 |
+
|
| 46 |
+
def _create_speech_recognizer(self, uses_default_microphone: bool = True):
|
| 47 |
+
"""
|
| 48 |
+
Create a speech recognizer instance with the specified configuration.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
uses_default_microphone (bool): Whether to use default microphone
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
SpeechRecognizer: Configured speech recognizer instance
|
| 55 |
+
"""
|
| 56 |
+
try:
|
| 57 |
+
audio_config = speechsdk.AudioConfig(
|
| 58 |
+
use_default_microphone=uses_default_microphone
|
| 59 |
+
)
|
| 60 |
+
return speechsdk.SpeechRecognizer(
|
| 61 |
+
speech_config=self.speech_config, audio_config=audio_config
|
| 62 |
+
)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.warning(f"Failed to create speech recognizer: {e}")
|
| 65 |
+
raise
|
| 66 |
+
|
| 67 |
+
async def async_transcribe_np(self, audio: np.ndarray) -> str:
|
| 68 |
+
"""
|
| 69 |
+
Asynchronously transcribe audio data using Azure Speech Services with auto language detection.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
audio (np.ndarray): Audio data as numpy array
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
str: Transcribed text
|
| 76 |
+
|
| 77 |
+
Raises:
|
| 78 |
+
Exception: If transcription fails
|
| 79 |
+
"""
|
| 80 |
+
temp_file = os.path.join(CACHE_DIR, f"{uuid.uuid4()}.wav")
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 84 |
+
sf.write(temp_file, audio, 16000, "PCM_16")
|
| 85 |
+
|
| 86 |
+
audio_config = speechsdk.AudioConfig(filename=temp_file)
|
| 87 |
+
speech_recognizer = speechsdk.SpeechRecognizer(
|
| 88 |
+
speech_config=self.speech_config, audio_config=audio_config
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Perform recognition
|
| 92 |
+
result = speech_recognizer.recognize_once()
|
| 93 |
+
|
| 94 |
+
if result.reason == speechsdk.ResultReason.RecognizedSpeech:
|
| 95 |
+
# Get detected language
|
| 96 |
+
detected_language = result.properties.get(
|
| 97 |
+
speechsdk.PropertyId.SpeechServiceConnection_AutoDetectSourceLanguageResult
|
| 98 |
+
)
|
| 99 |
+
logger.debug(f"Detected language: {detected_language}")
|
| 100 |
+
return result.text
|
| 101 |
+
elif result.reason == speechsdk.ResultReason.NoMatch:
|
| 102 |
+
logger.warning(
|
| 103 |
+
f"No speech could be recognized: {result.no_match_details}"
|
| 104 |
+
)
|
| 105 |
+
return ""
|
| 106 |
+
elif result.reason == speechsdk.ResultReason.Canceled:
|
| 107 |
+
cancellation_details = result.cancellation_details
|
| 108 |
+
logger.error(
|
| 109 |
+
f"Speech Recognition canceled: {cancellation_details.reason}"
|
| 110 |
+
)
|
| 111 |
+
if cancellation_details.reason == speechsdk.CancellationReason.Error:
|
| 112 |
+
logger.error(f"Error details: {cancellation_details.error_details}")
|
| 113 |
+
raise Exception(
|
| 114 |
+
f"Speech Recognition failed: {cancellation_details.reason}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Transcription failed: {e}")
|
| 119 |
+
raise
|
| 120 |
+
finally:
|
| 121 |
+
try:
|
| 122 |
+
if os.path.exists(temp_file):
|
| 123 |
+
os.remove(temp_file)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.debug(f"Failed to remove temporary file {temp_file}: {e}")
|
| 126 |
+
|
| 127 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 128 |
+
"""
|
| 129 |
+
Synchronously transcribe audio data using Azure Speech Services.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
audio (np.ndarray): Audio data as numpy array
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
str: Transcribed text
|
| 136 |
+
|
| 137 |
+
Raises:
|
| 138 |
+
Exception: If transcription fails
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
try:
|
| 142 |
+
loop = asyncio.get_event_loop()
|
| 143 |
+
except RuntimeError:
|
| 144 |
+
loop = asyncio.new_event_loop()
|
| 145 |
+
asyncio.set_event_loop(loop)
|
| 146 |
+
|
| 147 |
+
# Run async method synchronously
|
| 148 |
+
return loop.run_until_complete(self.async_transcribe_np(audio))
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Transcription failed: {e}")
|
| 151 |
+
raise
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
service = VoiceRecognition()
|
src/open_llm_vtuber/asr/faster_whisper_asr.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from faster_whisper import WhisperModel
|
| 3 |
+
from .asr_interface import ASRInterface
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VoiceRecognition(ASRInterface):
|
| 7 |
+
BEAM_SEARCH = True
|
| 8 |
+
# SAMPLE_RATE # Defined in asr_interface.py
|
| 9 |
+
|
| 10 |
+
def __init__(
|
| 11 |
+
self,
|
| 12 |
+
model_path: str = "distil-medium.en",
|
| 13 |
+
download_root: str = None,
|
| 14 |
+
language: str = "en",
|
| 15 |
+
device: str = "auto",
|
| 16 |
+
compute_type: str = "int8",
|
| 17 |
+
prompt: str = None,
|
| 18 |
+
) -> None:
|
| 19 |
+
self.MODEL_PATH = model_path
|
| 20 |
+
self.LANG = language
|
| 21 |
+
self.prompt = prompt
|
| 22 |
+
self.model = WhisperModel(
|
| 23 |
+
model_size_or_path=model_path,
|
| 24 |
+
download_root=download_root,
|
| 25 |
+
device=device,
|
| 26 |
+
compute_type=compute_type,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 30 |
+
if self.prompt:
|
| 31 |
+
segments, info = self.model.transcribe(
|
| 32 |
+
audio,
|
| 33 |
+
beam_size=5 if self.BEAM_SEARCH else 1,
|
| 34 |
+
language=self.LANG if self.LANG else None,
|
| 35 |
+
condition_on_previous_text=False,
|
| 36 |
+
initial_prompt=self.prompt,
|
| 37 |
+
)
|
| 38 |
+
else:
|
| 39 |
+
segments, info = self.model.transcribe(
|
| 40 |
+
audio,
|
| 41 |
+
beam_size=5 if self.BEAM_SEARCH else 1,
|
| 42 |
+
language=self.LANG if self.LANG else None,
|
| 43 |
+
condition_on_previous_text=False,
|
| 44 |
+
)
|
| 45 |
+
text = [segment.text for segment in segments]
|
| 46 |
+
|
| 47 |
+
if not text:
|
| 48 |
+
return ""
|
| 49 |
+
else:
|
| 50 |
+
return "".join(text)
|
src/open_llm_vtuber/asr/fun_asr.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import torch
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
from funasr import AutoModel
|
| 8 |
+
from .asr_interface import ASRInterface
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
# Try to import modelscope for local cache detection
|
| 12 |
+
try:
|
| 13 |
+
from modelscope.hub.snapshot_download import snapshot_download
|
| 14 |
+
|
| 15 |
+
MODEL_SCOPE_DOWNLOAD_AVAILABLE = True
|
| 16 |
+
except ImportError:
|
| 17 |
+
print("Warning: Unable to import modelscope.hub.snapshot_download.")
|
| 18 |
+
MODEL_SCOPE_DOWNLOAD_AVAILABLE = False
|
| 19 |
+
|
| 20 |
+
# Model alias to actual ModelScope ID mapping table
|
| 21 |
+
MODEL_ALIAS_TO_FULL_ID_MAP = {
|
| 22 |
+
"paraformer-zh": "iic/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
|
| 23 |
+
"paraformer-zh-spk": "iic/speech_paraformer-large-vad-punc-spk_asr_nat-zh-cn",
|
| 24 |
+
"paraformer-zh-online": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online",
|
| 25 |
+
"paraformer-en": "iic/speech_paraformer-large-vad-punc_asr_nat-en-16k-common-vocab10020",
|
| 26 |
+
"conformer-en": "iic/speech_conformer_asr-en-16k-vocab4199-pytorch",
|
| 27 |
+
"ct-punc": "iic/punc_ct-transformer_cn-en-common-vocab471067-large",
|
| 28 |
+
"fsmn-vad": "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
| 29 |
+
"fa-zh": "iic/speech_timestamp_prediction-v1-16k-offline",
|
| 30 |
+
"SenseVoiceSmall": "iic/SenseVoiceSmall",
|
| 31 |
+
"iic/SenseVoiceSmall": "iic/SenseVoiceSmall",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# paraformer-zh is a multi-functional asr model
|
| 36 |
+
# use vad, punc, spk or not as you need
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class VoiceRecognition(ASRInterface):
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
model_name: str = "iic/SenseVoiceSmall",
|
| 43 |
+
language: str = "auto",
|
| 44 |
+
vad_model: str = "fsmn-vad",
|
| 45 |
+
punc_model: str = "ct-punc",
|
| 46 |
+
ncpu: int = None,
|
| 47 |
+
hub: str = None,
|
| 48 |
+
device: str = "cpu",
|
| 49 |
+
disable_update: bool = True,
|
| 50 |
+
sample_rate: int = 16000,
|
| 51 |
+
use_itn: bool = False,
|
| 52 |
+
) -> None:
|
| 53 |
+
# Resolve model paths
|
| 54 |
+
final_model_input = self._get_final_model_input(model_name)
|
| 55 |
+
final_vad_input = self._get_final_model_input(vad_model) if vad_model else None
|
| 56 |
+
final_punc_input = (
|
| 57 |
+
self._get_final_model_input(punc_model) if punc_model else None
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
self.model = AutoModel(
|
| 61 |
+
model=final_model_input,
|
| 62 |
+
vad_model=final_vad_input,
|
| 63 |
+
ncpu=ncpu,
|
| 64 |
+
hub=hub,
|
| 65 |
+
device=device,
|
| 66 |
+
disable_update=disable_update,
|
| 67 |
+
punc_model=final_punc_input,
|
| 68 |
+
# spk_model="cam++",
|
| 69 |
+
)
|
| 70 |
+
self.SAMPLE_RATE = sample_rate
|
| 71 |
+
self.use_itn = use_itn
|
| 72 |
+
self.language = language
|
| 73 |
+
|
| 74 |
+
def _get_final_model_input(self, alias_or_id: Optional[str]) -> Optional[str]:
|
| 75 |
+
"""
|
| 76 |
+
Process model input function:
|
| 77 |
+
1. Check mapping table to get canonical ModelScope ID.
|
| 78 |
+
2. Try to get local path using snapshot_download.
|
| 79 |
+
3. If local path is valid, return local path, otherwise return canonical ModelScope ID.
|
| 80 |
+
"""
|
| 81 |
+
if not alias_or_id:
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
# Get canonical ModelScope ID from mapping table
|
| 85 |
+
resolved_id = MODEL_ALIAS_TO_FULL_ID_MAP.get(alias_or_id, alias_or_id)
|
| 86 |
+
final_input_for_automodel = resolved_id # Default to use resolved ID
|
| 87 |
+
|
| 88 |
+
# Try to get local path using snapshot_download
|
| 89 |
+
if MODEL_SCOPE_DOWNLOAD_AVAILABLE:
|
| 90 |
+
try:
|
| 91 |
+
local_path = snapshot_download(resolved_id, local_files_only=True)
|
| 92 |
+
if os.path.exists(local_path): # Double check path exists
|
| 93 |
+
final_input_for_automodel = local_path # Use local path if found
|
| 94 |
+
# print(f"Successfully resolved '{resolved_id}' to local path: {local_path}")
|
| 95 |
+
except ValueError:
|
| 96 |
+
# Not found in local cache, use original ID
|
| 97 |
+
pass
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"Error occurred while checking '{resolved_id}': {e}")
|
| 100 |
+
|
| 101 |
+
return final_input_for_automodel
|
| 102 |
+
|
| 103 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 104 |
+
audio_tensor = torch.tensor(audio, dtype=torch.float32)
|
| 105 |
+
|
| 106 |
+
res = self.model.generate(
|
| 107 |
+
input=audio_tensor,
|
| 108 |
+
batch_size_s=300,
|
| 109 |
+
use_itn=self.use_itn,
|
| 110 |
+
language=self.language,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
full_text = res[0]["text"]
|
| 114 |
+
|
| 115 |
+
# SenseVoiceSmall may spits out some tags
|
| 116 |
+
# like this: '<|zh|><|NEUTRAL|><|Speech|><|woitn|>欢迎大家来体验达摩院推出的语音识别模型'
|
| 117 |
+
# we should remove those tags from the result
|
| 118 |
+
|
| 119 |
+
# remove tags
|
| 120 |
+
full_text = re.sub(r"<\|.*?\|>", "", full_text)
|
| 121 |
+
# the tags can also look like '< | en | > < | EMO _ UNKNOWN | > < | S pe ech | > < | wo itn | > ', so...
|
| 122 |
+
full_text = re.sub(r"< \|.*?\| >", "", full_text)
|
| 123 |
+
|
| 124 |
+
return full_text.strip()
|
| 125 |
+
|
| 126 |
+
def _numpy_to_wav_in_memory(self, numpy_array: np.ndarray, sample_rate):
|
| 127 |
+
memory_file = io.BytesIO()
|
| 128 |
+
sf.write(memory_file, numpy_array, sample_rate, format="WAV")
|
| 129 |
+
memory_file.seek(0)
|
| 130 |
+
|
| 131 |
+
return memory_file
|
src/open_llm_vtuber/asr/groq_whisper_asr.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import wave
|
| 3 |
+
import numpy as np
|
| 4 |
+
from loguru import logger
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from .asr_interface import ASRInterface
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class VoiceRecognition(ASRInterface):
|
| 10 |
+
# sample_rate, n_channels, and sampwidth are defined in asr_interface.py
|
| 11 |
+
|
| 12 |
+
def __init__(
|
| 13 |
+
self, api_key: str, model: str = "distil-whisper-large-v3-en", lang: str = "en"
|
| 14 |
+
) -> None:
|
| 15 |
+
logger.info("Initializing Groq ASR...")
|
| 16 |
+
self.client = Groq(api_key=api_key)
|
| 17 |
+
self.lang = lang
|
| 18 |
+
self.model = model
|
| 19 |
+
|
| 20 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 21 |
+
"""Transcribe speech audio in numpy array format and return the transcription.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
audio: The numpy array of the audio data to transcribe.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
logger.info("Transcribing audio (GroqWhisperASR)...")
|
| 28 |
+
|
| 29 |
+
# Turn the audio into an audio file
|
| 30 |
+
# Make sure the audio is in the range [-1, 1]
|
| 31 |
+
audio = np.clip(audio, -1, 1)
|
| 32 |
+
# Convert the audio to 16-bit PCM
|
| 33 |
+
audio_integer = (audio * 32767).astype(np.int16)
|
| 34 |
+
|
| 35 |
+
# groq api requires a file-like object for the audio data, so we use a BytesIO object
|
| 36 |
+
audio_buffer = io.BytesIO()
|
| 37 |
+
|
| 38 |
+
with wave.open(audio_buffer, "wb") as wf:
|
| 39 |
+
wf.setnchannels(self.NUM_CHANNELS)
|
| 40 |
+
wf.setsampwidth(self.SAMPLE_WIDTH)
|
| 41 |
+
wf.setframerate(self.SAMPLE_RATE)
|
| 42 |
+
wf.writeframes(audio_integer.tobytes())
|
| 43 |
+
|
| 44 |
+
audio_buffer.seek(0)
|
| 45 |
+
|
| 46 |
+
# Transcribe the audio with the BytesIO object
|
| 47 |
+
transcription = self.client.audio.transcriptions.create(
|
| 48 |
+
file=("audio.wav", audio_buffer.read()),
|
| 49 |
+
model=self.model,
|
| 50 |
+
# prompt="Specify context or spelling",
|
| 51 |
+
response_format="text",
|
| 52 |
+
language=self.lang,
|
| 53 |
+
temperature=0.0,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return transcription
|
src/open_llm_vtuber/asr/openai_whisper_asr.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import whisper
|
| 3 |
+
from .asr_interface import ASRInterface
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VoiceRecognition(ASRInterface):
|
| 7 |
+
def __init__(
|
| 8 |
+
self,
|
| 9 |
+
name: str = "base",
|
| 10 |
+
download_root: str = None,
|
| 11 |
+
device="cpu",
|
| 12 |
+
prompt: str = None,
|
| 13 |
+
) -> None:
|
| 14 |
+
self.model = whisper.load_model(
|
| 15 |
+
name=name,
|
| 16 |
+
device=device,
|
| 17 |
+
download_root=download_root,
|
| 18 |
+
)
|
| 19 |
+
self.prompt = prompt
|
| 20 |
+
|
| 21 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 22 |
+
if self.prompt is not None:
|
| 23 |
+
result = self.model.transcribe(audio, initial_prompt=self.prompt)
|
| 24 |
+
else:
|
| 25 |
+
result = self.model.transcribe(audio)
|
| 26 |
+
full_text = result["text"]
|
| 27 |
+
return full_text
|
src/open_llm_vtuber/asr/sherpa_onnx_asr.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import sherpa_onnx
|
| 4 |
+
from loguru import logger
|
| 5 |
+
from .asr_interface import ASRInterface
|
| 6 |
+
from .utils import download_and_extract, check_and_extract_local_file
|
| 7 |
+
import onnxruntime
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class VoiceRecognition(ASRInterface):
|
| 11 |
+
def __init__(
|
| 12 |
+
self,
|
| 13 |
+
model_type: str = "paraformer", # or "transducer", "nemo_ctc", "wenet_ctc", "whisper", "tdnn_ctc", "sense_voice", "fire_red_asr"
|
| 14 |
+
encoder: str = None, # Path to the encoder model, used with transducer
|
| 15 |
+
decoder: str = None, # Path to the decoder model, used with transducer
|
| 16 |
+
joiner: str = None, # Path to the joiner model, used with transducer
|
| 17 |
+
paraformer: str = None, # Path to the model.onnx from Paraformer
|
| 18 |
+
nemo_ctc: str = None, # Path to the model.onnx from NeMo CTC
|
| 19 |
+
wenet_ctc: str = None, # Path to the model.onnx from WeNet CTC
|
| 20 |
+
tdnn_model: str = None, # Path to the model.onnx for the tdnn model of the yesno recipe
|
| 21 |
+
whisper_encoder: str = None, # Path to whisper encoder model
|
| 22 |
+
whisper_decoder: str = None, # Path to whisper decoder model
|
| 23 |
+
sense_voice: str = None, # Path to the model.onnx from SenseVoice
|
| 24 |
+
fire_red_asr_encoder: str = None, # Path to FireRedASR encoder model
|
| 25 |
+
fire_red_asr_decoder: str = None, # Path to FireRedASR decoder model
|
| 26 |
+
tokens: str = None, # Path to tokens.txt
|
| 27 |
+
hotwords_file: str = "", # Path to hotwords file
|
| 28 |
+
hotwords_score: float = 1.5, # Hotwords score
|
| 29 |
+
modeling_unit: str = "", # Modeling unit for hotwords
|
| 30 |
+
bpe_vocab: str = "", # Path to bpe vocabulary, used with hotwords
|
| 31 |
+
num_threads: int = 1, # Number of threads for neural network computation
|
| 32 |
+
whisper_language: str = "", # Language for whisper model
|
| 33 |
+
whisper_task: str = "transcribe", # Task for whisper model (transcribe or translate)
|
| 34 |
+
whisper_tail_paddings: int = -1, # Tail padding frames for whisper model
|
| 35 |
+
blank_penalty: float = 0.0, # Penalty for blank symbol
|
| 36 |
+
decoding_method: str = "greedy_search", # Decoding method (greedy_search or modified_beam_search)
|
| 37 |
+
debug: bool = False, # Show debug messages
|
| 38 |
+
sample_rate: int = 16000, # Sample rate
|
| 39 |
+
feature_dim: int = 80, # Feature dimension
|
| 40 |
+
use_itn: bool = True, # Use ITN for SenseVoice models
|
| 41 |
+
provider: str = "cpu", # Provider for inference (cpu or cuda)
|
| 42 |
+
) -> None:
|
| 43 |
+
self.model_type = model_type
|
| 44 |
+
self.encoder = encoder
|
| 45 |
+
self.decoder = decoder
|
| 46 |
+
self.joiner = joiner
|
| 47 |
+
self.paraformer = paraformer
|
| 48 |
+
self.nemo_ctc = nemo_ctc
|
| 49 |
+
self.wenet_ctc = wenet_ctc
|
| 50 |
+
self.tdnn_model = tdnn_model
|
| 51 |
+
self.whisper_encoder = whisper_encoder
|
| 52 |
+
self.whisper_decoder = whisper_decoder
|
| 53 |
+
self.sense_voice: str = sense_voice
|
| 54 |
+
self.fire_red_asr_encoder = fire_red_asr_encoder
|
| 55 |
+
self.fire_red_asr_decoder = fire_red_asr_decoder
|
| 56 |
+
self.tokens = tokens
|
| 57 |
+
self.hotwords_file = hotwords_file
|
| 58 |
+
self.hotwords_score = hotwords_score
|
| 59 |
+
self.modeling_unit = modeling_unit
|
| 60 |
+
self.bpe_vocab = bpe_vocab
|
| 61 |
+
self.num_threads = num_threads
|
| 62 |
+
self.whisper_language = whisper_language
|
| 63 |
+
self.whisper_task = whisper_task
|
| 64 |
+
self.whisper_tail_paddings = whisper_tail_paddings
|
| 65 |
+
self.blank_penalty = blank_penalty
|
| 66 |
+
self.decoding_method = decoding_method
|
| 67 |
+
self.debug = debug
|
| 68 |
+
self.SAMPLE_RATE = sample_rate
|
| 69 |
+
self.feature_dim = feature_dim
|
| 70 |
+
self.use_itn = use_itn
|
| 71 |
+
|
| 72 |
+
# we need to find a way to get cuda version of sherpa-onnx before we can
|
| 73 |
+
# use the gpu provider.
|
| 74 |
+
self.provider = provider
|
| 75 |
+
if self.provider == "cuda":
|
| 76 |
+
try:
|
| 77 |
+
if "CUDAExecutionProvider" not in onnxruntime.get_available_providers():
|
| 78 |
+
logger.warning(
|
| 79 |
+
"CUDA provider not available for ONNX. Falling back to CPU."
|
| 80 |
+
)
|
| 81 |
+
self.provider = "cpu"
|
| 82 |
+
except ImportError:
|
| 83 |
+
logger.warning("ONNX Runtime not installed. Falling back to CPU.")
|
| 84 |
+
self.provider = "cpu"
|
| 85 |
+
logger.info(f"Sherpa-Onnx-ASR: Using {self.provider} for inference")
|
| 86 |
+
|
| 87 |
+
self.recognizer = self._create_recognizer()
|
| 88 |
+
|
| 89 |
+
def _create_recognizer(self):
|
| 90 |
+
if self.model_type == "transducer":
|
| 91 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_transducer(
|
| 92 |
+
encoder=self.encoder,
|
| 93 |
+
decoder=self.decoder,
|
| 94 |
+
joiner=self.joiner,
|
| 95 |
+
tokens=self.tokens,
|
| 96 |
+
num_threads=self.num_threads,
|
| 97 |
+
sample_rate=self.SAMPLE_RATE,
|
| 98 |
+
feature_dim=self.feature_dim,
|
| 99 |
+
decoding_method=self.decoding_method,
|
| 100 |
+
hotwords_file=self.hotwords_file,
|
| 101 |
+
hotwords_score=self.hotwords_score,
|
| 102 |
+
modeling_unit=self.modeling_unit,
|
| 103 |
+
bpe_vocab=self.bpe_vocab,
|
| 104 |
+
blank_penalty=self.blank_penalty,
|
| 105 |
+
debug=self.debug,
|
| 106 |
+
provider=self.provider,
|
| 107 |
+
)
|
| 108 |
+
elif self.model_type == "paraformer":
|
| 109 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_paraformer(
|
| 110 |
+
paraformer=self.paraformer,
|
| 111 |
+
tokens=self.tokens,
|
| 112 |
+
num_threads=self.num_threads,
|
| 113 |
+
sample_rate=self.SAMPLE_RATE,
|
| 114 |
+
feature_dim=self.feature_dim,
|
| 115 |
+
decoding_method=self.decoding_method,
|
| 116 |
+
debug=self.debug,
|
| 117 |
+
provider=self.provider,
|
| 118 |
+
)
|
| 119 |
+
elif self.model_type == "nemo_ctc":
|
| 120 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_nemo_ctc(
|
| 121 |
+
model=self.nemo_ctc,
|
| 122 |
+
tokens=self.tokens,
|
| 123 |
+
num_threads=self.num_threads,
|
| 124 |
+
sample_rate=self.SAMPLE_RATE,
|
| 125 |
+
feature_dim=self.feature_dim,
|
| 126 |
+
decoding_method=self.decoding_method,
|
| 127 |
+
debug=self.debug,
|
| 128 |
+
provider=self.provider,
|
| 129 |
+
)
|
| 130 |
+
elif self.model_type == "wenet_ctc":
|
| 131 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_wenet_ctc(
|
| 132 |
+
model=self.wenet_ctc,
|
| 133 |
+
tokens=self.tokens,
|
| 134 |
+
num_threads=self.num_threads,
|
| 135 |
+
sample_rate=self.SAMPLE_RATE,
|
| 136 |
+
feature_dim=self.feature_dim,
|
| 137 |
+
decoding_method=self.decoding_method,
|
| 138 |
+
debug=self.debug,
|
| 139 |
+
provider=self.provider,
|
| 140 |
+
)
|
| 141 |
+
elif self.model_type == "whisper":
|
| 142 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_whisper(
|
| 143 |
+
encoder=self.whisper_encoder,
|
| 144 |
+
decoder=self.whisper_decoder,
|
| 145 |
+
tokens=self.tokens,
|
| 146 |
+
num_threads=self.num_threads,
|
| 147 |
+
decoding_method=self.decoding_method,
|
| 148 |
+
debug=self.debug,
|
| 149 |
+
language=self.whisper_language,
|
| 150 |
+
task=self.whisper_task,
|
| 151 |
+
tail_paddings=self.whisper_tail_paddings,
|
| 152 |
+
provider=self.provider,
|
| 153 |
+
)
|
| 154 |
+
elif self.model_type == "tdnn_ctc":
|
| 155 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_tdnn_ctc(
|
| 156 |
+
model=self.tdnn_model,
|
| 157 |
+
tokens=self.tokens,
|
| 158 |
+
sample_rate=self.SAMPLE_RATE,
|
| 159 |
+
feature_dim=self.feature_dim,
|
| 160 |
+
num_threads=self.num_threads,
|
| 161 |
+
decoding_method=self.decoding_method,
|
| 162 |
+
debug=self.debug,
|
| 163 |
+
provider=self.provider,
|
| 164 |
+
)
|
| 165 |
+
elif self.model_type == "sense_voice":
|
| 166 |
+
if not self.sense_voice or not os.path.isfile(self.sense_voice):
|
| 167 |
+
if self.sense_voice.startswith(
|
| 168 |
+
"./models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
|
| 169 |
+
):
|
| 170 |
+
logger.warning(
|
| 171 |
+
"SenseVoice model not found. Downloading the model..."
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2"
|
| 175 |
+
output_dir = "./models"
|
| 176 |
+
# check the local file first before download
|
| 177 |
+
local_result = check_and_extract_local_file(url, output_dir)
|
| 178 |
+
|
| 179 |
+
if local_result is None:
|
| 180 |
+
logger.info("Local file not found. Downloading...")
|
| 181 |
+
download_and_extract(url, output_dir)
|
| 182 |
+
else:
|
| 183 |
+
logger.info("Local file found. Using existing file.")
|
| 184 |
+
# download_and_extract(
|
| 185 |
+
# url="https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2",
|
| 186 |
+
# output_dir="./models",
|
| 187 |
+
# )
|
| 188 |
+
else:
|
| 189 |
+
logger.critical(
|
| 190 |
+
"The SenseVoice model is missing. Please provide the path to the model.onnx file."
|
| 191 |
+
)
|
| 192 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
|
| 193 |
+
model=self.sense_voice,
|
| 194 |
+
tokens=self.tokens,
|
| 195 |
+
num_threads=self.num_threads,
|
| 196 |
+
use_itn=self.use_itn,
|
| 197 |
+
debug=self.debug,
|
| 198 |
+
provider=self.provider,
|
| 199 |
+
)
|
| 200 |
+
elif self.model_type == "fire_red_asr":
|
| 201 |
+
recognizer = sherpa_onnx.OfflineRecognizer.from_fire_red_asr(
|
| 202 |
+
encoder=self.fire_red_asr_encoder,
|
| 203 |
+
decoder=self.fire_red_asr_decoder,
|
| 204 |
+
tokens=self.tokens,
|
| 205 |
+
num_threads=self.num_threads,
|
| 206 |
+
decoding_method=self.decoding_method,
|
| 207 |
+
debug=self.debug,
|
| 208 |
+
provider=self.provider,
|
| 209 |
+
)
|
| 210 |
+
else:
|
| 211 |
+
raise ValueError(f"Invalid model type: {self.model_type}")
|
| 212 |
+
|
| 213 |
+
return recognizer
|
| 214 |
+
|
| 215 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 216 |
+
stream = self.recognizer.create_stream()
|
| 217 |
+
stream.accept_waveform(self.SAMPLE_RATE, audio)
|
| 218 |
+
self.recognizer.decode_streams([stream])
|
| 219 |
+
return stream.result.text
|
src/open_llm_vtuber/asr/utils.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
import tarfile
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_github_asset_url(owner, repo, release_tag, filename_without_ext):
|
| 10 |
+
"""
|
| 11 |
+
Fetch the URL of a GitHub release asset by its filename (without extension).
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
owner (str): The owner of the repository.
|
| 15 |
+
repo (str): The name of the repository.
|
| 16 |
+
release_tag (str): The tag of the release.
|
| 17 |
+
filename_without_ext (str): The filename to search for (without extension).
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
str: The download URL of the matched asset, or None if no match is found.
|
| 21 |
+
"""
|
| 22 |
+
url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{release_tag}"
|
| 23 |
+
headers = {} # Add authentication headers if needed
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
# Make a GET request to fetch release data
|
| 27 |
+
response = requests.get(url, headers=headers)
|
| 28 |
+
response.raise_for_status()
|
| 29 |
+
|
| 30 |
+
# Parse the JSON response
|
| 31 |
+
release_data = response.json()
|
| 32 |
+
assets = release_data.get("assets", [])
|
| 33 |
+
|
| 34 |
+
# Look for a matching file
|
| 35 |
+
for asset in assets:
|
| 36 |
+
if asset["name"].startswith(filename_without_ext):
|
| 37 |
+
logger.info(f"Match found: {asset['name']}")
|
| 38 |
+
return asset["browser_download_url"]
|
| 39 |
+
|
| 40 |
+
# If no match found, log the error
|
| 41 |
+
logger.error(
|
| 42 |
+
f"No match found for filename: {filename_without_ext} in release {release_tag}."
|
| 43 |
+
)
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
except requests.exceptions.RequestException as e:
|
| 47 |
+
logger.error(f"An error occurred while fetching release data: {e}")
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def download_and_extract(url: str, output_dir: str) -> Path:
|
| 52 |
+
"""
|
| 53 |
+
Download a file from a URL and extract it if it is a tar.bz2 archive.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
url (str): The URL to download the file from.
|
| 57 |
+
output_dir (str): The directory to save the downloaded file.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
Path: Path to the extracted directory if it's a tar.bz2 file,
|
| 61 |
+
otherwise Path to the downloaded file.
|
| 62 |
+
"""
|
| 63 |
+
# Create the output directory if it doesn't exist
|
| 64 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 65 |
+
|
| 66 |
+
# Get the file name from the URL
|
| 67 |
+
file_name = url.split("/")[-1]
|
| 68 |
+
file_path = os.path.join(output_dir, file_name)
|
| 69 |
+
|
| 70 |
+
# Extract the root directory name from the filename (removing .tar.bz2)
|
| 71 |
+
root_dir = file_name.replace(".tar.bz2", "")
|
| 72 |
+
extracted_dir_path = Path(output_dir) / root_dir
|
| 73 |
+
|
| 74 |
+
# Check if the extracted directory already exists
|
| 75 |
+
if extracted_dir_path.exists():
|
| 76 |
+
logger.info(
|
| 77 |
+
f"✅ The directory {extracted_dir_path} already exists. I would assume that the model is already downloaded and we are ready to go. Skipping download and extraction."
|
| 78 |
+
)
|
| 79 |
+
return extracted_dir_path
|
| 80 |
+
|
| 81 |
+
# Download the file
|
| 82 |
+
logger.info(f"🏃♂️Downloading {url} to {file_path}...")
|
| 83 |
+
response = requests.get(url, stream=True)
|
| 84 |
+
response.raise_for_status() # Raise an error for bad status codes
|
| 85 |
+
total_size = int(response.headers.get("content-length", 0))
|
| 86 |
+
logger.debug(f"Total file size: {total_size / 1024 / 1024:.2f} MB")
|
| 87 |
+
|
| 88 |
+
with (
|
| 89 |
+
open(file_path, "wb") as f,
|
| 90 |
+
tqdm(
|
| 91 |
+
desc=file_name,
|
| 92 |
+
total=total_size,
|
| 93 |
+
unit="iB",
|
| 94 |
+
unit_scale=True,
|
| 95 |
+
unit_divisor=1024,
|
| 96 |
+
) as pbar,
|
| 97 |
+
):
|
| 98 |
+
for chunk in response.iter_content(chunk_size=8192):
|
| 99 |
+
size = f.write(chunk)
|
| 100 |
+
pbar.update(size)
|
| 101 |
+
|
| 102 |
+
logger.info(f"Downloaded {file_name} successfully.")
|
| 103 |
+
|
| 104 |
+
# Extract the tar.bz2 file
|
| 105 |
+
if file_name.endswith(".tar.bz2"):
|
| 106 |
+
logger.info(f"Extracting {file_name}...")
|
| 107 |
+
with tarfile.open(file_path, "r:bz2") as tar:
|
| 108 |
+
tar.extractall(path=output_dir)
|
| 109 |
+
logger.info("Extraction completed.")
|
| 110 |
+
|
| 111 |
+
# Delete the compressed file
|
| 112 |
+
os.remove(file_path)
|
| 113 |
+
logger.debug(f"Deleted the compressed file: {file_name}")
|
| 114 |
+
|
| 115 |
+
return extracted_dir_path
|
| 116 |
+
else:
|
| 117 |
+
logger.warning("The downloaded file is not a tar.bz2 archive.")
|
| 118 |
+
return Path(file_path)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def check_and_extract_local_file(url: str, output_dir: str) -> Path | None:
|
| 122 |
+
"""
|
| 123 |
+
Check if a local file exists and extract it if it is a tar.bz2 archive.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
url (str): The URL of the file.
|
| 127 |
+
output_dir (str): The directory to save the extracted files.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Path | None: Path to the extracted directory if it's a tar.bz2 file,
|
| 131 |
+
otherwise None.
|
| 132 |
+
"""
|
| 133 |
+
# Get the file name from the URL
|
| 134 |
+
file_name = url.split("/")[-1]
|
| 135 |
+
compressed_path = Path(output_dir) / file_name
|
| 136 |
+
|
| 137 |
+
# Check if the compressed file exists and is a tar.bz2 archive
|
| 138 |
+
extracted_dir = Path(output_dir) / file_name.replace(".tar.bz2", "")
|
| 139 |
+
|
| 140 |
+
if extracted_dir.exists():
|
| 141 |
+
logger.info(
|
| 142 |
+
f"✅ Extracted directory exists: {extracted_dir}, no operation needed."
|
| 143 |
+
)
|
| 144 |
+
return extracted_dir
|
| 145 |
+
|
| 146 |
+
if compressed_path.exists() and file_name.endswith(".tar.bz2"):
|
| 147 |
+
logger.info(f"🔍 Found local archive file: {compressed_path}")
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
logger.info("⏳ Extracting archive file...")
|
| 151 |
+
with tarfile.open(compressed_path, "r:bz2") as tar:
|
| 152 |
+
tar.extractall(path=output_dir)
|
| 153 |
+
logger.success(f"Extracted archive to the path: {extracted_dir}")
|
| 154 |
+
os.remove(compressed_path) # Remove the compressed file
|
| 155 |
+
return extracted_dir
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Fail to extract file: {str(e)}")
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
logger.warning(f"Local file not found or not a tar.bz2 archive: {compressed_path}")
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2"
|
| 166 |
+
output_dir = "./models"
|
| 167 |
+
|
| 168 |
+
# Try local extraction first.
|
| 169 |
+
local_result = check_and_extract_local_file(url, output_dir)
|
| 170 |
+
|
| 171 |
+
# Download if not available locally.
|
| 172 |
+
if local_result is None:
|
| 173 |
+
logger.info("Local archive not found. Starting download...")
|
| 174 |
+
download_and_extract(url, output_dir)
|
| 175 |
+
else:
|
| 176 |
+
logger.info("Extraction completed using local file.")
|
src/open_llm_vtuber/asr/whisper_cpp_asr.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pywhispercpp.model import Model
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from loguru import logger
|
| 5 |
+
from .asr_interface import ASRInterface
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class VoiceRecognition(ASRInterface):
|
| 9 |
+
def __init__(
|
| 10 |
+
self,
|
| 11 |
+
model_name: str = "base",
|
| 12 |
+
model_dir="asr/models",
|
| 13 |
+
language: str = "en",
|
| 14 |
+
print_realtime=False,
|
| 15 |
+
print_progress=False,
|
| 16 |
+
prompt: str = None,
|
| 17 |
+
) -> None:
|
| 18 |
+
self.model = Model(
|
| 19 |
+
model=model_name,
|
| 20 |
+
models_dir=model_dir,
|
| 21 |
+
language=language,
|
| 22 |
+
print_realtime=print_realtime,
|
| 23 |
+
print_progress=print_progress,
|
| 24 |
+
)
|
| 25 |
+
self.prompt = prompt
|
| 26 |
+
|
| 27 |
+
def transcribe_np(self, audio: np.ndarray) -> str:
|
| 28 |
+
if self.prompt is not None:
|
| 29 |
+
segments = self.model.transcribe(
|
| 30 |
+
audio, new_segment_callback=logger.info, initial_prompt=self.prompt
|
| 31 |
+
)
|
| 32 |
+
else:
|
| 33 |
+
segments = self.model.transcribe(audio, new_segment_callback=logger.info)
|
| 34 |
+
full_text = ""
|
| 35 |
+
for segment in segments:
|
| 36 |
+
full_text += segment.text
|
| 37 |
+
return full_text
|
src/open_llm_vtuber/audio_manager.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import uuid
|
| 3 |
+
import unicodedata
|
| 4 |
+
|
| 5 |
+
class AudioManager:
|
| 6 |
+
def __init__(self, tts, live2d, translator, config, verbose=False):
|
| 7 |
+
self.tts = tts
|
| 8 |
+
self.live2d = live2d
|
| 9 |
+
self.translator = translator
|
| 10 |
+
self.config = config
|
| 11 |
+
self.verbose = verbose
|
| 12 |
+
self.remove_special_char = config.get("REMOVE_SPECIAL_CHAR", True)
|
| 13 |
+
|
| 14 |
+
def clean_text(self, text: str) -> str:
|
| 15 |
+
text = re.sub(r'[^\u4e00-\u9fffA-Za-z0-9,]', ' ', text)
|
| 16 |
+
|
| 17 |
+
if self.remove_special_char:
|
| 18 |
+
text = self.remove_special_characters(text)
|
| 19 |
+
|
| 20 |
+
return text
|
| 21 |
+
|
| 22 |
+
def remove_special_characters(self, text: str) -> str:
|
| 23 |
+
"""Filter text to remove all non-letter, non-number, and non-punctuation characters."""
|
| 24 |
+
normalized_text = unicodedata.normalize("NFKC", text)
|
| 25 |
+
|
| 26 |
+
def is_valid_char(char: str) -> bool:
|
| 27 |
+
category = unicodedata.category(char)
|
| 28 |
+
return (
|
| 29 |
+
category.startswith("L")
|
| 30 |
+
or category.startswith("N")
|
| 31 |
+
or category.startswith("P")
|
| 32 |
+
or char.isspace()
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
filtered_text = "".join(char for char in normalized_text if is_valid_char(char))
|
| 36 |
+
return filtered_text
|
| 37 |
+
|
| 38 |
+
def generate_audio_file(self, sentence: str, file_name_no_ext: str) -> str | None:
|
| 39 |
+
"""
|
| 40 |
+
Generate an audio file from a given sentence using the TTS engine.
|
| 41 |
+
|
| 42 |
+
Parameters:
|
| 43 |
+
- sentence (str): The sentence to generate audio for
|
| 44 |
+
- file_name_no_ext (str): The name of the audio file (without extension)
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
- str or None: The path of the generated audio file, or None if the sentence iempty
|
| 48 |
+
"""
|
| 49 |
+
sentence = self.clean_text(sentence)
|
| 50 |
+
if self.verbose:
|
| 51 |
+
print(f">> generating {file_name_no_ext}...")
|
| 52 |
+
|
| 53 |
+
if not self.tts:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
if self.live2d:
|
| 57 |
+
sentence = self.live2d.remove_emotion_keywords(sentence)
|
| 58 |
+
|
| 59 |
+
if sentence.strip() == "":
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
return self.tts.generate_audio(sentence, file_name_no_ext=file_name_no_ext)
|
| 63 |
+
|
| 64 |
+
def play_audio_file(self, sentence: str | None, filepath: str | None, instrument_filepath: str | None = None) -> None:
|
| 65 |
+
"""
|
| 66 |
+
Play the audio file located at the given filepath.
|
| 67 |
+
"""
|
| 68 |
+
if filepath is None:
|
| 69 |
+
print("No audio to be streamed. Response is empty.")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
if sentence is None:
|
| 73 |
+
sentence = ""
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
if self.verbose:
|
| 77 |
+
print(f">> Playing {filepath}...")
|
| 78 |
+
self.tts.play_audio_file_local(filepath)
|
| 79 |
+
|
| 80 |
+
self.tts.remove_file(filepath, verbose=self.verbose)
|
| 81 |
+
except ValueError as e:
|
| 82 |
+
if str(e) == "Audio is empty or all zero.":
|
| 83 |
+
print("No audio to be streamed. Response is empty.")
|
| 84 |
+
else:
|
| 85 |
+
raise e
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error playing the audio file {filepath}: {e}")
|
| 88 |
+
|
| 89 |
+
def play_text(self, text: str) -> None:
|
| 90 |
+
if not text.strip():
|
| 91 |
+
print("No text to play.")
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
sentences = re.split(r'(?<=[.!?。!?])\s*', text)
|
| 95 |
+
sentences = [s for s in sentences if s.strip()]
|
| 96 |
+
|
| 97 |
+
for sentence in sentences:
|
| 98 |
+
tts_target_sentence = self.live2d.remove_emotion_keywords(sentence)
|
| 99 |
+
|
| 100 |
+
if self.translator and self.config.get("TRANSLATE_AUDIO", False):
|
| 101 |
+
print("Translating...")
|
| 102 |
+
tts_target_sentence = self.translator.translate (tts_target_sentence)
|
| 103 |
+
print(f"Translated: {tts_target_sentence}")
|
| 104 |
+
|
| 105 |
+
audio_filepath = self.generate_audio_file(
|
| 106 |
+
tts_target_sentence, file_name_no_ext=f"temp_text_{uuid.uuid4()}"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if audio_filepath:
|
| 110 |
+
self.play_audio_file(sentence=sentence, filepath=audio_filepath)
|
| 111 |
+
else:
|
| 112 |
+
print("No audio generated for sentence.")
|
| 113 |
+
|
| 114 |
+
|