feat(knowledge): add short-term ask memory
This commit is contained in:
@@ -164,7 +164,9 @@ const sendDraft = () => {
|
||||
|
||||
const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => {
|
||||
panelOpen.value = false;
|
||||
emit(event);
|
||||
if (event === 'pick-media') emit('pick-media');
|
||||
else if (event === 'pick-audio') emit('pick-audio');
|
||||
else emit('tool');
|
||||
};
|
||||
|
||||
const panelAction = (key: string) => {
|
||||
|
||||
@@ -485,12 +485,6 @@ const styleOptions = [
|
||||
{ value: 'professional', label: '更专业' }
|
||||
];
|
||||
|
||||
const roleLabel = (role: PracticeRole) => {
|
||||
if (role === 'customer') return '业主';
|
||||
if (role === 'coach') return '老师傅';
|
||||
return '我';
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<view class="chat-head">
|
||||
<view class="chat-head-row">
|
||||
<text class="chat-title">问 · 数字师傅</text>
|
||||
<text class="chat-context">{{ positionLabel }} · 住宅项目</text>
|
||||
<view class="chat-head-actions">
|
||||
<text class="chat-context">{{ positionLabel }} · 住宅项目</text>
|
||||
<button class="new-chat-button" :disabled="loading" @click="startNewConversation">新对话</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
@@ -59,6 +62,10 @@
|
||||
</view>
|
||||
|
||||
<view v-if="msg.voiceStatus === 'unavailable' || msg.transcriptOpen" class="bubble master transcript-bubble">
|
||||
<text
|
||||
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
|
||||
class="context-used"
|
||||
>已结合本次对话理解</text>
|
||||
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
|
||||
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
|
||||
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
|
||||
@@ -116,6 +123,32 @@
|
||||
<text class="transcript-collapse-text">收起全文 ▲</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="msg.result.resources?.length" class="resource-list">
|
||||
<view
|
||||
v-for="resource in msg.result.resources"
|
||||
:key="resource.attachmentId"
|
||||
class="resource-card"
|
||||
:class="{ video: resource.type === 'VIDEO' }"
|
||||
@click="openKnowledgeResource(resource)"
|
||||
>
|
||||
<view class="resource-icon">{{ resource.type === 'VIDEO' ? '▶' : '文' }}</view>
|
||||
<view class="resource-copy">
|
||||
<text class="resource-title">{{ resource.title }}</text>
|
||||
<text class="resource-meta">
|
||||
{{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="resource-arrow">›</text>
|
||||
<video
|
||||
v-if="resource.type === 'VIDEO' && resource.localUrl"
|
||||
class="resource-video"
|
||||
:src="resource.localUrl"
|
||||
controls
|
||||
@click.stop
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -237,7 +270,9 @@ import { nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeConversationState,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeResource,
|
||||
KnowledgeSearchResponse,
|
||||
SpeechPlaybackStatus,
|
||||
SummaryCardResponse
|
||||
@@ -260,6 +295,7 @@ import {
|
||||
roleKey
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
@@ -323,6 +359,14 @@ const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
|
||||
const supervisorLearner = ref(isSupervisorLearnerMode());
|
||||
const createConversationId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
||||
};
|
||||
const conversationId = ref(createConversationId());
|
||||
const contextVersion = ref(0);
|
||||
let msgSeq = 0;
|
||||
let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
@@ -363,6 +407,41 @@ const replaceMsg = (id: number, next: ChatMsg | null) => {
|
||||
|
||||
const pushSystem = (text: string) => pushMsg<SystemMsg>({ role: 'system', text });
|
||||
|
||||
const conversationState = (): KnowledgeConversationState => ({
|
||||
conversationId: conversationId.value,
|
||||
contextVersion: contextVersion.value
|
||||
});
|
||||
|
||||
const applyConversationState = (result: KnowledgeSearchResponse) => {
|
||||
if (result.conversationId === conversationId.value && typeof result.contextVersion === 'number') {
|
||||
contextVersion.value = result.contextVersion;
|
||||
}
|
||||
};
|
||||
|
||||
const releaseMessageResources = () => {
|
||||
messages.value.forEach((msg) => {
|
||||
if (msg.role !== 'master') return;
|
||||
(msg.result.resources || []).forEach((resource) => {
|
||||
if (resource.localUrl) releaseAuthenticatedFileUrl(resource.localUrl);
|
||||
resource.localUrl = undefined;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = (notify: boolean) => {
|
||||
cancelPendingMediaSearch();
|
||||
releaseMessageResources();
|
||||
speechPlayback.stop();
|
||||
messages.value = [];
|
||||
summaryCard.value = null;
|
||||
selectedMedia.value = null;
|
||||
conversationId.value = createConversationId();
|
||||
contextVersion.value = 0;
|
||||
if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解');
|
||||
};
|
||||
|
||||
const startNewConversation = () => resetConversation(true);
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
@@ -420,9 +499,10 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
const nextResult = media
|
||||
? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => {
|
||||
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
|
||||
})
|
||||
: await searchKnowledge(text);
|
||||
}, conversationState())
|
||||
: await searchKnowledge(text, getSelectedPosition(), conversationState());
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
applyConversationState(nextResult);
|
||||
const spoken = (nextResult.answer || '').trim();
|
||||
replaceMsg(pending.id, {
|
||||
id: pending.id,
|
||||
@@ -480,8 +560,9 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
|
||||
loading.value = true;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
try {
|
||||
const nextResult = await queryKnowledgeTool(effectiveToolCode, question);
|
||||
const nextResult = await queryKnowledgeTool(effectiveToolCode, question, conversationState());
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
applyConversationState(nextResult);
|
||||
replaceMsg(pending.id, {
|
||||
id: pending.id,
|
||||
role: 'master',
|
||||
@@ -626,6 +707,36 @@ const playVoice = (msg: MasterMsg) => {
|
||||
void speechPlayback.toggle(`answer-${msg.id}`, spokenTextOf(msg));
|
||||
};
|
||||
|
||||
const openKnowledgeResource = async (resource: KnowledgeResource) => {
|
||||
if (resource.loading) return;
|
||||
if (resource.type === 'VIDEO' && resource.localUrl) return;
|
||||
resource.loading = true;
|
||||
try {
|
||||
const localUrl = await authenticatedFileUrl(resource.contentUrl);
|
||||
if (resource.localUrl && resource.localUrl !== localUrl) {
|
||||
releaseAuthenticatedFileUrl(resource.localUrl);
|
||||
}
|
||||
resource.localUrl = localUrl;
|
||||
if (resource.type === 'FILE') {
|
||||
if (typeof document !== 'undefined') {
|
||||
const link = document.createElement('a');
|
||||
link.href = localUrl;
|
||||
link.download = resource.title || '资料文件';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} else {
|
||||
uni.openDocument({ filePath: localUrl, showMenu: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' });
|
||||
} finally {
|
||||
resource.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const snippetExcerpt = (text = '') => {
|
||||
const value = text.trim();
|
||||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||||
@@ -750,6 +861,9 @@ const openWebAi = () => {
|
||||
const handleShow = async () => {
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value && messages.value.length) {
|
||||
resetConversation(false);
|
||||
}
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
|
||||
return;
|
||||
}
|
||||
@@ -784,6 +898,7 @@ onHide(() => {
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
releaseMessageResources();
|
||||
speechPlayback.destroy();
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
@@ -1873,6 +1988,23 @@ onUnload(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
margin: 0;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid #e3e6ea;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #5a6270;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-source-switch {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
@@ -1971,7 +2103,7 @@ onUnload(() => {
|
||||
border-radius: 6px;
|
||||
background: rgba(200, 0, 26, 0.08);
|
||||
color: #a04a00;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bubble.user .bubble-tag {
|
||||
@@ -2013,6 +2145,13 @@ onUnload(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-used {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #74839a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bubble-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2062,6 +2201,80 @@ onUnload(() => {
|
||||
border-top: 1px dashed #eceff2;
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 16px;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e3e7ed;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
|
||||
}
|
||||
|
||||
.resource-card.video {
|
||||
border-color: #f1cbd0;
|
||||
}
|
||||
|
||||
.resource-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: #fff0f2;
|
||||
color: #c8001a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.resource-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-title,
|
||||
.resource-meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
color: #232a33;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-meta {
|
||||
margin-top: 3px;
|
||||
color: #8a93a0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.resource-arrow {
|
||||
color: #a4abb5;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.resource-video {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
height: 156px;
|
||||
margin-top: 2px;
|
||||
border-radius: 8px;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.citation {
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #f0c0c5;
|
||||
@@ -2080,7 +2293,7 @@ onUnload(() => {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #98a1ad;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.citation-content {
|
||||
|
||||
@@ -174,7 +174,6 @@ const positionProfile = ref(getSelectedPositionProfile());
|
||||
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
|
||||
const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP');
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeSearchResponse,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeConversationState,
|
||||
UnifiedKnowledgeResponse,
|
||||
KnowledgeSnippet,
|
||||
PositionSopSummary,
|
||||
@@ -53,7 +54,11 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
content: snippet.content || snippet.text || ''
|
||||
});
|
||||
|
||||
export const searchKnowledge = async (queryText: string, position = getSelectedPosition()) => {
|
||||
export const searchKnowledge = async (
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
conversation?: KnowledgeConversationState
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
method: 'POST',
|
||||
@@ -61,7 +66,9 @@ export const searchKnowledge = async (queryText: string, position = getSelectedP
|
||||
queryText,
|
||||
category: 'sop',
|
||||
position,
|
||||
limit: 5
|
||||
limit: 5,
|
||||
conversationId: conversation?.conversationId,
|
||||
contextVersion: conversation?.contextVersion
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -72,14 +79,22 @@ export const searchKnowledgeWithMedia = (
|
||||
file: KnowledgeMediaSelectedFile,
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
|
||||
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void,
|
||||
conversation?: KnowledgeConversationState
|
||||
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/knowledge/query-media'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: { queryText, position },
|
||||
formData: {
|
||||
queryText,
|
||||
position,
|
||||
...(conversation ? {
|
||||
conversationId: conversation.conversationId,
|
||||
contextVersion: String(conversation.contextVersion)
|
||||
} : {})
|
||||
},
|
||||
timeout: 360000,
|
||||
success: (response) => {
|
||||
try {
|
||||
@@ -97,12 +112,20 @@ export const searchKnowledgeWithMedia = (
|
||||
|
||||
export const queryKnowledgeTool = async (
|
||||
toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY',
|
||||
queryText: string
|
||||
queryText: string,
|
||||
conversation?: KnowledgeConversationState
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
method: 'POST',
|
||||
data: { queryText, source: 'mobile_uni_tool', limit: 5, toolCode },
|
||||
data: {
|
||||
queryText,
|
||||
source: 'mobile_uni_tool',
|
||||
limit: 5,
|
||||
toolCode,
|
||||
conversationId: conversation?.conversationId,
|
||||
contextVersion: conversation?.contextVersion
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
return normalizeUnifiedResponse(response);
|
||||
@@ -132,7 +155,12 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
|
||||
usedSpaceCodes: response.usedSpaceCodes || [],
|
||||
noEvidence: response.noEvidence,
|
||||
promptVersion: response.promptVersion,
|
||||
data: response.data
|
||||
data: response.data,
|
||||
conversationId: response.conversationId,
|
||||
contextVersion: response.contextVersion,
|
||||
intent: response.intent,
|
||||
rewrittenQuery: response.rewrittenQuery,
|
||||
resources: response.resources || []
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE';
|
||||
|
||||
@@ -126,6 +126,11 @@ export interface KnowledgeSearchResponse {
|
||||
usedSpaceCodes?: string[];
|
||||
noEvidence?: boolean;
|
||||
data?: unknown;
|
||||
conversationId?: string | null;
|
||||
contextVersion?: number | null;
|
||||
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
|
||||
rewrittenQuery?: string;
|
||||
resources?: KnowledgeResource[];
|
||||
}
|
||||
|
||||
export interface KnowledgeMediaSelectedFile {
|
||||
@@ -144,6 +149,20 @@ export interface KnowledgeCitation {
|
||||
fragmentId?: number | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeResource {
|
||||
attachmentId: number;
|
||||
title: string;
|
||||
type: 'FILE' | 'VIDEO';
|
||||
contentUrl: string;
|
||||
localUrl?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface KnowledgeConversationState {
|
||||
conversationId: string;
|
||||
contextVersion: number;
|
||||
}
|
||||
|
||||
export interface UnifiedKnowledgeResponse {
|
||||
requestId: string;
|
||||
queryText: string;
|
||||
@@ -154,6 +173,11 @@ export interface UnifiedKnowledgeResponse {
|
||||
promptVersion?: string | null;
|
||||
legacy?: KnowledgeSearchResponse | null;
|
||||
data?: unknown;
|
||||
conversationId?: string | null;
|
||||
contextVersion?: number | null;
|
||||
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
|
||||
rewrittenQuery?: string;
|
||||
resources?: KnowledgeResource[];
|
||||
}
|
||||
|
||||
export interface CaseCapabilities {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('问师傅为文字、媒体和数据工具请求携带同一短期会话版本', async () => {
|
||||
const service = await read('../src/services/knowledge.ts');
|
||||
|
||||
assert.match(service, /conversationId:\s*conversation\?\.conversationId/);
|
||||
assert.match(service, /contextVersion:\s*conversation\?\.contextVersion/);
|
||||
assert.match(service, /conversationId:\s*conversation\.conversationId/);
|
||||
assert.match(service, /resources:\s*response\.resources \|\| \[\]/);
|
||||
});
|
||||
|
||||
test('问师傅支持显式新对话、版本回写和受控原文件或视频卡', async () => {
|
||||
const page = await read('../src/pages/user/sop/index.vue');
|
||||
|
||||
assert.match(page, /@click="startNewConversation">新对话/);
|
||||
assert.match(page, /const contextVersion = ref\(0\)/);
|
||||
assert.match(page, /applyConversationState\(nextResult\)/);
|
||||
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
|
||||
assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/);
|
||||
assert.match(page, /resource\.type === 'VIDEO' && resource\.localUrl/);
|
||||
assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/);
|
||||
assert.match(page, /已结合本次对话理解/);
|
||||
});
|
||||
@@ -22,14 +22,14 @@ test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => {
|
||||
assert.equal(queryPanelStyle.includes('box-sizing'), false);
|
||||
});
|
||||
|
||||
test('员工首页地图主标题保留真实工具名称', async () => {
|
||||
test('员工首页核心工具保留真实业务入口', async () => {
|
||||
const source = await pageSource('../src/pages/user/today/index.vue');
|
||||
|
||||
assert.match(source, /class="map-symbol"/);
|
||||
assert.match(source, /<text class="map-title">\{\{ tile\.title \}\}<\/text>/);
|
||||
assert.match(source, /title:\s*'问老师傅'/);
|
||||
assert.match(source, /title:\s*'刻意练习'/);
|
||||
assert.match(source, /shortTitle:\s*'问'/);
|
||||
assert.match(source, /<text class="voice-title">问老师傅<\/text>/);
|
||||
assert.match(source, /<text class="question-board-title">问题榜<\/text>/);
|
||||
assert.match(source, /@click="goSop"/);
|
||||
assert.match(source, /const goPractice = \(\) =>/);
|
||||
assert.match(source, /const openQuestions = \(\) =>/);
|
||||
});
|
||||
|
||||
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
|
||||
@@ -74,7 +74,7 @@ test('共享视觉基座包含员工端主色和卡片圆角 token', async () =>
|
||||
assert.match(source, /\.employee-card\s*\{/);
|
||||
});
|
||||
|
||||
test('员工端三页接入共享页面和卡片基座', async () => {
|
||||
test('员工端首页、问师傅和训练页接入当前共享基座', async () => {
|
||||
const [todaySource, sopSource, practiceSource] = await Promise.all([
|
||||
pageSource('../src/pages/user/today/index.vue'),
|
||||
pageSource('../src/pages/user/sop/index.vue'),
|
||||
@@ -83,7 +83,8 @@ test('员工端三页接入共享页面和卡片基座', async () => {
|
||||
|
||||
assert.match(todaySource, /employee-page-shell/);
|
||||
assert.match(todaySource, /section-card employee-card/);
|
||||
assert.match(sopSource, /query-panel employee-card/);
|
||||
assert.match(sopSource, /sop-chat-page/);
|
||||
assert.match(sopSource, /<ChatComposer/);
|
||||
assert.match(practiceSource, /scenario-card employee-card/);
|
||||
});
|
||||
|
||||
@@ -122,13 +123,13 @@ test('主管可切换到学员显示模式且组织角色保持不变', async ()
|
||||
assert.doesNotMatch(supervisorProfile, /setStorageSync\(roleKey,\s*'user'/);
|
||||
});
|
||||
|
||||
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
|
||||
test('员工首页保留师傅区、语音条、今日安排和问题榜结构', async () => {
|
||||
const source = await pageSource('../src/pages/user/today/index.vue');
|
||||
|
||||
assert.match(source, /mentor-hero/);
|
||||
assert.match(source, /voice-panel/);
|
||||
assert.match(source, /今日安排/);
|
||||
assert.match(source, /我的地图/);
|
||||
assert.match(source, /问题榜/);
|
||||
assert.match(source, /brand-bar/);
|
||||
assert.match(source, /ask-card/);
|
||||
assert.match(source, /小贴士/);
|
||||
@@ -319,14 +320,17 @@ test('主管专项从正式团队名单按姓名多选并一次批量派发', as
|
||||
});
|
||||
|
||||
test('问师傅支持现场媒体、答案播报和总结卡图片下载', async () => {
|
||||
const [source, knowledgeService, speechService, imageService] = await Promise.all([
|
||||
const [source, composerSource, knowledgeService, speechService, imageService] = await Promise.all([
|
||||
pageSource('../src/pages/user/sop/index.vue'),
|
||||
pageSource('../src/components/chat/ChatComposer.vue'),
|
||||
pageSource('../src/services/knowledge.ts'),
|
||||
pageSource('../src/services/speech.ts'),
|
||||
pageSource('../src/services/summary-card-image.ts')
|
||||
]);
|
||||
|
||||
assert.match(source, /上传图片或视频/);
|
||||
assert.match(source, /@pick-media="pickMedia"/);
|
||||
assert.match(composerSource, /图片\/视频/);
|
||||
assert.match(composerSource, /仅用于本次现场分析/);
|
||||
assert.match(source, /searchKnowledgeWithMedia/);
|
||||
assert.match(source, /toggleAnswerSpeech/);
|
||||
assert.match(source, /toggleSummarySpeech/);
|
||||
@@ -340,18 +344,21 @@ test('问师傅支持现场媒体、答案播报和总结卡图片下载', async
|
||||
});
|
||||
|
||||
test('问师傅恢复旧版语音提问并在录音不可用时回退选择音频', async () => {
|
||||
const [source, speechService] = await Promise.all([
|
||||
const [source, composerSource, speechService] = await Promise.all([
|
||||
pageSource('../src/pages/user/sop/index.vue'),
|
||||
pageSource('../src/components/chat/ChatComposer.vue'),
|
||||
pageSource('../src/services/speech.ts')
|
||||
]);
|
||||
|
||||
assert.match(source, /语音提问/);
|
||||
assert.match(source, /toggleVoiceRecording/);
|
||||
assert.match(composerSource, /按住 说话/);
|
||||
assert.match(composerSource, /startHold/);
|
||||
assert.match(composerSource, /endHold/);
|
||||
assert.match(composerSource, /navigator\.mediaDevices\?\.getUserMedia/);
|
||||
assert.match(composerSource, /emit\('pick-audio'\)/);
|
||||
assert.match(composerSource, /const teardown = \(\) =>/);
|
||||
assert.match(source, /pickVoiceAudio/);
|
||||
assert.match(source, /transcribeSpeechBlob/);
|
||||
assert.match(source, /transcribeSpeechFile/);
|
||||
assert.match(source, /navigator\.mediaDevices\?\.getUserMedia/);
|
||||
assert.match(source, /cancelVoiceRecording/);
|
||||
assert.match(speechService, /\/api\/ai\/asr/);
|
||||
});
|
||||
|
||||
@@ -359,7 +366,10 @@ test('生成的每日题和对练对话恢复语音播报入口', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /daily-question-voice/);
|
||||
assert.match(source, /turn\.role !== 'trainee'/);
|
||||
assert.match(source, /turn\.role === 'customer'/);
|
||||
assert.match(source, /turn\.role === 'coach'/);
|
||||
assert.match(source, /playTurnVoice/);
|
||||
assert.match(source, /speakGenerated/);
|
||||
assert.match(source, /createSpeechPlaybackController/);
|
||||
assert.match(source, /停止播报/);
|
||||
});
|
||||
@@ -383,10 +393,9 @@ test('语音、媒体和私密画像在切页或退出后不会继续写回', as
|
||||
pageSource('../src/services/knowledge.ts')
|
||||
]);
|
||||
|
||||
assert.match(sopSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*cancelVoiceRecording\(\);[\s\S]*cancelPendingMediaSearch\(\);/);
|
||||
assert.match(sopSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*composerRef\.value\?\.teardown\(\);[\s\S]*cancelVoiceTranscription\(\);[\s\S]*cancelPendingMediaSearch\(\);/);
|
||||
assert.match(sopSource, /activeMediaUploadTask\?\.abort\(\)/);
|
||||
assert.match(sopSource, /generation !== knowledgeSearchGeneration/);
|
||||
assert.match(sopSource, /voiceStarting\.value/);
|
||||
assert.match(sopSource, /abortVoiceTranscription\?\.\(\)/);
|
||||
assert.match(sopSource, /generation !== voiceTranscriptionGeneration/);
|
||||
assert.match(practiceSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*cancelRecording\(\);/);
|
||||
@@ -409,7 +418,8 @@ test('播报复用已合成音频且总结图按内容高度生成并及时释
|
||||
|
||||
assert.match(speechService, /const sourceCache = new Map<string, string>\(\)/);
|
||||
assert.match(speechService, /const maxCachedSources = 4/);
|
||||
assert.match(speechService, /const cachedSource = sourceCache\.get\(value\)/);
|
||||
assert.match(speechService, /const sourceKey = cacheKey\(value, profile\)/);
|
||||
assert.match(speechService, /const cachedSource = sourceCache\.get\(sourceKey\)/);
|
||||
assert.match(speechService, /sourceCache\.clear\(\)/);
|
||||
assert.match(speechService, /registerAbort\?\.\(\(\) => task\.abort\(\)\)/);
|
||||
assert.match(speechService, /registerAbort\?\.\(\(\) => xhr\.abort\(\)\)/);
|
||||
|
||||
Reference in New Issue
Block a user