fix(personal): align assistant citations and UI state

This commit is contained in:
2026-07-12 12:07:28 +08:00
parent c8ef15ee9e
commit 36b7bf28bf
14 changed files with 181 additions and 61 deletions
+18 -26
View File
@@ -51,7 +51,7 @@
<text class="answer-text">{{ answer.answer }}</text>
<view v-if="answer.citations?.length" class="citation-list">
<text class="citation-heading">引用依据</text>
<button v-for="citation in answer.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation-card" @click="openCitation(citation)">
<button v-for="citation in answer.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation-card" :disabled="!citation.itemId" @click="openCitation(citation)">
<view class="citation-head">
<text class="domain-badge" :class="citation.domain.toLowerCase()">{{ citationLabel(citation) }}</text>
<text class="citation-title">{{ citation.title }}</text>
@@ -67,14 +67,14 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { PersonalAskResponse, PersonalCitation } from '@/types/api';
import { askPersonalAssistant, loadPersonalItem, type KnowledgeScope } from '@/services/personal-assistant';
import { askPersonalAssistant, type KnowledgeScope } from '@/services/personal-assistant';
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
const queryText = ref('');
const asking = ref(false);
const answer = ref<PersonalAskResponse>();
const message = ref('');
const citationSourceTypes = ref<Record<string, string>>({});
let requestGeneration = 0;
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
const toggleEnterprise = () => {
@@ -84,53 +84,45 @@ const toggleEnterprise = () => {
const submitQuestion = async () => {
const question = queryText.value.trim();
if (!question || asking.value) return;
const generation = ++requestGeneration;
asking.value = true;
answer.value = undefined;
message.value = '';
try {
answer.value = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
await resolveCitationTypes(answer.value.citations || []);
const response = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration) return;
answer.value = response;
} catch (error) {
if (generation !== requestGeneration) return;
const text = error instanceof Error ? error.message : '提问失败,请稍后重试';
if (enterpriseEnabled.value && /ENTERPRISE_SCOPE_FORBIDDEN|企业知识|FORBIDDEN/i.test(text)) {
scopes.value = ['PERSONAL'];
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
try {
answer.value = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
await resolveCitationTypes(answer.value.citations || []);
const personalResponse = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration) return;
answer.value = personalResponse;
} catch (personalError) {
if (generation !== requestGeneration) return;
answer.value = undefined;
message.value = personalError instanceof Error ? personalError.message : '我的资料查询失败,请稍后重试';
}
} else {
message.value = text;
}
} finally {
asking.value = false;
if (generation === requestGeneration) asking.value = false;
}
};
const resolveCitationTypes = async (citations: PersonalCitation[]) => {
const personalIds = Array.from(new Set(citations
.filter((citation) => citation.domain === 'PERSONAL' && /^\d+$/.test(citation.sourceId))
.map((citation) => citation.sourceId)));
const resolved = await Promise.all(personalIds.map(async (id) => {
try {
const item = await loadPersonalItem(Number(id));
return [id, item.sourceType] as const;
} catch {
return [id, ''] as const;
}
}));
citationSourceTypes.value = Object.fromEntries(resolved);
};
const citationLabel = (citation: PersonalCitation) => {
if (citation.domain === 'ENTERPRISE') return '企业 SOP';
return /^https?:/i.test(citation.sourceId) || citationSourceTypes.value[citation.sourceId] === 'URL' ? '外部网页' : '我的资料';
return citation.sourceType === 'URL' ? '外部网页' : '我的资料';
};
const openCitation = (citation: PersonalCitation) => {
if (citation.domain !== 'PERSONAL' || !/^\d+$/.test(citation.sourceId)) return;
uni.navigateTo({ url: `/pages/user/assistant/item?id=${encodeURIComponent(citation.sourceId)}` });
if (citation.domain !== 'PERSONAL' || !citation.itemId) return;
uni.navigateTo({ url: `/pages/user/assistant/item?id=${citation.itemId}` });
};
const goBack = () => uni.navigateBack();
@@ -25,6 +25,7 @@
<view v-else-if="message && !items.length" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button retry" @click="refresh">重试</button></view>
<view v-else-if="!items.length" class="panel empty">还没有个人资料,先收藏一条文字、文件或网页。</view>
<view v-else class="item-list">
<text v-if="message" class="status-message">{{ message }}</text>
<button v-for="item in items" :key="item.id" class="item-card employee-card" @click="openItem(item.id)">
<view class="item-head"><text class="source-badge">{{ sourceText(item.sourceType) }}</text><text class="status-badge" :class="item.status.toLowerCase()">{{ statusText(item.status) }}</text></view>
<text class="item-title">{{ item.title || '未命名资料' }}</text>
@@ -39,7 +40,7 @@
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalSpace } from '@/types/api';
import { loadPersonalItems, loadPersonalSpace } from '@/services/personal-assistant';
import { loadPersonalItems, loadPersonalSpace, mergePersonalRows } from '@/services/personal-assistant';
const pageNum = ref(1);
const pageSize = 10;
@@ -51,14 +52,15 @@ const status = ref<PersonalItemStatus | ''>('');
const keyword = ref('');
const loading = ref(false);
const message = ref('');
let requestGeneration = 0;
const sourceOptions = [{ label: '全部来源', value: '' }, { label: '文字', value: 'TEXT' }, { label: '文件', value: 'FILE' }, { label: '网页', value: 'URL' }] as const;
const statusOptions = [{ label: '全部状态', value: '' }, { label: '等待处理', value: 'QUEUED' }, { label: '解析中', value: 'PARSING' }, { label: '可检索', value: 'READY' }, { label: '失败', value: 'FAILED' }] as const;
const sourceLabel = computed(() => sourceOptions.find((item) => item.value === sourceType.value)?.label || '全部来源');
const statusLabel = computed(() => statusOptions.find((item) => item.value === status.value)?.label || '全部状态');
const usagePercent = computed(() => Math.min(100, Math.round(((space.value?.usedBytes || 0) / Math.max(1, space.value?.quotaBytes || 1)) * 100)));
const load = async (append = false) => {
if (loading.value) return;
const load = async (append = false, pageSnapshot = pageNum.value) => {
const generation = ++requestGeneration;
loading.value = true;
message.value = '';
try {
@@ -66,15 +68,25 @@ const load = async (append = false) => {
loadPersonalItems({ pageNum: pageNum.value, pageSize, sourceType: sourceType.value, status: status.value, keyword: keyword.value.trim() }),
loadPersonalSpace()
]);
items.value = append ? [...items.value, ...page.rows] : page.rows;
if (generation !== requestGeneration || pageSnapshot !== pageNum.value) return;
items.value = append ? mergePersonalRows(items.value, page.rows) : page.rows;
total.value = page.total;
space.value = latestSpace;
} catch (error) {
if (generation !== requestGeneration) return;
if (append) pageNum.value = Math.max(1, pageSnapshot - 1);
message.value = error instanceof Error ? error.message : '资料加载失败';
} finally { loading.value = false; }
} finally {
if (generation === requestGeneration) loading.value = false;
}
};
const refresh = () => { pageNum.value = 1; void load(); };
const loadMore = () => { pageNum.value += 1; void load(true); };
const loadMore = () => {
if (loading.value) return;
const nextPage = pageNum.value + 1;
pageNum.value = nextPage;
void load(true, nextPage);
};
const changeSource = (event: { detail: { value: string | number } }) => { sourceType.value = sourceOptions[Number(event.detail.value)]?.value || ''; refresh(); };
const changeStatus = (event: { detail: { value: string | number } }) => { status.value = statusOptions[Number(event.detail.value)]?.value || ''; refresh(); };
const sourceText = (value: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[value];
@@ -6,10 +6,12 @@
<view v-else-if="detail" class="detail-wrap">
<view class="detail-toolbar"><button class="secondary-button" @click="detail = undefined">返回列表</button><button class="secondary-button danger" @click="confirmDelete(detail.id)">删除会话</button></view>
<view v-for="chat in detail.messages" :key="chat.id" class="message-card employee-card" :class="chat.role.toLowerCase()">
<text class="role">{{ chat.role === 'USER' ? '我' : '个人助理' }}</text>
<text class="role">{{ chat.role.toLowerCase() === 'user' ? '我' : '个人助理' }}</text>
<text class="content">{{ chat.content }}</text>
<view v-if="chat.citations?.length" class="message-citations">
<text v-for="citation in chat.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation">{{ citation.domain === 'ENTERPRISE' ? '企业 SOP' : '我的资料' }} · {{ citation.title }}</text>
<button v-for="citation in chat.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation" :disabled="!citation.itemId" @click="openCitation(citation)">
{{ citationLabel(citation) }} · {{ citation.title }}
</button>
</view>
</view>
</view>
@@ -26,7 +28,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PersonalSession, PersonalSessionDetail } from '@/types/api';
import type { PersonalCitation, PersonalSession, PersonalSessionDetail } from '@/types/api';
import { deletePersonalSession, loadPersonalSession, loadPersonalSessions } from '@/services/personal-assistant';
const sessions = ref<PersonalSession[]>([]); const detail = ref<PersonalSessionDetail>(); const loading = ref(false); const message = ref('');
@@ -35,10 +37,16 @@ const openSession = async (id: number) => { loading.value = true; message.value
const confirmDelete = (id: number) => uni.showModal({ title: '删除会话', content: '只删除本次问答记录,不会删除资料库内容。', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(id); } });
const remove = async (id: number) => { loading.value = true; try { await deletePersonalSession(id); detail.value = undefined; await loadList(); } catch (error) { message.value = error instanceof Error ? error.message : '删除会话失败'; } finally { loading.value = false; } };
const scopeText = (scope: string) => scope.includes('ENTERPRISE') ? '我的资料 + 企业知识' : '我的资料';
const citationLabel = (citation: PersonalCitation) => citation.domain === 'ENTERPRISE'
? '企业 SOP'
: citation.sourceType === 'URL' ? '外部网页' : '我的资料';
const openCitation = (citation: PersonalCitation) => {
if (citation.itemId) uni.navigateTo({ url: `/pages/user/assistant/item?id=${citation.itemId}` });
};
const goBack = () => uni.navigateBack();
onShow(loadList);
</script>
<style scoped>
.sessions-page { overflow-x: hidden; }.session-list, .detail-wrap { display: grid; margin-top: 16px; gap: 10px; }.session-card, .message-card { width: 100%; min-width: 0; padding: 15px; text-align: left; }.session-title, .session-meta, .role, .content, .citation { display: block; }.session-title { color: var(--ink); font-weight: 900; }.session-meta { margin-top: 7px; color: var(--muted); font-size: 12px; }.detail-toolbar { display: flex; justify-content: space-between; gap: 10px; }.danger { color: #b42318; }.message-card.user { margin-left: 28px; width: calc(100% - 28px); background: #fff7f8; }.role { color: var(--muted); font-size: 12px; font-weight: 800; }.content { margin-top: 8px; line-height: 1.65; white-space: pre-wrap; }.message-citations { display: grid; margin-top: 10px; gap: 5px; }.citation { color: var(--knowledge); font-size: 12px; }.retry { width: 100%; margin-top: 10px; }
.sessions-page { overflow-x: hidden; }.session-list, .detail-wrap { display: grid; margin-top: 16px; gap: 10px; }.session-card, .message-card { width: 100%; min-width: 0; padding: 15px; text-align: left; }.session-title, .session-meta, .role, .content { display: block; }.session-title { color: var(--ink); font-weight: 900; }.session-meta { margin-top: 7px; color: var(--muted); font-size: 12px; }.detail-toolbar { display: flex; justify-content: space-between; gap: 10px; }.danger { color: #b42318; }.message-card.user { margin-left: 28px; width: calc(100% - 28px); background: #fff7f8; }.role { color: var(--muted); font-size: 12px; font-weight: 800; }.content { margin-top: 8px; line-height: 1.65; white-space: pre-wrap; }.message-citations { display: grid; margin-top: 10px; gap: 5px; }.citation { min-height: 34px; justify-content: flex-start; padding: 0 8px; background: #eef3f9; color: var(--knowledge); font-size: 12px; text-align: left; }.retry { width: 100%; margin-top: 10px; }
</style>
+4 -4
View File
@@ -32,7 +32,7 @@ export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUna
const payload = data as ApiPayload<T>;
const code = typeof payload?.code === 'number' ? payload.code : undefined;
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) {
if (clearAuthOnUnauthorized && (statusCode === 401 || code === 401)) {
clearAuth();
}
throw new Error(payload?.msg || `HTTP ${statusCode}`);
@@ -45,7 +45,7 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
try {
return JSON.parse(text || '{}');
} catch (error) {
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
if (clearAuthOnUnauthorized && statusCode === 401) clearAuth();
throw error instanceof SyntaxError
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
: error;
@@ -120,7 +120,7 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
resolve(URL.createObjectURL(xhr.response));
return;
}
if (xhr.status === 401 || xhr.status === 403) clearAuth();
if (xhr.status === 401) clearAuth();
reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('网络请求失败'));
@@ -137,7 +137,7 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
resolve(response.tempFilePath);
return;
}
if (response.statusCode === 401 || response.statusCode === 403) clearAuth();
if (response.statusCode === 401) clearAuth();
reject(new Error(`HTTP ${response.statusCode}`));
},
fail: () => reject(new Error('网络请求失败'))
@@ -42,10 +42,15 @@ export const askPersonalAssistant = (payload: {
url: '/api/aihr/personal-assistant/ask',
method: 'POST',
data: payload,
timeout: 45000,
clearAuthOnUnauthorized: false
timeout: 45000
});
export const mergePersonalRows = (current: PersonalItem[], incoming: PersonalItem[]) => {
const rows = new Map<number, PersonalItem>();
[...current, ...incoming].forEach((item) => rows.set(item.id, item));
return Array.from(rows.values());
};
export const loadPersonalSpace = () => apiRequest<PersonalSpace>({
url: '/api/aihr/personal-assistant/space',
method: 'GET'
+3
View File
@@ -123,6 +123,9 @@ export type PersonalItemStatus = 'QUEUED' | 'PARSING' | 'READY' | 'FAILED' | 'DE
export interface PersonalCitation {
domain: PersonalKnowledgeScope;
sourceId: string;
itemId?: number;
fragmentId?: number;
sourceType?: PersonalItemSourceType | 'ENTERPRISE';
title: string;
excerpt: string;
capturedAt?: string;