a920e45f96
* feat: astr live
* chore: remove
* feat: metrics
* feat: enhance audio processing and metrics display in live mode
* feat: genie tts
* feat: enhance live mode audio processing and text handling
* feat: add metrics
* feat: eyes
* feat: nervous
* chore: update readme
Added '自动压缩对话' feature and updated features list.
* feat: skip saving head system messages in history (#4538)
* feat: skip saving the first system message in history
* fix: rename variable for clarity in system message handling
* fix: update logic to skip all system messages until the first non-system message
* fix: clarify logic for skipping initial system messages in conversation
* chore: bump version to 4.12.2
* docs: update 4.12.2 changelog
* refactor: update event types for LLM tool usage and response
* chore: bump version to 4.12.3
* fix: ensure embedding dimensions are returned as integers in providers (#4547)
* fix: ensure embedding dimensions are returned as integers in providers
* chore: ruff format
* perf: T2I template editor preview (#4574)
* feat: add file drag upload feature for ChatUI (#4583)
* feat(chat): add drag-drop upload and fix batch file upload
* style(chat): adjust drop overlay to only cover input container
* fix: streaming response for DingTalk (#4590)
closes: #4384
* #4384 钉钉消息回复卡片模板
* chore: ruff format
* chore: ruff format
---------
Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Soulter <905617992@qq.com>
* feat: implement persona folder for advanced persona management (#4443)
* feat(db): add persona folder management for hierarchical organization
Implement hierarchical folder structure for organizing personas:
- Add PersonaFolder model with recursive parent-child relationships
- Add folder_id and sort_order fields to Persona model
- Implement CRUD operations for persona folders in database layer
- Add migration support for existing databases
- Extend PersonaManager with folder management methods
- Add dashboard API routes for folder operations
* feat(persona): add batch sort order update endpoint for personas and folders
Add new API endpoint POST /persona/reorder to batch update sort_order
for both personas and folders. This enables drag-and-drop reordering
in the dashboard UI.
Changes:
- Add abstract batch_update_sort_order method to BaseDatabase
- Implement batch_update_sort_order in SQLiteDatabase
- Add batch_update_sort_order to PersonaManager with cache refresh
- Add reorder_items route handler with input validation
* feat(persona): add folder_id and sort_order params to persona creation
Extend persona creation flow to support folder placement and ordering:
- Add folder_id and sort_order parameters to insert_persona in db layer
- Update PersonaManager.create_persona to accept and pass folder params
- Add get_folder_detail API endpoint for retrieving folder information
- Include folder_id and sort_order in persona creation response
- Add session flush/refresh to return complete persona object
* feat(dashboard): implement persona folder management UI
- Add folder management system with tree view and breadcrumbs
- Implement create, rename, delete, and move operations for folders
- Add drag-and-drop support for organizing personas and folders
- Create new PersonaManager component and Pinia store for state management
- Refactor PersonaPage to support hierarchical structure
- Update locale files with folder-related translations
- Handle empty parent_id correctly in backend route
* feat(dashboard): centralize folder expansion state in persona store
Move folder expansion logic from local component state to global Pinia
store to persist expansion state.
- Add `expandedFolderIds` state and toggle actions to `personaStore`
- Update `FolderTreeNode` to use store state instead of local data
- Automatically navigate to target folder after moving a persona
* feat(dashboard): add reusable folder management component library
Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.
- Add base folder components (tree, breadcrumb, card, dialogs) with
customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage
* refactor(dashboard): remove legacy persona folder management components
Remove deprecated persona folder management Vue components that have been
superseded by the new reusable folder management component library.
Deleted components:
- CreateFolderDialog.vue
- FolderBreadcrumb.vue
- FolderCard.vue
- FolderTree.vue
- FolderTreeNode.vue
- MoveTargetNode.vue
- MoveToFolderDialog.vue
- PersonaCard.vue
- PersonaManager.vue
These components are replaced by the centralized folder management
implementation introduced in commit 3fbb3db2.
* fix(dashboard): add delayed skeleton loading to prevent UI flicker
Implement a 150ms delay before showing the skeleton loader in
PersonaManager to prevent visual flicker during fast loading operations.
- Add showSkeleton state with timer-based delay mechanism
- Use v-fade-transition for smooth skeleton visibility transitions
- Clean up timer on component unmount to prevent memory leaks
- Only display skeleton when loading exceeds threshold duration
* feat(dashboard): add generic folder item selector component for persona selection
Introduce BaseFolderItemSelector.vue as a reusable component for selecting
items within folder hierarchies. Refactor PersonaSelector to use this new
base component instead of its previous flat list implementation.
Changes:
- Add BaseFolderItemSelector with folder tree navigation and item selection
- Extend folder types with SelectableItem and FolderItemSelectorLabels
- Refactor PersonaSelector to leverage the new base component
- Add i18n translations for rootFolder and emptyFolder labels
* feat(persona): add tree-view display for persona list command
Add hierarchical folder tree output for the persona list command,
showing personas organized by folders with visual tree connectors.
- Add _build_tree_output method for recursive tree structure rendering
- Display folders with 📁 icon and personas with 👤 icon
- Show root-level personas separately from folder contents
- Include total persona count in output
* refactor(persona): simplify tree-view output with shorter indentation lines
Replace complex tree connector logic with simpler depth-based indentation
using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and
computed variables (has_content, total_items, item_idx) in favor of a
cleaner depth-based approach.
* feat(dashboard): add duplicate persona ID validation in create form
Add frontend validation to prevent creating personas with duplicate IDs.
Load existing persona IDs when opening the create form and validate
against them in real-time.
- Add existingPersonaIds array and loadExistingPersonaIds method
- Add validation rule to check for duplicate persona IDs
- Add i18n messages for duplicate ID error (en-US and zh-CN)
- Fix minLength validation to require at least 1 character
* i18n(persona): add createButton translation key for folder dialog
Move create button label to folder-specific translation path
instead of using generic buttons.create key.
* feat(persona): show target folder name in persona creation dialog
Add visual feedback showing which folder a new persona will be created in.
- Add info alert in PersonaForm displaying the target folder name
- Pass currentFolderName prop from PersonaManager and PersonaSelector
- Add recursive findFolderName helper to resolve folder ID to name
- Add i18n translations for createInFolder and rootFolder labels
* style:format code
* fix: remove 'persistent' attribute from dialog components
---------
Co-authored-by: Soulter <905617992@qq.com>
* perf: live mode entry
* chore: remove japanese prompt
---------
Co-authored-by: Anima-IGCenter <cacheigcrystal2@gmail.com>
Co-authored-by: Clhikari <Clhikari@qq.com>
Co-authored-by: jiangman202506 <jiangman202506@163.com>
Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Ruochen Pan <67079377+RC-CHN@users.noreply.github.com>
399 lines
14 KiB
Python
399 lines
14 KiB
Python
import abc
|
|
import asyncio
|
|
import os
|
|
from collections.abc import AsyncGenerator
|
|
from typing import TypeAlias, Union
|
|
|
|
from astrbot.core.agent.message import ContentPart, Message
|
|
from astrbot.core.agent.tool import ToolSet
|
|
from astrbot.core.provider.entities import (
|
|
LLMResponse,
|
|
ProviderMeta,
|
|
RerankResult,
|
|
ToolCallsResult,
|
|
)
|
|
from astrbot.core.provider.register import provider_cls_map
|
|
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
|
|
|
Providers: TypeAlias = Union[
|
|
"Provider",
|
|
"STTProvider",
|
|
"TTSProvider",
|
|
"EmbeddingProvider",
|
|
"RerankProvider",
|
|
]
|
|
|
|
|
|
class AbstractProvider(abc.ABC):
|
|
"""Provider Abstract Class"""
|
|
|
|
def __init__(self, provider_config: dict) -> None:
|
|
super().__init__()
|
|
self.model_name = ""
|
|
self.provider_config = provider_config
|
|
|
|
def set_model(self, model_name: str):
|
|
"""Set the current model name"""
|
|
self.model_name = model_name
|
|
|
|
def get_model(self) -> str:
|
|
"""Get the current model name"""
|
|
return self.model_name
|
|
|
|
def meta(self) -> ProviderMeta:
|
|
"""Get the provider metadata"""
|
|
provider_type_name = self.provider_config["type"]
|
|
meta_data = provider_cls_map.get(provider_type_name)
|
|
if not meta_data:
|
|
raise ValueError(f"Provider type {provider_type_name} not registered")
|
|
meta = ProviderMeta(
|
|
id=self.provider_config.get("id", "default"),
|
|
model=self.get_model(),
|
|
type=provider_type_name,
|
|
provider_type=meta_data.provider_type,
|
|
)
|
|
return meta
|
|
|
|
async def test(self):
|
|
"""test the provider is a
|
|
|
|
raises:
|
|
Exception: if the provider is not available
|
|
"""
|
|
...
|
|
|
|
|
|
class Provider(AbstractProvider):
|
|
"""Chat Provider"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider_config: dict,
|
|
provider_settings: dict,
|
|
) -> None:
|
|
super().__init__(provider_config)
|
|
self.provider_settings = provider_settings
|
|
|
|
@abc.abstractmethod
|
|
def get_current_key(self) -> str:
|
|
raise NotImplementedError
|
|
|
|
def get_keys(self) -> list[str]:
|
|
"""获得提供商 Key"""
|
|
keys = self.provider_config.get("key", [""])
|
|
return keys or [""]
|
|
|
|
@abc.abstractmethod
|
|
def set_key(self, key: str):
|
|
raise NotImplementedError
|
|
|
|
@abc.abstractmethod
|
|
async def get_models(self) -> list[str]:
|
|
"""获得支持的模型列表"""
|
|
raise NotImplementedError
|
|
|
|
@abc.abstractmethod
|
|
async def text_chat(
|
|
self,
|
|
prompt: str | None = None,
|
|
session_id: str | None = None,
|
|
image_urls: list[str] | None = None,
|
|
func_tool: ToolSet | None = None,
|
|
contexts: list[Message] | list[dict] | None = None,
|
|
system_prompt: str | None = None,
|
|
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
|
model: str | None = None,
|
|
extra_user_content_parts: list[ContentPart] | None = None,
|
|
**kwargs,
|
|
) -> LLMResponse:
|
|
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
|
|
|
|
Args:
|
|
prompt: 提示词,和 contexts 二选一使用,如果都指定,则会将 prompt(以及可能的 image_urls) 作为最新的一条记录添加到 contexts 中
|
|
session_id: 会话 ID(此属性已经被废弃)
|
|
image_urls: 图片 URL 列表
|
|
tools: tool set
|
|
contexts: 上下文,和 prompt 二选一使用
|
|
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
|
extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
|
|
kwargs: 其他参数
|
|
|
|
Notes:
|
|
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
|
|
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
|
|
|
|
"""
|
|
...
|
|
|
|
async def text_chat_stream(
|
|
self,
|
|
prompt: str | None = None,
|
|
session_id: str | None = None,
|
|
image_urls: list[str] | None = None,
|
|
func_tool: ToolSet | None = None,
|
|
contexts: list[Message] | list[dict] | None = None,
|
|
system_prompt: str | None = None,
|
|
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
|
model: str | None = None,
|
|
**kwargs,
|
|
) -> AsyncGenerator[LLMResponse, None]:
|
|
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
|
|
|
|
Args:
|
|
prompt: 提示词,和 contexts 二选一使用,如果都指定,则会将 prompt(以及可能的 image_urls) 作为最新的一条记录添加到 contexts 中
|
|
session_id: 会话 ID(此属性已经被废弃)
|
|
image_urls: 图片 URL 列表
|
|
tools: tool set
|
|
contexts: 上下文,和 prompt 二选一使用
|
|
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
|
kwargs: 其他参数
|
|
|
|
Notes:
|
|
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
|
|
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
|
|
|
|
"""
|
|
if False: # pragma: no cover - make this an async generator for typing
|
|
yield None # type: ignore
|
|
raise NotImplementedError()
|
|
|
|
async def pop_record(self, context: list):
|
|
"""弹出 context 第一条非系统提示词对话记录"""
|
|
poped = 0
|
|
indexs_to_pop = []
|
|
for idx, record in enumerate(context):
|
|
if record["role"] == "system":
|
|
continue
|
|
indexs_to_pop.append(idx)
|
|
poped += 1
|
|
if poped == 2:
|
|
break
|
|
|
|
for idx in reversed(indexs_to_pop):
|
|
context.pop(idx)
|
|
|
|
def _ensure_message_to_dicts(
|
|
self,
|
|
messages: list[dict] | list[Message] | None,
|
|
) -> list[dict]:
|
|
"""Convert a list of Message objects to a list of dictionaries."""
|
|
if not messages:
|
|
return []
|
|
dicts: list[dict] = []
|
|
for message in messages:
|
|
if isinstance(message, Message):
|
|
dicts.append(message.model_dump())
|
|
else:
|
|
dicts.append(message)
|
|
|
|
return dicts
|
|
|
|
async def test(self, timeout: float = 45.0):
|
|
await asyncio.wait_for(
|
|
self.text_chat(prompt="REPLY `PONG` ONLY"),
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
class STTProvider(AbstractProvider):
|
|
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
|
super().__init__(provider_config)
|
|
self.provider_config = provider_config
|
|
self.provider_settings = provider_settings
|
|
|
|
@abc.abstractmethod
|
|
async def get_text(self, audio_url: str) -> str:
|
|
"""获取音频的文本"""
|
|
raise NotImplementedError
|
|
|
|
async def test(self):
|
|
sample_audio_path = os.path.join(
|
|
get_astrbot_path(),
|
|
"samples",
|
|
"stt_health_check.wav",
|
|
)
|
|
await self.get_text(sample_audio_path)
|
|
|
|
|
|
class TTSProvider(AbstractProvider):
|
|
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
|
super().__init__(provider_config)
|
|
self.provider_config = provider_config
|
|
self.provider_settings = provider_settings
|
|
|
|
def support_stream(self) -> bool:
|
|
"""是否支持流式 TTS
|
|
|
|
Returns:
|
|
bool: True 表示支持流式处理,False 表示不支持(默认)
|
|
|
|
Notes:
|
|
子类可以重写此方法返回 True 来启用流式 TTS 支持
|
|
"""
|
|
return False
|
|
|
|
@abc.abstractmethod
|
|
async def get_audio(self, text: str) -> str:
|
|
"""获取文本的音频,返回音频文件路径"""
|
|
raise NotImplementedError
|
|
|
|
async def get_audio_stream(
|
|
self,
|
|
text_queue: asyncio.Queue[str | None],
|
|
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
|
) -> None:
|
|
"""流式 TTS 处理方法。
|
|
|
|
从 text_queue 中读取文本片段,将生成的音频数据(WAV 格式的 in-memory bytes)放入 audio_queue。
|
|
当 text_queue 收到 None 时,表示文本输入结束,此时应该处理完所有剩余文本并向 audio_queue 发送 None 表示结束。
|
|
|
|
Args:
|
|
text_queue: 输入文本队列,None 表示输入结束
|
|
audio_queue: 输出音频队列(bytes 或 (text, bytes)),None 表示输出结束
|
|
|
|
Notes:
|
|
- 默认实现会将文本累积后一次性调用 get_audio 生成完整音频
|
|
- 子类可以重写此方法实现真正的流式 TTS
|
|
- 音频数据应该是 WAV 格式的 bytes
|
|
"""
|
|
accumulated_text = ""
|
|
|
|
while True:
|
|
text_part = await text_queue.get()
|
|
|
|
if text_part is None:
|
|
# 输入结束,处理累积的文本
|
|
if accumulated_text:
|
|
try:
|
|
# 调用原有的 get_audio 方法获取音频文件路径
|
|
audio_path = await self.get_audio(accumulated_text)
|
|
# 读取音频文件内容
|
|
with open(audio_path, "rb") as f:
|
|
audio_data = f.read()
|
|
await audio_queue.put((accumulated_text, audio_data))
|
|
except Exception:
|
|
# 出错时也要发送 None 结束标记
|
|
pass
|
|
# 发送结束标记
|
|
await audio_queue.put(None)
|
|
break
|
|
|
|
accumulated_text += text_part
|
|
|
|
async def test(self):
|
|
await self.get_audio("hi")
|
|
|
|
|
|
class EmbeddingProvider(AbstractProvider):
|
|
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
|
super().__init__(provider_config)
|
|
self.provider_config = provider_config
|
|
self.provider_settings = provider_settings
|
|
|
|
@abc.abstractmethod
|
|
async def get_embedding(self, text: str) -> list[float]:
|
|
"""获取文本的向量"""
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
|
"""批量获取文本的向量"""
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def get_dim(self) -> int:
|
|
"""获取向量的维度"""
|
|
...
|
|
|
|
async def test(self):
|
|
await self.get_embedding("astrbot")
|
|
|
|
async def get_embeddings_batch(
|
|
self,
|
|
texts: list[str],
|
|
batch_size: int = 16,
|
|
tasks_limit: int = 3,
|
|
max_retries: int = 3,
|
|
progress_callback=None,
|
|
) -> list[list[float]]:
|
|
"""批量获取文本的向量,分批处理以节省内存
|
|
|
|
Args:
|
|
texts: 文本列表
|
|
batch_size: 每批处理的文本数量
|
|
tasks_limit: 并发任务数量限制
|
|
max_retries: 失败时的最大重试次数
|
|
progress_callback: 进度回调函数,接收参数 (current, total)
|
|
|
|
Returns:
|
|
向量列表
|
|
|
|
"""
|
|
semaphore = asyncio.Semaphore(tasks_limit)
|
|
all_embeddings: list[list[float]] = []
|
|
failed_batches: list[tuple[int, list[str]]] = []
|
|
completed_count = 0
|
|
total_count = len(texts)
|
|
|
|
async def process_batch(batch_idx: int, batch_texts: list[str]):
|
|
nonlocal completed_count
|
|
async with semaphore:
|
|
for attempt in range(max_retries):
|
|
try:
|
|
batch_embeddings = await self.get_embeddings(batch_texts)
|
|
all_embeddings.extend(batch_embeddings)
|
|
completed_count += len(batch_texts)
|
|
if progress_callback:
|
|
await progress_callback(completed_count, total_count)
|
|
return
|
|
except Exception as e:
|
|
if attempt == max_retries - 1:
|
|
# 最后一次重试失败,记录失败的批次
|
|
failed_batches.append((batch_idx, batch_texts))
|
|
raise Exception(
|
|
f"批次 {batch_idx} 处理失败,已重试 {max_retries} 次: {e!s}",
|
|
)
|
|
# 等待一段时间后重试,使用指数退避
|
|
await asyncio.sleep(2**attempt)
|
|
|
|
tasks = []
|
|
for i in range(0, len(texts), batch_size):
|
|
batch_texts = texts[i : i + batch_size]
|
|
batch_idx = i // batch_size
|
|
tasks.append(process_batch(batch_idx, batch_texts))
|
|
|
|
# 收集所有任务的结果,包括失败的任务
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# 检查是否有失败的任务
|
|
errors = [r for r in results if isinstance(r, Exception)]
|
|
if errors:
|
|
error_msg = (
|
|
f"有 {len(errors)} 个批次处理失败: {'; '.join(str(e) for e in errors)}"
|
|
)
|
|
raise Exception(error_msg)
|
|
|
|
return all_embeddings
|
|
|
|
|
|
class RerankProvider(AbstractProvider):
|
|
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
|
super().__init__(provider_config)
|
|
self.provider_config = provider_config
|
|
self.provider_settings = provider_settings
|
|
|
|
@abc.abstractmethod
|
|
async def rerank(
|
|
self,
|
|
query: str,
|
|
documents: list[str],
|
|
top_n: int | None = None,
|
|
) -> list[RerankResult]:
|
|
"""获取查询和文档的重排序分数"""
|
|
...
|
|
|
|
async def test(self):
|
|
result = await self.rerank("Apple", documents=["apple", "banana"])
|
|
if not result:
|
|
raise Exception("Rerank provider test failed, no results returned")
|