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); index.set(draftId, DRAFT_TTL);
} }
redissonClient.<Long>getBucket(PREFIX + "draft:" + draftId).set(candidate.id(), 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) { public ConfirmMemoryResponse confirm(String draftId, ConfirmMemoryRequest request) {
@@ -134,13 +134,19 @@ public class AihrAgentOrchestrator {
: AgentStatus.NEEDS_INPUT; : AgentStatus.NEEDS_INPUT;
sourceType = "MEMORY_DRAFT"; sourceType = "MEMORY_DRAFT";
} }
String sourceLabel = response.citations().isEmpty() ? sourceType List<SourceSummary> sources = response.citations().isEmpty()
: response.citations().get(0).title(); && "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() String answer = actionDraft == null ? response.answer()
: "已整理为待确认记录,请核对后保存。"; : "已整理为待确认记录,请核对后保存。";
return new AgentResponse( return new AgentResponse(
runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, answer, 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() 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, return plan(Intent.RESOURCE_DELIVERY, request, Tool.KNOWLEDGE_RESOURCE, false, false,
ResponseStyle.GROUNDED); 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, "我今天有什么待办", "我有什么待办", "我的待办", "我的任务", if (containsAny(request, "我今天有什么待办", "我有什么待办", "我的待办", "我的任务",
"我今天该做什么", "我现在该做什么", "今天要做什么", "当前待办")) { "我今天该做什么", "我现在该做什么", "今天要做什么", "当前待办")) {
return plan(Intent.LIVE_MY_WORK, request, Tool.MY_CURRENT_TASKS, false, false, ResponseStyle.FACT); 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); var result = service.confirm(draft.draftId(), request);
assertEquals(candidate, draft.payload());
assertEquals("ASSISTANT_CAPTURE", result.targetDomain()); assertEquals("ASSISTANT_CAPTURE", result.targetDomain());
assertEquals(88L, result.targetId()); assertEquals(88L, result.targetId());
verify(memory).confirm(7L, request); 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.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev") @Tag("dev")
class AihrAgentOrchestratorTest { class AihrAgentOrchestratorTest {
@@ -52,6 +53,16 @@ class AihrAgentOrchestratorTest {
assertEquals(AgentStatus.COMPLETED, result.status()); 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 @Test
void socialAndClarificationDoNotInvokeKnowledge() { void socialAndClarificationDoNotInvokeKnowledge() {
var query = new CapturingQueryService(response("unused", null, false)); 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.CLARIFY;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA; 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_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.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.RESOURCE_DELIVERY;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.SOCIAL; import static org.dromara.aihr.agent.AihrAgentDto.Intent.SOCIAL;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.CAPTURE_MEMORY_DRAFT; 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.KNOWLEDGE_SEARCH;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.MEDIA_ANALYZE; 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_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.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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -38,6 +42,22 @@ class AihrAgentPlannerTest {
assertFalse(plan.requiresConfirmation()); 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 @Test
void routesExplicitCaptureToConfirmationDraft() { void routesExplicitCaptureToConfirmationDraft() {
var plan = planner.plan("记一下,3栋1201要回访", false); var plan = planner.plan("记一下,3栋1201要回访", false);
@@ -278,7 +278,7 @@ const confirm = async (saveScope: '' | 'PRIVATE' | 'COMPANY' = '') => {
errorText.value = ''; errorText.value = '';
const requestAccountKey = currentAccountKey(); const requestAccountKey = currentAccountKey();
try { try {
const result = await confirmMemoryCandidate(props.candidate.id, { const result = await confirmMemoryCandidate(props.candidate.agentDraftId || props.candidate.id, {
expectedVersion: props.candidate.version, expectedVersion: props.candidate.version,
idempotencyKey: idempotencyKey.value, idempotencyKey: idempotencyKey.value,
draft: { draft: {
@@ -306,7 +306,7 @@ const dismiss = async () => {
errorText.value = ''; errorText.value = '';
const requestAccountKey = currentAccountKey(); const requestAccountKey = currentAccountKey();
try { try {
await dismissMemoryCandidate(props.candidate.id); await dismissMemoryCandidate(props.candidate.agentDraftId || props.candidate.id);
if (requestAccountKey !== currentAccountKey()) return; if (requestAccountKey !== currentAccountKey()) return;
emit('dismissed', props.candidate.id); emit('dismissed', props.candidate.id);
} catch (error) { } catch (error) {
+84 -52
View File
@@ -121,7 +121,26 @@
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText" v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used" class="context-used"
>已结合本次对话理解</text> >已结合本次对话理解</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> <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"> <view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint"> <text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
{{ index + 1 }}. {{ cleanOrderedText(point) }} {{ index + 1 }}. {{ cleanOrderedText(point) }}
@@ -133,7 +152,7 @@
<text>{{ item }}</text> <text>{{ item }}</text>
</view> </view>
</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"> <view class="bubble-actions">
<button v-if="msg.voiceStatus === 'unavailable'" class="bubble-action" @click="toggleAnswerSpeech(msg)"> <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"> <button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }} 引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
</button> </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 ? '生成中' : '总结卡' }} {{ summaryLoading ? '生成中' : '总结卡' }}
</button> </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>
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row"> <view v-if="msg.result.snippets?.length" class="bubble-actions feedback-row">
<button <button
class="bubble-action" class="bubble-action"
:class="{ active: msg.feedbackVerdict === 'up' }" :class="{ active: msg.feedbackVerdict === 'up' }"
@@ -422,12 +441,10 @@ import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import { import {
chooseKnowledgeMedia, chooseKnowledgeMedia,
generateSummaryCard, generateSummaryCard,
queryKnowledgeTool,
requestKnowledgeResourceDownloadLink, requestKnowledgeResourceDownloadLink,
searchKnowledge,
searchKnowledgeWithMedia,
submitAnswerFeedback submitAnswerFeedback
} from '@/services/knowledge'; } from '@/services/knowledge';
import { askAgent, askAgentWithMedia } from '@/services/agent';
import { import {
getSelectedPosition, getSelectedPosition,
getSelectedPositionProfile, getSelectedPositionProfile,
@@ -909,7 +926,10 @@ const requestErrorText = (error: unknown, fallback: string) => {
return error instanceof Error ? error.message : fallback; 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) { if (loading.value) {
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' }); uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
return; return;
@@ -921,7 +941,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' }); uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' });
return; return;
} }
const text = rawText.trim() || (media ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : ''); const text = rawText.trim() || (media ? '请分析附件内容并回答我的问题' : '');
if (!text) { if (!text) {
uni.showToast({ title: '请先输入问题', icon: 'none' }); uni.showToast({ title: '请先输入问题', icon: 'none' });
return; return;
@@ -955,18 +975,17 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
loading.value = true; loading.value = true;
const generation = ++knowledgeSearchGeneration; const generation = ++knowledgeSearchGeneration;
try { try {
const agentOptions = {
conversation: conversationState(),
projectCode: broadcastMessageId ? undefined : currentProject.value?.projectCode,
broadcastMessageId,
externalConsent: Boolean(options.externalConsent)
};
const nextResult = media const nextResult = media
? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => { ? await askAgentWithMedia(media, text, agentOptions, (task) => {
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task; if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
}, conversationState(), currentProject.value?.projectCode) })
: await searchKnowledge( : await askAgent(text, agentOptions);
text,
getSelectedPosition(),
conversationState(),
broadcastMessageId ? undefined : currentProject.value?.projectCode,
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop',
broadcastMessageId
);
if (generation !== knowledgeSearchGeneration) return; if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult); applyConversationState(nextResult);
if (broadcastMessageId && nextResult.broadcastContext?.messageId === broadcastMessageId) { if (broadcastMessageId && nextResult.broadcastContext?.messageId === broadcastMessageId) {
@@ -1038,38 +1057,7 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
: toolCode; : toolCode;
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况'; const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
selectedMedia.value = null; selectedMedia.value = null;
pushMsg<UserMsg>({ role: 'user', text: question }); void sendQuestion(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;
}
}; };
const pickMedia = async () => { 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 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 = [ const feedbackReasonOptions = [
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' }, { code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' }, { code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
@@ -1419,6 +1413,11 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' }); 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 = () => { const openTodayResult = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' }); uni.navigateTo({ url: '/pages/user/work-results/index' });
}; };
@@ -1437,7 +1436,10 @@ const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
try { try {
const candidates = await getMemoryCandidates('DRAFT'); const candidates = await getMemoryCandidates('DRAFT');
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return; 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) { } catch (error) {
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return; if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' }); uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
@@ -2977,6 +2979,36 @@ onUnload(() => {
font-size: 12px; 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 { .bubble-actions {
display: flex; display: flex;
flex-wrap: wrap; 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 timeout: 15000
})); }));
export const confirmMemoryCandidate = (candidateId: number, request: MemoryConfirmRequest) => export const confirmMemoryCandidate = (candidateRef: number | string, request: MemoryConfirmRequest) =>
apiRequest<MemoryConfirmResult>({ apiRequest<MemoryConfirmResult>({
url: `${candidateBase}/${candidateId}/confirm`, url: typeof candidateRef === 'string'
? `/api/aihr/agent/actions/${encodeURIComponent(String(candidateRef))}/confirm`
: `${candidateBase}/${candidateRef}/confirm`,
method: 'POST', method: 'POST',
data: request, data: request,
timeout: 15000 timeout: 15000
}); });
export const dismissMemoryCandidate = (candidateId: number) => export const dismissMemoryCandidate = (candidateRef: number | string) =>
apiRequest<{ id: number; status: 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', method: 'POST',
timeout: 15000 timeout: 15000
}); });
+54
View File
@@ -152,6 +152,10 @@ export interface KnowledgeSearchResponse {
resources?: KnowledgeResource[]; resources?: KnowledgeResource[];
memoryCandidate?: MemoryCandidate | null; memoryCandidate?: MemoryCandidate | null;
broadcastContext?: BroadcastKnowledgeContext | null; broadcastContext?: BroadcastKnowledgeContext | null;
agentStatus?: AgentStatus;
agentIntent?: AgentIntent;
sourceSummary?: AgentSourceSummary[];
clarification?: AgentClarification | null;
} }
export interface KnowledgeMediaSelectedFile { export interface KnowledgeMediaSelectedFile {
@@ -214,6 +218,55 @@ export interface UnifiedKnowledgeResponse {
broadcastContext?: BroadcastKnowledgeContext | null; 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 MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
export type MemoryTargetDomain = 'ASSISTANT_CAPTURE' | 'PERSONAL' | 'COMPANY' | 'PROJECT_SERVICE' | string; export type MemoryTargetDomain = 'ASSISTANT_CAPTURE' | 'PERSONAL' | 'COMPANY' | 'PROJECT_SERVICE' | string;
@@ -254,6 +307,7 @@ export interface MemoryCandidate {
missingFields: string[]; missingFields: string[];
expiresAt: string | null; expiresAt: string | null;
source?: WorkSourceSnapshot | null; source?: WorkSourceSnapshot | null;
agentDraftId?: string;
} }
export interface MemoryConfirmRequest { 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/);
});