mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.9.1"
|
||||
version = "4.9.2"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.1'
|
||||
__version__ = '4.9.2'
|
||||
|
||||
@@ -70,12 +70,17 @@ class BotService:
|
||||
'lark',
|
||||
]:
|
||||
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}'
|
||||
adapter_runtime_values['webhook_url'] = 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:
|
||||
adapter_runtime_values['webhook_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
|
||||
|
||||
|
||||
@@ -105,11 +105,16 @@ class LLMModelsService:
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
||||
pipeline_config = pipeline.config
|
||||
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
||||
pipeline_data = {'config': pipeline_config}
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||
if pipeline is not None:
|
||||
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||
if not model_config.get('primary', ''):
|
||||
pipeline_config = pipeline.config
|
||||
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']
|
||||
|
||||
|
||||
@@ -993,7 +993,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
||||
{'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
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ admins: []
|
||||
api:
|
||||
port: 5300
|
||||
webhook_prefix: 'http://127.0.0.1:5300'
|
||||
extra_webhook_prefix: ''
|
||||
command:
|
||||
enable: true
|
||||
prefix:
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"runner": "local-agent"
|
||||
},
|
||||
"local-agent": {
|
||||
"model": "",
|
||||
"model": {
|
||||
"primary": "",
|
||||
"fallbacks": []
|
||||
},
|
||||
"max-round": 10,
|
||||
"prompt": [
|
||||
{
|
||||
|
||||
@@ -91,14 +91,15 @@ class TestWebhookDisplayPrefix:
|
||||
|
||||
def test_default_webhook_prefix(self):
|
||||
"""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
|
||||
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
||||
assert cfg['api']['extra_webhook_prefix'] == ''
|
||||
|
||||
def test_webhook_prefix_env_override(self):
|
||||
"""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
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
||||
@@ -112,7 +113,7 @@ class TestWebhookDisplayPrefix:
|
||||
|
||||
def test_webhook_prefix_with_custom_domain(self):
|
||||
"""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
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
||||
@@ -126,7 +127,7 @@ class TestWebhookDisplayPrefix:
|
||||
|
||||
def test_webhook_prefix_with_subdirectory(self):
|
||||
"""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
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
||||
@@ -138,6 +139,37 @@ class TestWebhookDisplayPrefix:
|
||||
# Cleanup
|
||||
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__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -194,7 +194,7 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'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}},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
@@ -219,7 +219,7 @@ def sample_pipeline_config():
|
||||
return {
|
||||
'ai': {
|
||||
'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}},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.9.1"
|
||||
version = "4.9.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IChooseAdapterEntity,
|
||||
IPipelineEntity,
|
||||
@@ -113,109 +113,73 @@ export default function BotForm({
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
>([]);
|
||||
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
|
||||
useState<IDynamicFormItemSchema[]>([]);
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const webhookInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
||||
|
||||
// Watch adapter and adapter_config for filtering
|
||||
const currentAdapter = form.watch('adapter');
|
||||
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(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
|
||||
// Filter dynamic form config list based on enable-webhook status for Lark adapter
|
||||
useEffect(() => {
|
||||
if (currentAdapter === 'lark') {
|
||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||
if (enableWebhook === false) {
|
||||
// Hide encrypt-key field when webhook is disabled
|
||||
setFilteredDynamicFormConfigList(
|
||||
dynamicFormConfigList.filter(
|
||||
(config) => config.name !== 'encrypt-key',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Show all fields when webhook is enabled or undefined
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
}
|
||||
// 复制到剪贴板的辅助函数
|
||||
const copyToClipboard = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// 降级:创建临时textarea复制
|
||||
fallbackCopy(text, setStatus);
|
||||
});
|
||||
} else {
|
||||
// For non-Lark adapters, show all fields
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
fallbackCopy(text, setStatus);
|
||||
}
|
||||
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
|
||||
};
|
||||
|
||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
||||
const copyToClipboard = () => {
|
||||
console.log('[Copy] Attempting to copy from input element');
|
||||
|
||||
const inputElement = webhookInputRef.current;
|
||||
if (!inputElement) {
|
||||
console.error('[Copy] Input element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保input元素可见且未被禁用
|
||||
inputElement.disabled = false;
|
||||
inputElement.readOnly = false;
|
||||
|
||||
// 聚焦并选中所有文本
|
||||
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;
|
||||
const fallbackCopy = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (successful) {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,6 +204,7 @@ export default function BotForm({
|
||||
} else {
|
||||
setWebhookUrl('');
|
||||
}
|
||||
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
@@ -249,6 +214,7 @@ export default function BotForm({
|
||||
} else {
|
||||
form.reset();
|
||||
setWebhookUrl('');
|
||||
setExtraWebhookUrl('');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -321,14 +287,20 @@ export default function BotForm({
|
||||
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
|
||||
}
|
||||
|
||||
async function getBotConfig(
|
||||
botId: string,
|
||||
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
|
||||
async function getBotConfig(botId: string): Promise<
|
||||
z.infer<typeof formSchema> & {
|
||||
webhook_full_url?: string;
|
||||
extra_webhook_full_url?: string;
|
||||
}
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpClient
|
||||
.getBot(botId)
|
||||
.then((res) => {
|
||||
const bot = res.bot;
|
||||
const runtimeValues = bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
resolve({
|
||||
adapter: bot.adapter,
|
||||
description: bot.description,
|
||||
@@ -336,10 +308,12 @@ export default function BotForm({
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: bot.enable ?? true,
|
||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||
webhook_full_url: bot.adapter_runtime_values
|
||||
? ((bot.adapter_runtime_values as Record<string, unknown>)
|
||||
.webhook_full_url as string)
|
||||
: undefined,
|
||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -530,13 +504,11 @@ export default function BotForm({
|
||||
|
||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||
{webhookUrl &&
|
||||
(currentAdapter !== 'lark' ||
|
||||
currentAdapterConfig?.['enable-webhook'] !== false) && (
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false) && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={webhookInputRef}
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
@@ -549,7 +521,7 @@ export default function BotForm({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||
@@ -559,8 +531,37 @@ export default function BotForm({
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</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">
|
||||
{t('bots.webhookUrlHint')}
|
||||
{extraWebhookUrl
|
||||
? t('bots.webhookUrlHintEither')
|
||||
: t('bots.webhookUrlHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -667,7 +668,7 @@ export default function BotForm({
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
initialValues={form.watch('adapter_config')}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values);
|
||||
}}
|
||||
|
||||
@@ -34,6 +34,35 @@ export default function DynamicFormComponent({
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
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
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
@@ -116,10 +145,10 @@ export default function DynamicFormComponent({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||
// 优先使用 initialValues,如果没有则使用默认值
|
||||
const value = initialValues?.[item.name] ?? item.default;
|
||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: value,
|
||||
[item.name]: normalizeFieldValue(item, rawValue),
|
||||
};
|
||||
}, {} as FormValues),
|
||||
});
|
||||
@@ -144,7 +173,8 @@ export default function DynamicFormComponent({
|
||||
// 合并默认值和初始值
|
||||
const mergedValues = itemConfigList.reduce(
|
||||
(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;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
@@ -181,6 +211,15 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
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 formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
@@ -191,6 +230,7 @@ export default function DynamicFormComponent({
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
@@ -348,10 +348,31 @@ export default function DynamicFormItemComponent({
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
const modelValue = field.value as {
|
||||
primary: string;
|
||||
fallbacks: string[];
|
||||
};
|
||||
const rawModelValue = field.value;
|
||||
const modelValue: { primary: 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 = (
|
||||
value: string,
|
||||
|
||||
@@ -284,6 +284,8 @@ const enUS = {
|
||||
webhookUrlCopied: 'Webhook URL copied',
|
||||
webhookUrlHint:
|
||||
'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',
|
||||
allLevels: 'All Levels',
|
||||
selectLevel: 'Select Level',
|
||||
|
||||
@@ -289,6 +289,8 @@
|
||||
webhookUrlCopied: 'Webhook URL をコピーしました',
|
||||
webhookUrlHint:
|
||||
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
||||
webhookUrlHintEither:
|
||||
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
|
||||
logLevel: 'ログレベル',
|
||||
allLevels: 'すべてのレベル',
|
||||
selectLevel: 'レベルを選択',
|
||||
|
||||
@@ -273,6 +273,7 @@ const zhHans = {
|
||||
webhookUrlCopied: 'Webhook 地址已复制',
|
||||
webhookUrlHint:
|
||||
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
||||
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
|
||||
logLevel: '日志级别',
|
||||
allLevels: '全部级别',
|
||||
selectLevel: '选择级别',
|
||||
|
||||
@@ -272,6 +272,7 @@ const zhHant = {
|
||||
webhookUrlCopied: 'Webhook 位址已複製',
|
||||
webhookUrlHint:
|
||||
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
||||
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
|
||||
logLevel: '日誌級別',
|
||||
allLevels: '全部級別',
|
||||
selectLevel: '選擇級別',
|
||||
|
||||
Reference in New Issue
Block a user