feat: vllm support

This commit is contained in:
jialin
2024-09-25 16:17:25 +08:00
parent 0652f850d6
commit 3cd96ba2e8
47 changed files with 1610 additions and 252 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ export async function queryModelScopeModels(
},
body: JSON.stringify({
...params,
Name: `${params.Name} gguf`,
Name: `${params.Name}`,
PageSize: 100,
PageNumber: 1
})
@@ -1,5 +1,8 @@
import LabelSelector from '@/components/label-selector';
import ListInput from '@/components/list-input';
import SealSelect from '@/components/seal-form/seal-select';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { InfoCircleOutlined, RightOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import {
@@ -13,22 +16,28 @@ import {
} from 'antd';
import _ from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { placementStrategyOptions } from '../config';
import { backendOptionsMap, placementStrategyOptions } from '../config';
import llamaConfig from '../config/llama-config';
import { FormData } from '../config/types';
import vllmConfig from '../config/vllm-config';
import dataformStyles from '../style/data-form.less';
import GPUCard from './gpu-card';
interface AdvanceConfigProps {
isGGUF: boolean;
form: FormInstance;
gpuOptions: Array<any>;
action: PageActionType;
}
const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
const { form, gpuOptions } = props;
const { form, gpuOptions, isGGUF, action } = props;
const intl = useIntl();
const wokerSelector = Form.useWatch('worker_selector', form);
const scheduleType = Form.useWatch('scheduleType', form);
const backend = Form.useWatch('backend', form);
const [params, setParams] = React.useState<string[]>([]);
const placementStrategyTips = [
{
@@ -64,6 +73,10 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
}
];
const paramsConfig = useMemo(() => {
return backend === backendOptionsMap.llamaBox ? llamaConfig : vllmConfig;
}, [backend]);
const renderSelectTips = (list: Array<{ title: string; tips: string }>) => {
return (
<div>
@@ -96,6 +109,10 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
[]
);
const handleBackendParametersChange = useCallback((list: string[]) => {
form.setFieldValue('backend_parameters', list);
}, []);
const collapseItems = useMemo(() => {
const children = (
<>
@@ -176,6 +193,33 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
</Form.Item>
</>
)}
<Form.Item name="backend">
<SealSelect
label={intl.formatMessage({ id: 'models.form.backend' })}
options={[
{
label: `llama-box(llama.cpp)`,
value: backendOptionsMap.llamaBox,
disabled: !isGGUF
},
{
label: 'vLLM',
value: backendOptionsMap.vllm,
disabled: isGGUF
}
]}
disabled={action === PageAction.EDIT}
></SealSelect>
</Form.Item>
<Form.Item<FormData> name="backend_parameters">
<ListInput
btnText="common.button.addParams"
label={intl.formatMessage({ id: 'models.form.backend_parameters' })}
dataList={form.getFieldValue('backend_parameters') || []}
onChange={handleBackendParametersChange}
options={paramsConfig}
></ListInput>
</Form.Item>
{scheduleType === 'manual' && (
<Form.Item<FormData>
name="gpu_selector"
@@ -202,34 +246,36 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
</SealSelect>
</Form.Item>
)}
<div style={{ paddingBottom: 22, paddingLeft: 10 }}>
<Form.Item<FormData>
name="cpu_offloading"
valuePropName="checked"
style={{ padding: '0 10px', marginBottom: 0 }}
noStyle
>
<Checkbox className="p-l-6">
<Tooltip
trigger={['click']}
title={intl.formatMessage({
id: 'models.form.partialoffload.tips'
})}
>
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{intl.formatMessage({
id: 'resources.form.enablePartialOffload'
{isGGUF && (
<div style={{ paddingBottom: 22, paddingLeft: 10 }}>
<Form.Item<FormData>
name="cpu_offloading"
valuePropName="checked"
style={{ padding: '0 10px', marginBottom: 0 }}
noStyle
>
<Checkbox className="p-l-6">
<Tooltip
trigger={['click']}
title={intl.formatMessage({
id: 'models.form.partialoffload.tips'
})}
</span>
<InfoCircleOutlined
className="m-l-4"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</Tooltip>
</Checkbox>
</Form.Item>
</div>
{scheduleType === 'auto' && (
>
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{intl.formatMessage({
id: 'resources.form.enablePartialOffload'
})}
</span>
<InfoCircleOutlined
className="m-l-4"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</Tooltip>
</Checkbox>
</Form.Item>
</div>
)}
{scheduleType === 'auto' && isGGUF && (
<div style={{ paddingBottom: 22, paddingLeft: 10 }}>
<Form.Item<FormData>
name="distributed_inference_across_workers"
@@ -270,7 +316,15 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
children
}
];
}, [form, intl, gpuOptions, scheduleType, wokerSelector]);
}, [
form,
intl,
gpuOptions,
paramsConfig,
scheduleType,
wokerSelector,
isGGUF
]);
return (
<Collapse
@@ -289,4 +343,4 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
);
};
export default AdvanceConfig;
export default React.memo(AdvanceConfig);
@@ -10,7 +10,7 @@ const ColumnWrapper: React.FC<any> = ({ children, footer, height }) => {
<div className="column-wrapper">
<SimpleBar
style={{
height: height || 'calc(100vh - 89px)',
maxHeight: height || 'calc(100vh - 89px)',
paddingBottom: '50px'
}}
>
@@ -23,7 +23,7 @@ const ColumnWrapper: React.FC<any> = ({ children, footer, height }) => {
}
return (
<div className="column-wrapper">
<SimpleBar style={{ height: height || 'calc(100vh - 89px)' }}>
<SimpleBar style={{ maxHeight: height || 'calc(100vh - 89px)' }}>
{children}
</SimpleBar>
</div>
+45 -25
View File
@@ -14,7 +14,11 @@ import React, {
useState
} from 'react';
import { queryGPUList } from '../apis';
import { modelSourceMap, ollamaModelOptions } from '../config';
import {
backendOptionsMap,
modelSourceMap,
ollamaModelOptions
} from '../config';
import { FormData, GPUListItem } from '../config/types';
import AdvanceConfig from './advance-config';
@@ -23,6 +27,7 @@ interface DataFormProps {
source: string;
action: PageActionType;
selectedModel: any;
isGGUF: boolean;
onOk: (values: FormData) => void;
}
@@ -49,7 +54,7 @@ const SEARCH_SOURCE = [
];
const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const { action, onOk } = props;
const { action, isGGUF, onOk } = props;
const [form] = Form.useForm();
const intl = useIntl();
const [gpuOptions, setGpuOptions] = useState<
@@ -140,27 +145,29 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
disabled={true}
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="file_name"
key="file_name"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.input'
},
{ name: intl.formatMessage({ id: 'models.form.filename' }) }
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({ id: 'models.form.filename' })}
required
disabled={true}
></SealInput.Input>
</Form.Item>
{isGGUF && (
<Form.Item<FormData>
name="file_name"
key="file_name"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.input'
},
{ name: intl.formatMessage({ id: 'models.form.filename' }) }
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({ id: 'models.form.filename' })}
required
disabled={true}
></SealInput.Input>
</Form.Item>
)}
</>
);
};
@@ -238,7 +245,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}
return null;
}, [props.source]);
}, [props.source, isGGUF]);
const handleOk = (formdata: FormData) => {
const gpu = _.find(gpuOptions, (item: any) => {
@@ -260,6 +267,14 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}
};
useEffect(() => {
if (action === PageAction.CREATE) {
form.setFieldValue(
'backend',
isGGUF ? backendOptionsMap.llamaBox : backendOptionsMap.vllm
);
}
}, [isGGUF]);
useEffect(() => {
handleOnSelectModel();
}, [props.selectedModel.name]);
@@ -360,7 +375,12 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
})}
></SealInput.TextArea>
</Form.Item>
<AdvanceConfig form={form} gpuOptions={gpuOptions}></AdvanceConfig>
<AdvanceConfig
form={form}
gpuOptions={gpuOptions}
isGGUF={isGGUF}
action={action}
></AdvanceConfig>
</Form>
);
});
+22 -6
View File
@@ -45,6 +45,8 @@ const AddModal: React.FC<AddModalProps> = (props) => {
const [selectedModel, setSelectedModel] = useState<any>({});
const [collapsed, setCollapsed] = useState<boolean>(false);
const [loadingModel, setLoadingModel] = useState<boolean>(false);
const [isGGUF, setIsGGUF] = useState<boolean>(false);
const modelFileRef = useRef<any>(null);
const handleSelectModelFile = useCallback((item: any) => {
form.current?.setFieldValue?.('file_name', item.fakeName);
@@ -58,6 +60,15 @@ const AddModal: React.FC<AddModalProps> = (props) => {
form.current?.submit?.();
};
const handleSetIsGGUF = (flag: boolean) => {
setIsGGUF(flag);
if (flag) {
setTimeout(() => {
modelFileRef.current?.fetchModelFiles?.();
}, 50);
}
};
useEffect(() => {
return () => {
setSelectedModel({});
@@ -121,13 +132,17 @@ const AddModal: React.FC<AddModalProps> = (props) => {
onCollapse={setCollapsed}
collapsed={collapsed}
modelSource={props.source}
setIsGGUF={handleSetIsGGUF}
></ModelCard>
<HFModelFile
selectedModel={selectedModel}
modelSource={props.source}
onSelectFile={handleSelectModelFile}
collapsed={collapsed}
></HFModelFile>
{isGGUF && (
<HFModelFile
ref={modelFileRef}
selectedModel={selectedModel}
modelSource={props.source}
onSelectFile={handleSelectModelFile}
collapsed={collapsed}
></HFModelFile>
)}
</ColumnWrapper>
<Separator></Separator>
</div>
@@ -159,6 +174,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
selectedModel={selectedModel}
onOk={onOk}
ref={form}
isGGUF={isGGUF}
></DataForm>
</>
</ColumnWrapper>
+28 -11
View File
@@ -4,7 +4,15 @@ import { useIntl } from '@umijs/max';
import { Col, Empty, Row, Select, Spin, Tag, Tooltip } from 'antd';
import classNames from 'classnames';
import _ from 'lodash';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
import { queryHuggingfaceModelFiles, queryModelScopeModelFiles } from '../apis';
@@ -19,12 +27,16 @@ interface HFModelFileProps {
collapsed?: boolean;
loadingModel?: boolean;
modelSource: string;
ref: any;
onSelectFile?: (file: any) => void;
}
const pattern = /^(.*)-(\d+)-of-(\d+)\.gguf$/;
const pattern = /^(.*)-(\d+)-of-(\d+)\.(.*)$/;
const HFModelFile: React.FC<HFModelFileProps> = (props) => {
const filterReg = /\.(safetensors|gguf)$/i;
const includeReg = /\.(safetensors|gguf)$/i;
const HFModelFile: React.FC<HFModelFileProps> = forwardRef((props, ref) => {
const { collapsed, modelSource } = props;
const intl = useIntl();
const [dataSource, setDataSource] = useState<any>({
@@ -58,7 +70,8 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
return {
filename: match[1],
part: parseInt(match[2], 10),
total: parseInt(match[3], 10)
total: parseInt(match[3], 10),
extension: match[4]
};
} else {
return null;
@@ -101,7 +114,7 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
(value: any[], filename: string) => {
return {
path: filename,
fakeName: `${filename}*.gguf`,
fakeName: `${filename}*.${_.get(value, '[0].extension')}`,
size: _.sumBy(value, 'size'),
parts: value
};
@@ -125,8 +138,9 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
});
const list = _.filter(fileList, (file: any) => {
return _.endsWith(file.path, '.gguf') || _.includes(file.path, '.gguf');
return filterReg.test(file.path) || _.includes(includeReg, file.path);
});
return list;
} catch (error) {
return [];
@@ -145,7 +159,7 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
}
);
const fileList = _.filter(_.get(data, ['Data', 'Files']), (file: any) => {
return _.endsWith(file.Path, '.gguf') || _.includes(file.Path, '.gguf');
return filterReg.test(file.path) || _.includes(includeReg, file.path);
});
const list = _.map(fileList, (item: any) => {
return {
@@ -226,10 +240,13 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
handleSelectModelFile(item);
}
};
useImperativeHandle(ref, () => ({
fetchModelFiles: handleFetchModelFiles
}));
useEffect(() => {
handleFetchModelFiles();
}, [props.selectedModel.name]);
// useEffect(() => {
// handleFetchModelFiles();
// }, [props.selectedModel.name]);
useEffect(() => {
return () => {
@@ -340,6 +357,6 @@ const HFModelFile: React.FC<HFModelFileProps> = (props) => {
</SimpleBar>
</div>
);
};
});
export default memo(HFModelFile);
@@ -23,7 +23,7 @@ interface HFModelItemProps {
source?: string;
tags?: string[];
}
const warningTask = ['image', 'audio', 'video'];
const warningTask = ['audio', 'video'];
const SUPPORTEDSOURCE = [
modelSourceMap.huggingface_value,
+38 -13
View File
@@ -1,5 +1,5 @@
import HighlightCode from '@/components/highlight-code';
import IconFont from '@/components/icon-font';
import MarkdownViewer from '@/components/markdown-viewer';
import useRequestToken from '@/hooks/use-request-token';
import {
DownOutlined,
@@ -7,7 +7,7 @@ import {
RightOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Empty, Tag, Tooltip } from 'antd';
import { Button, Empty, Spin, Tag, Tooltip } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
@@ -21,19 +21,22 @@ import '../style/model-card.less';
import TitleWrapper from './title-wrapper';
const ModelCard: React.FC<{
selectedModel: any;
onCollapse: (flag: boolean) => void;
setIsGGUF: (flag: boolean) => void;
selectedModel: any;
collapsed: boolean;
loadingModel?: boolean;
modelSource: string;
}> = (props) => {
const { onCollapse, collapsed, modelSource } = props;
const { onCollapse, setIsGGUF, collapsed, modelSource } = props;
const intl = useIntl();
const requestSource = useRequestToken();
const [modelData, setModelData] = useState<any>({});
const [readmeText, setReadmeText] = useState<string | null>(null);
const requestToken = useRef<any>(null);
const axiosTokenRef = useRef<any>(null);
const [isGGUFModel, setIsGGUFModel] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const loadFile = async (repo: string, sha: string) => {
try {
@@ -70,9 +73,13 @@ const ModelCard: React.FC<{
setModelData(modelcard);
setReadmeText(readme);
setIsGGUF(modelcard.tags?.includes('gguf'));
setIsGGUFModel(modelcard.tags?.includes('gguf'));
} catch (error) {
setModelData({});
setReadmeText(null);
setIsGGUF(false);
setIsGGUFModel(false);
}
};
@@ -86,15 +93,18 @@ const ModelCard: React.FC<{
token: requestToken.current.token
}
);
console.log('detaildata==========', data);
setModelData({
...data?.Data,
name: `${data.Data?.Path}/${data.Data?.Name}`
});
setReadmeText(data?.Data?.ReadMeContent);
setIsGGUF(data.Data?.Tags?.includes('gguf'));
setIsGGUFModel(data.Data?.Tags?.includes('gguf'));
} catch (error) {
setModelData({});
setReadmeText(null);
setIsGGUF(false);
setIsGGUFModel(false);
}
};
@@ -105,11 +115,13 @@ const ModelCard: React.FC<{
}
requestToken.current?.cancel?.();
requestToken.current = requestSource();
setLoading(true);
if (modelSource === modelSourceMap.huggingface_value) {
getHuggingfaceModelDetail();
await getHuggingfaceModelDetail();
} else if (modelSource === modelSourceMap.modelscope_value) {
getModelScopeModelDetail();
await getModelScopeModelDetail();
}
setLoading(false);
};
const handleCollapse = useCallback(() => {
@@ -191,7 +203,7 @@ const ModelCard: React.FC<{
</Tag>
)}
</div>
{readmeText && (
{readmeText && isGGUFModel && (
<div
style={{
borderRadius: 4,
@@ -213,12 +225,10 @@ const ModelCard: React.FC<{
maxHeight: collapsed ? 300 : 0
}}
>
<HighlightCode
code={readmeText}
lang="markdown"
copyable={false}
<MarkdownViewer
content={readmeText}
theme="light"
></HighlightCode>
></MarkdownViewer>
</SimpleBar>
</div>
)}
@@ -230,6 +240,21 @@ const ModelCard: React.FC<{
></Empty>
)}
</div>
{!isGGUFModel && readmeText && (
<div>
<TitleWrapper>
<div className="title">README.md</div>
</TitleWrapper>
<div className="card-wrapper">
<Spin spinning={loading}>
<MarkdownViewer
content={readmeText}
theme="light"
></MarkdownViewer>
</Spin>
</div>
</div>
)}
</>
);
};
+36 -18
View File
@@ -1,6 +1,6 @@
import { BulbOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Select } from 'antd';
import { Checkbox, Select } from 'antd';
import _ from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { queryHuggingfaceModels, queryModelScopeModels } from '../apis';
@@ -44,6 +44,7 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
const cacheRepoOptions = useRef<any[]>([]);
const axiosTokenRef = useRef<any>(null);
const searchInputRef = useRef<any>('');
const filterGGUFRef = useRef<boolean>(true);
const modelFilesSortOptions = useRef<any[]>([
{
label: intl.formatMessage({ id: 'models.sort.trending' }),
@@ -77,7 +78,7 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
search: {
query: searchInputRef.current || '',
sort: sort,
tags: ['gguf'],
tags: filterGGUFRef.current ? ['gguf'] : [],
task
}
};
@@ -101,7 +102,9 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
const getModelsFromModelscope = useCallback(async (sort: string) => {
try {
const params = {
Name: searchInputRef.current || '',
Name: filterGGUFRef.current
? `${searchInputRef.current} gguf`
: searchInputRef.current || '',
SortBy: ModelScopeSortType[sort]
};
const data = await queryModelScopeModels(params, {
@@ -205,6 +208,12 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
handleOnSearchRepo(value || '');
};
const handleFilterGGUFChange = (e: any) => {
console.log('filterggufChange:', e.target.checked);
filterGGUFRef.current = e.target.checked;
handleOnSearchRepo();
};
const renderHFSearch = () => {
return (
<>
@@ -221,21 +230,30 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
)}
</span>
</span>
<Select
allowClear
value={dataSource.sortType}
onChange={handleSortChange}
labelRender={({ label }) => {
return (
<span>
{intl.formatMessage({ id: 'model.deploy.sort' })}: {label}
</span>
);
}}
options={modelFilesSortOptions.current}
size="middle"
style={{ width: '150px' }}
></Select>
<span>
<Checkbox
onChange={handleFilterGGUFChange}
className="m-r-5"
checked={filterGGUFRef.current}
>
GGUF
</Checkbox>
<Select
allowClear
value={dataSource.sortType}
onChange={handleSortChange}
labelRender={({ label }) => {
return (
<span>
{intl.formatMessage({ id: 'model.deploy.sort' })}: {label}
</span>
);
}}
options={modelFilesSortOptions.current}
size="middle"
style={{ width: '150px' }}
></Select>
</span>
</div>
</>
);
+20 -17
View File
@@ -1,10 +1,11 @@
import IconFont from '@/components/icon-font';
import { SearchOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Col, Empty, Row, Spin } from 'antd';
import { Button, Col, Empty, Row, Spin } from 'antd';
import React from 'react';
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
import { modelSourceMap } from '../config';
import '../style/search-result.less';
import HFModelItem from './hf-model-item';
@@ -49,24 +50,26 @@ const SearchResult: React.FC<SearchResultProps> = (props) => {
></IconFont>
}
description={
<div className="flex-column gap-5">
<span>
{intl.formatMessage({ id: 'models.search.networkerror' })}
</span>
{/* <span>
source === modelSourceMap.huggingface_value ? (
<div className="flex-column gap-5">
<span>
{intl.formatMessage({ id: 'models.search.hfvisit' })}
{intl.formatMessage({ id: 'models.search.networkerror' })}
</span>
<Button
type="link"
size="small"
href="https://huggingface.co/"
target="_blank"
>
Hugging Face
</Button>
</span> */}
</div>
<span>
<span>
{intl.formatMessage({ id: 'models.search.hfvisit' })}
</span>
<Button
type="link"
size="small"
href="https://huggingface.co/"
target="_blank"
>
Hugging Face
</Button>
</span>
</div>
) : null
}
/>
);
+2 -2
View File
@@ -418,10 +418,10 @@ const Models: React.FC<ModelsProps> = ({
const generateSource = useCallback((record: ListItem) => {
if (record.source === modelSourceMap.modelscope_value) {
return `${modelSourceMap.modelScope} / ${record.model_scope_file_path}`;
return `${modelSourceMap.modelScope} / ${record.model_scope_file_path || record.model_scope_model_id}`;
}
if (record.source === modelSourceMap.huggingface_value) {
return `${modelSourceMap.huggingface} / ${record.huggingface_filename}`;
return `${modelSourceMap.huggingface} / ${record.huggingface_filename || record.huggingface_repo_id}`;
}
return `${modelSourceMap.ollama_library} / ${record.ollama_library_model_name}`;
}, []);
+38 -25
View File
@@ -12,7 +12,11 @@ import { memo, useEffect, useMemo, useState } from 'react';
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
import { queryGPUList, queryHuggingfaceModelFiles } from '../apis';
import { modelSourceMap, setSourceRepoConfigValue } from '../config';
import {
backendOptionsMap,
modelSourceMap,
setSourceRepoConfigValue
} from '../config';
import { FormData, GPUListItem, ListItem } from '../config/types';
import AdvanceConfig from './advance-config';
@@ -160,29 +164,31 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
disabled={true}
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="file_name"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.input'
},
{ name: intl.formatMessage({ id: 'models.form.filename' }) }
)
}
]}
>
<SealAutoComplete
filterOption
label={intl.formatMessage({ id: 'models.form.filename' })}
required
options={fileOptions}
loading={loading}
disabled={action === PageAction.EDIT}
></SealAutoComplete>
</Form.Item>
{form.getFieldValue('file_name') && (
<Form.Item<FormData>
name="file_name"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.input'
},
{ name: intl.formatMessage({ id: 'models.form.filename' }) }
)
}
]}
>
<SealAutoComplete
filterOption
label={intl.formatMessage({ id: 'models.form.filename' })}
required
options={fileOptions}
loading={loading}
disabled={action === PageAction.EDIT}
></SealAutoComplete>
</Form.Item>
)}
</>
);
};
@@ -418,7 +424,14 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
></SealInput.TextArea>
</Form.Item>
<AdvanceConfig form={form} gpuOptions={gpuOptions}></AdvanceConfig>
<AdvanceConfig
form={form}
gpuOptions={gpuOptions}
action={PageAction.EDIT}
isGGUF={
form.getFieldValue('backend') === backendOptionsMap.llamaBox
}
></AdvanceConfig>
</Form>
</SimpleBar>
</Modal>
+24
View File
@@ -81,6 +81,11 @@ export const ollamaModelOptions = [
}
];
export const backendOptionsMap = {
llamaBox: 'llama-box',
vllm: 'vllm'
};
export const modelSourceMap: Record<string, string> = {
huggingface: 'Hugging Face',
ollama_library: 'Ollama Library',
@@ -232,3 +237,22 @@ export const setSourceRepoConfigValue = (
omits: omits
};
};
export const getbackendParameters = (data: any) => {
const backendParameters = data.backend_parameters || {};
const result: string[] = [];
Object.keys(backendParameters)?.forEach((key: string) => {
result.push(`${key}=${backendParameters[key]}`);
});
return result;
};
export const setbackendParameters = (data: any) => {
const result: Record<string, string> = {};
const backendParameters = data.backend_parameters || [];
backendParameters.forEach((item: string) => {
const [key, value] = item.split('=');
result[key] = value;
});
return result;
};
+26
View File
@@ -0,0 +1,26 @@
export default [
{
label: '--chat-template',
value: '--chat-template'
},
{
label: '--ctx-size',
value: '--ctx-size'
},
{
label: '--flash-attn',
value: '--flash-attn'
},
{
label: '--parallel',
value: '--parallel'
},
{
label: '--batch-size',
value: '--batch-size'
},
{
label: '--ubatch-size',
value: '--ubatch-size'
}
];
+2
View File
@@ -24,6 +24,8 @@ export interface ListItem {
}
export interface FormData {
backend?: string;
backend_parameters?: string[];
source: string;
repo_id: string;
file_name: string;
+593
View File
@@ -0,0 +1,593 @@
const options = [
{
label: '--uvicorn-log-level',
value: '--uvicorn-log-level',
options: ['debug', 'info', 'warning', 'error', 'critical', 'trace']
},
{
label: '--allow-credentials',
value: '--allow-credentials',
options: []
},
{
label: '--allowed-origins',
value: '--allowed-origins',
options: []
},
{
label: '--allowed-methods',
value: '--allowed-methods',
options: []
},
{
label: '--allowed-headers',
value: '--allowed-headers',
options: []
},
{
label: '--api-key',
value: '--api-key',
options: []
},
{
label: '--lora-modules',
value: '--lora-modules',
options: []
},
{
label: '--prompt-adapters',
value: '--prompt-adapters',
options: []
},
{
label: '--chat-template',
value: '--chat-template',
options: []
},
{
label: '--response-role',
value: '--response-role',
options: []
},
{
label: '--ssl-keyfile',
value: '--ssl-keyfile',
options: []
},
{
label: '--ssl-certfile',
value: '--ssl-certfile',
options: []
},
{
label: '--ssl-ca-certs',
value: '--ssl-ca-certs',
options: []
},
{
label: '--ssl-cert-reqs',
value: '--ssl-cert-reqs',
options: []
},
{
label: '--root-path',
value: '--root-path',
options: []
},
{
label: '--middleware',
value: '--middleware',
options: []
},
{
label: '--return-tokens-as-token-ids',
value: '--return-tokens-as-token-ids',
options: []
},
{
label: '--disable-frontend-multiprocessing',
value: '--disable-frontend-multiprocessing',
options: []
},
{
label: '--enable-auto-tool-choice',
value: '--enable-auto-tool-choice',
options: []
},
{
label: '--tool-call-parser',
value: '--tool-call-parser',
options: ['mistral', 'hermes']
},
{
label: '--model',
value: '--model',
options: []
},
{
label: '--tokenizer',
value: '--tokenizer',
options: []
},
{
label: '--skip-tokenizer-init',
value: '--skip-tokenizer-init',
options: []
},
{
label: '--revision',
value: '--revision',
options: []
},
{
label: '--code-revision',
value: '--code-revision',
options: []
},
{
label: '--tokenizer-revision',
value: '--tokenizer-revision',
options: []
},
{
label: '--tokenizer-mode',
value: '--tokenizer-mode',
options: ['auto', 'slow', 'mistral']
},
{
label: '--trust-remote-code',
value: '--trust-remote-code',
options: []
},
{
label: '--download-dir',
value: '--download-dir',
options: []
},
{
label: '--load-format',
value: '--load-format',
options: [
'auto',
'pt',
'safetensors',
'npcache',
'dummy',
'tensorizer',
'sharded_state',
'gguf',
'bitsandbytes',
'mistral'
]
},
{
label: '--config-format',
value: '--config-format',
options: ['auto', 'hf', 'mistral']
},
{
label: '--dtype',
value: '--dtype',
options: ['auto', 'half', 'float16', 'bfloat16', 'float', 'float32']
},
{
label: '--kv-cache-dtype',
value: '--kv-cache-dtype',
options: ['auto', 'fp8', 'fp8_e5m2', 'fp8_e4m3']
},
{
label: '--quantization-param-path',
value: '--quantization-param-path',
options: []
},
{
label: '--max-model-len',
value: '--max-model-len',
options: []
},
{
label: '--guided-decoding-backend',
value: '--guided-decoding-backend',
options: ['outlines', 'lm-format-enforcer']
},
{
label: '--distributed-executor-backend',
value: '--distributed-executor-backend',
options: ['ray', 'mp']
},
{
label: '--worker-use-ray',
value: '--worker-use-ray',
options: []
},
{
label: '--pipeline-parallel-size',
value: '--pipeline-parallel-size',
options: []
},
{
label: '--tensor-parallel-size',
value: '--tensor-parallel-size',
options: []
},
{
label: '--max-parallel-loading-workers',
value: '--max-parallel-loading-workers',
options: []
},
{
label: '--ray-workers-use-nsight',
value: '--ray-workers-use-nsight',
options: []
},
{
label: '--block-size',
value: '--block-size',
options: ['8', '16', '32']
},
{
label: '--enable-prefix-caching',
value: '--enable-prefix-caching',
options: []
},
{
label: '--disable-sliding-window',
value: '--disable-sliding-window',
options: []
},
{
label: '--use-v2-block-manager',
value: '--use-v2-block-manager',
options: []
},
{
label: '--num-lookahead-slots',
value: '--num-lookahead-slots',
options: []
},
{
label: '--seed',
value: '--seed',
options: []
},
{
label: '--swap-space',
value: '--swap-space',
options: []
},
{
label: '--cpu-offload-gb',
value: '--cpu-offload-gb',
options: []
},
{
label: '--gpu-memory-utilization',
value: '--gpu-memory-utilization',
options: []
},
{
label: '--num-gpu-blocks-override',
value: '--num-gpu-blocks-override',
options: []
},
{
label: '--max-num-batched-tokens',
value: '--max-num-batched-tokens',
options: []
},
{
label: '--max-num-seqs',
value: '--max-num-seqs',
options: []
},
{
label: '--max-logprobs',
value: '--max-logprobs',
options: []
},
{
label: '--disable-log-stats',
value: '--disable-log-stats',
options: []
},
{
label: '--quantization',
value: '--quantization',
options: [
'aqlm',
'awq',
'deepspeedfp',
'tpu_int8',
'fp8',
'fbgemm_fp8',
'modelopt',
'marlin',
'gguf',
'gptq_marlin_24',
'gptq_marlin',
'awq_marlin',
'gptq',
'compressed-tensors',
'bitsandbytes',
'qqq',
'experts_int8',
'neuron_quant',
'None'
]
},
{
label: '--rope-scaling',
value: '--rope-scaling',
options: []
},
{
label: '--rope-theta',
value: '--rope-theta',
options: []
},
{
label: '--enforce-eager',
value: '--enforce-eager',
options: []
},
{
label: '--max-context-len-to-capture',
value: '--max-context-len-to-capture',
options: []
},
{
label: '--max-seq-len-to-capture',
value: '--max-seq-len-to-capture',
options: []
},
{
label: '--disable-custom-all-reduce',
value: '--disable-custom-all-reduce',
options: []
},
{
label: '--tokenizer-pool-size',
value: '--tokenizer-pool-size',
options: []
},
{
label: '--tokenizer-pool-type',
value: '--tokenizer-pool-type',
options: []
},
{
label: '--tokenizer-pool-extra-config',
value: '--tokenizer-pool-extra-config',
options: []
},
{
label: '--limit-mm-per-prompt',
value: '--limit-mm-per-prompt',
options: []
},
{
label: '--enable-lora',
value: '--enable-lora',
options: []
},
{
label: '--max-loras',
value: '--max-loras',
options: []
},
{
label: '--max-lora-rank',
value: '--max-lora-rank',
options: []
},
{
label: '--lora-extra-vocab-size',
value: '--lora-extra-vocab-size',
options: []
},
{
label: '--lora-dtype',
value: '--lora-dtype',
options: ['auto', 'float16', 'bfloat16', 'float32']
},
{
label: '--long-lora-scaling-factors',
value: '--long-lora-scaling-factors',
options: []
},
{
label: '--max-cpu-loras',
value: '--max-cpu-loras',
options: []
},
{
label: '--fully-sharded-loras',
value: '--fully-sharded-loras',
options: []
},
{
label: '--enable-prompt-adapter',
value: '--enable-prompt-adapter',
options: []
},
{
label: '--max-prompt-adapters',
value: '--max-prompt-adapters',
options: []
},
{
label: '--max-prompt-adapter-token',
value: '--max-prompt-adapter-token',
options: []
},
{
label: '--device',
value: '--device',
options: ['auto', 'cuda', 'neuron', 'cpu', 'openvino', 'tpu', 'xpu']
},
{
label: '--num-scheduler-steps',
value: '--num-scheduler-steps',
options: []
},
{
label: '--scheduler-delay-factor',
value: '--scheduler-delay-factor',
options: []
},
{
label: '--enable-chunked-prefill',
value: '--enable-chunked-prefill',
options: []
},
{
label: '--speculative-model',
value: '--speculative-model',
options: []
},
{
label: '--speculative-model-quantization',
value: '--speculative-model-quantization',
options: [
'aqlm',
'awq',
'deepspeedfp',
'tpu_int8',
'fp8',
'fbgemm_fp8',
'modelopt',
'marlin',
'gguf',
'gptq_marlin_24',
'gptq_marlin',
'awq_marlin',
'gptq',
'compressed-tensors',
'bitsandbytes',
'qqq',
'experts_int8',
'neuron_quant',
'None'
]
},
{
label: '--num-speculative-tokens',
value: '--num-speculative-tokens',
options: []
},
{
label: '--speculative-draft-tensor-parallel-size',
value: '--speculative-draft-tensor-parallel-size',
options: []
},
{
label: '--speculative-max-model-len',
value: '--speculative-max-model-len',
options: []
},
{
label: '--speculative-disable-by-batch-size',
value: '--speculative-disable-by-batch-size',
options: []
},
{
label: '--ngram-prompt-lookup-max',
value: '--ngram-prompt-lookup-max',
options: []
},
{
label: '--ngram-prompt-lookup-min',
value: '--ngram-prompt-lookup-min',
options: []
},
{
label: '--spec-decoding-acceptance-method',
value: '--spec-decoding-acceptance-method',
options: ['rejection_sampler', 'typical_acceptance_sampler']
},
{
label: '--typical-acceptance-sampler-posterior-threshold',
value: '--typical-acceptance-sampler-posterior-threshold',
options: []
},
{
label: '--typical-acceptance-sampler-posterior-alpha',
value: '--typical-acceptance-sampler-posterior-alpha',
options: []
},
{
label: '--disable-logprobs-during-spec-decoding',
value: '--disable-logprobs-during-spec-decoding',
options: []
},
{
label: '--model-loader-extra-config',
value: '--model-loader-extra-config',
options: []
},
{
label: '--ignore-patterns',
value: '--ignore-patterns',
options: []
},
{
label: '--preemption-mode',
value: '--preemption-mode',
options: []
},
{
label: '--served-model-name',
value: '--served-model-name',
options: []
},
{
label: '--qlora-adapter-name-or-path',
value: '--qlora-adapter-name-or-path',
options: []
},
{
label: '--otlp-traces-endpoint',
value: '--otlp-traces-endpoint',
options: []
},
{
label: '--collect-detailed-traces',
value: '--collect-detailed-traces',
options: []
},
{
label: '--disable-async-output-proc',
value: '--disable-async-output-proc',
options: []
},
{
label: '--override-neuron-config',
value: '--override-neuron-config',
options: []
},
{
label: '--disable-log-requests',
value: '--disable-log-requests',
options: []
},
{
label: '--max-log-len',
value: '--max-log-len',
options: []
}
];
const resultList = options.map((option) => {
return {
label: option.label,
value: option.value,
opts: option.options.map((opt) => {
return {
label: opt,
value: opt
};
})
};
});
export default resultList;
@@ -37,8 +37,6 @@
font-size: 12px;
height: 22px;
opacity: 0.7;
// border: 1px solid var(--ant-color-border);
// color: var(--ant-color-text-secondary);
}
.btn {
@@ -11,5 +11,10 @@
padding: @padding;
padding-top: 10px;
margin-bottom: 0;
font-weight: var(--font-weight-bold);
background-color: var(--color-white-1);
.title {
font-weight: var(--font-weight-bold);
}
}
@@ -99,7 +99,7 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
if (!chunk) {
return;
}
if (_.get(chunk, 'choices.0.finish_reason')) {
if (!_.get(chunk, 'choices', []).length) {
setTokenResult({
...chunk?.usage
});
@@ -142,6 +142,9 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
: [];
contentRef.current = '';
setMessageList((pre) => {
return [...pre, ...currentMessageRef.current];
});
const formatMessages = _.map(
[...messageList, ...currentMessageRef.current],
(item: MessageItem) => {
@@ -217,9 +220,10 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
setTokenResult(null);
};
const handleSendMessage = (message: { role: string; content: string }) => {
const handleSendMessage = (message: Omit<MessageItem, 'uid'>) => {
console.log('message:', message);
const currentMessage = message.content ? message : undefined;
const currentMessage =
message.content || message.imgs?.length ? message : undefined;
submitMessage(currentMessage);
};
@@ -110,7 +110,9 @@ const MessageInput: React.FC<MessageInputProps> = ({
const inputRef = useRef<any>(null);
const isDisabled = useMemo(() => {
return disabled ? true : !message.content && isEmpty;
return disabled
? true
: !message.content && isEmpty && !message.imgs?.length;
}, [disabled, message.content, isEmpty]);
const resetMessage = () => {
@@ -214,9 +216,6 @@ const MessageInput: React.FC<MessageInputProps> = ({
dataUrl: img
};
});
// setImgList((pre) => {
// return [...pre, ...list];
// });
setMessage({
...message,
imgs: [...(message.imgs || []), ...list]
@@ -253,7 +252,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
if (text) {
setMessage?.({
...message,
content: text
content: message.content + text
});
} else {
getPasteContent(e);
@@ -446,7 +445,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
<div className="input-box">
<TextArea
ref={inputRef}
autoSize={{ minRows: 3, maxRows: 3 }}
autoSize={{ minRows: 3, maxRows: 8 }}
onChange={(e) => handleInputChange(e.target.value)}
value={message.content}
size="large"
@@ -83,7 +83,7 @@ const ModelItem: React.FC<ModelItemProps> = forwardRef(
if (!chunk) {
return;
}
if (_.get(chunk, 'choices.0.finish_reason')) {
if (!_.get(chunk, 'choices', [].length)) {
setTokenResult({
...chunk?.usage
});
@@ -120,6 +120,9 @@ const ModelItem: React.FC<ModelItemProps> = forwardRef(
}
]
: [];
setMessageList((preList) => {
return [...preList, ...currentMessageRef.current];
});
console.log('currentMessageRef.current 1:', currentMessageRef.current);
console.log('currentMessage==========4', messageList);
const messages = _.map(
@@ -208,12 +211,11 @@ const ModelItem: React.FC<ModelItemProps> = forwardRef(
}
}, []);
const handleSubmit = (currentMessage: {
role: string;
content: string;
}) => {
console.log('currentMessage==========2', currentMessage);
const currentMsg = currentMessage.content ? currentMessage : undefined;
const handleSubmit = (currentMessage: Omit<MessageItem, 'uid'>) => {
const currentMsg =
currentMessage.content || currentMessage.imgs?.length
? currentMessage
: undefined;
submitMessage(currentMsg);
};
@@ -20,6 +20,7 @@ const ReferenceParams = (props: ReferenceParamsProps) => {
if (!usage) {
return null;
}
console.log('ReferenceParams usage:', usage);
return (
<div className="reference-params">
<span className="usage">
@@ -48,14 +49,18 @@ const ReferenceParams = (props: ReferenceParamsProps) => {
<Tooltip
title={
<Space>
<span>TPOT: {_.round(usage.time_per_output_token_ms, 2)} ms</span>
<span>TTFT: {_.round(usage.time_to_first_token_ms, 2)} ms</span>
<span>
TPOT: {_.round(usage.time_per_output_token_ms, 2) || 0} ms
</span>
<span>
TTFT: {_.round(usage.time_to_first_token_ms, 2) || 0} ms
</span>
</Space>
}
>
<span>
{intl.formatMessage({ id: 'playground.tokenoutput' })}:{' '}
{_.round(usage.tokens_per_second, 2)} Tokens/s
{_.round(usage.tokens_per_second, 2) || 0} Tokens/s
</span>
</Tooltip>
</span>
@@ -1,6 +1,6 @@
import EditorWrap from '@/components/editor-wrap';
import HighlightCode from '@/components/highlight-code';
import { BulbOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { useIntl } from '@umijs/max';
import { Button, Modal } from 'antd';
import _ from 'lodash';
@@ -16,6 +16,18 @@ type ViewModalProps = {
onCancel: () => void;
};
const langMap = {
shell: 'bash',
python: 'python',
javascript: 'javascript'
};
const langOptions = [
{ label: 'Curl', value: langMap.shell },
{ label: 'Python', value: langMap.python },
{ label: 'Nodejs', value: langMap.javascript }
];
const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
const {
title,
@@ -31,7 +43,7 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
const editorRef = useRef(null);
const [loaded, setLoaded] = useState(false);
const [codeValue, setCodeValue] = useState('');
const [lang, setLang] = useState('shell');
const [lang, setLang] = useState(langMap.shell);
const BaseURL = `${window.location.origin}/v1-openai`;
const ClientType = apiType === 'chat' ? 'chat.completions' : 'embeddings';
@@ -39,24 +51,6 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
const logcommand =
apiType === 'chat' ? 'choices[0].message.content' : 'data[0].embedding';
const langOptions = [
{ label: 'Curl', value: 'shell' },
{ label: 'Python', value: 'python' },
{ label: 'Nodejs', value: 'javascript' }
];
const formatCode = () => {
if (editorRef.current) {
setTimeout(() => {
editorRef.current
?.getAction?.('editor.action.formatDocument')
?.run()
.then(() => {
console.log('format success');
});
}, 100);
}
};
const generateCode = () => {
const systemList = systemMessage
? [
@@ -91,7 +85,7 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
]
};
});
if (lang === 'shell') {
if (lang === langMap.shell) {
const messages = [...systemList, ...formatMessageList];
const code = `curl ${window.location.origin}/v1-openai/${api} \\\n-H "Content-Type: application/json" \\\n-H "Authorization: Bearer $\{YOUR_GPUSTACK_API_KEY}" \\\n-d '${JSON.stringify(
{
@@ -102,7 +96,7 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
2
)}'`;
setCodeValue(code);
} else if (lang === 'javascript') {
} else if (lang === langMap.javascript) {
const messages = [...systemList, ...formatMessageList];
const code = `const OpenAI = require("openai");\n\nconst openai = new OpenAI({\n "apiKey": "YOUR_GPUSTACK_API_KEY",\n "baseURL": "${BaseURL}"\n});\n\nasync function main(){\n const params = ${JSON.stringify(
{
@@ -113,7 +107,7 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
4
)};\nconst response = await openai.${ClientType}.create(params);\n console.log(response.${logcommand});\n}\nmain();`;
setCodeValue(code);
} else if (lang === 'python') {
} else if (lang === langMap.python) {
const formattedParams = _.keys(parameters).reduce(
(acc: string, key: string) => {
if (parameters[key] === null) {
@@ -138,20 +132,6 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
const code = `from openai import OpenAI\n\nclient = OpenAI(\n base_url="${BaseURL}", \n api_key="YOUR_GPUSTACK_API_KEY"\n)\n\nresponse = client.${ClientType}.create(\n${formattedParams} ${messages})\nprint(response.${logcommand})`;
setCodeValue(code);
}
formatCode();
};
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor;
setLoaded(true);
console.log('loaded====', editor, monaco);
};
const handleBeforeMount = (monaco: any) => {
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
diagnosticCodesToIgnore: [80001]
});
};
const handleOnChangeLang = (value: string) => {
@@ -159,7 +139,7 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
};
const handleClose = () => {
setLang('shell');
setLang(langMap.shell);
onCancel();
};
const editorConfig = {
@@ -203,21 +183,22 @@ const ViewCodeModal: React.FC<ViewModalProps> = (props) => {
<EditorWrap
copyText={codeValue}
langOptions={langOptions}
defaultValue="shell"
showHeader={loaded}
defaultValue={langMap.shell}
showHeader={true}
onChangeLang={handleOnChangeLang}
styles={{
wrapper: {
backgroundColor: 'var(--color-editor-dark)'
}
}}
>
<Editor
<HighlightCode
height={380}
theme="vs-dark"
className="monaco-editor"
defaultLanguage="shell"
language={lang}
value={codeValue}
options={editorConfig}
beforeMount={handleBeforeMount}
onMount={handleEditorDidMount}
/>
theme="dark"
code={codeValue}
lang={lang}
copyable={false}
></HighlightCode>
</EditorWrap>
<div
style={{ marginTop: 10, display: 'flex', alignItems: 'baseline' }}