Compare commits

..

12 Commits

Author SHA1 Message Date
Junyan Qin
2e061a8713 feat: update version to 4.9.0 in pyproject.toml, __init__.py, and uv.lock 2026-03-09 20:09:00 +08:00
Junyan Qin
2cd8c56fe8 feat: update migration messages for knowledge base in multiple languages 2026-03-09 19:57:13 +08:00
youhuanghe
e09ae8f1a8 feat: add external plugin auto download 2026-03-09 09:55:12 +00:00
youhuanghe
aa7b0deb2b fix: show 2026-03-09 09:26:29 +00:00
youhuanghe
1c9a800f9d wq
Merge branch 'master' into feat/dbm20-rag
2026-03-09 08:26:05 +00:00
youhuanghe
96f24d73d5 feat: add external migration 2026-03-09 08:18:23 +00:00
youhuanghe
14ea8ca7b6 fix ruff lint 2026-03-09 01:26:39 +00:00
youhuanghe
f0093dab69 fix lint 2026-03-09 01:23:56 +00:00
youhuanghe
c29e6586b3 refactor: to red and no more 2026-03-09 01:08:56 +00:00
youhuanghe
1b37dababa feat(rag): add data-only migration option and fix dialog width
Add option to migrate knowledge base data without auto-installing
the LangRAG plugin (for offline/intranet environments). Also
narrow the migration dialog to match other confirmation dialogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:05:05 +00:00
youhuanghe
8da52b6dc7 fix(rag): query marketplace for actual plugin version instead of 'latest'
The marketplace API does not support 'latest' as a version string.
Fetch the plugin info first to get latest_version, then use that
concrete version for installation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:41:43 +00:00
youhuanghe
67c5c3af20 feat(rag): add knowledge base migration from v4.9.0 to plugin architecture
Rewrite dbm020 to backup old knowledge_bases data and preserve
external_knowledge_bases table. Add migration API endpoints and
frontend dialog so users can opt-in to auto-install LangRAG plugin
and restore their knowledge bases with original UUIDs preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:17:34 +00:00
42 changed files with 483 additions and 1598 deletions

View File

@@ -61,7 +61,7 @@ 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.1.0.post3",
"langbot-plugin==0.3.0", "langbot-plugin==0.3.0",

View File

@@ -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

View File

@@ -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(

View File

@@ -9,7 +9,6 @@ from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher from ..platform.webhook_pusher import WebhookPusher
from ..provider.session import sessionmgr as llm_session_mgr from ..provider.session import sessionmgr as llm_session_mgr
from ..provider.modelmgr import modelmgr as llm_model_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
from ..config import manager as config_mgr from ..config import manager as config_mgr
from ..command import cmdmgr from ..command import cmdmgr
@@ -31,7 +30,6 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service from ..api.http.service import monitoring as monitoring_service
from ..discover import engine as discover_engine from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr from ..storage import mgr as storagemgr
from ..utils import logcache from ..utils import logcache

View File

@@ -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):

View File

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

View File

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

View File

