front/src/components/NanaAssistant/AiAdvisorShell.vue
zzh bd97ed76fd feat: ?????????????????/???????
??????????????????????????????? H5/?????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:16:55 +08:00

1217 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>