Compare commits

..

2 Commits

Author SHA1 Message Date
RockChinQ
662e6a4815 fix(longtext): avoid split interfering with multi-chain agent responses
Use query variable '_longtext_split_extra_chains' to pass extra split
segments instead of appending to resp_message_chain directly. This
prevents agent tool-call multi-round responses from being misidentified
as split results and sent repeatedly.

respback.py reverts to original single-chain logic and appends split
extra chains after the main response.
2026-03-12 09:52:59 -04:00
Dong_master
c92d3d7ad7 feat(longtext): implement long text splitting strategy with Markdown awareness 2026-03-09 01:39:25 +08:00
56 changed files with 785 additions and 2616 deletions

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.0"
version = "4.8.7"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -61,10 +61,10 @@ dependencies = [
"html2text>=2024.2.26",
"langchain>=0.2.0",
"langchain-text-splitters>=0.0.1",
"chromadb>=1.0.0,<2.0.0",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.0",
"pyseekdb==1.0.0b7",
"langbot-plugin==0.3.0rc1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.0'
__version__ = '4.8.7'

View File

@@ -4,7 +4,6 @@ import base64
import binascii
import httpx
import traceback
from urllib.parse import quote
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
@@ -68,31 +67,6 @@ class WecomClient:
await self.logger.error(f'获取accesstoken失败:{response.json()}')
raise Exception(f'未获取access token: {data}')
async def get_user_info(self, userid: str) -> dict:
"""
Get user information by user ID using the application secret.
Args:
userid: The user ID to look up.
Returns:
dict: User information including 'name' field.
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_user_info(userid)
if data.get('errcode', 0) != 0:
await self.logger.error(f'获取用户信息失败:{data}')
return {}
return data
async def get_users(self):
if not self.check_access_token_for_contacts():
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)

View File

@@ -10,7 +10,6 @@ from typing import Callable
from .wecomcsevent import WecomCSEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles
import time
class WecomCSClient:
@@ -35,10 +34,6 @@ class WecomCSClient:
self.unified_mode = unified_mode
self.app = Quart(__name__)
# Customer info cache: {external_userid: (info_dict, timestamp)}
self._customer_cache: dict[str, tuple[dict, float]] = {}
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
@@ -383,53 +378,3 @@ class WecomCSClient:
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id
async def get_customer_info(self, external_userid: str) -> dict | None:
"""
Get customer information by external_userid with caching.
Uses a 1-minute cache to avoid repeated API calls for the same user.
Args:
external_userid: The external user ID of the customer.
Returns:
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
"""
# Check cache first
current_time = time.time()
if external_userid in self._customer_cache:
cached_info, cached_time = self._customer_cache[external_userid]
if current_time - cached_time < self._cache_ttl:
return cached_info
# Cache miss or expired, fetch from API
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
payload = {
'external_userid_list': [external_userid],
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_customer_info(external_userid)
if data.get('errcode', 0) != 0:
if self.logger:
await self.logger.warning(f'Failed to get customer info: {data}')
return None
customer_list = data.get('customer_list', [])
if customer_list:
customer_info = customer_list[0]
# Store in cache
self._customer_cache[external_userid] = (customer_info, current_time)
return customer_info
return None

View File

@@ -1,372 +0,0 @@
import asyncio
import json
import httpx
import quart
import sqlalchemy
from ... import group
from ......core import taskmgr
from ......entity.persistence import metadata as persistence_metadata
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
LANGRAG_PLUGIN_NAME = 'LangRAG'
LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'
DEFAULT_SPACE_URL = 'https://space.langbot.app'
# Old Retriever plugin_name -> New Connector plugin_name
EXTERNAL_PLUGIN_NAME_MAPPING = {
'DifyDatasetsRetriever': 'DifyDatasetsConnector',
'RAGFlowRetriever': 'RAGFlowConnector',
'FastGPTRetriever': 'FastGPTConnector',
}
# Per-plugin: which old retriever_config fields belong to creation_settings.
# Remaining fields go to retrieval_settings.
# None means ALL fields go to creation_settings (no retrieval_schema).
EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},
'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
}
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
class KnowledgeMigrationRouterGroup(group.RouterGroup):
async def _get_migration_flag(self) -> bool:
"""Check if rag_plugin_migration_needed flag is set."""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_metadata.Metadata).where(
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
)
)
row = result.first()
return row is not None and row.value == 'true'
async def _set_migration_flag(self, value: str):
"""Set rag_plugin_migration_needed flag."""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_metadata.Metadata)
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
.values(value=value)
)
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return result.scalar()
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def _install_plugin_from_marketplace(
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
) -> None:
"""Install a single plugin from the marketplace."""
p_author, p_name = plugin_id.split('/', 1)
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
resp.raise_for_status()
p_data = resp.json().get('data', {}).get('plugin', {})
p_version = p_data.get('latest_version')
if not p_version:
raise Exception(f'Could not determine latest version for {plugin_id}')
await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
{
'plugin_author': p_author,
'plugin_name': p_name,
'plugin_version': p_version,
},
task_context=task_context,
)
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
"""Execute RAG migration: install required plugins and restore backup data."""
warnings = []
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
needed_plugins: dict[str, str] = {
LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,
}
has_external = await self._table_exists('external_knowledge_bases')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
)
for row in result.fetchall():
plugin_author = row[0] or ''
plugin_name = row[1] or ''
mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
plugin_id = f'{plugin_author}/{mapped_name}'
if plugin_id not in needed_plugins:
needed_plugins[plugin_id] = mapped_name
self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')
if install_plugin:
# Step 1: Install all required plugins from marketplace
task_context.trace('Installing required plugins...', action='install-plugin')
space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')
for plugin_id in needed_plugins:
try:
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
except Exception as e:
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
# Step 2: Wait for all plugins to become available as knowledge engines
task_context.trace(
f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',
action='wait-plugin',
)
max_retries = 30
engine_id_set: set[str] = set()
for i in range(max_retries):
try:
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except Exception:
pass
if all(pid in engine_id_set for pid in needed_plugins):
self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')
task_context.trace('All required plugins are ready.')
break
if i == max_retries - 1:
still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]
warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'
self.ap.logger.warning(f'RAG migration: {warning}')
warnings.append(warning)
task_context.trace(warning)
await asyncio.sleep(2)
else:
try:
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except Exception:
engine_id_set = set()
# Step 3: Restore internal knowledge bases from backup
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
)
rows = result.fetchall()
columns = result.keys()
for row in rows:
row_dict = dict(zip(columns, row))
kb_uuid = row_dict.get('uuid')
name = row_dict.get('name', 'Untitled')
description = row_dict.get('description', '')
emoji = row_dict.get('emoji', '\U0001f4da')
embedding_model_uuid = row_dict.get('embedding_model_uuid', '')
top_k = row_dict.get('top_k', 5)
created_at = row_dict.get('created_at')
updated_at = row_dict.get('updated_at')
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=updated_at,
plugin_id=LANGRAG_PLUGIN_ID,
collection_id=kb_uuid,
creation_settings=creation_settings,
retrieval_settings=retrieval_settings,
)
)
try:
config = {'embedding_model_uuid': embedding_model_uuid}
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')
except Exception as e:
warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'
warnings.append(warning)
task_context.trace(warning)
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 4: Restore external knowledge bases
task_context.trace('Restoring external knowledge bases...', action='restore-external')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
)
rows = result.fetchall()
columns = result.keys()
self.ap.logger.info(
f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'
)
task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')
for row in rows:
row_dict = dict(zip(columns, row))
kb_uuid = row_dict.get('uuid')
name = row_dict.get('name', 'Untitled')
description = row_dict.get('description', '')
emoji = row_dict.get('emoji', '\U0001f517')
plugin_author = row_dict.get('plugin_author', '')
plugin_name = row_dict.get('plugin_name', '')
retriever_config = row_dict.get('retriever_config', {})
created_at = row_dict.get('created_at')
mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'
self.ap.logger.info(
f'RAG migration: processing external KB "{name}" ({kb_uuid}), '
f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'
)
if isinstance(retriever_config, str):
try:
retriever_config = json.loads(retriever_config)
except (json.JSONDecodeError, TypeError):
retriever_config = {}
creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)
if creation_fields is None:
creation_settings_dict = retriever_config
retrieval_settings_dict = {}
else:
creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=created_at,
plugin_id=external_plugin_id,
collection_id=kb_uuid,
creation_settings=json.dumps(creation_settings_dict),
retrieval_settings=json.dumps(retrieval_settings_dict),
)
)
if external_plugin_id not in engine_id_set:
warning = (
f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} '
f'is not installed yet. Install the connector plugin to use it.'
)
warnings.append(warning)
task_context.trace(warning)
else:
try:
await self.ap.plugin_connector.rag_on_kb_create(
external_plugin_id, kb_uuid, creation_settings_dict
)
task_context.trace(f'Restored external KB: {name} ({kb_uuid})')
except Exception as e:
warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'
warnings.append(warning)
task_context.trace(warning)
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 5: Clear migration flag
await self._set_migration_flag('false')
task_context.trace('RAG migration completed.', action='done')
if warnings:
task_context.trace(f'Completed with {len(warnings)} warning(s).')
async def initialize(self) -> None:
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
internal_kb_count = 0
external_kb_count = 0
if needed:
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
)
internal_kb_count = result.scalar() or 0
if await self._table_exists('external_knowledge_bases'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
)
external_kb_count = result.scalar() or 0
return self.success(
data={
'needed': needed,
'internal_kb_count': internal_kb_count,
'external_kb_count': external_kb_count,
}
)
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
data = await quart.request.get_json(silent=True) or {}
install_plugin = data.get('install_plugin', True)
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
kind='rag-migration',
name='rag-migration-execute',
label='Migrating knowledge bases to plugin architecture',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
await self._set_migration_flag('false')
return self.success()

View File

@@ -30,7 +30,6 @@ class MonitoringService:
level: str = 'info',
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
role: str = 'user',
@@ -50,7 +49,6 @@ class MonitoringService:
'level': level,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
'role': role,
@@ -154,7 +152,6 @@ class MonitoringService:
pipeline_name: str,
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
) -> None:
"""Record a new session"""
session_data = {
@@ -169,7 +166,6 @@ class MonitoringService:
'is_active': True,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
}
await self.ap.persistence_mgr.execute_async(

View File

@@ -20,7 +20,6 @@ class MonitoringMessage(Base):
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
@@ -65,7 +64,6 @@ class MonitoringSession(Base):
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
class MonitoringError(Base):

View File

@@ -1,3 +1,5 @@
import json
import sqlalchemy
from .. import migration
@@ -7,22 +9,20 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
"""Migrate to unified Knowledge Engine plugin architecture.
Changes:
- Backup existing knowledge_bases data to knowledge_bases_backup
- Clear knowledge_bases table and add new plugin architecture columns
- Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
- Preserve external_knowledge_bases table as-is for future migration
- Set rag_plugin_migration_needed flag in metadata if old data exists
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases
- Migrate existing top_k values into retrieval_settings JSON
- Migrate existing embedding_model_uuid into creation_settings JSON
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped)
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated)
"""
async def upgrade(self):
"""Upgrade"""
has_internal_data = await self._backup_knowledge_bases()
has_external_data = await self._check_external_knowledge_bases()
await self._clear_knowledge_bases()
await self._add_columns_to_knowledge_bases()
await self._migrate_top_k_to_retrieval_settings()
await self._migrate_embedding_model_uuid_to_creation_settings()
await self._drop_old_columns()
if has_internal_data or has_external_data:
await self._set_migration_flag()
await self._drop_external_knowledge_bases_table()
async def _get_table_columns(self, table_name: str) -> list[str]:
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
@@ -57,50 +57,6 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
)
return result.first() is not None
async def _backup_knowledge_bases(self) -> bool:
"""Backup knowledge_bases data. Returns True if data was backed up."""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))
count = result.scalar()
if count == 0:
return False
# Drop backup table if it already exists (from a previous failed migration)
if await self._table_exists('knowledge_bases_backup'):
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')
)
self.ap.logger.info(
'Backed up %d knowledge base(s) to knowledge_bases_backup table.',
count,
)
return True
async def _check_external_knowledge_bases(self) -> bool:
"""Check if external_knowledge_bases table exists and has data.
The table is preserved as-is (not dropped) for future migration.
"""
if not await self._table_exists('external_knowledge_bases'):
return False
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
)
count = result.scalar()
if count > 0:
self.ap.logger.info(
'Found %d external knowledge base(s) in external_knowledge_bases table. '
'Table preserved for future migration.',
count,
)
return count > 0
async def _clear_knowledge_bases(self):
"""Clear all rows from knowledge_bases table (preserve table structure)."""
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))
async def _add_columns_to_knowledge_bases(self):
"""Add new RAG plugin architecture columns to knowledge_bases table."""
columns = await self._get_table_columns('knowledge_bases')
@@ -118,6 +74,73 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
)
# For existing knowledge bases without knowledge_engine_plugin_id,
# set collection_id = uuid (same default as new KBs)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;')
)
async def _migrate_top_k_to_retrieval_settings(self):
"""Migrate existing top_k values into retrieval_settings JSON."""
columns = await self._get_table_columns('knowledge_bases')
if 'top_k' not in columns:
return
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;'
)
)
rows = result.fetchall()
for row in rows:
kb_uuid = row[0]
top_k = row[1]
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams(
rs=retrieval_settings, uuid=kb_uuid
)
)
async def _migrate_embedding_model_uuid_to_creation_settings(self):
"""Migrate existing embedding_model_uuid into creation_settings JSON."""
columns = await self._get_table_columns('knowledge_bases')
if 'embedding_model_uuid' not in columns:
return
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases '
"WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';"
)
)
rows = result.fetchall()
for row in rows:
kb_uuid = row[0]
emb_uuid = row[1]
existing_settings = row[2]
if existing_settings and isinstance(existing_settings, str):
try:
settings = json.loads(existing_settings)
except (json.JSONDecodeError, TypeError):
settings = {}
elif isinstance(existing_settings, dict):
settings = existing_settings
else:
settings = {}
if 'embedding_model_uuid' not in settings:
settings['embedding_model_uuid'] = emb_uuid
new_settings = json.dumps(settings)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;'
).bindparams(cs=new_settings, uuid=kb_uuid)
)
async def _drop_old_columns(self):
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
@@ -139,22 +162,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
)
async def _set_migration_flag(self):
"""Set rag_plugin_migration_needed flag in metadata table."""
# Check if the key already exists
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
)
row = result.first()
if row is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';")
async def _drop_external_knowledge_bases_table(self):
"""Drop the external_knowledge_bases table if it exists."""
if await self._table_exists('external_knowledge_bases'):
# Log existing external KBs before dropping, so users are aware of data loss
rows = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
)
self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
existing = rows.fetchall()
if existing:
self.ap.logger.warning(
'Dropping external_knowledge_bases table with %d existing record(s). '
'These external KB configurations will be removed: %s',
len(existing),
[dict(row._mapping) for row in existing],
)
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;'))
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,74 +0,0 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(21)
class DBMigrateMergeExceptionHandling(migration.DBMigration):
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
and add failure-hint field.
Conversion logic:
- block-failed-request-output=true -> exception-handling: hide
- hide-exception=true -> exception-handling: show-hint
- hide-exception=false -> exception-handling: show-error
"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'output' not in config:
config['output'] = {}
if 'misc' not in config['output']:
config['output']['misc'] = {}
misc = config['output']['misc']
# Determine new exception-handling value from legacy fields
hide_exception = misc.get('hide-exception', True)
block_failed = misc.get('block-failed-request-output', False)
if block_failed:
exception_handling = 'hide'
elif hide_exception:
exception_handling = 'show-hint'
else:
exception_handling = 'show-error'
misc['exception-handling'] = exception_handling
# Add failure-hint with default value
misc['failure-hint'] = 'Request failed.'
# Remove legacy fields
misc.pop('hide-exception', None)
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -1,73 +0,0 @@
import sqlalchemy
from .. import migration
@migration.migration_class(22)
class DBMigrateMonitoringUserId(migration.DBMigration):
"""Add user_id and user_name columns to monitoring_sessions table
This migration adds the missing user_id column and also ensures user_name
column exists (in case migration 21 failed or was skipped).
"""
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return bool(result.scalar())
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def _get_table_columns(self, table_name: str) -> list[str]:
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'
).bindparams(table_name=table_name)
)
return [row[0] for row in result.fetchall()]
else:
if not table_name.isidentifier():
raise ValueError(f'Invalid table name: {table_name}')
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
return [row[1] for row in result.fetchall()]
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it does not already exist."""
columns = await self._get_table_columns(table_name)
if column_name in columns:
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
return
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
)
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
async def upgrade(self):
# Check if monitoring_sessions table exists
if not await self._table_exists('monitoring_sessions'):
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
return
# Add user_id column to monitoring_sessions table
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
# Add user_name column to monitoring_messages table (in case migration 21 failed)
if await self._table_exists('monitoring_messages'):
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
async def downgrade(self):
pass