@@ -1,102 +0,0 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(23)
class DBMigrateModelFallbackConfig(migration.DBMigration):
"""Convert model field from plain UUID string to object with primary/fallbacks"""
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 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
changed = False
# Convert model from string to object
model_value = local_agent.get('model', '')
if isinstance(model_value, str):
local_agent['model'] = {
'primary': model_value,
'fallbacks': [],
}
changed = True
# Remove leftover fallback-models field if present
if 'fallback-models' in local_agent:
del local_agent['fallback-models']
changed = True
if not changed:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
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"""
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 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
# Convert model from object back to string
model_value = local_agent.get('model', '')
if isinstance(model_value, dict):
local_agent['model'] = model_value.get('primary', '')
else:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
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},
)

View File

@@ -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,
) )

View File

@@ -36,36 +36,17 @@ class PreProcessor(stage.PipelineStage):
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
# When not local-agent, llm_model is None # When not local-agent, llm_model is None
llm_model = None try:
if selected_runner == 'local-agent': llm_model = (
# Read model config — new format is { primary: str, fallbacks: [str] }, await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
# but handle legacy plain string for backward compatibility if selected_runner == 'local-agent'
model_config = query.pipeline_config['ai']['local-agent'].get('model', {}) else None
if isinstance(model_config, str): )
# Legacy format: plain UUID string except ValueError:
primary_uuid = model_config self.ap.logger.warning(
fallback_uuids = [] f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
else: )
primary_uuid = model_config.get('primary', '') llm_model = None
fallback_uuids = model_config.get('fallbacks', [])
if primary_uuid:
try:
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
# Resolve fallback model UUIDs
if fallback_uuids:
valid_fallbacks = []
for fb_uuid in fallback_uuids:
try:
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
valid_fallbacks.append(fb_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
if valid_fallbacks:
query.variables['_fallback_model_uuids'] = valid_fallbacks
conversation = await self.ap.sess_mgr.get_conversation( conversation = await self.ap.sess_mgr.get_conversation(
query, query,
@@ -80,28 +61,20 @@ class PreProcessor(stage.PipelineStage):
query.prompt = conversation.prompt.copy() query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy() query.messages = conversation.messages.copy()
if selected_runner == 'local-agent': if selected_runner == 'local-agent' and llm_model:
query.use_funcs = [] query.use_funcs = []
if llm_model: query.use_llm_model_uuid = llm_model.model_entity.uuid
query.use_llm_model_uuid = llm_model.model_entity.uuid
if llm_model.model_entity.abilities.__contains__('func_call'): if llm_model.model_entity.abilities.__contains__('func_call'):
# Get bound plugins and MCP servers for filtering tools # Get bound plugins and MCP servers for filtering tools
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
# If primary model doesn't support func_call but fallback models exist,
# load tools anyway since fallback models may support them
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
sender_name = '' sender_name = ''
if isinstance(query.message_event, platform_events.GroupMessage): if isinstance(query.message_event, platform_events.GroupMessage):

View File

@@ -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(),
) )

View File

@@ -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()}')

View File

@@ -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配置')

View File

@@ -4,7 +4,6 @@ import json
import copy import copy
import typing import typing
from .. import runner from .. import runner
from ..modelmgr import requester as modelmgr_requester
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context import langbot_plugin.api.entities.builtin.rag.context as rag_context
@@ -27,117 +26,21 @@ Respond in the same language as the user's input.
@runner.runner_class('local-agent') @runner.runner_class('local-agent')
class LocalAgentRunner(runner.RequestRunner): class LocalAgentRunner(runner.RequestRunner):
"""Local agent request runner""" """本地Agent请求运行器"""
async def _get_model_candidates( class ToolCallTracker:
self, """工具调用追踪器"""
query: pipeline_query.Query,
) -> list[modelmgr_requester.RuntimeLLMModel]:
"""Build ordered list of models to try: primary model + fallback models."""
candidates = []
# Primary model def __init__(self):
if query.use_llm_model_uuid: self.active_calls: dict[str, dict] = {}
try: self.completed_calls: list[provider_message.ToolCall] = []
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
candidates.append(primary)
except ValueError:
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
# Fallback models
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
for fb_uuid in fallback_uuids:
try:
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
candidates.append(fb_model)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
return candidates
async def _invoke_with_fallback(
self,
query: pipeline_query.Query,
candidates: list[modelmgr_requester.RuntimeLLMModel],
messages: list,
funcs: list,
remove_think: bool,
) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
last_error = None
for model in candidates:
try:
msg = await model.provider.invoke_llm(
query,
model,
messages,
funcs if model.model_entity.abilities.__contains__('func_call') else [],
extra_args=model.model_entity.extra_args,
remove_think=remove_think,
)
return msg, model
except Exception as e:
last_error = e
self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')
raise last_error or RuntimeError('No model candidates available')
async def _invoke_stream_with_fallback(
self,
query: pipeline_query.Query,
candidates: list[modelmgr_requester.RuntimeLLMModel],
messages: list,
funcs: list,
remove_think: bool,
) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:
"""Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).
Fallback is only possible before any chunks have been yielded to the client.
Once streaming starts, the model is committed.
"""
last_error = None
for model in candidates:
try:
stream = model.provider.invoke_llm_stream(
query,
model,
messages,
funcs if model.model_entity.abilities.__contains__('func_call') else [],
extra_args=model.model_entity.extra_args,
remove_think=remove_think,
)
# Attempt to get the first chunk to verify the stream works
first_chunk = await stream.__anext__()
async def _chain_stream(first, rest):
yield first
async for chunk in rest:
yield chunk
return _chain_stream(first_chunk, stream), model
except StopAsyncIteration:
# Empty stream — treat as success (model returned nothing)
async def _empty_stream():
return
yield # make it a generator
return _empty_stream(), model
except Exception as e:
last_error = e
self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')
raise last_error or RuntimeError('No model candidates available')
async def run( async def run(
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""Run request""" """运行请求"""
pending_tool_calls = [] pending_tool_calls = []
# Agent loop protection config
agent_config = query.pipeline_config['ai']['local-agent']
max_tool_iterations = agent_config.get('max-tool-iterations', 16)
max_tool_result_chars = agent_config.get('max-tool-result-chars', 8000)
iteration_count = 0
# Get knowledge bases list (new field) # Get knowledge bases list (new field)
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
@@ -171,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)
@@ -216,51 +113,51 @@ class LocalAgentRunner(runner.RequestRunner):
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think') remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
# Build ordered candidate list (primary + fallbacks) use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
candidates = await self._get_model_candidates(query)
if not candidates:
raise RuntimeError('No LLM model configured for local-agent runner')
self.ap.logger.debug( self.ap.logger.debug(
f'localagent req: query={query.query_id} req_messages={req_messages} ' f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
f'candidates={[m.model_entity.name for m in candidates]}'
) )
if not is_stream: if not is_stream:
# Non-streaming: invoke with fallback # 非流式输出,直接请求
msg, use_llm_model = await self._invoke_with_fallback(
msg = await use_llm_model.provider.invoke_llm(
query, query,
candidates, use_llm_model,
req_messages, req_messages,
query.use_funcs, query.use_funcs,
remove_think, extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think,
) )
yield msg yield msg
final_msg = msg final_msg = msg
else: else:
# Streaming: invoke with fallback # 流式输出,需要处理工具调用
tool_calls_map: dict[str, provider_message.ToolCall] = {} tool_calls_map: dict[str, provider_message.ToolCall] = {}
msg_idx = 0 msg_idx = 0
accumulated_content = '' accumulated_content = '' # 从开始累积的所有内容
last_role = 'assistant' last_role = 'assistant'
msg_sequence = 1 msg_sequence = 1
async for msg in use_llm_model.provider.invoke_llm_stream(
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
query, query,
candidates, use_llm_model,
req_messages, req_messages,
query.use_funcs, query.use_funcs,
remove_think, extra_args=use_llm_model.model_entity.extra_args,
) remove_think=remove_think,
async for msg in stream_src: ):
msg_idx = msg_idx + 1 msg_idx = msg_idx + 1
# 记录角色
if msg.role: if msg.role:
last_role = msg.role last_role = msg.role
# 累积内容
if msg.content: if msg.content:
accumulated_content += msg.content accumulated_content += msg.content
# 处理工具调用
if msg.tool_calls: if msg.tool_calls:
for tool_call in msg.tool_calls: for tool_call in msg.tool_calls:
if tool_call.id not in tool_calls_map: if tool_call.id not in tool_calls_map:
@@ -272,18 +169,21 @@ class LocalAgentRunner(runner.RequestRunner):
), ),
) )
if tool_call.function and tool_call.function.arguments: if tool_call.function and tool_call.function.arguments:
# 流式处理中工具调用参数可能分多个chunk返回需要追加而不是覆盖
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
# continue
# 每8个chunk或最后一个chunk时输出所有累积的内容
if msg_idx % 8 == 0 or msg.is_final: if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1 msg_sequence += 1
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, content=accumulated_content, # 输出所有累积内容
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
is_final=msg.is_final, is_final=msg.is_final,
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
) )
# 创建最终消息用于后续处理
final_msg = provider_message.MessageChunk( final_msg = provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, content=accumulated_content,
@@ -298,17 +198,8 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(final_msg) req_messages.append(final_msg)
# Once a model succeeds, commit to it for the tool call loop # 持续请求,只要还有待处理的工具调用就继续处理调用
# (no fallback mid-conversation — different models may interpret tool results differently)
while pending_tool_calls: while pending_tool_calls:
iteration_count += 1
if iteration_count > max_tool_iterations:
self.ap.logger.warning(
f'localagent: query={query.query_id} agent loop exceeded max iterations ({max_tool_iterations}), '
f'forcing termination'
)
break
for tool_call in pending_tool_calls: for tool_call in pending_tool_calls:
try: try:
func = tool_call.function func = tool_call.function
@@ -331,14 +222,6 @@ class LocalAgentRunner(runner.RequestRunner):
else: else:
tool_content = json.dumps(func_ret, ensure_ascii=False) tool_content = json.dumps(func_ret, ensure_ascii=False)
# Truncate oversized tool results to prevent context overflow
if isinstance(tool_content, str) and len(tool_content) > max_tool_result_chars:
self.ap.logger.warning(
f'localagent: tool {func.name} returned {len(tool_content)} chars, '
f'truncating to {max_tool_result_chars}'
)
tool_content = tool_content[:max_tool_result_chars] + '\n...[result truncated]'
if is_stream: if is_stream:
msg = provider_message.MessageChunk( msg = provider_message.MessageChunk(
role='tool', role='tool',
@@ -356,6 +239,7 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(msg) req_messages.append(msg)
except Exception as e: except Exception as e:
# 工具调用出错,添加一个报错信息到 req_messages
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
yield err_msg yield err_msg
@@ -363,38 +247,39 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(err_msg) req_messages.append(err_msg)
self.ap.logger.debug( self.ap.logger.debug(
f'localagent req: query={query.query_id} req_messages={req_messages} ' f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
f'use_llm_model={use_llm_model.model_entity.name}'
) )
if is_stream: if is_stream:
tool_calls_map = {} tool_calls_map = {}
msg_idx = 0 msg_idx = 0
accumulated_content = '' accumulated_content = '' # 从开始累积的所有内容
last_role = 'assistant' last_role = 'assistant'
msg_sequence = first_end_sequence msg_sequence = first_end_sequence
tool_stream_src = use_llm_model.provider.invoke_llm_stream( async for msg in use_llm_model.provider.invoke_llm_stream(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], query.use_funcs,
extra_args=use_llm_model.model_entity.extra_args, extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think, remove_think=remove_think,
) ):
async for msg in tool_stream_src:
msg_idx += 1 msg_idx += 1
# 记录角色
if msg.role: if msg.role:
last_role = msg.role last_role = msg.role
# Prepend first-round content on first chunk of tool-call round # 第一次请求工具调用时的内容
if msg_idx == 1: if msg_idx == 1:
accumulated_content = first_content if first_content is not None else accumulated_content accumulated_content = first_content if first_content is not None else accumulated_content
# 累积内容
if msg.content: if msg.content:
accumulated_content += msg.content accumulated_content += msg.content
# 处理工具调用
if msg.tool_calls: if msg.tool_calls:
for tool_call in msg.tool_calls: for tool_call in msg.tool_calls:
if tool_call.id not in tool_calls_map: if tool_call.id not in tool_calls_map:
@@ -406,13 +291,15 @@ class LocalAgentRunner(runner.RequestRunner):
), ),
) )
if tool_call.function and tool_call.function.arguments: if tool_call.function and tool_call.function.arguments:
# 流式处理中工具调用参数可能分多个chunk返回需要追加而不是覆盖
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
# 每8个chunk或最后一个chunk时输出所有累积的内容
if msg_idx % 8 == 0 or msg.is_final: if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1 msg_sequence += 1
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, content=accumulated_content, # 输出所有累积内容
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
is_final=msg.is_final, is_final=msg.is_final,
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
@@ -425,12 +312,12 @@ class LocalAgentRunner(runner.RequestRunner):
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
) )
else: else:
# Non-streaming: use committed model directly (no fallback in tool loop) # 处理完所有调用,再次请求
msg = await use_llm_model.provider.invoke_llm( msg = await use_llm_model.provider.invoke_llm(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], query.use_funcs,
extra_args=use_llm_model.model_entity.extra_args, extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think, remove_think=remove_think,
) )

