mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
13 Commits
v4.9.0
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3152e10c8 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 | ||
|
|
89064a9d5b | ||
|
|
8c2aef3734 | ||
|
|
3fb9e542b6 | ||
|
|
01844d8687 | ||
|
|
2655425fbe | ||
|
|
bd15b630b0 | ||
|
|
fe5ce68436 | ||
|
|
0541b05966 | ||
|
|
13cb0aa9be | ||
|
|
a048369b38 |
@@ -61,7 +61,7 @@ dependencies = [
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Callable
|
||||
from .wecomcsevent import WecomCSEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import aiofiles
|
||||
import time
|
||||
|
||||
|
||||
class WecomCSClient:
|
||||
@@ -34,6 +35,10 @@ class WecomCSClient:
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
||||
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
||||
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
@@ -378,3 +383,53 @@ class WecomCSClient:
|
||||
async def get_media_id(self, image: platform_message.Image):
|
||||
media_id = await self.upload_to_work(image=image)
|
||||
return media_id
|
||||
|
||||
async def get_customer_info(self, external_userid: str) -> dict | None:
|
||||
"""
|
||||
Get customer information by external_userid with caching.
|
||||
|
||||
Uses a 1-minute cache to avoid repeated API calls for the same user.
|
||||
|
||||
Args:
|
||||
external_userid: The external user ID of the customer.
|
||||
|
||||
Returns:
|
||||
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
|
||||
"""
|
||||
# Check cache first
|
||||
current_time = time.time()
|
||||
if external_userid in self._customer_cache:
|
||||
cached_info, cached_time = self._customer_cache[external_userid]
|
||||
if current_time - cached_time < self._cache_ttl:
|
||||
return cached_info
|
||||
|
||||
# Cache miss or expired, fetch from API
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
|
||||
|
||||
payload = {
|
||||
'external_userid_list': [external_userid],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get('errcode') in [40014, 42001]:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_customer_info(external_userid)
|
||||
|
||||
if data.get('errcode', 0) != 0:
|
||||
if self.logger:
|
||||
await self.logger.warning(f'Failed to get customer info: {data}')
|
||||
return None
|
||||
|
||||
customer_list = data.get('customer_list', [])
|
||||
if customer_list:
|
||||
customer_info = customer_list[0]
|
||||
# Store in cache
|
||||
self._customer_cache[external_userid] = (customer_info, current_time)
|
||||
return customer_info
|
||||
return None
|
||||
|
||||
@@ -30,6 +30,7 @@ class MonitoringService:
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
role: str = 'user',
|
||||
@@ -49,6 +50,7 @@ class MonitoringService:
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
'role': role,
|
||||
@@ -152,6 +154,7 @@ class MonitoringService:
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
session_data = {
|
||||
@@ -166,6 +169,7 @@ class MonitoringService:
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
|
||||
from ..platform.webhook_pusher import WebhookPusher
|
||||
from ..provider.session import sessionmgr as llm_session_mgr
|
||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||
|
||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||
from ..config import manager as config_mgr
|
||||
from ..command import cmdmgr
|
||||
@@ -30,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
|
||||
@@ -20,6 +20,7 @@ class MonitoringMessage(Base):
|
||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||
@@ -64,6 +65,7 @@ class MonitoringSession(Base):
|
||||
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
|
||||
|
||||
class MonitoringError(Base):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
@@ -0,0 +1,102 @@
|
||||
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},
|
||||
)
|
||||
@@ -34,6 +34,15 @@ class MonitoringHelper:
|
||||
# Check if session exists, if not, record session start
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Get sender name from message event
|
||||
sender_name = None
|
||||
if hasattr(query, 'message_event'):
|
||||
if hasattr(query.message_event, 'sender'):
|
||||
if hasattr(query.message_event.sender, 'nickname'):
|
||||
sender_name = query.message_event.sender.nickname
|
||||
elif hasattr(query.message_event.sender, 'member_name'):
|
||||
sender_name = query.message_event.sender.member_name
|
||||
|
||||
# Try to record message
|
||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||
@@ -57,6 +66,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
variables=None, # Will be updated in record_query_success
|
||||
)
|
||||
@@ -80,6 +90,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
)
|
||||
|
||||
return message_id
|
||||
@@ -128,6 +139,15 @@ class MonitoringHelper:
|
||||
try:
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Get sender name from message event
|
||||
sender_name = None
|
||||
if hasattr(query, 'message_event'):
|
||||
if hasattr(query.message_event, 'sender'):
|
||||
if hasattr(query.message_event.sender, 'nickname'):
|
||||
sender_name = query.message_event.sender.nickname
|
||||
elif hasattr(query.message_event.sender, 'member_name'):
|
||||
sender_name = query.message_event.sender.member_name
|
||||
|
||||
# Extract response content from resp_message_chain
|
||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||
# Serialize the last response message chain
|
||||
@@ -162,6 +182,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
role='assistant',
|
||||
)
|
||||
@@ -183,6 +204,15 @@ class MonitoringHelper:
|
||||
try:
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Get sender name from message event
|
||||
sender_name = None
|
||||
if hasattr(query, 'message_event'):
|
||||
if hasattr(query.message_event, 'sender'):
|
||||
if hasattr(query.message_event.sender, 'nickname'):
|
||||
sender_name = query.message_event.sender.nickname
|
||||
elif hasattr(query.message_event.sender, 'member_name'):
|
||||
sender_name = query.message_event.sender.member_name
|
||||
|
||||
# Record error message
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
bot_id=bot_id,
|
||||
@@ -197,6 +227,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
|
||||
# When not local-agent, llm_model is None
|
||||
try:
|
||||
llm_model = (
|
||||
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
||||
if selected_runner == 'local-agent'
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(
|
||||
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
||||
)
|
||||
llm_model = None
|
||||
llm_model = None
|
||||
if selected_runner == 'local-agent':
|
||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||
# but handle legacy plain string for backward compatibility
|
||||
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||
if isinstance(model_config, str):
|
||||
# Legacy format: plain UUID string
|
||||
primary_uuid = model_config
|
||||
fallback_uuids = []
|
||||
else:
|
||||
primary_uuid = model_config.get('primary', '')
|
||||
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(
|
||||
query,
|
||||
@@ -61,20 +80,28 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt = conversation.prompt.copy()
|
||||
query.messages = conversation.messages.copy()
|
||||
|
||||
if selected_runner == 'local-agent' and llm_model:
|
||||
if selected_runner == 'local-agent':
|
||||
query.use_funcs = []
|
||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||
if llm_model:
|
||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||
|
||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||
# Get bound plugins and MCP servers for filtering tools
|
||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||
# 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_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}')
|
||||
|
||||
sender_name = ''
|
||||
|
||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||
|
||||
@@ -149,12 +149,19 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||
traceback.print_exc()
|
||||
|
||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||
|
||||
if exception_handling == 'show-error':
|
||||
user_notice = f'{e}'
|
||||
elif exception_handling == 'show-hint':
|
||||
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
||||
else: # hide
|
||||
user_notice = None
|
||||
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.INTERRUPT,
|
||||
new_query=query,
|
||||
user_notice='请求失败' if hide_exception_info else f'{e}',
|
||||
user_notice=user_notice,
|
||||
error_notice=f'{e}',
|
||||
debug_notice=traceback.format_exc(),
|
||||
)
|
||||
|
||||
@@ -81,22 +81,33 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomCSEvent):
|
||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信客服事件。
|
||||
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
||||
|
||||
Returns:
|
||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||
"""
|
||||
# Try to get customer nickname from WeChat API
|
||||
nickname = str(event.user_id)
|
||||
if bot and event.user_id:
|
||||
try:
|
||||
customer_info = await bot.get_customer_info(event.user_id)
|
||||
if customer_info and customer_info.get('nickname'):
|
||||
nickname = customer_info.get('nickname')
|
||||
except Exception:
|
||||
pass # Fall back to user_id as nickname
|
||||
|
||||
# 转换消息链
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
@@ -106,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
@@ -187,7 +198,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def on_message(event: WecomCSEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -441,6 +441,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
is_final = False
|
||||
think_start = False
|
||||
think_end = False
|
||||
yielded_final = False
|
||||
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
@@ -493,13 +494,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
if answer:
|
||||
basic_mode_pending_chunk = answer
|
||||
|
||||
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final):
|
||||
if (
|
||||
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)
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=basic_mode_pending_chunk,
|
||||
is_final=is_final,
|
||||
)
|
||||
if is_final:
|
||||
yielded_final = True
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import copy
|
||||
import typing
|
||||
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.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||
@@ -26,21 +27,117 @@ Respond in the same language as the user's input.
|
||||
|
||||
@runner.runner_class('local-agent')
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
"""本地Agent请求运行器"""
|
||||
"""Local agent request runner"""
|
||||
|
||||
class ToolCallTracker:
|
||||
"""工具调用追踪器"""
|
||||
async def _get_model_candidates(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> list[modelmgr_requester.RuntimeLLMModel]:
|
||||
"""Build ordered list of models to try: primary model + fallback models."""
|
||||
candidates = []
|
||||
|
||||
def __init__(self):
|
||||
self.active_calls: dict[str, dict] = {}
|
||||
self.completed_calls: list[provider_message.ToolCall] = []
|
||||
# Primary model
|
||||
if query.use_llm_model_uuid:
|
||||
try:
|
||||
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(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""运行请求"""
|
||||
"""Run request"""
|
||||
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)
|
||||
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
||||
|
||||
@@ -74,7 +171,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||
continue
|
||||
|
||||
result = await kb.retrieve(user_message_text)
|
||||
result = await kb.retrieve(
|
||||
user_message_text,
|
||||
settings={
|
||||
'sender_id': str(query.sender_id),
|
||||
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
},
|
||||
)
|
||||
|
||||
if result:
|
||||
all_results.extend(result)
|
||||
@@ -113,51 +216,51 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
# Build ordered candidate list (primary + fallbacks)
|
||||
candidates = await self._get_model_candidates(query)
|
||||
if not candidates:
|
||||
raise RuntimeError('No LLM model configured for local-agent runner')
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||
f'candidates={[m.model_entity.name for m in candidates]}'
|
||||
)
|
||||
|
||||
if not is_stream:
|
||||
# 非流式输出,直接请求
|
||||
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
# Non-streaming: invoke with fallback
|
||||
msg, use_llm_model = await self._invoke_with_fallback(
|
||||
query,
|
||||
use_llm_model,
|
||||
candidates,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
remove_think,
|
||||
)
|
||||
yield msg
|
||||
final_msg = msg
|
||||
else:
|
||||
# 流式输出,需要处理工具调用
|
||||
# Streaming: invoke with fallback
|
||||
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||
msg_idx = 0
|
||||
accumulated_content = '' # 从开始累积的所有内容
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
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,
|
||||
use_llm_model,
|
||||
candidates,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
):
|
||||
remove_think,
|
||||
)
|
||||
async for msg in stream_src:
|
||||
msg_idx = msg_idx + 1
|
||||
|
||||
# 记录角色
|
||||
if msg.role:
|
||||
last_role = msg.role
|
||||
|
||||
# 累积内容
|
||||
if msg.content:
|
||||
accumulated_content += msg.content
|
||||
|
||||
# 处理工具调用
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id not in tool_calls_map:
|
||||
@@ -169,21 +272,18 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
),
|
||||
)
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
||||
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:
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
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,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
|
||||
# 创建最终消息用于后续处理
|
||||
final_msg = provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
content=accumulated_content,
|
||||
@@ -198,8 +298,17 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
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:
|
||||
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:
|
||||
try:
|
||||
func = tool_call.function
|
||||
@@ -222,6 +331,14 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
else:
|
||||
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:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
@@ -239,7 +356,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
req_messages.append(msg)
|
||||
except Exception as e:
|
||||
# 工具调用出错,添加一个报错信息到 req_messages
|
||||
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
||||
|
||||
yield err_msg
|
||||
@@ -247,39 +363,38 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
req_messages.append(err_msg)
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||
f'use_llm_model={use_llm_model.model_entity.name}'
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
tool_calls_map = {}
|
||||
msg_idx = 0
|
||||
accumulated_content = '' # 从开始累积的所有内容
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
msg_sequence = first_end_sequence
|
||||
|
||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
||||
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
):
|
||||
)
|
||||
async for msg in tool_stream_src:
|
||||
msg_idx += 1
|
||||
|
||||
# 记录角色
|
||||
if msg.role:
|
||||
last_role = msg.role
|
||||
|
||||
# 第一次请求工具调用时的内容
|
||||
# Prepend first-round content on first chunk of tool-call round
|
||||
if msg_idx == 1:
|
||||
accumulated_content = first_content if first_content is not None else accumulated_content
|
||||
|
||||
# 累积内容
|
||||
if msg.content:
|
||||
accumulated_content += msg.content
|
||||
|
||||
# 处理工具调用
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id not in tool_calls_map:
|
||||
@@ -291,15 +406,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
),
|
||||
)
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
||||
if msg_idx % 8 == 0 or msg.is_final:
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
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,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
@@ -312,12 +425,12 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
else:
|
||||
# 处理完所有调用,再次请求
|
||||
# Non-streaming: use committed model directly (no fallback in tool loop)
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
|
||||
@@ -321,13 +321,19 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
if not plugin_id:
|
||||
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
||||
|
||||
# Session context (e.g. session_name) stays in retrieval_settings
|
||||
# for plugins that need it. Do NOT move them into filters, as filters
|
||||
# are passed directly to vector_search by some plugins (e.g. LangRAG)
|
||||
# and would cause empty results when the metadata field doesn't exist.
|
||||
filters = settings.pop('filters', {})
|
||||
|
||||
retrieval_context = {
|
||||
'query': query,
|
||||
'knowledge_base_id': kb.uuid,
|
||||
'collection_id': kb.collection_id or kb.uuid,
|
||||
'retrieval_settings': settings,
|
||||
'creation_settings': kb.creation_settings or {},
|
||||
'filters': settings.pop('filters', {}),
|
||||
'filters': filters,
|
||||
}
|
||||
|
||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 20
|
||||
required_database_version = 23
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from chromadb import PersistentClient
|
||||
from langbot.pkg.vector.vdb import VectorDatabase
|
||||
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
|
||||
from langbot.pkg.core import app
|
||||
import chromadb
|
||||
import chromadb.errors
|
||||
|
||||
# RRF smoothing constant (standard value from the literature)
|
||||
_RRF_K = 60
|
||||
|
||||
|
||||
class ChromaVectorDatabase(VectorDatabase):
|
||||
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
||||
@@ -14,6 +17,10 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
self.client = PersistentClient(path=base_path)
|
||||
self._collections = {}
|
||||
|
||||
@classmethod
|
||||
def supported_search_types(cls) -> list[SearchType]:
|
||||
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
|
||||
|
||||
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
||||
if collection not in self._collections:
|
||||
self._collections[collection] = await asyncio.to_thread(
|
||||
@@ -34,8 +41,8 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
||||
if documents is not None:
|
||||
kwargs['documents'] = documents
|
||||
await asyncio.to_thread(col.add, **kwargs)
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||
await asyncio.to_thread(col.upsert, **kwargs)
|
||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -47,6 +54,23 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
filter: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
col = await self.get_or_create_collection(collection)
|
||||
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||
elif search_type == SearchType.HYBRID:
|
||||
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
|
||||
|
||||
# Default: vector search
|
||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||
|
||||
async def _vector_search(
|
||||
self,
|
||||
col: chromadb.Collection,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
k: int,
|
||||
filter: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
query_kwargs: dict[str, Any] = dict(
|
||||
query_embeddings=query_embedding,
|
||||
n_results=k,
|
||||
@@ -55,9 +79,137 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
if filter:
|
||||
query_kwargs['where'] = filter
|
||||
results = await asyncio.to_thread(col.query, **query_kwargs)
|
||||
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.")
|
||||
self.ap.logger.info(
|
||||
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
return results
|
||||
|
||||
async def _full_text_search(
|
||||
self,
|
||||
col: chromadb.Collection,
|
||||
collection: str,
|
||||
k: int,
|
||||
query_text: str,
|
||||
filter: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not query_text:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
get_kwargs: dict[str, Any] = dict(
|
||||
where_document={'$contains': query_text},
|
||||
include=['metadatas', 'documents'],
|
||||
limit=k,
|
||||
)
|
||||
if filter:
|
||||
get_kwargs['where'] = filter
|
||||
results = await asyncio.to_thread(col.get, **get_kwargs)
|
||||
|
||||
# col.get returns flat lists; wrap into column-major format.
|
||||
# Distances are all 0.0 because Chroma's local $contains is a boolean
|
||||
# filter with no relevance scoring. Chroma's BM25 sparse embedding
|
||||
# function (ChromaBm25EmbeddingFunction) can generate scored sparse
|
||||
# vectors, but sparse vector *indexing* is only available on Chroma
|
||||
# Cloud, not locally. For ranked results, use hybrid mode or apply a
|
||||
# reranker in a downstream stage.
|
||||
ids = results.get('ids', [])
|
||||
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||
documents = results.get('documents', []) or [None] * len(ids)
|
||||
distances = [0.0] * len(ids)
|
||||
|
||||
self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.")
|
||||
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}
|
||||
|
||||
async def _hybrid_search(
|
||||
self,
|
||||
col: chromadb.Collection,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
k: int,
|
||||
query_text: str,
|
||||
filter: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
# Fall back to pure vector search when no text is provided
|
||||
if not query_text:
|
||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||
|
||||
# Run vector search and full-text search in parallel
|
||||
vector_task = self._vector_search(col, collection, query_embedding, k, filter)
|
||||
text_task = self._full_text_search(col, collection, k, query_text, filter)
|
||||
vector_results, text_results = await asyncio.gather(vector_task, text_task)
|
||||
|
||||
vector_ids = vector_results.get('ids', [[]])[0]
|
||||
text_ids = text_results.get('ids', [[]])[0]
|
||||
|
||||
if not vector_ids and not text_ids:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
# RRF fusion
|
||||
fused = self._rrf_fuse([vector_ids, text_ids], k)
|
||||
if not fused:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
fused_ids = [doc_id for doc_id, _ in fused]
|
||||
|
||||
# Fetch full metadata and documents for fused results
|
||||
fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])
|
||||
|
||||
# col.get returns results in arbitrary order; re-order to match fused ranking
|
||||
fetched_map: dict[str, tuple] = {}
|
||||
for i, fid in enumerate(fetched.get('ids', [])):
|
||||
meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]
|
||||
doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]
|
||||
fetched_map[fid] = (meta, doc)
|
||||
|
||||
ordered_ids = []
|
||||
ordered_metas = []
|
||||
ordered_docs = []
|
||||
ordered_dists = []
|
||||
|
||||
# Normalize RRF scores to 0~1 distances via min-max scaling.
|
||||
# Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive
|
||||
# ``1 - score`` would compress all distances into a narrow 0.96~0.98
|
||||
# band with almost no discriminative power. Min-max normalization
|
||||
# spreads them across the full 0~1 range (0.0 = best match).
|
||||
max_score = fused[0][1]
|
||||
min_score = fused[-1][1]
|
||||
score_range = max_score - min_score
|
||||
|
||||
for doc_id, score in fused:
|
||||
if doc_id in fetched_map:
|
||||
meta, doc = fetched_map[doc_id]
|
||||
ordered_ids.append(doc_id)
|
||||
ordered_metas.append(meta)
|
||||
ordered_docs.append(doc)
|
||||
if score_range > 0:
|
||||
ordered_dists.append(1.0 - (score - min_score) / score_range)
|
||||
else:
|
||||
ordered_dists.append(0.0)
|
||||
|
||||
self.ap.logger.info(
|
||||
f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results "
|
||||
f'(vector={len(vector_ids)}, text={len(text_ids)}).'
|
||||
)
|
||||
return {
|
||||
'ids': [ordered_ids],
|
||||
'metadatas': [ordered_metas],
|
||||
'distances': [ordered_dists],
|
||||
'documents': [ordered_docs],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
|
||||
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||
|
||||
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||
truncated to *k* entries.
|
||||
"""
|
||||
scores: dict[str, float] = {}
|
||||
for ranked_ids in result_lists:
|
||||
for rank, doc_id in enumerate(ranked_ids):
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
|
||||
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return sorted_results[:k]
|
||||
|
||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||
col = await self.get_or_create_collection(collection)
|
||||
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
||||
|
||||
@@ -95,11 +95,12 @@
|
||||
"max": 0
|
||||
},
|
||||
"misc": {
|
||||
"hide-exception": true,
|
||||
"exception-handling": "show-hint",
|
||||
"failure-hint": "Request failed.",
|
||||
"at-sender": true,
|
||||
"quote-origin": true,
|
||||
"track-function-calls": false,
|
||||
"remove-think": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,11 @@ stages:
|
||||
label:
|
||||
en_US: Model
|
||||
zh_Hans: 模型
|
||||
type: llm-model-selector
|
||||
type: model-fallback-selector
|
||||
required: true
|
||||
default:
|
||||
primary: ''
|
||||
fallbacks: []
|
||||
- name: max-round
|
||||
label:
|
||||
en_US: Max Round
|
||||
@@ -90,6 +93,26 @@ stages:
|
||||
type: knowledge-base-multi-selector
|
||||
required: false
|
||||
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
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
|
||||
@@ -78,13 +78,39 @@ stages:
|
||||
en_US: Misc
|
||||
zh_Hans: 杂项
|
||||
config:
|
||||
- name: hide-exception
|
||||
- name: exception-handling
|
||||
label:
|
||||
en_US: Hide Exception
|
||||
zh_Hans: 不输出异常信息给用户
|
||||
type: boolean
|
||||
en_US: Exception Handling Strategy
|
||||
zh_Hans: 异常处理策略
|
||||
description:
|
||||
en_US: Controls how error messages are displayed to the user when an AI request fails
|
||||
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
|
||||
type: select
|
||||
required: true
|
||||
default: true
|
||||
default: show-hint
|
||||
options:
|
||||
- name: show-error
|
||||
label:
|
||||
en_US: Show Full Error
|
||||
zh_Hans: 显示完整报错信息
|
||||
- name: show-hint
|
||||
label:
|
||||
en_US: Show Failure Hint
|
||||
zh_Hans: 仅文字提示
|
||||
- name: hide
|
||||
label:
|
||||
en_US: Hide All
|
||||
zh_Hans: 不显示任何异常信息
|
||||
- name: failure-hint
|
||||
label:
|
||||
en_US: Failure Hint Text
|
||||
zh_Hans: 失败提示文本
|
||||
description:
|
||||
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
|
||||
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
|
||||
type: string
|
||||
required: false
|
||||
default: 'Request failed.'
|
||||
- name: at-sender
|
||||
label:
|
||||
en_US: At Sender
|
||||
@@ -119,3 +145,4 @@ stages:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
|
||||
@@ -124,12 +124,6 @@ export default function BotForm({
|
||||
const currentAdapter = form.watch('adapter');
|
||||
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(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
@@ -153,7 +147,7 @@ export default function BotForm({
|
||||
// For non-Lark adapters, show all fields
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
}
|
||||
}, [currentAdapter, adapterConfigJson, dynamicFormConfigList]);
|
||||
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
|
||||
|
||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
||||
const copyToClipboard = () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -27,6 +28,7 @@ interface SessionInfo {
|
||||
is_active: boolean;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
interface SessionMessage {
|
||||
@@ -60,8 +62,29 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const parseSessionType = (sessionId: string): string | null => {
|
||||
const idx = sessionId.indexOf('_');
|
||||
if (idx === -1) return null;
|
||||
const type = sessionId.slice(0, idx);
|
||||
if (type === 'person' || type === 'group') return type;
|
||||
return null;
|
||||
};
|
||||
|
||||
const abbreviateId = (id: string): string => {
|
||||
if (id.length <= 10) return id;
|
||||
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
const copyUserId = (userId: string) => {
|
||||
navigator.clipboard.writeText(userId).then(() => {
|
||||
setCopiedUserId(true);
|
||||
setTimeout(() => setCopiedUserId(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
@@ -338,24 +361,36 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-sm font-medium truncate mr-2">
|
||||
{session.user_id || session.session_id.slice(0, 12)}
|
||||
{session.user_name ||
|
||||
session.user_id ||
|
||||
session.session_id.slice(0, 12)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
||||
{formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{parseSessionType(session.session_id) && (
|
||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||
{parseSessionType(session.session_id)}
|
||||
</span>
|
||||
)}
|
||||
{session.platform && (
|
||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||
{session.platform}
|
||||
</span>
|
||||
)}
|
||||
{session.user_id && (
|
||||
<span className="truncate text-[10px]">
|
||||
{abbreviateId(session.user_id)}
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && (
|
||||
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
</span>
|
||||
)}
|
||||
<span>{session.pipeline_name}</span>
|
||||
<span className="truncate">{session.pipeline_name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -377,15 +412,42 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_id || selectedSessionId.slice(0, 20)}
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{selectedSession?.platform && (
|
||||
<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?.platform && <span>·</span>}
|
||||
<span>·</span>
|
||||
<span>{selectedSession.pipeline_name}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -73,6 +73,12 @@ export default function DynamicFormComponent({
|
||||
case 'bot-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'model-fallback-selector':
|
||||
fieldSchema = z.object({
|
||||
primary: z.string(),
|
||||
fallbacks: z.array(z.string()),
|
||||
});
|
||||
break;
|
||||
case 'prompt-editor':
|
||||
fieldSchema = z.array(
|
||||
z.object({
|
||||
@@ -160,39 +166,34 @@ export default function DynamicFormComponent({
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
// Track the last emitted values to avoid emitting identical snapshots,
|
||||
// which would cause the parent to call setValue with an equivalent object,
|
||||
// triggering a re-render loop.
|
||||
const lastEmittedRef = useRef<string>('');
|
||||
|
||||
const emitValues = useCallback(() => {
|
||||
// 监听表单值变化
|
||||
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.
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
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();
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
emitValues();
|
||||
const formValues = form.getValues();
|
||||
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();
|
||||
}, [form, itemConfigList, emitValues]);
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -231,6 +232,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
|
||||
@@ -124,6 +124,28 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (
|
||||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
||||
@@ -171,12 +193,7 @@ export default function DynamicFormItemComponent({
|
||||
return <Textarea {...field} className="min-h-[120px]" />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return (
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
);
|
||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||
|
||||
case DynamicFormItemType.STRING_ARRAY:
|
||||
return (
|
||||
@@ -227,7 +244,7 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
return (
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
@@ -318,6 +335,172 @@ export default function DynamicFormItemComponent({
|
||||
</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:
|
||||
// Group KBs by Knowledge Engine name
|
||||
const kbsByEngine = knowledgeBases.reduce(
|
||||
|
||||
@@ -463,14 +463,16 @@ export default function ModelsDialog({
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
Suspense,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -70,7 +63,7 @@ function MarketPageContent({
|
||||
RecommendationList[]
|
||||
>([]);
|
||||
|
||||
const pageSize = 16; // 每页16个,4行x4列
|
||||
const pageSize = 12; // 每页12个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -330,38 +323,7 @@ function MarketPageContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 计算所有推荐插件的 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 visiblePlugins = plugins;
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
|
||||
@@ -47,10 +47,12 @@ function RecommendationListRow({
|
||||
list,
|
||||
tagNames,
|
||||
onInstall,
|
||||
isLast,
|
||||
}: {
|
||||
list: RecommendationList;
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (author: string, pluginName: string) => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -143,7 +145,9 @@ function RecommendationListRow({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && <div className="border-b border-border mt-6" />}
|
||||
{totalPages > 1 && !isLast && (
|
||||
<div className="border-b border-border mt-6" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,12 +165,13 @@ export function RecommendationLists({
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{lists.map((list) => (
|
||||
{lists.map((list, index) => (
|
||||
<RecommendationListRow
|
||||
key={list.uuid}
|
||||
list={list}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
isLast={index === lists.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div className="border-b border-border mb-6" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
FileText,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
@@ -31,6 +31,43 @@ export default function PluginMarketCardComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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) {
|
||||
e.stopPropagation();
|
||||
@@ -135,10 +172,13 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
{/* 下部分:下载量、标签和组件列表 */}
|
||||
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
|
||||
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
|
||||
<div
|
||||
ref={bottomRef}
|
||||
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]">
|
||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||
<svg
|
||||
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"
|
||||
@@ -156,14 +196,14 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{cardVO.tags && cardVO.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cardVO.tags.slice(0, 2).map((tag) => (
|
||||
{/* Tags - adaptive */}
|
||||
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||
{cardVO.tags.slice(0, visibleTags).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
className="w-2.5 h-2.5 flex-shrink-0"
|
||||
@@ -178,15 +218,17 @@ 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" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
<span className="truncate">{tagNames[tag] || tag}</span>
|
||||
<span className="truncate max-w-[5rem]">
|
||||
{tagNames[tag] || tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{cardVO.tags.length > 2 && (
|
||||
{remainingTags > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
|
||||
className="text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap"
|
||||
>
|
||||
+{cardVO.tags.length - 2}
|
||||
+{remainingTags}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
|
||||
SELECT = 'select',
|
||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
||||
PROMPT_EDITOR = 'prompt-editor',
|
||||
UNKNOWN = 'unknown',
|
||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||
|
||||
@@ -356,6 +356,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
is_active: boolean;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
@@ -384,6 +385,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
level: string;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
runner_name: string | null;
|
||||
variables: string | null;
|
||||
role: string | null;
|
||||
|
||||
@@ -284,6 +284,27 @@ export default function Login() {
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.agreementNotice')}{' '}
|
||||
<a
|
||||
href="https://langbot.app/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.privacyPolicy')}
|
||||
</a>{' '}
|
||||
{t('common.and')}{' '}
|
||||
<a
|
||||
href={t('common.dataCollectionPolicyUrl')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.dataCollectionPolicy')}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +253,27 @@ export default function Register() {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.agreementNotice')}{' '}
|
||||
<a
|
||||
href="https://langbot.app/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.privacyPolicy')}
|
||||
</a>{' '}
|
||||
{t('common.and')}{' '}
|
||||
<a
|
||||
href={t('common.dataCollectionPolicyUrl')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.dataCollectionPolicy')}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,12 @@ const enUS = {
|
||||
copyFailed: 'Copy Failed',
|
||||
test: 'Test',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
agreementNotice: 'By continuing, you agree to our',
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
and: 'and',
|
||||
dataCollectionPolicy: 'Data Collection Policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/en/insight/data-collection-policy',
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
@@ -230,6 +236,11 @@ const enUS = {
|
||||
modelsCount: '{{count}} model(s)',
|
||||
expandModels: 'Expand',
|
||||
collapseModels: 'Collapse',
|
||||
fallback: {
|
||||
primary: 'Primary Model',
|
||||
fallbackList: 'Fallback Models',
|
||||
addFallback: 'Add Fallback Model',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'Bots',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const jaJP = {
|
||||
const jaJP = {
|
||||
common: {
|
||||
login: 'ログイン',
|
||||
logout: 'ログアウト',
|
||||
@@ -48,6 +48,12 @@ const jaJP = {
|
||||
copyFailed: 'コピーに失敗しました',
|
||||
test: 'テスト',
|
||||
forgotPassword: 'パスワードを忘れた?',
|
||||
agreementNotice: '続行することで、以下に同意したものとみなされます:',
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
and: 'および',
|
||||
dataCollectionPolicy: 'データ収集ポリシー',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/ja/insight/data-collection-policy',
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
@@ -235,6 +241,11 @@ const jaJP = {
|
||||
modelsCount: '{{count}} 個のモデル',
|
||||
expandModels: '展開',
|
||||
collapseModels: '折りたたむ',
|
||||
fallback: {
|
||||
primary: 'プライマリモデル',
|
||||
fallbackList: 'フォールバックモデル',
|
||||
addFallback: 'フォールバックモデルを追加',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'ボット',
|
||||
|
||||
@@ -47,6 +47,12 @@ const zhHans = {
|
||||
copyFailed: '复制失败',
|
||||
test: '测试',
|
||||
forgotPassword: '忘记密码?',
|
||||
agreementNotice: '继续即表示您同意我们的',
|
||||
privacyPolicy: '隐私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '数据收集政策',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
@@ -221,6 +227,11 @@ const zhHans = {
|
||||
modelsCount: '{{count}} 个模型',
|
||||
expandModels: '展开',
|
||||
collapseModels: '收起',
|
||||
fallback: {
|
||||
primary: '主模型',
|
||||
fallbackList: '备用模型',
|
||||
addFallback: '添加备用模型',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: '机器人',
|
||||
|
||||
@@ -47,6 +47,12 @@ const zhHant = {
|
||||
copyFailed: '複製失敗',
|
||||
test: '測試',
|
||||
forgotPassword: '忘記密碼?',
|
||||
agreementNotice: '繼續即表示您同意我們的',
|
||||
privacyPolicy: '隱私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '數據收集政策',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
@@ -220,6 +226,11 @@ const zhHant = {
|
||||
modelsCount: '{{count}} 個模型',
|
||||
expandModels: '展開',
|
||||
collapseModels: '收起',
|
||||
fallback: {
|
||||
primary: '主模型',
|
||||
fallbackList: '備用模型',
|
||||
addFallback: '新增備用模型',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
|
||||
Reference in New Issue
Block a user