feat(assistant): unify confirmed memory capture

This commit is contained in:
2026-07-20 02:30:06 +08:00
parent 46a6156d8c
commit 0a5fc9a463
32 changed files with 949 additions and 242 deletions
@@ -1,24 +1,16 @@
<template>
<view class="memory-card" :class="{ compact }">
<template v-if="candidate.status === 'NEEDS_INPUT'">
<view class="memory-head">
<text class="memory-title">还需要补充信息</text>
<text class="memory-tag muted">暂不保存</text>
</view>
<text class="needs-input-copy">{{ missingPrompt }}</text>
</template>
<template v-else-if="completed">
<template v-if="completed">
<view class="memory-head">
<text class="memory-title">已确认保存</text>
<text class="memory-tag success">{{ targetLabel }}</text>
</view>
<text class="saved-copy">这条事项已保存,后续工作助手可按权限查询。</text>
<text class="saved-copy">{{ completedCopy }}</text>
</template>
<template v-else-if="candidate.status === 'DRAFT'">
<template v-else-if="candidate.status === 'DRAFT' || candidate.status === 'NEEDS_INPUT'">
<view class="memory-head">
<text class="memory-title">发现一条可保存的信息</text>
<text class="memory-title">已帮你整理</text>
<text class="memory-tag">{{ memoryTypeLabel }}</text>
</view>
@@ -58,8 +50,8 @@
<text class="row-value">{{ draft.summary || '待补充' }}</text>
</view>
<view class="memory-row">
<text class="row-label">保存到</text>
<text class="row-value">{{ targetLabel }}</text>
<text class="row-label">保存范围</text>
<text class="row-value">{{ assistantCandidate ? '由你确认' : targetLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">记录时间</text>
@@ -67,6 +59,8 @@
</view>
</view>
<text v-if="optionalPrompt" class="optional-copy">{{ optionalPrompt }}</text>
<button
class="reminder-switch"
role="switch"
@@ -83,14 +77,28 @@
</view>
</picker>
<text class="memory-note">确认后才会长期保存;暂不保存不会写入个人资料或项目记录。</text>
<text class="memory-note">
{{ assistantCandidate ? '仅自己保存不会提交公司;提交公司处理后进入企业流转。' : '确认后才会长期保存;暂不保存不会写入项目记录。' }}
</text>
<text v-if="errorText" class="memory-error">{{ errorText }}</text>
<view class="memory-actions">
<button v-if="!editing" class="memory-action plain" :disabled="submitting" @click="editing = true">修改</button>
<button v-else class="memory-action plain" :disabled="submitting" @click="editing = false">完成修改</button>
<button class="memory-action outline" :disabled="submitting" @click="dismiss">暂不保存</button>
<button class="memory-action primary" :disabled="submitting" @click="confirm">确认保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope private"
:disabled="submitting"
@click="confirm('PRIVATE')"
>仅自己保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope company"
:disabled="submitting"
@click="confirm('COMPANY')"
>提交公司处理</button>
<button v-else class="memory-action primary" :disabled="submitting" @click="confirm()">确认保存</button>
</view>
</template>
@@ -147,6 +155,7 @@ const editing = ref(false);
const reminderEnabled = ref(false);
const submitting = ref(false);
const completed = ref(false);
const confirmedTarget = ref('');
const errorText = ref('');
const idempotencyKey = ref(createMemoryIdempotencyKey(props.candidate.id));
@@ -158,17 +167,28 @@ const missingProjectLocationLabels = computed(() => [
{ label: '单元', value: draft.value.unitName },
{ label: '房号', value: draft.value.roomNo }
].filter((item) => !item.value?.trim()).map((item) => item.label));
const assistantCandidate = computed(() => props.candidate.targetDomain === 'ASSISTANT_CAPTURE');
const memoryTypeLabel = computed(() => ({
RESIDENT_NEED: '住户需求',
SERVICE_LEAD: '服务线索',
RESIDENT_PROFILE: '住户画像',
CASE: '优秀案例',
PROJECT_NOTE: '工作记录',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项',
PERSONAL_NOTE: '个人事项'
PERSONAL_NOTE: '个人想法'
}[props.candidate.memoryType] || '工作事项'));
const targetLabel = computed(() =>
props.candidate.targetDomain === 'PROJECT_SERVICE' ? '项目服务记录' : '我的资料与记忆'
);
const targetLabel = computed(() => ({
PROJECT_SERVICE: '项目服务记录',
PERSONAL: '仅自己可见',
COMPANY: '公司处理中'
}[confirmedTarget.value || props.candidate.targetDomain] || '我的资料与记忆'));
const completedCopy = computed(() => confirmedTarget.value === 'COMPANY'
? '已提交公司处理,可在“我的资料与记忆”中查看流转状态。'
: '这条信息已保存,后续工作助手可按权限查询。');
const terminalStatusTitle = computed(() => ({
CONFIRMED: '这条事项已保存',
@@ -176,7 +196,7 @@ const terminalStatusTitle = computed(() => ({
EXPIRED: '这条确认卡已过期'
}[props.candidate.status] || '这条确认卡已结束'));
const missingPrompt = computed(() => {
const optionalPrompt = computed(() => {
const labels: Record<string, string> = {
projectCode: '所属项目',
buildingName: '楼栋',
@@ -187,7 +207,7 @@ const missingPrompt = computed(() => {
occurredAt: '发生时间'
};
const fields = (props.candidate.missingFields || []).map((item) => labels[item] || item);
return fields.length ? `请继续告诉我:${fields.join('、')}。信息补齐后再请你确认保存。` : '请继续补充对象或事项信息,信息补齐后再请你确认保存。';
return fields.length ? `可继续补充:${fields.join('、')}(不影响先保存)` : '';
});
const displayDate = (value: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
@@ -204,14 +224,14 @@ const changeFollowUpDate = (event: { detail?: { value?: string } }) => {
draft.value.followUpAt = value ? `${value}T09:00:00+08:00` : null;
};
const confirm = async () => {
const confirm = async (saveScope: '' | 'PRIVATE' | 'COMPANY' = '') => {
if (submitting.value || completed.value) return;
if (!draft.value.category || !draft.value.summary || !draft.value.occurredAt) {
errorText.value = '请先补全事项、摘要和记录时间';
editing.value = true;
return;
}
if (props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
if (!assistantCandidate.value && props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
errorText.value = `项目服务记录还需补充:${missingProjectLocationLabels.value.join('、')}`;
editing.value = true;
return;
@@ -231,10 +251,12 @@ const confirm = async () => {
...draft.value,
followUpAt: reminderEnabled.value ? draft.value.followUpAt : null
},
enableReminder: reminderEnabled.value
enableReminder: reminderEnabled.value,
saveScope: saveScope || undefined
});
if (requestAccountKey !== currentAccountKey()) return;
completed.value = true;
confirmedTarget.value = result.targetDomain;
emit('confirmed', result);
} catch (error) {
errorText.value = error instanceof Error ? error.message : '保存失败,请稍后重试';
@@ -267,6 +289,7 @@ watch(
reminderEnabled.value = false;
editing.value = false;
completed.value = false;
confirmedTarget.value = '';
errorText.value = '';
idempotencyKey.value = createMemoryIdempotencyKey(props.candidate.id);
}
@@ -451,7 +474,7 @@ watch(
.memory-note,
.memory-error,
.needs-input-copy,
.optional-copy,
.saved-copy {
display: block;
margin-top: 10px;
@@ -467,12 +490,20 @@ watch(
color: #c8001a;
}
.needs-input-copy,
.optional-copy,
.saved-copy {
color: #596474;
font-size: 14px;
}
.optional-copy {
padding: 9px 10px;
border-radius: 10px;
background: #f7f8fa;
color: #7a8491;
font-size: 12px;
}
.memory-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -510,6 +541,20 @@ watch(
color: #ffffff;
}
.memory-action.scope {
border: 1px solid #e60012;
}
.memory-action.scope.private {
background: #ffffff;
color: #d70016;
}
.memory-action.scope.company {
background: #e60012;
color: #ffffff;
}
@media (max-width: 360px) {
.memory-card {
padding: 14px;
@@ -5,7 +5,7 @@
<view class="subpage-heading">
<text class="employee-kicker">工作助手</text>
<text class="page-title">我的资料与记忆</text>
<text class="page-subtitle">首版仅展示待确认事项和项目跟进;完整个人资料库将在后续开放。</text>
<text class="page-subtitle">查看仅自己保存的信息、公司处理进度和项目跟进。</text>
</view>
</view>
@@ -38,7 +38,35 @@
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无待确认事项</text>
<text class="empty-copy">在工作助手里说“记一下”,识别到完整信息后会先让你确认。</text>
<text class="empty-copy">在工作助手里说“记一下”,系统整理后会先让你选择保存范围。</text>
</view>
<view class="section-head followup-head">
<view>
<text class="section-title">我的记录</text>
<text class="section-note">仅自己保存的内容不会提交公司</text>
</view>
</view>
<view v-if="captures.length" class="service-list">
<view v-for="item in captures" :key="item.id" class="service-card employee-card">
<view class="service-head">
<text class="service-category">{{ captureTypeLabel(item.suggestedType) }}</text>
<text class="service-status" :class="{ done: item.saveScope === 'PRIVATE' }">
{{ captureScopeLabel(item) }}
</text>
</view>
<text class="service-subject">{{ captureSubject(item) || item.category }}</text>
<text class="service-summary">{{ item.summary }}</text>
<view class="service-meta">
<text>记录 {{ displayDate(item.occurredAt) }}</text>
<text v-if="item.followUpAt">跟进 {{ displayDate(item.followUpAt) }}</text>
<text v-if="item.saveScope === 'COMPANY'">{{ deliveryLabel(item.deliveryStatus) }}</text>
</view>
</view>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无已保存记录</text>
<text class="empty-copy">确认保存后的个人想法、服务线索和工作事项会显示在这里。</text>
</view>
<view class="section-head followup-head">
@@ -74,9 +102,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { MemoryCandidate, ServiceMemory } from '@/types/api';
import type { AssistantCapture, MemoryCandidate, ServiceMemory } from '@/types/api';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import { getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { getAssistantCaptures, getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { ensureEmployeePosition } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
@@ -87,6 +115,7 @@ const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const candidates = ref<MemoryCandidate[]>([]);
const captures = ref<AssistantCapture[]>([]);
const serviceMemories = ref<ServiceMemory[]>([]);
const requests = createPageRequestScope();
@@ -122,41 +151,52 @@ const serviceSubject = (item: ServiceMemory) => {
].filter(Boolean).join('');
};
const captureSubject = (item: AssistantCapture) => [item.buildingName, item.unitName, item.roomNo]
.filter(Boolean).join('');
const captureTypeLabel = (type = '') => ({
SERVICE_LEAD: '服务线索',
RESIDENT_PROFILE: '住户画像',
CASE: '优秀案例',
PERSONAL_NOTE: '个人想法',
FOLLOW_UP: '待跟进事项',
PROJECT_NOTE: '工作记录'
}[type] || '工作记录');
const captureScopeLabel = (item: AssistantCapture) => item.saveScope === 'PRIVATE' ? '仅自己' : '公司处理';
const deliveryLabel = (status = '') => ({
PENDING: '待流转',
DELIVERED: '已流转',
FAILED: '流转待重试'
}[status] || '处理中');
const removeCandidate = (candidateId: number, toast: string) => {
candidates.value = candidates.value.filter((item) => item.id !== candidateId);
uni.showToast({ title: toast, icon: 'none' });
void loadServiceMemories();
};
const loadServiceMemories = async () => {
const request = requests.begin('service-memories');
try {
const result = await getServiceMemories();
if (requests.isCurrent(request)) serviceMemories.value = result;
} catch (error) {
if (requests.isCurrent(request)) {
message.value = error instanceof Error ? error.message : '项目跟进读取失败';
}
}
void load();
};
const load = async () => {
loggedIn.value = isLoggedIn();
candidates.value = [];
captures.value = [];
serviceMemories.value = [];
message.value = '';
if (!loggedIn.value) return;
if (!(await ensureEmployeePosition(path))) return;
const request = requests.begin('memory-page');
loading.value = true;
const [candidateResult, serviceResult] = await Promise.allSettled([
const [candidateResult, captureResult, serviceResult] = await Promise.allSettled([
getMemoryCandidates('DRAFT'),
getAssistantCaptures(),
getServiceMemories()
]);
if (!requests.isCurrent(request)) return;
if (candidateResult.status === 'fulfilled') candidates.value = candidateResult.value;
if (captureResult.status === 'fulfilled') captures.value = captureResult.value;
if (serviceResult.status === 'fulfilled') serviceMemories.value = serviceResult.value;
const errors = [candidateResult, serviceResult]
const errors = [candidateResult, captureResult, serviceResult]
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
.map((item) => item.reason instanceof Error ? item.reason.message : '读取失败');
message.value = errors.join(';');
+4 -1
View File
@@ -61,7 +61,10 @@
</button>
</view>
<view v-if="msg.voiceStatus === 'unavailable' || msg.transcriptOpen" class="bubble master transcript-bubble">
<view
v-if="(msg.voiceStatus === 'unavailable' || msg.transcriptOpen) && (msg.result.answer || msg.result.snippets?.length || !msg.result.memoryCandidate)"
class="bubble master transcript-bubble"
>
<text
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used"
@@ -1,4 +1,5 @@
import type {
AssistantCapture,
MemoryCandidate,
MemoryConfirmRequest,
MemoryConfirmResult,
@@ -44,6 +45,13 @@ export const dismissMemoryCandidate = (candidateId: number) =>
timeout: 15000
});
export const getAssistantCaptures = async () =>
rowsOf(await apiRequest<AssistantCapture[] | { rows?: AssistantCapture[]; records?: AssistantCapture[] }>({
url: '/api/aihr/personal-assistant/assistant-captures?limit=50',
method: 'GET',
timeout: 15000
}));
export const getServiceMemories = async () => {
const rows = rowsOf(await apiRequest<RawServiceMemory[] | { rows?: RawServiceMemory[]; records?: RawServiceMemory[] }>({
url: '/api/aihr/service-memories',
+19 -1
View File
@@ -193,7 +193,7 @@ export interface UnifiedKnowledgeResponse {
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
export type MemoryTargetDomain = 'PERSONAL' | 'PROJECT_SERVICE' | string;
export type MemoryTargetDomain = 'ASSISTANT_CAPTURE' | 'PERSONAL' | 'COMPANY' | 'PROJECT_SERVICE' | string;
export interface MemoryCandidateDraft {
projectCode: string | null;
@@ -225,6 +225,7 @@ export interface MemoryConfirmRequest {
idempotencyKey: string;
draft: MemoryCandidateDraft;
enableReminder: boolean;
saveScope?: 'PRIVATE' | 'COMPANY';
}
export interface MemoryConfirmResult {
@@ -233,6 +234,23 @@ export interface MemoryConfirmResult {
version: number;
}
export interface AssistantCapture {
id: number;
saveScope: 'PRIVATE' | 'COMPANY';
suggestedType: string;
projectCode?: string | null;
buildingName?: string | null;
unitName?: string | null;
roomNo?: string | null;
category: string;
summary: string;
detail?: string | null;
occurredAt: string;
followUpAt?: string | null;
deliveryStatus: 'NOT_REQUIRED' | 'PENDING' | 'DELIVERED' | 'FAILED' | string;
createdAt: string;
}
export interface ServiceMemory {
id: number;
version: number;
+6 -2
View File
@@ -14,9 +14,13 @@ test('问师傅为文字、媒体和数据工具请求携带同一短期会话
});
test('问师傅支持显式新对话、版本回写和受控原文件或视频卡', async () => {
const page = await read('../src/pages/user/sop/index.vue');
const [page, header] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/components/chat/AskSourceHeader.vue')
]);
assert.match(page, /@click="startNewConversation">新对话/);
assert.match(page, /@new-conversation="startNewConversation"/);
assert.match(header, /@click="emit\('newConversation'\)">新对话/);
assert.match(page, /const contextVersion = ref\(0\)/);
assert.match(page, /applyConversationState\(nextResult\)/);
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
+22 -1
View File
@@ -88,6 +88,27 @@ test('员工端首页、问师傅和训练页接入当前共享基座', async ()
assert.match(practiceSource, /scenario-card employee-card/);
});
test('工作助手与查全网使用一致的来源切换头部', async () => {
const [sopSource, webAiSource, headerSource] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/pages/user/web-ai/index.vue'),
pageSource('../src/components/chat/AskSourceHeader.vue')
]);
for (const page of [sopSource, webAiSource]) {
assert.match(page, /import AskSourceHeader from ['"]@\/components\/chat\/AskSourceHeader\.vue['"]/);
assert.match(page, /<AskSourceHeader\b/);
assert.doesNotMatch(page, /(?:^|\n)\s*\.chat-source-switch(?:\s+\.source-option(?:\.active)?)?\s*\{/);
}
assert.match(headerSource, /<text class="head-title">问 · 数字师傅<\/text>/);
assert.match(headerSource, /\.source-option\s*\{[^}]*height:\s*44px/);
assert.match(headerSource, /\.new-chat-button\s*\{[^}]*min-height:\s*44px/);
assert.match(headerSource, /@click="emit\('newConversation'\)"/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-switch\s*\{/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-option(?:\.active)?\s*\{/);
});
test('登录后按组织身份切换主管首页', async () => {
const source = await pageSource('../src/services/position.ts');
@@ -198,7 +219,7 @@ test('员工问师傅页显示当前岗位而不是固定生活顾问', async ()
assert.match(source, /getSelectedPosition/);
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\)\)/);
assert.match(source, /\{\{ positionLabel \}\} · 住宅项目/);
assert.match(source, /:context="`\$\{positionLabel\} · 住宅项目`"/);
assert.doesNotMatch(source, /sop-context">管家(生活顾问) · 住宅项目/);
});
+27 -5
View File
@@ -26,15 +26,17 @@ test('个人助理客户端使用确认式记忆接口且确认请求携带稳
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/confirm/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/dismiss/);
assert.match(service, /\/api\/aihr\/service-memories/);
assert.match(service, /\/api\/aihr\/personal-assistant\/assistant-captures/);
assert.match(card, /const idempotencyKey = ref\(createMemoryIdempotencyKey\(props\.candidate\.id\)\)/);
assert.match(card, /if \(submitting\.value \|\| completed\.value\) return/);
assert.match(card, /expectedVersion: props\.candidate\.version/);
assert.match(card, /enableReminder: reminderEnabled\.value/);
assert.match(card, /saveScope: saveScope \|\| undefined/);
assert.match(card, /\.\.\.value/);
for (const field of ['projectCode', 'title', 'detail', 'status']) assert.match(card, new RegExp(field));
});
test('候选确认卡区分待补充和草稿,提醒默认关闭并保留三个动作', async () => {
test('统一采集确认卡把缺失字段作为可选提示并让用户选择保存范围', async () => {
const card = await read('../src/components/chat/MemoryCandidateCard.vue');
assert.match(card, /candidate\.status === 'NEEDS_INPUT'/);
@@ -43,6 +45,10 @@ test('候选确认卡区分待补充和草稿,提醒默认关闭并保留三
assert.match(card, />修改</);
assert.match(card, />暂不保存</);
assert.match(card, />确认保存</);
assert.match(card, />仅自己保存</);
assert.match(card, />提交公司处理</);
assert.match(card, /可继续补充:/);
assert.doesNotMatch(card, />还需要补充信息</);
assert.match(card, /v-if="reminderEnabled"/);
for (const field of ['buildingName', 'unitName', 'roomNo']) {
assert.match(card, new RegExp(`value: draft\\.value\\.${field}`));
@@ -65,15 +71,31 @@ test('工作助手页面展示任务过期后不会消费待执行查询或数
assert.match(page, /ensureEmployeePosition\('\/pages\/user\/sop\/index'\)\) \|\| !isCurrentQuestion\(\)/);
});
test('我的资料与记忆展示个人记录和公司流转状态', async () => {
const [page, service] = await Promise.all([
read('../src/pages/user/assistant/memories.vue'),
read('../src/services/personal-assistant.ts')
]);
assert.match(page, /getAssistantCaptures/);
assert.match(page, />我的记录</);
assert.match(page, /仅自己保存的内容不会提交公司/);
assert.match(page, /deliveryLabel\(item\.deliveryStatus\)/);
assert.match(service, /assistant-captures\?limit=50/);
});
test('工作助手保留原问答链并只提供显式查全网入口', async () => {
const [page, knowledge] = await Promise.all([
const [page, header, knowledge] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/components/chat/AskSourceHeader.vue'),
read('../src/services/knowledge.ts')
]);
assert.match(page, />工作助手<\/button>/);
assert.match(page, />查全网<\/button>/);
assert.match(header, />\s*工作助手\s*<\/button>/);
assert.match(header, />\s*查全网\s*<\/button>/);
assert.match(page, /@select-web="openWebAi"/);
assert.match(page, /<MemoryCandidateCard/);
assert.match(page, /msg\.result\.answer \|\| msg\.result\.snippets\?\.length \|\| !msg\.result\.memoryCandidate/);
assert.match(page, /msg\.result\.noEvidence/);
assert.match(page, /@click="openWebAi">去查全网/);
assert.match(knowledge, /url: '\/api\/knowledge\/query'/);
@@ -137,7 +159,7 @@ test('我的页面和路由提供首版资料与记忆入口', async () => {
assert.match(profile, /openMemories/);
assert.match(memories, /待确认/);
assert.match(memories, /项目跟进/);
assert.match(memories, /首版仅展示/);
assert.match(memories, /仅展示当前账号有权限查看的项目服务记录/);
assert.match(memories, /项目:\{\{ item\.projectCode \}\}/);
assert.match(pages, /pages\/user\/assistant\/memories/);
});