From fabc43b37a933a2a67b56dc5cfd47ee68638aa03 Mon Sep 17 00:00:00 2001 From: EndlessCCL Date: Thu, 15 Oct 2026 17:34:07 +0800 Subject: [PATCH] feat(help): improve search, indexing lifecycle, and article editing Signed-off-by: EndlessCCL --- .env.example | 8 + app/components/help/HelpArticleEditor.vue | 233 +- app/components/help/HelpIndexFailure.vue | 21 + app/components/help/HelpIndexStatusBadge.vue | 31 + app/components/help/HelpSearchOverlay.vue | 117 +- app/pages/dashboard/help/index.vue | 32 +- i18n/locales/en.json | 38 +- i18n/locales/zh.json | 38 +- package.json | 1 + pnpm-lock.yaml | 10 + server/api/admin/help/articles/[id].get.ts | 4 +- .../help/articles/[id]/index-status.get.ts | 21 + .../help/articles/[id]/retry-index.post.ts | 15 + .../admin/help/articles/bulk-publish.post.ts | 31 +- .../help/articles/bulk-unpublish.post.ts | 27 +- server/api/help/search.get.ts | 103 +- .../db/migrations/0020_help_vector_index.sql | 14 + server/db/migrations/meta/0020_snapshot.json | 5100 +++++++++++++++++ server/db/migrations/meta/_journal.json | 7 + server/db/schemas/help-index.ts | 13 + server/db/schemas/index.ts | 1 + server/lib/agent/chat.ts | 22 +- server/lib/agent/citation-evidence.ts | 61 + server/lib/agent/help-search.ts | 72 +- server/lib/agent/prompts/system.ts | 8 +- server/lib/agent/tools.ts | 2 +- server/lib/help-search/chunks.ts | 46 + server/lib/help-search/embedding.ts | 28 + server/lib/help-search/indexer.ts | 59 + server/lib/help-search/jobs.ts | 47 + server/lib/help-search/management.ts | 30 + server/lib/help-search/progressive.ts | 35 + server/lib/help-search/query.ts | 14 + server/lib/help-search/relevance.ts | 73 + server/lib/help-search/rewrite.ts | 64 + server/lib/help-search/runtime.ts | 36 + server/lib/help-search/vector-search.ts | 38 + server/lib/inbox/scheduler.ts | 90 +- server/lib/inbox/tick.ts | 4 +- server/services/help-center-queries.ts | 5 + server/services/help-center.ts | 43 +- server/utils/help-search.ts | 34 +- shared/types/help-index.ts | 1 + tests/agent-citation-evidence.test.ts | 43 + tests/agent-help-search.test.ts | 69 + tests/help-article-publishing.test.ts | 6 + tests/help-index-dispatch.test.ts | 81 + tests/help-index-status.test.ts | 149 + tests/help-query-rewrite.test.ts | 42 + tests/help-search-excerpt.test.ts | 16 + tests/help-search-progressive.test.ts | 39 + tests/help-search-relevance.test.ts | 51 + tests/help-vector-index.test.ts | 242 + tests/inbox-scheduler.test.ts | 194 +- tests/mcp.integration.test.ts | 11 + 55 files changed, 7402 insertions(+), 218 deletions(-) create mode 100644 app/components/help/HelpIndexFailure.vue create mode 100644 app/components/help/HelpIndexStatusBadge.vue create mode 100644 server/api/admin/help/articles/[id]/index-status.get.ts create mode 100644 server/api/admin/help/articles/[id]/retry-index.post.ts create mode 100644 server/db/migrations/0020_help_vector_index.sql create mode 100644 server/db/migrations/meta/0020_snapshot.json create mode 100644 server/db/schemas/help-index.ts create mode 100644 server/lib/agent/citation-evidence.ts create mode 100644 server/lib/help-search/chunks.ts create mode 100644 server/lib/help-search/embedding.ts create mode 100644 server/lib/help-search/indexer.ts create mode 100644 server/lib/help-search/jobs.ts create mode 100644 server/lib/help-search/management.ts create mode 100644 server/lib/help-search/progressive.ts create mode 100644 server/lib/help-search/query.ts create mode 100644 server/lib/help-search/relevance.ts create mode 100644 server/lib/help-search/rewrite.ts create mode 100644 server/lib/help-search/runtime.ts create mode 100644 server/lib/help-search/vector-search.ts create mode 100644 shared/types/help-index.ts create mode 100644 tests/agent-citation-evidence.test.ts create mode 100644 tests/help-index-dispatch.test.ts create mode 100644 tests/help-index-status.test.ts create mode 100644 tests/help-query-rewrite.test.ts create mode 100644 tests/help-search-excerpt.test.ts create mode 100644 tests/help-search-progressive.test.ts create mode 100644 tests/help-search-relevance.test.ts create mode 100644 tests/help-vector-index.test.ts diff --git a/.env.example b/.env.example index 6ea9f659..778a09d8 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,9 @@ SYSTEM_ADMIN_EMAILS=admin@example.com # OPENAI_BASE_URL=https://api.openai.com/v1 # The Widget Agent requires an explicit model name with tool-calling support. # OPENAI_TEXT_MODEL=gpt-5.4 +# Optional automatic translation after an empty Chinese Help/Agent search (uses the model above). +# Disabled by default; articles and database schema remain unchanged. +# HELP_SEARCH_BILINGUAL=false # ============================================================================= # OPTIONAL — Transactional email (password reset, notifications) @@ -128,3 +131,8 @@ SYSTEM_ADMIN_EMAILS=admin@example.com # Optional application image override for Docker Compose. # FEEDLOG_IMAGE=ghcr.io/linkcraftstudio/feedlog:latest + +# Help semantic search + durable asynchronous indexing (no existing-article backfill). +# Requires pgvector migrations and OPENAI_API_KEY. Reuses the existing Inbox +# delayed-delivery adapters and recovery schedule; no extra queue/cron/secret. +HELP_VECTOR_SEARCH=false diff --git a/app/components/help/HelpArticleEditor.vue b/app/components/help/HelpArticleEditor.vue index 8521b950..74a9d57d 100644 --- a/app/components/help/HelpArticleEditor.vue +++ b/app/components/help/HelpArticleEditor.vue @@ -3,6 +3,7 @@ import { onClickOutside, onKeyStroke } from '@vueuse/core' import { toast } from 'vue-sonner' import type { HelpArticleStatus, HelpCollectionIcon } from '#layers/feedlog/shared/constants/help' import '~/assets/css/help-article.css' +import type { HelpIndexStatus } from '#layers/feedlog/shared/types/help-index' interface ArticleDetail { id: string @@ -12,6 +13,7 @@ interface ArticleDetail { description: string | null content: string aiEnabled: boolean + indexStatus: HelpIndexStatus | null status: HelpArticleStatus publishedAt: string | null createdAt: string @@ -28,7 +30,7 @@ interface CollectionOption { const props = defineProps<{ articleId?: string; collectionId?: string }>() -const { t } = useI18n() +const { t, locale } = useI18n() const { confirm } = useConfirmDialog() const router = useRouter() const localePath = useLocalePath() @@ -36,23 +38,38 @@ const localePath = useLocalePath() const id = computed(() => props.articleId ?? '') const isNew = computed(() => !props.articleId) -const { data: article, refresh } = await useFetch(() => `/api/admin/help/articles/${id.value}`, { +const { data: article, refresh, error: loadError, status: loadStatus } = await useFetch(() => `/api/admin/help/articles/${id.value}`, { immediate: !isNew.value, }) watch(isNew, (value) => { if (!value) refresh() }) +const { data: indexState, refresh: refreshIndex } = await useFetch<{ status: HelpIndexStatus; enabled: boolean; errorCode: string | null }>(() => `/api/admin/help/articles/${id.value}/index-status`, { immediate: !isNew.value }) +let indexPoll: ReturnType | undefined +onMounted(() => { + indexPoll = setInterval(() => { + if (id.value && indexState.value?.enabled && indexState.value.status === 'indexing') refreshIndex() + }, 5000) +}) +onBeforeUnmount(() => clearInterval(indexPoll)) +watch(() => article.value?.updatedAt, () => { if (id.value) refreshIndex() }) const { data: collectionsData } = await useFetch<{ data: CollectionOption[] }>('/api/admin/help/collections', { query: { flat: 1 }, }) const collections = computed(() => collectionsData.value?.data ?? []) +const TITLE_MAX_LENGTH = 200 const form = reactive({ title: '', description: '', content: '', aiEnabled: true }) const draftCollectionId = ref(props.collectionId ?? '') const saving = ref(false) +const titleInput = ref(null) +const bodyField = ref(null) +const errors = reactive({ title: false, content: false }) +watch(() => form.title, () => { errors.title = false }) +watch(() => form.content, () => { errors.content = false }) const pickerOpen = ref(false) const pickerRef = ref(null) onClickOutside(pickerRef, () => { pickerOpen.value = false }) -onKeyStroke('Escape', () => { pickerOpen.value = false }) +onKeyStroke('Escape', () => { pickerOpen.value = false; previewOpen.value = false }) const previewOpen = ref(false) const leavingAfterWrite = ref(false) @@ -71,10 +88,11 @@ const dirty = computed(() => || form.content !== (article.value?.content ?? '') || form.aiEnabled !== (article.value?.aiEnabled ?? true)) +const canSave = computed(() => isNew.value || dirty.value || (article.value?.status === 'published' && indexState.value?.enabled && ['not_indexed', 'failed'].includes(indexState.value.status))) + const collection = computed(() => isNew.value ? collections.value.find(c => c.id === draftCollectionId.value) : article.value?.collection) -const hiddenWarning = computed(() => article.value?.status === 'published' && collection.value && !collection.value.visible) const articleUrl = computed(() => article.value ? `/help/${article.value.shortId}-${article.value.slug}` : '') watch(collections, (list) => { @@ -83,14 +101,26 @@ watch(collections, (list) => { function formatStamp(iso: string | null | undefined) { if (!iso) return '—' - return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) + return new Intl.DateTimeFormat(locale.value, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) } async function patch(body: Record) { + if (saving.value) return false + const submitted = { ...form } saving.value = true try { - await $fetch(`/api/admin/help/articles/${id.value}`, { method: 'PATCH', body }) - await refresh() + const saved = await $fetch(`/api/admin/help/articles/${id.value}`, { method: 'PATCH', body }) + article.value = saved + // The save response already describes the queued generation; show it immediately. + if (indexState.value) indexState.value = { ...indexState.value, enabled: saved.indexStatus !== null, status: saved.indexStatus ?? 'not_indexed' } + // Apply server normalization only to submitted fields not edited in flight. + // Moving collections must not discard unrelated unsaved content. + if (article.value) { + for (const key of ['title', 'description', 'content'] as const) { + if (key in body && form[key] === submitted[key]) form[key] = article.value[key] ?? '' + } + if ('aiEnabled' in body && form.aiEnabled === submitted.aiEnabled) form.aiEnabled = article.value.aiEnabled + } return true } catch (e) { @@ -110,6 +140,7 @@ const contentBody = () => ({ }) async function create(publish: boolean) { + if (saving.value) return if (!draftCollectionId.value) { toast.error(t('help.admin.newArticleDisabledHint')) return @@ -133,42 +164,50 @@ async function create(publish: boolean) { } } +function validateContent() { + errors.title = !form.title.trim() + errors.content = !form.content.trim() + if (errors.title) titleInput.value?.focus() + else if (errors.content) bodyField.value?.querySelector('[contenteditable="true"]')?.focus() + return !errors.title && !errors.content +} + async function saveDraft() { - if (!form.title.trim()) { - toast.error(t('help.admin.editor.titleRequired')) - return - } - if (!form.content.trim()) { - toast.error(t('help.admin.editor.contentRequired')) - return - } + if (saving.value || !canSave.value || !validateContent()) return if (isNew.value) return create(false) if (await patch(contentBody())) toast.success(t('help.admin.editor.saved')) } async function togglePublish() { - if (!form.title.trim()) { - toast.error(t('help.admin.editor.titleRequired')) - return - } - if (article.value?.status !== 'published') { - if (!form.content.trim()) { - toast.error(t('help.admin.editor.contentRequired')) - return - } + if (saving.value) return + if (article.value?.status === 'published') { const ok = await confirm({ - title: t('help.admin.publishTitle'), - confirmText: t('help.admin.publish'), - cancelText: t('common.cancel'), + title: t('help.admin.editor.unpublishTitle'), + description: t('help.admin.editor.unpublishBody'), + confirmText: t('help.admin.unpublish'), cancelText: t('common.cancel'), }) - if (!ok) return + if (ok && await patch({ status: 'archived' })) toast.success(t('help.admin.editor.saved')) + return } + if (!validateContent()) return + const ok = await confirm({ + title: t('help.admin.publishTitle'), + description: t('help.admin.editor.publishBody'), + confirmText: t('help.admin.publish'), cancelText: t('common.cancel'), + }) + if (!ok) return if (isNew.value) return create(true) - const next = article.value?.status === 'published' ? 'archived' : 'published' - if (await patch({ ...contentBody(), status: next })) toast.success(t('help.admin.editor.saved')) + if (await patch({ ...contentBody(), status: 'published' })) toast.success(t('help.admin.editor.saved')) } +onKeyStroke('s', (event) => { + if (!(event.ctrlKey || event.metaKey)) return + event.preventDefault() + if (!saving.value) void saveDraft() +}) + async function pickCollection(collectionId: string) { + if (saving.value) return pickerOpen.value = false if (isNew.value) { draftCollectionId.value = collectionId @@ -180,7 +219,7 @@ async function pickCollection(collectionId: string) { } async function removeArticle() { - if (!article.value) return + if (saving.value || !article.value) return const ok = await confirm({ title: t('help.admin.editor.deleteTitle', { title: article.value.title }), @@ -223,6 +262,14 @@ onBeforeRouteLeave(async () => { }) }) +function warnBeforeUnload(event: BeforeUnloadEvent) { + if (!dirty.value || leavingAfterWrite.value) return + event.preventDefault() + event.returnValue = '' +} +onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload)) +onBeforeUnmount(() => window.removeEventListener('beforeunload', warnBeforeUnload)) + function goBack() { router.push(localePath('/dashboard/help')) } @@ -230,14 +277,15 @@ function goBack() {