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() {