feat(mobile): render evidence levels and route actions in ask page

- normalize evidenceLevel/uncoveredPoints/nextActions from agent response
- GUIDANCE banner (reference experience, non-formal), PARTIAL uncovered
  points list, ROUTE compact action buttons (human help / direct channel /
  web research); legacy responses render unchanged
- one-tap human help on ROUTE: auto-submit down feedback (NO_ANSWER,
  fragment-less allowed by backend) then existing confirm + idempotent
  publish chain
- direct channel page supports ?channel= preselection
This commit is contained in:
2026-08-05 00:53:00 +08:00
parent 6cc2ac73e5
commit 1fc99293f6
4 changed files with 238 additions and 13 deletions
+28 -2
View File
@@ -163,7 +163,7 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
import {
listDirectChannels,
listMyDirectFeedback,
@@ -202,6 +202,18 @@ const form = reactive({
content: ''
});
/** 从问师傅等入口带入的预选渠道(只消费一次,之后保持用户手动选择)。 */
const presetChannelCode = ref<DirectChannelCode | ''>('');
const channelParamMap: Record<string, DirectChannelCode> = {
direct_president: 'PRESIDENT',
direct_finance: 'FINANCE',
direct_hr: 'HR',
direct_audit: 'AUDIT',
direct_operations: 'OPERATIONS'
};
const directChannelCodes: DirectChannelCode[] = ['PRESIDENT', 'FINANCE', 'HR', 'AUDIT', 'OPERATIONS'];
const MAX_CONTENT_LENGTH = 5000;
const voiceRecording = ref(false);
const voiceStarting = ref(false);
@@ -407,7 +419,13 @@ const loadChannels = async () => {
return;
}
channels.value = rows;
if (!form.channelCode || !rows.some((channel) => channel.code === form.channelCode)) {
const preset = presetChannelCode.value
? rows.find((channel) => channel.code === presetChannelCode.value)
: undefined;
if (preset) {
presetChannelCode.value = '';
chooseChannel(preset);
} else if (!form.channelCode || !rows.some((channel) => channel.code === form.channelCode)) {
chooseChannel(rows[0]);
}
} catch {
@@ -473,6 +491,14 @@ const submit = async () => {
}
};
onLoad((options) => {
const raw = typeof options?.channel === 'string' ? options.channel.trim() : '';
if (!raw) return;
const upper = raw.toUpperCase() as DirectChannelCode;
presetChannelCode.value = channelParamMap[raw.toLowerCase()]
|| (directChannelCodes.includes(upper) ? upper : '');
});
onShow(async () => {
requests.activate();
resetPageScroll();
+183 -9
View File
@@ -121,6 +121,10 @@
<uni-icons type="info" size="13" color="#2f6bff" />
<text>AI 参考回答 · 下方引用为原文证据</text>
</view>
<view v-if="msg.result.evidenceLevel === 'GUIDANCE'" class="bubble-guidance-label">
<uni-icons type="info" size="12" color="#925b00" />
<text>参考经验 · 非正式制度依据</text>
</view>
<text
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used"
@@ -157,7 +161,30 @@
<text>{{ item }}</text>
</view>
</view>
<text v-if="msg.result.noEvidence" class="bubble-noevidence">{{ noEvidenceText(msg.result) }}</text>
<view
v-if="msg.result.evidenceLevel === 'PARTIAL' && msg.result.uncoveredPoints?.length"
class="bubble-uncovered"
>
<text class="bubble-section-label">以下点暂无正式制度依据</text>
<view v-for="point in msg.result.uncoveredPoints" :key="point" class="bubble-uncovered-item">
<uni-icons type="info-filled" size="12" color="#925b00" />
<text>{{ point }}</text>
</view>
</view>
<text
v-if="msg.result.noEvidence && !isRouteResult(msg.result)"
class="bubble-noevidence"
>{{ noEvidenceText(msg.result) }}</text>
<view v-if="visibleNextActions(msg.result).length" class="bubble-next-actions">
<button
v-for="(action, index) in visibleNextActions(msg.result)"
:key="`${action.type}-${action.channel || index}`"
class="bubble-action"
:class="{ primary: action.type === 'HUMAN_HELP' || action.type === 'WEB_RESEARCH' }"
:disabled="nextActionDisabled(action, msg)"
@click="runNextAction(action, msg)"
>{{ nextActionButtonLabel(action, msg) }}</button>
</view>
<view class="bubble-actions">
<button v-if="msg.voiceStatus === 'unavailable'" class="bubble-action" @click="toggleAnswerSpeech(msg)">
@@ -169,7 +196,7 @@
<button v-if="msg.result.agentIntent === 'KNOWLEDGE_QA' && summaryFragmentIds(msg).length && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
{{ summaryLoading ? '生成中' : '总结卡' }}
</button>
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext && !isRouteResult(msg.result)" class="bubble-action primary" @click="openWebAi">去查全网</button>
</view>
<view v-if="msg.result.snippets?.length" class="bubble-actions feedback-row">
<button
@@ -461,6 +488,7 @@ import type {
KnowledgeResource,
KnowledgeSearchResponse,
MobileProject,
NextAction,
SpeechPlaybackStatus,
SummaryCardResponse
} from '@/types/api';
@@ -1373,6 +1401,70 @@ const noEvidenceText = (result: KnowledgeSearchResponse) => {
return '当前授权空间内没有足够依据。';
};
/** ROUTE 出口:answer 已是可执行出口文案,旧 NO_EVIDENCE 提示与“去查全网”按钮不再重复展示。 */
const isRouteResult = (result: KnowledgeSearchResponse) =>
result.agentStatus === 'NO_EVIDENCE'
&& result.evidenceLevel === 'ROUTE'
&& Boolean(result.nextActions?.length);
/** ROUTE 展示全部出口动作;PARTIAL/GUIDANCE 只补充转人工入口。 */
const visibleNextActions = (result: KnowledgeSearchResponse): NextAction[] => {
const actions = result.nextActions || [];
if (isRouteResult(result)) return actions;
if (result.evidenceLevel === 'PARTIAL' || result.evidenceLevel === 'GUIDANCE') {
return actions.filter((action) => action.type === 'HUMAN_HELP');
}
return [];
};
const directChannelLabels: Record<string, string> = {
direct_president: '直通总裁',
direct_finance: '直通财务',
direct_hr: '直通人力',
direct_audit: '直通审计',
direct_operations: '直通运营'
};
const nextActionLabel = (action: NextAction) => {
if (action.label) return action.label;
if (action.type === 'HUMAN_HELP') return '转人工求助';
if (action.type === 'WEB_RESEARCH') return '去查全网';
if (action.type === 'DIRECT_CHANNEL') {
return directChannelLabels[action.channel || ''] || '反馈给公司';
}
return action.type;
};
const nextActionDisabled = (action: NextAction, msg: MasterMsg) => {
if (action.type === 'HUMAN_HELP') {
return msg.helpSubmitting || msg.feedbackSubmitting || Boolean(msg.helpQuestionId) || !msg.result.requestId;
}
if (action.type === 'DIRECT_CHANNEL') return !action.channel;
return false;
};
const nextActionButtonLabel = (action: NextAction, msg: MasterMsg) => {
if (action.type === 'HUMAN_HELP') {
if (msg.helpSubmitting || msg.feedbackSubmitting) return '提交中';
if (msg.helpQuestionId) return '已提交审核';
}
return nextActionLabel(action);
};
const runNextAction = (action: NextAction, msg: MasterMsg) => {
if (action.type === 'HUMAN_HELP') {
void submitHumanHelpFromNextAction(msg);
return;
}
if (action.type === 'WEB_RESEARCH') {
openWebAi();
return;
}
if (action.type === 'DIRECT_CHANNEL' && action.channel) {
uni.navigateTo({ url: `/pages/user/direct/index?channel=${encodeURIComponent(action.channel)}` });
}
};
const feedbackReasonOptions = [
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
@@ -1380,6 +1472,54 @@ const feedbackReasonOptions = [
{ code: 'NOT_APPLICABLE', label: '不适用于当前现场' }
];
const answerFeedbackFragmentIds = (msg: MasterMsg) => Array.from(
new Set(
(msg.result.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
/**
* 出口动作一键转人工:服务端要求先有本人 down 反馈,这里自动补提交,
* 原因码复用“没解决”面板的既有 NO_ANSWER,失败即中止,不静默进入转人工。
* ROUTE(NO_EVIDENCE 出口)允许无 fragmentIds 的 down 反馈,服务端按 requestId
* 校验归本人且该次查询无依据;非 ROUTE 且片段为空时维持中止。
*/
const submitAutoDownFeedback = async (msg: MasterMsg) => {
const fragmentIds = answerFeedbackFragmentIds(msg);
if (!fragmentIds.length && !isRouteResult(msg.result)) {
uni.showToast({ title: '暂无可反馈依据,无法提交人工求助', icon: 'none' });
return false;
}
msg.feedbackSubmitting = true;
try {
await submitAnswerFeedback({
queryText: msg.result.queryText || '',
requestId: msg.result.requestId,
fragmentIds,
verdict: 'down',
reviewId: msg.result.reviewId,
reasonCodes: ['NO_ANSWER']
});
msg.feedbackVerdict = 'down';
msg.feedbackPanelOpen = false;
return true;
} catch (error) {
uni.showToast({ title: requestErrorText(error, '反馈提交失败,请稍后重试'), icon: 'none' });
return false;
} finally {
msg.feedbackSubmitting = false;
}
};
/** nextActions 的 HUMAN_HELP 入口:未提交过 down 反馈时先自动补提交,再走既有确认弹窗 + 幂等提交流程。 */
const submitHumanHelpFromNextAction = async (msg: MasterMsg) => {
if (msg.helpSubmitting || msg.helpQuestionId || !msg.result.requestId) return;
if (!msg.feedbackVerdict && !await submitAutoDownFeedback(msg)) return;
await submitHumanHelp(msg);
};
const openNegativeFeedback = (msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
msg.feedbackPanelOpen = !msg.feedbackPanelOpen;
@@ -1402,13 +1542,7 @@ const submitNegativeFeedback = async (msg: MasterMsg) => {
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
const fragmentIds = Array.from(
new Set(
(msg.result.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
const fragmentIds = answerFeedbackFragmentIds(msg);
if (!fragmentIds.length) {
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
return;
@@ -3137,6 +3271,46 @@ onUnload(() => {
font-size: 13px;
}
.bubble-guidance-label {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 6px;
padding: 2px 7px;
border-radius: 999px;
background: #fff7e8;
color: #925b00;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
.bubble-uncovered {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #eceff2;
}
.bubble-uncovered-item {
display: flex;
align-items: flex-start;
gap: 4px;
color: #925b00;
font-size: 13px;
line-height: 1.5;
}
.bubble-next-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.context-used {
display: block;
margin-bottom: 6px;
+11 -1
View File
@@ -3,7 +3,8 @@ import type {
KnowledgeConversationState,
KnowledgeMediaSelectedFile,
KnowledgeSearchResponse,
MemoryCandidate
MemoryCandidate,
NextAction
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
@@ -96,6 +97,10 @@ export const normalizeAgentResponse = (
detailRef: citation.detailRef,
locatorSummary: citation.locatorSummary
}));
// 旧版后端 nextActions 是 string[],无 UI 语义;只透传新契约的对象结构。
const nextActions = (Array.isArray(response.nextActions) ? response.nextActions : [])
.filter((action): action is NextAction =>
Boolean(action) && typeof action === 'object' && typeof action.type === 'string');
return {
requestId: response.runId,
queryText: question,
@@ -119,6 +124,11 @@ export const normalizeAgentResponse = (
broadcastContext: response.broadcastContext || null,
agentStatus: response.status,
agentIntent: response.intent,
evidenceLevel: response.evidenceLevel ?? null,
uncoveredPoints: Array.isArray(response.uncoveredPoints)
? response.uncoveredPoints.filter((point): point is string => typeof point === 'string' && Boolean(point.trim()))
: [],
nextActions,
sourceSummary: response.sourceSummary || [],
clarification: response.clarification || null,
keyPoints: response.keyPoints || [],
+16 -1
View File
@@ -159,6 +159,9 @@ export interface KnowledgeSearchResponse {
broadcastContext?: BroadcastKnowledgeContext | null;
agentStatus?: AgentStatus;
agentIntent?: AgentIntent;
evidenceLevel?: AgentEvidenceLevel | null;
uncoveredPoints?: string[];
nextActions?: NextAction[];
sourceSummary?: AgentSourceSummary[];
clarification?: AgentClarification | null;
}
@@ -288,6 +291,16 @@ export interface AgentClarification {
fields: string[];
}
export type AgentEvidenceLevel = 'FULL' | 'PARTIAL' | 'GUIDANCE' | 'ROUTE';
export type NextActionType = 'HUMAN_HELP' | 'DIRECT_CHANNEL' | 'WEB_RESEARCH';
export interface NextAction {
type: NextActionType | string;
channel?: string;
label?: string;
}
export interface AgentResponse {
runId: string;
conversationId?: string | null;
@@ -302,7 +315,9 @@ export interface AgentResponse {
data?: unknown;
actionDraft?: AgentActionDraft | null;
clarification?: AgentClarification | null;
nextActions: string[];
evidenceLevel?: AgentEvidenceLevel | null;
uncoveredPoints?: string[];
nextActions?: NextAction[];
keyPoints?: string[];
cautions?: string[];
}