mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 11:37:13 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f29d51c69 | |||
| 6b60ad1678 | |||
| 7a4fadc375 | |||
| f2ba540ffb | |||
| 97b176aef2 | |||
| 7c387f75e1 | |||
| b0566f4c9d |
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
|||||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||||
|
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||||
|
already have a default pipeline.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
pipeline_data = await quart.request.json
|
||||||
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||||
|
request_context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
return self.success(data={'uuid': pipeline_uuid})
|
return self.success(data={'uuid': pipeline_uuid})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data={'sent': True})
|
return self.success(data={'sent': True})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/test-inbound',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||||
|
request_context,
|
||||||
|
bot_uuid,
|
||||||
|
str(json_data.get('message') or ''),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/admins',
|
'/<bot_uuid>/admins',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/wizard/recommended-model',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Resolve Space's best available chat model to this Workspace."""
|
||||||
|
try:
|
||||||
|
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(503, -1, str(exc))
|
||||||
|
return self.success(data=model)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
|||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
from ....utils import httpclient
|
||||||
|
from ....platform.sources import http_bot_signing
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -80,6 +83,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'http_bot',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
@@ -216,6 +220,53 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
|
async def send_http_bot_test_message(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
bot_uuid: str,
|
||||||
|
message: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||||
|
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||||
|
if bot is None:
|
||||||
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
if bot.get('adapter') != 'http_bot':
|
||||||
|
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||||
|
if not bot.get('enable'):
|
||||||
|
raise ValueError('Bot must be enabled before sending a test message')
|
||||||
|
|
||||||
|
text = message.strip()
|
||||||
|
if not text or len(text) > 2000:
|
||||||
|
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||||
|
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||||
|
config = bot.get('adapter_config') or {}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if config.get('signature_required', True):
|
||||||
|
secret = str(config.get('inbound_secret') or '')
|
||||||
|
if not secret:
|
||||||
|
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||||
|
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||||
|
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||||
|
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||||
|
|
||||||
|
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.post(
|
||||||
|
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
result = await httpclient.read_json_limited(response)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||||
|
return result.get('data') or {}
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
|||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import user
|
from ....entity.persistence import user
|
||||||
from ....entity.dto.space_model import SpaceModel
|
from ....entity.dto.space_model import SpaceModel
|
||||||
|
from ....entity.dto.space_model import SpaceModelSelection
|
||||||
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||||
@@ -238,3 +241,79 @@ class SpaceService:
|
|||||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
|
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||||
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
|
space_url = self._get_space_config()['url']
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(
|
||||||
|
f'{space_url}/api/v1/models/selection',
|
||||||
|
params={'category': category},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error = await httpclient.read_text_limited(response)
|
||||||
|
raise ValueError(f'Failed to get model selection: {error}')
|
||||||
|
payload = await httpclient.read_json_limited(response)
|
||||||
|
if payload.get('code') != 0:
|
||||||
|
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||||
|
|
||||||
|
data = payload.get('data', [])
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get('models', data.get('items', []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError('Failed to get model selection: invalid response')
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for selection in data:
|
||||||
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
|
models.append(selection['model'])
|
||||||
|
else:
|
||||||
|
models.append(selection)
|
||||||
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|
||||||
|
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||||
|
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||||
|
selection = await self.get_model_selection('chat')
|
||||||
|
if not selection:
|
||||||
|
raise ValueError('No recommended chat model is available')
|
||||||
|
recommended = selection[0]
|
||||||
|
|
||||||
|
async def find_local_model():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.LLMModel)
|
||||||
|
.join(
|
||||||
|
persistence_model.ModelProvider,
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_model.ModelProvider.workspace_uuid
|
||||||
|
== persistence_model.LLMModel.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||||
|
persistence_model.LLMModel.name == recommended.model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
local_model = await find_local_model()
|
||||||
|
if local_model is None:
|
||||||
|
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||||
|
# the recommendation was published after this process started.
|
||||||
|
from ..context import ExecutionContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.sync_new_models_from_space(
|
||||||
|
ExecutionContext.from_request(context)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
local_model = await find_local_model()
|
||||||
|
|
||||||
|
if local_model is None:
|
||||||
|
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||||
|
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||||
|
|||||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
|||||||
)
|
)
|
||||||
async def create_pipeline(pipeline_data: dict) -> str:
|
async def create_pipeline(pipeline_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
return _dump(
|
||||||
|
{
|
||||||
|
'uuid': await ap.pipeline_service.create_pipeline(
|
||||||
|
context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
|
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
model_id: str
|
||||||
|
|||||||
@@ -1240,6 +1240,14 @@
|
|||||||
// Root container
|
// Root container
|
||||||
var root = document.createElement("div");
|
var root = document.createElement("div");
|
||||||
root.id = "langbot-widget-root";
|
root.id = "langbot-widget-root";
|
||||||
|
root.langbotDestroy = function () {
|
||||||
|
wsDisconnect();
|
||||||
|
if (state.historyReloadTimer) {
|
||||||
|
clearTimeout(state.historyReloadTimer);
|
||||||
|
state.historyReloadTimer = null;
|
||||||
|
}
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
var shadow = root.attachShadow({ mode: "open" });
|
var shadow = root.attachShadow({ mode: "open" });
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
|||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
assert 'uuid' in data['data']
|
assert 'uuid' in data['data']
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||||
|
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||||
|
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.post(
|
||||||
|
'/api/v1/pipelines',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||||
|
assert call.kwargs == {'default': True}
|
||||||
|
assert call.args[1]['is_default'] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_pipeline_success(self, quart_test_client):
|
async def test_update_pipeline_success(self, quart_test_client):
|
||||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
from langbot.pkg.api.http.service.bot import BotService
|
||||||
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
|||||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||||
|
|
||||||
|
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(
|
||||||
|
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||||
|
),
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'name': 'HTTP Bot',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||||
|
|
||||||
|
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||||
|
'https://bot.example.com/bots/http-bot-uuid'
|
||||||
|
)
|
||||||
|
|
||||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceHttpBotInboundTest:
|
||||||
|
async def test_sends_signed_message_through_public_ingress(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {
|
||||||
|
'signature_required': True,
|
||||||
|
'inbound_secret': 'test-secret',
|
||||||
|
},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = MagicMock(status=202)
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||||
|
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'session_id': 'wizard-session',
|
||||||
|
'accepted_message_id': 'in-message',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'http-bot-uuid',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['accepted_message_id'] == 'in-message'
|
||||||
|
request = session.post.call_args
|
||||||
|
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||||
|
payload = json.loads(request.kwargs['data'])
|
||||||
|
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
|
headers = request.kwargs['headers']
|
||||||
|
assert headers['X-LB-Timestamp']
|
||||||
|
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||||
|
|
||||||
|
async def test_rejects_non_http_bot(self):
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'telegram-bot',
|
||||||
|
'adapter': 'telegram',
|
||||||
|
'adapter_config': {},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||||
|
await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'telegram-bot',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
|||||||
await service.get_models()
|
await service.get_models()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceServiceGetModelSelection:
|
||||||
|
"""Tests for availability-ranked model selection."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||||
|
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||||
|
service = SpaceService(ap)
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
'uuid': 'best-model',
|
||||||
|
'model_id': 'best-chat-model',
|
||||||
|
'provider': 'provider-1',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': 'fallback-model',
|
||||||
|
'model_id': 'fallback-chat-model',
|
||||||
|
'provider': 'provider-2',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if response_shape == 'models-envelope':
|
||||||
|
data = {'models': models}
|
||||||
|
elif response_shape == 'availability-wrapper':
|
||||||
|
data = [
|
||||||
|
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||||
|
for index, model in enumerate(models)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
data = models
|
||||||
|
payload = {'code': 0, 'data': data}
|
||||||
|
mock_response = MagicMock(status=200)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(return_value=payload),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
get_session.return_value = session
|
||||||
|
|
||||||
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
|
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||||
|
session.get.assert_called_once_with(
|
||||||
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
|
params={'category': 'chat'},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||||
|
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||||
|
persistence = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(first_item=None),
|
||||||
|
_create_mock_result(first_item=local_model),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={}),
|
||||||
|
persistence_mgr=persistence,
|
||||||
|
model_mgr=model_mgr,
|
||||||
|
)
|
||||||
|
service = SpaceService(ap)
|
||||||
|
service.get_model_selection = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||||
|
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
instance_uuid='instance',
|
||||||
|
workspace_uuid='workspace',
|
||||||
|
placement_generation=1,
|
||||||
|
principal=SimpleNamespace(),
|
||||||
|
entitlement_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_recommended_chat_model(context)
|
||||||
|
|
||||||
|
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||||
|
service.get_model_selection.assert_awaited_once_with('chat')
|
||||||
|
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||||
|
assert persistence.execute_async.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSpaceServiceCreditsCache:
|
class TestSpaceServiceCreditsCache:
|
||||||
"""Tests for credits cache behavior."""
|
"""Tests for credits cache behavior."""
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:5300
|
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
|
||||||
|
# login, and WebSocket requests working when the UI is opened from another
|
||||||
|
# device on the local network.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function BotLogListComponent({
|
|||||||
autoExpandImages = false,
|
autoExpandImages = false,
|
||||||
hideDetailedLogsLink = false,
|
hideDetailedLogsLink = false,
|
||||||
hideToolbar = false,
|
hideToolbar = false,
|
||||||
|
onMessageReceived,
|
||||||
}: {
|
}: {
|
||||||
botId: string;
|
botId: string;
|
||||||
/** When true, log entries with images are rendered expanded by default */
|
/** When true, log entries with images are rendered expanded by default */
|
||||||
@@ -28,6 +29,8 @@ export function BotLogListComponent({
|
|||||||
hideDetailedLogsLink?: boolean;
|
hideDetailedLogsLink?: boolean;
|
||||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
|
/** Called after an inbound person/group message appears in the bot log. */
|
||||||
|
onMessageReceived?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,6 +44,8 @@ export function BotLogListComponent({
|
|||||||
]);
|
]);
|
||||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||||
|
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||||
|
onMessageReceivedRef.current = onMessageReceived;
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ value: 'error', label: 'ERROR' },
|
{ value: 'error', label: 'ERROR' },
|
||||||
@@ -108,6 +113,9 @@ export function BotLogListComponent({
|
|||||||
manager.subscribeLogPush(handleBotLogPush);
|
manager.subscribeLogPush(handleBotLogPush);
|
||||||
manager.loadFirstPage().then((response) => {
|
manager.loadFirstPage().then((response) => {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
listenScroll();
|
listenScroll();
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,9 @@ export function BotLogListComponent({
|
|||||||
|
|
||||||
function handleBotLogPush(response: BotLog[]) {
|
function handleBotLogPush(response: BotLog[]) {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
onFormSubmit: () => void;
|
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||||
onFormCancel: () => void;
|
onFormCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +171,16 @@ export default function ProviderForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let savedProviderUuid = providerId;
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
await httpClient.updateModelProvider(providerId, data);
|
await httpClient.updateModelProvider(providerId, data);
|
||||||
toast.success(t('models.providerSaved'));
|
toast.success(t('models.providerSaved'));
|
||||||
} else {
|
} else {
|
||||||
await httpClient.createModelProvider(data);
|
const response = await httpClient.createModelProvider(data);
|
||||||
|
savedProviderUuid = response.uuid;
|
||||||
toast.success(t('models.providerCreated'));
|
toast.success(t('models.providerCreated'));
|
||||||
}
|
}
|
||||||
onFormSubmit();
|
await onFormSubmit(savedProviderUuid as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ export interface WizardProgress {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createProviderLLMModel(model: LLMModel): Promise<object> {
|
public createProviderLLMModel(
|
||||||
|
model: Omit<LLMModel, 'uuid'>,
|
||||||
|
): Promise<{ uuid: string }> {
|
||||||
return this.post('/api/v1/provider/models/llm', model);
|
return this.post('/api/v1/provider/models/llm', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +463,15 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testHttpBotInbound(
|
||||||
|
botId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<{ session_id: string; accepted_message_id: string }> {
|
||||||
|
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getBotSessions(
|
public getBotSessions(
|
||||||
botId: string,
|
botId: string,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
@@ -1046,12 +1057,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.put('/api/v1/system/wizard/progress', progress);
|
return this.put('/api/v1/system/wizard/progress', progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWizardRecommendedModel(): Promise<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
return this.get('/api/v1/system/wizard/recommended-model');
|
||||||
|
}
|
||||||
|
|
||||||
public getAsyncTasks(params?: {
|
public getAsyncTasks(params?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
|
||||||
|
import type { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type ModelSetupMode = 'scan' | 'manual';
|
||||||
|
type ScanFallbackReason = 'failed' | 'empty' | null;
|
||||||
|
|
||||||
|
export interface OwnModelSelection {
|
||||||
|
source: ModelSetupMode;
|
||||||
|
providerUuid: string;
|
||||||
|
model: ScannedProviderModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnModelSetupProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onSelectionChange: (selection: OwnModelSelection | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OwnModelSetup({
|
||||||
|
onBack,
|
||||||
|
onSelectionChange,
|
||||||
|
}: OwnModelSetupProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [providerUuid, setProviderUuid] = useState<string | null>(null);
|
||||||
|
const [showProviderForm, setShowProviderForm] = useState(true);
|
||||||
|
const [mode, setMode] = useState<ModelSetupMode>('scan');
|
||||||
|
const [models, setModels] = useState<ScannedProviderModel[]>([]);
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [scanFallbackReason, setScanFallbackReason] =
|
||||||
|
useState<ScanFallbackReason>(null);
|
||||||
|
const [manualModelName, setManualModelName] = useState('');
|
||||||
|
const [manualContextLength, setManualContextLength] = useState('');
|
||||||
|
const [manualVision, setManualVision] = useState(false);
|
||||||
|
const [manualFunctionCall, setManualFunctionCall] = useState(false);
|
||||||
|
|
||||||
|
const parsedManualContextLength = useMemo(() => {
|
||||||
|
if (!manualContextLength.trim()) return null;
|
||||||
|
const value = Number(manualContextLength);
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||||
|
}, [manualContextLength]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'manual' || !providerUuid) return;
|
||||||
|
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
|
||||||
|
onSelectionChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abilities = [
|
||||||
|
...(manualVision ? ['vision'] : []),
|
||||||
|
...(manualFunctionCall ? ['func_call'] : []),
|
||||||
|
];
|
||||||
|
const modelName = manualModelName.trim();
|
||||||
|
onSelectionChange({
|
||||||
|
source: 'manual',
|
||||||
|
providerUuid,
|
||||||
|
model: {
|
||||||
|
id: modelName,
|
||||||
|
name: modelName,
|
||||||
|
type: 'llm',
|
||||||
|
abilities,
|
||||||
|
context_length: parsedManualContextLength,
|
||||||
|
already_added: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
manualFunctionCall,
|
||||||
|
manualModelName,
|
||||||
|
manualVision,
|
||||||
|
mode,
|
||||||
|
onSelectionChange,
|
||||||
|
parsedManualContextLength,
|
||||||
|
providerUuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scanModels = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
setMode('scan');
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanFallbackReason(null);
|
||||||
|
setModels([]);
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.scanProviderModels(uuid, 'llm');
|
||||||
|
const availableModels = response.models.filter(
|
||||||
|
(model) => model.type === 'llm' && !model.already_added,
|
||||||
|
);
|
||||||
|
setModels(availableModels);
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
setScanFallbackReason('empty');
|
||||||
|
setMode('manual');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setScanFallbackReason('failed');
|
||||||
|
setMode('manual');
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProviderSaved = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
setProviderUuid(uuid);
|
||||||
|
setShowProviderForm(false);
|
||||||
|
await scanModels(uuid);
|
||||||
|
},
|
||||||
|
[scanModels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectModel = useCallback(
|
||||||
|
(model: ScannedProviderModel) => {
|
||||||
|
if (!providerUuid) return;
|
||||||
|
setSelectedModelId(model.id);
|
||||||
|
onSelectionChange({ source: 'scan', providerUuid, model });
|
||||||
|
},
|
||||||
|
[onSelectionChange, providerUuid],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModeChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setMode(value as ModelSetupMode);
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
onSelectionChange(null);
|
||||||
|
onBack();
|
||||||
|
}, [onBack, onSelectionChange]);
|
||||||
|
|
||||||
|
const handleEditProvider = useCallback(() => {
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
setShowProviderForm(true);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.backToChoices')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProviderForm ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('wizard.aiEngine.addProviderTitle')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('wizard.aiEngine.addProviderDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProviderForm
|
||||||
|
providerId={providerUuid ?? undefined}
|
||||||
|
onFormSubmit={handleProviderSaved}
|
||||||
|
onFormCancel={() =>
|
||||||
|
providerUuid ? setShowProviderForm(false) : handleBack()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t('wizard.aiEngine.selectModelTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.selectScannedModelDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title={t('wizard.aiEngine.editProvider')}
|
||||||
|
onClick={handleEditProvider}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="scan">
|
||||||
|
{t('wizard.aiEngine.scanModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">
|
||||||
|
{t('wizard.aiEngine.manualModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="scan" className="mt-4">
|
||||||
|
{isScanning ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{t('wizard.aiEngine.scanningModels')}
|
||||||
|
</div>
|
||||||
|
) : models.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{models.map((model) => {
|
||||||
|
const selected = selectedModelId === model.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-accent ring-1 ring-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectModel(model)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-5 shrink-0 items-center justify-center rounded-full border',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-primary text-primary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{model.display_name || model.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isScanning || !providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.scanModelsFailed'
|
||||||
|
: 'wizard.aiEngine.noScannedModels',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="mt-4 space-y-5">
|
||||||
|
{scanFallbackReason && (
|
||||||
|
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.manualFallbackFailed'
|
||||||
|
: 'wizard.aiEngine.manualFallbackEmpty',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-model-name">
|
||||||
|
{t('wizard.aiEngine.manualModelId')}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-model-name"
|
||||||
|
value={manualModelName}
|
||||||
|
onChange={(event) => setManualModelName(event.target.value)}
|
||||||
|
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.manualModelIdDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('wizard.aiEngine.manualModelOptions')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-context-length">
|
||||||
|
{t('models.contextLength')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-context-length"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={manualContextLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualContextLength(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('models.contextLengthPlaceholder')}
|
||||||
|
/>
|
||||||
|
{parsedManualContextLength === undefined && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t('models.contextLengthInvalid')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-vision"
|
||||||
|
checked={manualVision}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualVision(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-vision"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
{t('models.visionAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-function-call"
|
||||||
|
checked={manualFunctionCall}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualFunctionCall(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-function-call"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Wrench className="size-4" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+606
-174
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null && 'msg' in error) {
|
||||||
|
const message = (error as { msg?: unknown }).msg;
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSigningSecret(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHttpBotSigningSecret(
|
||||||
|
adapterName: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (
|
||||||
|
adapterName !== 'http_bot' ||
|
||||||
|
config.signature_required === false ||
|
||||||
|
(typeof config.inbound_secret === 'string' && config.inbound_secret)
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
inbound_secret: createSigningSecret(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDefaultPipeline<
|
||||||
|
T extends { uuid?: string; is_default?: boolean },
|
||||||
|
>(pipelines: T[]): T | undefined {
|
||||||
|
return pipelines.find(
|
||||||
|
(pipeline) =>
|
||||||
|
pipeline.is_default === true &&
|
||||||
|
typeof pipeline.uuid === 'string' &&
|
||||||
|
pipeline.uuid.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookConfigItem {
|
||||||
|
name: string;
|
||||||
|
show_if?: {
|
||||||
|
field: string;
|
||||||
|
operator: 'eq' | 'neq' | 'in';
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebhookModeEnabled(
|
||||||
|
configItems: WebhookConfigItem[],
|
||||||
|
configValues: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
const webhookField = configItems.find((item) => item.name === 'webhook_url');
|
||||||
|
if (!webhookField) return false;
|
||||||
|
if (!webhookField.show_if) return true;
|
||||||
|
|
||||||
|
const condition = webhookField.show_if;
|
||||||
|
const actualValue = configValues[condition.field];
|
||||||
|
if (condition.operator === 'eq') return actualValue === condition.value;
|
||||||
|
if (condition.operator === 'neq') return actualValue !== condition.value;
|
||||||
|
return (
|
||||||
|
Array.isArray(condition.value) && condition.value.includes(actualValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureLocalAgentPrimaryModel(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
modelUuid: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||||
|
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||||
|
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
ai: {
|
||||||
|
...aiConfig,
|
||||||
|
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||||
|
'local-agent': {
|
||||||
|
...localAgentConfig,
|
||||||
|
model: {
|
||||||
|
...modelConfig,
|
||||||
|
primary: modelUuid,
|
||||||
|
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||||
|
? modelConfig.fallbacks
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1827,6 +1827,23 @@ const enUS = {
|
|||||||
resaveBot: 'Re-save Configuration',
|
resaveBot: 'Re-save Configuration',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
||||||
|
waitingForMessage:
|
||||||
|
'The bot is enabled. Send it a message from your IM platform to continue.',
|
||||||
|
messageReceived:
|
||||||
|
'The bot received an IM message. You can continue to the next step.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
|
||||||
|
httpTestDefaultMessage: 'Hello, this is a connection test message.',
|
||||||
|
sendHttpTest: 'Send Test Message',
|
||||||
|
httpTestAccepted:
|
||||||
|
'The test message was accepted. It will appear in the log shortly.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Enter an inbound signing secret and save the configuration first.',
|
||||||
|
httpTestFailed: 'Failed to send the test message: {{error}}',
|
||||||
logsTitle: 'Bot Logs',
|
logsTitle: 'Bot Logs',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitor bot activity to verify the platform connection is working.',
|
'Monitor bot activity to verify the platform connection is working.',
|
||||||
@@ -1835,6 +1852,51 @@ const enUS = {
|
|||||||
title: 'Select an AI Engine',
|
title: 'Select an AI Engine',
|
||||||
description:
|
description:
|
||||||
"Choose the AI engine that will power your bot's intelligence.",
|
"Choose the AI engine that will power your bot's intelligence.",
|
||||||
|
optionalDescription:
|
||||||
|
'This step is optional. Choose how you want to continue with the current agent.',
|
||||||
|
externalTitle: 'Connect an External Agent',
|
||||||
|
externalDescription:
|
||||||
|
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
|
||||||
|
ownModelTitle: 'Use My Own Model',
|
||||||
|
ownModelDescription:
|
||||||
|
'Add a provider, then scan or manually enter a model to finish setup.',
|
||||||
|
ownModelSetupTitle: 'Add Your Own Model',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
|
||||||
|
addProviderTitle: 'Add Provider',
|
||||||
|
addProviderDescription:
|
||||||
|
'Enter the provider details and API key used to connect and scan models.',
|
||||||
|
selectModelTitle: 'Choose a Model',
|
||||||
|
selectScannedModelTitle: 'Choose a Model',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
|
||||||
|
scanModelMode: 'Scan Models',
|
||||||
|
manualModelMode: 'Add Manually',
|
||||||
|
scanningModels: 'Scanning available models…',
|
||||||
|
noScannedModels:
|
||||||
|
'No available chat models were found. Check the provider configuration.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Model scanning failed. Check the URL and API key, then try again.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Automatic scanning failed. Enter a model ID supported by the provider.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'No models were found. Enter a model ID supported by the provider.',
|
||||||
|
manualModelId: 'Model ID',
|
||||||
|
manualModelIdPlaceholder: 'For example: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Enter the model parameter used in model requests.',
|
||||||
|
manualModelOptions: 'Optional Model Capabilities',
|
||||||
|
editProvider: 'Edit provider',
|
||||||
|
rescanModels: 'Scan models again',
|
||||||
|
moreFeaturesTitle: 'Add More Agent Features',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Open the workbench to add tools, knowledge, and other capabilities.',
|
||||||
|
runnerDescription:
|
||||||
|
'Select a runner for the external agent and configure its connection.',
|
||||||
|
backToChoices: 'Back to options',
|
||||||
|
createExternal: 'Create and Bind',
|
||||||
|
finishWithModel: 'Use Selected Model & Finish',
|
||||||
|
openWorkbench: 'Open Workbench',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -1744,6 +1744,23 @@ const jaJP = {
|
|||||||
resaveBot: '設定を再保存',
|
resaveBot: '設定を再保存',
|
||||||
botSaved:
|
botSaved:
|
||||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||||
|
waitingForMessage:
|
||||||
|
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||||
|
messageReceived:
|
||||||
|
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||||
|
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||||
|
sendHttpTest: 'テストメッセージを送信',
|
||||||
|
httpTestAccepted:
|
||||||
|
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||||
|
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||||
logsTitle: 'ボットログ',
|
logsTitle: 'ボットログ',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||||
@@ -1752,6 +1769,50 @@ const jaJP = {
|
|||||||
title: 'AIエンジンを選択',
|
title: 'AIエンジンを選択',
|
||||||
description:
|
description:
|
||||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||||
|
optionalDescription:
|
||||||
|
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
|
||||||
|
externalTitle: '外部プラットフォームの Agent を接続',
|
||||||
|
externalDescription:
|
||||||
|
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
|
||||||
|
ownModelTitle: '自分のモデルを使用',
|
||||||
|
ownModelDescription:
|
||||||
|
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
|
||||||
|
ownModelSetupTitle: '自分のモデルを追加',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
|
||||||
|
addProviderTitle: 'プロバイダーを追加',
|
||||||
|
addProviderDescription:
|
||||||
|
'接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
|
||||||
|
selectModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
|
||||||
|
scanModelMode: 'モデルをスキャン',
|
||||||
|
manualModelMode: '手動で追加',
|
||||||
|
scanningModels: '利用可能なモデルをスキャン中…',
|
||||||
|
noScannedModels:
|
||||||
|
'利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
|
||||||
|
scanModelsFailed:
|
||||||
|
'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualModelId: 'モデル ID',
|
||||||
|
manualModelIdPlaceholder: '例:gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'モデルリクエストで実際に使用する model パラメーターを入力します。',
|
||||||
|
manualModelOptions: '任意のモデル機能',
|
||||||
|
editProvider: 'プロバイダーを編集',
|
||||||
|
rescanModels: 'モデルを再スキャン',
|
||||||
|
moreFeaturesTitle: 'Agent に機能を追加',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
|
||||||
|
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||||
|
backToChoices: '選択肢に戻る',
|
||||||
|
createExternal: '作成して関連付ける',
|
||||||
|
finishWithModel: '選択したモデルを使用して完了',
|
||||||
|
openWorkbench: 'ワークベンチを開く',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -1749,12 +1749,61 @@ const zhHans = {
|
|||||||
saveBot: '保存并启用',
|
saveBot: '保存并启用',
|
||||||
resaveBot: '重新保存配置',
|
resaveBot: '重新保存配置',
|
||||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||||
|
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||||
|
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
|
||||||
|
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
|
||||||
|
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
|
||||||
|
sendHttpTest: '发送测试消息',
|
||||||
|
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
|
||||||
|
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
|
||||||
|
httpTestFailed: '测试消息发送失败:{{error}}',
|
||||||
logsTitle: '机器人日志',
|
logsTitle: '机器人日志',
|
||||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '选择 AI 引擎',
|
title: '选择 AI 引擎',
|
||||||
description: '选择驱动机器人智能的 AI 引擎。',
|
description: '选择驱动机器人智能的 AI 引擎。',
|
||||||
|
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription:
|
||||||
|
'添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
|
||||||
|
ownModelSetupTitle: '添加你自己的模型',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。',
|
||||||
|
addProviderTitle: '添加供应商',
|
||||||
|
addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。',
|
||||||
|
selectModelTitle: '选择模型',
|
||||||
|
selectScannedModelTitle: '选择一个模型',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
|
||||||
|
scanModelMode: '扫描模型',
|
||||||
|
manualModelMode: '手动添加',
|
||||||
|
scanningModels: '正在扫描可用模型…',
|
||||||
|
noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。',
|
||||||
|
scanModelsFailed: '模型扫描失败,请检查地址和 API Key 后重试。',
|
||||||
|
manualFallbackFailed: '自动扫描失败,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'没有扫描到可用模型,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualModelId: '模型 ID',
|
||||||
|
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||||
|
manualModelIdDescription: '填写模型请求中实际使用的 model 参数。',
|
||||||
|
manualModelOptions: '可选模型能力',
|
||||||
|
editProvider: '修改供应商',
|
||||||
|
rescanModels: '重新扫描模型',
|
||||||
|
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
|
||||||
|
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||||
|
backToChoices: '返回选项',
|
||||||
|
createExternal: '创建并绑定',
|
||||||
|
finishWithModel: '使用所选模型并完成',
|
||||||
|
openWorkbench: '进入工作台',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
|
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/wizard/utils.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadWizardUtils() {
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', compiled)(
|
||||||
|
() => {
|
||||||
|
throw new Error('Wizard utils must not have runtime imports');
|
||||||
|
},
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
configureLocalAgentPrimaryModel,
|
||||||
|
ensureHttpBotSigningSecret,
|
||||||
|
findDefaultPipeline,
|
||||||
|
getErrorMessage,
|
||||||
|
isWebhookModeEnabled,
|
||||||
|
} = loadWizardUtils();
|
||||||
|
|
||||||
|
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
|
||||||
|
const config = ensureHttpBotSigningSecret('http_bot', {
|
||||||
|
signature_required: true,
|
||||||
|
inbound_secret: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
|
||||||
|
const existing = { signature_required: true, inbound_secret: 'keep-me' };
|
||||||
|
const disabled = { signature_required: false, inbound_secret: '' };
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add signing config to other adapters', () => {
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts the backend message from structured API errors', () => {
|
||||||
|
assert.equal(
|
||||||
|
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
|
||||||
|
'Signing secret is required',
|
||||||
|
);
|
||||||
|
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects only a usable Workspace default pipeline', () => {
|
||||||
|
const pipelines = [
|
||||||
|
{ uuid: 'recent-pipeline', is_default: false },
|
||||||
|
{ uuid: '', is_default: true },
|
||||||
|
{ uuid: 'default-pipeline', is_default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configures the selected model as the Local Agent primary model', () => {
|
||||||
|
const config = {
|
||||||
|
trigger: { prefix: '!' },
|
||||||
|
ai: {
|
||||||
|
runner: { runner: 'plugin:external', timeout: 30 },
|
||||||
|
'local-agent': {
|
||||||
|
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
|
||||||
|
tools: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
|
||||||
|
|
||||||
|
assert.equal(updated.ai.runner.runner, 'local-agent');
|
||||||
|
assert.equal(updated.ai.runner.timeout, 30);
|
||||||
|
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
|
||||||
|
'fallback-model',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
|
||||||
|
assert.deepEqual(updated.trigger, { prefix: '!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows webhook guidance only when the adapter webhook mode is active', () => {
|
||||||
|
const dualModeFields = [
|
||||||
|
{
|
||||||
|
name: 'webhook_url',
|
||||||
|
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
|
||||||
|
assert.equal(isWebhookModeEnabled([], {}), false);
|
||||||
|
});
|
||||||
+34
-13
@@ -1,18 +1,39 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
resolve: {
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:5300';
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
proxy: {
|
||||||
build: {
|
'/api': {
|
||||||
outDir: 'dist',
|
target: apiProxyTarget,
|
||||||
},
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/mcp': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/bots': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user