fix(mobile): preserve assistant IDs and scope state
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))}`;
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user