View File

@@ -1,105 +0,0 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# metadata type -> coercion function
_COERCE_MAP = {
'integer': lambda v: int(v),
'number': lambda v: float(v),
'float': lambda v: float(v),
}
def _coerce_bool(v):
if isinstance(v, bool):
return v
if isinstance(v, str):
if v.lower() == 'true':
return True
if v.lower() == 'false':
return False
raise ValueError(f'Cannot convert string {v!r} to bool')
return bool(v)
def _coerce_value(value, expected_type: str):
"""Convert a single value to the expected type.
Returns the converted value, or the original value if no conversion needed.
"""
if value is None:
return value
if expected_type == 'boolean':
if isinstance(value, bool):
return value
return _coerce_bool(value)
coerce_fn = _COERCE_MAP.get(expected_type)
if coerce_fn is None:
return value
# Already the correct type
if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):
return value
if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
return coerce_fn(value)
def coerce_pipeline_config(
config: dict,
*metadata_list: dict,
) -> None:
"""Coerce pipeline config values according to metadata type definitions.
Walks each metadata dict (trigger, safety, ai, output) and converts
config values in-place so that strings coming from the JSON column are
cast to their declared types (integer, number/float, boolean).
Args:
config: The pipeline config dict to modify in-place.
*metadata_list: Metadata dicts loaded from the YAML templates.
"""
for meta in metadata_list:
section_name = meta.get('name')
if not section_name or section_name not in config:
continue
section = config[section_name]
if not isinstance(section, dict):
continue
for stage_def in meta.get('stages', []):
stage_name = stage_def.get('name')
if not stage_name or stage_name not in section:
continue
stage_config = section[stage_name]
if not isinstance(stage_config, dict):
continue
for field_def in stage_def.get('config', []):
field_name = field_def.get('name')
field_type = field_def.get('type')
if not field_name or not field_type or field_name not in stage_config:
continue
old_value = stage_config[field_name]
try:
new_value = _coerce_value(old_value, field_type)
if new_value is not old_value:
stage_config[field_name] = new_value
except (ValueError, TypeError) as e:
logger.warning(
'Failed to coerce config %s.%s.%s (%r) to %s: %s',
section_name,
stage_name,
field_name,
old_value,
field_type,
e,
)

View File

