mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 12:56:02 +00:00
Compare commits
2 Commits
copilot/fi
...
feat/long-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662e6a4815 | ||
|
|
c92d3d7ad7 |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.0"
|
version = "4.8.7"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -61,10 +61,10 @@ dependencies = [
|
|||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"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)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.0.0b7",
|
||||||
"langbot-plugin==0.3.0",
|
"langbot-plugin==0.3.0rc1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.0'
|
__version__ = '4.8.7'
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
from urllib.parse import quote
|
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -68,31 +67,6 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
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):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Callable
|
|||||||
from .wecomcsevent import WecomCSEvent
|
from .wecomcsevent import WecomCSEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
@@ -35,10 +34,6 @@ class WecomCSClient:
|
|||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
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:
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
@@ -383,53 +378,3 @@ class WecomCSClient:
|
|||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, image: platform_message.Image):
|
||||||
media_id = await self.upload_to_work(image=image)
|
media_id = await self.upload_to_work(image=image)
|
||||||
return media_id
|
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
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -30,7 +30,6 @@ class MonitoringService:
|
|||||||
level: str = 'info',
|
level: str = 'info',
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
user_name: str | None = None,
|
|
||||||
runner_name: str | None = None,
|
runner_name: str | None = None,
|
||||||
variables: str | None = None,
|
variables: str | None = None,
|
||||||
role: str = 'user',
|
role: str = 'user',
|
||||||
@@ -50,7 +49,6 @@ class MonitoringService:
|
|||||||
'level': level,
|
'level': level,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
'user_name': user_name,
|
|
||||||
'runner_name': runner_name,
|
'runner_name': runner_name,
|
||||||
'variables': variables,
|
'variables': variables,
|
||||||
'role': role,
|
'role': role,
|
||||||
@@ -154,7 +152,6 @@ class MonitoringService:
|
|||||||
pipeline_name: str,
|
pipeline_name: str,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
user_name: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record a new session"""
|
"""Record a new session"""
|
||||||
session_data = {
|
session_data = {
|
||||||
@@ -169,7 +166,6 @@ class MonitoringService:
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
'user_name': user_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class MonitoringMessage(Base):
|
|||||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = 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
|
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
|
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
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)
|
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = 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):
|
class MonitoringError(Base):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from .. import migration
|
from .. import migration
|
||||||
|
|
||||||
@@ -7,22 +9,20 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
"""Migrate to unified Knowledge Engine plugin architecture.
|
"""Migrate to unified Knowledge Engine plugin architecture.
|
||||||
|
|
||||||
Changes:
|
Changes:
|
||||||
- Backup existing knowledge_bases data to knowledge_bases_backup
|
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases
|
||||||
- Clear knowledge_bases table and add new plugin architecture columns
|
- Migrate existing top_k values into retrieval_settings JSON
|
||||||
- Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
|
- Migrate existing embedding_model_uuid into creation_settings JSON
|
||||||
- Preserve external_knowledge_bases table as-is for future migration
|
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped)
|
||||||
- Set rag_plugin_migration_needed flag in metadata if old data exists
|
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def upgrade(self):
|
async def upgrade(self):
|
||||||
"""Upgrade"""
|
"""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._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()
|
await self._drop_old_columns()
|
||||||
if has_internal_data or has_external_data:
|
await self._drop_external_knowledge_bases_table()
|
||||||
await self._set_migration_flag()
|
|
||||||
|
|
||||||
async def _get_table_columns(self, table_name: str) -> list[str]:
|
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||||
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
"""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
|
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):
|
async def _add_columns_to_knowledge_bases(self):
|
||||||
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
||||||
columns = await self._get_table_columns('knowledge_bases')
|
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};')
|
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):
|
async def _drop_old_columns(self):
|
||||||
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
"""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;')
|
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _set_migration_flag(self):
|
async def _drop_external_knowledge_bases_table(self):
|
||||||
"""Set rag_plugin_migration_needed flag in metadata table."""
|
"""Drop the external_knowledge_bases table if it exists."""
|
||||||
# Check if the key already exists
|
if await self._table_exists('external_knowledge_bases'):
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
# Log existing external KBs before dropping, so users are aware of data loss
|
||||||
sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
|
rows = await self.ap.persistence_mgr.execute_async(
|
||||||
)
|
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
||||||
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';")
|
|
||||||
)
|
)
|
||||||
else:
|
existing = rows.fetchall()
|
||||||
await self.ap.persistence_mgr.execute_async(
|
if existing:
|
||||||
sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
|
self.ap.logger.warning(
|
||||||
)
|
'Dropping external_knowledge_bases table with %d existing record(s). '
|
||||||
self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
|
'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):
|
async def downgrade(self):
|
||||||
"""Downgrade"""
|
"""Downgrade"""
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -22,10 +22,13 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
strategy_impl: strategy.LongTextStrategy | None
|
strategy_impl: strategy.LongTextStrategy | None
|
||||||
|
is_split: bool
|
||||||
|
|
||||||
async def initialize(self, pipeline_config: dict):
|
async def initialize(self, pipeline_config: dict):
|
||||||
config = pipeline_config['output']['long-text-processing']
|
config = pipeline_config['output']['long-text-processing']
|
||||||
|
|
||||||
|
self.is_split = config['strategy'] == 'split'
|
||||||
|
|
||||||
if config['strategy'] == 'none':
|
if config['strategy'] == 'none':
|
||||||
self.strategy_impl = None
|
self.strategy_impl = None
|
||||||
return
|
return
|
||||||
@@ -90,8 +93,23 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
len(str(query.resp_message_chain[-1]))
|
len(str(query.resp_message_chain[-1]))
|
||||||
> query.pipeline_config['output']['long-text-processing']['threshold']
|
> query.pipeline_config['output']['long-text-processing']['threshold']
|
||||||
):
|
):
|
||||||
query.resp_message_chain[-1] = platform_message.MessageChain(
|
if self.is_split:
|
||||||
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
|
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)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
224
src/langbot/pkg/pipeline/longtext/strategies/split.py
Normal file
224
src/langbot/pkg/pipeline/longtext/strategies/split.py
Normal 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]
|
||||||
@@ -34,15 +34,6 @@ class MonitoringHelper:
|
|||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
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
|
# Try to record message
|
||||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||||
@@ -66,7 +57,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
variables=None, # Will be updated in record_query_success
|
variables=None, # Will be updated in record_query_success
|
||||||
)
|
)
|
||||||
@@ -90,7 +80,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_id
|
return message_id
|
||||||
@@ -139,15 +128,6 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
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
|
# Extract response content from resp_message_chain
|
||||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||||
# Serialize the last response message chain
|
# Serialize the last response message chain
|
||||||
@@ -182,7 +162,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
)
|
)
|
||||||
@@ -204,15 +183,6 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
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
|
# Record error message
|
||||||
message_id = await ap.monitoring_service.record_message(
|
message_id = await ap.monitoring_service.record_message(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
@@ -227,7 +197,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ..utils import importutil
|
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.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -421,14 +420,6 @@ class PipelineManager:
|
|||||||
elif isinstance(pipeline_entity, dict):
|
elif isinstance(pipeline_entity, dict):
|
||||||
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
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
|
# initialize stage containers according to pipeline_entity.stages
|
||||||
stage_containers: list[StageInstContainer] = []
|
stage_containers: list[StageInstContainer] = []
|
||||||
for stage_name in pipeline_entity.stages:
|
for stage_name in pipeline_entity.stages:
|
||||||
|
|||||||
@@ -149,19 +149,12 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
result_type=entities.ResultType.INTERRUPT,
|
result_type=entities.ResultType.INTERRUPT,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
user_notice=user_notice,
|
user_notice='请求失败' if hide_exception_info else f'{e}',
|
||||||
error_notice=f'{e}',
|
error_notice=f'{e}',
|
||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
quote_origin=quote_origin,
|
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)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -282,8 +282,6 @@ class PlatformManager:
|
|||||||
return runtime_bot
|
return runtime_bot
|
||||||
|
|
||||||
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
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:
|
for bot in self.bots:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
return bot
|
return bot
|
||||||
|
|||||||
@@ -37,24 +37,16 @@ class WebSocketSession:
|
|||||||
id: str
|
id: str
|
||||||
message_lists: dict[str, list[WebSocketMessage]] = {}
|
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||||
"""消息列表 {pipeline_uuid: [messages]}"""
|
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||||
stream_message_indexes: dict[str, dict[str, int]] = {}
|
|
||||||
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
|
|
||||||
|
|
||||||
def __init__(self, id: str):
|
def __init__(self, id: str):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.message_lists = {}
|
self.message_lists = {}
|
||||||
self.stream_message_indexes = {}
|
|
||||||
|
|
||||||
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||||
if pipeline_uuid not in self.message_lists:
|
if pipeline_uuid not in self.message_lists:
|
||||||
self.message_lists[pipeline_uuid] = []
|
self.message_lists[pipeline_uuid] = []
|
||||||
return 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):
|
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
"""WebSocket适配器 - 支持双向实时通信"""
|
"""WebSocket适配器 - 支持双向实时通信"""
|
||||||
@@ -97,46 +89,20 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
) -> dict:
|
) -> 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。
|
await self.outbound_message_queue.put(message_data)
|
||||||
我们需要尝试两种方式来确保消息能够送达。
|
|
||||||
"""
|
|
||||||
# 获取当前的 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'
|
|
||||||
|
|
||||||
# 选择会话
|
return 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()
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
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
|
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'
|
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
message_list = session.get_message_list(pipeline_uuid)
|
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.
|
# 检查是否是新的流式消息(通过bot_message对象判断)
|
||||||
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
||||||
resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
|
if not message_list or message_list[-1].is_final:
|
||||||
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):
|
|
||||||
# 创建新消息
|
# 创建新消息
|
||||||
msg_id = len(message_list) + 1
|
msg_id = len(message_list) + 1
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
@@ -221,31 +181,27 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
is_final=message_is_final,
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
|
# 只有在is_final时才保存到历史记录
|
||||||
message_list.append(message_data)
|
if is_final and bot_message.tool_calls is None:
|
||||||
if resp_message_id:
|
message_list.append(message_data)
|
||||||
stream_message_indexes[resp_message_id] = len(message_list) - 1
|
|
||||||
else:
|
else:
|
||||||
# 更新同一条流式消息
|
# 更新最后一条消息
|
||||||
old_message = message_list[existing_index]
|
msg_id = message_list[-1].id
|
||||||
msg_id = old_message.id
|
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=old_message.timestamp, # 保持原始时间戳
|
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
||||||
is_final=message_is_final,
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 更新历史记录中的对应消息
|
# 如果是final,更新历史记录中的最后一条
|
||||||
message_list[existing_index] = message_data
|
if is_final and bot_message.tool_calls is None:
|
||||||
|
message_list[-1] = message_data
|
||||||
if message_is_final and resp_message_id:
|
|
||||||
stream_message_indexes.pop(resp_message_id, None)
|
|
||||||
|
|
||||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
@@ -454,10 +410,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if session_type == 'person':
|
if session_type == 'person':
|
||||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
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:
|
else:
|
||||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
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] = {}
|
|
||||||
|
|||||||
@@ -148,54 +148,51 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if type(event) is platform_events.FriendMessage:
|
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
|
@staticmethod
|
||||||
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
async def target2yiri(event: WecomEvent):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信事件。
|
event (WecomEvent): 企业微信事件。
|
||||||
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
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':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
@@ -213,6 +210,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'secret',
|
'secret',
|
||||||
'token',
|
'token',
|
||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
|
'contacts_secret',
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
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'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
contacts_secret=config['contacts_secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
unified_mode=True,
|
||||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
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)
|
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
# user_id is the original FromUserName from WecomEvent
|
fixed_user_id = Wecom_event.user_id
|
||||||
user_id = Wecom_event.user_id
|
# 删掉开头的u
|
||||||
|
fixed_user_id = fixed_user_id[1:]
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
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':
|
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':
|
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':
|
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):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
@@ -288,7 +287,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomEvent):
|
async def on_message(event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
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:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: contacts_secret
|
||||||
|
label:
|
||||||
|
en_US: Contacts Secret
|
||||||
|
zh_Hans: 通讯录密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
- name: api_base_url
|
- name: api_base_url
|
||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
|
|||||||
@@ -81,33 +81,22 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
async def target2yiri(event: WecomCSEvent):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信客服事件。
|
event (WecomEvent): 企业微信客服事件。
|
||||||
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
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':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.user_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,7 +106,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.user_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,7 +187,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomCSEvent):
|
async def on_message(event: WecomCSEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
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:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -337,14 +337,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||||
|
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
|
||||||
# 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]
|
|
||||||
|
|
||||||
result = await llm_model.provider.invoke_llm(
|
result = await llm_model.provider.invoke_llm(
|
||||||
query=None,
|
query=None,
|
||||||
|
|||||||
@@ -441,7 +441,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
is_final = False
|
is_final = False
|
||||||
think_start = False
|
think_start = False
|
||||||
think_end = False
|
think_end = False
|
||||||
yielded_final = False
|
|
||||||
|
|
||||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
@@ -494,19 +493,13 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
if answer:
|
if answer:
|
||||||
basic_mode_pending_chunk = answer
|
basic_mode_pending_chunk = answer
|
||||||
|
|
||||||
if (
|
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final):
|
||||||
not yielded_final
|
|
||||||
and (is_final or message_idx % 8 == 0)
|
|
||||||
and (basic_mode_pending_chunk != '' or is_final)
|
|
||||||
):
|
|
||||||
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=basic_mode_pending_chunk,
|
content=basic_mode_pending_chunk,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
)
|
)
|
||||||
if is_final:
|
|
||||||
yielded_final = True
|
|
||||||
|
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||||
|
|||||||
@@ -74,13 +74,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await kb.retrieve(
|
result = await kb.retrieve(user_message_text)
|
||||||
user_message_text,
|
|
||||||
settings={
|
|
||||||
'sender_id': str(query.sender_id),
|
|
||||||
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
all_results.extend(result)
|
all_results.extend(result)
|
||||||
|
|||||||
@@ -321,19 +321,13 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
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 = {
|
retrieval_context = {
|
||||||
'query': query,
|
'query': query,
|
||||||
'knowledge_base_id': kb.uuid,
|
'knowledge_base_id': kb.uuid,
|
||||||
'collection_id': kb.collection_id or kb.uuid,
|
'collection_id': kb.collection_id or kb.uuid,
|
||||||
'retrieval_settings': settings,
|
'retrieval_settings': settings,
|
||||||
'creation_settings': kb.creation_settings or {},
|
'creation_settings': kb.creation_settings or {},
|
||||||
'filters': filters,
|
'filters': settings.pop('filters', {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
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"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from chromadb import PersistentClient
|
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
|
from langbot.pkg.core import app
|
||||||
import chromadb
|
import chromadb
|
||||||
import chromadb.errors
|
import chromadb.errors
|
||||||
|
|
||||||
# RRF smoothing constant (standard value from the literature)
|
|
||||||
_RRF_K = 60
|
|
||||||
|
|
||||||
|
|
||||||
class ChromaVectorDatabase(VectorDatabase):
|
class ChromaVectorDatabase(VectorDatabase):
|
||||||
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
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.client = PersistentClient(path=base_path)
|
||||||
self._collections = {}
|
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:
|
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
||||||
if collection not in self._collections:
|
if collection not in self._collections:
|
||||||
self._collections[collection] = await asyncio.to_thread(
|
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)
|
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
||||||
if documents is not None:
|
if documents is not None:
|
||||||
kwargs['documents'] = documents
|
kwargs['documents'] = documents
|
||||||
await asyncio.to_thread(col.upsert, **kwargs)
|
await asyncio.to_thread(col.add, **kwargs)
|
||||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
|
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
@@ -54,23 +47,6 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
col = await self.get_or_create_collection(collection)
|
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_kwargs: dict[str, Any] = dict(
|
||||||
query_embeddings=query_embedding,
|
query_embeddings=query_embedding,
|
||||||
n_results=k,
|
n_results=k,
|
||||||
@@ -79,137 +55,9 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
if filter:
|
if filter:
|
||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(col.query, **query_kwargs)
|
results = await asyncio.to_thread(col.query, **query_kwargs)
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.")
|
||||||
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
|
||||||
)
|
|
||||||
return 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:
|
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
||||||
|
|||||||
@@ -95,12 +95,11 @@
|
|||||||
"max": 0
|
"max": 0
|
||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"exception-handling": "show-hint",
|
"hide-exception": true,
|
||||||
"failure-hint": "Request failed.",
|
|
||||||
"at-sender": true,
|
"at-sender": true,
|
||||||
"quote-origin": true,
|
"quote-origin": true,
|
||||||
"track-function-calls": false,
|
"track-function-calls": false,
|
||||||
"remove-think": false
|
"remove-think": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,10 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Convert to Image
|
en_US: Convert to Image
|
||||||
zh_Hans: 转换为图片
|
zh_Hans: 转换为图片
|
||||||
|
- name: split
|
||||||
|
label:
|
||||||
|
en_US: Split into Multiple Messages
|
||||||
|
zh_Hans: 分割为多条消息发送
|
||||||
- name: none
|
- name: none
|
||||||
label:
|
label:
|
||||||
en_US: None
|
en_US: None
|
||||||
@@ -78,39 +82,13 @@ stages:
|
|||||||
en_US: Misc
|
en_US: Misc
|
||||||
zh_Hans: 杂项
|
zh_Hans: 杂项
|
||||||
config:
|
config:
|
||||||
- name: exception-handling
|
- name: hide-exception
|
||||||
label:
|
label:
|
||||||
en_US: Exception Handling Strategy
|
en_US: Hide Exception
|
||||||
zh_Hans: 异常处理策略
|
zh_Hans: 不输出异常信息给用户
|
||||||
description:
|
type: boolean
|
||||||
en_US: Controls how error messages are displayed to the user when an AI request fails
|
|
||||||
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
|
|
||||||
type: select
|
|
||||||
required: true
|
required: true
|
||||||
default: show-hint
|
default: true
|
||||||
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.'
|
|
||||||
- name: at-sender
|
- name: at-sender
|
||||||
label:
|
label:
|
||||||
en_US: At Sender
|
en_US: At Sender
|
||||||
@@ -145,4 +123,3 @@ stages:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -6,7 +6,6 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Copy, Check } from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -28,7 +27,6 @@ interface SessionInfo {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
user_id?: string | null;
|
user_id?: string | null;
|
||||||
user_name?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMessage {
|
interface SessionMessage {
|
||||||
@@ -62,29 +60,8 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
|||||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
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 () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setLoadingSessions(true);
|
setLoadingSessions(true);
|
||||||
try {
|
try {
|
||||||
@@ -361,36 +338,24 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
<span className="text-sm font-medium truncate mr-2">
|
<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>
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
||||||
{formatRelativeTime(session.last_activity)}
|
{formatRelativeTime(session.last_activity)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<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 && (
|
{session.platform && (
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
{session.platform}
|
{session.platform}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{session.user_id && (
|
|
||||||
<span className="truncate text-[10px]">
|
|
||||||
{abbreviateId(session.user_id)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{session.is_active && (
|
{session.is_active && (
|
||||||
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
<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 className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{session.pipeline_name}</span>
|
<span>{session.pipeline_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<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>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{parseSessionType(selectedSessionId) && (
|
|
||||||
<span>{parseSessionType(selectedSessionId)}</span>
|
|
||||||
)}
|
|
||||||
{selectedSession?.platform && (
|
{selectedSession?.platform && (
|
||||||
<>
|
<span>{selectedSession.platform}</span>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{selectedSession?.pipeline_name && (
|
{selectedSession?.pipeline_name && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
{selectedSession?.platform && <span>·</span>}
|
||||||
<span>{selectedSession.pipeline_name}</span>
|
<span>{selectedSession.pipeline_name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -422,12 +422,12 @@ export default function HomeSidebar({
|
|||||||
const language = localStorage.getItem('langbot_language');
|
const language = localStorage.getItem('langbot_language');
|
||||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||||
window.open(
|
window.open(
|
||||||
'https://docs.langbot.app/zh/insight/guide',
|
'https://docs.langbot.app/zh/insight/guide.html',
|
||||||
'_blank',
|
'_blank',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
window.open(
|
window.open(
|
||||||
'https://docs.langbot.app/en/insight/guide',
|
'https://docs.langbot.app/en/insight/guide.html',
|
||||||
'_blank',
|
'_blank',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export const sidebarConfigList = [
|
|||||||
route: '/home/bots',
|
route: '/home/bots',
|
||||||
description: t('bots.description'),
|
description: t('bots.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://docs.langbot.app/en/usage/platforms/readme',
|
en_US: 'https://docs.langbot.app/en/usage/platforms/readme.html',
|
||||||
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
|
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme.html',
|
||||||
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
|
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme.html',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new SidebarChildVO({
|
new SidebarChildVO({
|
||||||
@@ -44,9 +44,9 @@ export const sidebarConfigList = [
|
|||||||
route: '/home/pipelines',
|
route: '/home/pipelines',
|
||||||
description: t('pipelines.description'),
|
description: t('pipelines.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',
|
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme.html',
|
||||||
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
|
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme.html',
|
||||||
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
|
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme.html',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new SidebarChildVO({
|
new SidebarChildVO({
|
||||||
@@ -65,8 +65,8 @@ export const sidebarConfigList = [
|
|||||||
route: '/home/monitoring',
|
route: '/home/monitoring',
|
||||||
description: t('monitoring.description'),
|
description: t('monitoring.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: '',
|
en_US: 'https://docs.langbot.app/en/features/monitoring.html',
|
||||||
zh_Hans: '',
|
zh_Hans: 'https://docs.langbot.app/zh/features/monitoring.html',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new SidebarChildVO({
|
new SidebarChildVO({
|
||||||
@@ -84,9 +84,9 @@ export const sidebarConfigList = [
|
|||||||
route: '/home/knowledge',
|
route: '/home/knowledge',
|
||||||
description: t('knowledge.description'),
|
description: t('knowledge.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',
|
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme.html',
|
||||||
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
|
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme.html',
|
||||||
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
|
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme.html',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new SidebarChildVO({
|
new SidebarChildVO({
|
||||||
@@ -105,9 +105,9 @@ export const sidebarConfigList = [
|
|||||||
route: '/home/plugins',
|
route: '/home/plugins',
|
||||||
description: t('plugins.description'),
|
description: t('plugins.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://docs.langbot.app/en/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',
|
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro.html',
|
||||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
|
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro.html',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export default function NewVersionDialog({
|
|||||||
const getUpdateDocsUrl = () => {
|
const getUpdateDocsUrl = () => {
|
||||||
const language = i18n.language;
|
const language = i18n.language;
|
||||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
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') {
|
} else if (language === 'ja-JP') {
|
||||||
return 'https://docs.langbot.app/ja/deploy/update';
|
return 'https://docs.langbot.app/ja/deploy/update.html';
|
||||||
} else {
|
} else {
|
||||||
return 'https://docs.langbot.app/en/deploy/update';
|
return 'https://docs.langbot.app/en/deploy/update.html';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||||
@@ -69,9 +68,7 @@ export default function KBDetailDialog({
|
|||||||
setKbInfo(resp.base);
|
setKbInfo(resp.base);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load KB info:', e);
|
console.error('Failed to load KB info:', e);
|
||||||
toast.error(
|
toast.error(t('knowledge.loadKnowledgeBaseFailed'));
|
||||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +136,7 @@ export default function KBDetailDialog({
|
|||||||
onKbDeleted();
|
onKbDeleted();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete KB:', e);
|
console.error('Failed to delete KB:', e);
|
||||||
toast.error(
|
toast.error(t('knowledge.deleteKnowledgeBaseFailed'));
|
||||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ParserInfo } from '@/app/infra/entities/api';
|
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';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
|
||||||
interface FileUploadZoneProps {
|
interface FileUploadZoneProps {
|
||||||
@@ -97,9 +97,7 @@ export default function FileUploadZone({
|
|||||||
onUploadSuccess();
|
onUploadSuccess();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('File upload failed:', error);
|
console.error('File upload failed:', error);
|
||||||
const errorMessage =
|
const errorMessage = t('knowledge.documentsTab.uploadError');
|
||||||
t('knowledge.documentsTab.uploadError') +
|
|
||||||
(error as CustomApiError).msg;
|
|
||||||
toast.error(errorMessage, { id: toastId });
|
toast.error(errorMessage, { id: toastId });
|
||||||
onUploadError(errorMessage);
|
onUploadError(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { KnowledgeBaseFile } from '@/app/infra/entities/api';
|
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 { columns, DocumentFile } from './documents/columns';
|
||||||
import { DataTable } from './documents/data-table';
|
import { DataTable } from './documents/data-table';
|
||||||
import FileUploadZone from './FileUploadZone';
|
import FileUploadZone from './FileUploadZone';
|
||||||
@@ -87,10 +87,7 @@ export default function KBDoc({
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Delete failed:', error);
|
console.error('Delete failed:', error);
|
||||||
toast.error(
|
toast.error(t('knowledge.documentsTab.fileDeleteFailed'));
|
||||||
t('knowledge.documentsTab.fileDeleteFailed') +
|
|
||||||
(error as CustomApiError).msg,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
@@ -219,10 +217,7 @@ export default function KBForm({
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('update knowledge base failed', err);
|
console.error('update knowledge base failed', err);
|
||||||
toast.error(
|
toast.error(t('knowledge.updateKnowledgeBaseFailed'));
|
||||||
t('knowledge.updateKnowledgeBaseFailed') +
|
|
||||||
(err as CustomApiError).msg,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create knowledge base
|
// Create knowledge base
|
||||||
@@ -233,10 +228,7 @@ export default function KBForm({
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('create knowledge base failed', err);
|
console.error('create knowledge base failed', err);
|
||||||
toast.error(
|
toast.error(t('knowledge.createKnowledgeBaseFailed'));
|
||||||
t('knowledge.createKnowledgeBaseFailed') +
|
|
||||||
(err as CustomApiError).msg,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -265,12 +257,9 @@ export default function KBForm({
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t('knowledge.noEnginesAvailable')}
|
{t('knowledge.noEnginesAvailable')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<p className="text-sm text-muted-foreground">
|
||||||
href="/home/plugins"
|
|
||||||
className="text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t('knowledge.installEngineHint')}
|
{t('knowledge.installEngineHint')}
|
||||||
</Link>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RetrieveResult } from '@/app/infra/entities/api';
|
import { RetrieveResult } from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface KBRetrieveGenericProps {
|
interface KBRetrieveGenericProps {
|
||||||
@@ -42,7 +41,7 @@ export default function KBRetrieveGeneric({
|
|||||||
setResults(response.results);
|
setResults(response.results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Retrieve failed:', error);
|
console.error('Retrieve failed:', error);
|
||||||
toast.error(t('knowledge.retrieveError') + (error as CustomApiError).msg);
|
toast.error(t('knowledge.retrieveError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
.knowledgeListContainer {
|
.knowledgeListContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 2rem;
|
||||||
padding-left: 0.8rem;
|
padding-left: 0.8rem;
|
||||||
padding-right: 0.8rem;
|
padding-right: 0.8rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
||||||
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
||||||
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
|
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 { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
@@ -19,29 +18,10 @@ export default function KnowledgePage() {
|
|||||||
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
||||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Migration dialog state
|
|
||||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
|
||||||
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
|
|
||||||
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getKnowledgeBaseList();
|
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() {
|
async function getKnowledgeBaseList() {
|
||||||
const resp = await httpClient.getKnowledgeBases();
|
const resp = await httpClient.getKnowledgeBases();
|
||||||
|
|
||||||
@@ -105,20 +85,8 @@ export default function KnowledgePage() {
|
|||||||
getKnowledgeBaseList();
|
getKnowledgeBaseList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMigrationComplete = () => {
|
|
||||||
getKnowledgeBaseList();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<KBMigrationDialog
|
|
||||||
open={migrationDialogOpen}
|
|
||||||
onOpenChange={setMigrationDialogOpen}
|
|
||||||
internalKbCount={migrationInternalCount}
|
|
||||||
externalKbCount={migrationExternalCount}
|
|
||||||
onMigrationComplete={handleMigrationComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KBDetailDialog
|
<KBDetailDialog
|
||||||
open={detailDialogOpen}
|
open={detailDialogOpen}
|
||||||
onOpenChange={setDetailDialogOpen}
|
onOpenChange={setDetailDialogOpen}
|
||||||
|
|||||||
@@ -120,8 +120,6 @@ export default function PipelineFormComponent({
|
|||||||
|
|
||||||
// Track unsaved changes by comparing current form values against a saved snapshot
|
// Track unsaved changes by comparing current form values against a saved snapshot
|
||||||
const savedSnapshotRef = useRef<string>('');
|
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 watchedValues = form.watch();
|
||||||
const hasUnsavedChanges = useMemo(() => {
|
const hasUnsavedChanges = useMemo(() => {
|
||||||
if (!isEditMode || !savedSnapshotRef.current) return false;
|
if (!isEditMode || !savedSnapshotRef.current) return false;
|
||||||
@@ -162,7 +160,6 @@ export default function PipelineFormComponent({
|
|||||||
};
|
};
|
||||||
form.reset(loadedValues);
|
form.reset(loadedValues);
|
||||||
savedSnapshotRef.current = JSON.stringify(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(
|
function renderDynamicForms(
|
||||||
stage: PipelineConfigStage,
|
stage: PipelineConfigStage,
|
||||||
formName: keyof FormValues,
|
formName: keyof FormValues,
|
||||||
@@ -294,7 +264,13 @@ export default function PipelineFormComponent({
|
|||||||
{}
|
{}
|
||||||
}
|
}
|
||||||
onSubmit={(values) => {
|
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>
|
</div>
|
||||||
@@ -326,7 +302,13 @@ export default function PipelineFormComponent({
|
|||||||
{}
|
{}
|
||||||
}
|
}
|
||||||
onSubmit={(values) => {
|
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>
|
</div>
|
||||||
@@ -351,7 +333,13 @@ export default function PipelineFormComponent({
|
|||||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||||
}
|
}
|
||||||
onSubmit={(values) => {
|
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>
|
</div>
|
||||||
|
|||||||
@@ -562,7 +562,8 @@ function MarketPageContent({
|
|||||||
{/* Recommendation Lists */}
|
{/* Recommendation Lists */}
|
||||||
{!searchQuery &&
|
{!searchQuery &&
|
||||||
componentFilter === 'all' &&
|
componentFilter === 'all' &&
|
||||||
selectedTags.length === 0 && (
|
selectedTags.length === 0 &&
|
||||||
|
currentPage === 1 && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<RecommendationLists
|
<RecommendationLists
|
||||||
lists={recommendationLists}
|
lists={recommendationLists}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
@@ -18,7 +18,7 @@ export interface RecommendationList {
|
|||||||
plugins: PluginV4[];
|
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(
|
function pluginToVO(
|
||||||
plugin: PluginV4,
|
plugin: PluginV4,
|
||||||
@@ -54,44 +54,11 @@ function RecommendationListRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [perPage, setPerPage] = useState(4);
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const plugins = list.plugins || [];
|
const plugins = list.plugins || [];
|
||||||
|
const totalPages = Math.ceil(plugins.length / PAGE_SIZE);
|
||||||
// Measure how many columns the CSS grid actually renders
|
const start = page * PAGE_SIZE;
|
||||||
const measureCols = useCallback(() => {
|
const visiblePlugins = plugins.slice(start, start + PAGE_SIZE);
|
||||||
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);
|
|
||||||
|
|
||||||
if (plugins.length === 0) return null;
|
if (plugins.length === 0) return null;
|
||||||
|
|
||||||
@@ -110,19 +77,19 @@ function RecommendationListRow({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={safePage === 0}
|
disabled={page === 0}
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground px-1">
|
<span className="text-xs text-muted-foreground px-1">
|
||||||
{safePage + 1} / {totalPages}
|
{page + 1} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={safePage >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -130,10 +97,7 @@ function RecommendationListRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||||
ref={gridRef}
|
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
|
|
||||||
>
|
|
||||||
{visiblePlugins.map((plugin) => (
|
{visiblePlugins.map((plugin) => (
|
||||||
<PluginMarketCardComponent
|
<PluginMarketCardComponent
|
||||||
key={plugin.author + ' / ' + plugin.name}
|
key={plugin.author + ' / ' + plugin.name}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import {
|
import {
|
||||||
Wrench,
|
Wrench,
|
||||||
AudioWaveform,
|
AudioWaveform,
|
||||||
@@ -15,7 +9,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Book,
|
Book,
|
||||||
FileText,
|
FileText,
|
||||||
Info,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -53,13 +46,6 @@ export default function PluginMarketCardComponent({
|
|||||||
Parser: <FileText className="w-4 h-4" />,
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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">
|
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||||
{cardVO.pluginId}
|
{cardVO.pluginId}
|
||||||
</div>
|
</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 w-full">
|
||||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
|
{cardVO.label}
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -262,12 +262,6 @@ export interface ApiRespSystemInfo {
|
|||||||
limitation: SystemLimitation;
|
limitation: SystemLimitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RagMigrationStatusResp {
|
|
||||||
needed: boolean;
|
|
||||||
internal_kb_count: number;
|
|
||||||
external_kb_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiRespPluginSystemStatus {
|
export interface ApiRespPluginSystemStatus {
|
||||||
is_enable: boolean;
|
is_enable: boolean;
|
||||||
is_connected: boolean;
|
is_connected: boolean;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
ModelProvider,
|
ModelProvider,
|
||||||
ApiRespKnowledgeEngines,
|
ApiRespKnowledgeEngines,
|
||||||
ApiRespParsers,
|
ApiRespParsers,
|
||||||
RagMigrationStatusResp,
|
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||||
@@ -356,7 +355,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
user_name: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -385,7 +383,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
level: string;
|
level: string;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
user_name: string | null;
|
|
||||||
runner_name: string | null;
|
runner_name: string | null;
|
||||||
variables: string | null;
|
variables: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
@@ -713,23 +710,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get('/api/v1/system/status/plugin-system');
|
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<{
|
public getPluginDebugInfo(): Promise<{
|
||||||
debug_url: string;
|
debug_url: string;
|
||||||
plugin_debug_key: string;
|
plugin_debug_key: string;
|
||||||
|
|||||||
@@ -284,27 +284,6 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,27 +253,6 @@ export default function Register() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ const enUS = {
|
|||||||
copyFailed: 'Copy Failed',
|
copyFailed: 'Copy Failed',
|
||||||
test: 'Test',
|
test: 'Test',
|
||||||
forgotPassword: 'Forgot Password?',
|
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...',
|
loading: 'Loading...',
|
||||||
fieldRequired: 'This field is required',
|
fieldRequired: 'This field is required',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
@@ -489,9 +483,6 @@ const enUS = {
|
|||||||
allComponents: 'All Components',
|
allComponents: 'All Components',
|
||||||
requestPlugin: 'Request Plugin',
|
requestPlugin: 'Request Plugin',
|
||||||
viewDetails: 'View Details',
|
viewDetails: 'View Details',
|
||||||
deprecated: 'Deprecated',
|
|
||||||
deprecatedTooltip:
|
|
||||||
'Please install the corresponding Knowledge Engine plugin.',
|
|
||||||
tags: {
|
tags: {
|
||||||
filterByTags: 'Filter by Tags',
|
filterByTags: 'Filter by Tags',
|
||||||
selected: 'selected',
|
selected: 'selected',
|
||||||
@@ -716,7 +707,7 @@ const enUS = {
|
|||||||
cannotChangeEmbeddingModel:
|
cannotChangeEmbeddingModel:
|
||||||
'Knowledge base created cannot be modified embedding model',
|
'Knowledge base created cannot be modified embedding model',
|
||||||
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
|
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
|
||||||
updateKnowledgeBaseFailed: 'Knowledge base update failed: ',
|
updateKnowledgeBaseFailed: 'Knowledge base update failed',
|
||||||
documentsTab: {
|
documentsTab: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
@@ -726,14 +717,14 @@ const enUS = {
|
|||||||
supportedFormats:
|
supportedFormats:
|
||||||
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
|
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
|
||||||
uploadSuccess: 'File uploaded successfully!',
|
uploadSuccess: 'File uploaded successfully!',
|
||||||
uploadError: 'File upload failed: ',
|
uploadError: 'File upload failed, please try again',
|
||||||
uploadingFile: 'Uploading file...',
|
uploadingFile: 'Uploading file...',
|
||||||
fileSizeExceeded:
|
fileSizeExceeded:
|
||||||
'File size exceeds 10MB limit. Please split into smaller files.',
|
'File size exceeds 10MB limit. Please split into smaller files.',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
delete: 'Delete File',
|
delete: 'Delete File',
|
||||||
fileDeleteSuccess: 'File deleted successfully',
|
fileDeleteSuccess: 'File deleted successfully',
|
||||||
fileDeleteFailed: 'File deletion failed: ',
|
fileDeleteFailed: 'File deletion failed',
|
||||||
processing: 'Processing',
|
processing: 'Processing',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
@@ -754,7 +745,7 @@ const enUS = {
|
|||||||
content: 'Content',
|
content: 'Content',
|
||||||
fileName: 'File Name',
|
fileName: 'File Name',
|
||||||
noResults: 'No results',
|
noResults: 'No results',
|
||||||
retrieveError: 'Retrieve failed: ',
|
retrieveError: 'Retrieve failed',
|
||||||
unknownEngine: 'Unknown Engine',
|
unknownEngine: 'Unknown Engine',
|
||||||
knowledgeEngine: 'Knowledge Engine',
|
knowledgeEngine: 'Knowledge Engine',
|
||||||
knowledgeEngineRequired: 'Knowledge engine is required',
|
knowledgeEngineRequired: 'Knowledge engine is required',
|
||||||
@@ -766,10 +757,10 @@ const enUS = {
|
|||||||
engineSettingsReadonly: 'read-only in edit mode',
|
engineSettingsReadonly: 'read-only in edit mode',
|
||||||
retrievalSettings: 'Retrieval Settings',
|
retrievalSettings: 'Retrieval Settings',
|
||||||
noEnginesAvailable: 'No knowledge base engines available',
|
noEnginesAvailable: 'No knowledge base engines available',
|
||||||
installEngineHint: 'Please install a "Knowledge Engine" plugin first',
|
installEngineHint: 'Please install a knowledge base plugin first',
|
||||||
createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
|
createKnowledgeBaseFailed: 'Failed to create knowledge base',
|
||||||
loadKnowledgeBaseFailed: 'Failed to load knowledge base: ',
|
loadKnowledgeBaseFailed: 'Failed to load knowledge base',
|
||||||
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base: ',
|
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base',
|
||||||
getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
|
getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
|
||||||
embeddingModel: 'Embedding Model',
|
embeddingModel: 'Embedding Model',
|
||||||
embeddingModelRequired: 'Embedding model is required for this engine',
|
embeddingModelRequired: 'Embedding model is required for this engine',
|
||||||
@@ -782,23 +773,6 @@ const enUS = {
|
|||||||
retrieverConfiguration: 'Retriever Configuration',
|
retrieverConfiguration: 'Retriever Configuration',
|
||||||
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
|
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
|
||||||
retrieverMarketLink: 'here',
|
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: {
|
register: {
|
||||||
title: 'Initialize LangBot 👋',
|
title: 'Initialize LangBot 👋',
|
||||||
|
|||||||
@@ -48,12 +48,6 @@ const jaJP = {
|
|||||||
copyFailed: 'コピーに失敗しました',
|
copyFailed: 'コピーに失敗しました',
|
||||||
test: 'テスト',
|
test: 'テスト',
|
||||||
forgotPassword: 'パスワードを忘れた?',
|
forgotPassword: 'パスワードを忘れた?',
|
||||||
agreementNotice: '続行することで、以下に同意したものとみなされます:',
|
|
||||||
privacyPolicy: 'プライバシーポリシー',
|
|
||||||
and: 'および',
|
|
||||||
dataCollectionPolicy: 'データ収集ポリシー',
|
|
||||||
dataCollectionPolicyUrl:
|
|
||||||
'https://docs.langbot.app/ja/insight/data-collection-policy',
|
|
||||||
loading: '読み込み中...',
|
loading: '読み込み中...',
|
||||||
fieldRequired: 'この項目は必須です',
|
fieldRequired: 'この項目は必須です',
|
||||||
or: 'または',
|
or: 'または',
|
||||||
@@ -497,9 +491,6 @@ const jaJP = {
|
|||||||
noTags: 'タグがありません',
|
noTags: 'タグがありません',
|
||||||
},
|
},
|
||||||
viewDetails: '詳細を表示',
|
viewDetails: '詳細を表示',
|
||||||
deprecated: '非推奨',
|
|
||||||
deprecatedTooltip:
|
|
||||||
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
|
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
@@ -718,7 +709,7 @@ const jaJP = {
|
|||||||
cannotChangeEmbeddingModel:
|
cannotChangeEmbeddingModel:
|
||||||
'知識ベース作成後は埋め込みモデルを変更できません',
|
'知識ベース作成後は埋め込みモデルを変更できません',
|
||||||
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
|
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
|
||||||
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました:',
|
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
|
||||||
documentsTab: {
|
documentsTab: {
|
||||||
name: '名前',
|
name: '名前',
|
||||||
status: 'ステータス',
|
status: 'ステータス',
|
||||||
@@ -729,14 +720,14 @@ const jaJP = {
|
|||||||
supportedFormats:
|
supportedFormats:
|
||||||
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
|
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
|
||||||
uploadSuccess: 'ファイルのアップロードに成功しました!',
|
uploadSuccess: 'ファイルのアップロードに成功しました!',
|
||||||
uploadError: 'ファイルのアップロードに失敗しました:',
|
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
|
||||||
uploadingFile: 'ファイルをアップロード中...',
|
uploadingFile: 'ファイルをアップロード中...',
|
||||||
fileSizeExceeded:
|
fileSizeExceeded:
|
||||||
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
|
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
|
||||||
actions: 'アクション',
|
actions: 'アクション',
|
||||||
delete: 'ドキュメントを削除',
|
delete: 'ドキュメントを削除',
|
||||||
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
|
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
|
||||||
fileDeleteFailed: 'ドキュメントの削除に失敗しました:',
|
fileDeleteFailed: 'ドキュメントの削除に失敗しました',
|
||||||
processing: '処理中',
|
processing: '処理中',
|
||||||
completed: '完了',
|
completed: '完了',
|
||||||
failed: '失敗',
|
failed: '失敗',
|
||||||
@@ -757,13 +748,10 @@ const jaJP = {
|
|||||||
content: '内容',
|
content: '内容',
|
||||||
fileName: 'ファイル名',
|
fileName: 'ファイル名',
|
||||||
noResults: '検索結果がありません',
|
noResults: '検索結果がありません',
|
||||||
retrieveError: '検索に失敗しました:',
|
retrieveError: '検索に失敗しました',
|
||||||
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
|
|
||||||
installEngineHint:
|
|
||||||
'先に「ナレッジエンジン」プラグインをインストールしてください',
|
|
||||||
unknownEngine: '不明なエンジン',
|
unknownEngine: '不明なエンジン',
|
||||||
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました:',
|
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
|
||||||
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました:',
|
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
|
||||||
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
|
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
|
||||||
addExternal: '外部ナレッジベースを追加',
|
addExternal: '外部ナレッジベースを追加',
|
||||||
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
|
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
|
||||||
@@ -774,23 +762,6 @@ const jaJP = {
|
|||||||
retrieverConfiguration: '検索器設定',
|
retrieverConfiguration: '検索器設定',
|
||||||
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
|
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
|
||||||
retrieverMarketLink: 'こちらからインストールできます',
|
retrieverMarketLink: 'こちらからインストールできます',
|
||||||
migration: {
|
|
||||||
title: 'ナレッジベースの移行',
|
|
||||||
description:
|
|
||||||
'新バージョンではナレッジベースをプラグインベースのアーキテクチャに再構築し、内蔵ナレッジベースと外部ナレッジベースを「ナレッジエンジン」プラグインとして統合しました。旧ナレッジベースデータの移行が必要です。旧データはデータベースに自動的にバックアップされています。',
|
|
||||||
detected:
|
|
||||||
'移行が必要なナレッジベースが{{total}}件見つかりました(内部{{internal}}件、外部{{external}}件)。',
|
|
||||||
startWithInstall: 'プラグインを自動インストールして移行',
|
|
||||||
startDataOnly: 'データのみ移行',
|
|
||||||
dataOnlyHint:
|
|
||||||
'「データのみ移行」はオフライン環境向けです。移行完了後に対応するプラグインを手動でインストールしてください。',
|
|
||||||
dismiss: '元データを破棄',
|
|
||||||
running: 'ナレッジベースを移行中です。しばらくお待ちください...',
|
|
||||||
success: 'ナレッジベースの移行が完了しました',
|
|
||||||
error: 'ナレッジベースの移行に失敗しました:',
|
|
||||||
dismissError: '操作に失敗しました',
|
|
||||||
retry: 'リトライ',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: 'LangBot を初期化 👋',
|
title: 'LangBot を初期化 👋',
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ const zhHans = {
|
|||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
test: '测试',
|
test: '测试',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
agreementNotice: '继续即表示您同意我们的',
|
|
||||||
privacyPolicy: '隐私政策',
|
|
||||||
and: '和',
|
|
||||||
dataCollectionPolicy: '数据收集政策',
|
|
||||||
dataCollectionPolicyUrl:
|
|
||||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
fieldRequired: '此字段为必填项',
|
fieldRequired: '此字段为必填项',
|
||||||
or: '或',
|
or: '或',
|
||||||
@@ -474,8 +468,6 @@ const zhHans = {
|
|||||||
noTags: '暂无标签',
|
noTags: '暂无标签',
|
||||||
},
|
},
|
||||||
viewDetails: '查看详情',
|
viewDetails: '查看详情',
|
||||||
deprecated: '已弃用',
|
|
||||||
deprecatedTooltip: '请安装对应「知识引擎」插件',
|
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
@@ -687,7 +679,7 @@ const zhHans = {
|
|||||||
updateTime: '更新于',
|
updateTime: '更新于',
|
||||||
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
|
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
|
||||||
updateKnowledgeBaseSuccess: '知识库更新成功',
|
updateKnowledgeBaseSuccess: '知识库更新成功',
|
||||||
updateKnowledgeBaseFailed: '知识库更新失败:',
|
updateKnowledgeBaseFailed: '知识库更新失败',
|
||||||
documentsTab: {
|
documentsTab: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
@@ -696,13 +688,13 @@ const zhHans = {
|
|||||||
uploading: '上传中...',
|
uploading: '上传中...',
|
||||||
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
|
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
|
||||||
uploadSuccess: '文件上传成功!',
|
uploadSuccess: '文件上传成功!',
|
||||||
uploadError: '文件上传失败:',
|
uploadError: '文件上传失败,请重试',
|
||||||
uploadingFile: '上传文件中...',
|
uploadingFile: '上传文件中...',
|
||||||
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
|
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
delete: '删除文件',
|
delete: '删除文件',
|
||||||
fileDeleteSuccess: '文件删除成功',
|
fileDeleteSuccess: '文件删除成功',
|
||||||
fileDeleteFailed: '文件删除失败:',
|
fileDeleteFailed: '文件删除失败',
|
||||||
processing: '处理中',
|
processing: '处理中',
|
||||||
completed: '完成',
|
completed: '完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
@@ -723,7 +715,7 @@ const zhHans = {
|
|||||||
content: '内容',
|
content: '内容',
|
||||||
fileName: '文件名',
|
fileName: '文件名',
|
||||||
noResults: '暂无结果',
|
noResults: '暂无结果',
|
||||||
retrieveError: '检索失败:',
|
retrieveError: '检索失败',
|
||||||
unknownEngine: '未知引擎',
|
unknownEngine: '未知引擎',
|
||||||
knowledgeEngine: '知识引擎',
|
knowledgeEngine: '知识引擎',
|
||||||
knowledgeEngineRequired: '知识引擎不能为空',
|
knowledgeEngineRequired: '知识引擎不能为空',
|
||||||
@@ -734,10 +726,10 @@ const zhHans = {
|
|||||||
engineSettingsReadonly: '编辑模式下不可修改',
|
engineSettingsReadonly: '编辑模式下不可修改',
|
||||||
retrievalSettings: '检索设置',
|
retrievalSettings: '检索设置',
|
||||||
noEnginesAvailable: '没有可用的知识库引擎',
|
noEnginesAvailable: '没有可用的知识库引擎',
|
||||||
installEngineHint: '请先安装「知识引擎」插件',
|
installEngineHint: '请先安装知识库插件',
|
||||||
createKnowledgeBaseFailed: '知识库创建失败:',
|
createKnowledgeBaseFailed: '知识库创建失败',
|
||||||
loadKnowledgeBaseFailed: '知识库加载失败:',
|
loadKnowledgeBaseFailed: '知识库加载失败',
|
||||||
deleteKnowledgeBaseFailed: '知识库删除失败:',
|
deleteKnowledgeBaseFailed: '知识库删除失败',
|
||||||
getKnowledgeBaseListError: '获取知识库列表失败:',
|
getKnowledgeBaseListError: '获取知识库列表失败:',
|
||||||
embeddingModel: '嵌入模型',
|
embeddingModel: '嵌入模型',
|
||||||
embeddingModelRequired: '此引擎需要选择嵌入模型',
|
embeddingModelRequired: '此引擎需要选择嵌入模型',
|
||||||
@@ -750,23 +742,6 @@ const zhHans = {
|
|||||||
retrieverConfiguration: '检索器配置',
|
retrieverConfiguration: '检索器配置',
|
||||||
retrieverInstallInfo: '您可以从',
|
retrieverInstallInfo: '您可以从',
|
||||||
retrieverMarketLink: '此处安装知识检索器插件',
|
retrieverMarketLink: '此处安装知识检索器插件',
|
||||||
migration: {
|
|
||||||
title: '知识库迁移',
|
|
||||||
description:
|
|
||||||
'新版本已将知识库重构为插件化架构,并统一内置知识库和外部知识库为「知识引擎」插件,需要对旧知识库数据进行迁移。您的旧数据已自动备份在数据库中。',
|
|
||||||
detected:
|
|
||||||
'共检测到 {{total}} 个知识库需要迁移({{internal}} 个内置知识库,{{external}} 个外部知识库)。',
|
|
||||||
startWithInstall: '自动安装插件并迁移',
|
|
||||||
startDataOnly: '仅迁移数据',
|
|
||||||
dataOnlyHint:
|
|
||||||
'「仅迁移数据」适合内网环境使用,请在迁移完成后自行安装对应插件',
|
|
||||||
dismiss: '丢弃原数据',
|
|
||||||
running: '正在迁移知识库,请稍候...',
|
|
||||||
success: '知识库迁移完成',
|
|
||||||
error: '知识库迁移失败:',
|
|
||||||
dismissError: '操作失败',
|
|
||||||
retry: '重试',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: '初始化 LangBot 👋',
|
title: '初始化 LangBot 👋',
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ const zhHant = {
|
|||||||
copyFailed: '複製失敗',
|
copyFailed: '複製失敗',
|
||||||
test: '測試',
|
test: '測試',
|
||||||
forgotPassword: '忘記密碼?',
|
forgotPassword: '忘記密碼?',
|
||||||
agreementNotice: '繼續即表示您同意我們的',
|
|
||||||
privacyPolicy: '隱私政策',
|
|
||||||
and: '和',
|
|
||||||
dataCollectionPolicy: '數據收集政策',
|
|
||||||
dataCollectionPolicyUrl:
|
|
||||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
|
||||||
loading: '載入中...',
|
loading: '載入中...',
|
||||||
fieldRequired: '此欄位為必填',
|
fieldRequired: '此欄位為必填',
|
||||||
or: '或',
|
or: '或',
|
||||||
@@ -467,8 +461,6 @@ const zhHant = {
|
|||||||
noTags: '暫無標籤',
|
noTags: '暫無標籤',
|
||||||
},
|
},
|
||||||
viewDetails: '查看詳情',
|
viewDetails: '查看詳情',
|
||||||
deprecated: '已棄用',
|
|
||||||
deprecatedTooltip: '請安裝對應「知識引擎」插件',
|
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
@@ -680,7 +672,7 @@ const zhHant = {
|
|||||||
updateTime: '更新於',
|
updateTime: '更新於',
|
||||||
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
|
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
|
||||||
updateKnowledgeBaseSuccess: '知識庫更新成功',
|
updateKnowledgeBaseSuccess: '知識庫更新成功',
|
||||||
updateKnowledgeBaseFailed: '知識庫更新失敗:',
|
updateKnowledgeBaseFailed: '知識庫更新失敗',
|
||||||
documentsTab: {
|
documentsTab: {
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
status: '狀態',
|
status: '狀態',
|
||||||
@@ -689,13 +681,13 @@ const zhHant = {
|
|||||||
uploading: '上傳中...',
|
uploading: '上傳中...',
|
||||||
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
|
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
|
||||||
uploadSuccess: '文檔上傳成功!',
|
uploadSuccess: '文檔上傳成功!',
|
||||||
uploadError: '文檔上傳失敗:',
|
uploadError: '文檔上傳失敗,請重試',
|
||||||
uploadingFile: '上傳文檔中...',
|
uploadingFile: '上傳文檔中...',
|
||||||
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
|
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
delete: '刪除文檔',
|
delete: '刪除文檔',
|
||||||
fileDeleteSuccess: '文檔刪除成功',
|
fileDeleteSuccess: '文檔刪除成功',
|
||||||
fileDeleteFailed: '文檔刪除失敗:',
|
fileDeleteFailed: '文檔刪除失敗',
|
||||||
processing: '處理中',
|
processing: '處理中',
|
||||||
completed: '完成',
|
completed: '完成',
|
||||||
failed: '失敗',
|
failed: '失敗',
|
||||||
@@ -716,12 +708,10 @@ const zhHant = {
|
|||||||
content: '內容',
|
content: '內容',
|
||||||
fileName: '文檔名稱',
|
fileName: '文檔名稱',
|
||||||
noResults: '暫無結果',
|
noResults: '暫無結果',
|
||||||
retrieveError: '檢索失敗:',
|
retrieveError: '檢索失敗',
|
||||||
noEnginesAvailable: '沒有可用的知識庫引擎',
|
|
||||||
installEngineHint: '請先安裝「知識引擎」插件',
|
|
||||||
unknownEngine: '未知引擎',
|
unknownEngine: '未知引擎',
|
||||||
loadKnowledgeBaseFailed: '知識庫載入失敗:',
|
loadKnowledgeBaseFailed: '知識庫載入失敗',
|
||||||
deleteKnowledgeBaseFailed: '知識庫刪除失敗:',
|
deleteKnowledgeBaseFailed: '知識庫刪除失敗',
|
||||||
getKnowledgeBaseListError: '取得知識庫列表失敗:',
|
getKnowledgeBaseListError: '取得知識庫列表失敗:',
|
||||||
addExternal: '添加外部知識庫',
|
addExternal: '添加外部知識庫',
|
||||||
createExternalSuccess: '外部知識庫創建成功',
|
createExternalSuccess: '外部知識庫創建成功',
|
||||||
@@ -732,23 +722,6 @@ const zhHant = {
|
|||||||
retrieverConfiguration: '檢索器配置',
|
retrieverConfiguration: '檢索器配置',
|
||||||
retrieverInstallInfo: '您可以從',
|
retrieverInstallInfo: '您可以從',
|
||||||
retrieverMarketLink: '此處安裝知識檢索器插件',
|
retrieverMarketLink: '此處安裝知識檢索器插件',
|
||||||
migration: {
|
|
||||||
title: '知識庫遷移',
|
|
||||||
description:
|
|
||||||
'新版本已將知識庫重構為插件化架構,並統一內建知識庫和外部知識庫為「知識引擎」插件,需要對舊知識庫資料進行遷移。您的舊資料已自動備份在資料庫中。',
|
|
||||||
detected:
|
|
||||||
'共檢測到 {{total}} 個知識庫需要遷移({{internal}} 個內建知識庫,{{external}} 個外部知識庫)。',
|
|
||||||
startWithInstall: '自動安裝插件並遷移',
|
|
||||||
startDataOnly: '僅遷移資料',
|
|
||||||
dataOnlyHint:
|
|
||||||
'「僅遷移資料」適合內網環境使用,請在遷移完成後自行安裝對應插件',
|
|
||||||
dismiss: '丟棄原數據',
|
|
||||||
running: '正在遷移知識庫,請稍候...',
|
|
||||||
success: '知識庫遷移完成',
|
|
||||||
error: '知識庫遷移失敗:',
|
|
||||||
dismissError: '操作失敗',
|
|
||||||
retry: '重試',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: '初始化 LangBot 👋',
|
title: '初始化 LangBot 👋',
|
||||||
|
|||||||
Reference in New Issue
Block a user