feat(agent): move mobile ask to unified agent

This commit is contained in:
2026-07-24 22:36:14 +08:00
parent 6ba4e06a2f
commit 5b7c5613f5
12 changed files with 368 additions and 62 deletions
@@ -38,7 +38,7 @@ public class AihrAgentActionService {
index.set(draftId, DRAFT_TTL);
}
redissonClient.<Long>getBucket(PREFIX + "draft:" + draftId).set(candidate.id(), DRAFT_TTL);
return new ActionDraft(draftId, candidate.targetDomain(), candidate.version(), candidate.draft());
return new ActionDraft(draftId, candidate.targetDomain(), candidate.version(), candidate);
}
public ConfirmMemoryResponse confirm(String draftId, ConfirmMemoryRequest request) {
@@ -134,13 +134,19 @@ public class AihrAgentOrchestrator {
: AgentStatus.NEEDS_INPUT;
sourceType = "MEMORY_DRAFT";
}
String sourceLabel = response.citations().isEmpty() ? sourceType
: response.citations().get(0).title();
List<SourceSummary> sources = response.citations().isEmpty()
&& "ENTERPRISE_KNOWLEDGE".equals(sourceType)
? List.of()
: List.of(new SourceSummary(
sourceType,
response.citations().isEmpty() ? sourceType : response.citations().get(0).title(),
OffsetDateTime.now().toString()
));
String answer = actionDraft == null ? response.answer()
: "已整理为待确认记录,请核对后保存。";
return new AgentResponse(
runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, answer,
List.of(new SourceSummary(sourceType, sourceLabel, OffsetDateTime.now().toString())),
sources,
response.citations(), response.resources(), response.data(), actionDraft, null, List.of()
);
}
@@ -52,6 +52,14 @@ public class AihrAgentPlanner {
return plan(Intent.RESOURCE_DELIVERY, request, Tool.KNOWLEDGE_RESOURCE, false, false,
ResponseStyle.GROUNDED);
}
if (containsAny(request, "团队训练概况", "团队训练情况", "团队完训情况")) {
return plan(Intent.LIVE_TEAM_WORK, request, Tool.TEAM_PRACTICE_SUMMARY, false, false,
ResponseStyle.FACT);
}
if (containsAny(request, "我的训练概况", "我的训练情况", "我的练习概况", "我练得怎么样")) {
return plan(Intent.PRACTICE_COACHING, request, Tool.MY_PRACTICE_SUMMARY, false, false,
ResponseStyle.FACT);
}
if (containsAny(request, "我今天有什么待办", "我有什么待办", "我的待办", "我的任务",
"我今天该做什么", "我现在该做什么", "今天要做什么", "当前待办")) {
return plan(Intent.LIVE_MY_WORK, request, Tool.MY_CURRENT_TASKS, false, false, ResponseStyle.FACT);
@@ -38,6 +38,7 @@ class AihrAgentActionServiceTest {
var result = service.confirm(draft.draftId(), request);
assertEquals(candidate, draft.payload());
assertEquals("ASSISTANT_CAPTURE", result.targetDomain());
assertEquals(88L, result.targetId());
verify(memory).confirm(7L, request);
@@ -21,6 +21,7 @@ import static org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrAgentOrchestratorTest {
@@ -52,6 +53,16 @@ class AihrAgentOrchestratorTest {
assertEquals(AgentStatus.COMPLETED, result.status());
}
@Test
void knowledgeNoEvidenceDoesNotInventASource() {
var query = new CapturingQueryService(response("当前授权资料中没有明确依据。", null, true));
var result = orchestrator(query).handle(request("装修人员怎么进场?"));
assertEquals(AgentStatus.NO_EVIDENCE, result.status());
assertTrue(result.sourceSummary().isEmpty());
}
@Test
void socialAndClarificationDoNotInvokeKnowledge() {
var query = new CapturingQueryService(response("unused", null, false));
@@ -11,7 +11,9 @@ import static org.dromara.aihr.agent.AihrAgentDto.Intent.CAPTURE_FACT;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.CLARIFY;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_TEAM_WORK;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.MEDIA_UNDERSTANDING;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.PRACTICE_COACHING;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.RESOURCE_DELIVERY;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.SOCIAL;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.CAPTURE_MEMORY_DRAFT;
@@ -19,7 +21,9 @@ import static org.dromara.aihr.agent.AihrAgentDto.Tool.KNOWLEDGE_RESOURCE;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.KNOWLEDGE_SEARCH;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.MEDIA_ANALYZE;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.MY_CURRENT_TASKS;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.MY_PRACTICE_SUMMARY;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.NONE;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.TEAM_PRACTICE_SUMMARY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -38,6 +42,22 @@ class AihrAgentPlannerTest {
assertFalse(plan.requiresConfirmation());
}
@Test
void routesOwnTrainingSummaryToLivePracticeData() {
var plan = planner.plan("查询我的训练概况", false);
assertEquals(PRACTICE_COACHING, plan.intent());
assertEquals(MY_PRACTICE_SUMMARY, plan.tool());
}
@Test
void routesTeamTrainingSummaryToSupervisorData() {
var plan = planner.plan("查询我的团队训练概况", false);
assertEquals(LIVE_TEAM_WORK, plan.intent());
assertEquals(TEAM_PRACTICE_SUMMARY, plan.tool());
}
@Test
void routesExplicitCaptureToConfirmationDraft() {
var plan = planner.plan("记一下,3栋1201要回访", false);
@@ -278,7 +278,7 @@ const confirm = async (saveScope: '' | 'PRIVATE' | 'COMPANY' = '') => {
errorText.value = '';
const requestAccountKey = currentAccountKey();
try {
const result = await confirmMemoryCandidate(props.candidate.id, {
const result = await confirmMemoryCandidate(props.candidate.agentDraftId || props.candidate.id, {
expectedVersion: props.candidate.version,
idempotencyKey: idempotencyKey.value,
draft: {
@@ -306,7 +306,7 @@ const dismiss = async () => {
errorText.value = '';
const requestAccountKey = currentAccountKey();
try {
await dismissMemoryCandidate(props.candidate.id);
await dismissMemoryCandidate(props.candidate.agentDraftId || props.candidate.id);
if (requestAccountKey !== currentAccountKey()) return;
emit('dismissed', props.candidate.id);
} catch (error) {
+84 -52
View File
@@ -121,7 +121,26 @@
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used"
>已结合本次对话理解</text>
<view v-if="msg.result.sourceSummary?.length" class="agent-source-row">
<text
v-for="source in msg.result.sourceSummary"
:key="`${source.type}-${source.label}`"
class="agent-source-chip"
>{{ source.label }}</text>
</view>
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
<view
v-if="msg.result.agentStatus === 'NEEDS_INPUT' && msg.result.clarification"
class="agent-clarification"
>
<text class="agent-clarification-text">{{ msg.result.clarification.prompt }}</text>
<button
v-if="msg.result.agentIntent === 'WEB_RESEARCH'"
class="bubble-action primary"
:disabled="loading"
@click="consentWebResearch(msg)"
>同意查询全网</button>
</view>
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
{{ index + 1 }}. {{ cleanOrderedText(point) }}
@@ -133,7 +152,7 @@
<text>{{ item }}</text>
</view>
</view>
<text v-if="msg.result.noEvidence" class="bubble-noevidence">当前授权空间内没有足够依据。</text>
<text v-if="msg.result.noEvidence" class="bubble-noevidence">{{ noEvidenceText(msg.result) }}</text>
<view class="bubble-actions">
<button v-if="msg.voiceStatus === 'unavailable'" class="bubble-action" @click="toggleAnswerSpeech(msg)">
@@ -142,12 +161,12 @@
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
</button>
<button v-if="msg.toolCode === '' && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
<button v-if="msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
{{ summaryLoading ? '生成中' : '总结卡' }}
</button>
<button v-if="msg.result.noEvidence && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
</view>
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
<view v-if="msg.result.snippets?.length" class="bubble-actions feedback-row">
<button
class="bubble-action"
:class="{ active: msg.feedbackVerdict === 'up' }"
@@ -422,12 +441,10 @@ import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import {
chooseKnowledgeMedia,
generateSummaryCard,
queryKnowledgeTool,
requestKnowledgeResourceDownloadLink,
searchKnowledge,
searchKnowledgeWithMedia,
submitAnswerFeedback
} from '@/services/knowledge';
import { askAgent, askAgentWithMedia } from '@/services/agent';
import {
getSelectedPosition,
getSelectedPositionProfile,
@@ -909,7 +926,10 @@ const requestErrorText = (error: unknown, fallback: string) => {
return error instanceof Error ? error.message : fallback;
};
const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) => {
const sendQuestion = async (
rawText: string,
options: { voice?: boolean; externalConsent?: boolean } = {}
) => {
if (loading.value) {
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
return;
@@ -921,7 +941,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' });
return;
}
const text = rawText.trim() || (media ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
const text = rawText.trim() || (media ? '请分析附件内容并回答我的问题' : '');
if (!text) {
uni.showToast({ title: '请先输入问题', icon: 'none' });
return;
@@ -955,18 +975,17 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
loading.value = true;
const generation = ++knowledgeSearchGeneration;
try {
const agentOptions = {
conversation: conversationState(),
projectCode: broadcastMessageId ? undefined : currentProject.value?.projectCode,
broadcastMessageId,
externalConsent: Boolean(options.externalConsent)
};
const nextResult = media
? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => {
? await askAgentWithMedia(media, text, agentOptions, (task) => {
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
}, conversationState(), currentProject.value?.projectCode)
: await searchKnowledge(
text,
getSelectedPosition(),
conversationState(),
broadcastMessageId ? undefined : currentProject.value?.projectCode,
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop',
broadcastMessageId
);
})
: await askAgent(text, agentOptions);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
if (broadcastMessageId && nextResult.broadcastContext?.messageId === broadcastMessageId) {
@@ -1038,38 +1057,7 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
: toolCode;
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
selectedMedia.value = null;
pushMsg<UserMsg>({ role: 'user', text: question });
const pending = pushMsg<PendingMsg>({ role: 'pending' });
loading.value = true;
const generation = ++knowledgeSearchGeneration;
try {
const nextResult = await queryKnowledgeTool(
effectiveToolCode, question, conversationState(), currentProject.value?.projectCode);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
replaceMsg(pending.id, {
id: pending.id,
role: 'master',
result: nextResult,
toolCode: effectiveToolCode,
feedbackVerdict: '',
feedbackSubmitting: false,
feedbackPanelOpen: false,
feedbackReasonCodes: [],
feedbackComment: '',
citationsOpen: false,
voiceStatus: 'unavailable',
voiceDuration: null,
unread: false,
transcriptOpen: false
});
} catch (error) {
if (generation !== knowledgeSearchGeneration) return;
replaceMsg(pending.id, null);
pushSystem(requestErrorText(error, '训练概况查询失败'));
} finally {
if (generation === knowledgeSearchGeneration) loading.value = false;
}
void sendQuestion(question);
};
const pickMedia = async () => {
@@ -1271,6 +1259,12 @@ const serviceMemoryStatusLabel = (status?: string | null) => ({
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const noEvidenceText = (result: KnowledgeSearchResponse) => {
if (result.agentIntent === 'WEB_RESEARCH') return '公开网络中没有找到可核验来源。';
if (result.agentIntent === 'MEDIA_UNDERSTANDING') return '已分析附件,但当前资料中没有补充依据。';
return '当前授权空间内没有足够依据。';
};
const feedbackReasonOptions = [
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
@@ -1419,6 +1413,11 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const consentWebResearch = (msg: MasterMsg) => {
if (loading.value || msg.result.agentIntent !== 'WEB_RESEARCH') return;
void sendQuestion(msg.result.queryText, { externalConsent: true });
};
const openTodayResult = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
@@ -1437,7 +1436,10 @@ const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
try {
const candidates = await getMemoryCandidates('DRAFT');
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
msg.result.memoryCandidate = candidates.find((item) => item.id === candidateId) || null;
const refreshed = candidates.find((item) => item.id === candidateId);
msg.result.memoryCandidate = refreshed
? { ...refreshed, agentDraftId: msg.result.memoryCandidate?.agentDraftId }
: null;
} catch (error) {
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
@@ -2977,6 +2979,36 @@ onUnload(() => {
font-size: 12px;
}
.agent-source-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.agent-source-chip {
padding: 3px 8px;
border-radius: 999px;
background: #eef3f8;
color: #5b6b7f;
font-size: 11px;
}
.agent-clarification {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #eceff2;
}
.agent-clarification-text {
color: #38424e;
font-size: 13px;
}
.bubble-actions {
display: flex;
flex-wrap: wrap;
+120
View File
@@ -0,0 +1,120 @@
import type {
AgentResponse,
KnowledgeConversationState,
KnowledgeMediaSelectedFile,
KnowledgeSearchResponse,
MemoryCandidate
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
interface AgentMessageOptions {
conversation?: KnowledgeConversationState;
projectCode?: string;
broadcastMessageId?: number;
externalConsent?: boolean;
}
export const askAgent = async (question: string, options: AgentMessageOptions = {}) =>
normalizeAgentResponse(await apiRequest<AgentResponse>({
url: '/api/aihr/agent/messages',
method: 'POST',
data: {
question,
conversationId: options.conversation?.conversationId,
contextVersion: options.conversation?.contextVersion,
projectCode: options.projectCode,
broadcastMessageId: options.broadcastMessageId,
externalConsent: Boolean(options.externalConsent)
},
timeout: 45000
}), question);
export const askAgentWithMedia = (
file: KnowledgeMediaSelectedFile,
question: string,
options: AgentMessageOptions = {},
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
const task = uni.uploadFile({
url: apiUrl('/api/aihr/agent/messages/media'),
filePath: file.path,
name: 'file',
header: authHeaders(false),
formData: {
question,
...(options.conversation ? {
conversationId: options.conversation.conversationId,
contextVersion: String(options.conversation.contextVersion)
} : {}),
...(options.projectCode ? { projectCode: options.projectCode } : {}),
...(typeof options.broadcastMessageId === 'number'
? { broadcastMessageId: String(options.broadcastMessageId) }
: {}),
externalConsent: String(Boolean(options.externalConsent))
},
timeout: 360000,
success: (result) => {
try {
resolve(normalizeAgentResponse(
readTextPayload<AgentResponse>(result.statusCode, result.data),
question
));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error(file.kind === 'video' ? '视频上传或分析超时' : '图片上传失败')),
complete: () => onUploadTask?.(null)
});
onUploadTask?.(task);
});
export const normalizeAgentResponse = (
response: AgentResponse,
question: string
): KnowledgeSearchResponse => {
const memoryCandidate = response.actionDraft?.payload
? {
...(response.actionDraft.payload as MemoryCandidate),
agentDraftId: response.actionDraft.draftId
}
: null;
const snippets = (response.citations || []).map((citation) => ({
docTitle: citation.title,
content: citation.snippet,
docId: citation.docId,
fragmentId: citation.fragmentId,
spaceCode: citation.spaceCode,
sourceType: citation.sourceType,
domain: citation.domain,
status: citation.status,
occurredAt: citation.occurredAt,
updatedAt: citation.updatedAt
}));
return {
requestId: response.runId,
queryText: question,
category: 'sop',
answer: response.answer || response.clarification?.prompt || '',
reference: (response.sourceSummary || []).map((source) => source.label).filter(Boolean).join('、'),
docs: [],
snippets,
training: [],
records: [],
citations: response.citations || [],
usedSpaceCodes: [],
noEvidence: response.status === 'NO_EVIDENCE',
data: response.data,
conversationId: response.conversationId,
contextVersion: response.contextVersion,
intent: response.intent,
rewrittenQuery: question,
resources: response.resources || [],
memoryCandidate,
broadcastContext: null,
agentStatus: response.status,
agentIntent: response.intent,
sourceSummary: response.sourceSummary || [],
clarification: response.clarification || null
};
};
@@ -44,17 +44,21 @@ export const getMemoryCandidates = async (status = 'DRAFT') =>
timeout: 15000
}));
export const confirmMemoryCandidate = (candidateId: number, request: MemoryConfirmRequest) =>
export const confirmMemoryCandidate = (candidateRef: number | string, request: MemoryConfirmRequest) =>
apiRequest<MemoryConfirmResult>({
url: `${candidateBase}/${candidateId}/confirm`,
url: typeof candidateRef === 'string'
? `/api/aihr/agent/actions/${encodeURIComponent(String(candidateRef))}/confirm`
: `${candidateBase}/${candidateRef}/confirm`,
method: 'POST',
data: request,
timeout: 15000
});
export const dismissMemoryCandidate = (candidateId: number) =>
export const dismissMemoryCandidate = (candidateRef: number | string) =>
apiRequest<{ id: number; status: string }>({
url: `${candidateBase}/${candidateId}/dismiss`,
url: typeof candidateRef === 'string'
? `/api/aihr/agent/actions/${encodeURIComponent(String(candidateRef))}/dismiss`
: `${candidateBase}/${candidateRef}/dismiss`,
method: 'POST',
timeout: 15000
});
+54
View File
@@ -152,6 +152,10 @@ export interface KnowledgeSearchResponse {
resources?: KnowledgeResource[];
memoryCandidate?: MemoryCandidate | null;
broadcastContext?: BroadcastKnowledgeContext | null;
agentStatus?: AgentStatus;
agentIntent?: AgentIntent;
sourceSummary?: AgentSourceSummary[];
clarification?: AgentClarification | null;
}
export interface KnowledgeMediaSelectedFile {
@@ -214,6 +218,55 @@ export interface UnifiedKnowledgeResponse {
broadcastContext?: BroadcastKnowledgeContext | null;
}
export type AgentStatus = 'COMPLETED' | 'NEEDS_INPUT' | 'NEEDS_CONFIRMATION' | 'NO_EVIDENCE' | 'FORBIDDEN' | 'UNAVAILABLE' | 'FAILED';
export type AgentIntent =
| 'KNOWLEDGE_QA'
| 'RESOURCE_DELIVERY'
| 'LIVE_MY_WORK'
| 'LIVE_TEAM_WORK'
| 'PRACTICE_COACHING'
| 'CAPTURE_FACT'
| 'DRAFT_ACTION'
| 'MEDIA_UNDERSTANDING'
| 'WEB_RESEARCH'
| 'CLARIFY'
| 'SOCIAL';
export interface AgentSourceSummary {
type: 'ENTERPRISE_KNOWLEDGE' | 'LIVE_DATA' | 'MEMORY_DRAFT' | 'MEDIA' | 'WEB' | string;
label: string;
verifiedAt: string;
}
export interface AgentActionDraft {
draftId: string;
type: string;
version: number;
payload: MemoryCandidate;
}
export interface AgentClarification {
prompt: string;
fields: string[];
}
export interface AgentResponse {
runId: string;
conversationId?: string | null;
contextVersion?: number | null;
intent: AgentIntent;
status: AgentStatus;
answer: string;
sourceSummary: AgentSourceSummary[];
citations: KnowledgeCitation[];
resources: KnowledgeResource[];
data?: unknown;
actionDraft?: AgentActionDraft | null;
clarification?: AgentClarification | null;
nextActions: string[];
}
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
export type MemoryTargetDomain = 'ASSISTANT_CAPTURE' | 'PERSONAL' | 'COMPANY' | 'PROJECT_SERVICE' | string;
@@ -254,6 +307,7 @@ export interface MemoryCandidate {
missingFields: string[];
expiresAt: string | null;
source?: WorkSourceSnapshot | null;
agentDraftId?: string;
}
export interface MemoryConfirmRequest {
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = async (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('问数字师傅统一使用 Agent 文本与媒体入口', async () => {
const [service, page] = await Promise.all([
source('../src/services/agent.ts'),
source('../src/pages/user/sop/index.vue')
]);
assert.match(service, /\/api\/aihr\/agent\/messages'/);
assert.match(service, /\/api\/aihr\/agent\/messages\/media/);
assert.doesNotMatch(service, /toolCode/);
assert.doesNotMatch(service, /\/api\/knowledge\/query/);
assert.match(page, /askAgent/);
assert.match(page, /askAgentWithMedia/);
assert.doesNotMatch(page, /await searchKnowledge\(/);
assert.doesNotMatch(page, /await queryKnowledgeTool\(/);
});
test('Agent 响应按结构化状态渲染来源、澄清和确认动作', async () => {
const [types, service, page] = await Promise.all([
source('../src/types/api.ts'),
source('../src/services/agent.ts'),
source('../src/pages/user/sop/index.vue')
]);
assert.match(types, /export type AgentStatus = 'COMPLETED' \| 'NEEDS_INPUT' \| 'NEEDS_CONFIRMATION'/);
assert.match(types, /sourceSummary\?: AgentSourceSummary\[\]/);
assert.match(types, /agentDraftId\?: string/);
assert.match(service, /status === 'NO_EVIDENCE'/);
assert.match(service, /agentDraftId: response\.actionDraft\.draftId/);
assert.match(page, /agent-source-chip/);
assert.match(page, /msg\.result\.agentStatus === 'NEEDS_INPUT'/);
assert.match(page, /consentWebResearch/);
assert.doesNotMatch(page, /result\.answer\.(?:includes|match|startsWith)/);
});
test('Agent 确认卡通过不透明 draftId 调用动作端点', async () => {
const [assistantService, card] = await Promise.all([
source('../src/services/personal-assistant.ts'),
source('../src/components/chat/MemoryCandidateCard.vue')
]);
assert.match(assistantService, /\/api\/aihr\/agent\/actions\/\$\{encodeURIComponent\(String\(candidateRef\)\)\}\/confirm/);
assert.match(assistantService, /\/api\/aihr\/agent\/actions\/\$\{encodeURIComponent\(String\(candidateRef\)\)\}\/dismiss/);
assert.match(card, /props\.candidate\.agentDraftId \|\| props\.candidate\.id/);
});