??????????????????????????????? H5/????? Co-authored-by: Cursor <cursoragent@cursor.com>
1217 lines
37 KiB
Vue
1217 lines
37 KiB
Vue
<template>
|
||
<div
|
||
class="ai-advisor-shell ai-advisor-shell--page"
|
||
:class="{ 'ai-history-open': historyOpen }"
|
||
>
|
||
<div class="ai-shell-bg" aria-hidden="true" />
|
||
<div class="ai-advisor-layout">
|
||
<aside class="ai-history-sidebar" :aria-label="t('nanaAssistant.historyTitle')">
|
||
<div class="ai-sidebar-brand">
|
||
<div class="ai-sidebar-brand__link">
|
||
<img class="ai-sidebar-brand__logo" :src="logoImg" :alt="t('brand.name')" />
|
||
<span class="brand-lockup">
|
||
<span class="brand-lockup__title-row">
|
||
<h1>{{ t('brand.name') }}</h1>
|
||
<span class="ai-sidebar-brand__badge">{{ t('nanaAssistant.badge') }}</span>
|
||
</span>
|
||
<small class="brand-fullname">{{ t('brand.fullName') }}</small>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="ai-history-sidebar__head">
|
||
<p class="ai-history-sidebar__title">{{ t('nanaAssistant.historyTitle') }}</p>
|
||
<button type="button" class="ai-new-chat-btn" :disabled="chatLoading" @click="startNewChat">
|
||
<icon-plus />
|
||
<span class="ai-new-chat-btn__label">{{ t('nanaAssistant.newChat') }}</span>
|
||
</button>
|
||
</div>
|
||
<input
|
||
v-model="historySearch"
|
||
type="search"
|
||
class="ai-history-search"
|
||
:placeholder="t('nanaAssistant.searchHistory')"
|
||
/>
|
||
<div class="ai-history-list" role="list">
|
||
<p v-if="filteredThreads.length === 0" class="ai-history-empty">
|
||
{{ historySearch.trim() ? t('nanaAssistant.noSearchResults') : t('nanaAssistant.emptyHistory') }}
|
||
</p>
|
||
<button
|
||
v-for="thread in filteredThreads"
|
||
:key="thread.id"
|
||
type="button"
|
||
class="ai-history-item"
|
||
:class="{ 'is-active': thread.id === activeThreadId }"
|
||
role="listitem"
|
||
@click="switchThread(thread.id)"
|
||
>
|
||
<span
|
||
class="ai-history-item__icon"
|
||
:class="{ 'ai-history-item__icon--waybill': thread.waybillContext }"
|
||
>
|
||
<icon-file v-if="thread.waybillContext" />
|
||
<icon-message v-else />
|
||
</span>
|
||
<span class="ai-history-item__body">
|
||
<span class="ai-history-item__title">{{ thread.title || t('nanaAssistant.newChat') }}</span>
|
||
<span class="ai-history-item__time">{{ formatThreadTime(thread.updatedAt) }}</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="ai-history-item__delete"
|
||
:aria-label="t('nanaAssistant.deleteThread')"
|
||
:disabled="chatLoading"
|
||
@click.stop="deleteThread(thread.id)"
|
||
>
|
||
<icon-delete />
|
||
</button>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="ai-advisor-app">
|
||
<header class="ai-app-bar" :class="{ 'ai-app-bar--waybill': waybillContext }">
|
||
<button
|
||
type="button"
|
||
class="ai-sidebar-toggle"
|
||
:aria-label="t('nanaAssistant.historyTitle')"
|
||
@click="historyOpen = !historyOpen"
|
||
>
|
||
<icon-history />
|
||
</button>
|
||
<div v-if="!waybillContext" class="ai-app-bar__actions">
|
||
<button type="button" class="ai-app-bar__back" @click="goBack">
|
||
<icon-left />
|
||
{{ t('nanaAssistant.back') }}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div v-if="waybillContext" class="ai-waybill-context">
|
||
<div class="ai-waybill-context__main">
|
||
<icon-file />
|
||
<div class="ai-waybill-context__info">
|
||
<span class="ai-waybill-context__title">
|
||
{{ t('nanaAssistant.waybillContext', { no: waybillDisplayNo }) }}
|
||
</span>
|
||
<span v-if="waybillStatusLabel" class="ai-waybill-context__status">
|
||
{{ waybillStatusLabel }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="ai-waybill-context__actions">
|
||
<button type="button" class="ai-waybill-context__link" @click="goToWaybill">
|
||
{{ t('nanaAssistant.viewWaybill') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="ai-waybill-context__btn"
|
||
:disabled="chatLoading"
|
||
@click="askWaybillTrack"
|
||
>
|
||
{{ t('nanaAssistant.queryTrack') }}
|
||
</button>
|
||
<button type="button" class="ai-waybill-context__clear" @click="clearWaybillContext">
|
||
{{ t('nanaAssistant.endWaybillConsult') }}
|
||
</button>
|
||
<button type="button" class="ai-waybill-context__back" @click="goBack">
|
||
<icon-left />
|
||
{{ t('nanaAssistant.back') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div ref="mainScrollRef" class="ai-app-main">
|
||
<section v-show="showWelcome" class="ai-welcome">
|
||
<div class="ai-welcome__hero">
|
||
<NanaAvatar class="ai-welcome__avatar" size="md" />
|
||
<h2>{{ t('nanaAssistant.welcomeTitle') }}</h2>
|
||
<p>{{ t('nanaAssistant.welcomeSub') }}</p>
|
||
</div>
|
||
|
||
<p class="ai-quick-section-label">{{ t('nanaAssistant.mcp.sectionLabel') }}</p>
|
||
<div class="ai-mcp-grid">
|
||
<button
|
||
v-for="cap in mcpCapabilities"
|
||
:key="cap.id"
|
||
type="button"
|
||
class="ai-mcp-card"
|
||
:disabled="chatLoading"
|
||
@click="startMcpCapability(cap.id)"
|
||
>
|
||
<span class="ai-mcp-card__title">{{ t(cap.titleKey) }}</span>
|
||
<span class="ai-mcp-card__desc">{{ t(cap.descKey) }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div v-show="isChatting" class="ai-messages is-active" role="log" aria-live="polite">
|
||
<div
|
||
v-for="(msg, idx) in messages"
|
||
:key="idx"
|
||
:class="['ai-msg-row', msg.role === 'user' ? 'ai-msg-row--user' : 'ai-msg-row--assistant']"
|
||
>
|
||
<div class="ai-msg-avatar">
|
||
<NanaAvatar v-if="msg.role === 'assistant'" size="sm" :pulse="false" />
|
||
<template v-else>{{ youInitial }}</template>
|
||
</div>
|
||
<div class="ai-msg-bubble">{{ msg.content }}</div>
|
||
</div>
|
||
<div v-if="chatLoading" class="ai-msg-row ai-msg-row--assistant ai-msg-row--typing">
|
||
<div class="ai-msg-avatar"><NanaAvatar size="sm" :pulse="false" /></div>
|
||
<div class="ai-msg-bubble"><span /><span /><span /></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="isChatting" class="ai-followup is-visible" role="toolbar">
|
||
<button
|
||
v-for="cap in mcpCapabilities"
|
||
:key="cap.id"
|
||
type="button"
|
||
class="ai-followup-chip"
|
||
:disabled="chatLoading"
|
||
@click="startMcpCapability(cap.id)"
|
||
>
|
||
{{ t(cap.titleKey) }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="ai-composer-wrap">
|
||
<div class="ai-composer">
|
||
<form class="ai-composer-form" @submit.prevent="sendMessage">
|
||
<textarea
|
||
ref="inputRef"
|
||
v-model="inputText"
|
||
class="ai-composer-input"
|
||
rows="1"
|
||
maxlength="2000"
|
||
:placeholder="composerPlaceholder"
|
||
:disabled="chatLoading"
|
||
@keydown.enter.exact.prevent="sendMessage"
|
||
@input="resizeInput"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
class="ai-composer-send"
|
||
:aria-label="t('nanaAssistant.send')"
|
||
:disabled="!canSend"
|
||
>
|
||
<icon-arrow-up />
|
||
</button>
|
||
</form>
|
||
<div class="ai-composer-footer">
|
||
<p class="ai-composer-hint">
|
||
{{ providerKey ? t('nanaAssistant.hintLive') : t('nanaAssistant.hint') }}
|
||
</p>
|
||
<button type="button" class="ai-composer-human-btn" @click="openAdvisorView">
|
||
<img :src="logoImg" alt="" class="ai-composer-human-btn__logo" />
|
||
{{ t('nanaAssistant.human') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="ai-history-backdrop" aria-hidden="true" tabindex="-1" @click="historyOpen = false" />
|
||
|
||
<div
|
||
v-if="advisorDrawerOpen"
|
||
class="ai-advisor-drawer-backdrop"
|
||
@click="advisorDrawerOpen = false"
|
||
/>
|
||
<aside
|
||
class="ai-advisor-drawer"
|
||
:class="{ 'is-open': advisorDrawerOpen }"
|
||
:aria-label="t('nanaAssistant.tabAdvisor')"
|
||
>
|
||
<header class="ai-advisor-drawer__head">
|
||
<h3>{{ t('nanaAssistant.tabAdvisor') }}</h3>
|
||
<button type="button" class="ai-advisor-drawer__close" @click="advisorDrawerOpen = false">×</button>
|
||
</header>
|
||
<div class="ai-advisor-drawer__body">
|
||
<div v-if="advisorLoading" class="ai-advisor-drawer__loading">
|
||
<a-spin />
|
||
</div>
|
||
<template v-else-if="advisor.name">
|
||
<div class="ai-advisor-drawer__name">{{ advisor.name }}</div>
|
||
<div class="ai-advisor-drawer__qr-wrap">
|
||
<img
|
||
v-if="advisorQrSrc"
|
||
:src="advisorQrSrc"
|
||
class="ai-advisor-drawer__qr"
|
||
alt=""
|
||
@error="onAdvisorQrError"
|
||
/>
|
||
<div v-else class="ai-advisor-drawer__qr-placeholder">{{ t('nanaAssistant.noWechatQr') }}</div>
|
||
</div>
|
||
<div v-if="advisor.wechatId" class="ai-advisor-drawer__wechat">
|
||
<span class="ai-advisor-drawer__label">{{ t('nanaAssistant.wechatId') }}</span>
|
||
<span class="ai-advisor-drawer__value">{{ advisor.wechatId }}</span>
|
||
<button type="button" class="ai-advisor-drawer__copy" @click="copyWechatId">
|
||
{{ t('nanaAssistant.copyWechat') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<p v-else class="ai-advisor-drawer__empty">{{ t('nanaAssistant.noAdvisor') }}</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { Message } from '@arco-design/web-vue';
|
||
import {
|
||
IconArrowUp,
|
||
IconClockCircle,
|
||
IconCustomerService,
|
||
IconDelete,
|
||
IconFile,
|
||
IconHistory,
|
||
IconLeft,
|
||
IconLocation,
|
||
IconMessage,
|
||
IconPlus,
|
||
IconPublic,
|
||
IconStorage,
|
||
IconUser,
|
||
IconWifi,
|
||
} from '@arco-design/web-vue/es/icon';
|
||
import { chatAiAgent } from '@/api/mcp';
|
||
import { listXhsAiProviders } from '@/api/xhs';
|
||
import { getOrderDetail } from '@/api/order';
|
||
import { CustomerService, type CustomerServiceAdvisorDto } from '@/api/customer';
|
||
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
|
||
import { isValidAvatarUrl } from '@/utils/avatar';
|
||
import {
|
||
getWaybillContextKey,
|
||
getWaybillDisplayNo,
|
||
getWaybillPrefillText,
|
||
getWaybillReturnPath,
|
||
parseWaybillContextFromQuery,
|
||
type WaybillContext,
|
||
} from '@/composables/useAskAdvisor';
|
||
import {
|
||
MCP_CAPABILITY_GUIDES,
|
||
detectMissingFields,
|
||
extractOrderNo,
|
||
getCapability,
|
||
inferCapabilityFromText,
|
||
type McpCapabilityId,
|
||
} from '@/composables/assistantMcpGuide';
|
||
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
|
||
import {
|
||
providerModelTags,
|
||
} from '@/views/system/xhsAiAssistant/providerCatalog';
|
||
import NanaAvatar from './NanaAvatar.vue';
|
||
import logoImg from '@/assets/logo2.png';
|
||
|
||
type QuickTagDef = {
|
||
id: string;
|
||
icon: typeof IconStorage;
|
||
labelKey: string;
|
||
replyKey?: string;
|
||
track?: boolean;
|
||
human?: boolean;
|
||
iconSrc?: string;
|
||
};
|
||
|
||
type ChatMessage = { role: 'user' | 'assistant'; content: string };
|
||
type Thread = {
|
||
id: string;
|
||
title: string;
|
||
updatedAt: number;
|
||
messages: ChatMessage[];
|
||
waybillContext?: WaybillContext;
|
||
};
|
||
|
||
const STORAGE_THREADS = 'oms-ai-threads';
|
||
const STORAGE_ACTIVE_THREAD = 'oms-ai-active-thread';
|
||
|
||
const WECHAT_QR_PLACEHOLDER_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||
<rect fill="#f7f8fa" width="128" height="128" rx="10"/>
|
||
<g fill="#e5e6eb">
|
||
<rect x="28" y="28" width="22" height="22" rx="2"/>
|
||
<rect x="53" y="28" width="22" height="22" rx="2"/>
|
||
<rect x="78" y="28" width="22" height="22" rx="2"/>
|
||
<rect x="28" y="53" width="22" height="22" rx="2"/>
|
||
<rect x="78" y="53" width="22" height="22" rx="2"/>
|
||
<rect x="28" y="78" width="22" height="22" rx="2"/>
|
||
<rect x="53" y="78" width="22" height="22" rx="2"/>
|
||
<rect x="78" y="78" width="22" height="22" rx="2"/>
|
||
</g>
|
||
<rect x="51" y="51" width="26" height="26" rx="3" fill="#c9cdd4" opacity="0.45"/>
|
||
</svg>`,
|
||
)}`;
|
||
|
||
const { t, locale } = useI18n();
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
const historyOpen = ref(false);
|
||
const historySearch = ref('');
|
||
const waybillContext = ref<WaybillContext | null>(null);
|
||
const waybillStatusLabel = ref('');
|
||
const advisorDrawerOpen = ref(false);
|
||
const inputText = ref('');
|
||
const chatLoading = ref(false);
|
||
const providerLoading = ref(false);
|
||
const providerKey = ref('');
|
||
const providerModel = ref('');
|
||
const activeCapability = ref<McpCapabilityId | null>(null);
|
||
const guidedSession = ref(false);
|
||
const threads = ref<Thread[]>([]);
|
||
|
||
const mcpCapabilities = MCP_CAPABILITY_GUIDES;
|
||
const activeThreadId = ref<string | null>(null);
|
||
const messages = ref<ChatMessage[]>([]);
|
||
const mainScrollRef = ref<HTMLElement>();
|
||
const inputRef = ref<HTMLTextAreaElement>();
|
||
|
||
const advisorLoading = ref(false);
|
||
const advisorLoaded = ref(false);
|
||
const advisor = ref<CustomerServiceAdvisorDto>({});
|
||
const advisorQrLoadFailed = ref(false);
|
||
|
||
const quickTagDefs: QuickTagDef[] = [
|
||
{ id: 'small', icon: IconStorage, labelKey: 'nanaAssistant.quickSmall', replyKey: 'nanaAssistant.replySmall' },
|
||
{ id: 'price', icon: IconPublic, labelKey: 'nanaAssistant.quickPrice', replyKey: 'nanaAssistant.replyPrice' },
|
||
{
|
||
id: 'quote-speed',
|
||
icon: IconClockCircle,
|
||
labelKey: 'nanaAssistant.quickQuoteSpeed',
|
||
replyKey: 'nanaAssistant.replyQuoteSpeed',
|
||
},
|
||
{ id: 'track', icon: IconLocation, labelKey: 'nanaAssistant.quickTrack', track: true },
|
||
{
|
||
id: 'visibility',
|
||
icon: IconWifi,
|
||
labelKey: 'nanaAssistant.quickVisibility',
|
||
replyKey: 'nanaAssistant.replyVisibility',
|
||
},
|
||
{
|
||
id: 'after-sales',
|
||
icon: IconCustomerService,
|
||
labelKey: 'nanaAssistant.quickAfterSales',
|
||
replyKey: 'nanaAssistant.replyAfterSales',
|
||
},
|
||
{
|
||
id: 'human',
|
||
icon: IconUser,
|
||
labelKey: 'nanaAssistant.quickHuman',
|
||
human: true,
|
||
iconSrc: logoImg,
|
||
},
|
||
];
|
||
|
||
const quickTags = computed(() =>
|
||
quickTagDefs.map((tag) => ({
|
||
id: tag.id,
|
||
icon: tag.icon,
|
||
iconSrc: tag.iconSrc,
|
||
label: t(tag.labelKey),
|
||
})),
|
||
);
|
||
|
||
const followupTags = computed(() => quickTags.value.slice(0, 5));
|
||
|
||
const sortedThreads = computed(() => [...threads.value].sort((a, b) => b.updatedAt - a.updatedAt));
|
||
|
||
const filteredThreads = computed(() => {
|
||
const q = historySearch.value.trim().toLowerCase();
|
||
if (!q) return sortedThreads.value;
|
||
return sortedThreads.value.filter((thread) => {
|
||
const title = (thread.title || '').toLowerCase();
|
||
const waybill = thread.waybillContext ? getWaybillDisplayNo(thread.waybillContext).toLowerCase() : '';
|
||
return title.includes(q) || waybill.includes(q);
|
||
});
|
||
});
|
||
|
||
const isChatting = computed(
|
||
() => messages.value.some((m) => m.role === 'user') || guidedSession.value,
|
||
);
|
||
|
||
const showWelcome = computed(() => !isChatting.value && !waybillContext.value);
|
||
|
||
const canSend = computed(() => Boolean(inputText.value.trim() && !chatLoading.value));
|
||
|
||
const composerPlaceholder = computed(() => '');
|
||
|
||
const youInitial = computed(() => t('nanaAssistant.you').charAt(0).toUpperCase());
|
||
|
||
const waybillDisplayNo = computed(() =>
|
||
waybillContext.value ? getWaybillDisplayNo(waybillContext.value) : '',
|
||
);
|
||
|
||
function resolveWechatQrUrl(url?: string | null): string {
|
||
if (!url?.trim()) return '';
|
||
const value = url.trim();
|
||
if (isValidAvatarUrl(value)) {
|
||
if (value.indexOf('//') === -1 && !value.startsWith('data:') && !value.startsWith('/')) {
|
||
if (value.indexOf('QiNiuYunFile/') === -1) {
|
||
return `/api/upload/${value}`;
|
||
}
|
||
return `/${value}`;
|
||
}
|
||
return value;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
const advisorQrSrc = computed(() => {
|
||
if (advisorQrLoadFailed.value) return WECHAT_QR_PLACEHOLDER_SVG;
|
||
return resolveWechatQrUrl(advisor.value.wechatQrCode) || '';
|
||
});
|
||
|
||
function onAdvisorQrError() {
|
||
advisorQrLoadFailed.value = true;
|
||
}
|
||
|
||
function getActiveThread() {
|
||
return threads.value.find((thread) => thread.id === activeThreadId.value) || null;
|
||
}
|
||
|
||
function deriveThreadTitle(list: ChatMessage[]) {
|
||
const firstUser = list.find((m) => m.role === 'user');
|
||
if (!firstUser) return t('nanaAssistant.newChat');
|
||
const text = firstUser.content.trim();
|
||
return text.length > 28 ? `${text.slice(0, 28)}…` : text;
|
||
}
|
||
|
||
function formatThreadTime(ts: number) {
|
||
const date = new Date(ts);
|
||
const now = new Date();
|
||
const sameDay =
|
||
date.getFullYear() === now.getFullYear() &&
|
||
date.getMonth() === now.getMonth() &&
|
||
date.getDate() === now.getDate();
|
||
const localeTag = locale.value.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||
if (sameDay) {
|
||
return date.toLocaleTimeString(localeTag, { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
return date.toLocaleDateString(localeTag, { month: 'short', day: 'numeric' });
|
||
}
|
||
|
||
function newThreadId() {
|
||
return `t_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function applyThreadMessages(thread: Thread | null) {
|
||
messages.value = thread?.messages ? thread.messages.map((msg) => ({ ...msg })) : [];
|
||
}
|
||
|
||
function syncActiveThreadMessages() {
|
||
const thread = getActiveThread();
|
||
if (!thread) return;
|
||
thread.messages = messages.value.map((msg) => ({ ...msg }));
|
||
thread.updatedAt = Date.now();
|
||
if (thread.messages.some((m) => m.role === 'user')) {
|
||
thread.title = deriveThreadTitle(thread.messages);
|
||
}
|
||
}
|
||
|
||
function saveThreads() {
|
||
syncActiveThreadMessages();
|
||
localStorage.setItem(STORAGE_THREADS, JSON.stringify(threads.value));
|
||
if (activeThreadId.value) {
|
||
localStorage.setItem(STORAGE_ACTIVE_THREAD, activeThreadId.value);
|
||
}
|
||
}
|
||
|
||
function createThread(setActive = true) {
|
||
const thread: Thread = {
|
||
id: newThreadId(),
|
||
title: t('nanaAssistant.newChat'),
|
||
updatedAt: Date.now(),
|
||
messages: [],
|
||
};
|
||
threads.value.unshift(thread);
|
||
if (setActive) {
|
||
activeThreadId.value = thread.id;
|
||
applyThreadMessages(thread);
|
||
}
|
||
saveThreads();
|
||
return thread;
|
||
}
|
||
|
||
function loadThreads() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_THREADS);
|
||
threads.value = raw ? JSON.parse(raw) : [];
|
||
if (!Array.isArray(threads.value)) threads.value = [];
|
||
} catch {
|
||
threads.value = [];
|
||
}
|
||
|
||
activeThreadId.value = localStorage.getItem(STORAGE_ACTIVE_THREAD);
|
||
|
||
if (threads.value.length === 0) {
|
||
createThread(true);
|
||
return;
|
||
}
|
||
|
||
if (!activeThreadId.value || !threads.value.some((thread) => thread.id === activeThreadId.value)) {
|
||
activeThreadId.value = threads.value[0].id;
|
||
}
|
||
|
||
applyThreadMessages(getActiveThread());
|
||
}
|
||
|
||
function switchThread(threadId: string) {
|
||
if (chatLoading.value || threadId === activeThreadId.value) {
|
||
historyOpen.value = false;
|
||
return;
|
||
}
|
||
syncActiveThreadMessages();
|
||
activeThreadId.value = threadId;
|
||
applyThreadMessages(getActiveThread());
|
||
waybillContext.value = getActiveThread()?.waybillContext ?? null;
|
||
void refreshWaybillStatusLabel();
|
||
saveThreads();
|
||
advisorDrawerOpen.value = false;
|
||
historyOpen.value = false;
|
||
scrollToBottom();
|
||
focusInput();
|
||
}
|
||
|
||
function deleteThread(threadId: string) {
|
||
if (chatLoading.value) return;
|
||
const idx = threads.value.findIndex((thread) => thread.id === threadId);
|
||
if (idx === -1) return;
|
||
threads.value.splice(idx, 1);
|
||
if (activeThreadId.value === threadId) {
|
||
if (threads.value.length === 0) {
|
||
createThread(true);
|
||
waybillContext.value = parseWaybillContextFromQuery(route.query);
|
||
void refreshWaybillStatusLabel();
|
||
} else {
|
||
switchThread(threads.value[0].id);
|
||
return;
|
||
}
|
||
}
|
||
saveThreads();
|
||
}
|
||
|
||
function startNewChat() {
|
||
if (chatLoading.value) return;
|
||
syncActiveThreadMessages();
|
||
activeCapability.value = null;
|
||
guidedSession.value = false;
|
||
const current = getActiveThread();
|
||
if (current && current.messages.length === 0) {
|
||
historyOpen.value = false;
|
||
focusInput();
|
||
return;
|
||
}
|
||
createThread(true);
|
||
const ctx = parseWaybillContextFromQuery(route.query);
|
||
if (ctx) {
|
||
const thread = getActiveThread();
|
||
if (thread) {
|
||
thread.waybillContext = { ...ctx };
|
||
thread.title = getWaybillDisplayNo(ctx) || t('nanaAssistant.newChat');
|
||
}
|
||
waybillContext.value = ctx;
|
||
inputText.value = getWaybillPrefillText(ctx);
|
||
void enrichWaybillContext(ctx);
|
||
void maybeAutoAskWaybillTrack(true);
|
||
} else {
|
||
waybillContext.value = null;
|
||
waybillStatusLabel.value = '';
|
||
if (route.path.startsWith('/assistant') && Object.keys(route.query).length > 0) {
|
||
router.replace({ path: '/assistant' });
|
||
}
|
||
}
|
||
advisorDrawerOpen.value = false;
|
||
historyOpen.value = false;
|
||
scrollToBottom();
|
||
focusInput();
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
if (mainScrollRef.value) {
|
||
mainScrollRef.value.scrollTop = mainScrollRef.value.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 微信/手机自动聚焦会唤起键盘并放大页面,切 Tab 后缩放常残留 */
|
||
function shouldSkipAutoFocus() {
|
||
return isWeChatBrowser() || isMobileUserAgent();
|
||
}
|
||
|
||
function focusInput(options?: { force?: boolean }) {
|
||
if (!options?.force && shouldSkipAutoFocus()) return;
|
||
nextTick(() => {
|
||
inputRef.value?.focus({ preventScroll: true });
|
||
});
|
||
}
|
||
|
||
function blurInput() {
|
||
inputRef.value?.blur();
|
||
const active = document.activeElement;
|
||
if (active instanceof HTMLElement) active.blur();
|
||
}
|
||
|
||
function resizeInput() {
|
||
const el = inputRef.value;
|
||
if (!el) return;
|
||
el.style.height = 'auto';
|
||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||
}
|
||
|
||
function ensureWelcomeMessage() {
|
||
if (messages.value.length > 0) return;
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
content: t('nanaAssistant.welcomeMsg'),
|
||
});
|
||
saveThreads();
|
||
}
|
||
|
||
async function loadAdvisor() {
|
||
if (advisorLoading.value) return;
|
||
advisorLoading.value = true;
|
||
advisorQrLoadFailed.value = false;
|
||
try {
|
||
advisor.value = await CustomerService.getServiceAdvisor();
|
||
advisorLoaded.value = true;
|
||
} catch {
|
||
advisor.value = {};
|
||
advisorLoaded.value = true;
|
||
} finally {
|
||
advisorLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function openAdvisorView() {
|
||
advisorDrawerOpen.value = true;
|
||
if (!advisorLoaded.value) {
|
||
void loadAdvisor();
|
||
}
|
||
}
|
||
|
||
function copyWechatId() {
|
||
const id = advisor.value.wechatId?.trim();
|
||
if (!id) return;
|
||
navigator.clipboard
|
||
.writeText(id)
|
||
.then(() => {
|
||
Message.success(t('nanaAssistant.copyWechatSuccess'));
|
||
})
|
||
.catch(() => {
|
||
Message.warning(t('nanaAssistant.copyWechatFailed'));
|
||
});
|
||
}
|
||
|
||
function goToWaybill() {
|
||
const path = getWaybillReturnPath(waybillContext.value);
|
||
if (path.startsWith('/h5')) {
|
||
router.replace(path);
|
||
return;
|
||
}
|
||
router.push(path);
|
||
}
|
||
|
||
function clearWaybillContext() {
|
||
waybillContext.value = null;
|
||
waybillStatusLabel.value = '';
|
||
const thread = getActiveThread();
|
||
if (thread) {
|
||
delete thread.waybillContext;
|
||
saveThreads();
|
||
}
|
||
const path = route.path.startsWith('/h5') ? '/h5/assistant' : '/assistant';
|
||
router.replace({ path });
|
||
}
|
||
|
||
function resolveStatusLabel(status?: string) {
|
||
if (!status) return '';
|
||
const meta = getOrderStatusMeta(status);
|
||
return meta ? t(meta.labelKey) : status;
|
||
}
|
||
|
||
async function enrichWaybillContext(ctx: WaybillContext) {
|
||
if (ctx.orderStatus) {
|
||
waybillStatusLabel.value = resolveStatusLabel(ctx.orderStatus);
|
||
return;
|
||
}
|
||
if (!ctx.orderId) return;
|
||
try {
|
||
const detail = await getOrderDetail(ctx.orderId);
|
||
const code = resolveOrderStatusCode(detail);
|
||
if (!code) return;
|
||
ctx.orderStatus = code;
|
||
const thread = getActiveThread();
|
||
if (thread?.waybillContext) {
|
||
thread.waybillContext.orderStatus = code;
|
||
}
|
||
waybillStatusLabel.value = resolveStatusLabel(code);
|
||
saveThreads();
|
||
} catch {
|
||
waybillStatusLabel.value = '';
|
||
}
|
||
}
|
||
|
||
async function refreshWaybillStatusLabel() {
|
||
if (!waybillContext.value) {
|
||
waybillStatusLabel.value = '';
|
||
return;
|
||
}
|
||
await enrichWaybillContext(waybillContext.value);
|
||
}
|
||
|
||
function resolveTagReply(tagId: string) {
|
||
const def = quickTagDefs.find((tag) => tag.id === tagId);
|
||
if (!def) return t('nanaAssistant.emptyReply');
|
||
if (def.track && waybillContext.value) {
|
||
return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value });
|
||
}
|
||
if (def.track) return t('nanaAssistant.trackHint');
|
||
if (def.replyKey) return t(def.replyKey);
|
||
return t('nanaAssistant.emptyReply');
|
||
}
|
||
|
||
function isTrackQuestion(text: string) {
|
||
const lower = text.toLowerCase();
|
||
return /track|tracking|物流|单号|追踪|轨迹/.test(lower) || Boolean(extractOrderNo(text));
|
||
}
|
||
|
||
/** 未接 AI 时的诚实提示,不再假装已理解并乱要「起运地」 */
|
||
function offlineReply(userText: string) {
|
||
if (waybillContext.value && isTrackQuestion(userText)) {
|
||
return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value });
|
||
}
|
||
if (providerLoading.value) return t('nanaAssistant.providerLoading');
|
||
return t('nanaAssistant.aiUnavailable');
|
||
}
|
||
|
||
function demoReplyDelay() {
|
||
return 400 + Math.random() * 200;
|
||
}
|
||
|
||
async function appendDemoReply(replyText: string) {
|
||
await new Promise((resolve) => window.setTimeout(resolve, demoReplyDelay()));
|
||
messages.value.push({ role: 'assistant', content: replyText });
|
||
saveThreads();
|
||
}
|
||
|
||
async function loadProvider() {
|
||
providerLoading.value = true;
|
||
try {
|
||
const items = await listXhsAiProviders();
|
||
const enabled = (items ?? []).filter((p: any) => p.enabled && p.apiKey);
|
||
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 : '') || '';
|
||
} else {
|
||
providerModel.value = '';
|
||
}
|
||
} catch {
|
||
providerKey.value = '';
|
||
providerModel.value = '';
|
||
} finally {
|
||
providerLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const text = inputText.value.trim();
|
||
if (!text || chatLoading.value) return;
|
||
await sendUserText(text);
|
||
}
|
||
|
||
async function askWaybillTrack() {
|
||
if (chatLoading.value || !waybillContext.value) return;
|
||
activeCapability.value = 'track';
|
||
const text = t('nanaAssistant.waybillAutoAsk', { no: waybillDisplayNo.value });
|
||
await sendUserText(text);
|
||
}
|
||
|
||
/** 点选三大能力:先给引导话术 + 预填草稿,等客户补全再发 */
|
||
async function startMcpCapability(id: McpCapabilityId) {
|
||
if (chatLoading.value) return;
|
||
const cap = getCapability(id);
|
||
activeCapability.value = id;
|
||
|
||
if (id === 'track' && waybillContext.value) {
|
||
await askWaybillTrack();
|
||
return;
|
||
}
|
||
|
||
if (!isChatting.value && messages.value.length === 0) {
|
||
ensureWelcomeMessage();
|
||
}
|
||
|
||
const coach = t(cap.coachKey);
|
||
messages.value.push({ role: 'assistant', content: coach });
|
||
guidedSession.value = true;
|
||
saveThreads();
|
||
inputText.value = '';
|
||
await nextTick();
|
||
resizeInput();
|
||
scrollToBottom();
|
||
focusInput({ force: true });
|
||
}
|
||
|
||
async function sendQuickTag(tag: { id: string; label: string }) {
|
||
if (chatLoading.value) return;
|
||
const def = quickTagDefs.find((item) => item.id === tag.id);
|
||
if (def?.human) {
|
||
openAdvisorView();
|
||
return;
|
||
}
|
||
|
||
if (providerKey.value) {
|
||
if (tag.id === 'price' || tag.id === 'quote-speed') {
|
||
await startMcpCapability('price');
|
||
return;
|
||
}
|
||
if (tag.id === 'track') {
|
||
await startMcpCapability('track');
|
||
return;
|
||
}
|
||
if (tag.id === 'small') {
|
||
await startMcpCapability('guide');
|
||
return;
|
||
}
|
||
}
|
||
|
||
await sendUserText(tag.label, resolveTagReply(tag.id));
|
||
}
|
||
|
||
function buildAgentMessages(userFacingMessages: ChatMessage[]) {
|
||
const list: { role: string; content: string }[] = [];
|
||
if (waybillContext.value) {
|
||
list.push({
|
||
role: 'system',
|
||
content: `用户当前关注运单号:${waybillDisplayNo.value}。查轨迹时可直接使用该单号调用 oms.track_query。`,
|
||
});
|
||
}
|
||
|
||
const recentUserBlob = userFacingMessages
|
||
.filter((m) => m.role === 'user')
|
||
.slice(-4)
|
||
.map((m) => m.content)
|
||
.join('\n');
|
||
const hintedOrderNo = extractOrderNo(recentUserBlob);
|
||
if (hintedOrderNo && !waybillContext.value) {
|
||
list.push({
|
||
role: 'system',
|
||
content: `用户消息中已出现单号「${hintedOrderNo}」。若在查轨迹,请立即用该单号调用 oms.track_query,不要再向用户索要单号。`,
|
||
});
|
||
}
|
||
|
||
if (activeCapability.value) {
|
||
const cap = getCapability(activeCapability.value);
|
||
list.push({
|
||
role: 'system',
|
||
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
|
||
});
|
||
}
|
||
// 只带最近几轮,避免历史把 token 打爆
|
||
const dialog = userFacingMessages
|
||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||
.slice(-8)
|
||
.map((m) => ({
|
||
role: m.role,
|
||
content: m.content.length > 1000 ? `${m.content.slice(0, 1000)}…` : m.content,
|
||
}));
|
||
list.push(...dialog);
|
||
return list;
|
||
}
|
||
|
||
function buildMissingCoach(capability: McpCapabilityId, missingKeys: string[]) {
|
||
const cap = getCapability(capability);
|
||
const labels = missingKeys
|
||
.map((key) => {
|
||
const field = cap.fields.find((f) => f.key === key);
|
||
return field ? t(field.labelKey) : key;
|
||
})
|
||
.filter(Boolean);
|
||
if (!labels.length) return '';
|
||
return t('nanaAssistant.mcp.missingAsk', { fields: labels.join('、') });
|
||
}
|
||
|
||
async function sendUserText(text: string, presetReply?: string) {
|
||
if (!isChatting.value && messages.value.length === 0 && !waybillContext.value) {
|
||
ensureWelcomeMessage();
|
||
}
|
||
|
||
const inferred = inferCapabilityFromText(text) || activeCapability.value;
|
||
if (inferred) activeCapability.value = inferred;
|
||
|
||
messages.value.push({ role: 'user', content: text });
|
||
saveThreads();
|
||
inputText.value = '';
|
||
resizeInput();
|
||
chatLoading.value = true;
|
||
scrollToBottom();
|
||
|
||
try {
|
||
if (presetReply !== undefined) {
|
||
await appendDemoReply(presetReply);
|
||
return;
|
||
}
|
||
|
||
const priorTexts = messages.value
|
||
.filter((m) => m.role === 'user')
|
||
.slice(-5)
|
||
.map((m) => m.content);
|
||
|
||
// 仅在「明显缺必填」时本地追问;多轮上下文合并判断,避免反复要已经给过的信息
|
||
if (providerKey.value && inferred) {
|
||
const missing = detectMissingFields(inferred, text, {
|
||
hasWaybillContext: Boolean(waybillContext.value),
|
||
priorTexts: priorTexts.slice(0, -1),
|
||
});
|
||
if (missing.length) {
|
||
const coach = buildMissingCoach(inferred, missing);
|
||
await appendDemoReply(coach || t(getCapability(inferred).coachKey));
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (providerKey.value) {
|
||
const res = await chatAiAgent({
|
||
providerKey: providerKey.value,
|
||
model: providerModel.value || undefined,
|
||
messages: buildAgentMessages(messages.value),
|
||
maxToolRounds: 2,
|
||
});
|
||
let content = String(res.content || '').trim();
|
||
if (!content && res.toolTraces?.length) {
|
||
const ok = res.toolTraces.filter((x) => x.success).map((x) => x.toolKey).join('、');
|
||
content = ok
|
||
? t('nanaAssistant.toolDoneEmpty', { tools: ok })
|
||
: t('nanaAssistant.emptyReply');
|
||
}
|
||
if (!content) content = t('nanaAssistant.emptyReply');
|
||
messages.value.push({ role: 'assistant', content });
|
||
saveThreads();
|
||
return;
|
||
}
|
||
|
||
await appendDemoReply(offlineReply(text));
|
||
} catch (e: any) {
|
||
if (presetReply !== undefined) return;
|
||
const errMsg = String(e?.message || '').trim();
|
||
Message.warning(errMsg || t('nanaAssistant.sendFailed'));
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
content: errMsg ? t('nanaAssistant.sendFailedDetail', { msg: errMsg }) : t('nanaAssistant.sendFailed'),
|
||
});
|
||
saveThreads();
|
||
} finally {
|
||
chatLoading.value = false;
|
||
scrollToBottom();
|
||
focusInput({ force: true });
|
||
}
|
||
}
|
||
|
||
async function maybeAutoAskWaybillTrack(isNewThread: boolean) {
|
||
if (!isNewThread || !waybillContext.value) return;
|
||
if (messages.value.some((m) => m.role === 'user')) return;
|
||
await askWaybillTrack();
|
||
}
|
||
|
||
function goBack() {
|
||
syncActiveThreadMessages();
|
||
saveThreads();
|
||
historyOpen.value = false;
|
||
advisorDrawerOpen.value = false;
|
||
if (window.history.length > 1) {
|
||
router.back();
|
||
return;
|
||
}
|
||
if (route.path.startsWith('/h5')) {
|
||
const returnPath = getWaybillReturnPath(waybillContext.value);
|
||
router.replace(returnPath.startsWith('/h5') ? returnPath : '/h5/orders');
|
||
return;
|
||
}
|
||
router.push('/home/workplace');
|
||
}
|
||
|
||
async function applyWaybillContextFromRoute() {
|
||
const ctx = parseWaybillContextFromQuery(route.query);
|
||
if (!ctx) {
|
||
waybillContext.value = getActiveThread()?.waybillContext ?? null;
|
||
await refreshWaybillStatusLabel();
|
||
return;
|
||
}
|
||
|
||
const key = getWaybillContextKey(ctx);
|
||
const existing = threads.value.find(
|
||
(thread) => thread.waybillContext && getWaybillContextKey(thread.waybillContext) === key,
|
||
);
|
||
|
||
syncActiveThreadMessages();
|
||
|
||
let isNewThread = false;
|
||
if (existing) {
|
||
activeThreadId.value = existing.id;
|
||
applyThreadMessages(existing);
|
||
} else {
|
||
isNewThread = true;
|
||
createThread(true);
|
||
const thread = getActiveThread();
|
||
if (thread) {
|
||
thread.waybillContext = { ...ctx };
|
||
thread.title = getWaybillDisplayNo(ctx) || t('nanaAssistant.newChat');
|
||
messages.value = [];
|
||
}
|
||
}
|
||
|
||
waybillContext.value = ctx;
|
||
inputText.value = getWaybillPrefillText(ctx);
|
||
advisorDrawerOpen.value = false;
|
||
historyOpen.value = false;
|
||
saveThreads();
|
||
await enrichWaybillContext(ctx);
|
||
resizeInput();
|
||
scrollToBottom();
|
||
focusInput();
|
||
if (isNewThread) {
|
||
await maybeAutoAskWaybillTrack(true);
|
||
}
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
if (advisorDrawerOpen.value) {
|
||
advisorDrawerOpen.value = false;
|
||
return;
|
||
}
|
||
goBack();
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => messages.value.length,
|
||
() => scrollToBottom(),
|
||
);
|
||
|
||
watch(
|
||
() => advisor.value.wechatQrCode,
|
||
() => {
|
||
advisorQrLoadFailed.value = false;
|
||
},
|
||
);
|
||
|
||
function isAdvisorRoute() {
|
||
return route.path === '/assistant' || route.path.startsWith('/h5/assistant');
|
||
}
|
||
|
||
const prefillRouteApplied = ref(false);
|
||
let prefillApplying = false;
|
||
|
||
function takeQueryString(key: string): string {
|
||
const raw = route.query[key];
|
||
const text = Array.isArray(raw) ? raw[0] : raw;
|
||
return typeof text === 'string' ? text.trim() : '';
|
||
}
|
||
|
||
async function clearPrefillQueryFlags() {
|
||
if (!route.query.prefill && !route.query.auto && !route.query.from) return;
|
||
const nextQuery = { ...route.query } as Record<string, unknown>;
|
||
delete nextQuery.prefill;
|
||
delete nextQuery.auto;
|
||
delete nextQuery.from;
|
||
try {
|
||
await router.replace({ path: route.path, query: nextQuery as any });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function applyPrefillFromRoute() {
|
||
if (parseWaybillContextFromQuery(route.query)) return;
|
||
const text = takeQueryString('prefill');
|
||
if (!text || prefillApplying) return;
|
||
if (inputText.value.trim() && prefillRouteApplied.value) return;
|
||
|
||
prefillApplying = true;
|
||
try {
|
||
const intent = takeQueryString('intent');
|
||
if (intent === 'guide' || intent === 'price' || intent === 'track') {
|
||
activeCapability.value = intent;
|
||
}
|
||
|
||
// 从查价跳入:已有对话时开新会话,避免上下文串台
|
||
if (takeQueryString('from') === 'priceCheck' && messages.value.some((m) => m.role === 'user')) {
|
||
startNewChat();
|
||
activeCapability.value =
|
||
intent === 'guide' || intent === 'price' || intent === 'track' ? intent : activeCapability.value;
|
||
}
|
||
|
||
const shouldAuto = takeQueryString('auto') === '1';
|
||
// 默认不预填输入框,避免占位文案杂乱;仅 auto 发送时写入
|
||
if (shouldAuto && !inputText.value.trim()) {
|
||
inputText.value = text;
|
||
}
|
||
prefillRouteApplied.value = true;
|
||
|
||
await nextTick();
|
||
resizeInput();
|
||
if (!shouldSkipAutoFocus()) focusInput();
|
||
|
||
if (shouldAuto && canSend.value) {
|
||
await clearPrefillQueryFlags();
|
||
await sendMessage();
|
||
}
|
||
} finally {
|
||
prefillApplying = false;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => route.query,
|
||
() => {
|
||
if (!isAdvisorRoute()) return;
|
||
if (takeQueryString('prefill')) prefillRouteApplied.value = false;
|
||
void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute());
|
||
},
|
||
{ deep: true },
|
||
);
|
||
|
||
onMounted(() => {
|
||
loadThreads();
|
||
loadProvider();
|
||
void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute());
|
||
window.addEventListener('keydown', onKeydown);
|
||
// 手机/微信:禁止进页自动聚焦,避免整页被放大
|
||
if (!shouldSkipAutoFocus() && !parseWaybillContextFromQuery(route.query)) {
|
||
nextTick(() => focusInput());
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', onKeydown);
|
||
blurInput();
|
||
syncActiveThreadMessages();
|
||
saveThreads();
|
||
});
|
||
|
||
defineExpose({
|
||
setInputText(text: string) {
|
||
inputText.value = String(text || '').trim();
|
||
nextTick(() => {
|
||
resizeInput();
|
||
focusInput();
|
||
});
|
||
},
|
||
clearWaybillContext,
|
||
});
|
||
</script>
|
||
|
||
<style src="./ai-advisor.css"></style>
|