Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
233 changes: 170 additions & 63 deletions app/components/help/HelpArticleEditor.vue

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions app/components/help/HelpIndexFailure.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { TooltipRoot as Tooltip, TooltipProvider, TooltipTrigger, TooltipContent, TooltipPortal } from 'reka-ui'
const props = defineProps<{ inEditor?: boolean; indexing?: boolean }>()
const { t } = useI18n()
const hint = computed(() => t(props.indexing ? 'help.admin.editor.indexStatus.indexing' : props.inEditor ? 'help.admin.editor.indexFailureHintEditor' : 'help.admin.editor.indexFailureHint'))
</script>

<template>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0" role="img" :aria-label="hint" class="inline-flex shrink-0" :class="indexing ? 'text-muted-foreground' : 'text-destructive'" :data-testid="indexing ? 'help-index-loading' : 'help-index-failure'">
<Icon :name="indexing ? 'lucide:loader-circle' : 'lucide:info'" size="16" :class="indexing && 'motion-safe:animate-spin'" />
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent :side-offset="6" class="z-50 max-w-xs rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">{{ hint }}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
</template>
31 changes: 31 additions & 0 deletions app/components/help/HelpIndexStatusBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { TooltipRoot as Tooltip, TooltipProvider, TooltipTrigger, TooltipContent, TooltipPortal } from 'reka-ui'
import type { HelpIndexStatus } from '#layers/feedlog/shared/types/help-index'

