mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
Compare commits
3 Commits
copilot/fi
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3152e10c8 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 |
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'ボット',
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ const zhHans = {
|
|||||||
modelsCount: '{{count}} 个模型',
|
modelsCount: '{{count}} 个模型',
|
||||||
expandModels: '展开',
|
expandModels: '展开',
|
||||||
collapseModels: '收起',
|
collapseModels: '收起',
|
||||||
|
fallback: {
|
||||||
|
primary: '主模型',
|
||||||
|
fallbackList: '备用模型',
|
||||||
|
addFallback: '添加备用模型',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: '机器人',
|
title: '机器人',
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ const zhHant = {
|
|||||||
modelsCount: '{{count}} 個模型',
|
modelsCount: '{{count}} 個模型',
|
||||||
expandModels: '展開',
|
expandModels: '展開',
|
||||||
collapseModels: '收起',
|
collapseModels: '收起',
|
||||||
|
fallback: {
|
||||||
|
primary: '主模型',
|
||||||
|
fallbackList: '備用模型',
|
||||||
|
addFallback: '新增備用模型',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: '機器人',
|
title: '機器人',
|
||||||
|
|||||||
Reference in New Issue
Block a user