fix(mobile): preserve assistant IDs and scope state

This commit is contained in:
2026-07-12 12:15:02 +08:00
parent 36b7bf28bf
commit 0d64cf0f21
8 changed files with 91 additions and 39 deletions
+15 -7
View File
@@ -15,7 +15,7 @@
<text class="domain-name">我的资料</text>
<text class="domain-desc">个人收藏,仅自己可见</text>
</button>
<button class="domain-option" :class="{ active: enterpriseEnabled }" @click="toggleEnterprise">
<button class="domain-option" :class="{ active: enterpriseEnabled }" :disabled="asking" @click="changeScope">
<text class="domain-name">企业知识</text>
<text class="domain-desc">企业知识按岗位权限</text>
</button>
@@ -68,6 +68,7 @@
import { computed, ref } from 'vue';
import type { PersonalAskResponse, PersonalCitation } from '@/types/api';
import { askPersonalAssistant, type KnowledgeScope } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
const queryText = ref('');
@@ -77,7 +78,12 @@ const message = ref('');
let requestGeneration = 0;
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
const toggleEnterprise = () => {
const scopeKey = (value: KnowledgeScope[]) => value.join(',');
const changeScope = () => {
if (asking.value) return;
requestGeneration += 1;
answer.value = undefined;
message.value = '';
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
};
@@ -85,22 +91,24 @@ const submitQuestion = async () => {
const question = queryText.value.trim();
if (!question || asking.value) return;
const generation = ++requestGeneration;
const scopeSnapshot = [...scopes.value];
asking.value = true;
answer.value = undefined;
message.value = '';
try {
const response = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration) return;
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration || scopeKey(scopes.value) !== scopeKey(scopeSnapshot)) 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'];
const fallbackScope: KnowledgeScope[] = ['PERSONAL'];
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
try {
const personalResponse = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration) return;
const personalResponse = await askPersonalAssistant({ queryText: question, scope: fallbackScope, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration || scopeKey(scopes.value) !== scopeKey(fallbackScope)) return;
answer.value = personalResponse;
} catch (personalError) {
if (generation !== requestGeneration) return;
@@ -122,7 +130,7 @@ const citationLabel = (citation: PersonalCitation) => {
const openCitation = (citation: PersonalCitation) => {
if (citation.domain !== 'PERSONAL' || !citation.itemId) return;
uni.navigateTo({ url: `/pages/user/assistant/item?id=${citation.itemId}` });
uni.navigateTo({ url: personalItemRoute(citation.itemId) });
};
const goBack = () => uni.navigateBack();
+10 -2
View File
@@ -34,8 +34,9 @@ import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus } from '@/types/api';
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, retryPersonalItem } from '@/services/personal-assistant';
import { normalizeBackendId } from '@/services/personal-assistant-id';
const itemId = ref(0); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
const itemId = ref(''); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
const statusText = (status: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[status];
const statusDescription = (status: PersonalItemStatus) => ({ QUEUED: '已进入解析队列。', PARSING: '正在提取正文和建立搜索索引。', READY: '已完成解析,可用于个人助理问答。', FAILED: '本次解析失败,可查看公开原因后重试。', DELETING: '正在清理附件、正文和索引。', DELETED: '资料已删除。' })[status];
const sourceText = (source: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[source];
@@ -45,7 +46,14 @@ const download = async () => { busy.value = true; try { const result = await dow
const confirmDelete = () => uni.showModal({ title: '确认删除资料', content: '附件、解析正文和搜索索引会清除,删除后无法恢复。', confirmText: '确认删除', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(); } });
const remove = async () => { busy.value = true; message.value = ''; try { await deletePersonalItem(itemId.value); uni.redirectTo({ url: '/pages/user/assistant/library' }); } catch (error) { message.value = error instanceof Error ? error.message : '删除失败,请重试'; } finally { busy.value = false; } };
const goBack = () => uni.navigateBack();
onLoad((options) => { itemId.value = Number(options?.id || 0); if (!Number.isSafeInteger(itemId.value) || itemId.value <= 0) message.value = '资料不存在或无权访问'; else void load(); });
onLoad((options) => {
try {
itemId.value = normalizeBackendId(String(options?.id || ''));
void load();
} catch {
message.value = '资料不存在或无权访问';
}
});
</script>
<style scoped>
@@ -41,6 +41,7 @@ import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalSpace } from '@/types/api';
import { loadPersonalItems, loadPersonalSpace, mergePersonalRows } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
const pageNum = ref(1);
const pageSize = 10;
@@ -92,7 +93,7 @@ const changeStatus = (event: { detail: { value: string | number } }) => { status
const sourceText = (value: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[value];
const statusText = (value: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[value];
const formatBytes = (bytes: number) => bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
const openItem = (id: number) => uni.navigateTo({ url: `/pages/user/assistant/item?id=${id}` });
const openItem = (id: string) => uni.navigateTo({ url: personalItemRoute(id) });
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
const goBack = () => uni.navigateBack();
onShow(refresh);
@@ -30,18 +30,19 @@ import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PersonalCitation, PersonalSession, PersonalSessionDetail } from '@/types/api';
import { deletePersonalSession, loadPersonalSession, loadPersonalSessions } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
const sessions = ref<PersonalSession[]>([]); const detail = ref<PersonalSessionDetail>(); const loading = ref(false); const message = ref('');
const loadList = async () => { loading.value = true; message.value = ''; try { sessions.value = await loadPersonalSessions(); } catch (error) { message.value = error instanceof Error ? error.message : '会话加载失败'; } finally { loading.value = false; } };
const openSession = async (id: number) => { loading.value = true; message.value = ''; try { detail.value = await loadPersonalSession(id); } catch (error) { message.value = error instanceof Error ? error.message : '会话不存在或无权访问'; } finally { loading.value = false; } };
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 openSession = async (id: string) => { loading.value = true; message.value = ''; try { detail.value = await loadPersonalSession(id); } catch (error) { message.value = error instanceof Error ? error.message : '会话不存在或无权访问'; } finally { loading.value = false; } };
const confirmDelete = (id: string) => uni.showModal({ title: '删除会话', content: '只删除本次问答记录,不会删除资料库内容。', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(id); } });
const remove = async (id: string) => { 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}` });
if (citation.itemId) uni.navigateTo({ url: personalItemRoute(citation.itemId) });
};
const goBack = () => uni.navigateBack();
onShow(loadList);
@@ -0,0 +1,14 @@
export const normalizeBackendId = (value: string) => {
const id = value.trim();
if (!/^[1-9]\d*$/.test(id)) throw new Error('PERSONAL_ID_INVALID');
return id;
};
export const personalItemApiPath = (id: string, suffix = '') =>
`/api/aihr/personal-assistant/items/${encodeURIComponent(normalizeBackendId(id))}${suffix}`;
export const personalSessionApiPath = (id: string) =>
`/api/aihr/personal-assistant/sessions/${encodeURIComponent(normalizeBackendId(id))}`;
export const personalItemRoute = (id: string) =>
`/pages/user/assistant/item?id=${encodeURIComponent(normalizeBackendId(id))}`;
+16 -15
View File
@@ -10,6 +10,7 @@ import type {
PersonalSpace
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
export type KnowledgeScope = PersonalKnowledgeScope;
@@ -31,12 +32,12 @@ const queryString = (query: PersonalItemQuery) => {
};
export const askPersonalAssistant = (payload: {
sessionId?: number;
sessionId?: string;
queryText: string;
scope: KnowledgeScope[];
dateFrom?: string;
dateTo?: string;
itemIds?: number[];
itemIds?: string[];
outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE';
}) => apiRequest<PersonalAskResponse>({
url: '/api/aihr/personal-assistant/ask',
@@ -46,7 +47,7 @@ export const askPersonalAssistant = (payload: {
});
export const mergePersonalRows = (current: PersonalItem[], incoming: PersonalItem[]) => {
const rows = new Map<number, PersonalItem>();
const rows = new Map<string, PersonalItem>();
[...current, ...incoming].forEach((item) => rows.set(item.id, item));
return Array.from(rows.values());
};
@@ -61,8 +62,8 @@ export const loadPersonalItems = (query: PersonalItemQuery = {}) => apiRequest<P
method: 'GET'
});
export const loadPersonalItem = (id: number) => apiRequest<PersonalItem>({
url: `/api/aihr/personal-assistant/items/${id}`,
export const loadPersonalItem = (id: string) => apiRequest<PersonalItem>({
url: personalItemApiPath(id),
method: 'GET'
});
@@ -128,19 +129,19 @@ export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, c
});
});
export const retryPersonalItem = (id: number) => apiRequest<PersonalItem>({
url: `/api/aihr/personal-assistant/items/${id}/retry`,
export const retryPersonalItem = (id: string) => apiRequest<PersonalItem>({
url: personalItemApiPath(id, '/retry'),
method: 'POST'
});
export const deletePersonalItem = (id: number) => apiRequest<{ cleanupJobId: number }>({
url: `/api/aihr/personal-assistant/items/${id}`,
export const deletePersonalItem = (id: string) => apiRequest<{ cleanupJobId: string }>({
url: personalItemApiPath(id),
method: 'DELETE',
timeout: 15000
});
export const downloadPersonalItem = (id: number) => apiRequest<{ url: string; expiresAt?: string }>({
url: `/api/aihr/personal-assistant/items/${id}/download-url`,
export const downloadPersonalItem = (id: string) => apiRequest<{ url: string; expiresAt?: string }>({
url: personalItemApiPath(id, '/download-url'),
method: 'GET'
});
@@ -149,12 +150,12 @@ export const loadPersonalSessions = () => apiRequest<PersonalSession[]>({
method: 'GET'
});
export const loadPersonalSession = (id: number) => apiRequest<PersonalSessionDetail>({
url: `/api/aihr/personal-assistant/sessions/${id}`,
export const loadPersonalSession = (id: string) => apiRequest<PersonalSessionDetail>({
url: personalSessionApiPath(id),
method: 'GET'
});
export const deletePersonalSession = (id: number) => apiRequest<void>({
url: `/api/aihr/personal-assistant/sessions/${id}`,
export const deletePersonalSession = (id: string) => apiRequest<void>({
url: personalSessionApiPath(id),
method: 'DELETE'
});
+10 -10
View File
@@ -123,8 +123,8 @@ export type PersonalItemStatus = 'QUEUED' | 'PARSING' | 'READY' | 'FAILED' | 'DE
export interface PersonalCitation {
domain: PersonalKnowledgeScope;
sourceId: string;
itemId?: number;
fragmentId?: number;
itemId?: string;
fragmentId?: string;
sourceType?: PersonalItemSourceType | 'ENTERPRISE';
title: string;
excerpt: string;
@@ -132,7 +132,7 @@ export interface PersonalCitation {
}
export interface PersonalAskResponse {
sessionId: number;
sessionId: string;
answer: string;
citations: PersonalCitation[];
model?: string;
@@ -140,7 +140,7 @@ export interface PersonalAskResponse {
}
export interface PersonalSpace {
spaceId: number;
spaceId: string;
status: string;
quotaBytes: number;
usedBytes: number;
@@ -148,7 +148,7 @@ export interface PersonalSpace {
}
export interface PersonalItem {
id: number;
id: string;
sourceType: PersonalItemSourceType;
title: string;
originalUrl?: string;
@@ -171,20 +171,20 @@ export interface PersonalPage<T> {
}
export interface PersonalItemCreated {
itemId: number;
itemId: string;
status: PersonalItemStatus;
duplicateOf?: number;
duplicateOf?: string;
}
export interface PersonalSession {
id: number;
id: string;
title: string;
defaultScope: string;
updatedAt?: string;
}
export interface PersonalChatMessage {
id: number;
id: string;
role: string;
content: string;
citations: PersonalCitation[];
@@ -192,7 +192,7 @@ export interface PersonalChatMessage {
}
export interface PersonalSessionDetail {
id: number;
id: string;
title: string;
messages: PersonalChatMessage[];
}
@@ -3,6 +3,15 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
const idHelpers = await import('../src/services/personal-assistant-id.ts');
test('Snowflake ID 全程保留字符串精度并安全生成详情路径', () => {
const hugeId = '1990000000000000000';
assert.equal(idHelpers.normalizeBackendId(hugeId), hugeId);
assert.equal(idHelpers.personalItemRoute(hugeId), `/pages/user/assistant/item?id=${hugeId}`);
assert.equal(idHelpers.personalItemApiPath(hugeId), `/api/aihr/personal-assistant/items/${hugeId}`);
assert.equal(idHelpers.personalSessionApiPath(hugeId), `/api/aihr/personal-assistant/sessions/${hugeId}`);
});
test('个人助理 API 固定使用登录态且不接收 owner 参数', async () => {
const [client, api] = await Promise.all([
@@ -13,6 +22,10 @@ test('个人助理 API 固定使用登录态且不接收 owner 参数', async ()
assert.match(client, /\/api\/aihr\/personal-assistant\/ask/);
assert.match(client, /\/api\/aihr\/personal-assistant\/items\/file/);
assert.match(client, /uni\.uploadFile/);
assert.match(client, /sessionId\?: string/);
assert.match(client, /itemIds\?: string\[\]/);
assert.match(client, /loadPersonalItem = \(id: string\)/);
assert.match(client, /loadPersonalSession = \(id: string\)/);
assert.doesNotMatch(client, /ownerUserId|tenantId/);
assert.doesNotMatch(client, /clearAuthOnUnauthorized:\s*false/);
assert.doesNotMatch(api, /statusCode === 401 \|\| statusCode === 403/);
@@ -32,6 +45,10 @@ test('问答首页默认只查询个人资料并展示真实引用域', async ()
assert.doesNotMatch(page, /loadPersonalItem/);
assert.match(page, /answer\.value = undefined/);
assert.match(page, /requestGeneration/);
assert.match(page, /:disabled="asking"/);
assert.match(page, /if \(asking\.value\) return/);
assert.match(page, /scopeSnapshot/);
assert.match(page, /scopeKey\(scopes\.value\)/);
assert.match(page, /收藏资料/);
assert.doesNotMatch(page, /ownerUserId|tenantId/);
});
@@ -69,6 +86,8 @@ test('个人资料详情包含全部状态、重试、下载和明确删除提
assert.match(page, /deletePersonalItem/);
assert.match(page, /downloadPersonalItem/);
assert.match(page, /附件、解析正文和搜索索引/);
assert.match(page, /normalizeBackendId\(String\(options\?\.id/);
assert.doesNotMatch(page, /Number\(options\?\.id|parseInt\(options\?\.id|Number\.isSafeInteger/);
});
test('会话页覆盖列表、详情和删除且不伪造会话', async () => {