mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 06:46:02 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.1"
|
version = "4.9.2"
|
||||||
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"]
|
||||||
|
|||||||
@@ -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.2'
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|
||||||
|
|||||||
@@ -993,7 +993,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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.1"
|
version = "4.9.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -284,6 +284,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',
|
||||||
|
|||||||
@@ -289,6 +289,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: 'レベルを選択',
|
||||||
|
|||||||
@@ -273,6 +273,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: '选择级别',
|
||||||
|
|||||||
@@ -272,6 +272,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