@@ -22,10 +22,13 @@ class LongTextProcessStage(stage.PipelineStage):
"""
strategy_impl: strategy.LongTextStrategy | None
is_split: bool
async def initialize(self, pipeline_config: dict):
config = pipeline_config['output']['long-text-processing']
self.is_split = config['strategy'] == 'split'
if config['strategy'] == 'none':
self.strategy_impl = None
return
@@ -90,8 +93,23 @@ class LongTextProcessStage(stage.PipelineStage):
len(str(query.resp_message_chain[-1]))
> query.pipeline_config['output']['long-text-processing']['threshold']
):
query.resp_message_chain[-1] = platform_message.MessageChain(
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
)
if self.is_split:
original_text = str(query.resp_message_chain[-1])
threshold = query.pipeline_config['output']['long-text-processing']['threshold']
segments = self.strategy_impl.split_text(original_text, threshold)
# Replace the last chain with the first segment, store extra segments separately
# to avoid interfering with existing multi-chain scenarios (e.g. agent tool calls)
query.resp_message_chain[-1] = platform_message.MessageChain(
[platform_message.Plain(text=segments[0])]
)
if len(segments) > 1:
query.set_variable(
'_longtext_split_extra_chains',
[platform_message.MessageChain([platform_message.Plain(text=seg)]) for seg in segments[1:]],
)
else:
query.resp_message_chain[-1] = platform_message.MessageChain(
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)

View File

@@ -0,0 +1,224 @@
from __future__ import annotations
import re
from .. import strategy as strategy_model
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@strategy_model.strategy_class('split')
class SplitStrategy(strategy_model.LongTextStrategy):
"""Split long text into multiple message segments with Markdown awareness."""
async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:
segments = self.split_text(
message,
query.pipeline_config['output']['long-text-processing']['threshold'],
)
return [platform_message.Plain(text=segments[0])] if segments else []
def split_text(self, text: str, max_length: int) -> list[str]:
"""Split text into segments respecting Markdown structure.
Priority:
1. Markdown structural boundaries (headings, code blocks, horizontal rules)
2. Paragraph breaks (blank lines)
3. List item boundaries
4. Line breaks
5. Hard cut (fallback)
"""
if len(text) <= max_length:
return [text]
blocks = self._parse_markdown_blocks(text)
return self._merge_blocks(blocks, max_length)
def _parse_markdown_blocks(self, text: str) -> list[str]:
"""Parse text into Markdown-aware blocks.
Keeps code blocks intact and splits the rest by structural elements.
"""
blocks: list[str] = []
lines = text.split('\n')
current_block: list[str] = []
in_code_block = False
for line in lines:
stripped = line.strip()
# Toggle fenced code block state
if stripped.startswith('```'):
if in_code_block:
# End of code block - close it as one block
current_block.append(line)
blocks.append('\n'.join(current_block))
current_block = []
in_code_block = False
continue
else:
# Start of code block - flush current block first
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
current_block.append(line)
in_code_block = True
continue
if in_code_block:
current_block.append(line)
continue
# Heading (# ...) - start a new block
if re.match(r'^#{1,6}\s', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
current_block.append(line)
continue
# Horizontal rule (---, ***, ___) - start a new block
if re.match(r'^(-{3,}|\*{3,}|_{3,})\s*$', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(line)
continue
# Blank line - paragraph boundary
if stripped == '':
if current_block:
current_block.append(line)
blocks.append('\n'.join(current_block))
current_block = []
continue
current_block.append(line)
# Flush remaining (including unclosed code blocks)
if current_block:
blocks.append('\n'.join(current_block))
return [b for b in blocks if b.strip()]
def _merge_blocks(self, blocks: list[str], max_length: int) -> list[str]:
"""Merge small blocks greedily until approaching max_length.
If a single block exceeds max_length, split it by lines as fallback.
"""
segments: list[str] = []
current = ''
for block in blocks:
candidate = (current + '\n\n' + block) if current else block
if len(candidate) <= max_length:
current = candidate
else:
# Flush current segment
if current:
segments.append(current)
# Check if this single block fits
if len(block) <= max_length:
current = block
else:
# Block too large - split it by lines
for part in self._split_large_block(block, max_length):
segments.append(part)
current = ''
if current:
segments.append(current)
return [s for s in segments if s.strip()]
def _split_large_block(self, block: str, max_length: int) -> list[str]:
"""Split an oversized block by lines, preserving code block fences.
For single-line plain text (no newlines), falls back to splitting at
natural language boundaries (spaces, punctuation).
"""
lines = block.split('\n')
# Single long line with no newlines - use plain text splitting
if len(lines) == 1:
return self._split_plain_text(block, max_length)
is_code_block = lines[0].strip().startswith('```')
segments: list[str] = []
current_lines: list[str] = []
current_len = 0
# For code blocks, track the opening fence to re-apply on continuations
code_fence = lines[0] if is_code_block else ''
for i, line in enumerate(lines):
line_len = len(line) + 1 # +1 for newline
# Single line exceeds limit on its own - split it first
if line_len > max_length:
if current_lines:
seg = '\n'.join(current_lines)
if is_code_block and not seg.rstrip().endswith('```'):
seg += '\n```'
segments.append(seg)
current_lines = []
current_len = 0
for part in self._split_plain_text(line, max_length):
segments.append(part)
continue
if current_len + line_len > max_length and current_lines:
segment = '\n'.join(current_lines)
# Close code block fence if splitting mid-code-block
if is_code_block and not segment.rstrip().endswith('```'):
segment += '\n```'
segments.append(segment)
current_lines = []
current_len = 0
# Re-open code block fence for continuation
if is_code_block and i < len(lines) - 1 and not line.strip().startswith('```'):
current_lines.append(code_fence)
current_len = len(code_fence) + 1
current_lines.append(line)
current_len += line_len
if current_lines:
segments.append('\n'.join(current_lines))
return segments
def _split_plain_text(self, text: str, max_length: int) -> list[str]:
"""Split a long plain text string (no newlines) at word/space boundaries."""
if len(text) <= max_length:
return [text]
segments: list[str] = []
remaining = text
while remaining:
if len(remaining) <= max_length:
segments.append(remaining)
break
chunk = remaining[:max_length]
min_pos = int(max_length * 0.3)
# Try to find a space to split at
pos = chunk.rfind(' ')
if pos >= min_pos:
split_pos = pos
else:
# Hard cut as last resort
split_pos = max_length
segments.append(remaining[:split_pos].rstrip())
remaining = remaining[split_pos:].lstrip()
return [s for s in segments if s]

View File

@@ -34,15 +34,6 @@ class MonitoringHelper:
# Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
@@ -66,7 +57,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
variables=None, # Will be updated in record_query_success
)
@@ -90,7 +80,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
)
return message_id
@@ -139,15 +128,6 @@ class MonitoringHelper:
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Extract response content from resp_message_chain
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
# Serialize the last response message chain
@@ -182,7 +162,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
role='assistant',
)
@@ -204,15 +183,6 @@ class MonitoringHelper:
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Record error message
message_id = await ap.monitoring_service.record_message(
bot_id=bot_id,
@@ -227,7 +197,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
)

View File

@@ -13,7 +13,6 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
from .config_coercion import coerce_pipeline_config
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -421,14 +420,6 @@ class PipelineManager:
elif isinstance(pipeline_entity, dict):
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
)
# initialize stage containers according to pipeline_entity.stages
stage_containers: list[StageInstContainer] = []
for stage_name in pipeline_entity.stages:

View File

@@ -149,19 +149,12 @@ class ChatMessageHandler(handler.MessageHandler):
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
traceback.print_exc()
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
if exception_handling == 'show-error':
user_notice = f'{e}'
elif exception_handling == 'show-hint':
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
else: # hide
user_notice = None
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
yield entities.StageProcessResult(
result_type=entities.ResultType.INTERRUPT,
new_query=query,
user_notice=user_notice,
user_notice='请求失败' if hide_exception_info else f'{e}',
error_notice=f'{e}',
debug_notice=traceback.format_exc(),
)

View File

@@ -55,4 +55,15 @@ class SendResponseBackStage(stage.PipelineStage):
quote_origin=quote_origin,
)
# Send extra chains produced by long text split strategy
extra_chains = query.get_variable('_longtext_split_extra_chains')
if extra_chains:
for chain in extra_chains:
await query.adapter.reply_message(
message_source=query.message_event,
message=chain,
quote_origin=False,
)
query.set_variable('_longtext_split_extra_chains', None)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)

View File

@@ -282,8 +282,6 @@ class PlatformManager:
return runtime_bot
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
return self.websocket_proxy_bot
for bot in self.bots:
if bot.bot_entity.uuid == bot_uuid:
return bot

View File

@@ -37,24 +37,16 @@ class WebSocketSession:
id: str
message_lists: dict[str, list[WebSocketMessage]] = {}
"""消息列表 {pipeline_uuid: [messages]}"""
stream_message_indexes: dict[str, dict[str, int]] = {}
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
def __init__(self, id: str):
self.id = id
self.message_lists = {}
self.stream_message_indexes = {}
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
if pipeline_uuid not in self.message_lists:
self.message_lists[pipeline_uuid] = []
return self.message_lists[pipeline_uuid]
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
if pipeline_uuid not in self.stream_message_indexes:
self.stream_message_indexes[pipeline_uuid] = {}
return self.stream_message_indexes[pipeline_uuid]
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""WebSocket适配器 - 支持双向实时通信"""
@@ -97,46 +89,20 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
target_id: str,
message: platform_message.MessageChain,
) -> dict:
"""发送消息 - 这里用于主动推送消息到前端
"""发送消息 - 这里用于主动推送消息到前端"""
message_data = {
'type': 'bot_message',
'target_type': target_type,
'target_id': target_id,
'content': str(message),
'message_chain': [component.__dict__ for component in message],
'timestamp': datetime.now().isoformat(),
}
对于 WebSocket 适配器,我们需要将消息广播到正确的 pipeline 连接。
target_id 可能是 launcher_id如 websocket_xxx或 pipeline_uuid。
我们需要尝试两种方式来确保消息能够送达。
"""
# 获取当前的 pipeline_uuid
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if target_type == 'group' else 'person'
# 推送到所有相关连接
await self.outbound_message_queue.put(message_data)
# 选择会话
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
# 生成唯一消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
message_data = WebSocketMessage(
id=msg_id,
role='assistant',
content=str(message),
message_chain=[component.__dict__ for component in message],
timestamp=datetime.now().isoformat(),
is_final=True,
)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
# 直接广播到当前pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'session_type': session_type,
'data': message_data.model_dump(),
},
session_type=session_type,
)
return message_data.model_dump()
return message_data
async def reply_message(
self,
@@ -203,16 +169,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
message_list = session.get_message_list(pipeline_uuid)
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
# Use it as the primary key to avoid overwriting an old card from a previous reply.
resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None
message_is_final = is_final and bot_message.tool_calls is None
if existing_index is None or existing_index >= len(message_list):
# 检查是否是新的流式消息通过bot_message对象判断
# 如果列表为空或者最后一条消息已经is_final=True则创建新消息
if not message_list or message_list[-1].is_final:
# 创建新消息
msg_id = len(message_list) + 1
message_data = WebSocketMessage(
@@ -221,31 +181,27 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
content=str(message),
message_chain=[component.__dict__ for component in message],
timestamp=datetime.now().isoformat(),
is_final=message_is_final,
is_final=is_final and bot_message.tool_calls is None,
)
# 立即添加到历史记录即使is_final=False以便后续块可以更新它
message_list.append(message_data)
if resp_message_id:
stream_message_indexes[resp_message_id] = len(message_list) - 1
# 只有在is_final时才保存到历史记录
if is_final and bot_message.tool_calls is None:
message_list.append(message_data)
else:
# 更新同一条流式消息
old_message = message_list[existing_index]
msg_id = old_message.id
# 更新最后一条消息
msg_id = message_list[-1].id
message_data = WebSocketMessage(
id=msg_id,
role='assistant',
content=str(message),
message_chain=[component.__dict__ for component in message],
timestamp=old_message.timestamp, # 保持原始时间戳
is_final=message_is_final,
timestamp=message_list[-1].timestamp, # 保持原始时间戳
is_final=is_final and bot_message.tool_calls is None,
)
# 更新历史记录中的对应消息
message_list[existing_index] = message_data
if message_is_final and resp_message_id:
stream_message_indexes.pop(resp_message_id, None)
# 如果是final更新历史记录中的最后一条
if is_final and bot_message.tool_calls is None:
message_list[-1] = message_data
# 直接广播到所有该pipeline的连接包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
@@ -454,10 +410,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if session_type == 'person':
if pipeline_uuid in self.websocket_person_session.message_lists:
self.websocket_person_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
else:
if pipeline_uuid in self.websocket_group_session.message_lists:
self.websocket_group_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}