View File

@@ -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(

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 23 required_database_version = 20
"""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

View File

@@ -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})

View File

@@ -95,8 +95,7 @@
"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,

View File

@@ -59,11 +59,8 @@ stages:
label: label:
en_US: Model en_US: Model
zh_Hans: 模型 zh_Hans: 模型
type: model-fallback-selector type: llm-model-selector
required: true required: true
default:
primary: ''
fallbacks: []
- name: max-round - name: max-round
label: label:
en_US: Max Round en_US: Max Round
@@ -93,26 +90,6 @@ stages:
type: knowledge-base-multi-selector type: knowledge-base-multi-selector
required: false required: false
default: [] default: []
- name: max-tool-iterations
label:
en_US: Max Tool Iterations
zh_Hans: 最大工具调用轮次
description:
en_US: Maximum number of tool call iterations in a single agent loop to prevent runaway loops
zh_Hans: 单次 Agent 循环中工具调用的最大轮次,防止无限循环
type: integer
required: false
default: 16
- name: max-tool-result-chars
label:
en_US: Max Tool Result Length
zh_Hans: 工具返回最大字符数
description:
en_US: Maximum character length of a single tool call result, longer results will be truncated
zh_Hans: 单次工具调用返回结果的最大字符数,超出部分将被截断
type: integer
required: false
default: 8000
- name: tbox-app-api - name: tbox-app-api
label: label:
en_US: Tbox App API en_US: Tbox App API

View File

@@ -78,39 +78,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 +119,3 @@ stages:
type: boolean type: boolean
required: true required: true
default: false default: false

520
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -124,6 +124,12 @@ export default function BotForm({
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Serialize adapter_config to a stable string so it can be used as a
// useEffect dependency without triggering on every render. form.watch()
// returns a new object reference each time, which would otherwise cause
// the filtering effect below to loop indefinitely.
const adapterConfigJson = JSON.stringify(currentAdapterConfig);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
@@ -147,7 +153,7 @@ export default function BotForm({
// For non-Lark adapters, show all fields // For non-Lark adapters, show all fields
setFilteredDynamicFormConfigList(dynamicFormConfigList); setFilteredDynamicFormConfigList(dynamicFormConfigList);
} }
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]); }, [currentAdapter, adapterConfigJson, dynamicFormConfigList]);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => { const copyToClipboard = () => {

View File

@@ -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>
</> </>
)} )}

View File

@@ -11,7 +11,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -73,12 +73,6 @@ export default function DynamicFormComponent({
case 'bot-selector': case 'bot-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
break;
case 'prompt-editor': case 'prompt-editor':
fieldSchema = z.array( fieldSchema = z.array(
z.object({ z.object({
@@ -166,34 +160,39 @@ export default function DynamicFormComponent({
const onSubmitRef = useRef(onSubmit); const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit; onSubmitRef.current = onSubmit;
// 监听表单值变化 // Track the last emitted values to avoid emitting identical snapshots,
useEffect(() => { // which would cause the parent to call setValue with an equivalent object,
// Emit initial form values immediately so the parent always has a valid snapshot, // triggering a re-render loop.
// even if the user saves without modifying any field. const lastEmittedRef = useRef<string>('');
// form.watch(callback) only fires on subsequent changes, not on mount.
const emitValues = useCallback(() => {
const formValues = form.getValues(); const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce( const finalValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default; acc[item.name] = formValues[item.name] ?? item.default;
return acc; return acc;
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
onSubmitRef.current?.(initialFinalValues); const serialized = JSON.stringify(finalValues);
if (serialized !== lastEmittedRef.current) {
lastEmittedRef.current = serialized;
onSubmitRef.current?.(finalValues);
}
}, [form, itemConfigList]);
// 监听表单值变化
useEffect(() => {
// Emit initial form values immediately so the parent always has a valid snapshot,
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
emitValues();
const subscription = form.watch(() => { const subscription = form.watch(() => {
const formValues = form.getValues(); emitValues();
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(finalValues);
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, itemConfigList]); }, [form, itemConfigList, emitValues]);
return ( return (
<Form {...form}> <Form {...form}>
@@ -232,7 +231,6 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable) // All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing; const isFieldDisabled = !!isEditing;
return ( return (
<FormField <FormField
key={config.id} key={config.id}

View File

@@ -124,28 +124,6 @@ export default function DynamicFormItemComponent({
} }
}, [config.type]); }, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error('Failed to get LLM model list: ' + err.msg);
});
}
}, [config.type]);
useEffect(() => { useEffect(() => {
if ( if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR || config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
@@ -193,7 +171,12 @@ export default function DynamicFormItemComponent({
return <Textarea {...field} className="min-h-[120px]" />; return <Textarea {...field} className="min-h-[120px]" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />; return (
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
);
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
@@ -244,7 +227,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT: case DynamicFormItemType.SELECT:
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
</SelectTrigger> </SelectTrigger>
@@ -335,172 +318,6 @@ export default function DynamicFormItemComponent({
</Select> </Select>
); );
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider
const groupedModelsForFallback = llmModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
const modelValue = field.value as {
primary: string;
fallbacks: string[];
};
const renderModelSelect = (
value: string,
onChange: (val: string) => void,
placeholder: string,
) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModelsForFallback).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
);
const updateValue = (patch: Partial<typeof modelValue>) => {
field.onChange({ ...modelValue, ...patch });
};
const addFallbackModel = () => {
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
};
const updateFallbackModel = (index: number, value: string) => {
const updated = [...modelValue.fallbacks];
updated[index] = value;
updateValue({ fallbacks: updated });
};
const removeFallbackModel = (index: number) => {
const updated = [...modelValue.fallbacks];
updated.splice(index, 1);
updateValue({ fallbacks: updated });
};
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
const updated = [...modelValue.fallbacks];
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= updated.length) return;
[updated[index], updated[newIndex]] = [
updated[newIndex],
updated[index],
];
updateValue({ fallbacks: updated });
};
return (
<div className="space-y-3">
{/* Primary model selector */}
<div>
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
</div>
{/* Fallback models */}
{modelValue.fallbacks.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t('models.fallback.fallbackList')}
</p>
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}.
</span>
<div className="flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
t('models.selectModel'),
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => moveFallbackModel(index, 'up')}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => moveFallbackModel(index, 'down')}
disabled={index === modelValue.fallbacks.length - 1}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive"
onClick={() => removeFallbackModel(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Add fallback button */}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={addFallbackModel}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.fallback.addFallback')}
</Button>
</div>
);
}
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name // Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce( const kbsByEngine = knowledgeBases.reduce(

View File

@@ -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',
); );
} }

View File

@@ -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',
}, },
}), }),
]; ];

View File

@@ -463,16 +463,14 @@ export default function ModelsDialog({
) )
: t('models.providerCount', { count: otherProviders.length })} : t('models.providerCount', { count: otherProviders.length })}
</span> </span>
<div className="flex gap-2"> <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" onClick={handleCreateProvider}
onClick={handleCreateProvider} >
> <Plus className="h-4 w-4 mr-1" />
<Plus className="h-4 w-4 mr-1" /> {t('models.addProvider')}
{t('models.addProvider')} </Button>
</Button>
</div>
</div> </div>
{/* Provider List */} {/* Provider List */}

View File

@@ -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';
} }
}; };

View File

@@ -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';
@@ -265,12 +264,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>
); );
} }

View File

@@ -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;

View File

@@ -1,6 +1,13 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; import {
useState,
useEffect,
useCallback,
useRef,
Suspense,
useMemo,
} from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
@@ -63,7 +70,7 @@ function MarketPageContent({
RecommendationList[] RecommendationList[]
>([]); >([]);
const pageSize = 12; // 每页12个 const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -323,7 +330,38 @@ function MarketPageContent({
}; };
}, []); }, []);
const visiblePlugins = plugins; // 计算所有推荐插件的 ID 集合
const recommendedPluginIds = useMemo(() => {
const ids = new Set<string>();
recommendationLists.forEach((list) => {
list.plugins.forEach((plugin) => {
ids.add(`${plugin.author} / ${plugin.name}`);
});
});
return ids;
}, [recommendationLists]);
// 过滤掉已在推荐列表中展示的插件
// 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤
// 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现
// 但是推荐列表只在第一页且无筛选时显示。
// 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。
const visiblePlugins = useMemo(() => {
const showRecommendations =
!searchQuery && componentFilter === 'all' && selectedTags.length === 0;
if (!showRecommendations) {
return plugins;
}
return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId));
}, [
plugins,
recommendedPluginIds,
searchQuery,
componentFilter,
selectedTags,
]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -524,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}

View File

@@ -47,12 +47,10 @@ function RecommendationListRow({
list, list,
tagNames, tagNames,
onInstall, onInstall,
isLast,
}: { }: {
list: RecommendationList; list: RecommendationList;
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void; onInstall: (author: string, pluginName: string) => void;
isLast: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -145,9 +143,7 @@ function RecommendationListRow({
/> />
))} ))}
</div> </div>
{totalPages > 1 && !isLast && ( {totalPages > 1 && <div className="border-b border-border mt-6" />}
<div className="border-b border-border mt-6" />
)}
</div> </div>
); );
} }
@@ -165,13 +161,12 @@ export function RecommendationLists({
return ( return (
<div className="mt-6"> <div className="mt-6">
{lists.map((list, index) => ( {lists.map((list) => (
<RecommendationListRow <RecommendationListRow
key={list.uuid} key={list.uuid}
list={list} list={list}
tagNames={tagNames} tagNames={tagNames}
onInstall={onInstall} onInstall={onInstall}
isLast={index === lists.length - 1}
/> />
))} ))}
<div className="border-b border-border mb-6" /> <div className="border-b border-border mb-6" />

View File

@@ -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,9 +9,8 @@ import {
ExternalLink, ExternalLink,
Book, Book,
FileText, FileText,
Info,
} from 'lucide-react'; } from 'lucide-react';
import { useState, useRef, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({ export default function PluginMarketCardComponent({
@@ -31,43 +24,6 @@ export default function PluginMarketCardComponent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
const measure = () => {
const container = bottomRef.current;
if (!container) return;
const width = container.offsetWidth;
const availableForTags = width - 140 - 80;
if (availableForTags <= 0) {
setVisibleTags(0);
return;
}
const tagWidth = 80;
const plusBadgeWidth = 40;
const maxTags = Math.max(
0,
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
);
if (maxTags >= tags.length) {
setVisibleTags(tags.length);
} else {
setVisibleTags(Math.max(1, maxTags));
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(bottomRef.current);
return () => observer.disconnect();
}, [cardVO.tags]);
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) { function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();
@@ -90,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"
@@ -117,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>
@@ -172,13 +95,10 @@ export default function PluginMarketCardComponent({
</div> </div>
{/* 下部分:下载量、标签和组件列表 */} {/* 下部分:下载量、标签和组件列表 */}
<div <div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
ref={bottomRef} <div className="flex flex-row items-center justify-start gap-2 flex-wrap">
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */} {/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0"> <div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
<svg <svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0" className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -196,14 +116,14 @@ export default function PluginMarketCardComponent({
</div> </div>
</div> </div>
{/* Tags - adaptive */} {/* Tags */}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && ( {cardVO.tags && cardVO.tags.length > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0"> <div className="flex flex-wrap gap-1.5">
{cardVO.tags.slice(0, visibleTags).map((tag) => ( {cardVO.tags.slice(0, 2).map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="secondary" variant="secondary"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0 whitespace-nowrap" className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
> >
<svg <svg
className="w-2.5 h-2.5 flex-shrink-0" className="w-2.5 h-2.5 flex-shrink-0"
@@ -218,17 +138,15 @@ export default function PluginMarketCardComponent({
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" /> <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" /> <line x1="7" y1="7" x2="7.01" y2="7" />
</svg> </svg>
<span className="truncate max-w-[5rem]"> <span className="truncate">{tagNames[tag] || tag}</span>
{tagNames[tag] || tag}
</span>
</Badge> </Badge>
))} ))}
{remainingTags > 0 && ( {cardVO.tags.length > 2 && (
<Badge <Badge
variant="outline" variant="outline"
className="text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap" className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
> >
+{remainingTags} +{cardVO.tags.length - 2}
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -35,7 +35,6 @@ export enum DynamicFormItemType {
SELECT = 'select', SELECT = 'select',
LLM_MODEL_SELECTOR = 'llm-model-selector', LLM_MODEL_SELECTOR = 'llm-model-selector',
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector', EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
PROMPT_EDITOR = 'prompt-editor', PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown', UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector', KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',

View File

@@ -356,7 +356,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 +384,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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
@@ -236,11 +230,6 @@ const enUS = {
modelsCount: '{{count}} model(s)', modelsCount: '{{count}} model(s)',
expandModels: 'Expand', expandModels: 'Expand',
collapseModels: 'Collapse', collapseModels: 'Collapse',
fallback: {
primary: 'Primary Model',
fallbackList: 'Fallback Models',
addFallback: 'Add Fallback Model',
},
}, },
bots: { bots: {
title: 'Bots', title: 'Bots',
@@ -494,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',
@@ -771,7 +757,7 @@ 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: ',

View File

@@ -1,4 +1,4 @@
const jaJP = { const jaJP = {
common: { common: {
login: 'ログイン', login: 'ログイン',
logout: 'ログアウト', logout: 'ログアウト',
@@ -48,12 +48,6 @@
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: 'または',
@@ -241,11 +235,6 @@
modelsCount: '{{count}} 個のモデル', modelsCount: '{{count}} 個のモデル',
expandModels: '展開', expandModels: '展開',
collapseModels: '折りたたむ', collapseModels: '折りたたむ',
fallback: {
primary: 'プライマリモデル',
fallbackList: 'フォールバックモデル',
addFallback: 'フォールバックモデルを追加',
},
}, },
bots: { bots: {
title: 'ボット', title: 'ボット',
@@ -502,9 +491,6 @@
noTags: 'タグがありません', noTags: 'タグがありません',
}, },
viewDetails: '詳細を表示', viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -763,9 +749,6 @@
fileName: 'ファイル名', fileName: 'ファイル名',
noResults: '検索結果がありません', noResults: '検索結果がありません',
retrieveError: '検索に失敗しました:', retrieveError: '検索に失敗しました:',
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください',
unknownEngine: '不明なエンジン', unknownEngine: '不明なエンジン',
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました:', loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました:',
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました:', deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました:',

View File

@@ -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: '或',
@@ -227,11 +221,6 @@ const zhHans = {
modelsCount: '{{count}} 个模型', modelsCount: '{{count}} 个模型',
expandModels: '展开', expandModels: '展开',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '备用模型',
addFallback: '添加备用模型',
},
}, },
bots: { bots: {
title: '机器人', title: '机器人',
@@ -479,8 +468,6 @@ const zhHans = {
noTags: '暂无标签', noTags: '暂无标签',
}, },
viewDetails: '查看详情', viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -739,7 +726,7 @@ const zhHans = {
engineSettingsReadonly: '编辑模式下不可修改', engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
noEnginesAvailable: '没有可用的知识库引擎', noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装知识引擎」插件', installEngineHint: '请先安装知识插件',
createKnowledgeBaseFailed: '知识库创建失败:', createKnowledgeBaseFailed: '知识库创建失败:',
loadKnowledgeBaseFailed: '知识库加载失败:', loadKnowledgeBaseFailed: '知识库加载失败:',
deleteKnowledgeBaseFailed: '知识库删除失败:', deleteKnowledgeBaseFailed: '知识库删除失败:',

View File

@@ -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: '或',
@@ -226,11 +220,6 @@ const zhHant = {
modelsCount: '{{count}} 個模型', modelsCount: '{{count}} 個模型',
expandModels: '展開', expandModels: '展開',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '備用模型',
addFallback: '新增備用模型',
},
}, },
bots: { bots: {
title: '機器人', title: '機器人',
@@ -472,8 +461,6 @@ const zhHant = {
noTags: '暫無標籤', noTags: '暫無標籤',
}, },
viewDetails: '查看詳情', viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -722,8 +709,6 @@ const zhHant = {
fileName: '文檔名稱', fileName: '文檔名稱',
noResults: '暫無結果', noResults: '暫無結果',
retrieveError: '檢索失敗:', retrieveError: '檢索失敗:',
noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件',
unknownEngine: '未知引擎', unknownEngine: '未知引擎',
loadKnowledgeBaseFailed: '知識庫載入失敗:', loadKnowledgeBaseFailed: '知識庫載入失敗:',
deleteKnowledgeBaseFailed: '知識庫刪除失敗:', deleteKnowledgeBaseFailed: '知識庫刪除失敗:',