mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 15:26:03 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e366fc536 | ||
|
|
8bd6442965 | ||
|
|
1a1eadb282 | ||
|
|
eed72b1c12 | ||
|
|
351350ea03 | ||
|
|
bc3d6ba92f | ||
|
|
345e4baf2a | ||
|
|
6c64dc057f | ||
|
|
eec0a9c9d9 | ||
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.1"
|
version = "4.9.3"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -64,7 +64,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.1",
|
"langbot-plugin==0.3.2",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.1'
|
__version__ = '4.9.3'
|
||||||
|
|||||||
@@ -70,12 +70,17 @@ class BotService:
|
|||||||
'lark',
|
'lark',
|
||||||
]:
|
]:
|
||||||
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', '')
|
||||||
webhook_url = f'/bots/{bot_uuid}'
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
adapter_runtime_values['webhook_url'] = webhook_url
|
adapter_runtime_values['webhook_url'] = webhook_url
|
||||||
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = (
|
||||||
|
f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
adapter_runtime_values['webhook_url'] = None
|
adapter_runtime_values['webhook_url'] = None
|
||||||
adapter_runtime_values['webhook_full_url'] = None
|
adapter_runtime_values['webhook_full_url'] = None
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = None
|
||||||
|
|
||||||
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,16 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
if pipeline is not None:
|
||||||
pipeline_config = pipeline.config
|
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
if not model_config.get('primary', ''):
|
||||||
pipeline_data = {'config': pipeline_config}
|
pipeline_config = pipeline.config
|
||||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
pipeline_config['ai']['local-agent']['model'] = {
|
||||||
|
'primary': model_data['uuid'],
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
pipeline_data = {'config': pipeline_config}
|
||||||
|
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -74,20 +74,26 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
current = cfg
|
current = cfg
|
||||||
|
|
||||||
for i, key in enumerate(keys):
|
for i, key in enumerate(keys):
|
||||||
if not isinstance(current, dict) or key not in current:
|
if not isinstance(current, dict):
|
||||||
break
|
break
|
||||||
|
|
||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key - check if it's a scalar value
|
# At the final key
|
||||||
if isinstance(current[key], (dict, list)):
|
if key in current:
|
||||||
# Skip dict and list types
|
if isinstance(current[key], (dict, list)):
|
||||||
pass
|
# Skip dict and list types
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Valid scalar value - convert and set it
|
||||||
|
converted_value = convert_value(env_value, current[key])
|
||||||
|
current[key] = converted_value
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Key doesn't exist yet - create it as string
|
||||||
converted_value = convert_value(env_value, current[key])
|
current[key] = env_value
|
||||||
current[key] = converted_value
|
|
||||||
else:
|
else:
|
||||||
# Navigate deeper
|
# Navigate deeper - create intermediate dict if needed
|
||||||
|
if key not in current:
|
||||||
|
current[key] = {}
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
@@ -146,16 +152,50 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
await ap.instance_config.dump_config()
|
await ap.instance_config.dump_config()
|
||||||
|
|
||||||
# load or generate instance id
|
# load or generate instance id
|
||||||
ap.instance_id = await config.load_json_config(
|
# Priority:
|
||||||
'data/labels/instance_id.json',
|
# 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
|
||||||
template_data={
|
# 2. data/labels/instance_id.json (if file exists)
|
||||||
'instance_id': f'instance_{str(uuid.uuid4())}',
|
# 3. Generate new and save to file
|
||||||
'instance_create_ts': int(time.time()),
|
config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
|
||||||
},
|
|
||||||
completion=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
constants.instance_id = ap.instance_id.data['instance_id']
|
if config_instance_id:
|
||||||
|
# Use the instance_id from config.yaml
|
||||||
|
constants.instance_id = config_instance_id
|
||||||
|
# Still load/create the file for backward compat, but don't use its value
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': f'instance_{str(uuid.uuid4())}',
|
||||||
|
'instance_create_ts': int(time.time()),
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Try loading file-based instance id
|
||||||
|
instance_id_path = os.path.join('data', 'labels', 'instance_id.json')
|
||||||
|
if os.path.exists(instance_id_path):
|
||||||
|
# File exists, read it
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': '',
|
||||||
|
'instance_create_ts': 0,
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
constants.instance_id = ap.instance_id.data['instance_id']
|
||||||
|
else:
|
||||||
|
# Neither config nor file, generate new and save to file
|
||||||
|
new_id = f'instance_{str(uuid.uuid4())}'
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': new_id,
|
||||||
|
'instance_create_ts': int(time.time()),
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
constants.instance_id = new_id
|
||||||
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
||||||
|
|
||||||
print(f'LangBot instance id: {constants.instance_id}')
|
print(f'LangBot instance id: {constants.instance_id}')
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
query.user_message = provider_message.Message(role='user', content=content_list)
|
query.user_message = provider_message.Message(role='user', content=content_list)
|
||||||
|
|
||||||
|
# Extract knowledge base UUIDs into query variables so plugins can modify them
|
||||||
|
# during PromptPreProcessing before the runner performs retrieval.
|
||||||
|
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
||||||
|
if not kb_uuids:
|
||||||
|
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
kb_uuids = [old_kb_uuid]
|
||||||
|
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
|
||||||
|
|
||||||
# =========== 触发事件 PromptPreProcessing
|
# =========== 触发事件 PromptPreProcessing
|
||||||
|
|
||||||
event = events.PromptPreProcessing(
|
event = events.PromptPreProcessing(
|
||||||
|
|||||||
@@ -675,11 +675,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||||
entries = await kb.retrieve(
|
entries = await kb.retrieve(
|
||||||
query_text,
|
query_text,
|
||||||
settings={
|
settings={
|
||||||
'top_k': top_k,
|
'top_k': top_k,
|
||||||
'filters': filters,
|
'filters': filters,
|
||||||
|
'session_name': session_name,
|
||||||
|
'bot_uuid': query.bot_uuid or '',
|
||||||
|
'sender_id': str(query.sender_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
results = [entry.model_dump(mode='json') for entry in entries]
|
results = [entry.model_dump(mode='json') for entry in entries]
|
||||||
@@ -993,7 +997,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
||||||
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
||||||
timeout=300, # Ingestion can be slow
|
timeout=1200, # Ingestion can be slow for large documents
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -132,14 +132,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
"""Run request"""
|
"""Run request"""
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
|
|
||||||
# Get knowledge bases list (new field)
|
# Get knowledge bases list from query variables (set by PreProcessor,
|
||||||
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
# may have been modified by plugins during PromptPreProcessing)
|
||||||
|
kb_uuids = query.variables.get('_knowledge_base_uuids', [])
|
||||||
# Fallback to old field for backward compatibility
|
|
||||||
if not kb_uuids:
|
|
||||||
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
|
||||||
if old_kb_uuid and old_kb_uuid != '__none__':
|
|
||||||
kb_uuids = [old_kb_uuid]
|
|
||||||
|
|
||||||
user_message = copy.deepcopy(query.user_message)
|
user_message = copy.deepcopy(query.user_message)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ admins: []
|
|||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
webhook_prefix: 'http://127.0.0.1:5300'
|
||||||
|
extra_webhook_prefix: ''
|
||||||
command:
|
command:
|
||||||
enable: true
|
enable: true
|
||||||
prefix:
|
prefix:
|
||||||
@@ -15,6 +16,7 @@ proxy:
|
|||||||
http: ''
|
http: ''
|
||||||
https: ''
|
https: ''
|
||||||
system:
|
system:
|
||||||
|
instance_id: ''
|
||||||
edition: community
|
edition: community
|
||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
|
|||||||
@@ -41,7 +41,10 @@
|
|||||||
"runner": "local-agent"
|
"runner": "local-agent"
|
||||||
},
|
},
|
||||||
"local-agent": {
|
"local-agent": {
|
||||||
"model": "",
|
"model": {
|
||||||
|
"primary": "",
|
||||||
|
"fallbacks": []
|
||||||
|
},
|
||||||
"max-round": 10,
|
"max-round": 10,
|
||||||
"prompt": [
|
"prompt": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -91,14 +91,15 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_default_webhook_prefix(self):
|
def test_default_webhook_prefix(self):
|
||||||
"""Test that the default webhook display prefix is correctly set"""
|
"""Test that the default webhook display prefix is correctly set"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Should have the default value
|
# Should have the default value
|
||||||
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
||||||
|
assert cfg['api']['extra_webhook_prefix'] == ''
|
||||||
|
|
||||||
def test_webhook_prefix_env_override(self):
|
def test_webhook_prefix_env_override(self):
|
||||||
"""Test overriding webhook_prefix via environment variable"""
|
"""Test overriding webhook_prefix via environment variable"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set environment variable
|
# Set environment variable
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
||||||
@@ -112,7 +113,7 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_webhook_prefix_with_custom_domain(self):
|
def test_webhook_prefix_with_custom_domain(self):
|
||||||
"""Test webhook_prefix with custom domain"""
|
"""Test webhook_prefix with custom domain"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set to a custom domain
|
# Set to a custom domain
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
||||||
@@ -126,7 +127,7 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_webhook_prefix_with_subdirectory(self):
|
def test_webhook_prefix_with_subdirectory(self):
|
||||||
"""Test webhook_prefix with subdirectory path"""
|
"""Test webhook_prefix with subdirectory path"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set to a URL with subdirectory
|
# Set to a URL with subdirectory
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
||||||
@@ -138,6 +139,37 @@ class TestWebhookDisplayPrefix:
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
del os.environ['API__WEBHOOK_PREFIX']
|
del os.environ['API__WEBHOOK_PREFIX']
|
||||||
|
|
||||||
|
def test_extra_webhook_prefix_default_empty(self):
|
||||||
|
"""Test that extra_webhook_prefix defaults to empty string"""
|
||||||
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
|
bot_uuid = 'test-bot-uuid'
|
||||||
|
webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
|
extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '')
|
||||||
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
|
|
||||||
|
assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid'
|
||||||
|
# extra should be empty when not configured
|
||||||
|
assert extra_webhook_prefix == ''
|
||||||
|
|
||||||
|
def test_extra_webhook_prefix_env_override(self):
|
||||||
|
"""Test overriding extra_webhook_prefix via environment variable"""
|
||||||
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
|
os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
|
||||||
|
|
||||||
|
bot_uuid = 'test-bot-uuid'
|
||||||
|
extra_prefix = result['api']['extra_webhook_prefix']
|
||||||
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
|
assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid'
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del os.environ['API__EXTRA_WEBHOOK_PREFIX']
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
pytest.main([__file__, '-v'])
|
pytest.main([__file__, '-v'])
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
|
|||||||
pipeline_config={
|
pipeline_config={
|
||||||
'ai': {
|
'ai': {
|
||||||
'runner': {'runner': 'local-agent'},
|
'runner': {'runner': 'local-agent'},
|
||||||
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'},
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
},
|
},
|
||||||
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
||||||
'trigger': {'misc': {'combine-quote-message': False}},
|
'trigger': {'misc': {'combine-quote-message': False}},
|
||||||
@@ -219,7 +219,7 @@ def sample_pipeline_config():
|
|||||||
return {
|
return {
|
||||||
'ai': {
|
'ai': {
|
||||||
'runner': {'runner': 'local-agent'},
|
'runner': {'runner': 'local-agent'},
|
||||||
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'},
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
},
|
},
|
||||||
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
||||||
'trigger': {'misc': {'combine-quote-message': False}},
|
'trigger': {'misc': {'combine-quote-message': False}},
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.1"
|
version = "4.9.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.1" },
|
{ name = "langbot-plugin", specifier = "==0.3.2" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +1993,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/ed/b440e26ebc40983abf00dd343338101ada3381065fb3347401ba75f873fe/langbot_plugin-0.3.1.tar.gz", hash = "sha256:0839dcb4cfe689fc670d0ded29b57e6a3f683d8f7326eaa771a5b753675459ac", size = 170285, upload-time = "2026-03-12T15:07:01.918Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/05/32/1b029961d718b5418d9d139687287193d4657914c467833176d9c060fb4c/langbot_plugin-0.3.2.tar.gz", hash = "sha256:2f7f16285600ec019a4e8cc8b40bc8f8d404e3bbc69c9d129620dc70b3bde2f8", size = 170431, upload-time = "2026-03-14T12:42:55.175Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/81/d3c4142911792838b90384a28f7dd1540d0862303293c53ba77e69fc0e15/langbot_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8139796926fe8385b7b546ef865e29b1b8d8e28249e20f3b5417d42d3181ec62", size = 144813, upload-time = "2026-03-12T15:07:03.69Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/d7/a5dd6cff000a4f7fe88692689be276875b04485f48fc26e162f1ad2a341c/langbot_plugin-0.3.2-py3-none-any.whl", hash = "sha256:a1de527c4d651e6b6ba2458b6fac09a300e6b1ffdc5a25bbfad88d970cfd6cfd", size = 144906, upload-time = "2026-03-14T12:42:56.486Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IChooseAdapterEntity,
|
IChooseAdapterEntity,
|
||||||
IPipelineEntity,
|
IPipelineEntity,
|
||||||
@@ -113,109 +113,73 @@ export default function BotForm({
|
|||||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||||
IDynamicFormItemSchema[]
|
IDynamicFormItemSchema[]
|
||||||
>([]);
|
>([]);
|
||||||
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
|
|
||||||
useState<IDynamicFormItemSchema[]>([]);
|
|
||||||
const [, setIsLoading] = useState<boolean>(false);
|
const [, setIsLoading] = useState<boolean>(false);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||||
const webhookInputRef = React.useRef<HTMLInputElement>(null);
|
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
// Watch adapter and adapter_config for filtering
|
// Watch adapter and adapter_config for filtering
|
||||||
const currentAdapter = form.watch('adapter');
|
const currentAdapter = form.watch('adapter');
|
||||||
const currentAdapterConfig = form.watch('adapter_config');
|
const currentAdapterConfig = form.watch('adapter_config');
|
||||||
|
|
||||||
|
// Derive the filtered config list via useMemo instead of useEffect+setState
|
||||||
|
// to avoid creating new array references that would cause DynamicFormComponent
|
||||||
|
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
|
||||||
|
// Only depend on the specific field we care about (enable-webhook) rather than
|
||||||
|
// the entire currentAdapterConfig object, which changes on every emission.
|
||||||
|
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||||
|
const filteredDynamicFormConfigList = useMemo(() => {
|
||||||
|
if (currentAdapter === 'lark' && enableWebhook === false) {
|
||||||
|
// Hide encrypt-key field when webhook is disabled
|
||||||
|
return dynamicFormConfigList.filter(
|
||||||
|
(config) => config.name !== 'encrypt-key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
|
||||||
|
return dynamicFormConfigList;
|
||||||
|
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBotFormValues();
|
setBotFormValues();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter dynamic form config list based on enable-webhook status for Lark adapter
|
// 复制到剪贴板的辅助函数
|
||||||
useEffect(() => {
|
const copyToClipboard = (
|
||||||
if (currentAdapter === 'lark') {
|
text: string,
|
||||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
if (enableWebhook === false) {
|
) => {
|
||||||
// Hide encrypt-key field when webhook is disabled
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
setFilteredDynamicFormConfigList(
|
navigator.clipboard
|
||||||
dynamicFormConfigList.filter(
|
.writeText(text)
|
||||||
(config) => config.name !== 'encrypt-key',
|
.then(() => {
|
||||||
),
|
setStatus(true);
|
||||||
);
|
setTimeout(() => setStatus(false), 2000);
|
||||||
} else {
|
})
|
||||||
// Show all fields when webhook is enabled or undefined
|
.catch(() => {
|
||||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
// 降级:创建临时textarea复制
|
||||||
}
|
fallbackCopy(text, setStatus);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// For non-Lark adapters, show all fields
|
fallbackCopy(text, setStatus);
|
||||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
|
||||||
}
|
}
|
||||||
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
|
};
|
||||||
|
|
||||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
const fallbackCopy = (
|
||||||
const copyToClipboard = () => {
|
text: string,
|
||||||
console.log('[Copy] Attempting to copy from input element');
|
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
) => {
|
||||||
const inputElement = webhookInputRef.current;
|
const textarea = document.createElement('textarea');
|
||||||
if (!inputElement) {
|
textarea.value = text;
|
||||||
console.error('[Copy] Input element not found');
|
textarea.style.position = 'fixed';
|
||||||
return;
|
textarea.style.opacity = '0';
|
||||||
}
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
try {
|
const successful = document.execCommand('copy');
|
||||||
// 确保input元素可见且未被禁用
|
document.body.removeChild(textarea);
|
||||||
inputElement.disabled = false;
|
if (successful) {
|
||||||
inputElement.readOnly = false;
|
setStatus(true);
|
||||||
|
setTimeout(() => setStatus(false), 2000);
|
||||||
// 聚焦并选中所有文本
|
|
||||||
inputElement.focus();
|
|
||||||
inputElement.select();
|
|
||||||
|
|
||||||
// 尝试使用现代API
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
console.log(
|
|
||||||
'[Copy] Using Clipboard API with input value:',
|
|
||||||
inputElement.value,
|
|
||||||
);
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(inputElement.value)
|
|
||||||
.then(() => {
|
|
||||||
console.log('[Copy] Clipboard API success');
|
|
||||||
inputElement.blur(); // 取消选中
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[Copy] Clipboard API failed, trying execCommand:',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
// 降级到execCommand
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
console.log('[Copy] execCommand result:', successful);
|
|
||||||
inputElement.blur();
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 直接使用execCommand
|
|
||||||
console.log(
|
|
||||||
'[Copy] Using execCommand with input value:',
|
|
||||||
inputElement.value,
|
|
||||||
);
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
console.log('[Copy] execCommand result:', successful);
|
|
||||||
inputElement.blur();
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Copy] Copy failed:', err);
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,6 +204,7 @@ export default function BotForm({
|
|||||||
} else {
|
} else {
|
||||||
setWebhookUrl('');
|
setWebhookUrl('');
|
||||||
}
|
}
|
||||||
|
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -249,6 +214,7 @@ export default function BotForm({
|
|||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
setWebhookUrl('');
|
setWebhookUrl('');
|
||||||
|
setExtraWebhookUrl('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -321,14 +287,20 @@ export default function BotForm({
|
|||||||
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
|
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBotConfig(
|
async function getBotConfig(botId: string): Promise<
|
||||||
botId: string,
|
z.infer<typeof formSchema> & {
|
||||||
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
|
webhook_full_url?: string;
|
||||||
|
extra_webhook_full_url?: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
httpClient
|
httpClient
|
||||||
.getBot(botId)
|
.getBot(botId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const bot = res.bot;
|
const bot = res.bot;
|
||||||
|
const runtimeValues = bot.adapter_runtime_values as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
resolve({
|
resolve({
|
||||||
adapter: bot.adapter,
|
adapter: bot.adapter,
|
||||||
description: bot.description,
|
description: bot.description,
|
||||||
@@ -336,10 +308,12 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
webhook_full_url: bot.adapter_runtime_values
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
? ((bot.adapter_runtime_values as Record<string, unknown>)
|
| string
|
||||||
.webhook_full_url as string)
|
| undefined,
|
||||||
: undefined,
|
extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -530,13 +504,11 @@ export default function BotForm({
|
|||||||
|
|
||||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||||
{webhookUrl &&
|
{webhookUrl &&
|
||||||
(currentAdapter !== 'lark' ||
|
(currentAdapter !== 'lark' || enableWebhook !== false) && (
|
||||||
currentAdapterConfig?.['enable-webhook'] !== false) && (
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
ref={webhookInputRef}
|
|
||||||
value={webhookUrl}
|
value={webhookUrl}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||||
@@ -549,7 +521,7 @@ export default function BotForm({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={copyToClipboard}
|
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||||
@@ -559,8 +531,37 @@ export default function BotForm({
|
|||||||
{t('common.copy')}
|
{t('common.copy')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{extraWebhookUrl && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
value={extraWebhookUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||||
|
onClick={(e) => {
|
||||||
|
(e.target as HTMLInputElement).select();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{extraCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{t('bots.webhookUrlHint')}
|
{extraWebhookUrl
|
||||||
|
? t('bots.webhookUrlHintEither')
|
||||||
|
: t('bots.webhookUrlHint')}
|
||||||
</p>
|
</p>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -667,7 +668,7 @@ export default function BotForm({
|
|||||||
</div>
|
</div>
|
||||||
<DynamicFormComponent
|
<DynamicFormComponent
|
||||||
itemConfigList={filteredDynamicFormConfigList}
|
itemConfigList={filteredDynamicFormConfigList}
|
||||||
initialValues={form.watch('adapter_config')}
|
initialValues={currentAdapterConfig}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
form.setValue('adapter_config', values);
|
form.setValue('adapter_config', values);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,6 +34,35 @@ export default function DynamicFormComponent({
|
|||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Normalize a form value according to its field type.
|
||||||
|
// This ensures legacy/malformed data (e.g. a plain string for
|
||||||
|
// model-fallback-selector) is coerced to the expected shape
|
||||||
|
// so that downstream components never crash.
|
||||||
|
const normalizeFieldValue = (
|
||||||
|
item: IDynamicFormItemSchema,
|
||||||
|
value: unknown,
|
||||||
|
): unknown => {
|
||||||
|
if (item.type === 'model-fallback-selector') {
|
||||||
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
primary: typeof obj.primary === 'string' ? obj.primary : '',
|
||||||
|
fallbacks: Array.isArray(obj.fallbacks)
|
||||||
|
? (obj.fallbacks as unknown[]).filter(
|
||||||
|
(v): v is string => typeof v === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Legacy string format or any other unexpected type
|
||||||
|
return {
|
||||||
|
primary: typeof value === 'string' ? value : '',
|
||||||
|
fallbacks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
// 根据 itemConfigList 动态生成 zod schema
|
// 根据 itemConfigList 动态生成 zod schema
|
||||||
const formSchema = z.object(
|
const formSchema = z.object(
|
||||||
itemConfigList.reduce(
|
itemConfigList.reduce(
|
||||||
@@ -116,10 +145,10 @@ export default function DynamicFormComponent({
|
|||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||||
// 优先使用 initialValues,如果没有则使用默认值
|
// 优先使用 initialValues,如果没有则使用默认值
|
||||||
const value = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[item.name]: value,
|
[item.name]: normalizeFieldValue(item, rawValue),
|
||||||
};
|
};
|
||||||
}, {} as FormValues),
|
}, {} as FormValues),
|
||||||
});
|
});
|
||||||
@@ -144,7 +173,8 @@ export default function DynamicFormComponent({
|
|||||||
// 合并默认值和初始值
|
// 合并默认值和初始值
|
||||||
const mergedValues = itemConfigList.reduce(
|
const mergedValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = initialValues[item.name] ?? item.default;
|
const rawValue = initialValues[item.name] ?? item.default;
|
||||||
|
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
@@ -181,6 +211,15 @@ export default function DynamicFormComponent({
|
|||||||
);
|
);
|
||||||
onSubmitRef.current?.(initialFinalValues);
|
onSubmitRef.current?.(initialFinalValues);
|
||||||
|
|
||||||
|
// Update previousInitialValues to the emitted snapshot so that if the
|
||||||
|
// parent writes these values back as new initialValues, the deep
|
||||||
|
// comparison in the initialValues-sync useEffect won't detect a change
|
||||||
|
// and won't trigger an infinite update loop.
|
||||||
|
previousInitialValues.current = initialFinalValues as Record<
|
||||||
|
string,
|
||||||
|
object
|
||||||
|
>;
|
||||||
|
|
||||||
const subscription = form.watch(() => {
|
const subscription = form.watch(() => {
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
const finalValues = itemConfigList.reduce(
|
const finalValues = itemConfigList.reduce(
|
||||||
@@ -191,6 +230,7 @@ export default function DynamicFormComponent({
|
|||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
);
|
);
|
||||||
onSubmitRef.current?.(finalValues);
|
onSubmitRef.current?.(finalValues);
|
||||||
|
previousInitialValues.current = finalValues as Record<string, object>;
|
||||||
});
|
});
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, itemConfigList]);
|
}, [form, itemConfigList]);
|
||||||
|
|||||||
@@ -348,10 +348,31 @@ export default function DynamicFormItemComponent({
|
|||||||
{} as Record<string, LLMModel[]>,
|
{} as Record<string, LLMModel[]>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modelValue = field.value as {
|
const rawModelValue = field.value;
|
||||||
primary: string;
|
const modelValue: { primary: string; fallbacks: string[] } =
|
||||||
fallbacks: string[];
|
rawModelValue != null &&
|
||||||
};
|
typeof rawModelValue === 'object' &&
|
||||||
|
!Array.isArray(rawModelValue)
|
||||||
|
? {
|
||||||
|
primary:
|
||||||
|
typeof (rawModelValue as Record<string, unknown>).primary ===
|
||||||
|
'string'
|
||||||
|
? ((rawModelValue as Record<string, unknown>)
|
||||||
|
.primary as string)
|
||||||
|
: '',
|
||||||
|
fallbacks: Array.isArray(
|
||||||
|
(rawModelValue as Record<string, unknown>).fallbacks,
|
||||||
|
)
|
||||||
|
? (
|
||||||
|
(rawModelValue as Record<string, unknown>)
|
||||||
|
.fallbacks as unknown[]
|
||||||
|
).filter((v): v is string => typeof v === 'string')
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
|
||||||
|
fallbacks: [],
|
||||||
|
};
|
||||||
|
|
||||||
const renderModelSelect = (
|
const renderModelSelect = (
|
||||||
value: string,
|
value: string,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient, initializeUserInfo } from '@/app/infra/http';
|
import { httpClient, initializeUserInfo } from '@/app/infra/http';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Mail, Lock, Loader2 } from 'lucide-react';
|
import { Mail, Lock, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -46,6 +46,8 @@ export default function Login() {
|
|||||||
const [accountType, setAccountType] = useState<AccountType | null>(null);
|
const [accountType, setAccountType] = useState<AccountType | null>(null);
|
||||||
const [hasPassword, setHasPassword] = useState(false);
|
const [hasPassword, setHasPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||||
resolver: zodResolver(formSchema(t)),
|
resolver: zodResolver(formSchema(t)),
|
||||||
@@ -61,6 +63,7 @@ export default function Login() {
|
|||||||
|
|
||||||
async function checkAccountInfo() {
|
async function checkAccountInfo() {
|
||||||
try {
|
try {
|
||||||
|
setLoadError(null);
|
||||||
const res = await httpClient.getAccountInfo();
|
const res = await httpClient.getAccountInfo();
|
||||||
if (!res.initialized) {
|
if (!res.initialized) {
|
||||||
router.push('/register');
|
router.push('/register');
|
||||||
@@ -72,11 +75,22 @@ export default function Login() {
|
|||||||
|
|
||||||
// Also check if already logged in
|
// Also check if already logged in
|
||||||
checkIfAlreadyLoggedIn();
|
checkIfAlreadyLoggedIn();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : t('common.loginLoadError');
|
||||||
|
setLoadError(errorMessage);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRetry() {
|
||||||
|
setRetrying(true);
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
await checkAccountInfo();
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
|
||||||
function checkIfAlreadyLoggedIn() {
|
function checkIfAlreadyLoggedIn() {
|
||||||
httpClient
|
httpClient
|
||||||
.checkUserToken()
|
.checkUserToken()
|
||||||
@@ -129,6 +143,54 @@ export default function Login() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show error state when account info failed to load
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
|
||||||
|
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={langbotIcon.src}
|
||||||
|
alt="LangBot"
|
||||||
|
className="w-16 h-16 mb-4 mx-auto"
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-2xl text-center">
|
||||||
|
{t('common.welcome')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
{t('common.loginLoadErrorDesc')}
|
||||||
|
</p>
|
||||||
|
<code className="text-xs bg-muted px-3 py-2 rounded max-w-full overflow-x-auto block text-center text-muted-foreground">
|
||||||
|
{loadError}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine what to show based on account type
|
// Determine what to show based on account type
|
||||||
const showLocalLogin =
|
const showLocalLogin =
|
||||||
accountType === 'local' || (accountType === 'space' && hasPassword);
|
accountType === 'local' || (accountType === 'space' && hasPassword);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const enUS = {
|
|||||||
continueToLogin: 'Login to continue',
|
continueToLogin: 'Login to continue',
|
||||||
loginSuccess: 'Login successful',
|
loginSuccess: 'Login successful',
|
||||||
loginFailed: 'Login failed, please check your email and password',
|
loginFailed: 'Login failed, please check your email and password',
|
||||||
|
loginLoadError: 'Unable to connect to server',
|
||||||
|
loginLoadErrorDesc:
|
||||||
|
'Unable to connect to the LangBot backend. Please make sure the service is running and try again.',
|
||||||
|
retry: 'Retry',
|
||||||
enterEmail: 'Enter email address',
|
enterEmail: 'Enter email address',
|
||||||
enterPassword: 'Enter password',
|
enterPassword: 'Enter password',
|
||||||
invalidEmail: 'Please enter a valid email address',
|
invalidEmail: 'Please enter a valid email address',
|
||||||
@@ -284,6 +288,8 @@ const enUS = {
|
|||||||
webhookUrlCopied: 'Webhook URL copied',
|
webhookUrlCopied: 'Webhook URL copied',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
|
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
|
||||||
|
webhookUrlHintEither:
|
||||||
|
'Use either of the two URLs above in your platform configuration',
|
||||||
logLevel: 'Log Level',
|
logLevel: 'Log Level',
|
||||||
allLevels: 'All Levels',
|
allLevels: 'All Levels',
|
||||||
selectLevel: 'Select Level',
|
selectLevel: 'Select Level',
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
loginSuccess: 'ログインに成功しました',
|
loginSuccess: 'ログインに成功しました',
|
||||||
loginFailed:
|
loginFailed:
|
||||||
'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください',
|
'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください',
|
||||||
|
loginLoadError: 'サーバーに接続できません',
|
||||||
|
loginLoadErrorDesc:
|
||||||
|
'LangBot バックエンドに接続できません。サービスが起動していることを確認してから再試行してください。',
|
||||||
|
retry: '再試行',
|
||||||
enterEmail: 'メールアドレスを入力',
|
enterEmail: 'メールアドレスを入力',
|
||||||
enterPassword: 'パスワードを入力',
|
enterPassword: 'パスワードを入力',
|
||||||
invalidEmail: '有効なメールアドレスを入力してください',
|
invalidEmail: '有効なメールアドレスを入力してください',
|
||||||
@@ -289,6 +293,8 @@
|
|||||||
webhookUrlCopied: 'Webhook URL をコピーしました',
|
webhookUrlCopied: 'Webhook URL をコピーしました',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
||||||
|
webhookUrlHintEither:
|
||||||
|
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
|
||||||
logLevel: 'ログレベル',
|
logLevel: 'ログレベル',
|
||||||
allLevels: 'すべてのレベル',
|
allLevels: 'すべてのレベル',
|
||||||
selectLevel: 'レベルを選択',
|
selectLevel: 'レベルを選択',
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const zhHans = {
|
|||||||
continueToLogin: '登录以继续',
|
continueToLogin: '登录以继续',
|
||||||
loginSuccess: '登录成功',
|
loginSuccess: '登录成功',
|
||||||
loginFailed: '登录失败,请检查邮箱和密码是否正确',
|
loginFailed: '登录失败,请检查邮箱和密码是否正确',
|
||||||
|
loginLoadError: '无法连接到服务器',
|
||||||
|
loginLoadErrorDesc: '无法连接到 LangBot 后端服务,请确认服务已启动后重试。',
|
||||||
|
retry: '重试',
|
||||||
enterEmail: '输入邮箱地址',
|
enterEmail: '输入邮箱地址',
|
||||||
enterPassword: '输入密码',
|
enterPassword: '输入密码',
|
||||||
invalidEmail: '请输入有效的邮箱地址',
|
invalidEmail: '请输入有效的邮箱地址',
|
||||||
@@ -273,6 +276,7 @@ const zhHans = {
|
|||||||
webhookUrlCopied: 'Webhook 地址已复制',
|
webhookUrlCopied: 'Webhook 地址已复制',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
||||||
|
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
|
||||||
logLevel: '日志级别',
|
logLevel: '日志级别',
|
||||||
allLevels: '全部级别',
|
allLevels: '全部级别',
|
||||||
selectLevel: '选择级别',
|
selectLevel: '选择级别',
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const zhHant = {
|
|||||||
continueToLogin: '登入以繼續',
|
continueToLogin: '登入以繼續',
|
||||||
loginSuccess: '登入成功',
|
loginSuccess: '登入成功',
|
||||||
loginFailed: '登入失敗,請檢查電子郵件和密碼是否正確',
|
loginFailed: '登入失敗,請檢查電子郵件和密碼是否正確',
|
||||||
|
loginLoadError: '無法連線到伺服器',
|
||||||
|
loginLoadErrorDesc: '無法連線到 LangBot 後端服務,請確認服務已啟動後重試。',
|
||||||
|
retry: '重試',
|
||||||
enterEmail: '輸入電子郵件地址',
|
enterEmail: '輸入電子郵件地址',
|
||||||
enterPassword: '輸入密碼',
|
enterPassword: '輸入密碼',
|
||||||
invalidEmail: '請輸入有效的電子郵件地址',
|
invalidEmail: '請輸入有效的電子郵件地址',
|
||||||
@@ -272,6 +275,7 @@ const zhHant = {
|
|||||||
webhookUrlCopied: 'Webhook 位址已複製',
|
webhookUrlCopied: 'Webhook 位址已複製',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
||||||
|
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
|
||||||
logLevel: '日誌級別',
|
logLevel: '日誌級別',
|
||||||
allLevels: '全部級別',
|
allLevels: '全部級別',
|
||||||
selectLevel: '選擇級別',
|
selectLevel: '選擇級別',
|
||||||
|
|||||||
Reference in New Issue
Block a user