View File

@@ -148,54 +148,51 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
pass
if type(event) is platform_events.FriendMessage:
return event.source_platform_object
payload = {
'MsgType': 'text',
'Content': '',
'FromUserName': event.sender.id,
'ToUserName': bot_account_id,
'CreateTime': int(datetime.datetime.now().timestamp()),
'AgentID': event.sender.nickname,
}
wecom_event = WecomEvent.from_payload(payload=payload)
if not wecom_event:
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
return wecom_event
@staticmethod
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
async def target2yiri(event: WecomEvent):
"""
将 WecomEvent 转换为平台的 FriendMessage 对象。
Args:
event (WecomEvent): 企业微信事件。
bot (WecomClient): 企业微信客户端,用于获取用户信息。
Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
"""
# Try to get the user's real name from the WeCom API
nickname = str(event.user_id)
if bot and event.user_id:
try:
user_info = await bot.get_user_info(event.user_id)
if user_info and user_info.get('name'):
nickname = user_info.get('name')
except Exception:
pass # Fall back to user_id as nickname
# 转换消息链
if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.agent_id),
remark='',
)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
elif event.type == 'image':
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.agent_id),
remark='',
)
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
@@ -213,6 +210,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'secret',
'token',
'EncodingAESKey',
'contacts_secret',
]
missing_keys = [key for key in required_keys if key not in config]
@@ -225,7 +223,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
secret=config['secret'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
@@ -250,17 +248,18 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
# user_id is the original FromUserName from WecomEvent
user_id = Wecom_event.user_id
fixed_user_id = Wecom_event.user_id
# 删掉开头的u
fixed_user_id = fixed_user_id[1:]
for content in content_list:
if content['type'] == 'text':
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image':
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -288,7 +287,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomEvent):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')

View File

@@ -39,6 +39,13 @@ spec:
type: string
required: true
default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
type: string
required: true
default: ""
- name: api_base_url
label:
en_US: API Base URL

View File

@@ -81,33 +81,22 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
return event.source_platform_object
@staticmethod
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
async def target2yiri(event: WecomCSEvent):
"""
将 WecomEvent 转换为平台的 FriendMessage 对象。
Args:
event (WecomEvent): 企业微信客服事件。
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
"""
# Try to get customer nickname from WeChat API
nickname = str(event.user_id)
if bot and event.user_id:
try:
customer_info = await bot.get_customer_info(event.user_id)
if customer_info and customer_info.get('nickname'):
nickname = customer_info.get('nickname')
except Exception:
pass # Fall back to user_id as nickname
# 转换消息链
if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.user_id),
remark='',
)
@@ -117,7 +106,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
elif event.type == 'image':
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.user_id),
remark='',
)
@@ -198,7 +187,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomCSEvent):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')

View File

@@ -337,14 +337,7 @@ class RuntimeConnectionHandler(handler.Handler):
)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
# but it's a required field for LLMTool validation. We need to provide a placeholder
# function when reconstructing the LLMTool objects from serialized data.
async def _placeholder_func(**kwargs):
pass
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
result = await llm_model.provider.invoke_llm(
query=None,

View File

@@ -441,7 +441,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
is_final = False
think_start = False
think_end = False
yielded_final = False
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
@@ -494,19 +493,13 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if answer:
basic_mode_pending_chunk = answer
if (
not yielded_final
and (is_final or message_idx % 8 == 0)
and (basic_mode_pending_chunk != '' or is_final)
):
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final):
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
yield provider_message.MessageChunk(
role='assistant',
content=basic_mode_pending_chunk,
is_final=is_final,
)
if is_final:
yielded_final = True
if chunk is None:
raise errors.DifyAPIError('Dify API 没有返回任何响应请检查网络连接和API配置')

View File

@@ -74,13 +74,7 @@ class LocalAgentRunner(runner.RequestRunner):
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
continue
result = await kb.retrieve(
user_message_text,
settings={
'sender_id': str(query.sender_id),
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
},
)
result = await kb.retrieve(user_message_text)
if result:
all_results.extend(result)

View File

