feat: govern knowledge assets and source citations

This commit is contained in:
key
2026-08-02 01:43:43 +08:00
parent cafb836cda
commit 699cc08050
144 changed files with 17205 additions and 453 deletions
+6 -4
View File
@@ -45,7 +45,7 @@
/>
</view>
</uni-forms-item>
<uni-forms-item label="验证码" name="smsCode">
<uni-forms-item v-if="smsVerificationEnabled" label="验证码" name="smsCode">
<view class="code-row">
<view class="field-shell code-shell">
<uni-icons class="field-icon" type="locked" size="22" color="#a7adb8" />
@@ -74,7 +74,7 @@
</uni-forms-item>
</uni-forms>
<view v-if="showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
<view v-if="smsVerificationEnabled && showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
<uni-icons type="info-filled" size="14" color="#d97706" />
<text class="dev-hint-text">测试验证码: {{ devSmsHintValue }}</text>
<text class="dev-fill-btn">快捷填入</text>
@@ -132,6 +132,8 @@ import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position';
const stored = getAuth();
const loginForm = ref();
const smsVerificationEnabled = !import.meta.env.DEV
|| import.meta.env.VITE_SMS_VERIFICATION_ENABLED === 'true';
const formData = reactive({
phone: stored.phone || uni.getStorageSync(phoneKey) || '',
smsCode: ''
@@ -144,10 +146,10 @@ const formRules = {
]
},
smsCode: {
rules: [
rules: smsVerificationEnabled ? [
{ required: true, errorMessage: '请输入短信验证码' },
{ pattern: '^\\d{4,6}$', errorMessage: '验证码应为 4 至 6 位数字' }
]
] : []
}
};
const sending = ref(false);
@@ -0,0 +1,139 @@
<template>
<view class="citation-page">
<view v-if="state === 'loading'" class="state-panel" role="status">正在确认来源…</view>
<view v-else-if="state === 'forbidden' || state === 'not-found'" class="state-panel error-panel">
<text class="state-title">暂时无法查看这条来源</text>
<text class="state-copy">来源可能已撤回、过期,或当前账号没有访问权限。</text>
<button class="action-button" @click="goBack">返回问答</button>
</view>
<view v-else-if="state === 'error'" class="state-panel error-panel">
<text class="state-title">来源加载失败</text>
<text class="state-copy">网络连接可能不稳定,请稍后重试。</text>
<button class="action-button" @click="load">重试</button>
</view>
<template v-else-if="detail">
<view class="detail-header">
<text class="detail-kind">{{ kindLabel(detail.mediaType) }}</text>
<text class="detail-title">{{ detail.title }}</text>
<text class="detail-location">{{ locationLabel(detail) }}</text>
</view>
<scroll-view v-if="detail.mediaType !== 'IMAGE' && detail.mediaType !== 'VIDEO'" scroll-y class="text-content" :scroll-into-view="targetView">
<view v-for="segment in detail.segments" :id="segment.target ? 'target-segment' : undefined" :key="segment.index" class="text-segment" :class="{ target: segment.target }">
<text>{{ segment.text }}</text>
</view>
</scroll-view>
<view v-else-if="detail.mediaType === 'IMAGE'" class="media-content">
<image v-if="mediaUrl" class="source-image" :src="mediaUrl" mode="widthFix" @click="previewImage" />
<text v-else class="media-loading">正在加载原图…</text>
</view>
<view v-else class="media-content video-content">
<video v-if="mediaUrl" class="source-video" :src="mediaUrl" controls :initial-time="initialSeconds" @loadedmetadata="seekVideo" @canplay="seekVideo" />
<text v-else class="media-loading">正在加载视频…</text>
<text class="video-note">{{ locationLabel(detail) }},播放器将在可用时定位</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import type { KnowledgeCitationDetail } from '@/types/api';
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { isLoggedIn } from '@/services/auth';
import { loadKnowledgeCitationDetail, loadKnowledgeCitationMediaUrl } from '@/services/knowledge';
type ViewState = 'loading' | 'ready' | 'forbidden' | 'not-found' | 'error';
const state = ref<ViewState>('loading');
const detail = ref<KnowledgeCitationDetail | null>(null);
const mediaUrl = ref('');
const targetView = ref('');
const initialSeconds = ref(0);
const detailRef = ref('');
const kindLabel = (kind: string) => ({ TEXT: '文本', PDF: 'PDF', DOCX: '文档', PPTX: '演示文稿', XLSX: '表格', IMAGE: '图片', VIDEO: '视频' }[kind] || '来源');
const formatTime = (ms?: number | null) => typeof ms === 'number' && ms >= 0
? `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}` : '';
const locationLabel = (value: KnowledgeCitationDetail) => {
const locator = value.locator;
if (value.mediaType === 'VIDEO') return `引用位置 ${formatTime(locator?.frameMs ?? locator?.startMs) || '未知'}`;
if (locator?.pageNumber) return `第 ${locator.pageNumber} 页`;
if (locator?.slideNumber) return `第 ${locator.slideNumber} 页`;
if (locator?.sheetName && locator.rowStart) return `${locator.sheetName} · 第 ${locator.rowStart} 行`;
if (locator?.paragraphStart) return `第 ${locator.paragraphStart} 段`;
return '来源全文';
};
const releaseMedia = () => {
if (mediaUrl.value) releaseAuthenticatedFileUrl(mediaUrl.value);
mediaUrl.value = '';
};
const load = async () => {
if (!detailRef.value) { state.value = 'not-found'; return; }
releaseMedia();
detail.value = null;
targetView.value = '';
state.value = 'loading';
try {
const loaded = await loadKnowledgeCitationDetail(detailRef.value);
detail.value = loaded;
const target = loaded.locator?.startMs ?? loaded.locator?.frameMs;
initialSeconds.value = typeof target === 'number' ? target / 1000 : 0;
if (loaded.mediaType === 'VIDEO') {
mediaUrl.value = await loadKnowledgeCitationMediaUrl(detailRef.value);
} else if (loaded.mediaType === 'IMAGE') {
if (!loaded.contentUrl) throw new Error('来源媒体地址缺失');
mediaUrl.value = await authenticatedFileUrl(loaded.contentUrl);
}
state.value = 'ready';
await nextTick();
if (loaded.segments.some((segment) => segment.target)) targetView.value = 'target-segment';
} catch (error) {
if (!isLoggedIn()) state.value = 'forbidden';
else if (error instanceof ApiError && error.statusCode === 404) state.value = 'not-found';
else if (error instanceof ApiError && (error.statusCode === 401 || error.statusCode === 403)) state.value = 'forbidden';
else state.value = 'error';
}
};
const seekVideo = (event: any) => {
const target = initialSeconds.value;
if (target <= 0 || !event?.target) return;
try { event.target.currentTime = target; } catch { /* platform may delay seek until canplay */ }
};
const previewImage = () => mediaUrl.value && uni.previewImage({ urls: [mediaUrl.value], current: mediaUrl.value });
const goBack = () => uni.navigateBack();
onMounted(() => {
const pages = getCurrentPages();
const current = pages[pages.length - 1] as { options?: Record<string, string> } | undefined;
detailRef.value = current?.options?.ref || '';
void load();
});
onUnmounted(releaseMedia);
</script>
<style scoped>
.citation-page { min-height: 100vh; box-sizing: border-box; padding: 16px 14px calc(24px + env(safe-area-inset-bottom)); background: #f5f7fa; color: #202733; }
.detail-header { padding: 16px; border: 1px solid #e5e8ed; border-radius: 12px; background: #fff; }
.detail-kind { display: block; color: #c90018; font-size: 12px; font-weight: 700; }
.detail-title { display: block; margin-top: 6px; font-size: 18px; font-weight: 800; line-height: 1.4; word-break: break-word; }
.detail-location { display: block; margin-top: 8px; color: #697586; font-size: 12px; }
.text-content { height: calc(100vh - 156px); margin-top: 12px; padding: 4px 2px; box-sizing: border-box; }
.text-segment { margin-bottom: 10px; padding: 12px; border-radius: 8px; background: #fff; font-size: 15px; line-height: 1.7; word-break: break-word; }
.text-segment.target { border: 1px solid #f2aab2; background: #fff3f4; box-shadow: 0 0 0 2px rgba(201, 0, 24, .08); }
.media-content { margin-top: 12px; padding: 10px; border-radius: 12px; background: #111; text-align: center; }
.source-image { width: 100%; max-height: 72vh; border-radius: 8px; background: #fff; }
.source-video { width: 100%; height: 230px; border-radius: 8px; background: #000; }
.video-note { display: block; margin-top: 9px; color: #d6dbe2; font-size: 12px; text-align: left; }
.media-loading { color: #d6dbe2; font-size: 13px; }
.state-panel { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.error-panel { padding: 24px; }
.state-title { font-size: 18px; font-weight: 800; }
.state-copy { max-width: 280px; margin-top: 9px; color: #7a8492; font-size: 13px; line-height: 1.6; }
.action-button { min-width: 112px; margin-top: 20px; border: 0; border-radius: 8px; background: #c90018; color: #fff; font-size: 14px; }
.action-button::after { border: 0; }
</style>
+61 -3
View File
@@ -217,7 +217,14 @@
</view>
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
<component
:is="snippet.detailRef && snippet.sourceType === 'DOCUMENT' ? 'button' : 'view'"
v-for="(snippet, index) in msg.result.snippets"
:key="`${snippet.docTitle}-${index}`"
class="citation"
:class="{ 'citation-action': Boolean(snippet.detailRef && snippet.sourceType === 'DOCUMENT') }"
@click="openCitationDetail(snippet)"
>
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
{{ citationDomainLabel(snippet) }}
</text>
@@ -227,8 +234,11 @@
</text>
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
</view>
<view v-if="snippet.detailRef && snippet.sourceType === 'DOCUMENT'" class="citation-open-row">
<text class="citation-location">{{ citationLocationLabel(snippet) }}</text>
<text class="citation-open">查看出处 ›</text>
</view>
</component>
</view>
<view v-if="msg.voiceStatus !== 'unavailable'" class="transcript-collapse" @click="collapseTranscript(msg)">
@@ -1304,6 +1314,28 @@ const serviceMemoryStatusLabel = (status?: string | null) => ({
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const formatCitationTime = (milliseconds?: number | null) => {
if (typeof milliseconds !== 'number' || milliseconds < 0) return '';
const seconds = Math.floor(milliseconds / 1000);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
};
const citationLocationLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
const locator = snippet.locatorSummary;
const at = locator?.frameMs ?? locator?.startMs;
if (snippet.mediaType === 'VIDEO' && typeof at === 'number') return `视频 ${formatCitationTime(at)}`;
if (locator?.pageNumber) return `第 ${locator.pageNumber} 页`;
if (locator?.slideNumber) return `第 ${locator.slideNumber} 页`;
if (locator?.sheetName && locator?.rowStart) return `${locator.sheetName} · 第 ${locator.rowStart} 行`;
if (locator?.paragraphStart) return `第 ${locator.paragraphStart} 段`;
return snippet.mediaType === 'IMAGE' ? '原图' : '来源全文';
};
const openCitationDetail = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
if (!snippet.detailRef || snippet.sourceType !== 'DOCUMENT') return;
uni.navigateTo({ url: `/pages/user/sop/citation-detail?ref=${encodeURIComponent(snippet.detailRef)}` });
};
const noEvidenceText = (result: KnowledgeSearchResponse) => {
if (result.agentIntent === 'WEB_RESEARCH') return '公开网络中没有找到可核验来源。';
if (result.agentIntent === 'MEDIA_UNDERSTANDING') return '已分析附件,但当前资料中没有补充依据。';
@@ -1354,6 +1386,7 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
try {
await submitAnswerFeedback({
queryText: msg.result.queryText || '',
requestId: msg.result.requestId,
fragmentIds,
verdict,
reviewId: msg.result.reviewId,
@@ -3253,6 +3286,31 @@ onUnload(() => {
background: #fafbfc;
}
.citation-action {
box-sizing: border-box;
width: 100%;
margin: 0;
border: 0;
border-left: 3px solid #d3132a;
text-align: left;
line-height: normal;
}
.citation-action::after { border: 0; }
.citation-open-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid #e8ebef;
}
.citation-location { color: #697586; font-size: 12px; }
.citation-open { color: #c90018; font-size: 12px; font-weight: 700; }
.citation-title {
display: block;
color: #232a33;