Form.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  1. <template>
  2. <div class="llm-sku-create-form">
  3. <a-form :form="form.fc" v-bind="formItemLayout">
  4. <a-form-item :label="$t('aice.project')">
  5. <domain-project
  6. v-if="!isEditMode"
  7. :fc="form.fc"
  8. :fd="form.fd"
  9. :form-layout="formItemLayout"
  10. :decorators="{ project: decorators.project, domain: decorators.domain }" />
  11. <a-label v-if="isEditMode">{{ projectName }}</a-label>
  12. </a-form-item>
  13. <a-form-item :label="$t('common.name')">
  14. <a-input v-decorator="decorators.name" v-if="!isEditMode" />
  15. <template v-slot:extra v-if="!isEditMode">
  16. <name-repeated res="llm_skus" :name="form.fd.name" :default-text="$t('aice.name_repeat_extra')" />
  17. </template>
  18. <a-label v-if="isEditMode">{{ modelName }}</a-label>
  19. </a-form-item>
  20. <a-form-item :label="$t('aice.llm_type')">
  21. <a-radio-group
  22. v-if="!isEditMode"
  23. class="llm-type-picker"
  24. button-style="solid"
  25. v-decorator="decorators.llm_type">
  26. <a-radio-button v-for="opt in llmTypeOptions" :key="opt.id" :value="opt.id">
  27. {{ opt.name }}
  28. </a-radio-button>
  29. </a-radio-group>
  30. <a-label v-else>{{ llmTypeName }}</a-label>
  31. </a-form-item>
  32. <template v-if="form.fd.llm_type === 'dify'">
  33. <a-form-item :label="$t('aice.dify_api_image')">
  34. <base-select
  35. v-decorator="decorators.dify_api_image_id"
  36. resource="llm_images"
  37. :params="{ ...appImageParams, $t: 'dify_api_image' }"
  38. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_api_image')]) }" />
  39. </a-form-item>
  40. <a-form-item :label="$t('aice.dify_plugin_image')">
  41. <base-select
  42. v-decorator="decorators.dify_plugin_image_id"
  43. resource="llm_images"
  44. :params="{ ...appImageParams, $t: 'dify_plugin_image' }"
  45. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_plugin_image')]) }" />
  46. </a-form-item>
  47. <a-form-item :label="$t('aice.dify_sandbox_image')">
  48. <base-select
  49. v-decorator="decorators.dify_sandbox_image_id"
  50. resource="llm_images"
  51. :params="{ ...appImageParams, $t: 'dify_sandbox_image' }"
  52. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_sandbox_image')]) }" />
  53. </a-form-item>
  54. <a-form-item :label="$t('aice.dify_ssrf_image')">
  55. <base-select
  56. v-decorator="decorators.dify_ssrf_image_id"
  57. resource="llm_images"
  58. :params="{ ...appImageParams, $t: 'dify_ssr_image' }"
  59. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_ssr_image')]) }" />
  60. </a-form-item>
  61. <a-form-item :label="$t('aice.dify_weaviate_image')">
  62. <base-select
  63. v-decorator="decorators.dify_weaviate_image_id"
  64. resource="llm_images"
  65. :params="{ ...appImageParams, $t: 'dify_weaviate_image' }"
  66. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_weaviate_image')]) }" />
  67. </a-form-item>
  68. <a-form-item :label="$t('aice.dify_web_image')">
  69. <base-select
  70. v-decorator="decorators.dify_web_image_id"
  71. resource="llm_images"
  72. :params="{ ...appImageParams, $t: 'dify_web_image' }"
  73. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.dify_web_image')]) }" />
  74. </a-form-item>
  75. <a-form-item :label="$t('aice.nginx_image')">
  76. <base-select
  77. v-decorator="decorators.nginx_image_id"
  78. resource="llm_images"
  79. :params="{ ...appImageParams, $t: 'nginx_image' }"
  80. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.nginx_image')]) }" />
  81. </a-form-item>
  82. <a-form-item :label="$t('aice.postgres_image')">
  83. <base-select
  84. v-decorator="decorators.postgres_image_id"
  85. resource="llm_images"
  86. :params="{ ...appImageParams, $t: 'postgres_image' }"
  87. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.postgres_image')]) }" />
  88. </a-form-item>
  89. <a-form-item :label="$t('aice.redis_image')">
  90. <base-select
  91. v-decorator="decorators.redis_image_id"
  92. resource="llm_images"
  93. :params="{ ...appImageParams, $t: 'redis_image' }"
  94. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.redis_image')]) }" />
  95. </a-form-item>
  96. </template>
  97. <a-form-item v-else :label="$t('aice.llm_image')">
  98. <base-select
  99. v-decorator="decorators.llm_image_id"
  100. resource="llm_images"
  101. :params="appImageParams"
  102. :selectProps="{ placeholder: $t('common.tips.select', [$t('aice.llm_image')]) }" />
  103. </a-form-item>
  104. <a-form-item :label="$t('aice.cpu')">
  105. <a-input-number
  106. v-decorator="decorators.cpu"
  107. :min="2"
  108. :step="2"
  109. :precision="0" /> {{ $t('aice.cpu.unit') }}
  110. </a-form-item>
  111. <a-form-item :label="$t('aice.memory')">
  112. <a-input-number
  113. v-decorator="decorators.memory"
  114. :min="2"
  115. :step="2"
  116. :precision="0" /> GB
  117. </a-form-item>
  118. <a-form-item :label="$t('aice.disk')">
  119. <a-input-number
  120. v-decorator="decorators.volume_size"
  121. :min="10"
  122. :step="32"
  123. :precision="0" /> GB
  124. </a-form-item>
  125. <a-form-item :label="$t('aice.bandwidth')">
  126. <a-input-number
  127. v-decorator="decorators.bandwidth"
  128. :min="1"
  129. :max="10000"
  130. :step="1"
  131. :precision="0" /> MB
  132. </a-form-item>
  133. <template v-for="field in currentTypeFields">
  134. <a-form-item
  135. v-if="field.component === 'base-select'"
  136. :key="field.fieldKey"
  137. :label="$t(field.label)">
  138. <base-select
  139. v-decorator="decorators[field.fieldKey]"
  140. v-bind="getBaseSelectProps(field)" />
  141. </a-form-item>
  142. <a-form-item v-else-if="field.component === 'input-number'" :key="field.fieldKey" :label="$t(field.label)">
  143. <a-input-number
  144. v-decorator="decorators[field.fieldKey]"
  145. v-bind="field.props" />
  146. <template v-if="field.suffixKey">{{ $t(field.suffixKey) }}</template>
  147. <template v-else-if="field.suffix">{{ field.suffix }}</template>
  148. </a-form-item>
  149. <a-form-item
  150. v-else-if="field.component === 'input'"
  151. :key="field.fieldKey"
  152. :label="$t(field.label)">
  153. <a-input
  154. v-decorator="decorators[field.fieldKey]"
  155. :placeholder="field.props && field.props.placeholderKey ? $t('common.tips.input', [$t(field.props.placeholderKey)]) : ''" />
  156. </a-form-item>
  157. <a-form-item
  158. v-else-if="field.component === 'customized-args'"
  159. :key="field.fieldKey"
  160. :label="$t('aice.customized_args')">
  161. <a-row v-for="item in customizedArgsRows" :key="item.key" :gutter="4">
  162. <a-col :span="11">
  163. <a-form-item>
  164. <a-input
  165. v-decorator="decorators.customized_args.argKey(item.key)"
  166. :placeholder="$t('common.tips.input', [$t('aice.customized_arg_key')])" />
  167. </a-form-item>
  168. </a-col>
  169. <a-col :span="11">
  170. <a-form-item>
  171. <a-input
  172. v-decorator="decorators.customized_args.argValue(item.key)"
  173. :placeholder="$t('common.tips.input', [$t('aice.customized_arg_value')])" />
  174. </a-form-item>
  175. </a-col>
  176. <a-col :span="2">
  177. <a-button shape="circle" icon="minus" size="small" @click="delCustomizedArg(item)" class="mt-2 ml-2" />
  178. </a-col>
  179. </a-row>
  180. <a-row>
  181. <a-col>
  182. <div class="d-flex align-items-center">
  183. <a-button type="primary" shape="circle" icon="plus" size="small" @click="addCustomizedArg" />
  184. <a-button type="link" @click="addCustomizedArg">{{ $t('aice.add_customized_arg') }}</a-button>
  185. </div>
  186. </a-col>
  187. </a-row>
  188. </a-form-item>
  189. </template>
  190. <!-- 暂时隐藏 Agent 个性化配置,恢复时将 showAgentPersonalization 改为 true -->
  191. <a-divider
  192. v-if="form.fd.llm_type === 'openclaw' && showAgentPersonalization"
  193. key="section-agent-personalization"
  194. orientation="left"
  195. class="openclaw-section-divider">
  196. {{ $t('aice.openclaw.section.agent_personalization') }}
  197. </a-divider>
  198. <a-form-item
  199. v-if="form.fd.llm_type === 'openclaw' && showAgentPersonalization"
  200. :label="$t('aice.openclaw.workspace_templates')"
  201. :extra="$t('aice.openclaw.workspace_templates_tip')">
  202. <div class="openclaw-template-item">
  203. <div class="openclaw-template-title">AGENTS.md</div>
  204. <a-textarea
  205. v-decorator="decorators.openclaw_agents_md"
  206. :auto-size="{ minRows: 3, maxRows: 10 }"
  207. :placeholder="'AGENTS.md'" />
  208. </div>
  209. <div class="openclaw-template-item">
  210. <div class="openclaw-template-title">SOUL.md</div>
  211. <a-textarea
  212. v-decorator="decorators.openclaw_soul_md"
  213. :auto-size="{ minRows: 3, maxRows: 10 }"
  214. :placeholder="'SOUL.md'" />
  215. </div>
  216. <div class="openclaw-template-item mb-0">
  217. <div class="openclaw-template-title">USER.md</div>
  218. <a-textarea
  219. v-decorator="decorators.openclaw_user_md"
  220. :auto-size="{ minRows: 3, maxRows: 10 }"
  221. :placeholder="'USER.md'" />
  222. </div>
  223. </a-form-item>
  224. <a-form-item :label="$t('aice.container_port_mapping')" :extra="$t('aice.container_port_mapping_tip')">
  225. <a-row v-for="item in portMappings" :key="item.key" :gutter="4">
  226. <a-col :span="11">
  227. <a-form-item>
  228. <base-select
  229. v-decorator="decorators.port_mapppings.protocol(item.key)"
  230. :options="dict.protocolArr" />
  231. </a-form-item>
  232. </a-col>
  233. <a-col :span="11">
  234. <a-form-item>
  235. <a-input
  236. v-decorator="decorators.port_mapppings.container_port(item.key)"
  237. :placeholder="$t('common.tips.input', [$t('aice.container_port')])" />
  238. </a-form-item>
  239. </a-col>
  240. <a-col :span="2">
  241. <a-button shape="circle" icon="minus" size="small" @click="del(item)" class="mt-2 ml-2" />
  242. </a-col>
  243. </a-row>
  244. <a-row>
  245. <a-col>
  246. <div class="d-flex align-items-center">
  247. <a-button type="primary" shape="circle" icon="plus" size="small" @click="add" />
  248. <a-button type="link" @click="add">{{$t('aice.add_port_mapping')}}</a-button>
  249. </div>
  250. </a-col>
  251. </a-row>
  252. </a-form-item>
  253. </a-form>
  254. </div>
  255. </template>
  256. <script>
  257. import { mapGetters } from 'vuex'
  258. import WindowsMixin from '@/mixins/windows'
  259. import DomainProject from '@/sections/DomainProject'
  260. import NameRepeated from '@/sections/NameRepeated'
  261. import { isRequired } from '@/utils/validate'
  262. import { uuid } from '@/utils/utils'
  263. import { dict } from '../constant'
  264. import { LLM_TYPE_OPTIONS, LLM_TYPE_FORM_CONFIG, getParamsForType } from '../llmTypeConfig'
  265. const getInitVal = (list, key, property) => {
  266. const target = list.filter(item => item.key === key)
  267. return target.length ? target[0][property] : ''
  268. }
  269. export default {
  270. name: 'LlmSkuCreateForm',
  271. components: {
  272. DomainProject,
  273. NameRepeated,
  274. },
  275. mixins: [WindowsMixin],
  276. props: {
  277. mode: {
  278. type: String,
  279. default: 'create',
  280. validator: v => ['create', 'edit'].includes(v),
  281. },
  282. editData: {
  283. type: Object,
  284. default: () => ({}),
  285. },
  286. onManager: {
  287. type: Function,
  288. default: null,
  289. },
  290. },
  291. data () {
  292. const data = this.mode === 'edit' && this.editData ? this.editData : {}
  293. const isApplyType = this.$route.path.includes('app-llm')
  294. const llmTypeOptions = isApplyType ? LLM_TYPE_OPTIONS.filter(opt => opt.id !== 'vllm' && opt.id !== 'ollama') : LLM_TYPE_OPTIONS.filter(opt => opt.id === 'vllm' || opt.id === 'ollama')
  295. const {
  296. domain_id,
  297. project_domain,
  298. tenant_id,
  299. tenant,
  300. name,
  301. llm_type: rowLlmType,
  302. cpu = 2,
  303. memory = 2048,
  304. volume = { size: 10240, storage_type: 'local', template_id: undefined },
  305. image_id,
  306. envs = [],
  307. llm_image_id,
  308. devices,
  309. mounted_models = [],
  310. mounted_apps = [],
  311. llm_spec: llmSpec,
  312. openclaw: openclawConf = {},
  313. port_mappings = [],
  314. preferred_model: rowPreferredModel,
  315. } = data
  316. const preferredModelInit = rowPreferredModel != null && rowPreferredModel !== ''
  317. ? String(rowPreferredModel)
  318. : (llmSpec?.vllm?.preferred_model != null ? String(llmSpec.vllm.preferred_model) : '')
  319. const {
  320. dify_api_image_id,
  321. dify_plugin_image_id,
  322. dify_sandbox_image_id,
  323. dify_ssrf_image_id,
  324. dify_weaviate_image_id,
  325. dify_web_image_id,
  326. nginx_image_id,
  327. postgres_image_id,
  328. redis_image_id,
  329. } = llmSpec?.dify || {}
  330. // openclaw 优先来自 llm_spec.openclaw,其次兼容旧的顶层 openclaw 字段
  331. let openclawConfObj = llmSpec?.openclaw ?? openclawConf
  332. if (typeof openclawConfObj === 'string') {
  333. try { openclawConfObj = JSON.parse(openclawConfObj) } catch (e) { openclawConfObj = {} }
  334. }
  335. if (!openclawConfObj || typeof openclawConfObj !== 'object') openclawConfObj = {}
  336. let openclawWorkspaceTemplates = openclawConfObj?.workspace_templates
  337. if (typeof openclawWorkspaceTemplates === 'string') {
  338. try { openclawWorkspaceTemplates = JSON.parse(openclawWorkspaceTemplates) } catch (e) { openclawWorkspaceTemplates = {} }
  339. }
  340. if (!openclawWorkspaceTemplates || typeof openclawWorkspaceTemplates !== 'object') openclawWorkspaceTemplates = {}
  341. const envVars = (envs || []).map(item => ({ env_key: item.key, env_value: item.value, key: uuid() }))
  342. const defaultLlmType = (llmTypeOptions[0] && llmTypeOptions[0].id) || (isApplyType ? 'openclaw' : 'ollama')
  343. const portMappings = port_mappings.map(item => ({ ...item, key: uuid() }))
  344. let customizedArgsSource = data.customized_args ?? llmSpec?.vllm?.customized_args ?? []
  345. if (!Array.isArray(customizedArgsSource)) customizedArgsSource = []
  346. const customizedArgsRows = customizedArgsSource.map((row) => ({
  347. key: uuid(),
  348. argKey: row != null && row.key != null ? String(row.key) : '',
  349. argValue: row != null && row.value != null ? String(row.value) : '',
  350. }))
  351. return {
  352. loading: false,
  353. // 暂时隐藏 openclaw 创建/编辑时的「Agent 个性化配置」区块,恢复时改为 true
  354. showAgentPersonalization: false,
  355. isApplyType,
  356. llmTypeOptions: llmTypeOptions.map(opt => ({ id: opt.id, name: this.$t(opt.name) })),
  357. dict,
  358. portMappings,
  359. customizedArgsRows,
  360. form: {
  361. fc: this.$form.createForm(this, {
  362. onValuesChange: (props, values) => {
  363. Object.keys(values).forEach((key) => {
  364. this.$set(this.form.fd, key, values[key])
  365. })
  366. },
  367. }),
  368. fd: {
  369. // 须与 decorators.llm_type.initialValue 一致,否则 currentTypeFields / v-if 与真实选中类型不同步,初始化不展示类型字段
  370. llm_type: rowLlmType || defaultLlmType,
  371. },
  372. },
  373. decorators: {
  374. domain: [
  375. 'domain',
  376. {
  377. initialValue: { key: domain_id, label: project_domain },
  378. rules: [
  379. { validator: isRequired(), message: this.$t('rules.domain'), trigger: 'change' },
  380. ],
  381. },
  382. ],
  383. project: [
  384. 'project',
  385. {
  386. initialValue: { key: tenant_id, label: tenant },
  387. rules: [
  388. { validator: isRequired(), message: this.$t('rules.project'), trigger: 'change' },
  389. ],
  390. },
  391. ],
  392. name: [
  393. 'name',
  394. {
  395. initialValue: name,
  396. rules: [
  397. { required: true, message: this.$t('common.tips.input', [this.$t('common.name')]) },
  398. ],
  399. },
  400. ],
  401. llm_image_id: [
  402. 'llm_image_id',
  403. {
  404. initialValue: llm_image_id,
  405. rules: [
  406. { required: true, message: this.$t('common.tips.select', [this.$t('aice.llm_image')]) },
  407. ],
  408. },
  409. ],
  410. dify_api_image_id: [
  411. 'dify_api_image_id',
  412. {
  413. initialValue: dify_api_image_id,
  414. rules: [
  415. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_api_image')]) },
  416. ],
  417. },
  418. ],
  419. dify_plugin_image_id: [
  420. 'dify_plugin_image_id',
  421. {
  422. initialValue: dify_plugin_image_id,
  423. rules: [
  424. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_plugin_image')]) },
  425. ],
  426. },
  427. ],
  428. dify_sandbox_image_id: [
  429. 'dify_sandbox_image_id',
  430. {
  431. initialValue: dify_sandbox_image_id,
  432. rules: [
  433. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_sandbox_image')]) },
  434. ],
  435. },
  436. ],
  437. dify_ssrf_image_id: [
  438. 'dify_ssrf_image_id',
  439. {
  440. initialValue: dify_ssrf_image_id,
  441. rules: [
  442. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_ssr_image')]) },
  443. ],
  444. },
  445. ],
  446. llm_type: [
  447. 'llm_type',
  448. {
  449. initialValue: rowLlmType || defaultLlmType,
  450. rules: [
  451. { required: true, message: this.$t('common.tips.select', [this.$t('aice.llm_type')]) },
  452. ],
  453. },
  454. ],
  455. dify_weaviate_image_id: [
  456. 'dify_weaviate_image_id',
  457. {
  458. initialValue: dify_weaviate_image_id,
  459. rules: [
  460. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_weaviate_image')]) },
  461. ],
  462. },
  463. ],
  464. dify_web_image_id: [
  465. 'dify_web_image_id',
  466. {
  467. initialValue: dify_web_image_id,
  468. rules: [
  469. { required: true, message: this.$t('common.tips.select', [this.$t('aice.dify_web_image')]) },
  470. ],
  471. },
  472. ],
  473. nginx_image_id: [
  474. 'nginx_image_id',
  475. {
  476. initialValue: nginx_image_id,
  477. rules: [
  478. { required: true, message: this.$t('common.tips.select', [this.$t('aice.nginx_image')]) },
  479. ],
  480. },
  481. ],
  482. postgres_image_id: [
  483. 'postgres_image_id',
  484. {
  485. initialValue: postgres_image_id,
  486. rules: [
  487. { required: true, message: this.$t('common.tips.select', [this.$t('aice.postgres_image')]) },
  488. ],
  489. },
  490. ],
  491. redis_image_id: [
  492. 'redis_image_id',
  493. {
  494. initialValue: redis_image_id,
  495. rules: [
  496. { required: true, message: this.$t('common.tips.select', [this.$t('aice.redis_image')]) },
  497. ],
  498. },
  499. ],
  500. openclaw_agents_md: [
  501. 'openclaw_agents_md',
  502. {
  503. initialValue: openclawWorkspaceTemplates['AGENTS.md'] || openclawWorkspaceTemplates.agents_md,
  504. },
  505. ],
  506. openclaw_soul_md: [
  507. 'openclaw_soul_md',
  508. {
  509. initialValue: openclawWorkspaceTemplates['SOUL.md'] || openclawWorkspaceTemplates.soul_md,
  510. },
  511. ],
  512. openclaw_user_md: [
  513. 'openclaw_user_md',
  514. {
  515. initialValue: openclawWorkspaceTemplates['USER.md'] || openclawWorkspaceTemplates.user_md,
  516. },
  517. ],
  518. mounted_models: [
  519. 'mounted_models',
  520. {
  521. initialValue: mounted_models.map(v => v.id),
  522. rules: [
  523. { required: true, message: this.$t('common.tips.select', [this.$t('aice.model')]) },
  524. ],
  525. },
  526. ],
  527. preferred_model: [
  528. 'preferred_model',
  529. {
  530. initialValue: preferredModelInit,
  531. },
  532. ],
  533. bandwidth: [
  534. 'bandwidth',
  535. {
  536. initialValue: 100,
  537. rules: [
  538. { required: true, message: this.$t('common.tips.input', [this.$t('aice.bandwidth')]) },
  539. ],
  540. },
  541. ],
  542. cpu: [
  543. 'cpu',
  544. {
  545. initialValue: cpu,
  546. rules: [
  547. { required: true, message: this.$t('common.tips.input', ['CPU']) },
  548. ],
  549. },
  550. ],
  551. memory: [
  552. 'memory',
  553. {
  554. initialValue: memory / 1024,
  555. rules: [
  556. { required: true, message: this.$t('common.tips.input', [this.$t('aice.memory')]) },
  557. ],
  558. },
  559. ],
  560. volume_size: [
  561. 'volume_size',
  562. {
  563. initialValue: volume.size / 1024,
  564. rules: [
  565. { required: true, message: this.$t('common.tips.input', [this.$t('aice.disk')]) },
  566. ],
  567. },
  568. ],
  569. phone_image: [
  570. 'phone_image',
  571. {
  572. initialValue: image_id,
  573. rules: [
  574. { required: true, message: this.$t('common.tips.select', [this.$t('aice.image')]) },
  575. ],
  576. },
  577. ],
  578. request_sync_image: [
  579. 'request_sync_image',
  580. {
  581. initialValue: false,
  582. },
  583. ],
  584. device: [
  585. 'device',
  586. {
  587. initialValue: devices && devices.length > 0 ? devices.map(v => v.model) : [],
  588. rules: [
  589. { required: true, message: this.$t('common.tips.select', [this.$t('aice.devices')]) },
  590. ],
  591. },
  592. ],
  593. mounted_apps: [
  594. 'mounted_apps',
  595. {
  596. initialValue: mounted_apps || [],
  597. },
  598. ],
  599. port_mapppings: {
  600. protocol: i => [
  601. `protocol[${i}]`,
  602. {
  603. initialValue: getInitVal(portMappings, i, 'protocol'),
  604. },
  605. ],
  606. container_port: i => [
  607. `container_port[${i}]`,
  608. {
  609. initialValue: getInitVal(portMappings, i, 'container_port'),
  610. rules: [
  611. { type: 'number', min: 0, max: 65535, message: this.$t('aice.container_port.message'), trigger: 'blur', transform: (v) => parseInt(v) },
  612. ],
  613. },
  614. ],
  615. },
  616. customized_args: {
  617. argKey: rowKey => [
  618. `customized_arg_key[${rowKey}]`,
  619. { initialValue: getInitVal(customizedArgsRows, rowKey, 'argKey') },
  620. ],
  621. argValue: rowKey => [
  622. `customized_arg_value[${rowKey}]`,
  623. { initialValue: getInitVal(customizedArgsRows, rowKey, 'argValue') },
  624. ],
  625. },
  626. },
  627. formItemLayout: {
  628. wrapperCol: { span: 20 },
  629. labelCol: { span: 4 },
  630. },
  631. envVars,
  632. projectName: (project_domain != null && tenant != null) ? `${project_domain}/${tenant}` : '',
  633. modelName: name || '',
  634. audioImageParams: { limit: 20, scope: this.$store.getters.scope, $t: 2 },
  635. streamImageParams: { limit: 20, scope: this.$store.getters.scope, $t: 3 },
  636. }
  637. },
  638. computed: {
  639. ...mapGetters(['userInfo']),
  640. isEditMode () {
  641. return this.mode === 'edit'
  642. },
  643. llmTypeName () {
  644. const cur = this.form?.fd?.llm_type
  645. const opt = this.llmTypeOptions.find(o => o.id === cur)
  646. return (opt && opt.name) || cur || '-'
  647. },
  648. currentTypeFields () {
  649. const type = this.form.fd.llm_type || 'ollama'
  650. const base = LLM_TYPE_FORM_CONFIG[type] || []
  651. if (type !== 'vllm') return base
  652. const out = []
  653. base.forEach((f) => {
  654. out.push(f)
  655. if (f.fieldKey === 'preferred_model') {
  656. out.push({ fieldKey: '__customized_args__', component: 'customized-args' })
  657. }
  658. })
  659. return out
  660. },
  661. appImageParams () {
  662. return {
  663. limit: 20,
  664. scope: this.$store.getters.scope,
  665. $t: 1,
  666. ...getParamsForType(this.form.fd.llm_type),
  667. }
  668. },
  669. mountedModelParams () {
  670. return {
  671. limit: 20,
  672. scope: this.$store.getters.scope,
  673. ...getParamsForType(this.form.fd.llm_type),
  674. }
  675. },
  676. credentialParams () {
  677. const filter = ['type.equals(container_secret)']
  678. if (this.$store.getters.scope === 'project') {
  679. const uid = this.$store.getters.userInfo?.id
  680. if (uid) filter.push(`user_id.equals(${uid})`)
  681. }
  682. return {
  683. scope: this.$store.getters.scope,
  684. filter,
  685. }
  686. },
  687. specList () {
  688. const list = Object.values(this.$store.getters.capability?.pci_model_types || {}).filter(item => item.hypervisor === 'pod')
  689. return list.map(item => ({ key: item.model, label: item.model }))
  690. },
  691. },
  692. watch: {
  693. 'form.fd.llm_type' (val, oldVal) {
  694. if (val === oldVal) return
  695. if (oldVal === 'openclaw' && val !== 'openclaw') {
  696. this.form.fc.setFieldsValue({
  697. openclaw_agents_md: undefined,
  698. openclaw_soul_md: undefined,
  699. openclaw_user_md: undefined,
  700. })
  701. }
  702. },
  703. },
  704. methods: {
  705. add () {
  706. this.portMappings.push({ key: uuid() })
  707. },
  708. del (item) {
  709. const idx = this.portMappings.findIndex(v => v.key === item.key)
  710. this.portMappings.splice(idx, 1)
  711. },
  712. addCustomizedArg () {
  713. this.customizedArgsRows.push({ key: uuid(), argKey: '', argValue: '' })
  714. },
  715. delCustomizedArg (item) {
  716. const idx = this.customizedArgsRows.findIndex(v => v.key === item.key)
  717. if (idx >= 0) this.customizedArgsRows.splice(idx, 1)
  718. },
  719. handleCancel () {
  720. this.$emit('cancel')
  721. },
  722. getBaseSelectProps (field) {
  723. const { props } = field
  724. const selectProps = {
  725. placeholder: this.$t('common.tips.select', [this.$t(props.placeholderKey)]),
  726. ...(props.selectProps || {}),
  727. }
  728. if (props.resource) {
  729. const paramsKeyMap = {
  730. appImageParams: 'appImageParams',
  731. mountedModelParams: 'mountedModelParams',
  732. credentialParams: 'credentialParams',
  733. }
  734. const paramsKey = paramsKeyMap[props.paramsKey] || 'mountedModelParams'
  735. return {
  736. resource: props.resource,
  737. params: this[paramsKey],
  738. selectProps,
  739. remote: props.remote || false,
  740. }
  741. }
  742. const options = props.optionsKey ? this[props.optionsKey] : []
  743. return { options, selectProps }
  744. },
  745. async handleConfirm () {
  746. this.loading = true
  747. try {
  748. const manager = new this.$Manager('llm_skus')
  749. const values = await this.form.fc.validateFields()
  750. const {
  751. project,
  752. name,
  753. llm_type,
  754. phone_image,
  755. request_sync_image,
  756. llm_image_id,
  757. dify_api_image_id,
  758. dify_plugin_image_id,
  759. dify_sandbox_image_id,
  760. dify_ssrf_image_id,
  761. dify_weaviate_image_id,
  762. dify_web_image_id,
  763. nginx_image_id,
  764. postgres_image_id,
  765. redis_image_id,
  766. cpu,
  767. memory,
  768. volume_size,
  769. bandwidth,
  770. protocol,
  771. container_port,
  772. customized_arg_key,
  773. customized_arg_value,
  774. } = values
  775. const effectiveLlmType = this.isEditMode ? this.form.fd.llm_type : llm_type
  776. const typeFields = this.currentTypeFields
  777. const typeFieldKeys = typeFields.filter(f => f.fieldKey !== '__customized_args__').map(f => f.fieldKey)
  778. const pickTypeValues = {}
  779. typeFieldKeys.forEach(key => {
  780. if (values[key] !== undefined) pickTypeValues[key] = values[key]
  781. })
  782. const volumes = [{
  783. containers: this.mode === 'edit' && this.editData && this.editData.volumes && this.editData.volumes[0] ? this.editData.volumes[0].containers : {
  784. 1: { mount_path: '/etc/wolf', sub_directory: 'wolf' },
  785. 2: {
  786. mount_path: '/home/retro',
  787. sub_directory: 'home',
  788. overlay: {
  789. lower_dir: ['/opt/steam-data/steam', '/opt/steam-data/games'],
  790. use_disk_image: false,
  791. },
  792. },
  793. },
  794. size_mb: (volume_size ?? 10) * 1024,
  795. }]
  796. const port_mappings = this.portMappings.map(item => {
  797. return {
  798. protocol: protocol[item.key],
  799. container_port: container_port[item.key],
  800. }
  801. })
  802. const data = {
  803. name,
  804. llm_image_id,
  805. image_id: phone_image,
  806. cpu,
  807. memory: (memory ?? 2) * 1024,
  808. bandwidth: bandwidth ?? 100,
  809. volumes,
  810. disk_size: volumes[0].size_mb,
  811. app_type: 'steam',
  812. }
  813. if (port_mappings.length > 0) {
  814. data.port_mappings = port_mappings
  815. }
  816. if (!this.isEditMode) {
  817. data.llm_type = effectiveLlmType
  818. // 默认填入 llm_sku,空对象即可,如 openclaw => llm_sku: { openclaw: {} }
  819. data.llm_sku = { [effectiveLlmType]: {} }
  820. }
  821. typeFieldKeys.forEach(key => {
  822. const v = pickTypeValues[key]
  823. if (v === undefined) return
  824. if (effectiveLlmType === 'vllm' && key === 'preferred_model') return
  825. if (key === 'device') {
  826. data.devices = v.map(k => ({ model: k }))
  827. } else {
  828. data[key] = v
  829. }
  830. })
  831. if (effectiveLlmType === 'vllm') {
  832. const vllm = {}
  833. const pm = pickTypeValues.preferred_model
  834. const preferredStr = pm != null ? String(pm).trim() : ''
  835. if (preferredStr !== '') {
  836. vllm.preferred_model = preferredStr
  837. }
  838. const customized_args = this.customizedArgsRows
  839. .map((item) => ({
  840. key: customized_arg_key?.[item.key] != null ? String(customized_arg_key[item.key]).trim() : '',
  841. value: customized_arg_value?.[item.key] != null ? String(customized_arg_value[item.key]).trim() : '',
  842. }))
  843. .filter((row) => row.key !== '' || row.value !== '')
  844. if (customized_args.length > 0) {
  845. vllm.customized_args = customized_args
  846. }
  847. if (Object.keys(vllm).length > 0) {
  848. data.llm_spec = { vllm }
  849. }
  850. }
  851. if (effectiveLlmType === 'openclaw') {
  852. const workspace_templates = {}
  853. const agents = (values.openclaw_agents_md || '').trim()
  854. const soul = (values.openclaw_soul_md || '').trim()
  855. const user = (values.openclaw_user_md || '').trim()
  856. if (agents) workspace_templates.agents_md = agents
  857. if (soul) workspace_templates.soul_md = soul
  858. if (user) workspace_templates.user_md = user
  859. if (Object.keys(workspace_templates).length > 0) {
  860. data.llm_spec = { openclaw: { workspace_templates } }
  861. }
  862. }
  863. if (effectiveLlmType === 'dify') {
  864. data.llm_spec = {
  865. dify: {
  866. dify_api_image_id: dify_api_image_id,
  867. dify_plugin_image_id: dify_plugin_image_id,
  868. dify_sandbox_image_id: dify_sandbox_image_id,
  869. dify_ssrf_image_id: dify_ssrf_image_id,
  870. dify_weaviate_image_id: dify_weaviate_image_id,
  871. dify_web_image_id: dify_web_image_id,
  872. nginx_image_id: nginx_image_id,
  873. postgres_image_id: postgres_image_id,
  874. redis_image_id: redis_image_id,
  875. },
  876. }
  877. delete data.llm_image_id
  878. }
  879. if (this.mode === 'edit' && this.onManager && this.editData) {
  880. if (request_sync_image) data.request_sync_image = true
  881. await this.onManager('update', {
  882. id: this.editData.id,
  883. managerArgs: { data },
  884. })
  885. } else {
  886. data.generate_name = name
  887. data.project_id = project?.key || this.userInfo.projectId
  888. await manager.create({ data })
  889. }
  890. this.$message.success(this.$t('common.success'))
  891. this.$emit('success')
  892. } catch (error) {
  893. throw error
  894. } finally {
  895. this.loading = false
  896. }
  897. },
  898. },
  899. }
  900. </script>
  901. <style scoped>
  902. .llm-sku-create-form .form-footer {
  903. margin-top: 24px;
  904. padding-top: 16px;
  905. border-top: 1px solid #e8e8e8;
  906. display: flex;
  907. justify-content: flex-end;
  908. gap: 8px;
  909. }
  910. .openclaw-channel-tabs { margin-top: 8px; }
  911. .llm-type-picker ::v-deep .ant-radio-button-wrapper {
  912. height: 36px;
  913. line-height: 34px;
  914. border-radius: 4px;
  915. margin-right: 8px;
  916. margin-bottom: 8px;
  917. }
  918. .llm-type-picker ::v-deep .ant-radio-button-wrapper:first-child { border-radius: 4px; }
  919. .llm-type-picker ::v-deep .ant-radio-button-wrapper:last-child { border-radius: 4px; }
  920. .openclaw-template-item { margin-bottom: 12px; }
  921. .openclaw-template-title { font-weight: 500; margin-bottom: 6px; }
  922. .openclaw-section-divider { margin-top: 20px; }
  923. .openclaw-credential-intro { color: rgba(0, 0, 0, 0.65); font-size: 13px; }
  924. .openclaw-credential-mode { margin-bottom: 0; }
  925. .openclaw-configured-providers { font-weight: 500; }
  926. .openclaw-new-blob-section-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; }
  927. .openclaw-new-blob-row ::v-deep .ant-form-item-label { padding-bottom: 4px; }
  928. .openclaw-filter-empty { padding: 12px 0; font-size: 13px; }
  929. .openclaw-tab-with-close { display: inline-flex; align-items: center; gap: 6px; }
  930. .openclaw-tab-close { font-size: 12px; cursor: pointer; opacity: 0.6; }
  931. .openclaw-tab-close:hover { opacity: 1; }
  932. </style>