fix(personal): align assistant citations and UI state
This commit is contained in:
+21
-2
@@ -27,8 +27,13 @@ public final class PersonalAssistantDto {
|
|||||||
LocalDate dateTo, List<Long> itemIds, String outputFormat) {
|
LocalDate dateTo, List<Long> itemIds, String outputFormat) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CitationResponse(String domain, String sourceId, String title, String excerpt,
|
public record CitationResponse(String domain, String sourceId, Long itemId, Long fragmentId, String sourceType,
|
||||||
|
String title, String excerpt, LocalDateTime capturedAt) {
|
||||||
|
/** Reads legacy persisted citations whose sourceId represented the fragment id. */
|
||||||
|
public CitationResponse(String domain, String sourceId, String title, String excerpt,
|
||||||
LocalDateTime capturedAt) {
|
LocalDateTime capturedAt) {
|
||||||
|
this(domain, sourceId, null, legacyId(sourceId), null, title, excerpt, capturedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record AskResponse(long sessionId, String answer, List<CitationResponse> citations, String model,
|
public record AskResponse(long sessionId, String answer, List<CitationResponse> citations, String model,
|
||||||
@@ -53,8 +58,12 @@ public final class PersonalAssistantDto {
|
|||||||
LocalDate dateTo, List<Long> itemIds, Integer limit) {
|
LocalDate dateTo, List<Long> itemIds, Integer limit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SearchHitResponse(String domain, String sourceId, String title, String excerpt,
|
public record SearchHitResponse(String domain, String sourceId, Long itemId, Long fragmentId, String sourceType,
|
||||||
|
String title, String excerpt, LocalDateTime capturedAt, double score) {
|
||||||
|
public SearchHitResponse(String domain, String sourceId, String title, String excerpt,
|
||||||
LocalDateTime capturedAt, double score) {
|
LocalDateTime capturedAt, double score) {
|
||||||
|
this(domain, sourceId, null, legacyId(sourceId), null, title, excerpt, capturedAt, score);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record PersonalSearchResponse(String queryText, List<SearchHitResponse> hits) {
|
public record PersonalSearchResponse(String queryText, List<SearchHitResponse> hits) {
|
||||||
@@ -69,4 +78,14 @@ public final class PersonalAssistantDto {
|
|||||||
public record ChatMessageResponse(long id, String role, String content, List<CitationResponse> citations,
|
public record ChatMessageResponse(long id, String role, String content, List<CitationResponse> citations,
|
||||||
LocalDateTime createdAt) {
|
LocalDateTime createdAt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Long legacyId(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(value);
|
||||||
|
return id > 0 ? id : null;
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-5
@@ -202,13 +202,15 @@ public class PersonalAnswerService {
|
|||||||
PersonalSearchRequest search = new PersonalSearchRequest(request.query(), List.of(SearchScope.PERSONAL),
|
PersonalSearchRequest search = new PersonalSearchRequest(request.query(), List.of(SearchScope.PERSONAL),
|
||||||
request.dateFrom(), request.dateTo(), request.itemIds(), PER_DOMAIN_LIMIT);
|
request.dateFrom(), request.dateTo(), request.itemIds(), PER_DOMAIN_LIMIT);
|
||||||
personal = personalRetriever.search(owner, search).stream()
|
personal = personalRetriever.search(owner, search).stream()
|
||||||
.map(hit -> citation("PERSONAL", hit.sourceId(), hit.title(), hit.excerpt(), hit.capturedAt()))
|
.map(hit -> citation("PERSONAL", hit.sourceId(), hit.itemId(), hit.fragmentId(), hit.sourceType(),
|
||||||
|
hit.title(), hit.excerpt(), hit.capturedAt()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
if (request.scopes().contains(SearchScope.ENTERPRISE)) {
|
if (request.scopes().contains(SearchScope.ENTERPRISE)) {
|
||||||
enterprise = enterpriseRetriever.search(owner, request.query(), enterpriseGrant.orElseThrow(),
|
enterprise = enterpriseRetriever.search(owner, request.query(), enterpriseGrant.orElseThrow(),
|
||||||
PER_DOMAIN_LIMIT).stream()
|
PER_DOMAIN_LIMIT).stream()
|
||||||
.map(hit -> citation("ENTERPRISE", Long.toString(hit.fragmentId()), hit.title(), hit.content(), null))
|
.map(hit -> citation("ENTERPRISE", Long.toString(hit.fragmentId()), null, hit.fragmentId(),
|
||||||
|
"ENTERPRISE", hit.title(), hit.content(), null))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
List<CitationResponse> ordered = new ArrayList<>();
|
List<CitationResponse> ordered = new ArrayList<>();
|
||||||
@@ -237,10 +239,11 @@ public class PersonalAnswerService {
|
|||||||
target.addAll(unique.values());
|
target.addAll(unique.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CitationResponse citation(String domain, String sourceId, String title, String excerpt,
|
private static CitationResponse citation(String domain, String sourceId, Long itemId, Long fragmentId,
|
||||||
|
String sourceType, String title, String excerpt,
|
||||||
LocalDateTime capturedAt) {
|
LocalDateTime capturedAt) {
|
||||||
return new CitationResponse(domain, sourceId, truncate(clean(title), MAX_TITLE_LENGTH),
|
return new CitationResponse(domain, sourceId, itemId, fragmentId, sourceType,
|
||||||
truncate(clean(excerpt), MAX_EXCERPT_LENGTH), capturedAt);
|
truncate(clean(title), MAX_TITLE_LENGTH), truncate(clean(excerpt), MAX_EXCERPT_LENGTH), capturedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String systemPrompt() {
|
private static String systemPrompt() {
|
||||||
|
|||||||
+11
-6
@@ -95,7 +95,7 @@ public class PersonalRetrievalService {
|
|||||||
|
|
||||||
private List<SearchHitResponse> fulltext(PersonalOwner owner, ValidatedRequest request) {
|
private List<SearchHitResponse> fulltext(PersonalOwner owner, ValidatedRequest request) {
|
||||||
StringBuilder sql = new StringBuilder("""
|
StringBuilder sql = new StringBuilder("""
|
||||||
select f.id as fragment_id, i.title, f.content, i.captured_at,
|
select f.id as fragment_id, i.id as item_id, i.source_type, i.title, f.content, i.captured_at,
|
||||||
match(f.content) against (? in natural language mode) as relevance
|
match(f.content) against (? in natural language mode) as relevance
|
||||||
from aihr_personal_fragment f
|
from aihr_personal_fragment f
|
||||||
join aihr_personal_item i
|
join aihr_personal_item i
|
||||||
@@ -130,7 +130,8 @@ public class PersonalRetrievalService {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
StringBuilder sql = new StringBuilder("""
|
StringBuilder sql = new StringBuilder("""
|
||||||
select f.id as fragment_id, i.title, f.content, i.captured_at, 0 as relevance
|
select f.id as fragment_id, i.id as item_id, i.source_type, i.title, f.content, i.captured_at,
|
||||||
|
0 as relevance
|
||||||
from aihr_personal_fragment f
|
from aihr_personal_fragment f
|
||||||
join aihr_personal_item i
|
join aihr_personal_item i
|
||||||
on i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id
|
on i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id
|
||||||
@@ -160,8 +161,8 @@ public class PersonalRetrievalService {
|
|||||||
for (PersonalVectorStore.VectorMatch match : matches) {
|
for (PersonalVectorStore.VectorMatch match : matches) {
|
||||||
SearchHitResponse hit = byId.get(Long.toString(match.fragmentId()));
|
SearchHitResponse hit = byId.get(Long.toString(match.fragmentId()));
|
||||||
if (hit != null) {
|
if (hit != null) {
|
||||||
ordered.add(new SearchHitResponse(hit.domain(), hit.sourceId(), hit.title(), hit.excerpt(),
|
ordered.add(new SearchHitResponse(hit.domain(), hit.sourceId(), hit.itemId(), hit.fragmentId(),
|
||||||
hit.capturedAt(), match.score()));
|
hit.sourceType(), hit.title(), hit.excerpt(), hit.capturedAt(), match.score()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ordered;
|
return ordered;
|
||||||
@@ -171,6 +172,9 @@ public class PersonalRetrievalService {
|
|||||||
return (rs, rowNum) -> new SearchHitResponse(
|
return (rs, rowNum) -> new SearchHitResponse(
|
||||||
"PERSONAL",
|
"PERSONAL",
|
||||||
Long.toString(rs.getLong("fragment_id")),
|
Long.toString(rs.getLong("fragment_id")),
|
||||||
|
rs.getLong("item_id"),
|
||||||
|
rs.getLong("fragment_id"),
|
||||||
|
rs.getString("source_type"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
excerpt(rs.getString("content")),
|
excerpt(rs.getString("content")),
|
||||||
rs.getObject("captured_at", LocalDateTime.class),
|
rs.getObject("captured_at", LocalDateTime.class),
|
||||||
@@ -184,8 +188,9 @@ public class PersonalRetrievalService {
|
|||||||
addRanking(lexical, hits, scores);
|
addRanking(lexical, hits, scores);
|
||||||
addRanking(vector, hits, scores);
|
addRanking(vector, hits, scores);
|
||||||
return hits.values().stream()
|
return hits.values().stream()
|
||||||
.map(hit -> new SearchHitResponse(hit.domain(), hit.sourceId(), hit.title(), hit.excerpt(),
|
.map(hit -> new SearchHitResponse(hit.domain(), hit.sourceId(), hit.itemId(), hit.fragmentId(),
|
||||||
hit.capturedAt(), scores.getOrDefault(hit.sourceId(), 0D)))
|
hit.sourceType(), hit.title(), hit.excerpt(), hit.capturedAt(),
|
||||||
|
scores.getOrDefault(hit.sourceId(), 0D)))
|
||||||
.sorted(Comparator.comparingDouble(SearchHitResponse::score).reversed()
|
.sorted(Comparator.comparingDouble(SearchHitResponse::score).reversed()
|
||||||
.thenComparing(SearchHitResponse::sourceId))
|
.thenComparing(SearchHitResponse::sourceId))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
+15
-1
@@ -296,12 +296,26 @@ public class PersonalSpaceService {
|
|||||||
private List<CitationResponse> citations(String value) {
|
private List<CitationResponse> citations(String value) {
|
||||||
if (value == null || value.isBlank()) return List.of();
|
if (value == null || value.isBlank()) return List.of();
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(value, new TypeReference<>() { });
|
List<CitationResponse> parsed = objectMapper.readValue(value, new TypeReference<>() { });
|
||||||
|
return parsed.stream().map(this::normalizeLegacyCitation).toList();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new ServiceException("PERSONAL_SESSION_DATA_INVALID");
|
throw new ServiceException("PERSONAL_SESSION_DATA_INVALID");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CitationResponse normalizeLegacyCitation(CitationResponse citation) {
|
||||||
|
if (citation == null || citation.fragmentId() != null) return citation;
|
||||||
|
Long fragmentId = null;
|
||||||
|
try {
|
||||||
|
long parsed = Long.parseLong(citation.sourceId());
|
||||||
|
if (parsed > 0) fragmentId = parsed;
|
||||||
|
} catch (NumberFormatException | NullPointerException ignored) {
|
||||||
|
// Legacy non-numeric source ids stay nullable instead of inventing an identity.
|
||||||
|
}
|
||||||
|
return new CitationResponse(citation.domain(), citation.sourceId(), citation.itemId(), fragmentId,
|
||||||
|
citation.sourceType(), citation.title(), citation.excerpt(), citation.capturedAt());
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalized(String value, Set<String> allowed, String error) {
|
private static String normalized(String value, Set<String> allowed, String error) {
|
||||||
if (value == null || value.isBlank()) return null;
|
if (value == null || value.isBlank()) return null;
|
||||||
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
|||||||
+15
-2
@@ -56,6 +56,11 @@ class PersonalAnswerServiceTest {
|
|||||||
assertEquals(List.of("PERSONAL", "ENTERPRISE"),
|
assertEquals(List.of("PERSONAL", "ENTERPRISE"),
|
||||||
response.citations().stream().map(citation -> citation.domain()).toList());
|
response.citations().stream().map(citation -> citation.domain()).toList());
|
||||||
assertEquals(2, response.citations().size());
|
assertEquals(2, response.citations().size());
|
||||||
|
assertEquals(9L, response.citations().get(0).itemId());
|
||||||
|
assertEquals(109L, response.citations().get(0).fragmentId());
|
||||||
|
assertEquals("URL", response.citations().get(0).sourceType());
|
||||||
|
assertEquals(null, response.citations().get(1).itemId());
|
||||||
|
assertEquals(7L, response.citations().get(1).fragmentId());
|
||||||
assertEquals(500L, response.sessionId());
|
assertEquals(500L, response.sessionId());
|
||||||
assertEquals(List.of(SearchScope.PERSONAL, SearchScope.ENTERPRISE), persistence.scope);
|
assertEquals(List.of(SearchScope.PERSONAL, SearchScope.ENTERPRISE), persistence.scope);
|
||||||
assertEquals(response.citations(), persistence.citations);
|
assertEquals(response.citations(), persistence.citations);
|
||||||
@@ -343,7 +348,8 @@ class PersonalAnswerServiceTest {
|
|||||||
jdbc, transaction, new ObjectMapper().findAndRegisterModules());
|
jdbc, transaction, new ObjectMapper().findAndRegisterModules());
|
||||||
List<org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse> citations = List.of(
|
List<org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse> citations = List.of(
|
||||||
new org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse(
|
new org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse(
|
||||||
"PERSONAL", "8", "标题", "摘录", LocalDateTime.of(2026, 7, 12, 9, 0)));
|
"PERSONAL", "8", 80L, 8L, "FILE", "标题", "摘录",
|
||||||
|
LocalDateTime.of(2026, 7, 12, 9, 0)));
|
||||||
|
|
||||||
assertTrue(persistence.sessionAccessible(OWNER, 88L));
|
assertTrue(persistence.sessionAccessible(OWNER, 88L));
|
||||||
assertEquals(88L, persistence.save(OWNER, 88L, "问题", "答案",
|
assertEquals(88L, persistence.save(OWNER, 88L, "问题", "答案",
|
||||||
@@ -375,6 +381,13 @@ class PersonalAnswerServiceTest {
|
|||||||
.filter(args -> "user".equals(args[4]))
|
.filter(args -> "user".equals(args[4]))
|
||||||
.findFirst().orElseThrow();
|
.findFirst().orElseThrow();
|
||||||
assertEquals("provider-model", assistantArgs[8]);
|
assertEquals("provider-model", assistantArgs[8]);
|
||||||
|
String citationsJson = String.valueOf(assistantArgs[7]);
|
||||||
|
assertTrue(citationsJson.contains("\"itemId\""));
|
||||||
|
assertTrue(citationsJson.contains("\"fragmentId\""));
|
||||||
|
assertTrue(citationsJson.contains("\"sourceType\""));
|
||||||
|
assertTrue(citationsJson.contains("\"itemId\":80"));
|
||||||
|
assertTrue(citationsJson.contains("\"fragmentId\":8"));
|
||||||
|
assertTrue(citationsJson.contains("\"sourceType\":\"FILE\""));
|
||||||
assertEquals(17, assistantArgs[10]);
|
assertEquals(17, assistantArgs[10]);
|
||||||
assertEquals(8, assistantArgs[11]);
|
assertEquals(8, assistantArgs[11]);
|
||||||
assertTrue(((Long) userArgs[0]) < ((Long) assistantArgs[0]));
|
assertTrue(((Long) userArgs[0]) < ((Long) assistantArgs[0]));
|
||||||
@@ -443,7 +456,7 @@ class PersonalAnswerServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SearchHitResponse personalHit(String id, String title, String excerpt) {
|
private static SearchHitResponse personalHit(String id, String title, String excerpt) {
|
||||||
return new SearchHitResponse("PERSONAL", id, title, excerpt,
|
return new SearchHitResponse("PERSONAL", id, Long.parseLong(id), 100L + Long.parseLong(id), "URL", title, excerpt,
|
||||||
LocalDateTime.of(2026, 7, 12, 9, 0), 1D);
|
LocalDateTime.of(2026, 7, 12, 9, 0), 1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
@@ -44,6 +44,8 @@ class PersonalRetrievalServiceTest {
|
|||||||
assertTrue(normalized.contains("i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id"));
|
assertTrue(normalized.contains("i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id"));
|
||||||
assertTrue(normalized.contains("f.tenant_id = ? and f.owner_user_id = ?"));
|
assertTrue(normalized.contains("f.tenant_id = ? and f.owner_user_id = ?"));
|
||||||
assertTrue(normalized.contains("i.status = 'READY'"));
|
assertTrue(normalized.contains("i.status = 'READY'"));
|
||||||
|
assertTrue(normalized.contains("i.id as item_id"));
|
||||||
|
assertTrue(normalized.contains("i.source_type"));
|
||||||
assertTrue(normalized.contains("match(f.content) against (? in natural language mode)"));
|
assertTrue(normalized.contains("match(f.content) against (? in natural language mode)"));
|
||||||
assertTrue(normalized.contains("i.id in (?,?)"));
|
assertTrue(normalized.contains("i.id in (?,?)"));
|
||||||
assertFalse(sql.getValue().contains("3,5"));
|
assertFalse(sql.getValue().contains("3,5"));
|
||||||
@@ -169,6 +171,15 @@ class PersonalRetrievalServiceTest {
|
|||||||
return new SearchHitResponse("PERSONAL", id, title, title + " excerpt", LocalDateTime.of(2026, 1, 1, 0, 0), 1);
|
return new SearchHitResponse("PERSONAL", id, title, title + " excerpt", LocalDateTime.of(2026, 1, 1, 0, 0), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void personalHitCarriesItemFragmentAndSourceTypeSeparately() {
|
||||||
|
SearchHitResponse hit = new SearchHitResponse("PERSONAL", "44", 12L, 44L, "URL", "网页", "正文",
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 0), 1D);
|
||||||
|
assertEquals(12L, hit.itemId());
|
||||||
|
assertEquals(44L, hit.fragmentId());
|
||||||
|
assertEquals("URL", hit.sourceType());
|
||||||
|
}
|
||||||
|
|
||||||
private void assertDateInvalid(org.junit.jupiter.api.function.Executable executable) {
|
private void assertDateInvalid(org.junit.jupiter.api.function.Executable executable) {
|
||||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, executable);
|
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, executable);
|
||||||
assertEquals("PERSONAL_SEARCH_DATE_INVALID", error.getMessage());
|
assertEquals("PERSONAL_SEARCH_DATE_INVALID", error.getMessage());
|
||||||
|
|||||||
+19
@@ -4,6 +4,7 @@ import org.dromara.aihr.personal.config.PersonalSchedulingConfig;
|
|||||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||||
import org.dromara.aihr.personal.service.PersonalIngestionWorker;
|
import org.dromara.aihr.personal.service.PersonalIngestionWorker;
|
||||||
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
||||||
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse;
|
||||||
import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
||||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
@@ -46,6 +47,24 @@ import static org.mockito.Mockito.when;
|
|||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
class PersonalSpaceServiceTest {
|
class PersonalSpaceServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void citationPersistenceReadsNewIdentityAndLegacySourceId() {
|
||||||
|
PersonalSpaceService service = new PersonalSpaceService(mock(JdbcTemplate.class), properties());
|
||||||
|
List<CitationResponse> current = (List<CitationResponse>) ReflectionTestUtils.invokeMethod(service,
|
||||||
|
"citations", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"44\",\"itemId\":12,"
|
||||||
|
+ "\"fragmentId\":44,\"sourceType\":\"URL\",\"title\":\"网页\",\"excerpt\":\"摘要\"}]");
|
||||||
|
assertEquals(12L, current.get(0).itemId());
|
||||||
|
assertEquals(44L, current.get(0).fragmentId());
|
||||||
|
assertEquals("URL", current.get(0).sourceType());
|
||||||
|
|
||||||
|
List<CitationResponse> legacy = (List<CitationResponse>) ReflectionTestUtils.invokeMethod(service,
|
||||||
|
"citations", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"45\",\"title\":\"旧记录\","
|
||||||
|
+ "\"excerpt\":\"摘要\"}]");
|
||||||
|
assertEquals(null, legacy.get(0).itemId());
|
||||||
|
assertEquals(45L, legacy.get(0).fragmentId());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void itemListAndSessionsAreAlwaysOwnerScoped() {
|
void itemListAndSessionsAreAlwaysOwnerScoped() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<text class="answer-text">{{ answer.answer }}</text>
|
<text class="answer-text">{{ answer.answer }}</text>
|
||||||
<view v-if="answer.citations?.length" class="citation-list">
|
<view v-if="answer.citations?.length" class="citation-list">
|
||||||
<text class="citation-heading">引用依据</text>
|
<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">
|
<view class="citation-head">
|
||||||
<text class="domain-badge" :class="citation.domain.toLowerCase()">{{ citationLabel(citation) }}</text>
|
<text class="domain-badge" :class="citation.domain.toLowerCase()">{{ citationLabel(citation) }}</text>
|
||||||
<text class="citation-title">{{ citation.title }}</text>
|
<text class="citation-title">{{ citation.title }}</text>
|
||||||
@@ -67,14 +67,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { PersonalAskResponse, PersonalCitation } from '@/types/api';
|
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 scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
||||||
const queryText = ref('');
|
const queryText = ref('');
|
||||||
const asking = ref(false);
|
const asking = ref(false);
|
||||||
const answer = ref<PersonalAskResponse>();
|
const answer = ref<PersonalAskResponse>();
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const citationSourceTypes = ref<Record<string, string>>({});
|
let requestGeneration = 0;
|
||||||
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
||||||
|
|
||||||
const toggleEnterprise = () => {
|
const toggleEnterprise = () => {
|
||||||
@@ -84,53 +84,45 @@ const toggleEnterprise = () => {
|
|||||||
const submitQuestion = async () => {
|
const submitQuestion = async () => {
|
||||||
const question = queryText.value.trim();
|
const question = queryText.value.trim();
|
||||||
if (!question || asking.value) return;
|
if (!question || asking.value) return;
|
||||||
|
const generation = ++requestGeneration;
|
||||||
asking.value = true;
|
asking.value = true;
|
||||||
|
answer.value = undefined;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
answer.value = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
|
const response = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
|
||||||
await resolveCitationTypes(answer.value.citations || []);
|
if (generation !== requestGeneration) return;
|
||||||
|
answer.value = response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== requestGeneration) return;
|
||||||
const text = error instanceof Error ? error.message : '提问失败,请稍后重试';
|
const text = error instanceof Error ? error.message : '提问失败,请稍后重试';
|
||||||
if (enterpriseEnabled.value && /ENTERPRISE_SCOPE_FORBIDDEN|企业知识|FORBIDDEN/i.test(text)) {
|
if (enterpriseEnabled.value && /ENTERPRISE_SCOPE_FORBIDDEN|企业知识|FORBIDDEN/i.test(text)) {
|
||||||
scopes.value = ['PERSONAL'];
|
scopes.value = ['PERSONAL'];
|
||||||
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
|
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
|
||||||
try {
|
try {
|
||||||
answer.value = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
|
const personalResponse = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
|
||||||
await resolveCitationTypes(answer.value.citations || []);
|
if (generation !== requestGeneration) return;
|
||||||
|
answer.value = personalResponse;
|
||||||
} catch (personalError) {
|
} catch (personalError) {
|
||||||
|
if (generation !== requestGeneration) return;
|
||||||
|
answer.value = undefined;
|
||||||
message.value = personalError instanceof Error ? personalError.message : '我的资料查询失败,请稍后重试';
|
message.value = personalError instanceof Error ? personalError.message : '我的资料查询失败,请稍后重试';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.value = text;
|
message.value = text;
|
||||||
}
|
}
|
||||||
} finally {
|
} 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) => {
|
const citationLabel = (citation: PersonalCitation) => {
|
||||||
if (citation.domain === 'ENTERPRISE') return '企业 SOP';
|
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) => {
|
const openCitation = (citation: PersonalCitation) => {
|
||||||
if (citation.domain !== 'PERSONAL' || !/^\d+$/.test(citation.sourceId)) return;
|
if (citation.domain !== 'PERSONAL' || !citation.itemId) return;
|
||||||
uni.navigateTo({ url: `/pages/user/assistant/item?id=${encodeURIComponent(citation.sourceId)}` });
|
uni.navigateTo({ url: `/pages/user/assistant/item?id=${citation.itemId}` });
|
||||||
};
|
};
|
||||||
|
|
||||||
const goBack = () => uni.navigateBack();
|
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="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-if="!items.length" class="panel empty">还没有个人资料,先收藏一条文字、文件或网页。</view>
|
||||||
<view v-else class="item-list">
|
<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)">
|
<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>
|
<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>
|
<text class="item-title">{{ item.title || '未命名资料' }}</text>
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalSpace } from '@/types/api';
|
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 pageNum = ref(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
@@ -51,14 +52,15 @@ const status = ref<PersonalItemStatus | ''>('');
|
|||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
let requestGeneration = 0;
|
||||||
const sourceOptions = [{ label: '全部来源', value: '' }, { label: '文字', value: 'TEXT' }, { label: '文件', value: 'FILE' }, { label: '网页', value: 'URL' }] as const;
|
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 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 sourceLabel = computed(() => sourceOptions.find((item) => item.value === sourceType.value)?.label || '全部来源');
|
||||||
const statusLabel = computed(() => statusOptions.find((item) => item.value === status.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 usagePercent = computed(() => Math.min(100, Math.round(((space.value?.usedBytes || 0) / Math.max(1, space.value?.quotaBytes || 1)) * 100)));
|
||||||
|
|
||||||
const load = async (append = false) => {
|
const load = async (append = false, pageSnapshot = pageNum.value) => {
|
||||||
if (loading.value) return;
|
const generation = ++requestGeneration;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
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() }),
|
loadPersonalItems({ pageNum: pageNum.value, pageSize, sourceType: sourceType.value, status: status.value, keyword: keyword.value.trim() }),
|
||||||
loadPersonalSpace()
|
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;
|
total.value = page.total;
|
||||||
space.value = latestSpace;
|
space.value = latestSpace;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== requestGeneration) return;
|
||||||
|
if (append) pageNum.value = Math.max(1, pageSnapshot - 1);
|
||||||
message.value = error instanceof Error ? error.message : '资料加载失败';
|
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 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 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 changeStatus = (event: { detail: { value: string | number } }) => { status.value = statusOptions[Number(event.detail.value)]?.value || ''; refresh(); };
|
||||||
const sourceText = (value: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[value];
|
const sourceText = (value: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[value];
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
<view v-else-if="detail" class="detail-wrap">
|
<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 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()">
|
<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>
|
<text class="content">{{ chat.content }}</text>
|
||||||
<view v-if="chat.citations?.length" class="message-citations">
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
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';
|
import { deletePersonalSession, loadPersonalSession, loadPersonalSessions } from '@/services/personal-assistant';
|
||||||
|
|
||||||
const sessions = ref<PersonalSession[]>([]); const detail = ref<PersonalSessionDetail>(); const loading = ref(false); const message = ref('');
|
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 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 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 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();
|
const goBack = () => uni.navigateBack();
|
||||||
onShow(loadList);
|
onShow(loadList);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUna
|
|||||||
const payload = data as ApiPayload<T>;
|
const payload = data as ApiPayload<T>;
|
||||||
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
||||||
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
|
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();
|
clearAuth();
|
||||||
}
|
}
|
||||||
throw new Error(payload?.msg || `HTTP ${statusCode}`);
|
throw new Error(payload?.msg || `HTTP ${statusCode}`);
|
||||||
@@ -45,7 +45,7 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text || '{}');
|
return JSON.parse(text || '{}');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
|
if (clearAuthOnUnauthorized && statusCode === 401) clearAuth();
|
||||||
throw error instanceof SyntaxError
|
throw error instanceof SyntaxError
|
||||||
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
|
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
|
||||||
: error;
|
: error;
|
||||||
@@ -120,7 +120,7 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
|||||||
resolve(URL.createObjectURL(xhr.response));
|
resolve(URL.createObjectURL(xhr.response));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (xhr.status === 401 || xhr.status === 403) clearAuth();
|
if (xhr.status === 401) clearAuth();
|
||||||
reject(new Error(`HTTP ${xhr.status}`));
|
reject(new Error(`HTTP ${xhr.status}`));
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new Error('网络请求失败'));
|
xhr.onerror = () => reject(new Error('网络请求失败'));
|
||||||
@@ -137,7 +137,7 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
|||||||
resolve(response.tempFilePath);
|
resolve(response.tempFilePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.statusCode === 401 || response.statusCode === 403) clearAuth();
|
if (response.statusCode === 401) clearAuth();
|
||||||
reject(new Error(`HTTP ${response.statusCode}`));
|
reject(new Error(`HTTP ${response.statusCode}`));
|
||||||
},
|
},
|
||||||
fail: () => reject(new Error('网络请求失败'))
|
fail: () => reject(new Error('网络请求失败'))
|
||||||
|
|||||||
@@ -42,10 +42,15 @@ export const askPersonalAssistant = (payload: {
|
|||||||
url: '/api/aihr/personal-assistant/ask',
|
url: '/api/aihr/personal-assistant/ask',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: payload,
|
data: payload,
|
||||||
timeout: 45000,
|
timeout: 45000
|
||||||
clearAuthOnUnauthorized: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>({
|
export const loadPersonalSpace = () => apiRequest<PersonalSpace>({
|
||||||
url: '/api/aihr/personal-assistant/space',
|
url: '/api/aihr/personal-assistant/space',
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ export type PersonalItemStatus = 'QUEUED' | 'PARSING' | 'READY' | 'FAILED' | 'DE
|
|||||||
export interface PersonalCitation {
|
export interface PersonalCitation {
|
||||||
domain: PersonalKnowledgeScope;
|
domain: PersonalKnowledgeScope;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
itemId?: number;
|
||||||
|
fragmentId?: number;
|
||||||
|
sourceType?: PersonalItemSourceType | 'ENTERPRISE';
|
||||||
title: string;
|
title: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
capturedAt?: string;
|
capturedAt?: string;
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import test from 'node:test';
|
|||||||
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||||
|
|
||||||
test('个人助理 API 固定使用登录态且不接收 owner 参数', async () => {
|
test('个人助理 API 固定使用登录态且不接收 owner 参数', async () => {
|
||||||
const client = await source('../src/services/personal-assistant.ts');
|
const [client, api] = await Promise.all([
|
||||||
|
source('../src/services/personal-assistant.ts'),
|
||||||
|
source('../src/services/api.ts')
|
||||||
|
]);
|
||||||
assert.match(client, /apiRequest/);
|
assert.match(client, /apiRequest/);
|
||||||
assert.match(client, /\/api\/aihr\/personal-assistant\/ask/);
|
assert.match(client, /\/api\/aihr\/personal-assistant\/ask/);
|
||||||
assert.match(client, /\/api\/aihr\/personal-assistant\/items\/file/);
|
assert.match(client, /\/api\/aihr\/personal-assistant\/items\/file/);
|
||||||
assert.match(client, /uni\.uploadFile/);
|
assert.match(client, /uni\.uploadFile/);
|
||||||
assert.doesNotMatch(client, /ownerUserId|tenantId/);
|
assert.doesNotMatch(client, /ownerUserId|tenantId/);
|
||||||
|
assert.doesNotMatch(client, /clearAuthOnUnauthorized:\s*false/);
|
||||||
|
assert.doesNotMatch(api, /statusCode === 401 \|\| statusCode === 403/);
|
||||||
|
assert.match(api, /statusCode === 401 \|\| code === 401/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('问答首页默认只查询个人资料并展示真实引用域', async () => {
|
test('问答首页默认只查询个人资料并展示真实引用域', async () => {
|
||||||
@@ -21,6 +27,11 @@ test('问答首页默认只查询个人资料并展示真实引用域', async ()
|
|||||||
assert.match(page, /企业 SOP/);
|
assert.match(page, /企业 SOP/);
|
||||||
assert.match(page, /外部网页/);
|
assert.match(page, /外部网页/);
|
||||||
assert.match(page, /citation\.domain/);
|
assert.match(page, /citation\.domain/);
|
||||||
|
assert.match(page, /citation\.itemId/);
|
||||||
|
assert.match(page, /citation\.sourceType/);
|
||||||
|
assert.doesNotMatch(page, /loadPersonalItem/);
|
||||||
|
assert.match(page, /answer\.value = undefined/);
|
||||||
|
assert.match(page, /requestGeneration/);
|
||||||
assert.match(page, /收藏资料/);
|
assert.match(page, /收藏资料/);
|
||||||
assert.doesNotMatch(page, /ownerUserId|tenantId/);
|
assert.doesNotMatch(page, /ownerUserId|tenantId/);
|
||||||
});
|
});
|
||||||
@@ -34,6 +45,10 @@ test('资料库和收藏页覆盖分页筛选以及三种真实收藏方式', as
|
|||||||
assert.match(library, /sourceType/);
|
assert.match(library, /sourceType/);
|
||||||
assert.match(library, /status/);
|
assert.match(library, /status/);
|
||||||
assert.match(library, /loadPersonalItems/);
|
assert.match(library, /loadPersonalItems/);
|
||||||
|
assert.match(library, /requestGeneration/);
|
||||||
|
assert.match(library, /pageSnapshot/);
|
||||||
|
assert.match(library, /mergePersonalRows/);
|
||||||
|
assert.match(library, /pageNum\.value = Math\.max/);
|
||||||
assert.match(capture, /TEXT/);
|
assert.match(capture, /TEXT/);
|
||||||
assert.match(capture, /FILE/);
|
assert.match(capture, /FILE/);
|
||||||
assert.match(capture, /URL/);
|
assert.match(capture, /URL/);
|
||||||
@@ -62,6 +77,7 @@ test('会话页覆盖列表、详情和删除且不伪造会话', async () => {
|
|||||||
assert.match(page, /loadPersonalSession/);
|
assert.match(page, /loadPersonalSession/);
|
||||||
assert.match(page, /deletePersonalSession/);
|
assert.match(page, /deletePersonalSession/);
|
||||||
assert.match(page, /暂无历史会话/);
|
assert.match(page, /暂无历史会话/);
|
||||||
|
assert.match(page, /chat\.role\.toLowerCase\(\) === 'user'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('个人助理路由已注册且企业 SOP 问答保留独立入口', async () => {
|
test('个人助理路由已注册且企业 SOP 问答保留独立入口', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user