const props = defineProps<{ status: HelpIndexStatus }>()
const { t } = useI18n()
const styles = {
not_indexed: { icon: 'lucide:clock-3', tone: 'border-border bg-muted/50 text-muted-foreground' },
indexing: { icon: 'lucide:loader-circle', tone: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-300' },
ready: { icon: 'lucide:circle-check', tone: 'text-[var(--status-done)] bg-[var(--status-done-bg)] border-[var(--status-done-border)]' },
failed: { icon: 'lucide:circle-alert', tone: 'border-destructive/25 bg-destructive/5 text-destructive' },
}
const presentation = computed(() => styles[props.status])
const hint = computed(() => t(props.status === 'failed' ? 'help.admin.editor.indexFailureHintEditor' : `help.admin.editor.indexHints.${props.status}`))
</script>

<template>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0" :aria-label="`${t(`help.admin.editor.indexStatus.${status}`)}. ${hint}`" class="inline-flex shrink-0 items-center gap-1 rounded border px-2 py-0.5 text-[10px] font-bold leading-[15px]" :class="presentation.tone" data-testid="help-index-status" :data-status="status">
<Icon :name="presentation.icon" size="12" aria-hidden="true" :class="status === 'indexing' && 'motion-safe:animate-spin'" />
{{ t(`help.admin.editor.indexStatus.${status}`) }}
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent :side-offset="6" class="z-50 max-w-xs rounded-md border bg-popover px-3 py-2 text-xs font-normal text-popover-foreground shadow-md">{{ hint }}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
</template>
117 changes: 92 additions & 25 deletions app/components/help/HelpSearchOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,104 @@ interface SearchHit {
collection: { id: string; name: string }
}

const props = defineProps<{ query: string }>()
const props = withDefaults(defineProps<{ query: string; autoTranslate?: boolean }>(), {
autoTranslate: true,
})

const localePath = useLocalePath()

const hits = ref<SearchHit[]>([])
const total = ref(0)
const rateLimited = ref(false)
const failed = ref(false)
const fallback = ref(false)
const loading = ref(false)
const canTranslate = ref(false)
const translating = ref(false)
const translatedQueries = ref<string[]>([])

let timer: ReturnType<typeof setTimeout>
let seq = 0
let controller: AbortController | undefined

watch(() => props.query, (value) => {
clearTimeout(timer)
interface SearchResponse {
total: number
data: SearchHit[]
searchMode?: string
canTranslate?: boolean
translatedQueries?: string[]
}

async function search(q: string, mine: number, translate = false) {
controller = new AbortController()
loading.value = true
const q = value.trim()
timer = setTimeout(async () => {
const mine = ++seq
try {
const result = await $fetch<{ total: number; data: SearchHit[] }>('/api/help/search', { query: { q } })
if (mine !== seq) return
hits.value = result.data
total.value = result.total
rateLimited.value = false
translating.value = translate
canTranslate.value = false
fallback.value = false
try {
const result = await $fetch<SearchResponse>('/api/help/search', {
query: { q, ...(translate ? { translate: '1' } : {}) }, signal: controller.signal, retry: 0,
})
if (mine !== seq) return
hits.value = result.data
total.value = result.total
fallback.value = result.searchMode === 'fallback'
canTranslate.value = result.canTranslate === true || (translate && fallback.value)
translatedQueries.value = result.translatedQueries ?? []
rateLimited.value = false
failed.value = false
// Only the original, empty Chinese query can start one automatic retry.
// Await it so the lexical request cannot clear the translation loading state.
if (!translate && props.autoTranslate && !result.total && result.canTranslate && /[\u3400-\u9fff]/.test(q)) {
await search(q, mine, true)
}
catch (e) {
if (mine !== seq) return
hits.value = []
total.value = 0
rateLimited.value = (e as { statusCode?: number; response?: { status?: number } }).statusCode === 429
|| (e as { response?: { status?: number } }).response?.status === 429
}
finally {
if (mine === seq) loading.value = false
}
catch (e) {
if (mine !== seq) return
failed.value = true
hits.value = []
total.value = 0
rateLimited.value = (e as { statusCode?: number; response?: { status?: number } }).statusCode === 429
|| (e as { response?: { status?: number } }).response?.status === 429
canTranslate.value = translate && !rateLimited.value
}
finally {
if (mine === seq) {
loading.value = false
translating.value = false
}
}, 200)
}
}

function translateSearch() {
if (loading.value || !canTranslate.value) return
void search(props.query.trim(), seq, true)
}

watch(() => props.query, (value) => {
clearTimeout(timer)
const mine = ++seq
controller?.abort()
hits.value = []
total.value = 0
rateLimited.value = false
failed.value = false
fallback.value = false
canTranslate.value = false
translating.value = false
translatedQueries.value = []
const q = value.trim()
loading.value = !!q
if (!q) return
timer = setTimeout(() => { void search(q, mine) }, 200)
}, { immediate: true })

onBeforeUnmount(() => {
++seq
clearTimeout(timer)
controller?.abort()
})

function segments(text: string, ranges: [number, number][]) {
const out: { text: string; hit: boolean }[] = []
let cursor = 0
Expand All @@ -62,6 +122,9 @@ function segments(text: string, ranges: [number, number][]) {

<template>
<div class="absolute inset-x-0 top-[60px] z-20 overflow-hidden rounded-2xl border border-border bg-card shadow-[0_4px_20px_rgba(196,90,70,.08)]">
<p v-if="translatedQueries.length" class="px-[18px] py-2 text-xs text-muted-foreground">
{{ $t('help.portal.translatedSearch', { query: translatedQueries.join(' / ') }) }}
</p>
<template v-if="hits.length">
<p class="border-b border-border px-[18px] py-2.5 text-[11px] font-bold uppercase tracking-[.05em] text-muted-foreground">
{{ $t('help.portal.results', { n: total }, total) }}
Expand All @@ -88,15 +151,19 @@ function segments(text: string, ranges: [number, number][]) {
</p>
</template>

<div v-else-if="loading" class="flex justify-center px-[18px] py-7">
<div v-else-if="loading" class="flex items-center justify-center gap-2 px-[18px] py-7" role="status">
<Icon name="lucide:loader-2" size="20" class="animate-spin text-muted-foreground" />
<span v-if="translating" class="text-sm text-muted-foreground">{{ $t('help.portal.translatingSearch') }}</span>
</div>

<div v-else class="px-[18px] py-7 text-center">
<div class="text-sm font-semibold">{{ rateLimited ? $t('help.portal.rateLimited') : $t('help.portal.noMatch') }}</div>
<div class="text-sm font-semibold">{{ fallback ? $t('help.portal.crossLanguageUnavailable') : rateLimited ? $t('help.portal.rateLimited') : failed ? $t('help.portal.searchFailed') : $t('help.portal.noMatch') }}</div>
<div class="mt-1 text-xs leading-[18px] text-muted-foreground">
{{ rateLimited ? $t('help.portal.rateLimitedHint') : $t('help.portal.noMatchHint') }}
{{ fallback ? $t('help.portal.translationRetryHint') : rateLimited ? $t('help.portal.rateLimitedHint') : failed ? $t('help.portal.searchFailedHint') : canTranslate ? $t('help.portal.translationOfferHint') : $t('help.portal.noMatchHint') }}
</div>
<Button v-if="canTranslate" variant="outline" size="sm" class="mt-3" :disabled="loading" @click="translateSearch">
{{ $t('help.portal.translateSearch') }}
</Button>
</div>
</div>
</template>
32 changes: 30 additions & 2 deletions app/pages/dashboard/help/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import HelpIndexFailure from '#layers/feedlog/app/components/help/HelpIndexFailure.vue'
import type { HelpIndexStatus } from '#layers/feedlog/shared/types/help-index'
import { toast } from 'vue-sonner'
import type { HelpCollectionFormValue } from '#layers/feedlog/app/components/help/HelpCollectionDialog.vue'
import type { HelpArticleStatus, HelpCollectionIcon } from '#layers/feedlog/shared/constants/help'
Expand All @@ -9,6 +11,7 @@ definePageMeta({ layout: 'dashboard', middleware: 'admin' })
interface InlineArticle {
id: string
title: string
indexStatus: HelpIndexStatus | null
aiEnabled: boolean
status: HelpArticleStatus
position: number
Expand All @@ -31,6 +34,7 @@ interface FlatArticle {
shortId: string
slug: string
title: string
indexStatus: HelpIndexStatus | null
aiEnabled: boolean
status: HelpArticleStatus
position: number
Expand Down Expand Up @@ -89,12 +93,35 @@ const { data: articlesData, refresh: refreshArticles } = await useFetch<{
data: FlatArticle[]
pagination: { total: number }
}>('/api/admin/help/articles', {
deep: true,
query: computed(() => ({ page: page.value, pageSize: PAGE_SIZE, q: debouncedSearch.value || undefined })),
})

const collections = computed(() => collectionsData.value?.data ?? [])
const articles = computed(() => articlesData.value?.data ?? [])

let indexPoll: ReturnType<typeof setInterval> | undefined
let pollingIndex = false
onMounted(() => {
indexPoll = setInterval(async () => {
if (pollingIndex || document.hidden) return
const visibleArticles = flat.value ? articles.value : collections.value.flatMap(c => c.articles)
const pending = visibleArticles.filter(a => a.indexStatus === 'indexing')
if (!pending.length) return
pollingIndex = true
try {
await Promise.allSettled(pending.map(async (article) => {
const state = await $fetch<{ status: HelpIndexStatus; enabled: boolean }>(`/api/admin/help/articles/${article.id}/index-status`)
for (const current of [...articles.value, ...collections.value.flatMap(c => c.articles)]) {
if (current.id === article.id) current.indexStatus = state.enabled ? state.status : null
}
}))
}
finally { pollingIndex = false }
}, 5000)
})
onBeforeUnmount(() => { clearInterval(indexPoll); clearTimeout(searchTimer) })

const total = computed(() => (flat.value ? articlesData.value?.pagination.total ?? 0 : collections.value.length))
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))

Expand Down Expand Up @@ -391,7 +418,8 @@ function formatDate(iso: string) {
{{ article.collection.name }}
</span>
<HelpStatusBadge :status="article.status" />
<span v-if="article.aiEnabled" :class="[CHIP, 'gap-1 border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-300']"><Icon name="lucide:sparkles" size="11" />{{ $t('help.admin.aiVisible') }}</span>
<span v-if="article.aiEnabled" :class="[CHIP, 'gap-1 border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-300']"><Icon name="lucide:sparkles" size="11" />{{ $t('help.admin.aiVisible') }}</span>
<HelpIndexFailure v-if="article.indexStatus === 'failed' || article.indexStatus === 'indexing'" :indexing="article.indexStatus === 'indexing'" />
<span class="w-24 shrink-0 text-right text-xs font-medium leading-4 text-muted-foreground">{{ formatDate(article.updatedAt) }}</span>
</NuxtLink>
</template>
Expand Down Expand Up @@ -441,7 +469,6 @@ function formatDate(iso: string) {
<span class="block truncate text-sm font-bold leading-5">{{ collection.name }}</span>
<span class="block truncate text-xs leading-4 text-muted-foreground">{{ collection.description }}</span>
</span>
<HelpStatusBadge v-if="!collection.visible" status="hidden" />
<span class="shrink-0 text-xs font-medium leading-4 text-muted-foreground">
{{ $t('help.admin.articleCount', { n: collection.articleCount }, collection.articleCount) }}
</span>
Expand Down Expand Up @@ -484,6 +511,7 @@ function formatDate(iso: string) {
<span class="min-w-0 flex-1 truncate text-sm font-semibold leading-5">{{ article.title }}</span>
<HelpStatusBadge :status="article.status" />
<span v-if="article.aiEnabled" :class="[CHIP, 'gap-1 border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-300']"><Icon name="lucide:sparkles" size="11" />{{ $t('help.admin.aiVisible') }}</span>
<HelpIndexFailure v-if="article.indexStatus === 'failed' || article.indexStatus === 'indexing'" :indexing="article.indexStatus === 'indexing'" />
<span class="w-24 shrink-0 text-right text-xs font-medium leading-4 text-muted-foreground">{{ formatDate(article.updatedAt) }}</span>
</NuxtLink>
</template>
Expand Down
38 changes: 36 additions & 2 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,14 @@
"notSaved": "Not saved yet",
"lastSaved": "Last saved {at}",
"preview": "Preview",
"saving": "Saving…",
"saveChanges": "Save changes",
"publishBody": "Readers can access this article after publishing it in a visible collection.",
"unpublishTitle": "Unpublish this article?",
"unpublishBody": "Only the saved article will be unpublished. Unsaved edits remain in the editor. AI access is unchanged.",
"linkNotPublic": "This article is not publicly available at this URL yet.",
"loadFailed": "The article could not be loaded or is not accessible. Please retry.",
"retryLoad": "Reload",
"saveDraft": "Save draft",
"title": "Title",
"titlePlaceholder": "Untitled article",
Expand All @@ -985,7 +993,6 @@
"articleUrl": "Article URL",
"urlPending": "Assigned when you first save.",
"copied": "Link copied",
"hiddenWarning": "This article is published, but {name} is hidden from readers — they cannot see it yet. Turn the collection visible to publish it.",
"previewHeader": "Preview · exactly how readers will see it (portal article layout)",
"leaveTitle": "Leave without saving?",
"leaveBody": "This article has changes that have not been saved. If you leave now they are discarded.",
Expand All @@ -999,7 +1006,26 @@
"deleteTitle": "Delete \"{title}\"?",
"deleteDescription": "This article and its URL are permanently removed. This cannot be undone.",
"deleted": "Article deleted",
"deleteFailed": "Could not delete the article"
"deleteFailed": "Could not delete the article",
"indexStatus": {
"pending": "Preparing search",
"processing": "Preparing search",
"retry": "Search preparation delayed; retrying",
"failed": "Index failed",
"ready": "Indexed",
"not_indexed": "Not indexed",
"indexing": "Indexing"
},
"indexRetry": "Retry",
"indexRetryFailed": "Could not retry. Please try again.",
"indexFailureHint": "The article is saved, but some advanced search features are not ready yet. Open the article and click Save to retry.",
"indexFailureHintEditor": "The article is saved, but some advanced search features are not ready yet. Click Save to retry.",
"indexHints": {
"not_indexed": "This article has not been indexed. Click Save to build an index for advanced search.",
"indexing": "Building or updating the index. Your latest saved content will be available for advanced search when indexing is complete.",
"ready": "Your latest saved content has been indexed and is available for advanced search.",
"failed": "Indexing failed for your latest saved content. Click Save to retry and enable advanced search."
}
},
"publishTitle": "Publish this article?",
"bulkPublishTitle": "Publish {n} selected article? | Publish {n} selected articles?",
Expand All @@ -1017,6 +1043,14 @@
"searchPlaceholder": "Search articles...",
"results": "{n} result | {n} results",
"more": "{n} more — keep typing to narrow it down",
"translateSearch": "Search English articles with translation",
"translatingSearch": "No direct match. Translating your search—please wait…",
"translatedSearch": "Searched for: {query}",
"translationOfferHint": "No direct match. Try English keywords or translate your search.",
"translationRetryHint": "Try English keywords or search again later.",
"crossLanguageUnavailable": "Cross-language search is temporarily unavailable. Try English keywords.",
"searchFailed": "Search is unavailable",
"searchFailedHint": "Please try again shortly.",
"noMatch": "No articles matched",
"noMatchHint": "Try a different keyword, or browse the collections below",
"emptyTitle": "No articles yet",
Expand Down
Loading
Loading