feat(knowledge): add short-term ask memory

This commit is contained in:
2026-07-18 21:22:49 +08:00
parent 2eda757483
commit 064816a784
33 changed files with 1348 additions and 99 deletions
@@ -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 '生成中';
+219 -6
View File
@@ -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 = '';
+35 -7
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import type { ApiPayload } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE';
+24
View File
@@ -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 {