feat(personal-assistant): add confirmed project memory
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
|
||||
<button class="source-option active" aria-current="page">工作助手</button>
|
||||
<button class="source-option" @click="openWebAi">查全网</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<view class="bubble-row left">
|
||||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">你好,我是老师傅。工作里的 SOP、话术、流程直接问我,答案都带引用依据;也可以按住下面的按钮说话。</text>
|
||||
<text class="bubble-text">工作里的问题直接问我,我会查制度、SOP、项目记录和经你确认保存的事项;也可以按住下面的按钮说话。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||
</button>
|
||||
<button v-if="msg.result.noEvidence" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||||
</view>
|
||||
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
|
||||
<button
|
||||
@@ -112,8 +113,14 @@
|
||||
|
||||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||||
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
|
||||
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
|
||||
{{ citationDomainLabel(snippet) }}
|
||||
</text>
|
||||
<text class="citation-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text v-if="snippet.domain === 'PROJECT_SERVICE'" class="citation-meta">
|
||||
{{ serviceMemoryStatusLabel(snippet.status) }} · 发生 {{ displayCitationDate(snippet.occurredAt) }} · 更新 {{ displayCitationDate(snippet.updatedAt) }}
|
||||
</text>
|
||||
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
@@ -149,6 +156,13 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<MemoryCandidateCard
|
||||
v-if="msg.result.memoryCandidate"
|
||||
:candidate="msg.result.memoryCandidate"
|
||||
@dismissed="hideMemoryCandidate(msg)"
|
||||
@refresh="refreshMemoryCandidate(msg, $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -156,7 +170,7 @@
|
||||
<view v-if="!loggedIn" class="bubble-row left">
|
||||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
|
||||
<text class="bubble-text">登录后才能使用工作助手、确认保存事项并查询内部资料。</text>
|
||||
<view class="bubble-actions">
|
||||
<button class="bubble-action primary" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
@@ -167,6 +181,8 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="scope-note">资料范围:自动 · 仅查询当前有权限的内部资料</view>
|
||||
|
||||
<ChatComposer
|
||||
ref="composerRef"
|
||||
:disabled="!loggedIn"
|
||||
@@ -278,6 +294,7 @@ import type {
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
|
||||
import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
@@ -299,6 +316,8 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
|
||||
@@ -372,6 +391,9 @@ let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let knowledgeSearchGeneration = 0;
|
||||
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
let candidateRefreshGeneration = 0;
|
||||
let showGeneration = 0;
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
@@ -418,6 +440,15 @@ const applyConversationState = (result: KnowledgeSearchResponse) => {
|
||||
}
|
||||
};
|
||||
|
||||
const retireEarlierMemoryCandidate = (candidateId?: number) => {
|
||||
if (!candidateId) return;
|
||||
messages.value.forEach((item) => {
|
||||
if (item.role === 'master' && item.result.memoryCandidate?.id === candidateId) {
|
||||
item.result.memoryCandidate = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const releaseMessageResources = () => {
|
||||
messages.value.forEach((msg) => {
|
||||
if (msg.role !== 'master') return;
|
||||
@@ -430,6 +461,7 @@ const releaseMessageResources = () => {
|
||||
|
||||
const resetConversation = (notify: boolean) => {
|
||||
cancelPendingMediaSearch();
|
||||
candidateRefreshGeneration += 1;
|
||||
releaseMessageResources();
|
||||
speechPlayback.stop();
|
||||
messages.value = [];
|
||||
@@ -474,16 +506,21 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const requestShowGeneration = showGeneration;
|
||||
const requestAccountKey = currentAccountKey();
|
||||
const isCurrentQuestion = () => requestShowGeneration === showGeneration
|
||||
&& requestAccountKey === currentAccountKey();
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, text);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!isCurrentQuestion()) return;
|
||||
pushMsg<UserMsg>({
|
||||
role: 'user',
|
||||
text,
|
||||
@@ -503,6 +540,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
: await searchKnowledge(text, getSelectedPosition(), conversationState());
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
applyConversationState(nextResult);
|
||||
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
|
||||
const spoken = (nextResult.answer || '').trim();
|
||||
replaceMsg(pending.id, {
|
||||
id: pending.id,
|
||||
@@ -518,7 +556,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
transcriptOpen: false
|
||||
});
|
||||
if (spoken) void prepareVoice(pending.id, spoken);
|
||||
if (!nextResult.answer && !nextResult.snippets?.length) {
|
||||
if (!nextResult.answer && !nextResult.snippets?.length && !nextResult.memoryCandidate) {
|
||||
pushSystem('未检索到可引用内容,换个问法试试');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -748,6 +786,22 @@ const cleanOrderedText = (text = '') =>
|
||||
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
|
||||
.trim();
|
||||
|
||||
const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
|
||||
if (snippet.domain === 'PROJECT_SERVICE') return '项目记录';
|
||||
if (snippet.domain === 'PERSONAL') return '我的资料';
|
||||
if (snippet.sourceType === 'DATA_TOOL') return '数据工具';
|
||||
return '企业 SOP';
|
||||
};
|
||||
|
||||
const serviceMemoryStatusLabel = (status?: string | null) => ({
|
||||
OPEN: '待跟进',
|
||||
IN_PROGRESS: '跟进中',
|
||||
RESOLVED: '已完成',
|
||||
VOID: '已作废'
|
||||
}[status || ''] || '状态未知');
|
||||
|
||||
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
|
||||
|
||||
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||||
const fragmentIds = Array.from(
|
||||
@@ -858,30 +912,60 @@ const openWebAi = () => {
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
const hideMemoryCandidate = (msg: MasterMsg) => {
|
||||
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
|
||||
};
|
||||
|
||||
const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
|
||||
const generation = ++candidateRefreshGeneration;
|
||||
const accountKey = currentAccountKey();
|
||||
try {
|
||||
const candidates = await getMemoryCandidates('DRAFT');
|
||||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||||
msg.result.memoryCandidate = candidates.find((item) => item.id === candidateId) || null;
|
||||
} catch (error) {
|
||||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||||
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
const generation = ++showGeneration;
|
||||
resetPageScroll();
|
||||
const accountKey = currentAccountKey();
|
||||
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== accountKey) resetConversation(false);
|
||||
visibleAccountKey = accountKey;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value && messages.value.length) {
|
||||
resetConversation(false);
|
||||
}
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
|
||||
return;
|
||||
if (!isCurrentShow()) return;
|
||||
if (loggedIn.value) {
|
||||
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
|
||||
if (!isCurrentShow() || !hasEmployeePosition) return;
|
||||
}
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
scrollToBottom();
|
||||
if (!isCurrentShow()) return;
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
if (!isCurrentShow()) return;
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!isCurrentShow()) return;
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
if (!isCurrentShow()) return;
|
||||
void runDataTool(pendingTool);
|
||||
return;
|
||||
}
|
||||
if (pending && loggedIn.value) {
|
||||
if (!isCurrentShow()) return;
|
||||
uni.removeStorageSync(pendingSopQueryKey);
|
||||
if (!isCurrentShow()) return;
|
||||
void sendQuestion(String(pending));
|
||||
}
|
||||
};
|
||||
@@ -891,6 +975,8 @@ onShow(() => {
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
showGeneration += 1;
|
||||
candidateRefreshGeneration += 1;
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
@@ -898,6 +984,7 @@ onHide(() => {
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
showGeneration += 1;
|
||||
releaseMessageResources();
|
||||
speechPlayback.destroy();
|
||||
composerRef.value?.teardown();
|
||||
@@ -2295,6 +2382,22 @@ onUnload(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.citation-domain {
|
||||
display: inline-flex;
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f8;
|
||||
color: #526176;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.citation-domain.project {
|
||||
background: #fff0f2;
|
||||
color: #c8001a;
|
||||
}
|
||||
|
||||
.citation-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
@@ -2423,4 +2526,18 @@ onUnload(() => {
|
||||
color: #98a1ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid #eceef1;
|
||||
background: #ffffff;
|
||||
color: #8a93a0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.master-col :deep(.memory-card) {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user