llmSpecDetail.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. /**
  2. * 详情页按 llm_type 展示 llm_spec 的共享逻辑
  3. * 供 LLM 实例详情、LLM 套餐详情复用,避免重复代码
  4. */
  5. import { OPENCLAW_CHANNEL_SECTIONS } from '../openclawChannelConfig'
  6. import { OPENCLAW_PROVIDER_OPTIONS } from '../openclawProviderConfig'
  7. const CHANNEL_KEY_TO_LABEL = {}
  8. OPENCLAW_CHANNEL_SECTIONS.forEach(s => {
  9. CHANNEL_KEY_TO_LABEL[s.sectionKey] = s.sectionLabelKey
  10. })
  11. /** provider 短名(create 里 providerShortName)→ providerLabelKey,与渠道同样用 i18n 显示供应商名称 */
  12. const PROVIDER_SHORT_TO_LABEL = {}
  13. OPENCLAW_PROVIDER_OPTIONS.forEach(labelKey => {
  14. const parts = String(labelKey || '').split('.')
  15. const short = parts[parts.length - 1]
  16. if (short) PROVIDER_SHORT_TO_LABEL[short] = labelKey
  17. })
  18. /**
  19. * 取供应商展示名(与沟通渠道一致:先显示 provider 名称,再在同一行显示密钥名称)
  20. * 提交时 providers[].name 为 providerShortName(providerKey),如 moonshot → 映射到 aice.openclaw.provider.moonshot 再 $t
  21. */
  22. function getProviderDisplayName (p, vm) {
  23. if (typeof p === 'string') return p
  24. if (!p) return '-'
  25. if (p.name && typeof p.name === 'string') {
  26. const name = p.name
  27. if (vm.$te(name)) return vm.$t(name)
  28. const labelKey = PROVIDER_SHORT_TO_LABEL[name]
  29. if (labelKey && vm.$te(labelKey)) return vm.$t(labelKey)
  30. return name
  31. }
  32. // 无 name 时尝试用 credential 或 id 占位,避免显示 export_keys 串
  33. if (p.credential_id) return p.credential_id
  34. if (p.credential && p.credential.id) return p.credential.id
  35. return '-'
  36. }
  37. /** 取渠道显示名 */
  38. function getChannelDisplayName (item, vm) {
  39. const key = typeof item === 'string' ? item : (item && (item.name || item.sectionKey || item.type || item.key)) || ''
  40. return key ? (vm.$te(CHANNEL_KEY_TO_LABEL[key]) ? vm.$t(CHANNEL_KEY_TO_LABEL[key]) : key) : '-'
  41. }
  42. /** 渲染指向容器密钥详情页的链接 */
  43. function renderCredentialLink (credId, displayText, vm, h) {
  44. if (!credId) return h('span', { class: 'text-secondary' }, displayText || '-')
  45. const name = (vm.credentialNamesMap && vm.credentialNamesMap[credId]) || displayText || credId
  46. return h('side-page-trigger', {
  47. props: {
  48. permission: 'credentials_get',
  49. name: 'ContainerSecretSidePage',
  50. id: String(credId),
  51. vm,
  52. options: { resource: 'credentials', apiVersion: 'v1' },
  53. },
  54. }, [name])
  55. }
  56. /** 从 openclaw spec 中收集所有容器密钥 id */
  57. function collectCredentialIds (spec) {
  58. const ids = new Set()
  59. if (spec.providers && Array.isArray(spec.providers)) {
  60. spec.providers.forEach(p => {
  61. const id = p && (p.credential_id || (p.credential && p.credential.id))
  62. if (id) ids.add(id)
  63. })
  64. }
  65. if (spec.channels && Array.isArray(spec.channels)) {
  66. spec.channels.forEach(item => {
  67. const id = item && item.credential && item.credential.id
  68. if (id) ids.add(id)
  69. })
  70. }
  71. return Array.from(ids)
  72. }
  73. /**
  74. * 拉取 llm_spec.openclaw 中涉及的容器密钥名称,并写入 vm.credentialNamesMap(需在 Detail 的 data 中初始化 credentialNamesMap: {})
  75. * 若 vm.skuLlmSpecOpenclaw 存在(实例详情无 AI 供应商时从套餐拉取),会一并收集其 credential id 并拉取名称
  76. * @param {Object} vm - 详情页 Vue 实例,需有 vm.data、vm.$set,且 data 中有 credentialNamesMap
  77. */
  78. export async function fetchLlmSpecCredentialNames (vm) {
  79. if (!vm || !vm.data) return
  80. const instanceSpec = vm.data.llm_spec && vm.data.llm_spec.openclaw != null ? vm.data.llm_spec.openclaw : (vm.data.llm_spec || null)
  81. let ids = instanceSpec ? collectCredentialIds(instanceSpec) : []
  82. if (vm.skuLlmSpecOpenclaw) {
  83. const skuIds = collectCredentialIds(vm.skuLlmSpecOpenclaw)
  84. ids = [...new Set([...ids, ...skuIds])]
  85. }
  86. if (ids.length === 0) return
  87. const map = { ...(vm.credentialNamesMap || {}) }
  88. const manager = new vm.$Manager('credentials', 'v1')
  89. await Promise.all(ids.map(async (id) => {
  90. if (map[id]) return
  91. try {
  92. const { data } = await manager.get({ id })
  93. if (data && data.name) map[id] = data.name
  94. } catch (e) {
  95. // 忽略单条失败,保留未拉到的用 id 展示
  96. }
  97. }))
  98. vm.$set(vm, 'credentialNamesMap', map)
  99. }
  100. /**
  101. * 渲染 openclaw llm_spec 的可读内容
  102. * @param {Object} spec - llm_spec 对象,可能含 providers / channels / workspace_templates
  103. * @param {Object} vm - Vue 实例,用于 vm.$t
  104. * @param {Function} h - createElement
  105. */
  106. function renderOpenclawSpec (spec, vm, h) {
  107. const nodes = []
  108. if (spec.providers && Array.isArray(spec.providers) && spec.providers.length > 0) {
  109. const sectionLabel = vm.$te('aice.openclaw.section.ai_providers_detail')
  110. ? vm.$t('aice.openclaw.section.ai_providers_detail')
  111. : vm.$t('aice.openclaw.section.ai_providers_env')
  112. const rows = spec.providers.map(p => {
  113. const providerName = getProviderDisplayName(p, vm)
  114. const credId = p && (p.credential_id || (p.credential && p.credential.id))
  115. const credDisplay = (vm.credentialNamesMap && credId && vm.credentialNamesMap[credId]) || (p && p.credential && p.credential.name) || credId || '-'
  116. return h('div', { class: 'd-flex align-items-center flex-wrap mb-1' }, [
  117. h('span', { class: 'text-secondary mr-1' }, providerName + ':'),
  118. renderCredentialLink(credId, credDisplay, vm, h),
  119. ])
  120. })
  121. nodes.push(h('div', { class: 'mb-2' }, [
  122. h('div', { class: 'detail-item-title text-secondary mb-1' }, sectionLabel),
  123. h('div', { class: 'detail-item-value' }, rows),
  124. ]))
  125. }
  126. if (spec.channels && Array.isArray(spec.channels) && spec.channels.length > 0) {
  127. const sectionLabel = vm.$t('aice.openclaw.section.chat_channels')
  128. const rows = spec.channels.map(item => {
  129. const channelName = getChannelDisplayName(item, vm)
  130. const credId = item && item.credential && item.credential.id
  131. const credDisplay = (vm.credentialNamesMap && credId && vm.credentialNamesMap[credId]) || (item && item.credential && item.credential.name) || credId || '-'
  132. return h('div', { class: 'd-flex align-items-center flex-wrap mb-1' }, [
  133. h('span', { class: 'text-secondary mr-1' }, channelName + ':'),
  134. renderCredentialLink(credId, credDisplay, vm, h),
  135. ])
  136. })
  137. nodes.push(h('div', { class: 'mb-2' }, [
  138. h('div', { class: 'detail-item-title text-secondary mb-1' }, sectionLabel),
  139. h('div', { class: 'detail-item-value' }, rows),
  140. ]))
  141. }
  142. if (spec.workspace_templates && typeof spec.workspace_templates === 'object' && Object.keys(spec.workspace_templates).length > 0) {
  143. const label = vm.$t('aice.openclaw.workspace_templates')
  144. const keys = Object.keys(spec.workspace_templates)
  145. nodes.push(h('div', { class: 'mb-2' }, [
  146. h('div', { class: 'detail-item-title text-secondary mb-1' }, label),
  147. h('div', { class: 'detail-item-value' }, keys.join('、')),
  148. ]))
  149. }
  150. if (nodes.length === 0) {
  151. return h('div', { class: 'detail-item-value text-secondary' }, '-')
  152. }
  153. return h('div', { class: 'llm-spec-openclaw' }, nodes)
  154. }
  155. /** Dify 镜像字段:与创建表单 / 接口返回的 dify_* 一致 */
  156. const DIFY_SPEC_IMAGE_FIELDS = [
  157. { keys: ['dify_api_image_id'], labelKey: 'aice.dify_api_image' },
  158. { keys: ['dify_plugin_image_id'], labelKey: 'aice.dify_plugin_image' },
  159. { keys: ['dify_sandbox_image_id'], labelKey: 'aice.dify_sandbox_image' },
  160. { keys: ['dify_ssrf_image_id'], labelKey: 'aice.dify_ssr_image' },
  161. { keys: ['dify_weaviate_image_id'], labelKey: 'aice.dify_weaviate_image' },
  162. { keys: ['dify_web_image_id'], labelKey: 'aice.dify_web_image' },
  163. { keys: ['nginx_image_id'], labelKey: 'aice.nginx_image' },
  164. { keys: ['postgres_image_id'], labelKey: 'aice.postgres_image' },
  165. { keys: ['redis_image_id'], labelKey: 'aice.redis_image' },
  166. ]
  167. function pickDifyField (dify, keys) {
  168. if (!dify || typeof dify !== 'object') return null
  169. for (let i = 0; i < keys.length; i++) {
  170. const v = dify[keys[i]]
  171. if (v != null && v !== '') return String(v)
  172. }
  173. return null
  174. }
  175. function collectDifyImageIds (dify) {
  176. if (!dify || typeof dify !== 'object') return []
  177. const ids = new Set()
  178. DIFY_SPEC_IMAGE_FIELDS.forEach(({ keys }) => {
  179. const id = pickDifyField(dify, keys)
  180. if (id) ids.add(id)
  181. })
  182. return Array.from(ids)
  183. }
  184. /**
  185. * 根据 llm_spec.dify 中的镜像 id 请求 llm_images,反填名称到 vm.difyImageNamesMap(需在 vm 上初始化 difyImageNamesMap: {})
  186. */
  187. export async function fetchLlmSpecDifyImages (vm) {
  188. if (!vm || !vm.data) return
  189. const type = (vm.data.llm_type || '').toLowerCase()
  190. if (type !== 'dify') {
  191. if (vm.difyImageNamesMap && Object.keys(vm.difyImageNamesMap).length > 0) {
  192. vm.$set(vm, 'difyImageNamesMap', {})
  193. }
  194. return
  195. }
  196. const spec = vm.data.llm_spec
  197. const dify = spec && (spec.dify != null ? spec.dify : spec)
  198. const ids = collectDifyImageIds(dify)
  199. if (ids.length === 0) {
  200. vm.$set(vm, 'difyImageNamesMap', {})
  201. return
  202. }
  203. const map = { ...(vm.difyImageNamesMap || {}) }
  204. const manager = new vm.$Manager('llm_images')
  205. await Promise.all(ids.map(async (id) => {
  206. if (map[id]) return
  207. try {
  208. const { data } = await manager.get({ id })
  209. if (data) {
  210. map[id] = data.name || data.displayname || id
  211. }
  212. } catch (e) {
  213. // 单条失败仍用 id 展示
  214. }
  215. }))
  216. vm.$set(vm, 'difyImageNamesMap', map)
  217. }
  218. function renderDifyImageRow (label, imageId, vm, h) {
  219. const display = imageId && vm.difyImageNamesMap && vm.difyImageNamesMap[imageId]
  220. ? vm.difyImageNamesMap[imageId]
  221. : imageId
  222. return h('div', {
  223. class: 'd-flex align-items-center flex-wrap mb-2',
  224. style: { lineHeight: '1.65' },
  225. }, [
  226. h('span', { class: 'text-secondary mr-2', style: { flex: '0 0 200px' } }, label),
  227. imageId
  228. ? h('side-page-trigger', {
  229. props: {
  230. permission: 'llm_images_get',
  231. name: 'LlmImageSidePage',
  232. id: String(imageId),
  233. vm,
  234. },
  235. }, [display || imageId])
  236. : h('span', { class: 'detail-item-value' }, '-'),
  237. ])
  238. }
  239. function renderDifySpec (dify, vm, h) {
  240. if (!dify || typeof dify !== 'object') {
  241. return h('div', { class: 'detail-item-value text-secondary' }, '-')
  242. }
  243. const rows = DIFY_SPEC_IMAGE_FIELDS.map(({ keys, labelKey }) => {
  244. const label = vm.$te(labelKey) ? vm.$t(labelKey) : keys[0]
  245. const id = pickDifyField(dify, keys)
  246. return renderDifyImageRow(label, id, vm, h)
  247. })
  248. return h('div', { class: 'llm-spec-dify' }, rows)
  249. }
  250. /**
  251. * 仅渲染套餐的 AI 供应商列表(用于实例详情下「对应套餐的 LLM 规格配置」独立 section)
  252. * @param {Object} vm - 详情页 Vue 实例,需有 vm.skuLlmSpecOpenclaw.providers、vm.credentialNamesMap
  253. * @param {Function} h - createElement
  254. */
  255. function renderSkuProvidersBlock (vm, h) {
  256. const providers = vm.skuLlmSpecOpenclaw && vm.skuLlmSpecOpenclaw.providers
  257. if (!providers || !Array.isArray(providers) || providers.length === 0) {
  258. return h('div', { class: 'detail-item-value text-secondary' }, '-')
  259. }
  260. const sectionLabel = vm.$te('aice.openclaw.section.ai_providers_detail')
  261. ? vm.$t('aice.openclaw.section.ai_providers_detail')
  262. : vm.$t('aice.openclaw.section.ai_providers_env')
  263. const rows = providers.map(p => {
  264. const providerName = getProviderDisplayName(p, vm)
  265. const credId = p && (p.credential_id || (p.credential && p.credential.id))
  266. const credDisplay = (vm.credentialNamesMap && credId && vm.credentialNamesMap[credId]) || (p && p.credential && p.credential.name) || credId || '-'
  267. return h('div', { class: 'd-flex align-items-center flex-wrap mb-1' }, [
  268. h('span', { class: 'text-secondary mr-1' }, providerName + ':'),
  269. renderCredentialLink(credId, credDisplay, vm, h),
  270. ])
  271. })
  272. return h('div', { class: 'llm-spec-openclaw' }, [
  273. h('div', { class: 'mb-2' }, [
  274. h('div', { class: 'detail-item-title text-secondary mb-1' }, sectionLabel),
  275. h('div', { class: 'detail-item-value' }, rows),
  276. ]),
  277. ])
  278. }
  279. /**
  280. * 按 llm_type 渲染 llm_spec 内容(供 Detail 的 slot 调用)
  281. * @param {Object} row - 详情 data,含 llm_type、llm_spec
  282. * @param {Object} vm - Vue 实例
  283. * @param {Function} h - createElement
  284. */
  285. function renderLlmSpecContent (row, vm, h) {
  286. const spec = row.llm_spec
  287. if (!spec || (typeof spec === 'object' && Object.keys(spec).length === 0)) {
  288. return h('div', { class: 'detail-item-value text-secondary' }, '-')
  289. }
  290. const type = (row.llm_type || '').toLowerCase()
  291. if (type === 'openclaw') {
  292. const openclawData = spec.openclaw != null ? spec.openclaw : spec
  293. return renderOpenclawSpec(openclawData, vm, h)
  294. }
  295. if (type === 'dify') {
  296. const difyData = spec.dify != null ? spec.dify : spec
  297. return renderDifySpec(difyData, vm, h)
  298. }
  299. // ollama / vllm / comfyui 等:通用 JSON 展示
  300. try {
  301. const text = typeof spec === 'string' ? spec : JSON.stringify(spec, null, 2)
  302. return h('pre', { class: 'detail-item-value mb-0 p-2 bg-secondary rounded', style: { maxHeight: '320px', overflow: 'auto' } }, text)
  303. } catch (e) {
  304. return h('div', { class: 'detail-item-value text-secondary' }, '-')
  305. }
  306. }
  307. /**
  308. * 获取用于 Detail extraInfo 的 llm_spec 区块(无 llm_spec 时返回空数组)
  309. * @param {Object} vm - 详情页 Vue 实例,需有 vm.data、vm.$t
  310. * @returns {Array<{ title: string, items: Array }>}
  311. */
  312. export function getLlmSpecSections (vm) {
  313. if (!vm || !vm.data) return []
  314. if (vm.data.llm_spec == null || (typeof vm.data.llm_spec === 'object' && !Array.isArray(vm.data.llm_spec) && Object.keys(vm.data.llm_spec).length === 0)) {
  315. return []
  316. }
  317. const title = vm.$te('aice.llm_spec_config') ? vm.$t('aice.llm_spec_config') : 'LLM规格配置'
  318. const sections = [
  319. {
  320. title,
  321. items: [
  322. {
  323. field: 'llm_spec',
  324. slots: {
  325. default: (scope, h) => renderLlmSpecContent(scope.row || vm.data, vm, h),
  326. },
  327. },
  328. ],
  329. },
  330. ]
  331. if (vm.skuLlmSpecOpenclaw && vm.skuLlmSpecOpenclaw.providers && vm.skuLlmSpecOpenclaw.providers.length > 0) {
  332. const skuTitle = vm.$te('aice.openclaw.sku_llm_spec_config') ? vm.$t('aice.openclaw.sku_llm_spec_config') : '对应套餐的 LLM 规格配置'
  333. sections.push({
  334. title: skuTitle,
  335. items: [
  336. {
  337. field: 'sku_llm_spec',
  338. slots: {
  339. default: (scope, h) => renderSkuProvidersBlock(vm, h),
  340. },
  341. },
  342. ],
  343. })
  344. }
  345. return sections
  346. }