@@ -321,19 +321,13 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
if not plugin_id:
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
# Session context (e.g. session_name) stays in retrieval_settings
# for plugins that need it. Do NOT move them into filters, as filters
# are passed directly to vector_search by some plugins (e.g. LangRAG)
# and would cause empty results when the metadata field doesn't exist.
filters = settings.pop('filters', {})
retrieval_context = {
'query': query,
'knowledge_base_id': kb.uuid,
'collection_id': kb.collection_id or kb.uuid,
'retrieval_settings': settings,
'creation_settings': kb.creation_settings or {},
'filters': filters,
'filters': settings.pop('filters', {}),
}
result = await self.ap.plugin_connector.call_rag_retrieve(

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 22
required_database_version = 19
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -2,14 +2,11 @@ from __future__ import annotations
import asyncio
from typing import Any
from chromadb import PersistentClient
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
import chromadb
import chromadb.errors
# RRF smoothing constant (standard value from the literature)
_RRF_K = 60
class ChromaVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
@@ -17,10 +14,6 @@ class ChromaVectorDatabase(VectorDatabase):
self.client = PersistentClient(path=base_path)
self._collections = {}
@classmethod
def supported_search_types(cls) -> list[SearchType]:
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
if collection not in self._collections:
self._collections[collection] = await asyncio.to_thread(
@@ -41,8 +34,8 @@ class ChromaVectorDatabase(VectorDatabase):
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
if documents is not None:
kwargs['documents'] = documents
await asyncio.to_thread(col.upsert, **kwargs)
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
await asyncio.to_thread(col.add, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
async def search(
self,
@@ -54,23 +47,6 @@ class ChromaVectorDatabase(VectorDatabase):
filter: dict[str, Any] | None = None,
) -> dict[str, Any]:
col = await self.get_or_create_collection(collection)
if search_type == SearchType.FULL_TEXT:
return await self._full_text_search(col, collection, k, query_text, filter)
elif search_type == SearchType.HYBRID:
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
# Default: vector search
return await self._vector_search(col, collection, query_embedding, k, filter)
async def _vector_search(
self,
col: chromadb.Collection,
collection: str,
query_embedding: list[float],
k: int,
filter: dict[str, Any] | None,
) -> dict[str, Any]:
query_kwargs: dict[str, Any] = dict(
query_embeddings=query_embedding,
n_results=k,
@@ -79,137 +55,9 @@ class ChromaVectorDatabase(VectorDatabase):
if filter:
query_kwargs['where'] = filter
results = await asyncio.to_thread(col.query, **query_kwargs)
self.ap.logger.info(
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.")
return results
async def _full_text_search(
self,
col: chromadb.Collection,
collection: str,
k: int,
query_text: str,
filter: dict[str, Any] | None,
) -> dict[str, Any]:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
get_kwargs: dict[str, Any] = dict(
where_document={'$contains': query_text},
include=['metadatas', 'documents'],
limit=k,
)
if filter:
get_kwargs['where'] = filter
results = await asyncio.to_thread(col.get, **get_kwargs)
# col.get returns flat lists; wrap into column-major format.
# Distances are all 0.0 because Chroma's local $contains is a boolean
# filter with no relevance scoring. Chroma's BM25 sparse embedding
# function (ChromaBm25EmbeddingFunction) can generate scored sparse
# vectors, but sparse vector *indexing* is only available on Chroma
# Cloud, not locally. For ranked results, use hybrid mode or apply a
# reranker in a downstream stage.
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
distances = [0.0] * len(ids)
self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.")
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}
async def _hybrid_search(
self,
col: chromadb.Collection,
collection: str,
query_embedding: list[float],
k: int,
query_text: str,
filter: dict[str, Any] | None,
) -> dict[str, Any]:
# Fall back to pure vector search when no text is provided
if not query_text:
return await self._vector_search(col, collection, query_embedding, k, filter)
# Run vector search and full-text search in parallel
vector_task = self._vector_search(col, collection, query_embedding, k, filter)
text_task = self._full_text_search(col, collection, k, query_text, filter)
vector_results, text_results = await asyncio.gather(vector_task, text_task)
vector_ids = vector_results.get('ids', [[]])[0]
text_ids = text_results.get('ids', [[]])[0]
if not vector_ids and not text_ids:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
# RRF fusion
fused = self._rrf_fuse([vector_ids, text_ids], k)
if not fused:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
fused_ids = [doc_id for doc_id, _ in fused]
# Fetch full metadata and documents for fused results
fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])
# col.get returns results in arbitrary order; re-order to match fused ranking
fetched_map: dict[str, tuple] = {}
for i, fid in enumerate(fetched.get('ids', [])):
meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]
doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]
fetched_map[fid] = (meta, doc)
ordered_ids = []
ordered_metas = []
ordered_docs = []
ordered_dists = []
# Normalize RRF scores to 0~1 distances via min-max scaling.
# Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive
# ``1 - score`` would compress all distances into a narrow 0.96~0.98
# band with almost no discriminative power. Min-max normalization
# spreads them across the full 0~1 range (0.0 = best match).
max_score = fused[0][1]
min_score = fused[-1][1]
score_range = max_score - min_score
for doc_id, score in fused:
if doc_id in fetched_map:
meta, doc = fetched_map[doc_id]
ordered_ids.append(doc_id)
ordered_metas.append(meta)
ordered_docs.append(doc)
if score_range > 0:
ordered_dists.append(1.0 - (score - min_score) / score_range)
else:
ordered_dists.append(0.0)
self.ap.logger.info(
f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results "
f'(vector={len(vector_ids)}, text={len(text_ids)}).'
)
return {
'ids': [ordered_ids],
'metadatas': [ordered_metas],
'distances': [ordered_dists],
'documents': [ordered_docs],
}
@staticmethod
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion over multiple ranked ID lists.
Returns a list of (doc_id, rrf_score) sorted by descending score,
truncated to *k* entries.
"""
scores: dict[str, float] = {}
for ranked_ids in result_lists:
for rank, doc_id in enumerate(ranked_ids):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:k]
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
col = await self.get_or_create_collection(collection)
await asyncio.to_thread(col.delete, where={'file_id': file_id})

View File

@@ -95,12 +95,11 @@
"max": 0
},
"misc": {
"exception-handling": "show-hint",
"failure-hint": "Request failed.",
"hide-exception": true,
"at-sender": true,
"quote-origin": true,
"track-function-calls": false,
"remove-think": false
}
}
}
}

View File

@@ -37,6 +37,10 @@ stages:
label:
en_US: Convert to Image
zh_Hans: 转换为图片
- name: split
label:
en_US: Split into Multiple Messages
zh_Hans: 分割为多条消息发送
- name: none
label:
en_US: None
@@ -78,39 +82,13 @@ stages:
en_US: Misc
zh_Hans: 杂项
config:
- name: exception-handling
- name: hide-exception
label:
en_US: Exception Handling Strategy
zh_Hans: 异常处理策略
description:
en_US: Controls how error messages are displayed to the user when an AI request fails
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
type: select
en_US: Hide Exception
zh_Hans: 不输出异常信息给用户
type: boolean
required: true
default: show-hint
options:
- name: show-error
label:
en_US: Show Full Error
zh_Hans: 显示完整报错信息
- name: show-hint
label:
en_US: Show Failure Hint
zh_Hans: 仅文字提示
- name: hide
label:
en_US: Hide All
zh_Hans: 不显示任何异常信息
- name: failure-hint
label:
en_US: Failure Hint Text
zh_Hans: 失败提示文本
description:
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
type: string
required: false
default: 'Request failed.'
default: true
- name: at-sender
label:
en_US: At Sender
@@ -145,4 +123,3 @@ stages:
type: boolean
required: true
default: false

View File

@@ -1,113 +0,0 @@
"""Unit tests for config_coercion module"""
from __future__ import annotations
import pytest
from langbot.pkg.pipeline.config_coercion import _coerce_value, coerce_pipeline_config
class TestCoerceValue:
"""Tests for _coerce_value function"""
def test_none_passthrough(self):
assert _coerce_value(None, 'integer') is None
assert _coerce_value(None, 'boolean') is None
def test_string_to_integer(self):
assert _coerce_value('120', 'integer') == 120
assert _coerce_value('0', 'integer') == 0
assert _coerce_value('-5', 'integer') == -5
def test_integer_passthrough(self):
assert _coerce_value(42, 'integer') == 42
def test_string_to_float(self):
assert _coerce_value('3.14', 'number') == 3.14
assert _coerce_value('3.14', 'float') == 3.14
def test_int_to_float(self):
assert _coerce_value(3, 'number') == 3.0
assert isinstance(_coerce_value(3, 'number'), float)
def test_float_passthrough(self):
assert _coerce_value(3.14, 'float') == 3.14
def test_string_to_bool(self):
assert _coerce_value('true', 'boolean') is True
assert _coerce_value('True', 'boolean') is True
assert _coerce_value('false', 'boolean') is False
assert _coerce_value('False', 'boolean') is False
def test_bool_passthrough(self):
assert _coerce_value(True, 'boolean') is True
assert _coerce_value(False, 'boolean') is False
def test_invalid_bool_string_raises(self):
with pytest.raises(ValueError):
_coerce_value('notabool', 'boolean')
def test_unknown_type_passthrough(self):
assert _coerce_value('hello', 'string') == 'hello'
assert _coerce_value('hello', 'unknown') == 'hello'
def test_invalid_integer_raises(self):
with pytest.raises(ValueError):
_coerce_value('abc', 'integer')
class TestCoercePipelineConfig:
"""Tests for coerce_pipeline_config function"""
def _make_meta(self, section_name: str, stage_name: str, fields: list[dict]) -> dict:
return {
'name': section_name,
'stages': [{'name': stage_name, 'config': fields}],
}
def test_coerce_integer_in_config(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 120
def test_coerce_boolean_in_config(self):
config = {'output': {'misc': {'at-sender': 'true'}}}
meta = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta)
assert config['output']['misc']['at-sender'] is True
def test_missing_section_skipped(self):
config = {'ai': {}}
meta = self._make_meta('trigger', 'misc', [{'name': 'x', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_missing_field_skipped(self):
config = {'trigger': {'misc': {}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'nonexistent', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_invalid_value_logs_warning(self, caplog):
config = {'trigger': {'misc': {'timeout': 'abc'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
import logging
with caplog.at_level(logging.WARNING):
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 'abc' # unchanged
assert 'Failed to coerce' in caplog.text
def test_empty_metadata(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
coerce_pipeline_config(config) # no metadata args, should not raise
def test_multiple_metadata(self):
config = {
'trigger': {'misc': {'timeout': '120'}},
'output': {'misc': {'at-sender': 'false'}},
}
meta_trigger = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
meta_output = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta_trigger, meta_output)
assert config['trigger']['misc']['timeout'] == 120
assert config['output']['misc']['at-sender'] is False

939
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react';
import {
MessageChainComponent,
Plain,
@@ -28,7 +27,6 @@ interface SessionInfo {
is_active: boolean;
platform?: string | null;
user_id?: string | null;
user_name?: string | null;
}
interface SessionMessage {
@@ -62,29 +60,8 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_');
if (idx === -1) return null;
const type = sessionId.slice(0, idx);
if (type === 'person' || type === 'group') return type;
return null;
};
const abbreviateId = (id: string): string => {
if (id.length <= 10) return id;
return `${id.slice(0, 4)}..${id.slice(-4)}`;
};
const copyUserId = (userId: string) => {
navigator.clipboard.writeText(userId).then(() => {
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
});
};
const loadSessions = useCallback(async () => {
setLoadingSessions(true);
try {
@@ -361,36 +338,24 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
{session.user_name ||
session.user_id ||
session.session_id.slice(0, 12)}
{session.user_id || session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parseSessionType(session.session_id) && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{parseSessionType(session.session_id)}
</span>
)}
{session.platform && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{session.platform}
</span>
)}
{session.user_id && (
<span className="truncate text-[10px]">
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
<span className="truncate">{session.pipeline_name}</span>
<span>{session.pipeline_name}</span>
</div>
</button>
);
@@ -412,42 +377,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
{selectedSession?.user_id || selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
<span>{selectedSession.platform}</span>
)}
{selectedSession?.pipeline_name && (
<>
<span>·</span>
{selectedSession?.platform && <span>·</span>}
<span>{selectedSession.pipeline_name}</span>
</>
)}

View File

@@ -422,12 +422,12 @@ export default function HomeSidebar({
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide',
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide',
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}

View File

@@ -23,9 +23,9 @@ export const sidebarConfigList = [
route: '/home/bots',
description: t('bots.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/usage/platforms/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
en_US: 'https://docs.langbot.app/en/usage/platforms/readme.html',
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme.html',
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme.html',
},
}),
new SidebarChildVO({
@@ -44,9 +44,9 @@ export const sidebarConfigList = [
route: '/home/pipelines',
description: t('pipelines.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme.html',
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme.html',
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme.html',
},
}),
new SidebarChildVO({
@@ -65,8 +65,8 @@ export const sidebarConfigList = [
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
en_US: '',
zh_Hans: '',
en_US: 'https://docs.langbot.app/en/features/monitoring.html',
zh_Hans: 'https://docs.langbot.app/zh/features/monitoring.html',
},
}),
new SidebarChildVO({
@@ -84,9 +84,9 @@ export const sidebarConfigList = [
route: '/home/knowledge',
description: t('knowledge.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme.html',
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme.html',
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme.html',
},
}),
new SidebarChildVO({
@@ -105,9 +105,9 @@ export const sidebarConfigList = [
route: '/home/plugins',
description: t('plugins.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro.html',
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro.html',
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro.html',
},
}),
];

View File

@@ -36,11 +36,11 @@ export default function NewVersionDialog({
const getUpdateDocsUrl = () => {
const language = i18n.language;
if (language === 'zh-Hans' || language === 'zh-Hant') {
return 'https://docs.langbot.app/zh/deploy/update';
return 'https://docs.langbot.app/zh/deploy/update.html';
} else if (language === 'ja-JP') {
return 'https://docs.langbot.app/ja/deploy/update';
return 'https://docs.langbot.app/ja/deploy/update.html';
} else {
return 'https://docs.langbot.app/en/deploy/update';
return 'https://docs.langbot.app/en/deploy/update.html';
}
};

View File

@@ -22,7 +22,6 @@ import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
@@ -69,9 +68,7 @@ export default function KBDetailDialog({
setKbInfo(resp.base);
} catch (e) {
console.error('Failed to load KB info:', e);
toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
toast.error(t('knowledge.loadKnowledgeBaseFailed'));
}
}
@@ -139,9 +136,7 @@ export default function KBDetailDialog({
onKbDeleted();
} catch (e) {
console.error('Failed to delete KB:', e);
toast.error(
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
toast.error(t('knowledge.deleteKnowledgeBaseFailed'));
} finally {
setShowDeleteConfirm(false);
}

View File

@@ -12,7 +12,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { ParserInfo } from '@/app/infra/entities/api';
import { CustomApiError, I18nObject } from '@/app/infra/entities/common';
import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
interface FileUploadZoneProps {
@@ -97,9 +97,7 @@ export default function FileUploadZone({
onUploadSuccess();
} catch (error) {
console.error('File upload failed:', error);
const errorMessage =
t('knowledge.documentsTab.uploadError') +
(error as CustomApiError).msg;
const errorMessage = t('knowledge.documentsTab.uploadError');
toast.error(errorMessage, { id: toastId });
onUploadError(errorMessage);
} finally {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBaseFile } from '@/app/infra/entities/api';
import { I18nObject, CustomApiError } from '@/app/infra/entities/common';
import { I18nObject } from '@/app/infra/entities/common';
import { columns, DocumentFile } from './documents/columns';
import { DataTable } from './documents/data-table';
import FileUploadZone from './FileUploadZone';
@@ -87,10 +87,7 @@ export default function KBDoc({
})
.catch((error) => {
console.error('Delete failed:', error);
toast.error(
t('knowledge.documentsTab.fileDeleteFailed') +
(error as CustomApiError).msg,
);
toast.error(t('knowledge.documentsTab.fileDeleteFailed'));
});
};

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -24,7 +23,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
@@ -219,10 +217,7 @@ export default function KBForm({
})
.catch((err) => {
console.error('update knowledge base failed', err);
toast.error(
t('knowledge.updateKnowledgeBaseFailed') +
(err as CustomApiError).msg,
);
toast.error(t('knowledge.updateKnowledgeBaseFailed'));
});
} else {
// Create knowledge base
@@ -233,10 +228,7 @@ export default function KBForm({
})
.catch((err) => {
console.error('create knowledge base failed', err);
toast.error(
t('knowledge.createKnowledgeBaseFailed') +
(err as CustomApiError).msg,
);
toast.error(t('knowledge.createKnowledgeBaseFailed'));
});
}
};
@@ -265,12 +257,9 @@ export default function KBForm({
<p className="text-muted-foreground">
{t('knowledge.noEnginesAvailable')}
</p>
<Link
href="/home/plugins"
className="text-sm text-primary hover:underline"
>
<p className="text-sm text-muted-foreground">
{t('knowledge.installEngineHint')}
</Link>
</p>
</div>
);
}

View File

@@ -1,157 +0,0 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
interface KBMigrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
internalKbCount: number;
externalKbCount: number;
onMigrationComplete: () => void;
}
export default function KBMigrationDialog({
open,
onOpenChange,
internalKbCount,
externalKbCount,
onMigrationComplete,
}: KBMigrationDialogProps) {
const { t } = useTranslation();
const [dismissing, setDismissing] = useState(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
toast.success(t('knowledge.migration.success'));
onOpenChange(false);
onMigrationComplete();
},
onError: (error) => {
toast.error(`${t('knowledge.migration.error')}${error}`);
},
});
const handleMigration = async (installPlugin: boolean) => {
try {
const resp = await httpClient.executeRagMigration(installPlugin);
asyncTask.startTask(resp.task_id);
} catch {
toast.error(t('knowledge.migration.error'));
}
};
const handleDismiss = async () => {
setDismissing(true);
try {
await httpClient.dismissRagMigration();
onOpenChange(false);
} catch {
toast.error(t('knowledge.migration.dismissError'));
} finally {
setDismissing(false);
}
};
const isRunning = asyncTask.status === AsyncTaskStatus.RUNNING;
const isError = asyncTask.status === AsyncTaskStatus.ERROR;
const totalCount = internalKbCount + externalKbCount;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!isRunning) onOpenChange(v);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('knowledge.migration.title')}</DialogTitle>
<DialogDescription>
{t('knowledge.migration.description')}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-3">
{!isRunning && !isError && (
<p className="text-sm text-muted-foreground">
{t('knowledge.migration.detected', {
total: totalCount,
internal: internalKbCount,
external: externalKbCount,
})}
</p>
)}
{isRunning && (
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<p className="text-sm">{t('knowledge.migration.running')}</p>
</div>
)}
{isError && (
<div className="space-y-2">
<p className="text-sm text-destructive">
{t('knowledge.migration.error')}
</p>
{asyncTask.error && (
<p className="text-xs text-muted-foreground bg-muted p-2 rounded">
{asyncTask.error}
</p>
)}
</div>
)}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
{!isRunning && !isError && (
<>
<Button onClick={() => handleMigration(true)} className="w-full">
{t('knowledge.migration.startWithInstall')}
</Button>
<Button
variant="outline"
onClick={() => handleMigration(false)}
className="w-full"
>
{t('knowledge.migration.startDataOnly')}
</Button>
<p className="text-xs text-muted-foreground text-center">
{t('knowledge.migration.dataOnlyHint')}
</p>
</>
)}
{isError && (
<Button onClick={() => handleMigration(true)} className="w-full">
{t('knowledge.migration.retry')}
</Button>
)}
{!isRunning && (
<Button
variant="ghost"
onClick={handleDismiss}
disabled={dismissing}
className="w-full text-destructive hover:text-destructive"
>
{t('knowledge.migration.dismiss')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslation } from 'react-i18next';
import { RetrieveResult } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
interface KBRetrieveGenericProps {
@@ -42,7 +41,7 @@ export default function KBRetrieveGeneric({
setResults(response.results);
} catch (error) {
console.error('Retrieve failed:', error);
toast.error(t('knowledge.retrieveError') + (error as CustomApiError).msg);
toast.error(t('knowledge.retrieveError'));
} finally {
setLoading(false);
}

View File

@@ -5,6 +5,7 @@
.knowledgeListContainer {
width: 100%;
margin-top: 2rem;
padding-left: 0.8rem;
padding-right: 0.8rem;
display: grid;

View File

@@ -7,7 +7,6 @@ import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api';
@@ -19,29 +18,10 @@ export default function KnowledgePage() {
const [selectedKbId, setSelectedKbId] = useState<string>('');
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// Migration dialog state
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
useEffect(() => {
getKnowledgeBaseList();
checkMigrationStatus();
}, []);
async function checkMigrationStatus() {
try {
const resp = await httpClient.getRagMigrationStatus();
if (resp.needed) {
setMigrationInternalCount(resp.internal_kb_count);
setMigrationExternalCount(resp.external_kb_count);
setMigrationDialogOpen(true);
}
} catch {
// Silently ignore - migration check is non-critical
}
}
async function getKnowledgeBaseList() {
const resp = await httpClient.getKnowledgeBases();
@@ -105,20 +85,8 @@ export default function KnowledgePage() {
getKnowledgeBaseList();
};
const handleMigrationComplete = () => {
getKnowledgeBaseList();
};
return (
<div>
<KBMigrationDialog
open={migrationDialogOpen}
onOpenChange={setMigrationDialogOpen}
internalKbCount={migrationInternalCount}
externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete}
/>
<KBDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}

View File

@@ -120,8 +120,6 @@ export default function PipelineFormComponent({
// Track unsaved changes by comparing current form values against a saved snapshot
const savedSnapshotRef = useRef<string>('');
// Track which dynamic form stages have completed their initial mount emission.
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => {
if (!isEditMode || !savedSnapshotRef.current) return false;
@@ -162,7 +160,6 @@ export default function PipelineFormComponent({
};
form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear();
});
}
}, []);
@@ -238,33 +235,6 @@ export default function PipelineFormComponent({
});
}
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
function handleDynamicFormEmit(
formName: keyof FormValues,
stageName: string,
values: object,
) {
const stageKey = `${String(formName)}.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stageName]: values,
});
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
// Synchronously re-capture snapshot so that the useMemo comparison
// in the same render cycle still returns false.
savedSnapshotRef.current = JSON.stringify(form.getValues());
}
}
function renderDynamicForms(
stage: PipelineConfigStage,
formName: keyof FormValues,
@@ -294,7 +264,13 @@ export default function PipelineFormComponent({
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}}
/>
</div>
@@ -326,7 +302,13 @@ export default function PipelineFormComponent({
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}}
/>
</div>
@@ -351,7 +333,13 @@ export default function PipelineFormComponent({
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}}
/>
</div>

View File

@@ -562,7 +562,8 @@ function MarketPageContent({
{/* Recommendation Lists */}
{!searchQuery &&
componentFilter === 'all' &&
selectedTags.length === 0 && (
selectedTags.length === 0 &&
currentPage === 1 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
@@ -18,7 +18,7 @@ export interface RecommendationList {
plugins: PluginV4[];
}
// Match the main plugin grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4
const PAGE_SIZE = 4; // plugins per page in a recommendation row
function pluginToVO(
plugin: PluginV4,
@@ -54,44 +54,11 @@ function RecommendationListRow({
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
const gridRef = useRef<HTMLDivElement>(null);
const plugins = list.plugins || [];
// Measure how many columns the CSS grid actually renders
const measureCols = useCallback(() => {
if (!gridRef.current) return;
const style = window.getComputedStyle(gridRef.current);
const cols = style.gridTemplateColumns.split(' ').length;
setPerPage(cols);
}, []);
useEffect(() => {
measureCols();
const observer = new ResizeObserver(measureCols);
if (gridRef.current) observer.observe(gridRef.current);
return () => observer.disconnect();
}, [measureCols]);
// Auto-advance every 5 seconds
useEffect(() => {
if (plugins.length <= perPage) return;
const timer = setInterval(() => {
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
}, 5000);
return () => clearInterval(timer);
}, [plugins.length, perPage]);
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
const safePage = Math.min(page, totalPages - 1);
if (safePage !== page) setPage(safePage);
const start = safePage * perPage;
const visiblePlugins = plugins.slice(start, start + perPage);
const totalPages = Math.ceil(plugins.length / PAGE_SIZE);
const start = page * PAGE_SIZE;
const visiblePlugins = plugins.slice(start, start + PAGE_SIZE);
if (plugins.length === 0) return null;
@@ -110,19 +77,19 @@ function RecommendationListRow({
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={safePage === 0}
disabled={page === 0}
className="h-7 w-7 p-0"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground px-1">
{safePage + 1} / {totalPages}
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={safePage >= totalPages - 1}
disabled={page >= totalPages - 1}
className="h-7 w-7 p-0"
>
<ChevronRight className="w-4 h-4" />
@@ -130,10 +97,7 @@ function RecommendationListRow({
</div>
)}
</div>
<div
ref={gridRef}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}

View File

@@ -1,12 +1,6 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Wrench,
AudioWaveform,
@@ -15,7 +9,6 @@ import {
ExternalLink,
Book,
FileText,
Info,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -53,13 +46,6 @@ export default function PluginMarketCardComponent({
Parser: <FileText className="w-4 h-4" />,
};
// Plugins that only contain KnowledgeRetriever components are deprecated
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
@@ -80,34 +66,8 @@ export default function PluginMarketCardComponent({
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId}
</div>
<div className="flex items-center gap-1.5 w-full min-w-0">
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e) => e.preventDefault()}
>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[240px] text-xs"
>
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full">
{cardVO.label}
</div>
</div>

View File

@@ -262,12 +262,6 @@ export interface ApiRespSystemInfo {
limitation: SystemLimitation;
}
export interface RagMigrationStatusResp {
needed: boolean;
internal_kb_count: number;
external_kb_count: number;
}
export interface ApiRespPluginSystemStatus {
is_enable: boolean;
is_connected: boolean;

View File

@@ -40,7 +40,6 @@ import {
ModelProvider,
ApiRespKnowledgeEngines,
ApiRespParsers,
RagMigrationStatusResp,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
@@ -356,7 +355,6 @@ export class BackendClient extends BaseHttpClient {
is_active: boolean;
platform: string | null;
user_id: string | null;
user_name: string | null;
}>;
total: number;
}> {
@@ -385,7 +383,6 @@ export class BackendClient extends BaseHttpClient {
level: string;
platform: string | null;
user_id: string | null;
user_name: string | null;
runner_name: string | null;
variables: string | null;
role: string | null;
@@ -713,23 +710,6 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/status/plugin-system');
}
// ============ RAG Migration API ============
public getRagMigrationStatus(): Promise<RagMigrationStatusResp> {
return this.get('/api/v1/knowledge/migration/status');
}
public executeRagMigration(
installPlugin: boolean = true,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/knowledge/migration/execute', {
install_plugin: installPlugin,
});
}
public dismissRagMigration(): Promise<object> {
return this.post('/api/v1/knowledge/migration/dismiss');
}
public getPluginDebugInfo(): Promise<{
debug_url: string;
plugin_debug_key: string;

View File

@@ -284,27 +284,6 @@ export default function Login() {
</form>
</Form>
)}
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
<a
href="https://langbot.app/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.privacyPolicy')}
</a>{' '}
{t('common.and')}{' '}
<a
href={t('common.dataCollectionPolicyUrl')}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.dataCollectionPolicy')}
</a>
</p>
</CardContent>
</Card>
</div>

View File

@@ -253,27 +253,6 @@ export default function Register() {
</Button>
</form>
</Form>
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
<a
href="https://langbot.app/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.privacyPolicy')}
</a>{' '}
{t('common.and')}{' '}
<a
href={t('common.dataCollectionPolicyUrl')}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.dataCollectionPolicy')}
</a>
</p>
</CardContent>
</Card>
</div>

View File

@@ -47,12 +47,6 @@ const enUS = {
copyFailed: 'Copy Failed',
test: 'Test',
forgotPassword: 'Forgot Password?',
agreementNotice: 'By continuing, you agree to our',
privacyPolicy: 'Privacy Policy',
and: 'and',
dataCollectionPolicy: 'Data Collection Policy',
dataCollectionPolicyUrl:
'https://docs.langbot.app/en/insight/data-collection-policy',
loading: 'Loading...',
fieldRequired: 'This field is required',
or: 'or',
@@ -489,9 +483,6 @@ const enUS = {
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
deprecated: 'Deprecated',
deprecatedTooltip:
'Please install the corresponding Knowledge Engine plugin.',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
@@ -716,7 +707,7 @@ const enUS = {
cannotChangeEmbeddingModel:
'Knowledge base created cannot be modified embedding model',
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
updateKnowledgeBaseFailed: 'Knowledge base update failed: ',
updateKnowledgeBaseFailed: 'Knowledge base update failed',
documentsTab: {
name: 'Name',
status: 'Status',
@@ -726,14 +717,14 @@ const enUS = {
supportedFormats:
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
uploadSuccess: 'File uploaded successfully!',
uploadError: 'File upload failed: ',
uploadError: 'File upload failed, please try again',
uploadingFile: 'Uploading file...',
fileSizeExceeded:
'File size exceeds 10MB limit. Please split into smaller files.',
actions: 'Actions',
delete: 'Delete File',
fileDeleteSuccess: 'File deleted successfully',
fileDeleteFailed: 'File deletion failed: ',
fileDeleteFailed: 'File deletion failed',
processing: 'Processing',
completed: 'Completed',
failed: 'Failed',
@@ -754,7 +745,7 @@ const enUS = {
content: 'Content',
fileName: 'File Name',
noResults: 'No results',
retrieveError: 'Retrieve failed: ',
retrieveError: 'Retrieve failed',
unknownEngine: 'Unknown Engine',
knowledgeEngine: 'Knowledge Engine',
knowledgeEngineRequired: 'Knowledge engine is required',
@@ -766,10 +757,10 @@ const enUS = {
engineSettingsReadonly: 'read-only in edit mode',
retrievalSettings: 'Retrieval Settings',
noEnginesAvailable: 'No knowledge base engines available',
installEngineHint: 'Please install a "Knowledge Engine" plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
loadKnowledgeBaseFailed: 'Failed to load knowledge base: ',
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base: ',
installEngineHint: 'Please install a knowledge base plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base',
loadKnowledgeBaseFailed: 'Failed to load knowledge base',
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base',
getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
embeddingModel: 'Embedding Model',
embeddingModelRequired: 'Embedding model is required for this engine',
@@ -782,23 +773,6 @@ const enUS = {
retrieverConfiguration: 'Retriever Configuration',
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
retrieverMarketLink: 'here',
migration: {
title: 'Knowledge Base Migration',
description:
'The new version has refactored the knowledge base into a plugin-based architecture, unifying built-in and external knowledge bases as "Knowledge Engine" plugins. Migration of legacy knowledge base data is required. Your old data has been automatically backed up in the database.',
detected:
'Found {{total}} knowledge base(s) to migrate ({{internal}} internal, {{external}} external).',
startWithInstall: 'Auto-install Plugin & Migrate',
startDataOnly: 'Migrate Data Only',
dataOnlyHint:
'"Migrate Data Only" is for offline/intranet environments. Please install the corresponding plugin manually after migration.',
dismiss: 'Discard Original Data',
running: 'Migrating knowledge bases, please wait...',
success: 'Knowledge base migration completed',
error: 'Knowledge base migration failed: ',
dismissError: 'Operation failed',
retry: 'Retry',
},
},
register: {
title: 'Initialize LangBot 👋',

View File

@@ -48,12 +48,6 @@ const jaJP = {
copyFailed: 'コピーに失敗しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
agreementNotice: '続行することで、以下に同意したものとみなされます:',
privacyPolicy: 'プライバシーポリシー',
and: 'および',
dataCollectionPolicy: 'データ収集ポリシー',
dataCollectionPolicyUrl:
'https://docs.langbot.app/ja/insight/data-collection-policy',
loading: '読み込み中...',
fieldRequired: 'この項目は必須です',
or: 'または',
@@ -497,9 +491,6 @@ const jaJP = {
noTags: 'タグがありません',
},
viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
},
mcp: {
title: 'MCP',
@@ -718,7 +709,7 @@ const jaJP = {
cannotChangeEmbeddingModel:
'知識ベース作成後は埋め込みモデルを変更できません',
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
documentsTab: {
name: '名前',
status: 'ステータス',
@@ -729,14 +720,14 @@ const jaJP = {
supportedFormats:
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
uploadSuccess: 'ファイルのアップロードに成功しました!',
uploadError: 'ファイルのアップロードに失敗しました',
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
uploadingFile: 'ファイルをアップロード中...',
fileSizeExceeded:
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
actions: 'アクション',
delete: 'ドキュメントを削除',
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
fileDeleteFailed: 'ドキュメントの削除に失敗しました',
fileDeleteFailed: 'ドキュメントの削除に失敗しました',
processing: '処理中',
completed: '完了',
failed: '失敗',
@@ -757,13 +748,10 @@ const jaJP = {
content: '内容',
fileName: 'ファイル名',
noResults: '検索結果がありません',
retrieveError: '検索に失敗しました',
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください',
retrieveError: '検索に失敗しました',
unknownEngine: '不明なエンジン',
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
addExternal: '外部ナレッジベースを追加',
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
@@ -774,23 +762,6 @@ const jaJP = {
retrieverConfiguration: '検索器設定',
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
retrieverMarketLink: 'こちらからインストールできます',
migration: {
title: 'ナレッジベースの移行',
description:
'新バージョンではナレッジベースをプラグインベースのアーキテクチャに再構築し、内蔵ナレッジベースと外部ナレッジベースを「ナレッジエンジン」プラグインとして統合しました。旧ナレッジベースデータの移行が必要です。旧データはデータベースに自動的にバックアップされています。',
detected:
'移行が必要なナレッジベースが{{total}}件見つかりました(内部{{internal}}件、外部{{external}}件)。',
startWithInstall: 'プラグインを自動インストールして移行',
startDataOnly: 'データのみ移行',
dataOnlyHint:
'「データのみ移行」はオフライン環境向けです。移行完了後に対応するプラグインを手動でインストールしてください。',
dismiss: '元データを破棄',
running: 'ナレッジベースを移行中です。しばらくお待ちください...',
success: 'ナレッジベースの移行が完了しました',
error: 'ナレッジベースの移行に失敗しました:',
dismissError: '操作に失敗しました',
retry: 'リトライ',
},
},
register: {
title: 'LangBot を初期化 👋',

View File

@@ -47,12 +47,6 @@ const zhHans = {
copyFailed: '复制失败',
test: '测试',
forgotPassword: '忘记密码?',
agreementNotice: '继续即表示您同意我们的',
privacyPolicy: '隐私政策',
and: '和',
dataCollectionPolicy: '数据收集政策',
dataCollectionPolicyUrl:
'https://docs.langbot.app/zh/insight/data-collection-policy',
loading: '加载中...',
fieldRequired: '此字段为必填项',
or: '或',
@@ -474,8 +468,6 @@ const zhHans = {
noTags: '暂无标签',
},
viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',
},
mcp: {
title: 'MCP',
@@ -687,7 +679,7 @@ const zhHans = {
updateTime: '更新于',
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知识库更新成功',
updateKnowledgeBaseFailed: '知识库更新失败',
updateKnowledgeBaseFailed: '知识库更新失败',
documentsTab: {
name: '名称',
status: '状态',
@@ -696,13 +688,13 @@ const zhHans = {
uploading: '上传中...',
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
uploadSuccess: '文件上传成功!',
uploadError: '文件上传失败',
uploadError: '文件上传失败,请重试',
uploadingFile: '上传文件中...',
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
actions: '操作',
delete: '删除文件',
fileDeleteSuccess: '文件删除成功',
fileDeleteFailed: '文件删除失败',
fileDeleteFailed: '文件删除失败',
processing: '处理中',
completed: '完成',
failed: '失败',
@@ -723,7 +715,7 @@ const zhHans = {
content: '内容',
fileName: '文件名',
noResults: '暂无结果',
retrieveError: '检索失败',
retrieveError: '检索失败',
unknownEngine: '未知引擎',
knowledgeEngine: '知识引擎',
knowledgeEngineRequired: '知识引擎不能为空',
@@ -734,10 +726,10 @@ const zhHans = {
engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置',
noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装知识引擎」插件',
createKnowledgeBaseFailed: '知识库创建失败',
loadKnowledgeBaseFailed: '知识库加载失败',
deleteKnowledgeBaseFailed: '知识库删除失败',
installEngineHint: '请先安装知识插件',
createKnowledgeBaseFailed: '知识库创建失败',
loadKnowledgeBaseFailed: '知识库加载失败',
deleteKnowledgeBaseFailed: '知识库删除失败',
getKnowledgeBaseListError: '获取知识库列表失败:',
embeddingModel: '嵌入模型',
embeddingModelRequired: '此引擎需要选择嵌入模型',
@@ -750,23 +742,6 @@ const zhHans = {
retrieverConfiguration: '检索器配置',
retrieverInstallInfo: '您可以从',
retrieverMarketLink: '此处安装知识检索器插件',
migration: {
title: '知识库迁移',
description:
'新版本已将知识库重构为插件化架构,并统一内置知识库和外部知识库为「知识引擎」插件,需要对旧知识库数据进行迁移。您的旧数据已自动备份在数据库中。',
detected:
'共检测到 {{total}} 个知识库需要迁移({{internal}} 个内置知识库,{{external}} 个外部知识库)。',
startWithInstall: '自动安装插件并迁移',
startDataOnly: '仅迁移数据',
dataOnlyHint:
'「仅迁移数据」适合内网环境使用,请在迁移完成后自行安装对应插件',
dismiss: '丢弃原数据',
running: '正在迁移知识库,请稍候...',
success: '知识库迁移完成',
error: '知识库迁移失败:',
dismissError: '操作失败',
retry: '重试',
},
},
register: {
title: '初始化 LangBot 👋',

View File

@@ -47,12 +47,6 @@ const zhHant = {
copyFailed: '複製失敗',
test: '測試',
forgotPassword: '忘記密碼?',
agreementNotice: '繼續即表示您同意我們的',
privacyPolicy: '隱私政策',
and: '和',
dataCollectionPolicy: '數據收集政策',
dataCollectionPolicyUrl:
'https://docs.langbot.app/zh/insight/data-collection-policy',
loading: '載入中...',
fieldRequired: '此欄位為必填',
or: '或',
@@ -467,8 +461,6 @@ const zhHant = {
noTags: '暫無標籤',
},
viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',
},
mcp: {
title: 'MCP',
@@ -680,7 +672,7 @@ const zhHant = {
updateTime: '更新於',
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知識庫更新成功',
updateKnowledgeBaseFailed: '知識庫更新失敗',
updateKnowledgeBaseFailed: '知識庫更新失敗',
documentsTab: {
name: '名稱',
status: '狀態',
@@ -689,13 +681,13 @@ const zhHant = {
uploading: '上傳中...',
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
uploadSuccess: '文檔上傳成功!',
uploadError: '文檔上傳失敗',
uploadError: '文檔上傳失敗,請重試',
uploadingFile: '上傳文檔中...',
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
actions: '操作',
delete: '刪除文檔',
fileDeleteSuccess: '文檔刪除成功',
fileDeleteFailed: '文檔刪除失敗',
fileDeleteFailed: '文檔刪除失敗',
processing: '處理中',
completed: '完成',
failed: '失敗',
@@ -716,12 +708,10 @@ const zhHant = {
content: '內容',
fileName: '文檔名稱',
noResults: '暫無結果',
retrieveError: '檢索失敗',
noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件',
retrieveError: '檢索失敗',
unknownEngine: '未知引擎',
loadKnowledgeBaseFailed: '知識庫載入失敗',
deleteKnowledgeBaseFailed: '知識庫刪除失敗',
loadKnowledgeBaseFailed: '知識庫載入失敗',
deleteKnowledgeBaseFailed: '知識庫刪除失敗',
getKnowledgeBaseListError: '取得知識庫列表失敗:',
addExternal: '添加外部知識庫',
createExternalSuccess: '外部知識庫創建成功',
@@ -732,23 +722,6 @@ const zhHant = {
retrieverConfiguration: '檢索器配置',
retrieverInstallInfo: '您可以從',
retrieverMarketLink: '此處安裝知識檢索器插件',
migration: {
title: '知識庫遷移',
description:
'新版本已將知識庫重構為插件化架構,並統一內建知識庫和外部知識庫為「知識引擎」插件,需要對舊知識庫資料進行遷移。您的舊資料已自動備份在資料庫中。',
detected:
'共檢測到 {{total}} 個知識庫需要遷移({{internal}} 個內建知識庫,{{external}} 個外部知識庫)。',
startWithInstall: '自動安裝插件並遷移',
startDataOnly: '僅遷移資料',
dataOnlyHint:
'「僅遷移資料」適合內網環境使用,請在遷移完成後自行安裝對應插件',
dismiss: '丟棄原數據',
running: '正在遷移知識庫,請稍候...',
success: '知識庫遷移完成',
error: '知識庫遷移失敗:',
dismissError: '操作失敗',
retry: '重試',
},
},
register: {
title: '初始化 LangBot 👋',