Compare commits

...

3 Commits

Author SHA1 Message Date
RockChinQ
c3152e10c8 feat: add agent loop protection - max iterations and tool result truncation
- Add max-tool-iterations config (default 16) to prevent runaway agent loops
- Add max-tool-result-chars config (default 8000) to truncate oversized tool results
- Both settings are configurable in pipeline UI under Local Agent settings
- Logs warnings when limits are hit for debugging

Closes #2051
2026-03-12 03:14:36 -04:00
Junyan Chin
8b8cfb76de fix(market): sync plugin market UI improvements from Space (#2056)
* fix(market): sync plugin market UI from space - page size 12, full list display, fix double separator, adaptive tag display

* fix: lint and prettier formatting

* fix: prettier formatting for remaining files
2026-03-12 15:06:11 +08:00
Junyan Chin
79311ccde3 feat: model fallback chain (#2017) (#2018) 2026-03-12 03:33:05 +08:00
18 changed files with 641 additions and 169 deletions

View File

@@ -9,6 +9,7 @@ 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
@@ -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 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

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

View File

@@ -36,17 +36,36 @@ 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
try: llm_model = None
llm_model = ( if selected_runner == 'local-agent':
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) # Read model config — new format is { primary: str, fallbacks: [str] },
if selected_runner == 'local-agent' # but handle legacy plain string for backward compatibility
else None model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
) if isinstance(model_config, str):
except ValueError: # Legacy format: plain UUID string
self.ap.logger.warning( primary_uuid = model_config
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured' fallback_uuids = []
) else:
llm_model = None 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( conversation = await self.ap.sess_mgr.get_conversation(
query, query,
@@ -61,20 +80,28 @@ 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' and llm_model: if selected_runner == 'local-agent':
query.use_funcs = [] 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'): 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

@@ -4,6 +4,7 @@ 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
@@ -26,21 +27,117 @@ 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):
"""本地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): # Primary model
self.active_calls: dict[str, dict] = {} if query.use_llm_model_uuid:
self.completed_calls: list[provider_message.ToolCall] = [] 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( 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', [])
@@ -119,51 +216,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')
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( 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: 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,
use_llm_model, candidates,
req_messages, req_messages,
query.use_funcs, query.use_funcs,
extra_args=use_llm_model.model_entity.extra_args, remove_think,
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,
use_llm_model, candidates,
req_messages, req_messages,
query.use_funcs, 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 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:
@@ -175,21 +272,18 @@ 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,
@@ -204,8 +298,17 @@ 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
@@ -228,6 +331,14 @@ 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',
@@ -245,7 +356,6 @@ 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
@@ -253,39 +363,38 @@ 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} 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: 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
async for msg in use_llm_model.provider.invoke_llm_stream( tool_stream_src = use_llm_model.provider.invoke_llm_stream(
query, query,
use_llm_model, use_llm_model,
req_messages, 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, 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:
@@ -297,15 +406,13 @@ 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,
@@ -318,12 +425,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, query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
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

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

@@ -59,8 +59,11 @@ stages:
label: label:
en_US: Model en_US: Model
zh_Hans: 模型 zh_Hans: 模型
type: llm-model-selector type: model-fallback-selector
required: true required: true
default:
primary: ''
fallbacks: []
- name: max-round - name: max-round
label: label:
en_US: Max Round en_US: Max Round
@@ -90,6 +93,26 @@ 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

@@ -124,12 +124,6 @@ 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();
}, []); }, []);
@@ -153,7 +147,7 @@ export default function BotForm({
// For non-Lark adapters, show all fields // For non-Lark adapters, show all fields
setFilteredDynamicFormConfigList(dynamicFormConfigList); setFilteredDynamicFormConfigList(dynamicFormConfigList);
} }
}, [currentAdapter, adapterConfigJson, dynamicFormConfigList]); }, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => { const copyToClipboard = () => {

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 { useCallback, useEffect, useRef } from 'react'; import { 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,6 +73,12 @@ 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({
@@ -160,39 +166,34 @@ 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, // 监听表单值变化
// which would cause the parent to call setValue with an equivalent object, useEffect(() => {
// triggering a re-render loop. // Emit initial form values immediately so the parent always has a valid snapshot,
const lastEmittedRef = useRef<string>(''); // even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const emitValues = useCallback(() => {
const formValues = form.getValues(); const formValues = form.getValues();
const finalValues = itemConfigList.reduce( const initialFinalValues = 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>,
); );
const serialized = JSON.stringify(finalValues); onSubmitRef.current?.(initialFinalValues);
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(() => {
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(); return () => subscription.unsubscribe();
}, [form, itemConfigList, emitValues]); }, [form, itemConfigList]);
return ( return (
<Form {...form}> <Form {...form}>
@@ -231,6 +232,7 @@ 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,6 +124,28 @@ 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 ||
@@ -171,12 +193,7 @@ 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 ( return <Switch checked={field.value} onCheckedChange={field.onChange} />;
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
);
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
@@ -227,7 +244,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>
@@ -318,6 +335,172 @@ 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

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

View File

@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
useState,
useEffect,
useCallback,
useRef,
Suspense,
useMemo,
} from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
@@ -70,7 +63,7 @@ function MarketPageContent({
RecommendationList[] RecommendationList[]
>([]); >([]);
const pageSize = 16; // 每页16个4行x4列 const pageSize = 12; // 每页12个
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);
@@ -330,38 +323,7 @@ function MarketPageContent({
}; };
}, []); }, []);
// 计算所有推荐插件的 ID 集合 const visiblePlugins = plugins;
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(() => {

View File

@@ -47,10 +47,12 @@ 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);
@@ -143,7 +145,9 @@ function RecommendationListRow({
/> />
))} ))}
</div> </div>
{totalPages > 1 && <div className="border-b border-border mt-6" />} {totalPages > 1 && !isLast && (
<div className="border-b border-border mt-6" />
)}
</div> </div>
); );
} }
@@ -161,12 +165,13 @@ export function RecommendationLists({
return ( return (
<div className="mt-6"> <div className="mt-6">
{lists.map((list) => ( {lists.map((list, index) => (
<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

@@ -17,7 +17,7 @@ import {
FileText, FileText,
Info, Info,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({ export default function PluginMarketCardComponent({
@@ -31,6 +31,43 @@ 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();
@@ -135,10 +172,13 @@ export default function PluginMarketCardComponent({
</div> </div>
{/* 下部分:下载量、标签和组件列表 */} {/* 下部分:下载量、标签和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0"> <div
<div className="flex flex-row items-center justify-start gap-2 flex-wrap"> 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 <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"
@@ -156,14 +196,14 @@ export default function PluginMarketCardComponent({
</div> </div>
</div> </div>
{/* Tags */} {/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && ( {cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, 2).map((tag) => ( {cardVO.tags.slice(0, visibleTags).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" 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 <svg
className="w-2.5 h-2.5 flex-shrink-0" 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" /> <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">{tagNames[tag] || tag}</span> <span className="truncate max-w-[5rem]">
{tagNames[tag] || tag}
</span>
</Badge> </Badge>
))} ))}
{cardVO.tags.length > 2 && ( {remainingTags > 0 && (
<Badge <Badge
variant="outline" 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> </Badge>
)} )}
</div> </div>

View File

@@ -35,6 +35,7 @@ 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

@@ -236,6 +236,11 @@ 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',

View File

@@ -1,4 +1,4 @@
const jaJP = { const jaJP = {
common: { common: {
login: 'ログイン', login: 'ログイン',
logout: 'ログアウト', logout: 'ログアウト',
@@ -241,6 +241,11 @@ const jaJP = {
modelsCount: '{{count}} 個のモデル', modelsCount: '{{count}} 個のモデル',
expandModels: '展開', expandModels: '展開',
collapseModels: '折りたたむ', collapseModels: '折りたたむ',
fallback: {
primary: 'プライマリモデル',
fallbackList: 'フォールバックモデル',
addFallback: 'フォールバックモデルを追加',
},
}, },
bots: { bots: {
title: 'ボット', title: 'ボット',

View File

@@ -227,6 +227,11 @@ const zhHans = {
modelsCount: '{{count}} 个模型', modelsCount: '{{count}} 个模型',
expandModels: '展开', expandModels: '展开',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '备用模型',
addFallback: '添加备用模型',
},
}, },
bots: { bots: {
title: '机器人', title: '机器人',

View File

@@ -226,6 +226,11 @@ const zhHant = {
modelsCount: '{{count}} 個模型', modelsCount: '{{count}} 個模型',
expandModels: '展開', expandModels: '展開',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '備用模型',
addFallback: '新增備用模型',
},
}, },
bots: { bots: {
title: '機器人', title: '機器人',