|
@@ -65,7 +65,7 @@
|
|
|
preRecoveryPage: null,
|
|
preRecoveryPage: null,
|
|
|
selectedGroupIds: new Set(),
|
|
selectedGroupIds: new Set(),
|
|
|
customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
|
|
customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
|
|
|
- portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
|
|
|
|
|
|
|
+ portraits: { lastPortrait: null, lastTags: [], personalLabels: [], personalLabelsLoaded: false, directoryCustomers: [], dashboardSnapshot: null, loadingPromise: null, lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
|
|
|
agent: {
|
|
agent: {
|
|
|
status: initialAgentSnapshot.status || null,
|
|
status: initialAgentSnapshot.status || null,
|
|
|
conversations: Array.isArray(initialAgentSnapshot.conversations) ? initialAgentSnapshot.conversations : [],
|
|
conversations: Array.isArray(initialAgentSnapshot.conversations) ? initialAgentSnapshot.conversations : [],
|
|
@@ -79,11 +79,14 @@
|
|
|
signature: initialAgentSnapshot.signature || null,
|
|
signature: initialAgentSnapshot.signature || null,
|
|
|
replyEdits: {},
|
|
replyEdits: {},
|
|
|
groupReplyEdits: {},
|
|
groupReplyEdits: {},
|
|
|
|
|
+ voiceTone: 'auto',
|
|
|
|
|
+ renderPending: false,
|
|
|
|
|
+ insightInteractionUntil: 0,
|
|
|
cachedAccountKey: initialAgentSnapshot.accountKey || null
|
|
cachedAccountKey: initialAgentSnapshot.accountKey || null
|
|
|
},
|
|
},
|
|
|
skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
|
|
skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
|
|
|
knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
|
|
knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
|
|
|
- transfers: { lastTransferPreview: null, candidates: [], groups: [], ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
|
|
|
|
|
|
|
+ transfers: { lastTransferPreview: null, candidates: [], groups: [], dashboardSnapshot: null, loadingPromise: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const els = {
|
|
const els = {
|
|
@@ -192,6 +195,9 @@
|
|
|
state.agent.signature = null;
|
|
state.agent.signature = null;
|
|
|
state.agent.replyEdits = {};
|
|
state.agent.replyEdits = {};
|
|
|
state.agent.groupReplyEdits = {};
|
|
state.agent.groupReplyEdits = {};
|
|
|
|
|
+ state.agent.voiceTone = 'auto';
|
|
|
|
|
+ state.agent.renderPending = false;
|
|
|
|
|
+ state.agent.insightInteractionUntil = 0;
|
|
|
state.agent.cachedAccountKey = null;
|
|
state.agent.cachedAccountKey = null;
|
|
|
try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
|
|
try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
|
|
|
}
|
|
}
|
|
@@ -382,6 +388,7 @@
|
|
|
<div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
|
|
<div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
|
|
|
${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
|
|
${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
|
|
|
${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
|
|
${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
|
|
|
|
|
+ ${options.dontAskAgainLabel ? `<label class="business-modal-dont-ask"><input type="checkbox" name="dontAskAgain" value="1" /><span>${escapeHtml(options.dontAskAgainLabel)}</span></label>` : ''}
|
|
|
<p class="business-modal-error" data-business-error role="alert" hidden></p>
|
|
<p class="business-modal-error" data-business-error role="alert" hidden></p>
|
|
|
<footer class="business-modal-actions">${options.cancelText !== null ? `<button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button>` : ''}<button type="submit" class="btn business-modal-confirm ${options.danger ? 'danger' : ''}" data-business-primary>${escapeHtml(options.confirmText || '确认')}</button></footer>
|
|
<footer class="business-modal-actions">${options.cancelText !== null ? `<button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button>` : ''}<button type="submit" class="btn business-modal-confirm ${options.danger ? 'danger' : ''}" data-business-primary>${escapeHtml(options.confirmText || '确认')}</button></footer>
|
|
|
</form>
|
|
</form>
|
|
@@ -471,7 +478,19 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const CONFIRM_DISMISS_PREFIX = 'qiwei:confirm-dismiss:';
|
|
|
|
|
+
|
|
|
|
|
+ function isConfirmDismissed(key) {
|
|
|
|
|
+ try { return localStorage.getItem(`${CONFIRM_DISMISS_PREFIX}${key}`) === '1'; } catch { return false; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function dismissConfirm(key) {
|
|
|
|
|
+ try { localStorage.setItem(`${CONFIRM_DISMISS_PREFIX}${key}`, '1'); } catch {}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function confirmModal(message, options = {}) {
|
|
function confirmModal(message, options = {}) {
|
|
|
|
|
+ const dismissKey = String(options.dismissKey || '').trim();
|
|
|
|
|
+ if (dismissKey && isConfirmDismissed(dismissKey)) return Promise.resolve(true);
|
|
|
return showBusinessModal({
|
|
return showBusinessModal({
|
|
|
title: options.title || '确认操作',
|
|
title: options.title || '确认操作',
|
|
|
description: message,
|
|
description: message,
|
|
@@ -480,7 +499,12 @@
|
|
|
warning: options.warning,
|
|
warning: options.warning,
|
|
|
warningTitle: options.warningTitle,
|
|
warningTitle: options.warningTitle,
|
|
|
danger: options.danger,
|
|
danger: options.danger,
|
|
|
- }).then(result => result !== null);
|
|
|
|
|
|
|
+ dontAskAgainLabel: dismissKey ? (options.dismissLabel || '以后不再提示') : null,
|
|
|
|
|
+ }).then(result => {
|
|
|
|
|
+ if (result === null) return false;
|
|
|
|
|
+ if (dismissKey && result.dontAskAgain) dismissConfirm(dismissKey);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function alertModal(message, options = {}) {
|
|
function alertModal(message, options = {}) {
|
|
@@ -1328,7 +1352,7 @@
|
|
|
const highAlerts = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.highAlerts || 0), 0);
|
|
const highAlerts = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.highAlerts || 0), 0);
|
|
|
const actionCount = pendingReplies + Number(group.pendingReview || 0) + Number(group.highRisk || 0) + openCustomerTasks;
|
|
const actionCount = pendingReplies + Number(group.pendingReview || 0) + Number(group.highRisk || 0) + openCustomerTasks;
|
|
|
const connectionOnline = Boolean(agent.account?.online || currentAccount()?.online);
|
|
const connectionOnline = Boolean(agent.account?.online || currentAccount()?.online);
|
|
|
- const listenerRunning = Boolean(agent.listener?.running);
|
|
|
|
|
|
|
+ const listenerRunning = Boolean(agent.ingress?.running ?? agent.listener?.running);
|
|
|
const healthScore = group.averageHealthScore;
|
|
const healthScore = group.averageHealthScore;
|
|
|
const query = state.overview.query || '';
|
|
const query = state.overview.query || '';
|
|
|
|
|
|
|
@@ -2387,9 +2411,23 @@
|
|
|
function renderAgentMessage(message) {
|
|
function renderAgentMessage(message) {
|
|
|
const roleLabel = message.senderName || (message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议');
|
|
const roleLabel = message.senderName || (message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议');
|
|
|
const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
|
|
const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
|
|
|
- return `<div class="agent-message ${escapeHtml(message.role)}">
|
|
|
|
|
|
|
+ const voiceMeta = message.raw?.msgType === 16 ? message.raw : null;
|
|
|
|
|
+ const voiceAudio = voiceMeta?.audioAvailable && message.id ? `/api/agent/messages/${encodeURIComponent(message.id)}/voice-audio` : null;
|
|
|
|
|
+ const voiceTranscript = voiceMeta ? String(message.content || '').replace(/^\[语音[^\]]*\]\s*/, '') : '';
|
|
|
|
|
+ const transcriptId = voiceMeta && message.id ? `voice-transcript-${String(message.id).replace(/[^a-zA-Z0-9_-]/g, '')}` : '';
|
|
|
|
|
+ const voiceDuration = Math.max(1, Number(voiceMeta?.duration) || 1);
|
|
|
|
|
+ const voiceBubbleWidth = Math.min(232, Math.max(118, Math.round(112 + voiceDuration * 2)));
|
|
|
|
|
+ return `<div class="agent-message ${escapeHtml(message.role)}" data-message-id="${escapeHtml(message.id || '')}">
|
|
|
<div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
|
|
<div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
|
|
|
- <div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>
|
|
|
|
|
|
|
+ ${voiceAudio ? `<div class="agent-message-voice-row">
|
|
|
|
|
+ <button type="button" class="agent-voice-transcript-toggle" data-voice-transcript-toggle aria-expanded="false" aria-controls="${escapeHtml(transcriptId)}">${voiceIcon('text')}<span>转文字</span></button>
|
|
|
|
|
+ <button type="button" class="agent-voice-bubble" data-voice-play aria-pressed="false" style="--voice-bubble-width:${voiceBubbleWidth}px" aria-label="播放语音,时长 ${Math.ceil(voiceDuration)} 秒">
|
|
|
|
|
+ <span class="agent-voice-play-icon" aria-hidden="true">${voiceIcon('volume')}</span>
|
|
|
|
|
+ <span class="agent-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
|
|
|
+ <span class="agent-voice-duration">${Math.ceil(voiceDuration)}″</span>
|
|
|
|
|
+ <audio class="agent-voice-audio" src="${escapeHtml(voiceAudio)}" preload="metadata"></audio>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div><div class="agent-voice-transcript" id="${escapeHtml(transcriptId)}" hidden>${escapeHtml(voiceTranscript).replace(/\n/g, '<br>')}</div>` : `<div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>`}
|
|
|
</div>`;
|
|
</div>`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2586,6 +2624,109 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function captureAgentWorkspaceScroll(page) {
|
|
|
|
|
+ const list = page.querySelector('.agent-list-scroll');
|
|
|
|
|
+ const stream = page.querySelector('.agent-message-stream');
|
|
|
|
|
+ return {
|
|
|
|
|
+ viewKey: page.dataset.agentViewKey || '',
|
|
|
|
|
+ listTop: list?.scrollTop || 0,
|
|
|
|
|
+ messageTop: stream?.scrollTop || 0,
|
|
|
|
|
+ messageAtBottom: stream
|
|
|
|
|
+ ? stream.scrollHeight - stream.scrollTop - stream.clientHeight <= 48
|
|
|
|
|
+ : true,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function restoreAgentWorkspaceScroll(page, snapshot, viewKey, incremental) {
|
|
|
|
|
+ if (!incremental || !snapshot || snapshot.viewKey !== viewKey) {
|
|
|
|
|
+ scrollAgentMessagesToLatest(page);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ const list = page.querySelector('.agent-list-scroll');
|
|
|
|
|
+ const stream = page.querySelector('.agent-message-stream');
|
|
|
|
|
+ if (list) list.scrollTop = Math.min(snapshot.listTop, Math.max(0, list.scrollHeight - list.clientHeight));
|
|
|
|
|
+ if (!stream) return;
|
|
|
|
|
+ stream.scrollTop = snapshot.messageAtBottom
|
|
|
|
|
+ ? stream.scrollHeight
|
|
|
|
|
+ : Math.min(snapshot.messageTop, Math.max(0, stream.scrollHeight - stream.clientHeight));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function agentDomNodeKey(node) {
|
|
|
|
|
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
|
|
|
+ for (const attribute of ['id', 'data-message-id', 'data-conversation-id', 'data-group-monitor-id']) {
|
|
|
|
|
+ const value = node.getAttribute(attribute);
|
|
|
|
|
+ if (value) return `${node.tagName}:${attribute}:${value}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function agentDomNodesCompatible(current, next) {
|
|
|
|
|
+ return current?.nodeType === next?.nodeType
|
|
|
|
|
+ && (current.nodeType !== Node.ELEMENT_NODE || current.tagName === next.tagName);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function syncAgentDomAttributes(current, next) {
|
|
|
|
|
+ for (const attribute of [...current.attributes]) {
|
|
|
|
|
+ if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const attribute of [...next.attributes]) {
|
|
|
|
|
+ if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (current instanceof HTMLTextAreaElement && document.activeElement !== current && current.value !== next.value) {
|
|
|
|
|
+ current.value = next.value;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (current instanceof HTMLInputElement && document.activeElement !== current && current.value !== next.value) {
|
|
|
|
|
+ current.value = next.value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function patchAgentDomNode(current, next) {
|
|
|
|
|
+ if (!agentDomNodesCompatible(current, next)) {
|
|
|
|
|
+ current.replaceWith(next.cloneNode(true));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (current.nodeType === Node.TEXT_NODE || current.nodeType === Node.COMMENT_NODE) {
|
|
|
|
|
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ syncAgentDomAttributes(current, next);
|
|
|
|
|
+ let cursor = current.firstChild;
|
|
|
|
|
+ for (const nextChild of [...next.childNodes]) {
|
|
|
|
|
+ const nextKey = agentDomNodeKey(nextChild);
|
|
|
|
|
+ let match = cursor;
|
|
|
|
|
+ if (nextKey) {
|
|
|
|
|
+ if (agentDomNodeKey(match) !== nextKey) {
|
|
|
|
|
+ match = [...current.childNodes].find(candidate => agentDomNodeKey(candidate) === nextKey) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (!agentDomNodesCompatible(match, nextChild) || agentDomNodeKey(match)) {
|
|
|
|
|
+ match = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!match) {
|
|
|
|
|
+ match = nextChild.cloneNode(true);
|
|
|
|
|
+ current.insertBefore(match, cursor);
|
|
|
|
|
+ } else if (match !== cursor) {
|
|
|
|
|
+ current.insertBefore(match, cursor);
|
|
|
|
|
+ }
|
|
|
|
|
+ patchAgentDomNode(match, nextChild);
|
|
|
|
|
+ cursor = match.nextSibling;
|
|
|
|
|
+ }
|
|
|
|
|
+ while (cursor) {
|
|
|
|
|
+ const nextSibling = cursor.nextSibling;
|
|
|
|
|
+ cursor.remove();
|
|
|
|
|
+ cursor = nextSibling;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function patchAgentPage(page, html) {
|
|
|
|
|
+ const nextPage = page.cloneNode(false);
|
|
|
|
|
+ nextPage.innerHTML = html;
|
|
|
|
|
+ patchAgentDomNode(page, nextPage);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function captureAgentInsightScroll(page) {
|
|
function captureAgentInsightScroll(page) {
|
|
|
const panel = page.querySelector('.agent-insight-panel');
|
|
const panel = page.querySelector('.agent-insight-panel');
|
|
|
if (!panel) return null;
|
|
if (!panel) return null;
|
|
@@ -2728,6 +2869,7 @@
|
|
|
function renderAgentWorkspace(page) {
|
|
function renderAgentWorkspace(page) {
|
|
|
const status = state.agent.status || {};
|
|
const status = state.agent.status || {};
|
|
|
const listener = status.listener || {};
|
|
const listener = status.listener || {};
|
|
|
|
|
+ const ingress = status.ingress || {};
|
|
|
const account = status.account || {};
|
|
const account = status.account || {};
|
|
|
const config = status.config || {};
|
|
const config = status.config || {};
|
|
|
const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
|
|
const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
|
|
@@ -2832,10 +2974,188 @@
|
|
|
scrollAgentMessagesToLatest(page);
|
|
scrollAgentMessagesToLatest(page);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function voiceIcon(name) {
|
|
|
|
|
+ const paths = {
|
|
|
|
|
+ mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/>',
|
|
|
|
|
+ play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
|
|
|
|
+ volume: '<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18 6a8.5 8.5 0 0 1 0 12"/>',
|
|
|
|
|
+ text: '<path d="M4 5h16M8 10h8M8 15h8M6 20h12"/>',
|
|
|
|
|
+ upload: '<path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M20 16v4H4v-4"/>',
|
|
|
|
|
+ trash: '<path d="M3 6h18M8 6V4h8v2m-9 0 1 15h8l1-15M10 11v5m4-5v5"/>',
|
|
|
|
|
+ stop: '<rect x="6" y="6" width="12" height="12" rx="1"/>',
|
|
|
|
|
+ };
|
|
|
|
|
+ return `<svg class="voice-icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths[name] || paths.mic}</svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fileToBase64(file) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ reader.onload = () => resolve(String(reader.result || '').split(',').pop() || '');
|
|
|
|
|
+ reader.onerror = () => reject(reader.error || new Error('读取音频失败'));
|
|
|
|
|
+ reader.readAsDataURL(file);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function openVoiceProfileModal(page) {
|
|
|
|
|
+ const voice = state.agent.status?.voice || {};
|
|
|
|
|
+ const profile = voice.profile || {};
|
|
|
|
|
+ openModal('本人声音', `
|
|
|
|
|
+ <div class="voice-profile-modal-body">
|
|
|
|
|
+ <div class="voice-profile-status ${voice.enrolled ? 'ready' : ''}">
|
|
|
|
|
+ <span class="voice-profile-dot"></span>
|
|
|
|
|
+ <div><strong>${voice.enrolled ? '声音已初始化' : '尚未初始化声音'}</strong><small>${voice.enrolled ? `${Number(profile.duration || 0).toFixed(1)} 秒 · ${Number(profile.sampleRate || 0) / 1000}kHz · ${escapeHtml(profile.originalName || '参考录音')}` : '支持 5~30 秒音频'}</small></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="voice-capture-row">
|
|
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-record>${voiceIcon('mic')}<span>开始录音</span></button>
|
|
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-stop disabled>${voiceIcon('stop')}<span>停止</span></button>
|
|
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-upload>${voiceIcon('upload')}<span>选择音频</span></button>
|
|
|
|
|
+ <input type="file" accept="audio/*,.m4a" data-voice-file hidden />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="voice-selected-file" data-voice-selected>未选择新录音</div>
|
|
|
|
|
+ <audio class="voice-profile-preview" data-voice-audio controls hidden></audio>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `, `
|
|
|
|
|
+ <button type="button" class="btn btn-secondary" data-voice-cancel>取消</button>
|
|
|
|
|
+ ${voice.enrolled ? `<button type="button" class="btn btn-secondary voice-delete-button" data-voice-delete>${voiceIcon('trash')}删除声音</button>` : ''}
|
|
|
|
|
+ <button type="button" class="btn" data-voice-save disabled>保存声音</button>
|
|
|
|
|
+ `, 'voice-profile-modal');
|
|
|
|
|
+ const modal = state.modal;
|
|
|
|
|
+ const fileInput = modal.querySelector('[data-voice-file]');
|
|
|
|
|
+ const recordButton = modal.querySelector('[data-voice-record]');
|
|
|
|
|
+ const stopButton = modal.querySelector('[data-voice-stop]');
|
|
|
|
|
+ const uploadButton = modal.querySelector('[data-voice-upload]');
|
|
|
|
|
+ const saveButton = modal.querySelector('[data-voice-save]');
|
|
|
|
|
+ const selectedLabel = modal.querySelector('[data-voice-selected]');
|
|
|
|
|
+ const audio = modal.querySelector('[data-voice-audio]');
|
|
|
|
|
+ let selectedFile = null;
|
|
|
|
|
+ let stream = null;
|
|
|
|
|
+ let recorder = null;
|
|
|
|
|
+ let chunks = [];
|
|
|
|
|
+ let previewUrl = '';
|
|
|
|
|
+
|
|
|
|
|
+ const cleanupMedia = () => {
|
|
|
|
|
+ if (recorder?.state === 'recording') recorder.stop();
|
|
|
|
|
+ stream?.getTracks().forEach(track => track.stop());
|
|
|
|
|
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
|
|
|
+ };
|
|
|
|
|
+ const originalCleanup = modal.__modalCleanup;
|
|
|
|
|
+ modal.__modalCleanup = () => { originalCleanup?.(); cleanupMedia(); };
|
|
|
|
|
+
|
|
|
|
|
+ const selectFile = file => {
|
|
|
|
|
+ if (!file) return;
|
|
|
|
|
+ selectedFile = file;
|
|
|
|
|
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
|
|
|
+ previewUrl = URL.createObjectURL(file);
|
|
|
|
|
+ audio.src = previewUrl;
|
|
|
|
|
+ audio.hidden = false;
|
|
|
|
|
+ selectedLabel.textContent = `${file.name} · ${(file.size / 1024).toFixed(0)}KB`;
|
|
|
|
|
+ saveButton.disabled = false;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ uploadButton.addEventListener('click', () => fileInput.click());
|
|
|
|
|
+ fileInput.addEventListener('change', () => selectFile(fileInput.files?.[0]));
|
|
|
|
|
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
|
|
|
+ recordButton.disabled = true;
|
|
|
|
|
+ recordButton.title = '当前浏览器不支持录音';
|
|
|
|
|
+ }
|
|
|
|
|
+ recordButton.addEventListener('click', async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
|
|
|
+ const preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(type => MediaRecorder.isTypeSupported(type));
|
|
|
|
|
+ recorder = preferred ? new MediaRecorder(stream, { mimeType: preferred }) : new MediaRecorder(stream);
|
|
|
|
|
+ chunks = [];
|
|
|
|
|
+ recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
|
|
|
|
|
+ recorder.onstop = () => {
|
|
|
|
|
+ const type = recorder.mimeType || 'audio/webm';
|
|
|
|
|
+ const ext = type.includes('mp4') ? 'm4a' : 'webm';
|
|
|
|
|
+ selectFile(new File(chunks, `voice-reference.${ext}`, { type }));
|
|
|
|
|
+ stream?.getTracks().forEach(track => track.stop());
|
|
|
|
|
+ stream = null;
|
|
|
|
|
+ recordButton.disabled = false;
|
|
|
|
|
+ stopButton.disabled = true;
|
|
|
|
|
+ recordButton.classList.remove('recording');
|
|
|
|
|
+ };
|
|
|
|
|
+ recorder.start();
|
|
|
|
|
+ recordButton.disabled = true;
|
|
|
|
|
+ stopButton.disabled = false;
|
|
|
|
|
+ recordButton.classList.add('recording');
|
|
|
|
|
+ selectedLabel.textContent = '正在录音…';
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ toast(error.message || '无法使用麦克风', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ stopButton.addEventListener('click', () => recorder?.state === 'recording' && recorder.stop());
|
|
|
|
|
+ modal.querySelector('[data-voice-cancel]').addEventListener('click', closeModal);
|
|
|
|
|
+ modal.querySelector('[data-voice-delete]')?.addEventListener('click', async event => {
|
|
|
|
|
+ if (!await confirmModal('确认删除当前账号保存的声音档案?', { title: '删除声音', confirmText: '确认删除', warning: '删除后无法继续生成克隆语音。', danger: true })) return;
|
|
|
|
|
+ event.currentTarget.disabled = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await api('DELETE', '/api/agent/voice/profile');
|
|
|
|
|
+ state.agent.status.voice = { ...voice, ...(result.data || {}), enrolled: false, profile: null };
|
|
|
|
|
+ closeModal();
|
|
|
|
|
+ toast(result.assistantMessage || '声音档案已删除');
|
|
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ event.currentTarget.disabled = false;
|
|
|
|
|
+ toast(error.message || '删除声音失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ saveButton.addEventListener('click', async () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ saveButton.disabled = true;
|
|
|
|
|
+ saveButton.textContent = '正在处理…';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await api('POST', '/api/agent/voice/profile', {
|
|
|
|
|
+ name: selectedFile.name,
|
|
|
|
|
+ type: selectedFile.type,
|
|
|
|
|
+ data: await fileToBase64(selectedFile),
|
|
|
|
|
+ }, 120000);
|
|
|
|
|
+ state.agent.status.voice = result.data || {};
|
|
|
|
|
+ closeModal();
|
|
|
|
|
+ toast(result.assistantMessage || '声音已初始化');
|
|
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ saveButton.disabled = false;
|
|
|
|
|
+ saveButton.textContent = '保存声音';
|
|
|
|
|
+ toast(error.message || '声音初始化失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function voiceComposerToolbar({ selected, content, draftId, disabled }) {
|
|
|
|
|
+ const voice = state.agent.status?.voice || {};
|
|
|
|
|
+ const tones = voice.tones || [];
|
|
|
|
|
+ const profileLabel = voice.enrolled ? '声音设置' : '初始化声音';
|
|
|
|
|
+ const voiceContentDisabled = disabled || !voice.enrolled || !content;
|
|
|
|
|
+ const sendDisabled = voiceContentDisabled || !voice.sendConfigured;
|
|
|
|
|
+ return `<div class="voice-composer-toolbar">
|
|
|
|
|
+ <button type="button" class="voice-profile-button" data-agent-action="voice-profile" data-id="${escapeHtml(selected?.id || '')}">${voiceIcon('mic')}<span>${profileLabel}</span><i class="${voice.enrolled ? 'ready' : ''}"></i></button>
|
|
|
|
|
+ <label class="voice-tone-select"><span>语气</span><select id="agent-voice-tone" aria-label="语音语气" ${disabled ? 'disabled' : ''}><option value="auto" ${state.agent.voiceTone === 'auto' ? 'selected' : ''}>自动识别</option>${tones.map(item => `<option value="${escapeHtml(item.id)}" ${state.agent.voiceTone === item.id ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}</select></label>
|
|
|
|
|
+ <div class="voice-composer-actions">
|
|
|
|
|
+ <button type="button" class="btn voice-action-button" data-agent-action="voice-send" data-id="${escapeHtml(selected?.id || '')}" data-draft-id="${escapeHtml(draftId || '')}" ${sendDisabled ? 'disabled' : ''} ${!voice.sendConfigured ? 'title="媒体上传未配置"' : ''}>${voiceIcon('mic')}语音发送</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function agentErrorPresentation(error = {}) {
|
|
|
|
|
+ return ({
|
|
|
|
|
+ quota_exhausted: { title: '模型额度不足', next: '恢复额度后,可点击“初始化 Session”或“让 Agent 处理”重试。' },
|
|
|
|
|
+ task_budget_exceeded: { title: '单次任务预算不足', next: '请提高 Claude Code 单次任务预算后重试。' },
|
|
|
|
|
+ authentication_failed: { title: 'Claude Code 登录失效', next: '请重新登录或检查模型凭据后重试。' },
|
|
|
|
|
+ permission_denied: { title: 'Claude Code 权限不足', next: '请检查当前账号与模型权限。' },
|
|
|
|
|
+ not_configured: { title: 'Claude Code 尚未配置', next: '请先在“连接与设置”中完成模型配置。' },
|
|
|
|
|
+ cli_not_found: { title: '未找到 Claude Code CLI', next: '请先完成 CLI 安装或配置可执行文件路径。' },
|
|
|
|
|
+ timeout: { title: 'Claude Code 运行超时', next: '请稍后重试,必要时缩短当前会话上下文。' },
|
|
|
|
|
+ network_error: { title: 'Claude Code 网络异常', next: '请检查网络连接后重试。' },
|
|
|
|
|
+ })[error.code] || { title: 'Claude Code 暂时不可用', next: '请稍后重试;详细原因已记录在审计日志中。' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function renderAgentWorkspaceV2(page) {
|
|
function renderAgentWorkspaceV2(page) {
|
|
|
const insightScroll = captureAgentInsightScroll(page);
|
|
const insightScroll = captureAgentInsightScroll(page);
|
|
|
|
|
+ const workspaceScroll = captureAgentWorkspaceScroll(page);
|
|
|
const status = state.agent.status || {};
|
|
const status = state.agent.status || {};
|
|
|
const listener = status.listener || {};
|
|
const listener = status.listener || {};
|
|
|
|
|
+ const ingress = status.ingress || {};
|
|
|
const account = status.account || {};
|
|
const account = status.account || {};
|
|
|
const config = status.config || {};
|
|
const config = status.config || {};
|
|
|
const product = status.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
|
|
const product = status.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
|
|
@@ -2844,6 +3164,7 @@
|
|
|
const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
|
|
const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
|
|
|
const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
|
|
const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
|
|
|
const listenerRunning = Boolean(listener.running);
|
|
const listenerRunning = Boolean(listener.running);
|
|
|
|
|
+ const messageIngressRunning = product.mode === 'enterprise' ? Boolean(ingress.running) : listenerRunning;
|
|
|
const allowlistCount = Number(config.allowedSenderCount) || 0;
|
|
const allowlistCount = Number(config.allowedSenderCount) || 0;
|
|
|
const allowlistReady = allowlistCount > 0;
|
|
const allowlistReady = allowlistCount > 0;
|
|
|
const agent = status.agent || {};
|
|
const agent = status.agent || {};
|
|
@@ -2891,7 +3212,7 @@
|
|
|
els.accountName.textContent = accountName;
|
|
els.accountName.textContent = accountName;
|
|
|
els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
|
|
els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
|
|
|
|
|
|
|
|
- page.innerHTML = `
|
|
|
|
|
|
|
+ const nextHtml = `
|
|
|
<section class="agent-hero">
|
|
<section class="agent-hero">
|
|
|
<div>
|
|
<div>
|
|
|
<div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
|
|
<div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
|
|
@@ -2900,17 +3221,19 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="agent-safety-card">
|
|
<div class="agent-safety-card">
|
|
|
<span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
|
|
<span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
|
|
|
- <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${listenerRunning ? '消息处理中' : '处理已停止'}</small></div>
|
|
|
|
|
|
|
+ <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${messageIngressRunning ? '回调接收中' : '回调待恢复'}</small></div>
|
|
|
<div class="agent-safety-actions">
|
|
<div class="agent-safety-actions">
|
|
|
<button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
|
|
<button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
|
|
|
<button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
|
|
<button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
|
|
|
- <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>
|
|
|
|
|
|
|
+ ${product.mode === 'personal'
|
|
|
|
|
+ ? `<button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
|
|
|
|
|
+ : ''}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</section>
|
|
</section>
|
|
|
|
|
|
|
|
<section class="agent-modebar">
|
|
<section class="agent-modebar">
|
|
|
- <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (product.relayReady ? '企业 Relay 已接入' : '企业 Relay 待接入') : '数据保存在当前项目'} · 暂停时仍可收消息</span></div>
|
|
|
|
|
|
|
+ <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (messageIngressRunning ? '公网回调常驻' : '公网回调正在恢复') : '数据保存在当前项目'} · 暂停时仍接收并保存消息</span></div>
|
|
|
<div class="agent-mode-switch">
|
|
<div class="agent-mode-switch">
|
|
|
<button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
|
|
<button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
|
|
|
<button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
|
|
<button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
|
|
@@ -2922,7 +3245,7 @@
|
|
|
${renderResponseMonitor(state.agent.responseMonitor || {})}
|
|
${renderResponseMonitor(state.agent.responseMonitor || {})}
|
|
|
|
|
|
|
|
${selected?.agentError
|
|
${selected?.agentError
|
|
|
- ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`
|
|
|
|
|
|
|
+ ? (() => { const errorUi = agentErrorPresentation(selected.agentError); return `<div class="agent-runtime-alert"><strong>${escapeHtml(errorUi.title)}</strong><span>${escapeHtml(selected.agentError.message)} ${escapeHtml(errorUi.next)} 真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`; })()
|
|
|
: noReplyNotice
|
|
: noReplyNotice
|
|
|
? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
|
|
? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
|
|
|
: ''}
|
|
: ''}
|
|
@@ -2946,7 +3269,7 @@
|
|
|
<span class="agent-avatar group">群</span>
|
|
<span class="agent-avatar group">群</span>
|
|
|
<span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群消息同步')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
|
|
<span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群消息同步')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
|
|
|
<time>${formatAgentTime(item.lastMessageAt)}</time>
|
|
<time>${formatAgentTime(item.lastMessageAt)}</time>
|
|
|
- </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? '启动监听后,请从已选企微客户发送测试内容。' : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
|
|
|
|
|
|
|
+ </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? (product.mode === 'enterprise' ? '公网回调会持续接收消息,请从已选企微客户发送测试内容。' : '启动监听后,请从已选企微客户发送测试内容。') : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
|
|
|
</div>
|
|
</div>
|
|
|
</aside>
|
|
</aside>
|
|
|
|
|
|
|
@@ -2985,12 +3308,13 @@
|
|
|
<div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
|
|
<div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
|
|
|
<span class="agent-session-dot"></span>
|
|
<span class="agent-session-dot"></span>
|
|
|
<div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
|
|
<div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
|
|
|
- <button class="btn btn-sm btn-secondary" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '查看说明'}</button>
|
|
|
|
|
|
|
+ <button class="btn btn-sm btn-secondary agent-session-action" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '初始化 Session'}</button>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
|
|
<div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
|
|
|
<div class="agent-composer ${manualActive ? 'manual' : ''}">
|
|
<div class="agent-composer ${manualActive ? 'manual' : ''}">
|
|
|
<div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
|
|
<div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
|
|
|
<textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
|
|
<textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
|
|
|
|
|
+ ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused })}
|
|
|
<div class="agent-composer-actions">
|
|
<div class="agent-composer-actions">
|
|
|
<span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
|
|
<span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
|
|
|
<div class="agent-review-actions">
|
|
<div class="agent-review-actions">
|
|
@@ -3053,8 +3377,13 @@
|
|
|
</aside>
|
|
</aside>
|
|
|
</section>
|
|
</section>
|
|
|
`;
|
|
`;
|
|
|
|
|
+ const viewKey = `${listMode}:${selected?.id || selectedGroup?.id || 'empty'}`;
|
|
|
|
|
+ const incremental = page.dataset.agentViewKey === viewKey && Boolean(page.querySelector('.agent-workspace'));
|
|
|
|
|
+ if (incremental) patchAgentPage(page, nextHtml);
|
|
|
|
|
+ else page.innerHTML = nextHtml;
|
|
|
|
|
+ page.dataset.agentViewKey = viewKey;
|
|
|
restoreAgentInsightScroll(page, insightScroll, selected?.id || selectedGroup?.id);
|
|
restoreAgentInsightScroll(page, insightScroll, selected?.id || selectedGroup?.id);
|
|
|
- scrollAgentMessagesToLatest(page);
|
|
|
|
|
|
|
+ restoreAgentWorkspaceScroll(page, workspaceScroll, viewKey, incremental);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function responseMonitorRenderSignature(monitor = {}) {
|
|
function responseMonitorRenderSignature(monitor = {}) {
|
|
@@ -3119,8 +3448,10 @@
|
|
|
const nextSignature = JSON.stringify({
|
|
const nextSignature = JSON.stringify({
|
|
|
globalMode: nextStatus.globalMode,
|
|
globalMode: nextStatus.globalMode,
|
|
|
listenerRunning: Boolean(nextStatus.listener?.running),
|
|
listenerRunning: Boolean(nextStatus.listener?.running),
|
|
|
|
|
+ ingressRunning: Boolean(nextStatus.ingress?.running),
|
|
|
account: nextStatus.account,
|
|
account: nextStatus.account,
|
|
|
config: nextStatus.config,
|
|
config: nextStatus.config,
|
|
|
|
|
+ voice: nextStatus.voice,
|
|
|
conversations: nextConversations,
|
|
conversations: nextConversations,
|
|
|
responseMonitor: responseMonitorRenderSignature(nextResponseMonitor),
|
|
responseMonitor: responseMonitorRenderSignature(nextResponseMonitor),
|
|
|
groupAgents: nextGroupAgents,
|
|
groupAgents: nextGroupAgents,
|
|
@@ -3145,9 +3476,15 @@
|
|
|
});
|
|
});
|
|
|
const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
|
|
const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
|
|
|
const editorFocused = document.activeElement === page.querySelector('#agent-reply-editor') || document.activeElement === page.querySelector('#agent-group-reply-editor');
|
|
const editorFocused = document.activeElement === page.querySelector('#agent-reply-editor') || document.activeElement === page.querySelector('#agent-group-reply-editor');
|
|
|
- if (!editorFocused) {
|
|
|
|
|
|
|
+ const insightPanel = page.querySelector('.agent-insight-panel');
|
|
|
|
|
+ const insightActive = Date.now() < Number(state.agent.insightInteractionUntil || 0) || Boolean(insightPanel?.matches(':hover'));
|
|
|
|
|
+ const voicePlaying = [...page.querySelectorAll('.agent-voice-audio')].some(audio => !audio.paused && !audio.ended);
|
|
|
|
|
+ if (!editorFocused && !insightActive && !voicePlaying) {
|
|
|
state.agent.signature = nextSignature;
|
|
state.agent.signature = nextSignature;
|
|
|
- if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
|
|
|
|
|
|
|
+ if ((changed || waitingForFirstRender || state.agent.renderPending) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
|
|
|
|
|
+ state.agent.renderPending = false;
|
|
|
|
|
+ } else if (changed || waitingForFirstRender) {
|
|
|
|
|
+ state.agent.renderPending = true;
|
|
|
}
|
|
}
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
if (!silent) toast(error.message || '智能会话加载失败', 'error');
|
|
if (!silent) toast(error.message || '智能会话加载失败', 'error');
|
|
@@ -3185,10 +3522,18 @@
|
|
|
els.content.appendChild(page);
|
|
els.content.appendChild(page);
|
|
|
if (state.agent.status) renderAgentWorkspaceV2(page);
|
|
if (state.agent.status) renderAgentWorkspaceV2(page);
|
|
|
|
|
|
|
|
|
|
+ const markInsightInteraction = event => {
|
|
|
|
|
+ if (event.target?.closest?.('.agent-insight-panel')) state.agent.insightInteractionUntil = Date.now() + 2000;
|
|
|
|
|
+ };
|
|
|
|
|
+ page.addEventListener('pointerdown', markInsightInteraction, true);
|
|
|
|
|
+ page.addEventListener('scroll', markInsightInteraction, true);
|
|
|
|
|
+
|
|
|
page.addEventListener('input', event => {
|
|
page.addEventListener('input', event => {
|
|
|
const editor = event.target.closest('#agent-reply-editor');
|
|
const editor = event.target.closest('#agent-reply-editor');
|
|
|
if (editor?.dataset.agentEditKey) {
|
|
if (editor?.dataset.agentEditKey) {
|
|
|
state.agent.replyEdits[editor.dataset.agentEditKey] = editor.value;
|
|
state.agent.replyEdits[editor.dataset.agentEditKey] = editor.value;
|
|
|
|
|
+ const voiceReady = Boolean(state.agent.status?.voice?.enrolled && editor.value.trim() && !editor.disabled);
|
|
|
|
|
+ page.querySelectorAll('[data-agent-action="voice-send"]').forEach(button => { button.disabled = !voiceReady || !state.agent.status?.voice?.sendConfigured; });
|
|
|
const status = page.querySelector('[data-agent-edit-status]');
|
|
const status = page.querySelector('[data-agent-edit-status]');
|
|
|
if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
|
return;
|
|
return;
|
|
@@ -3200,7 +3545,60 @@
|
|
|
if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ page.addEventListener('change', event => {
|
|
|
|
|
+ if (event.target.id !== 'agent-voice-tone') return;
|
|
|
|
|
+ state.agent.voiceTone = event.target.value || 'auto';
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const updateVoicePlaybackUi = audio => {
|
|
|
|
|
+ const bubble = audio?.closest('.agent-voice-bubble');
|
|
|
|
|
+ if (!bubble) return;
|
|
|
|
|
+ const playing = !audio.paused && !audio.ended;
|
|
|
|
|
+ bubble.classList.toggle('playing', playing);
|
|
|
|
|
+ bubble.setAttribute('aria-pressed', String(playing));
|
|
|
|
|
+ const duration = Math.max(1, Number(audio.duration) || Number(bubble.closest('.agent-message')?.querySelector('.agent-voice-duration')?.textContent) || 1);
|
|
|
|
|
+ bubble.setAttribute('aria-label', `${playing ? '暂停' : '播放'}语音,时长 ${Math.ceil(duration)} 秒`);
|
|
|
|
|
+ };
|
|
|
|
|
+ page.addEventListener('play', event => {
|
|
|
|
|
+ if (!event.target.matches?.('.agent-voice-audio')) return;
|
|
|
|
|
+ page.querySelectorAll('.agent-voice-audio').forEach(audio => {
|
|
|
|
|
+ if (audio !== event.target && !audio.paused) audio.pause();
|
|
|
|
|
+ });
|
|
|
|
|
+ updateVoicePlaybackUi(event.target);
|
|
|
|
|
+ }, true);
|
|
|
|
|
+ page.addEventListener('pause', event => {
|
|
|
|
|
+ if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
|
|
|
|
|
+ }, true);
|
|
|
|
|
+ page.addEventListener('ended', event => {
|
|
|
|
|
+ if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
|
|
|
|
|
+ }, true);
|
|
|
|
|
+
|
|
|
page.addEventListener('click', async event => {
|
|
page.addEventListener('click', async event => {
|
|
|
|
|
+ const transcriptButton = event.target.closest('[data-voice-transcript-toggle]');
|
|
|
|
|
+ if (transcriptButton) {
|
|
|
|
|
+ const transcript = document.getElementById(transcriptButton.getAttribute('aria-controls'));
|
|
|
|
|
+ const expanded = transcriptButton.getAttribute('aria-expanded') === 'true';
|
|
|
|
|
+ transcriptButton.setAttribute('aria-expanded', String(!expanded));
|
|
|
|
|
+ transcriptButton.querySelector('span').textContent = expanded ? '转文字' : '收起';
|
|
|
|
|
+ if (transcript) transcript.hidden = expanded;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const voicePlayButton = event.target.closest('[data-voice-play]');
|
|
|
|
|
+ if (voicePlayButton) {
|
|
|
|
|
+ const audio = voicePlayButton.querySelector('.agent-voice-audio');
|
|
|
|
|
+ if (!audio) return;
|
|
|
|
|
+ if (audio.paused || audio.ended) {
|
|
|
|
|
+ if (audio.ended) audio.currentTime = 0;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await audio.play();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ toast(error.message || '语音播放失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ audio.pause();
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
|
|
const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
|
|
|
if (responseMonitorButton) {
|
|
if (responseMonitorButton) {
|
|
|
if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
|
|
if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
|
|
@@ -3319,7 +3717,35 @@
|
|
|
}
|
|
}
|
|
|
if (action === 'session-guide') {
|
|
if (action === 'session-guide') {
|
|
|
const conversation = (state.agent.conversations || []).find(item => item.id === id);
|
|
const conversation = (state.agent.conversations || []).find(item => item.id === id);
|
|
|
- openAgentSessionGuide(conversation?.claudeSession || {});
|
|
|
|
|
|
|
+ const guide = conversation?.claudeSession || {};
|
|
|
|
|
+ if (guide.ready) {
|
|
|
|
|
+ openAgentSessionGuide(guide);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ actionButton.disabled = true;
|
|
|
|
|
+ actionButton.setAttribute('aria-busy', 'true');
|
|
|
|
|
+ actionButton.innerHTML = '<span class="agent-session-spinner" aria-hidden="true"></span>初始化中…';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await api('POST', `/api/agent/conversations/${id}/generate`, {}, 240000);
|
|
|
|
|
+ await loadAgentData(page);
|
|
|
|
|
+ const updated = (state.agent.conversations || []).find(item => item.id === id);
|
|
|
|
|
+ if (!updated?.claudeSession?.ready) {
|
|
|
|
|
+ throw new Error(result.assistantMessage || result.data?.error || '客户 Session 初始化未完成');
|
|
|
|
|
+ }
|
|
|
|
|
+ toast('客户 Session 已初始化,生成内容已进入人工审核流程');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ toast(error.message || '客户 Session 初始化失败', 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (actionButton.isConnected) {
|
|
|
|
|
+ actionButton.disabled = false;
|
|
|
|
|
+ actionButton.removeAttribute('aria-busy');
|
|
|
|
|
+ actionButton.textContent = '初始化 Session';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (action === 'voice-profile') {
|
|
|
|
|
+ await openVoiceProfileModal(page);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
actionButton.disabled = true;
|
|
actionButton.disabled = true;
|
|
@@ -3396,9 +3822,19 @@
|
|
|
const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
|
if (!await confirmModal(`确认由人工真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`, { title: '人工发送客户回复', confirmText: '确认发送', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
|
|
if (!await confirmModal(`确认由人工真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`, { title: '人工发送客户回复', confirmText: '确认发送', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
|
|
|
result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
|
|
result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
|
|
|
|
|
+ } else if (action === 'voice-send') {
|
|
|
|
|
+ const content = page.querySelector('#agent-reply-editor')?.value.trim();
|
|
|
|
|
+ if (!content) throw new Error('请先输入语音内容');
|
|
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
|
|
|
+ const toneLabel = state.agent.voiceTone === 'auto'
|
|
|
|
|
+ ? '自动识别语气'
|
|
|
|
|
+ : (state.agent.status?.voice?.tones || []).find(item => item.id === state.agent.voiceTone)?.label || '当前语气';
|
|
|
|
|
+ const voiceAccountKey = agentAccountKey() || state.agent.cachedAccountKey || 'default';
|
|
|
|
|
+ if (!await confirmModal(`确认使用“${toneLabel}”生成语音,并真实发送给“${selected?.displayName || '当前客户'}”?`, { title: '发送克隆语音', confirmText: '生成并发送', warning: '该操作会调用云端声音克隆服务并立即发送真实企微语音。', danger: true, dismissKey: `voice-send:${voiceAccountKey}` })) return;
|
|
|
|
|
+ result = await api('POST', `/api/agent/conversations/${id}/voice-send`, { content, draftId: actionButton.dataset.draftId || undefined, tone: state.agent.voiceTone, confirmed: true }, 240000);
|
|
|
}
|
|
}
|
|
|
if (result) toast(result.assistantMessage || '操作成功');
|
|
if (result) toast(result.assistantMessage || '操作成功');
|
|
|
- if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send'].includes(action)) {
|
|
|
|
|
|
|
+ if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send', 'voice-send'].includes(action)) {
|
|
|
delete state.agent.replyEdits[replyEditKey];
|
|
delete state.agent.replyEdits[replyEditKey];
|
|
|
}
|
|
}
|
|
|
if (result && groupReplyEditKey && ['group-approve', 'group-reject', 'group-regenerate', 'group-generate'].includes(action)) {
|
|
if (result && groupReplyEditKey && ['group-approve', 'group-reject', 'group-regenerate', 'group-generate'].includes(action)) {
|
|
@@ -5715,17 +6151,18 @@
|
|
|
`;
|
|
`;
|
|
|
els.content.appendChild(page);
|
|
els.content.appendChild(page);
|
|
|
|
|
|
|
|
- await loadPortraitsDashboard(page);
|
|
|
|
|
|
|
+ if (renderPortraitsDashboardSnapshot(page)) void loadPortraitsDashboard(page, true);
|
|
|
|
|
+ else await loadPortraitsDashboard(page);
|
|
|
dashboardRefreshTimer = setInterval(() => {
|
|
dashboardRefreshTimer = setInterval(() => {
|
|
|
if (state.page === 'portraits') loadPortraitsDashboard(page, true);
|
|
if (state.page === 'portraits') loadPortraitsDashboard(page, true);
|
|
|
}, 30000);
|
|
}, 30000);
|
|
|
|
|
|
|
|
- page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
|
|
|
|
|
|
|
+ page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page, false, true));
|
|
|
page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
|
|
page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
|
|
|
try {
|
|
try {
|
|
|
await api('POST', '/api/dashboard/rebuild');
|
|
await api('POST', '/api/dashboard/rebuild');
|
|
|
toast('索引已重建');
|
|
toast('索引已重建');
|
|
|
- loadPortraitsDashboard(page);
|
|
|
|
|
|
|
+ loadPortraitsDashboard(page, false, true);
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
toast(err.message || '重建失败', 'error');
|
|
toast(err.message || '重建失败', 'error');
|
|
|
}
|
|
}
|
|
@@ -5759,103 +6196,140 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function loadPortraitsDashboard(page, silent = false) {
|
|
|
|
|
|
|
+ function renderPortraitsDashboardSnapshot(page) {
|
|
|
|
|
+ const snapshot = state.portraits.dashboardSnapshot;
|
|
|
|
|
+ if (!snapshot || !page?.isConnected) return false;
|
|
|
|
|
+ const signature = JSON.stringify({
|
|
|
|
|
+ summary: snapshot.summary,
|
|
|
|
|
+ portraitIds: Object.entries(snapshot.portraits || {}).map(([id, item]) => [id, item.updatedAt || item.profileUpdatedAt || '']),
|
|
|
|
|
+ customerIds: Object.keys(snapshot.customers || {}),
|
|
|
|
|
+ tags: snapshot.tags || [],
|
|
|
|
|
+ });
|
|
|
|
|
+ if (page.dataset.portraitSignature === signature && page.querySelector('#pt-portrait-table-wrap')?.children.length) return true;
|
|
|
|
|
+ state.portraits.allPortraits = snapshot.portraits;
|
|
|
|
|
+ state.portraits.allCustomers = snapshot.customers;
|
|
|
|
|
+ state.portraits.personalLabels = snapshot.personalLabels || [];
|
|
|
|
|
+ hideLoading(page.querySelector('#pt-portrait-table-wrap'));
|
|
|
|
|
+ renderPortraitsKpi(page, snapshot.summary);
|
|
|
|
|
+ renderPortraitsCharts(page, snapshot.summary, snapshot.portraits, snapshot.tags);
|
|
|
|
|
+ renderPortraitsTagFilter(page, snapshot.tags);
|
|
|
|
|
+ loadPortraitsTable(page);
|
|
|
|
|
+ page.dataset.portraitSignature = signature;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function buildPortraitsDashboardSnapshot({ summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes, directoryCustomers, personalLabels }) {
|
|
|
|
|
+ const summary = structuredClone(summaryRes.data || {});
|
|
|
|
|
+ const legacyPortraits = portraitsRes.data?.portraits || {};
|
|
|
|
|
+ const legacyCustomers = customersRes.data?.customers || {};
|
|
|
|
|
+ const customerMaster = customerMasterRes.data?.customers || [];
|
|
|
|
|
+ const canonicalPortraits = {};
|
|
|
|
|
+ const canonicalCustomers = {};
|
|
|
|
|
+ for (const customer of directoryCustomers || []) {
|
|
|
|
|
+ const externalUserId = String(customer.externalUserId || '').trim();
|
|
|
|
|
+ if (!externalUserId) continue;
|
|
|
|
|
+ canonicalCustomers[externalUserId] = {
|
|
|
|
|
+ externalUserId,
|
|
|
|
|
+ name: customer.name || customer.displayName || '',
|
|
|
|
|
+ phone: customer.phone || '',
|
|
|
|
|
+ tags: customer.tags || [],
|
|
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
|
|
+ remote: customer.source === 'qiwei-contact',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const customer of customerMaster) {
|
|
|
|
|
+ const externalUserId = String(customer.externalUserId || customer.id || '').trim();
|
|
|
|
|
+ if (!externalUserId) continue;
|
|
|
|
|
+ const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
|
|
|
|
|
+ if (hasProfile) {
|
|
|
|
|
+ canonicalPortraits[externalUserId] = {
|
|
|
|
|
+ externalUserId,
|
|
|
|
|
+ displayId: customer.maskedId,
|
|
|
|
|
+ fields: customer.fields || [],
|
|
|
|
|
+ profile: customer.profile || {},
|
|
|
|
|
+ source: 'agent',
|
|
|
|
|
+ canonical: true,
|
|
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
|
|
+ updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ canonicalCustomers[externalUserId] = {
|
|
|
|
|
+ ...(canonicalCustomers[externalUserId] || {}),
|
|
|
|
|
+ externalUserId,
|
|
|
|
|
+ name: customer.displayName,
|
|
|
|
|
+ phone: customer.maskedId,
|
|
|
|
|
+ tags: customer.tags || [],
|
|
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ const portraits = { ...legacyPortraits, ...canonicalPortraits };
|
|
|
|
|
+ const customers = { ...legacyCustomers, ...canonicalCustomers };
|
|
|
|
|
+ const tags = [...new Set([
|
|
|
|
|
+ ...(tagsRes.data?.allTags || []),
|
|
|
|
|
+ ...customerMaster.flatMap(customer => customer.tags || []),
|
|
|
|
|
+ ...(personalLabels || []).map(label => label.name).filter(Boolean),
|
|
|
|
|
+ ])];
|
|
|
|
|
+ const portraitItems = Object.values(portraits);
|
|
|
|
|
+ const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length, Object.keys(customers).length);
|
|
|
|
|
+ const bySource = portraitItems.reduce((counts, portrait) => {
|
|
|
|
|
+ const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
|
|
|
|
|
+ counts[source] += 1;
|
|
|
|
|
+ return counts;
|
|
|
|
|
+ }, { agent: 0, keyword: 0, other: 0 });
|
|
|
|
|
+ summary.portraits = {
|
|
|
|
|
+ ...(summary.portraits || {}),
|
|
|
|
|
+ total: portraitItems.length,
|
|
|
|
|
+ coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
|
|
|
|
|
+ bySource,
|
|
|
|
|
+ };
|
|
|
|
|
+ summary.tags = {
|
|
|
|
|
+ ...(summary.tags || {}),
|
|
|
|
|
+ total: tags.length,
|
|
|
|
|
+ avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ return { summary, portraits, customers, tags, personalLabels: personalLabels || [] };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadPortraitsDashboard(page, silent = false, force = false) {
|
|
|
|
|
+ if (state.portraits.loadingPromise) return state.portraits.loadingPromise;
|
|
|
const tableWrap = page.querySelector('#pt-portrait-table-wrap');
|
|
const tableWrap = page.querySelector('#pt-portrait-table-wrap');
|
|
|
- if (!silent) showLoading(tableWrap, 'table');
|
|
|
|
|
- try {
|
|
|
|
|
- const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes, directoryRes, personalLabelsRes] = await Promise.all([
|
|
|
|
|
|
|
+ if (!silent && !state.portraits.dashboardSnapshot) showLoading(tableWrap, 'table');
|
|
|
|
|
+ const run = (async () => {
|
|
|
|
|
+ const fastPromise = Promise.all([
|
|
|
api('GET', '/api/dashboard/summary'),
|
|
api('GET', '/api/dashboard/summary'),
|
|
|
api('GET', '/api/dashboard/portraits'),
|
|
api('GET', '/api/dashboard/portraits'),
|
|
|
api('GET', '/api/dashboard/tags'),
|
|
api('GET', '/api/dashboard/tags'),
|
|
|
api('GET', '/api/dashboard/customers'),
|
|
api('GET', '/api/dashboard/customers'),
|
|
|
api('GET', '/api/customers'),
|
|
api('GET', '/api/customers'),
|
|
|
- api('GET', '/api/dashboard/customers/search?scope=portrait&limit=100&sortBy=nameAsc'),
|
|
|
|
|
- getGuid()
|
|
|
|
|
- ? api('POST', '/api/personal-labels/sync', { guid: getGuid() }).catch(() => ({ data: { labels: [] } }))
|
|
|
|
|
- : Promise.resolve({ data: { labels: [] } })
|
|
|
|
|
]);
|
|
]);
|
|
|
- const summary = summaryRes.data || {};
|
|
|
|
|
- const legacyPortraits = portraitsRes.data?.portraits || {};
|
|
|
|
|
- const legacyCustomers = customersRes.data?.customers || {};
|
|
|
|
|
- const customerMaster = customerMasterRes.data?.customers || [];
|
|
|
|
|
- const directoryCustomers = directoryRes.data?.customers || [];
|
|
|
|
|
- const personalLabels = (personalLabelsRes.data?.labels || []).filter(label => label && label.isDeleted !== 1 && label.dataType !== 2);
|
|
|
|
|
- const canonicalPortraits = {};
|
|
|
|
|
- const canonicalCustomers = {};
|
|
|
|
|
- for (const customer of directoryCustomers) {
|
|
|
|
|
- const externalUserId = String(customer.externalUserId || '').trim();
|
|
|
|
|
- if (!externalUserId) continue;
|
|
|
|
|
- canonicalCustomers[externalUserId] = {
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- name: customer.name || customer.displayName || '',
|
|
|
|
|
- phone: customer.phone || '',
|
|
|
|
|
- tags: customer.tags || [],
|
|
|
|
|
- messageCount: customer.messageCount || 0,
|
|
|
|
|
- remote: customer.source === 'qiwei-contact',
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- for (const customer of customerMaster) {
|
|
|
|
|
- const externalUserId = String(customer.externalUserId || customer.id || '').trim();
|
|
|
|
|
- if (!externalUserId) continue;
|
|
|
|
|
- const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
|
|
|
|
|
- if (hasProfile) {
|
|
|
|
|
- canonicalPortraits[externalUserId] = {
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- displayId: customer.maskedId,
|
|
|
|
|
- fields: customer.fields || [],
|
|
|
|
|
- profile: customer.profile || {},
|
|
|
|
|
- source: 'agent',
|
|
|
|
|
- canonical: true,
|
|
|
|
|
- messageCount: customer.messageCount || 0,
|
|
|
|
|
- updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- canonicalCustomers[externalUserId] = {
|
|
|
|
|
- ...(canonicalCustomers[externalUserId] || {}),
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- name: customer.displayName,
|
|
|
|
|
- phone: customer.maskedId,
|
|
|
|
|
- tags: customer.tags || [],
|
|
|
|
|
- messageCount: customer.messageCount || 0,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- const portraits = { ...legacyPortraits, ...canonicalPortraits };
|
|
|
|
|
- const customers = { ...legacyCustomers, ...canonicalCustomers };
|
|
|
|
|
- const tags = [...new Set([
|
|
|
|
|
- ...(tagsRes.data?.allTags || []),
|
|
|
|
|
- ...customerMaster.flatMap(customer => customer.tags || []),
|
|
|
|
|
- ...personalLabels.map(label => label.name).filter(Boolean),
|
|
|
|
|
- ])];
|
|
|
|
|
- const portraitItems = Object.values(portraits);
|
|
|
|
|
- const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length, Object.keys(customers).length);
|
|
|
|
|
- const bySource = portraitItems.reduce((counts, portrait) => {
|
|
|
|
|
- const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
|
|
|
|
|
- counts[source] += 1;
|
|
|
|
|
- return counts;
|
|
|
|
|
- }, { agent: 0, keyword: 0, other: 0 });
|
|
|
|
|
- summary.portraits = {
|
|
|
|
|
- ...(summary.portraits || {}),
|
|
|
|
|
- total: portraitItems.length,
|
|
|
|
|
- coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
|
|
|
|
|
- bySource,
|
|
|
|
|
- };
|
|
|
|
|
- summary.tags = {
|
|
|
|
|
- ...(summary.tags || {}),
|
|
|
|
|
- total: tags.length,
|
|
|
|
|
- avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- state.portraits.allPortraits = portraits;
|
|
|
|
|
- state.portraits.allCustomers = customers;
|
|
|
|
|
- state.portraits.personalLabels = personalLabels;
|
|
|
|
|
- hideLoading(tableWrap);
|
|
|
|
|
- renderPortraitsKpi(page, summary);
|
|
|
|
|
- renderPortraitsCharts(page, summary, portraits, tags);
|
|
|
|
|
- renderPortraitsTagFilter(page, tags);
|
|
|
|
|
- loadPortraitsTable(page);
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const directoryPromise = api('GET', `/api/dashboard/customers/search?scope=portrait&limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
|
|
|
|
|
+ const labelsPromise = getGuid() && (force || !state.portraits.personalLabelsLoaded)
|
|
|
|
|
+ ? api('POST', '/api/personal-labels/sync', { guid: getGuid() }).catch(() => ({ data: { labels: [] } }))
|
|
|
|
|
+ : Promise.resolve({ data: { labels: state.portraits.personalLabels || [] } });
|
|
|
|
|
+ const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await fastPromise;
|
|
|
|
|
+ const baseInput = { summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes };
|
|
|
|
|
+ state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
|
|
|
|
|
+ ...baseInput,
|
|
|
|
|
+ directoryCustomers: state.portraits.directoryCustomers || [],
|
|
|
|
|
+ personalLabels: state.portraits.personalLabels || [],
|
|
|
|
|
+ });
|
|
|
|
|
+ renderPortraitsDashboardSnapshot(page);
|
|
|
|
|
+
|
|
|
|
|
+ const [directoryRes, personalLabelsRes] = await Promise.all([directoryPromise, labelsPromise]);
|
|
|
|
|
+ state.portraits.directoryCustomers = directoryRes.data?.customers || [];
|
|
|
|
|
+ state.portraits.personalLabels = (personalLabelsRes.data?.labels || []).filter(label => label && label.isDeleted !== 1 && label.dataType !== 2);
|
|
|
|
|
+ state.portraits.personalLabelsLoaded = true;
|
|
|
|
|
+ state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
|
|
|
|
|
+ ...baseInput,
|
|
|
|
|
+ directoryCustomers: state.portraits.directoryCustomers,
|
|
|
|
|
+ personalLabels: state.portraits.personalLabels,
|
|
|
|
|
+ });
|
|
|
|
|
+ renderPortraitsDashboardSnapshot(page);
|
|
|
|
|
+ })().catch(err => {
|
|
|
|
|
+ if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page, false, true));
|
|
|
|
|
+ }).finally(() => { state.portraits.loadingPromise = null; });
|
|
|
|
|
+ state.portraits.loadingPromise = run;
|
|
|
|
|
+ return run;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderPortraitsKpi(page, summary) {
|
|
function renderPortraitsKpi(page, summary) {
|
|
@@ -6419,17 +6893,18 @@
|
|
|
`;
|
|
`;
|
|
|
els.content.appendChild(page);
|
|
els.content.appendChild(page);
|
|
|
|
|
|
|
|
- await loadTransfersDashboard(page);
|
|
|
|
|
|
|
+ if (renderTransfersDashboardSnapshot(page)) void loadTransfersDashboard(page, true);
|
|
|
|
|
+ else await loadTransfersDashboard(page);
|
|
|
dashboardRefreshTimer = setInterval(() => {
|
|
dashboardRefreshTimer = setInterval(() => {
|
|
|
if (state.page === 'transfers') loadTransfersDashboard(page, true);
|
|
if (state.page === 'transfers') loadTransfersDashboard(page, true);
|
|
|
}, 30000);
|
|
}, 30000);
|
|
|
|
|
|
|
|
- page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
|
|
|
|
|
|
|
+ page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page, false, true));
|
|
|
page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
|
|
page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
|
|
|
try {
|
|
try {
|
|
|
await api('POST', '/api/dashboard/rebuild');
|
|
await api('POST', '/api/dashboard/rebuild');
|
|
|
toast('索引已重建');
|
|
toast('索引已重建');
|
|
|
- loadTransfersDashboard(page);
|
|
|
|
|
|
|
+ loadTransfersDashboard(page, false, true);
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
toast(err.message || '重建失败', 'error');
|
|
toast(err.message || '重建失败', 'error');
|
|
|
}
|
|
}
|
|
@@ -6439,30 +6914,68 @@
|
|
|
page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
|
|
page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function loadTransfersDashboard(page, silent = false) {
|
|
|
|
|
|
|
+ function renderTransfersDashboardSnapshot(page) {
|
|
|
|
|
+ const snapshot = state.transfers.dashboardSnapshot;
|
|
|
|
|
+ if (!snapshot || !page?.isConnected) return false;
|
|
|
|
|
+ const signature = JSON.stringify({
|
|
|
|
|
+ summary: snapshot.summary?.transfers || {},
|
|
|
|
|
+ previews: (snapshot.transfers?.previews || []).map(item => [item.filePath, item.status, item.createdAt]),
|
|
|
|
|
+ executions: (snapshot.transfers?.executions || []).map(item => [item.filePath, item.status, item.executedAt]),
|
|
|
|
|
+ candidateIds: (snapshot.candidates || []).map(item => item.externalUserId),
|
|
|
|
|
+ groupIds: (snapshot.groups || []).map(item => item.roomId),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (page.dataset.transferSignature === signature && page.querySelector('#tf-transfer-table-wrap')?.children.length) return true;
|
|
|
|
|
+ renderTransfersKpi(page, snapshot.summary, { candidates: snapshot.candidates, groups: snapshot.groups });
|
|
|
|
|
+ renderTransfersCharts(page, snapshot.summary, snapshot.transfers);
|
|
|
|
|
+ renderTransferSourceSummary(page, snapshot.candidates, snapshot.groups);
|
|
|
|
|
+ state.transfers = {
|
|
|
|
|
+ ...state.transfers,
|
|
|
|
|
+ ...snapshot.transfers,
|
|
|
|
|
+ candidates: snapshot.candidates,
|
|
|
|
|
+ groups: snapshot.groups,
|
|
|
|
|
+ dashboardSnapshot: snapshot,
|
|
|
|
|
+ };
|
|
|
|
|
+ hideLoading(page.querySelector('#tf-transfer-table-wrap'));
|
|
|
|
|
+ loadTransfersTable(page);
|
|
|
|
|
+ page.dataset.transferSignature = signature;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadTransfersDashboard(page, silent = false, force = false) {
|
|
|
|
|
+ if (state.transfers.loadingPromise) return state.transfers.loadingPromise;
|
|
|
const tableWrap = page.querySelector('#tf-transfer-table-wrap');
|
|
const tableWrap = page.querySelector('#tf-transfer-table-wrap');
|
|
|
- if (!silent) showLoading(tableWrap, 'table');
|
|
|
|
|
- try {
|
|
|
|
|
- const [summaryRes, transfersRes, customersRes, groupsRes] = await Promise.all([
|
|
|
|
|
|
|
+ if (!silent && !state.transfers.dashboardSnapshot) showLoading(tableWrap, 'table');
|
|
|
|
|
+ const run = (async () => {
|
|
|
|
|
+ const candidatesPromise = api('GET', `/api/dashboard/customers/search?limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
|
|
|
|
|
+ const [summaryRes, transfersRes, groupsRes] = await Promise.all([
|
|
|
api('GET', '/api/dashboard/summary'),
|
|
api('GET', '/api/dashboard/summary'),
|
|
|
api('GET', '/api/dashboard/transfers'),
|
|
api('GET', '/api/dashboard/transfers'),
|
|
|
- api('GET', '/api/dashboard/customers/search?limit=100&sortBy=nameAsc'),
|
|
|
|
|
api('GET', '/api/groups/list')
|
|
api('GET', '/api/groups/list')
|
|
|
]);
|
|
]);
|
|
|
const summary = summaryRes.data || {};
|
|
const summary = summaryRes.data || {};
|
|
|
const transfers = transfersRes.data || {};
|
|
const transfers = transfersRes.data || {};
|
|
|
- const candidates = customersRes.data?.customers || [];
|
|
|
|
|
const groups = groupsRes.data?.groups || [];
|
|
const groups = groupsRes.data?.groups || [];
|
|
|
-
|
|
|
|
|
- hideLoading(tableWrap);
|
|
|
|
|
- renderTransfersKpi(page, summary, { candidates, groups });
|
|
|
|
|
- renderTransfersCharts(page, summary, transfers);
|
|
|
|
|
- renderTransferSourceSummary(page, candidates, groups);
|
|
|
|
|
- state.transfers = { ...state.transfers, ...transfers, candidates, groups };
|
|
|
|
|
- loadTransfersTable(page);
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ state.transfers.dashboardSnapshot = {
|
|
|
|
|
+ summary,
|
|
|
|
|
+ transfers,
|
|
|
|
|
+ candidates: state.transfers.candidates || [],
|
|
|
|
|
+ groups,
|
|
|
|
|
+ };
|
|
|
|
|
+ renderTransfersDashboardSnapshot(page);
|
|
|
|
|
+
|
|
|
|
|
+ const customersRes = await candidatesPromise;
|
|
|
|
|
+ state.transfers.dashboardSnapshot = {
|
|
|
|
|
+ summary,
|
|
|
|
|
+ transfers,
|
|
|
|
|
+ candidates: customersRes.data?.customers || [],
|
|
|
|
|
+ groups,
|
|
|
|
|
+ };
|
|
|
|
|
+ renderTransfersDashboardSnapshot(page);
|
|
|
|
|
+ })().catch(err => {
|
|
|
|
|
+ if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page, false, true));
|
|
|
|
|
+ }).finally(() => { state.transfers.loadingPromise = null; });
|
|
|
|
|
+ state.transfers.loadingPromise = run;
|
|
|
|
|
+ return run;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderTransfersKpi(page, summary, source = {}) {
|
|
function renderTransfersKpi(page, summary, source = {}) {
|