Compare commits

...

3 Commits

Author SHA1 Message Date
RockChinQ
e43b76eba4 feat: parallel execution of tool calls in LocalAgentRunner
Use asyncio.gather() to execute independent tool calls concurrently
instead of sequentially. LLM returns multiple tool_calls in a single
response when they are independent, so parallel execution is safe
and significantly reduces latency.

Closes #2050
2026-03-12 03:16:20 -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 620 additions and 188 deletions

View File

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

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

View File

@@ -2,8 +2,10 @@ from __future__ import annotations
import json
import copy
import asyncio
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,19 +28,109 @@ 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 = []
# Get knowledge bases list (new field)
@@ -119,51 +211,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:
@@ -175,21 +267,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,
@@ -204,30 +293,40 @@ 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:
for tool_call in pending_tool_calls:
# Execute all tool calls in parallel (they are independent within the same batch)
async def _execute_single_tool(tc):
"""Execute a single tool call and return (tool_call, content, error)."""
try:
func = tool_call.function
if func.arguments:
parameters = json.loads(func.arguments)
else:
parameters = {}
func = tc.function
parameters = json.loads(func.arguments) if func.arguments else {}
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
# Handle return value content
tool_content = None
if (
isinstance(func_ret, list)
and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement)
):
tool_content = func_ret
return tc, func_ret, None
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
return tc, json.dumps(func_ret, ensure_ascii=False), None
except Exception as e:
return tc, None, e
tool_results = await asyncio.gather(*[_execute_single_tool(tc) for tc in pending_tool_calls])
# Yield results in order and append to messages
for tool_call, tool_content, tool_error in tool_results:
if tool_error is not None:
err_msg = provider_message.Message(
role='tool', content=f'err: {tool_error}', tool_call_id=tool_call.id
)
yield err_msg
req_messages.append(err_msg)
else:
if is_stream:
msg = provider_message.MessageChunk(
role='tool',
@@ -240,52 +339,42 @@ class LocalAgentRunner(runner.RequestRunner):
content=tool_content,
tool_call_id=tool_call.id,
)
yield msg
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
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:
@@ -297,15 +386,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,
@@ -318,12 +405,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,
)

View File

@@ -2,7 +2,7 @@ import langbot
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"""
debug_mode = False

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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