Merge pull request '20261028' (#14) from test into master
All checks were successful
Build and Deploy Front / deploy (push) Successful in 2m13s

Reviewed-on: #14
This commit is contained in:
zzh 2026-10-28 11:47:07 +08:00
commit ffadbcd92f
19 changed files with 941 additions and 204 deletions

View File

@ -364,9 +364,7 @@ import {
} from '@/composables/assistantMcpGuide';
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
import { openWecomKf } from '@/utils/wecomKf';
import {
providerModelTags,
} from '@/views/system/aiAssistant/providerCatalog';
import { resolveTextModelId } from '@/views/system/aiAssistant/providerCatalog';
import NanaAvatar from './NanaAvatar.vue';
import logoImg from '@/assets/logo2.png';
@ -903,10 +901,10 @@ async function loadProvider() {
const def = enabled.find((p: any) => p.isDefault) ?? enabled[0];
providerKey.value = def?.providerKey ?? '';
if (def) {
const tags = providerModelTags(String(def.providerType || ''));
const secret = String(def.apiSecret || '').trim();
providerModel.value =
tags[0]?.modelId || (secret && secret !== '******' ? secret : '') || '';
providerModel.value = resolveTextModelId({
providerType: String(def.providerType || ''),
apiSecret: def.apiSecret,
});
} else {
providerModel.value = '';
}
@ -1028,10 +1026,20 @@ function buildAgentMessages(userFacingMessages: ChatMessage[]) {
if (activeCapability.value) {
const cap = getCapability(activeCapability.value);
list.push({
role: 'system',
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
});
if (cap.id === 'guide') {
list.push({
role: 'system',
content:
'用户在问品名能不能寄/走什么渠道。必须调用 oms.channel_guide,参数形如 ' +
'{"productCode":"","productName":"","cargoName":"<从用户话提取的品名>"}。' +
'已知渠道再填 productCode/productName,否则留空。不要空回复。',
});
} else {
list.push({
role: 'system',
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
});
}
}
// 只带最近几轮,避免历史把 token 打爆
const dialog = userFacingMessages

View File

@ -50,8 +50,8 @@ export const MCP_CAPABILITY_GUIDES: McpCapabilityGuide[] = [
exampleKey: 'nanaAssistant.mcp.guideExample',
draftKey: 'nanaAssistant.mcp.guideDraft',
fields: [
{ key: 'product', required: true, labelKey: 'nanaAssistant.mcp.field.product' },
{ key: 'cargoName', required: true, labelKey: 'nanaAssistant.mcp.field.cargoName' },
{ key: 'product', required: false, labelKey: 'nanaAssistant.mcp.field.product' },
],
},
{
@ -80,7 +80,6 @@ const ORDER_NO_LOOSE = /[A-Za-z][A-Za-z0-9\-_]{5,}/
const ORDER_NO_STRICT =
/\b(IDE\d{8,}|(?:YD|SF|YT|NF|LT)[A-Za-z0-9]{6,}|(?:[A-Z]{2,}\d{8,}))\b/i
const PRODUCT_HINT = /产品|渠道|线路|运输方式|[A-Z]{2,}[_-][A-Z0-9_]+/i
const CARGO_HINT =
/品名|货物|普货|敏感|衣服|服装|鞋|化妆品|lipstick|手机|电池|粉末|液体|食品|寄|发/i
@ -113,8 +112,12 @@ export function detectMissingFields(
if (!COUNTRY_HINT.test(t)) missing.push('country')
if (!WEIGHT_HINT.test(t)) missing.push('weight')
} else if (capability === 'guide') {
if (!PRODUCT_HINT.test(t)) missing.push('product')
if (!CARGO_HINT.test(t) && t.replace(/\s/g, '').length < 4) missing.push('cargoName')
// 仅品名必填;销售产品可选(工具可按品名检索对客说明)
const hasCargo =
CARGO_HINT.test(t) ||
/[A-Za-z]{2,}/.test(t) ||
(t.replace(/\s/g, '').length >= 2 && !/^(能不能|可以|吗|么|寄|发|走)+$/.test(t.replace(/\s/g, '')))
if (!hasCargo) missing.push('cargoName')
} else if (capability === 'track') {
if (!opts?.hasWaybillContext && !extractOrderNo(t)) missing.push('orderNo')
}
@ -131,7 +134,13 @@ export function inferCapabilityFromText(text: string): McpCapabilityId | null {
if (extractOrderNo(raw) && !COUNTRY_HINT.test(raw)) return 'track'
if (ORDER_NO_STRICT.test(raw) && raw.length < 40) return 'track'
if (/品名|能发|禁运|可寄|对客说明|承运|敏感货/.test(lower)) return 'guide'
if (
/品名|能发|禁运|可寄|对客说明|承运|敏感货|能不能走|可以走|走什么渠道|哪个渠道|什么渠道|能不能寄|可以寄|能寄吗|可发吗/.test(
lower,
)
) {
return 'guide'
}
if (/price|cost|quote|报价|价格|多少钱|运费|查价|怎么走|走得动/.test(lower)) return 'price'

View File

@ -40,9 +40,9 @@ export default {
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
'nanaAssistant.mcp.guideCoach':
'I need the sales product (service) and the cargo name.\nExample: product XX, cargo lipstick — can it ship?',
'nanaAssistant.mcp.guideExample': 'Can lipstick ship?',
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
'Tell me the cargo name and I’ll check which channels allow it.\nExample: Can lipstick ship? / Which channel for cosmetics?',
'nanaAssistant.mcp.guideExample': 'Can lipstick ship, which channel?',
'nanaAssistant.mcp.guideDraft': 'Cargo name:\nSales product code/name (optional):',
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
'nanaAssistant.mcp.trackCoach':

View File

@ -39,9 +39,9 @@ export default {
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
'nanaAssistant.mcp.guideCoach':
'判断能不能寄需要:销售产品(运输方式)和品名。\n例如:产品 XX,品名 lipstick,能发吗?',
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗',
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
'告诉我品名即可查能不能寄、走什么渠道。\n例如:lipstick 能寄吗? / 化妆品走什么渠道?',
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗,走什么渠道',
'nanaAssistant.mcp.guideDraft': '客户品名:\n销售产品代码/名称(选填):',
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
'nanaAssistant.mcp.trackCoach':

View File

@ -6,7 +6,7 @@
import CodeMirror from '@/components/CodeMirror/index.vue';
import { listAiProviders } from '@/api/ai';
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
import {
@ -64,12 +64,7 @@
const modelOptions = computed(() => {
const p = selectedProvider.value;
if (!p) return [];
const tags = providerModelTags(p.providerType);
const extra = String(p.apiSecret || '').trim();
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }];
}
return tags;
return resolveProviderModelTags(p);
});
function loadPrefs() {

View File

@ -4,7 +4,7 @@
import { Message } from '@arco-design/web-vue'
import { listAiProviders, type AiSkillItem } from '@/api/ai'
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog'
import {
@ -62,12 +62,7 @@
const modelOptions = computed(() => {
const p = selectedProvider.value
if (!p) return []
const tags = providerModelTags(p.providerType)
const extra = String(p.apiSecret || '').trim()
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }]
}
return tags
return resolveProviderModelTags(p)
})
const previewRows = computed(() => {

View File

@ -152,7 +152,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog'
import {
@ -237,12 +237,7 @@ const selectedProvider = computed(
const modelOptions = computed(() => {
const p = selectedProvider.value
if (!p) return []
const tags = providerModelTags(p.providerType)
const extra = String(p.apiSecret || '').trim()
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }]
}
return tags
return resolveProviderModelTags(p)
})
const canSend = computed(

View File

@ -158,7 +158,7 @@ import { useRouter } from 'vue-router'
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
import type { SnapshotDiffRow } from '@/utils/publishSnapshotDiff'
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog'
import {
@ -232,12 +232,7 @@ const selectedProvider = computed(
const modelOptions = computed(() => {
const p = selectedProvider.value
if (!p) return []
const tags = providerModelTags(p.providerType)
const extra = String(p.apiSecret || '').trim()
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }]
}
return tags
return resolveProviderModelTags(p)
})
const canSend = computed(

View File

@ -1262,7 +1262,7 @@ import {
type DomesticPickupProductOption,
} from '@/utils/domesticPickupProducts';
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
import H5SelectDeclarationSkuSheet from './H5SelectDeclarationSkuSheet.vue';
@ -1362,12 +1362,7 @@ const receiverAiSelectedProvider = computed(
const receiverAiModelOptions = computed(() => {
const p = receiverAiSelectedProvider.value;
if (!p) return [];
const tags = providerModelTags(p.providerType);
const extra = String(p.apiSecret || '').trim();
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }];
}
return tags;
return resolveProviderModelTags(p);
});
/** 申报品「更多」展开状态,按 item._key */
const openItemMoreKeys = ref<Set<string>>(new Set());

View File

@ -919,7 +919,7 @@ export default {
'h5.invite.tab.share': 'Share invite',
'h5.invite.tab.referees': 'Invited',
'h5.invite.lead':
'Earn freight credit when friends’ deliveries succeed: 5% on 1st order (max ¥50), 3% on orders 2–6 (max ¥30), plus a ¥20 coupon. Friends get ¥5 on sign-up.',
'Earn freight credit when friends’ deliveries succeed: 5% on 1st order (max ¥50), 3% on orders 2–6 (max ¥30), plus a ¥10 coupon. Friends get ¥5 on sign-up.',
'h5.invite.locked': 'Upgrade to V1 to invite friends',
'h5.invite.code': 'Invite code',
'h5.invite.link': 'Invite link (open in WeChat)',

View File

@ -905,7 +905,7 @@ export default {
'h5.invite.tab.share': '分享邀请',
'h5.invite.tab.referees': '已邀请好友',
'h5.invite.lead':
'邀请有奖:好友派送成功后,您得运费余额(首单 5% 最高 50 元,第 2–6 单 3% 最高 30 元),另得 20 元券;好友注册即送 5 元券',
'邀请有奖:好友派送成功后,您得运费余额(首单 5% 最高 50 元,第 2–6 单 3% 最高 30 元),另得 10 元券;好友注册即送 5 元券',
'h5.invite.locked': '升级到 V1 后可邀请好友',
'h5.invite.code': '邀请码',
'h5.invite.link': '邀请链接(微信内打开)',

View File

@ -9,6 +9,9 @@
v-if="formCollapsed"
type="button"
class="quote-collapsed"
:class="{ 'is-loading': queryLoading }"
:disabled="queryLoading"
:aria-busy="queryLoading"
@click="expandQuoteForm"
>
<div class="quote-collapsed__main">
@ -16,8 +19,14 @@
<span class="quote-collapsed__meta">{{ collapsedMetaText }}</span>
</div>
<span class="quote-collapsed__action">
{{ t('h5.price.editQuery') }}
<icon-down class="quote-collapsed__chevron" />
<template v-if="queryLoading">
<span class="quote-collapsed__pulse" aria-hidden="true" />
{{ t('h5.price.querying') }}
</template>
<template v-else>
{{ t('h5.price.editQuery') }}
<icon-down class="quote-collapsed__chevron" />
</template>
</span>
</button>
@ -205,8 +214,12 @@
</template>
</section>
<section v-if="queried" class="h5-price-results">
<div v-if="sortedResults.length" class="h5-results-bar">
<section
v-if="queried"
class="h5-price-results"
:aria-busy="queryLoading"
>
<div v-if="sortedResults.length && !queryLoading" class="h5-results-bar h5-results-bar--in">
<div class="h5-results-bar__row">
<p class="h5-results-tip" :title="resultsBarTipFull">
<span>{{ t('h5.price.resultCount', { n: sortedResults.length }) }}</span>
@ -228,8 +241,42 @@
</div>
</div>
<a-spin :loading="queryLoading" class="h5-price-spin">
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty h5-empty--no-quote">
<!-- 查价中:骨架占位(飞猪/携程式),避免整页 Spin 生硬切换 -->
<div
v-if="queryLoading"
class="h5-quote-skel-list"
aria-live="polite"
:aria-label="t('h5.price.querying')"
>
<div
v-for="i in 3"
:key="`skel-${resultsRevealEpoch}-${i}`"
class="h5-quote-skel"
:style="{ '--i': i - 1 }"
>
<div class="h5-quote-skel__head">
<div class="h5-quote-skel__col">
<span class="h5-quote-skel__bone h5-quote-skel__bone--title" />
<span class="h5-quote-skel__bone h5-quote-skel__bone--sub" />
</div>
<span class="h5-quote-skel__bone h5-quote-skel__bone--eta" />
<div class="h5-quote-skel__col h5-quote-skel__col--end">
<span class="h5-quote-skel__bone h5-quote-skel__bone--price" />
<span class="h5-quote-skel__bone h5-quote-skel__bone--ref" />
</div>
</div>
<div class="h5-quote-skel__foot">
<span class="h5-quote-skel__bone h5-quote-skel__bone--chip" />
<span class="h5-quote-skel__bone h5-quote-skel__bone--chip" />
<span class="h5-quote-skel__bone h5-quote-skel__bone--btn" />
</div>
</div>
</div>
<div
v-else-if="!sortedResults.length"
class="h5-empty h5-empty--no-quote h5-empty--in"
>
<div class="h5-empty__icon" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28" r="14" fill="none" stroke="currentColor" stroke-width="3" />
@ -319,16 +366,18 @@
<a-button size="small" class="h5-empty__action" @click="askAdvisorEmpty">
{{ t('h5.price.askAdvisor') }}
</a-button>
</div>
</div>
<template v-else>
<article
v-for="item in sortedResults"
:key="item.rowKey"
v-for="(item, idx) in sortedResults"
:key="`${resultsRevealEpoch}-${item.rowKey}`"
:ref="(el) => setQuoteRef(item.rowKey, el)"
class="h5-quote"
class="h5-quote h5-quote--reveal"
:class="{
'h5-quote--ai': item.rowKey === recommendedRowKey,
}"
:style="{ '--i': Math.min(idx, 7) }"
@click="openDetail(item)"
>
<div class="h5-quote__main">
@ -363,7 +412,10 @@
>
{{ t('h5.price.tag.priceOptimal') }}
</span>
<div class="h5-quote__price">
<div
class="h5-quote__price"
:class="{ 'is-best': item.isPriceOptimal }"
>
<small>¥</small>{{ formatMoney(item.totalCost) }}
</div>
<span class="ref-label">
@ -403,10 +455,10 @@
</div>
</div>
</article>
</a-spin>
</template>
<!-- AI 荐线降权:列表之后,默认一条轻入口 -->
<div v-if="sortedResults.length" class="h5-ai-panel">
<div v-if="sortedResults.length && !queryLoading" class="h5-ai-panel h5-ai-panel--in">
<button
v-if="!aiPanelOpen"
type="button"
@ -769,6 +821,8 @@ const optionsLoading = ref(false);
const optionsLoadFailed = ref(false);
const queryLoading = ref(false);
const queried = ref(false);
/** 每次查价递增,用于重播卡片/价格入场(不依赖路由重挂载) */
const resultsRevealEpoch = ref(0);
const queryFailureText = ref('');
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
const failureExpanded = ref(false);
@ -1244,12 +1298,20 @@ async function handleQuery() {
} catch {
// ignore
}
// 乐观收起条件区 + 骨架占位:视线立刻落到「结果位」,避免等接口再硬切
queryLoading.value = true;
queried.value = true;
formCollapsed.value = false;
formCollapsed.value = true;
results.value = [];
queryHint.value = null;
resultsRevealEpoch.value += 1;
clearPriceCheckFailures();
clearRecommendInsight();
aiExpanded.value = false;
await nextTick();
document
.querySelector('.h5-price-results')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const length = parseOptionalDimension(form.length);
const width = parseOptionalDimension(form.width);
@ -1275,10 +1337,6 @@ async function handleQuery() {
} else {
clearPriceCheckFailures();
formCollapsed.value = true;
await nextTick();
document
.querySelector('.h5-price-results')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
sortMode.value = 'recommend';
} catch (e: any) {
@ -1602,9 +1660,27 @@ onMounted(() => {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active {
&:active:not(:disabled) {
opacity: 0.78;
}
&:disabled {
cursor: default;
}
&.is-loading .quote-collapsed__action {
color: var(--app-primary-color);
}
}
.quote-collapsed__pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--app-primary-color);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-primary-color) 45%, transparent);
animation: h5-quote-pulse 1.25s ease-out infinite;
flex-shrink: 0;
}
.quote-collapsed__main {
@ -1958,6 +2034,11 @@ onMounted(() => {
margin-top: 12px;
}
.h5-ai-panel--in {
animation: h5-soft-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 0.18s;
}
.h5-ai-entry {
width: 100%;
height: 40px;
@ -2393,6 +2474,14 @@ onMounted(() => {
margin-bottom: 8px;
}
.h5-results-bar--in {
animation: h5-soft-in 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.h5-empty--in {
animation: h5-soft-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.h5-results-bar__row {
display: flex;
align-items: center;
@ -2447,9 +2536,100 @@ onMounted(() => {
}
}
.h5-price-spin {
width: 100%;
/* 骨架:与真实卡片同结构,shimmer 代替 Spin */
.h5-quote-skel-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.h5-quote-skel {
padding: 14px 14px 12px;
border-radius: 16px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
animation: h5-soft-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 55ms);
}
.h5-quote-skel__head {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.h5-quote-skel__col {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&--end {
align-items: flex-end;
}
}
.h5-quote-skel__foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(242, 243, 245, 0.85);
}
.h5-quote-skel__bone {
display: block;
border-radius: 6px;
background: linear-gradient(
90deg,
#f2f3f5 0%,
#e8eaed 40%,
#f2f3f5 80%
);
background-size: 200% 100%;
animation: h5-skel-shimmer 1.35s ease-in-out infinite;
&--title {
width: 72%;
height: 14px;
}
&--sub {
width: 42%;
height: 10px;
}
&--eta {
width: 36px;
height: 28px;
border-radius: 8px;
justify-self: center;
}
&--price {
width: 76px;
height: 20px;
}
&--ref {
width: 48px;
height: 10px;
}
&--chip {
width: 52px;
height: 20px;
border-radius: 4px;
}
&--btn {
width: 64px;
height: 28px;
border-radius: 14px;
margin-left: auto;
}
}
.h5-quote {
@ -2464,7 +2644,12 @@ onMounted(() => {
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
text-align: left;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--reveal {
animation: h5-quote-reveal 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 48ms);
}
&--ai {
box-shadow:
@ -2623,17 +2808,119 @@ onMounted(() => {
}
.h5-quote__price {
display: inline-block;
font-size: 20px;
font-weight: 700;
color: #f53f3f;
/* 非最优:中性色;最优才用红强调(无额外高亮动效) */
color: #1d2129;
white-space: nowrap;
font-variant-numeric: tabular-nums;
line-height: 1.1;
will-change: filter, opacity, transform;
small {
font-size: 12px;
margin-right: 1px;
}
&.is-best {
color: #f53f3f;
font-weight: 800;
letter-spacing: -0.01em;
}
}
/* 价格:模糊 → 清晰(后于卡片半拍) */
.h5-quote--reveal .h5-quote__price {
animation: h5-price-figure 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 48ms + 90ms);
}
.h5-quote--reveal .ref-label {
animation: h5-soft-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 48ms + 140ms);
}
@keyframes h5-soft-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes h5-quote-reveal {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes h5-price-figure {
from {
opacity: 0.25;
filter: blur(6px);
transform: translateY(3px) scale(0.99);
}
55% {
opacity: 1;
filter: blur(1.2px);
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
}
}
@keyframes h5-skel-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@keyframes h5-quote-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-primary-color) 42%, transparent);
opacity: 1;
}
70% {
box-shadow: 0 0 0 8px transparent;
opacity: 0.55;
}
100% {
box-shadow: 0 0 0 0 transparent;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.h5-results-bar--in,
.h5-empty--in,
.h5-ai-panel--in,
.h5-quote-skel,
.h5-quote--reveal,
.h5-quote--reveal .h5-quote__price,
.h5-quote--reveal .ref-label,
.quote-collapsed__pulse,
.h5-quote-skel__bone {
animation: none !important;
}
.h5-quote--reveal .h5-quote__price {
filter: none;
opacity: 1;
}
}
.h5-quote__actions {

View File

@ -45,7 +45,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
} from '@/api/order';
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
import {
providerModelTags,
resolveProviderModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
@ -171,12 +171,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const receiverAiModelOptions = computed(() => {
const p = receiverAiSelectedProvider.value;
if (!p) return [];
const tags = providerModelTags(p.providerType);
const extra = String(p.apiSecret || '').trim();
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }];
}
return tags;
return resolveProviderModelTags(p);
});
/** 申报表固定高度(框内滚动,不支持拖拽调高) */
const DECLARATION_PANEL_HEIGHT = 280;

View File

@ -257,8 +257,8 @@ export const HOME_DOORS = [
icon: 'coin',
intentId: 'invite' as const,
primary: false,
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 20 元券。' },
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥20 coupon.' },
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 10 元券。' },
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥10 coupon.' },
},
{
id: 'newcomer',

View File

@ -1,7 +1,15 @@
<template>
<div class="ai-provider-card xhs-inner-card">
<div class="card-header">
<div class="card-title" :title="title">{{ title }}</div>
<div class="card-title-wrap">
<div class="card-title" :title="title">{{ title }}</div>
<div class="status-row">
<span v-if="provider.isDefault" class="status-tag default">默认</span>
<span class="status-tag" :class="provider.enabled ? 'on' : 'off'">
{{ provider.enabled ? '已启用' : '未启用' }}
</span>
</div>
</div>
<div class="wh-action-group">
<span class="wh-action-btn" title="编辑" @click="emit('edit', provider)">
<IconEdit :size="14" />
@ -17,46 +25,101 @@
</div>
</div>
<div class="field-row">
<span class="field-label">请求地址</span>
<span class="field-value" :title="provider.baseUrl">{{ provider.baseUrl }}</span>
</div>
<div class="field-row">
<span class="field-label">API Key</span>
<span class="field-value">{{ maskedKey }}</span>
<div class="meta-block">
<div class="field-row">
<span class="field-label">请求地址</span>
<span class="field-value" :title="provider.baseUrl">{{ provider.baseUrl }}</span>
</div>
<div class="field-row">
<span class="field-label">API Key</span>
<span class="field-value">{{ maskedKey }}</span>
</div>
</div>
<div v-if="modelTags.length" class="models-row">
<span class="field-label">模型</span>
<div class="models-section">
<div class="models-head">
<span class="models-title">模型</span>
<button type="button" class="models-add-link" @click="openAddModel">
<IconPlus :size="12" />
新增
</button>
</div>
<div class="models-panel">
<div v-for="tag in modelTags" :key="tag.modelId" class="model-chip">
<div
v-for="(tag, index) in modelTags"
:key="`${tag.role}-${tag.modelId}-${index}`"
class="model-chip"
:class="{ editable: true, custom: tag.custom, 'is-default': tag.isChatDefault }"
:title="chipTitle(tag)"
@click="onChipClick(tag, index)"
>
<span class="chip-role" :data-role="tag.role">{{ tag.role }}</span>
<span class="chip-name">{{ tag.modelId }}</span>
<span v-if="tag.isChatDefault" class="chip-default">默</span>
<span
v-if="canRemoveChip"
class="chip-reset"
title="删除该模型"
@click.stop="removeModel(index)"
>
×
</span>
</div>
</div>
</div>
<div v-else class="field-row">
<span class="field-label">API Secret</span>
<span class="field-value">{{ maskedSecret }}</span>
</div>
<div class="card-footer">
<span v-if="provider.isDefault" class="status-tag default">默认</span>
<span class="status-tag" :class="provider.enabled ? 'on' : 'off'">
{{ provider.enabled ? '已启用' : '未启用' }}
</span>
</div>
<a-modal
v-model:visible="modelModalVisible"
:title="modelModalTitle"
:width="420"
ok-text="保存"
cancel-text="取消"
:ok-loading="modelSaving"
unmount-on-close
@before-ok="saveModel"
@cancel="modelModalVisible = false"
>
<div class="model-modal-hint">
「新增」会追加一条模型;点击已有芯片可修改。对话默认使用标记为「默」的文字模型。
</div>
<a-form :model="modelForm" layout="vertical">
<a-form-item label="用途" required>
<a-select v-model="modelForm.role">
<a-option v-for="role in MODEL_ROLES" :key="role" :value="role">{{ role }}</a-option>
</a-select>
</a-form-item>
<a-form-item label="模型 ID" required>
<a-input
v-model="modelForm.modelId"
placeholder="如 gpt-4o-mini / glm-5.1"
allow-clear
/>
</a-form-item>
<a-form-item v-if="modelForm.role === '文字'">
<a-checkbox v-model="modelForm.asChatDefault">设为对话默认模型</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { IconDelete, IconEdit } from '@arco-design/web-vue/es/icon';
import { computed } from 'vue';
import { Message } from '@arco-design/web-vue';
import { IconDelete, IconEdit, IconPlus } from '@arco-design/web-vue/es/icon';
import { computed, reactive, ref } from 'vue';
import { saveAiProvider } from '@/api/ai';
import {
MODEL_ROLES,
defaultModel,
maskSecret,
providerModelTags,
parseProviderModelsConfig,
providerLabel,
providerTypeLabel,
resolveProviderModelTags,
serializeProviderModelsConfig,
type AiProviderItem,
type ModelRole,
type ProviderModelTag,
} from './providerCatalog';
const props = defineProps<{
@ -66,113 +129,321 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'edit', provider: AiProviderItem): void;
(e: 'disable', provider: AiProviderItem): void;
(e: 'saved'): void;
}>();
const title = computed(() => providerTypeLabel(props.provider));
const maskedKey = computed(() => maskSecret(props.provider.apiKey));
const maskedSecret = computed(() => maskSecret(props.provider.apiSecret));
const modelTags = computed(() => providerModelTags(props.provider.providerType));
const modelTags = computed(() => resolveProviderModelTags(props.provider));
const canDisable = computed(() => Boolean(props.provider.enabled));
const canRemoveChip = computed(() => modelTags.value.length > 1);
const modelModalVisible = ref(false);
const modelSaving = ref(false);
const editIndex = ref<number | null>(null);
const modelForm = reactive<{
role: ModelRole;
modelId: string;
asChatDefault: boolean;
}>({
role: '文字',
modelId: '',
asChatDefault: false,
});
const modelModalTitle = computed(() =>
editIndex.value == null ? '新增模型' : '修改模型',
);
function chipTitle(tag: ProviderModelTag): string {
return tag.isChatDefault ? '对话默认 · 点击修改' : '点击修改';
}
function onChipClick(tag: ProviderModelTag, index: number) {
editIndex.value = index;
modelForm.role = tag.role;
modelForm.modelId = tag.modelId;
modelForm.asChatDefault = Boolean(tag.isChatDefault) || tag.role === '文字';
modelModalVisible.value = true;
}
function openAddModel() {
editIndex.value = null;
modelForm.role = '文字';
modelForm.modelId = '';
modelForm.asChatDefault = false;
modelModalVisible.value = true;
}
async function persistConfig(apiSecret: string) {
const p = props.provider;
await saveAiProvider({
providerKey: p.providerKey,
providerType: p.providerType,
displayName: p.displayName || providerLabel(p.providerType),
baseUrl: p.baseUrl,
apiSecret,
isDefault: Boolean(p.isDefault),
enabled: p.enabled !== false,
});
}
async function saveModel() {
const modelId = modelForm.modelId.trim();
if (!modelId) {
Message.warning('请填写模型 ID');
return false;
}
const config = parseProviderModelsConfig(
props.provider.apiSecret,
props.provider.providerType,
);
const next = { modelId, role: modelForm.role };
if (editIndex.value == null) {
const dup = config.models.some(
(m) => m.modelId === modelId && m.role === modelForm.role,
);
if (dup) {
Message.warning('已存在相同用途的模型');
return false;
}
config.models.push(next);
} else {
const prev = config.models[editIndex.value];
if (!prev) {
Message.warning('模型不存在');
return false;
}
const dup = config.models.some(
(m, i) =>
i !== editIndex.value && m.modelId === modelId && m.role === modelForm.role,
);
if (dup) {
Message.warning('已存在相同用途的模型');
return false;
}
config.models[editIndex.value] = next;
if (prev.modelId === config.defaultModelId) {
config.defaultModelId = modelId;
}
}
if (modelForm.role === '文字' && modelForm.asChatDefault) {
config.defaultModelId = modelId;
} else if (!config.models.some((m) => m.modelId === config.defaultModelId)) {
config.defaultModelId =
config.models.find((m) => m.role === '文字')?.modelId ||
config.models[0]?.modelId ||
defaultModel(props.provider.providerType);
}
modelSaving.value = true;
try {
await persistConfig(serializeProviderModelsConfig(config));
Message.success(editIndex.value == null ? '已新增模型' : '模型已更新');
modelModalVisible.value = false;
emit('saved');
return true;
} catch (e: any) {
Message.error(e?.message || '保存失败');
return false;
} finally {
modelSaving.value = false;
}
}
async function removeModel(index: number) {
const config = parseProviderModelsConfig(
props.provider.apiSecret,
props.provider.providerType,
);
if (config.models.length <= 1) {
Message.warning('至少保留一个模型');
return;
}
const removed = config.models[index];
config.models.splice(index, 1);
if (removed && removed.modelId === config.defaultModelId) {
config.defaultModelId =
config.models.find((m) => m.role === '文字')?.modelId ||
config.models[0]?.modelId ||
defaultModel(props.provider.providerType);
}
try {
await persistConfig(serializeProviderModelsConfig(config));
Message.success('已删除模型');
emit('saved');
} catch (e: any) {
Message.error(e?.message || '操作失败');
}
}
</script>
<style scoped lang="less">
@import '@/views/xhs/_shared/xhs-page.less';
.ai-provider-card {
height: 196px;
display: flex;
flex-direction: column;
padding: 10px 12px;
transition: border-color 0.15s;
gap: 14px;
min-height: 0;
padding: 16px 18px;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color) 45%, var(--app-card-bg));
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
box-shadow: 0 2px 10px color-mix(in srgb, var(--app-primary-color) 8%, transparent);
}
}
.card-header {
display: flex;
align-items: flex-start;
gap: 6px;
margin-bottom: 6px;
gap: 10px;
}
.card-title-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-title {
flex: 1;
font-size: 15px;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.3;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-row,
.models-row {
.status-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
min-height: 0;
}
.meta-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 12px;
background: var(--color-fill-1);
border-radius: 8px;
}
.field-row {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.field-label {
width: 72px;
width: 64px;
flex-shrink: 0;
font-size: 11px;
font-size: 12px;
color: var(--color-text-3);
line-height: 1.4;
}
.field-value {
flex: 1;
font-size: 11px;
min-width: 0;
font-size: 12px;
color: var(--color-text-2);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.models-row {
flex: 1;
min-height: 0;
align-items: flex-start;
.models-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.models-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.models-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-3);
}
.models-add-link {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--app-primary-color);
font-size: 12px;
font-weight: 600;
cursor: pointer;
line-height: 1;
&:hover {
background: color-mix(in srgb, var(--app-primary-color) 8%, transparent);
}
}
.models-panel {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: var(--color-fill-1);
border: 1px solid var(--color-border-2);
border-radius: 8px;
max-height: 72px;
overflow: hidden;
gap: 8px;
}
.model-chip {
position: relative;
min-width: 92px;
max-width: 120px;
height: 54px;
padding: 5px 8px 6px;
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
min-height: 32px;
padding: 6px 10px;
background: var(--app-card-bg);
border: 1px solid var(--color-border-2);
border-radius: 6px;
box-sizing: border-box;
&:hover {
&.editable {
cursor: pointer;
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color) 45%, var(--app-card-bg));
background: color-mix(in srgb, var(--app-primary-color) 4%, var(--app-card-bg));
}
}
&.custom {
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
}
&.is-default {
border-color: color-mix(in srgb, var(--app-primary-color) 55%, var(--app-card-bg));
}
}
.chip-role {
position: absolute;
top: 5px;
right: 8px;
font-size: 9px;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: var(--app-primary-color);
line-height: 1;
&[data-role='图片'] {
color: rgb(var(--green-6));
@ -184,29 +455,51 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
}
.chip-name {
display: block;
margin-top: 14px;
font-size: 11px;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-footer {
margin-top: auto;
display: flex;
gap: 6px;
padding-top: 4px;
.chip-default {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
background: var(--app-primary-color-light, #e6f7f7);
color: var(--app-primary-color);
line-height: 1.2;
}
.chip-reset {
flex-shrink: 0;
width: 16px;
height: 16px;
margin-left: 2px;
line-height: 14px;
text-align: center;
font-size: 13px;
color: var(--color-text-3);
border-radius: 50%;
&:hover {
color: rgb(var(--danger-6));
background: rgb(var(--danger-1));
}
}
.status-tag {
font-size: 10px;
padding: 1px 6px;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: var(--color-fill-2);
color: var(--color-text-3);
line-height: 1.4;
&.default {
background: var(--app-primary-color-light, #e6f7f7);
@ -223,4 +516,11 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
color: rgb(var(--danger-6));
}
}
.model-modal-hint {
margin-bottom: 12px;
font-size: 12px;
color: var(--color-text-3);
line-height: 1.5;
}
</style>

View File

@ -25,12 +25,11 @@
@visibility-change="(v: boolean) => (showKey = v)"
/>
</a-form-item>
<a-form-item v-if="!isZhipu" label="API Secret">
<a-input-password
<a-form-item label="默认模型(文字)">
<a-input
v-model="form.apiSecret"
placeholder="可选,可填模型名"
:visibility="showSecret"
@visibility-change="(v: boolean) => (showSecret = v)"
:placeholder="`默认 ${defaultModel(form.providerType) || '模型 ID'}`"
allow-clear
/>
</a-form-item>
<a-form-item>
@ -47,11 +46,13 @@ import { Message } from '@arco-design/web-vue';
import { saveAiProvider } from '@/api/ai';
import {
defaultBaseUrl,
defaultModel,
isMaskedSecret,
providerLabel,
resolveTextModelId,
upsertDefaultTextModel,
usesZhipuSdk,
ZHIPU_CHAT_MODEL,
ZHIPU_IMAGE_MODEL,
ZHIPU_VISION_MODEL,
type AiProviderItem,
} from './providerCatalog';
@ -67,7 +68,6 @@ const emit = defineEmits<{
const saving = ref(false);
const showKey = ref(false);
const showSecret = ref(false);
const form = reactive({
providerKey: '',
@ -89,9 +89,9 @@ const isZhipu = computed(() => usesZhipuSdk(form.providerType));
const hintText = computed(() => {
if (isZhipu.value) {
return `填写 API Key 即可;模型在创作/分析时按场景选择。文字 ${ZHIPU_CHAT_MODEL}、图文 ${ZHIPU_VISION_MODEL}、图片 ${ZHIPU_IMAGE_MODEL}。`;
return `填写 API Key 与默认文字模型即可。未填模型时使用 ${ZHIPU_CHAT_MODEL}。`;
}
return '填写请求地址与 API Key;API Secret 可填自定义模型名。';
return '填写请求地址、API Key;此处只改对话默认文字模型,不会清空卡片上已新增的其它模型。';
});
watch(
@ -103,11 +103,12 @@ watch(
form.displayName = p.displayName;
form.baseUrl = p.baseUrl;
form.apiKey = '';
form.apiSecret = p.apiSecret === '******' ? '' : p.apiSecret || '';
form.apiSecret = isMaskedSecret(p.apiSecret)
? ''
: resolveTextModelId(p);
form.isDefault = Boolean(p.isDefault);
form.enabled = p.enabled !== false;
showKey.value = false;
showSecret.value = false;
},
{ immediate: true },
);
@ -135,6 +136,13 @@ async function onOk() {
}
}
const modelId = form.apiSecret.trim() || defaultModel(form.providerType);
const apiSecret = upsertDefaultTextModel(
props.provider?.apiSecret,
form.providerType,
modelId,
);
saving.value = true;
try {
await saveAiProvider({
@ -143,7 +151,7 @@ async function onOk() {
displayName: form.displayName || providerLabel(form.providerType),
baseUrl,
apiKey: apiKey || undefined,
apiSecret: isZhipu.value ? undefined : form.apiSecret.trim() || undefined,
apiSecret: apiSecret || undefined,
isDefault: form.isDefault,
enabled: form.enabled,
});

View File

@ -5,7 +5,7 @@
<div>
<p class="xhs-panel-title">AI 助手 · 模型接入</p>
<p class="xhs-panel-desc">
默认提供 ChatGPT、豆包、DeepSeek、智谱;点击卡片可编辑密钥与模型,支持禁用。
默认提供 ChatGPT、豆包、DeepSeek、智谱;卡片内可新增/修改文字模型,也可编辑密钥,支持禁用。
</p>
</div>
</div>
@ -23,6 +23,7 @@
:provider="p"
@edit="(item) => emit('edit', item)"
@disable="(item) => emit('disable', item)"
@saved="emit('refresh')"
/>
</div>
<div v-else class="empty-hint">
@ -75,8 +76,8 @@ const emit = defineEmits<{
.providers-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.empty-hint {
@ -87,13 +88,7 @@ const emit = defineEmits<{
line-height: 1.6;
}
@media (max-width: 1100px) {
.providers-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
@media (max-width: 900px) {
.providers-grid {
grid-template-columns: 1fr;
}

View File

@ -61,17 +61,174 @@ export function usesZhipuSdk(typeId: string): boolean {
return ZHIPU_SDK_TYPES.has(typeId as ProviderTypeId);
}
export function providerModelTags(typeId: string): Array<{ modelId: string; role: string }> {
if (typeId === 'zhipu') {
return [
{ modelId: ZHIPU_CHAT_MODEL, role: '文字' },
{ modelId: ZHIPU_IMAGE_MODEL, role: '图片' },
{ modelId: ZHIPU_VISION_MODEL, role: '图文' },
];
export type ModelRole = '文字' | '图片' | '图文';
export interface ProviderModelTag {
modelId: string;
role: ModelRole;
/** 是否为对话默认模型 */
isChatDefault?: boolean;
/** 是否相对目录默认有改动(可删除) */
custom?: boolean;
editable?: boolean;
}
/** apiSecret 多模型存储结构(兼容旧版纯模型名字符串) */
export interface ProviderModelsConfig {
defaultModelId: string;
models: Array<{ modelId: string; role: ModelRole }>;
}
export const MODEL_ROLES: ModelRole[] = ['文字', '图片', '图文'];
export function defaultModel(typeId: string): string {
return PROVIDER_TYPES.find((p) => p.id === typeId)?.defaultModel ?? '';
}
export function isMaskedSecret(value?: string | null): boolean {
const text = (value || '').trim();
return !text || text === '******';
}
function isModelRole(value: unknown): value is ModelRole {
return value === '文字' || value === '图片' || value === '图文';
}
/** 解析 apiSecret:纯字符串 / JSON 多模型 */
export function parseProviderModelsConfig(
apiSecret: string | null | undefined,
providerType: string,
): ProviderModelsConfig {
const catalogDefault = defaultModel(providerType);
const secret = (apiSecret || '').trim();
if (isMaskedSecret(secret)) {
return {
defaultModelId: catalogDefault,
models: catalogDefault ? [{ modelId: catalogDefault, role: '文字' }] : [],
};
}
const def = PROVIDER_TYPES.find((p) => p.id === typeId);
if (!def) return [];
return [{ modelId: def.defaultModel, role: '文字' }];
if (secret.startsWith('{')) {
try {
const raw = JSON.parse(secret) as {
defaultModelId?: string;
models?: Array<{ modelId?: string; role?: string }>;
};
const models: Array<{ modelId: string; role: ModelRole }> = (raw.models ?? [])
.map((m) => ({
modelId: String(m.modelId || '').trim(),
role: (isModelRole(m.role) ? m.role : '文字') as ModelRole,
}))
.filter((m) => m.modelId);
let defaultModelId = String(raw.defaultModelId || '').trim();
if (!defaultModelId) {
defaultModelId =
models.find((m) => m.role === '文字')?.modelId || models[0]?.modelId || catalogDefault;
}
if (!models.length && defaultModelId) {
models.push({ modelId: defaultModelId, role: '文字' });
}
if (defaultModelId && !models.some((m) => m.modelId === defaultModelId)) {
models.unshift({ modelId: defaultModelId, role: '文字' });
}
return { defaultModelId: defaultModelId || catalogDefault, models };
} catch {
/* fallthrough as plain model id */
}
}
return {
defaultModelId: secret,
models: [{ modelId: secret, role: '文字' }],
};
}
/** 序列化到 apiSecret:单模型仍写纯字符串,多模型写 JSON */
export function serializeProviderModelsConfig(config: ProviderModelsConfig): string {
const models = config.models
.map((m) => ({
modelId: m.modelId.trim(),
role: isModelRole(m.role) ? m.role : ('文字' as ModelRole),
}))
.filter((m) => m.modelId);
const defaultModelId =
(config.defaultModelId || '').trim() ||
models.find((m) => m.role === '文字')?.modelId ||
models[0]?.modelId ||
'';
if (!models.length) return defaultModelId;
const unique: Array<{ modelId: string; role: ModelRole }> = [];
const seen = new Set<string>();
for (const m of models) {
const key = `${m.role}::${m.modelId}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(m);
}
if (unique.length === 1 && unique[0].role === '文字' && unique[0].modelId === defaultModelId) {
return defaultModelId;
}
return JSON.stringify({
defaultModelId: defaultModelId || unique[0].modelId,
models: unique,
});
}
/** 目录预设 chips(仅文字主模型;不含用户覆盖) */
export function providerModelTags(typeId: string): ProviderModelTag[] {
const modelId = defaultModel(typeId);
if (!modelId) return [];
return [{ modelId, role: '文字', editable: true, isChatDefault: true }];
}
/** 卡片/选择器:展示已配置的全部模型 chips */
export function resolveProviderModelTags(item: {
providerType: string;
apiSecret?: string | null;
}): ProviderModelTag[] {
const catalogDefault = defaultModel(item.providerType);
const config = parseProviderModelsConfig(item.apiSecret, item.providerType);
return config.models.map((m) => ({
modelId: m.modelId,
role: m.role,
editable: true,
isChatDefault: m.modelId === config.defaultModelId && m.role === '文字'
? true
: m.modelId === config.defaultModelId,
custom: !(m.role === '文字' && m.modelId === catalogDefault && config.models.length === 1),
}));
}
/** 对话默认文字模型;空则回落目录默认 */
export function resolveTextModelId(item: {
providerType: string;
apiSecret?: string | null;
}): string {
const config = parseProviderModelsConfig(item.apiSecret, item.providerType);
return (
config.defaultModelId ||
config.models.find((m) => m.role === '文字')?.modelId ||
defaultModel(item.providerType)
);
}
/** 在已有配置上设置/确保对话默认文字模型 */
export function upsertDefaultTextModel(
apiSecret: string | null | undefined,
providerType: string,
modelId: string,
): string {
const config = parseProviderModelsConfig(apiSecret, providerType);
const id = modelId.trim();
if (!id) return serializeProviderModelsConfig(config);
const idx = config.models.findIndex((m) => m.modelId === id && m.role === '文字');
if (idx < 0) config.models.unshift({ modelId: id, role: '文字' });
config.defaultModelId = id;
return serializeProviderModelsConfig(config);
}
export function maskSecret(value?: string | null, visibleTail = 4): string {
@ -81,12 +238,16 @@ export function maskSecret(value?: string | null, visibleTail = 4): string {
return '*'.repeat(text.length - visibleTail) + text.slice(-visibleTail);
}
export function sortProviders<T extends { providerKey: string; providerType: string }>(
items: T[],
): T[] {
export function sortProviders<
T extends { providerKey: string; providerType: string; isDefault?: boolean },
>(items: T[]): T[] {
const order = DEFAULT_PROVIDER_KEYS.map((p) => p.key);
const typeOrder = DEFAULT_PROVIDER_KEYS.map((p) => p.type);
return [...items].sort((a, b) => {
const aDef = a.isDefault ? 0 : 1;
const bDef = b.isDefault ? 0 : 1;
if (aDef !== bDef) return aDef - bDef;
const ai = order.indexOf(a.providerKey);
const bi = order.indexOf(b.providerKey);
if (ai >= 0 || bi >= 0) return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);

View File

@ -143,7 +143,7 @@ import {
} from '@/api/mcp';
import { listAiProviders } from '@/api/ai';
import {
providerModelTags,
resolveTextModelId,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
@ -287,8 +287,7 @@ async function loadProviders() {
const def = providers.value.find((p) => p.isDefault) ?? providers.value[0];
if (def) {
providerKey.value = def.providerKey;
const tags = providerModelTags(def.providerType);
modelId.value = tags[0]?.modelId || String(def.apiSecret || '').trim() || '';
modelId.value = resolveTextModelId(def);
}
} catch {
providers.value = [];