local inference: stricter GGUF requirements, auto detection of tool calling support, fixed thinking output parsing (#9442)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-05-27 20:00:30 +02:00
committed by GitHub
parent d90b349a69
commit 27b41d93f5
23 changed files with 1844 additions and 492 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -3
View File
@@ -76,6 +76,16 @@ export type ChatRequest = {
user_message: Message;
};
export type ChatTemplate = {
type: 'embedded';
} | {
name: string;
type: 'builtin';
} | {
template: string;
type: 'custom_inline';
};
export type CheckProviderRequest = {
provider: string;
};
@@ -863,6 +873,7 @@ export type ModelInfoResponse = {
};
export type ModelSettings = {
chat_template?: ChatTemplate;
context_size?: number | null;
enable_thinking?: boolean;
flash_attention?: boolean | null;
@@ -880,16 +891,15 @@ export type ModelSettings = {
n_batch?: number | null;
n_gpu_layers?: number | null;
n_threads?: number | null;
native_tool_calling?: boolean;
presence_penalty?: number;
repeat_last_n?: number;
repeat_penalty?: number;
sampling?: SamplingConfig;
use_jinja?: boolean;
tool_calling?: ToolCallingMode;
use_mlock?: boolean;
/**
* Whether this model architecture supports vision input.
* Derived from the featured model table, not user-configurable.
* Derived from associated mmproj metadata, not user-configurable.
*/
vision_capable?: boolean;
};
@@ -1544,6 +1554,8 @@ export type ToolAnnotations = {
title?: string;
};
export type ToolCallingMode = 'auto' | 'force_native' | 'force_emulated';
export type ToolConfirmationRequest = {
arguments: JsonObject;
id: string;
@@ -3241,6 +3253,22 @@ export type StartTetrateSetupResponses = {
export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses];
export type ListBuiltinChatTemplatesData = {
body?: never;
path?: never;
query?: never;
url: '/local-inference/chat-templates/builtin';
};
export type ListBuiltinChatTemplatesResponses = {
/**
* llama.cpp built-in chat template names
*/
200: Array<string>;
};
export type ListBuiltinChatTemplatesResponse = ListBuiltinChatTemplatesResponses[keyof ListBuiltinChatTemplatesResponses];
export type DownloadHfModelData = {
body: DownloadModelRequest;
path?: never;
@@ -4,9 +4,12 @@ import { Button } from '../../ui/button';
import { Switch } from '../../ui/switch';
import {
getModelSettings,
listBuiltinChatTemplates,
updateModelSettings,
type ChatTemplate,
type ModelSettings,
type SamplingConfig,
type ToolCallingMode,
} from '../../../api';
import { defineMessages, useIntl } from '../../../i18n';
@@ -161,16 +164,59 @@ const i18n = defineMessages({
},
toolCalling: {
id: 'modelSettingsPanel.toolCalling',
defaultMessage: 'Tool Calling',
defaultMessage: 'Tool calling',
},
nativeToolCalling: {
id: 'modelSettingsPanel.nativeToolCalling',
defaultMessage: 'Native tool calling',
toolCallingDescription: {
id: 'modelSettingsPanel.toolCallingDescription',
defaultMessage: 'Choose how local models select native or emulated tool calling',
},
nativeToolCallingDescription: {
id: 'modelSettingsPanel.nativeToolCallingDescription',
defaultMessage:
"Use the model's built-in tool-call format instead of the shell-command emulator. Enable for large models that reliably support tool calling.",
toolCallingAuto: {
id: 'modelSettingsPanel.toolCallingAuto',
defaultMessage: 'Auto',
},
toolCallingForceNative: {
id: 'modelSettingsPanel.toolCallingForceNative',
defaultMessage: 'Force native',
},
toolCallingForceEmulated: {
id: 'modelSettingsPanel.toolCallingForceEmulated',
defaultMessage: 'Force emulated',
},
chatTemplate: {
id: 'modelSettingsPanel.chatTemplate',
defaultMessage: 'Chat template',
},
chatTemplateDescription: {
id: 'modelSettingsPanel.chatTemplateDescription',
defaultMessage: 'Use embedded GGUF metadata, a llama.cpp built-in template, or inline Jinja',
},
chatTemplateEmbedded: {
id: 'modelSettingsPanel.chatTemplateEmbedded',
defaultMessage: 'Embedded',
},
chatTemplateBuiltin: {
id: 'modelSettingsPanel.chatTemplateBuiltin',
defaultMessage: 'Built-in',
},
chatTemplateCustomInline: {
id: 'modelSettingsPanel.chatTemplateCustomInline',
defaultMessage: 'Custom inline',
},
builtinChatTemplate: {
id: 'modelSettingsPanel.builtinChatTemplate',
defaultMessage: 'Built-in template',
},
builtinChatTemplateDescription: {
id: 'modelSettingsPanel.builtinChatTemplateDescription',
defaultMessage: 'Select a llama.cpp built-in template name',
},
customChatTemplate: {
id: 'modelSettingsPanel.customChatTemplate',
defaultMessage: 'Custom chat template',
},
customChatTemplateDescription: {
id: 'modelSettingsPanel.customChatTemplateDescription',
defaultMessage: 'Paste the full Jinja chat template source',
},
});
@@ -194,10 +240,12 @@ const DEFAULT_SETTINGS: ModelSettings = {
use_mlock: false,
flash_attention: null,
n_threads: null,
native_tool_calling: false,
tool_calling: 'auto',
chat_template: { type: 'embedded' },
};
type SamplingType = SamplingConfig['type'];
type ChatTemplateMode = 'embedded' | 'builtin' | 'custom_inline';
function NumberField({
label,
@@ -302,16 +350,59 @@ function SelectField<T extends string>({
);
}
function TextAreaField({
label,
description,
value,
onChange,
onBlur,
}: {
label: string;
description?: string;
value: string;
onChange: (v: string) => void;
onBlur: () => void;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-text-default">{label}</label>
{description && <span className="text-xs text-text-muted">{description}</span>}
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
spellCheck={false}
className="min-h-32 rounded border border-border-subtle bg-background-default px-2 py-1 font-mono text-xs text-text-default"
/>
</div>
);
}
export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
const intl = useIntl();
const [settings, setSettings] = useState<ModelSettings>(DEFAULT_SETTINGS);
const [chatTemplateDraft, setChatTemplateDraft] = useState('');
const [builtinTemplateDraft, setBuiltinTemplateDraft] = useState('chatml');
const [builtinTemplateOptions, setBuiltinTemplateOptions] = useState<string[]>(['chatml']);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const res = await getModelSettings({ path: { model_id: modelId } });
if (res.data) setSettings(res.data);
const [settingsResult, builtinsResult] = await Promise.allSettled([
getModelSettings({ path: { model_id: modelId } }),
listBuiltinChatTemplates(),
]);
if (builtinsResult.status === 'fulfilled' && builtinsResult.value.data?.length) {
setBuiltinTemplateOptions(builtinsResult.value.data);
}
if (settingsResult.status === 'fulfilled' && settingsResult.value.data) {
setSettings({
...settingsResult.value.data,
tool_calling: settingsResult.value.data.tool_calling ?? 'auto',
chat_template: settingsResult.value.data.chat_template ?? { type: 'embedded' },
});
}
} catch {
// use defaults
} finally {
@@ -323,6 +414,18 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
load();
}, [load]);
useEffect(() => {
const chatTemplate = settings.chat_template;
if (chatTemplate?.type === 'custom_inline') {
setChatTemplateDraft(chatTemplate.template ?? '');
} else {
setChatTemplateDraft('');
}
if (chatTemplate?.type === 'builtin') {
setBuiltinTemplateDraft(chatTemplate.name ?? 'chatml');
}
}, [settings.chat_template]);
const save = async (updated: ModelSettings) => {
setSettings(updated);
setSaving(true);
@@ -342,6 +445,38 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
};
const samplingType: SamplingType = settings.sampling?.type ?? 'Temperature';
const chatTemplate = settings.chat_template ?? { type: 'embedded' };
const chatTemplateMode: ChatTemplateMode =
chatTemplate.type === 'custom_inline'
? 'custom_inline'
: chatTemplate.type === 'builtin'
? 'builtin'
: 'embedded';
const setChatTemplateMode = (mode: ChatTemplateMode) => {
let next: ChatTemplate;
if (mode === 'custom_inline') {
next = { type: 'custom_inline', template: chatTemplateDraft };
} else if (mode === 'builtin') {
next = { type: 'builtin', name: builtinTemplateDraft.trim() || builtinTemplateOptions[0] || 'chatml' };
} else {
next = { type: 'embedded' };
}
updateField('chat_template', next);
};
const setBuiltinTemplateName = (name: string) => {
setBuiltinTemplateDraft(name);
if (chatTemplateMode === 'builtin') {
updateField('chat_template', { type: 'builtin', name });
}
};
const saveChatTemplateDraft = () => {
if (chatTemplateMode === 'custom_inline') {
updateField('chat_template', { type: 'custom_inline', template: chatTemplateDraft });
}
};
const setSamplingType = (type: SamplingType) => {
let sampling: SamplingConfig;
@@ -366,6 +501,10 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
save({ ...settings, sampling: { ...settings.sampling!, ...partial } as SamplingConfig });
};
const visibleBuiltinTemplateOptions = builtinTemplateOptions.includes(builtinTemplateDraft)
? builtinTemplateOptions
: [builtinTemplateDraft, ...builtinTemplateOptions].filter(Boolean);
if (loading) {
return <div className="py-2 text-xs text-text-muted">{intl.formatMessage(i18n.loadingSettings)}</div>;
}
@@ -585,16 +724,52 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
]}
onChange={(v) => updateField('flash_attention', v === 'auto' ? null : v === 'on')}
/>
</div>
{/* Tool Calling */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-text-default">{intl.formatMessage(i18n.toolCalling)}</h5>
<ToggleField
label={intl.formatMessage(i18n.nativeToolCalling)}
description={intl.formatMessage(i18n.nativeToolCallingDescription)}
value={settings.native_tool_calling ?? false}
onChange={(v) => updateField('native_tool_calling', v)}
<SelectField<ToolCallingMode>
label={intl.formatMessage(i18n.toolCalling)}
description={intl.formatMessage(i18n.toolCallingDescription)}
value={settings.tool_calling ?? 'auto'}
options={[
{ value: 'auto', label: intl.formatMessage(i18n.toolCallingAuto) },
{ value: 'force_native', label: intl.formatMessage(i18n.toolCallingForceNative) },
{ value: 'force_emulated', label: intl.formatMessage(i18n.toolCallingForceEmulated) },
]}
onChange={(v) => updateField('tool_calling', v)}
/>
<SelectField<ChatTemplateMode>
label={intl.formatMessage(i18n.chatTemplate)}
description={intl.formatMessage(i18n.chatTemplateDescription)}
value={chatTemplateMode}
options={[
{ value: 'embedded', label: intl.formatMessage(i18n.chatTemplateEmbedded) },
{ value: 'builtin', label: intl.formatMessage(i18n.chatTemplateBuiltin) },
{
value: 'custom_inline',
label: intl.formatMessage(i18n.chatTemplateCustomInline),
},
]}
onChange={setChatTemplateMode}
/>
{chatTemplateMode === 'builtin' && (
<SelectField<string>
label={intl.formatMessage(i18n.builtinChatTemplate)}
description={intl.formatMessage(i18n.builtinChatTemplateDescription)}
value={builtinTemplateDraft}
options={visibleBuiltinTemplateOptions.map((template) => ({
value: template,
label: template,
}))}
onChange={setBuiltinTemplateName}
/>
)}
{chatTemplateMode === 'custom_inline' && (
<TextAreaField
label={intl.formatMessage(i18n.customChatTemplate)}
description={intl.formatMessage(i18n.customChatTemplateDescription)}
value={chatTemplateDraft}
onChange={setChatTemplateDraft}
onBlur={saveChatTemplateDraft}
/>
)}
</div>
</div>
);
+40 -7
View File
@@ -2183,6 +2183,27 @@
"modelSettingsPanel.batchSizeDescription": {
"defaultMessage": "Prompt processing batch"
},
"modelSettingsPanel.builtinChatTemplate": {
"defaultMessage": "Built-in template"
},
"modelSettingsPanel.builtinChatTemplateDescription": {
"defaultMessage": "Select a llama.cpp built-in template name"
},
"modelSettingsPanel.chatTemplate": {
"defaultMessage": "Chat template"
},
"modelSettingsPanel.chatTemplateBuiltin": {
"defaultMessage": "Built-in"
},
"modelSettingsPanel.chatTemplateCustomInline": {
"defaultMessage": "Custom inline"
},
"modelSettingsPanel.chatTemplateDescription": {
"defaultMessage": "Use embedded GGUF metadata, a llama.cpp built-in template, or inline Jinja"
},
"modelSettingsPanel.chatTemplateEmbedded": {
"defaultMessage": "Embedded"
},
"modelSettingsPanel.contextAndGeneration": {
"defaultMessage": "Context & Generation"
},
@@ -2192,6 +2213,12 @@
"modelSettingsPanel.contextSizeDescription": {
"defaultMessage": "Max context window (0 = model default)"
},
"modelSettingsPanel.customChatTemplate": {
"defaultMessage": "Custom chat template"
},
"modelSettingsPanel.customChatTemplateDescription": {
"defaultMessage": "Paste the full Jinja chat template source"
},
"modelSettingsPanel.etaLearningRate": {
"defaultMessage": "Eta (learning rate)"
},
@@ -2231,12 +2258,6 @@
"modelSettingsPanel.minP": {
"defaultMessage": "Min P"
},
"modelSettingsPanel.nativeToolCalling": {
"defaultMessage": "Native tool calling"
},
"modelSettingsPanel.nativeToolCallingDescription": {
"defaultMessage": "Use the model's built-in tool-call format instead of the shell-command emulator. Enable for large models that reliably support tool calling."
},
"modelSettingsPanel.performance": {
"defaultMessage": "Performance"
},
@@ -2289,7 +2310,19 @@
"defaultMessage": "CPU threads for generation"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "Tool Calling"
"defaultMessage": "Tool calling"
},
"modelSettingsPanel.toolCallingAuto": {
"defaultMessage": "Auto"
},
"modelSettingsPanel.toolCallingDescription": {
"defaultMessage": "Choose how local models select native or emulated tool calling"
},
"modelSettingsPanel.toolCallingForceEmulated": {
"defaultMessage": "Force emulated"
},
"modelSettingsPanel.toolCallingForceNative": {
"defaultMessage": "Force native"
},
"modelSettingsPanel.topK": {
"defaultMessage": "Top K"
+42 -9
View File
@@ -2201,6 +2201,48 @@
"modelSettingsPanel.flashAttentionDescription": {
"defaultMessage": "Включить оптимизацию flash attention"
},
"modelSettingsPanel.builtinChatTemplate": {
"defaultMessage": "Встроенный шаблон"
},
"modelSettingsPanel.builtinChatTemplateDescription": {
"defaultMessage": "Выберите имя встроенного шаблона llama.cpp"
},
"modelSettingsPanel.chatTemplate": {
"defaultMessage": "Шаблон чата"
},
"modelSettingsPanel.chatTemplateBuiltin": {
"defaultMessage": "Встроенный"
},
"modelSettingsPanel.chatTemplateCustomInline": {
"defaultMessage": "Пользовательский inline"
},
"modelSettingsPanel.chatTemplateDescription": {
"defaultMessage": "Использовать встроенные метаданные GGUF, встроенный шаблон llama.cpp или inline-шаблон Jinja"
},
"modelSettingsPanel.chatTemplateEmbedded": {
"defaultMessage": "Встроенный"
},
"modelSettingsPanel.customChatTemplate": {
"defaultMessage": "Пользовательский шаблон чата"
},
"modelSettingsPanel.customChatTemplateDescription": {
"defaultMessage": "Вставьте полный исходный код шаблона Jinja"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "Вызов инструментов"
},
"modelSettingsPanel.toolCallingAuto": {
"defaultMessage": "Авто"
},
"modelSettingsPanel.toolCallingDescription": {
"defaultMessage": "Выберите, как локальные модели используют нативный или эмулируемый вызов инструментов"
},
"modelSettingsPanel.toolCallingForceEmulated": {
"defaultMessage": "Принудительно эмулируемый"
},
"modelSettingsPanel.toolCallingForceNative": {
"defaultMessage": "Принудительно нативный"
},
"modelSettingsPanel.frequencyPenalty": {
"defaultMessage": "Штраф частоты"
},
@@ -2231,12 +2273,6 @@
"modelSettingsPanel.minP": {
"defaultMessage": "Min P"
},
"modelSettingsPanel.nativeToolCalling": {
"defaultMessage": "Нативный вызов инструментов"
},
"modelSettingsPanel.nativeToolCallingDescription": {
"defaultMessage": "Использовать встроенный формат вызова инструментов модели вместо эмулятора shell-команд. Включайте для крупных моделей, надежно поддерживающих вызов tool calling."
},
"modelSettingsPanel.performance": {
"defaultMessage": "Производительность"
},
@@ -2288,9 +2324,6 @@
"modelSettingsPanel.threadsDescription": {
"defaultMessage": "Потоки CPU для генерации"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "Вызов инструментов"
},
"modelSettingsPanel.topK": {
"defaultMessage": "Top K"
},
+42 -9
View File
@@ -2279,6 +2279,48 @@
"modelSettingsPanel.flashAttentionDescription": {
"defaultMessage": "Flaş dikkati optimizasyonunu etkinleştir"
},
"modelSettingsPanel.builtinChatTemplate": {
"defaultMessage": "Yerleşik şablon"
},
"modelSettingsPanel.builtinChatTemplateDescription": {
"defaultMessage": "Bir llama.cpp yerleşik şablon adı seçin"
},
"modelSettingsPanel.chatTemplate": {
"defaultMessage": "Sohbet şablonu"
},
"modelSettingsPanel.chatTemplateBuiltin": {
"defaultMessage": "Yerleşik"
},
"modelSettingsPanel.chatTemplateCustomInline": {
"defaultMessage": "Özel satır içi"
},
"modelSettingsPanel.chatTemplateDescription": {
"defaultMessage": "Gömülü GGUF meta verilerini, llama.cpp yerleşik şablonunu veya satır içi Jinja şablonunu kullan"
},
"modelSettingsPanel.chatTemplateEmbedded": {
"defaultMessage": "Gömülü"
},
"modelSettingsPanel.customChatTemplate": {
"defaultMessage": "Özel sohbet şablonu"
},
"modelSettingsPanel.customChatTemplateDescription": {
"defaultMessage": "Tam Jinja sohbet şablonu kaynağını yapıştır"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "Araç çağırma"
},
"modelSettingsPanel.toolCallingAuto": {
"defaultMessage": "Otomatik"
},
"modelSettingsPanel.toolCallingDescription": {
"defaultMessage": "Yerel modellerin yerel veya emüle araç çağırmayı nasıl seçeceğini belirleyin"
},
"modelSettingsPanel.toolCallingForceEmulated": {
"defaultMessage": "Emüle etmeye zorla"
},
"modelSettingsPanel.toolCallingForceNative": {
"defaultMessage": "Yereli zorla"
},
"modelSettingsPanel.frequencyPenalty": {
"defaultMessage": "Frekans cezası"
},
@@ -2309,12 +2351,6 @@
"modelSettingsPanel.minP": {
"defaultMessage": "Min P"
},
"modelSettingsPanel.nativeToolCalling": {
"defaultMessage": "Yerel araç çağrısı"
},
"modelSettingsPanel.nativeToolCallingDescription": {
"defaultMessage": "Kabuk komutu emülatörü yerine modelin yerleşik araç çağrısı formatını kullanın. Araç çağırmayı güvenilir şekilde destekleyen büyük modeller için etkinleştirin."
},
"modelSettingsPanel.performance": {
"defaultMessage": "Performans"
},
@@ -2366,9 +2402,6 @@
"modelSettingsPanel.threadsDescription": {
"defaultMessage": "Nesil için CPU iş parçacıkları"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "Takım Çağırma"
},
"modelSettingsPanel.topK": {
"defaultMessage": "En İyi K"
},
+42 -9
View File
@@ -2150,6 +2150,48 @@
"modelSettingsPanel.flashAttentionDescription": {
"defaultMessage": "启用 Flash Attention 优化"
},
"modelSettingsPanel.builtinChatTemplate": {
"defaultMessage": "内置模板"
},
"modelSettingsPanel.builtinChatTemplateDescription": {
"defaultMessage": "选择 llama.cpp 内置模板名称"
},
"modelSettingsPanel.chatTemplate": {
"defaultMessage": "聊天模板"
},
"modelSettingsPanel.chatTemplateBuiltin": {
"defaultMessage": "内置"
},
"modelSettingsPanel.chatTemplateCustomInline": {
"defaultMessage": "自定义内联"
},
"modelSettingsPanel.chatTemplateDescription": {
"defaultMessage": "使用嵌入式 GGUF 元数据、llama.cpp 内置模板或内联 Jinja 模板"
},
"modelSettingsPanel.chatTemplateEmbedded": {
"defaultMessage": "嵌入式"
},
"modelSettingsPanel.customChatTemplate": {
"defaultMessage": "自定义聊天模板"
},
"modelSettingsPanel.customChatTemplateDescription": {
"defaultMessage": "粘贴完整的 Jinja 聊天模板源码"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "工具调用"
},
"modelSettingsPanel.toolCallingAuto": {
"defaultMessage": "自动"
},
"modelSettingsPanel.toolCallingDescription": {
"defaultMessage": "选择本地模型如何使用原生或模拟工具调用"
},
"modelSettingsPanel.toolCallingForceEmulated": {
"defaultMessage": "强制模拟"
},
"modelSettingsPanel.toolCallingForceNative": {
"defaultMessage": "强制原生"
},
"modelSettingsPanel.frequencyPenalty": {
"defaultMessage": "频率惩罚"
},
@@ -2180,12 +2222,6 @@
"modelSettingsPanel.minP": {
"defaultMessage": "Min P"
},
"modelSettingsPanel.nativeToolCalling": {
"defaultMessage": "原生工具调用"
},
"modelSettingsPanel.nativeToolCallingDescription": {
"defaultMessage": "使用模型内置的工具调用格式,而不是 shell 命令模拟器。对可靠支持工具调用的大模型建议启用。"
},
"modelSettingsPanel.performance": {
"defaultMessage": "性能"
},
@@ -2237,9 +2273,6 @@
"modelSettingsPanel.threadsDescription": {
"defaultMessage": "用于生成的 CPU 线程数"
},
"modelSettingsPanel.toolCalling": {
"defaultMessage": "工具调用"
},
"modelSettingsPanel.topK": {
"defaultMessage": "Top K"
},
-15
View File
@@ -97,7 +97,6 @@ export function getTextAndImageContent(message: Message): {
// Strip assistant-only markup that shouldn't appear in rendered text
if (message.role === 'assistant') {
textContent = stripToolCallMarkers(textContent);
textContent = textContent.replace(/<think>[\s\S]*?<\/think>/gi, '');
}
return { textContent, imagePaths };
@@ -122,20 +121,6 @@ export function getThinkingContent(message: Message): string | null {
}
}
// Inline <think> tags in assistant text content
if (message.role === 'assistant') {
for (const content of message.content) {
if (content.type === 'text') {
const regex = /<think>([\s\S]*?)<\/think>/gi;
let match;
while ((match = regex.exec(content.text)) !== null) {
const text = match[1].trim();
if (text) parts.push(text);
}
}
}
}
return parts.length > 0 ? parts.join('') : null;
}