Files
planet/frontend/src/admin/search/indexers.ts
linkong fbecf30513
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.73.0
2026-06-29 17:04:05 +08:00

444 lines
19 KiB
TypeScript

import axios from 'axios'
import {
AlertTriangle,
AppWindow,
Bot,
CircleGauge,
Database,
FileText,
Globe2,
HardDrive,
Network,
Search,
Settings,
ShieldAlert,
Users,
} from 'lucide-react'
import i18n from '../../i18n'
import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest'
import type { AdminSearchTarget } from './types'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
function apiPath(path: string) {
if (path.startsWith('/api/')) return path
return `${API_BASE_URL}${path}`
}
function text(value: unknown, fallback = '') {
if (value === null || value === undefined || value === '') return fallback
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return fallback
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function objectAt(payload: unknown, key: string): Record<string, unknown> {
return isObjectRecord(payload) && isObjectRecord(payload[key]) ? payload[key] as Record<string, unknown> : {}
}
function dataArray(payload: unknown): Record<string, unknown>[] {
if (Array.isArray(payload)) return payload.filter(isObjectRecord)
if (!isObjectRecord(payload)) return []
const value = payload.data || payload.items || payload.results
return Array.isArray(value) ? value.filter(isObjectRecord) : []
}
function targetId(parts: Array<string | undefined>) {
return parts.filter(Boolean).join(':')
}
const labelKeys: Record<string, string> = {
'AI': 'admin.routes.ai',
'BGP观测': 'admin.routes.bgp',
'BGP': 'admin.sections.bgpOverview',
'BGP 事故': 'admin.sections.alerts',
'BGP 告警': 'admin.routes.bgpAlerts',
'Playground': 'admin.sections.aiPlayground',
'SMTP 邮件': 'admin.sections.smtp',
'工具调用': 'admin.sections.aiTools',
'提示词': 'admin.sections.aiPrompts',
'日志': 'admin.routes.logs',
'日志源': 'admin.sections.logsSources',
'智能星球内容': 'admin.routes.earthContent',
'模型供应商': 'admin.sections.aiIntegrations',
'模型预设': 'admin.sections.aiIntegrations',
'电视直播': 'admin.sections.tv',
'系统告警': 'admin.routes.systemAlerts',
'系统显示': 'admin.sections.settingsSystem',
'系统设置': 'admin.routes.settings',
'采集历史': 'admin.sections.collectionHistory',
'采集历史 / 快照': 'admin.sections.collectionHistory',
'采集器': 'admin.sections.collectorCredentials',
'采集数据': 'admin.routes.data',
'采集管理': 'admin.routes.collectionManagement',
'采集调度': 'admin.sections.collectors',
'数据源': 'admin.routes.datasources',
'用户': 'admin.routes.users',
'用户管理': 'admin.routes.users',
'告警记录': 'admin.sections.alerts',
'国界精度': 'admin.sections.earthAssets',
'品牌标识': 'admin.sections.earthBrand',
'态势告警': 'admin.routes.situationalAlerts',
'通知策略': 'admin.sections.notifications',
'安全策略': 'admin.sections.security',
'新闻源': 'admin.sections.newsSources',
'页面': 'admin.search.pageContext',
}
function translateLabel(label: string | undefined): string | undefined {
if (!label) return label
const key = labelKeys[label]
return key ? i18n.t(key) : label
}
function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget {
const routeLabel = translateLabel(target.routeLabel) || target.routeLabel
const sectionLabel = translateLabel(target.sectionLabel) || target.sectionLabel
const label = translateLabel(target.label) || target.label
const contextLabel = translateLabel(target.contextLabel) || target.contextLabel
return {
...target,
contextLabel,
id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]),
label,
routeLabel,
sectionLabel,
terms: Array.from(new Set([
label,
routeLabel,
sectionLabel,
contextLabel,
target.routeLabel,
target.sectionLabel,
target.contextLabel,
target.label,
target.routePath,
target.groupKey,
target.fieldKey,
target.tooltip,
target.ariaLabel,
target.highlightText,
...target.terms,
].filter(Boolean).map(String))),
}
}
const sectionTargets = [
{ routePath: '/ai', routeLabel: 'AI', icon: Bot, sections: [
{ key: 'integrations', label: '模型供应商', terms: ['AI Provider', 'provider设置', '供应商配置', 'LLM', '模型', 'deepseek', 'minimax', 'openai'] },
{ key: 'tools', label: '工具调用', terms: ['Web Search', 'OCR', 'tavily', 'searxng', 'firecrawl'] },
{ key: 'prompts', label: '提示词', terms: ['prompt', 'system prompt', 'AI prompts'] },
{ key: 'playground', label: 'Playground', terms: ['playground', '测试', '对话'] },
] },
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sections: [
{ key: 'brand', label: '品牌标识', terms: ['logo', '标题', 'subtitle'] },
{ key: 'earth_assets', label: '国界精度', terms: ['boundary', 'PMTiles', '边界'] },
{ key: 'tv', label: '电视直播', terms: ['TV', '直播源', '频道'] },
{ key: 'news_sources', label: '新闻源', terms: ['news', 'rss', '商业新闻', '电商', '财经', '新闻类型'] },
] },
{ routePath: '/collection-management', routeLabel: '采集管理', icon: Database, sections: [
{ key: 'collector_credentials', label: '采集器', terms: ['collector', 'credential', '凭证教程'] },
{ key: 'collectors', label: '采集调度', terms: ['schedule', 'frequency'] },
{ key: 'collection_history', label: '采集历史 / 快照', terms: ['history', 'snapshot'] },
] },
{ routePath: '/settings', routeLabel: '系统设置', icon: Settings, sections: [
{ key: 'system', label: '系统显示', terms: ['system', 'display'] },
{ key: 'notifications', label: '通知策略', terms: ['notification', 'email'] },
{ key: 'security', label: '安全策略', terms: ['security', 'password'] },
{ key: 'smtp', label: 'SMTP 邮件', terms: ['smtp', 'mail'] },
] },
{ routePath: '/logs', routeLabel: '日志', icon: FileText, sections: [
{ key: 'sources', label: '日志源', terms: ['log', 'tail', 'source'] },
] },
{ routePath: '/bgp', routeLabel: 'BGP观测', icon: Network, sections: [
{ key: 'overview', label: 'BGP', terms: ['bgp', '网络', '观测'] },
] },
{ routePath: '/alerts/system', routeLabel: '系统告警', icon: AlertTriangle, sections: [
{ key: 'alerts', label: '告警记录', terms: ['alert', 'system', 'resolve'] },
] },
{ routePath: '/alerts/bgp', routeLabel: 'BGP 告警', icon: ShieldAlert, sections: [
{ key: 'incidents', label: 'BGP 事故', terms: ['incident', 'anomaly', 'brief'] },
] },
{ routePath: '/alerts/situational', routeLabel: '态势告警', icon: Globe2, sections: [
{ key: 'alerts', label: '告警记录', terms: ['situational', '态势'] },
] },
]
const fieldTargets = [
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'integrations', sectionLabel: '模型供应商', labels: ['供应商', '协议适配', 'LLM 基础地址', '默认模型', 'LLM API Key', '最大输出 Tokens', 'Anthropic 版本', '超时(秒)', '重试次数', '代理地址', '代理 Token', '测试 AI Provider 连通性', '刷新当前 Provider 的模型配置', '显示 LLM API Key / Service Token', '设为默认', '恢复默认配置'] },
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'tools', sectionLabel: '工具调用', labels: ['搜索供应商', 'API 基础地址', 'WebSearch API Key', '最大结果数', '搜索深度', 'SerpAPI 引擎', 'SearXNG 分类', 'OCR 供应商', 'OCR API Key', '测试 Web Search 连通性'] },
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'prompts', sectionLabel: '提示词', labels: ['System Prompt', '任务提示词', '重置 Prompt'] },
{ routePath: '/collection-management', routeLabel: '采集管理', sectionKey: 'collector_credentials', sectionLabel: '采集器', labels: ['凭证教程', '生成凭证教程', '采集器配置', '映射模板', '目标 Schema'] },
{ routePath: '/earth-content', routeLabel: '智能星球内容', sectionKey: 'tv', sectionLabel: '电视直播', labels: ['默认频道', '自动回退', '直播源', '频道', '主页地址'] },
{ routePath: '/settings', routeLabel: '系统设置', sectionKey: 'smtp', sectionLabel: 'SMTP 邮件', labels: ['主机', '端口', '用户名', '密码', '使用 TLS', '发件邮箱'] },
]
const curatedDynamicLikeTargets = [
...['deepseek', 'minimax', 'openai', 'anthropic', 'ollama', 'qwen', 'moonshot', 'zhipu'].map((provider) => makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'integrations',
sectionLabel: '模型供应商',
groupKey: `provider:${provider}`,
label: provider,
contextLabel: 'AI / 模型供应商',
terms: [provider, 'AI Provider', 'LLM', '供应商配置', 'provider设置'],
highlightText: provider,
icon: Bot,
})),
...['tavily', 'searxng', 'serpapi', 'firecrawl'].map((provider) => makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'tools',
sectionLabel: '工具调用',
groupKey: `web_search:${provider}`,
label: provider,
contextLabel: 'AI / 工具调用 / Web Search',
terms: [provider, 'Web Search', '搜索供应商', '工具调用'],
highlightText: provider,
icon: Search,
})),
]
export function buildStaticAdminTargets(isSuperAdmin: boolean): AdminSearchTarget[] {
const visiblePaths = new Set(getVisibleAdminRoutes(isSuperAdmin).map((route) => route.path))
const routeTargets = adminRoutes
.filter((route) => visiblePaths.has(route.path))
.map((route) => makeTarget({
routePath: route.path,
routeLabel: i18n.t(route.labelKey),
label: i18n.t(route.labelKey),
contextLabel: i18n.t('admin.search.pageContext'),
terms: route.keywords,
icon: route.icon,
}))
const sections = sectionTargets
.filter((route) => visiblePaths.has(route.routePath))
.flatMap((route) => route.sections.map((section) => makeTarget({
routePath: route.routePath,
routeLabel: route.routeLabel,
sectionKey: section.key,
sectionLabel: section.label,
label: section.label,
contextLabel: route.routeLabel,
terms: section.terms,
highlightText: section.label,
icon: route.icon,
})))
const fields = fieldTargets
.filter((target) => visiblePaths.has(target.routePath))
.flatMap((target) => target.labels.map((label) => makeTarget({
routePath: target.routePath,
routeLabel: target.routeLabel,
sectionKey: target.sectionKey,
sectionLabel: target.sectionLabel,
fieldKey: label,
label,
contextLabel: `${target.routeLabel} / ${target.sectionLabel}`,
terms: [label, 'tooltip', 'title', 'aria-label'],
tooltip: label,
ariaLabel: label,
highlightText: label,
icon: Search,
})))
return [...routeTargets, ...sections, ...fields, ...curatedDynamicLikeTargets.filter((target) => visiblePaths.has(target.routePath))]
}
const dynamicEndpoints = [
{ routePath: '/datasources', routeLabel: '数据源', icon: Database, sectionKey: 'data', sectionLabel: '数据源', url: '/datasources/configs/all' },
{ routePath: '/data', routeLabel: '采集数据', icon: AppWindow, sectionKey: 'data', sectionLabel: '采集数据', url: '/datasources/data' },
{ routePath: '/logs', routeLabel: '系统日志', icon: FileText, sectionKey: 'sources', sectionLabel: '日志源', url: '/system/logs/sources' },
{ routePath: '/users', routeLabel: '用户管理', icon: Users, sectionKey: 'users', sectionLabel: '用户', url: '/users' },
{ routePath: '/admin', routeLabel: '仪表盘', icon: CircleGauge, sectionKey: 'overview', sectionLabel: '总览', url: '/health' },
{ routePath: '/collection-management', routeLabel: '采集管理', icon: HardDrive, sectionKey: 'collector_credentials', sectionLabel: '采集器', url: '/datasources/configs/all' },
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sectionKey: 'tv', sectionLabel: '电视直播', url: '/settings/tv' },
]
function flattenRecordValues(record: Record<string, unknown>, limit = 16) {
const values: string[] = []
const visit = (value: unknown) => {
if (values.length >= limit) return
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
const next = text(value, '').trim()
if (next && next.length <= 120) values.push(next)
return
}
if (Array.isArray(value)) value.slice(0, 6).forEach(visit)
else if (isObjectRecord(value)) Object.values(value).slice(0, 8).forEach(visit)
}
visit(record)
return Array.from(new Set(values))
}
export async function buildDynamicAdminTargets(isSuperAdmin: boolean): Promise<AdminSearchTarget[]> {
const visiblePaths = new Set(getVisibleAdminRoutes(isSuperAdmin).map((route) => route.path))
const targets: AdminSearchTarget[] = []
try {
const integrationsResponse = await axios.get(apiPath('/settings/integrations'))
const integrations = objectAt(integrationsResponse.data, 'integrations')
const aiProvider = objectAt(integrations, 'ai_provider')
const webSearch = objectAt(integrations, 'web_search')
const ocr = objectAt(integrations, 'ocr')
const providers = objectAt(aiProvider, 'providers')
const webProviders = objectAt(webSearch, 'providers')
const aiDefault = text(aiProvider.default_provider || aiProvider.provider, 'minimax')
const webDefault = text(webSearch.default_provider || webSearch.provider, 'tavily')
const providerIds = Array.from(new Set([aiDefault, ...Object.keys(providers)].filter(Boolean)))
const webIds = Array.from(new Set([webDefault, ...Object.keys(webProviders)].filter(Boolean)))
providerIds.forEach((provider) => {
targets.push(makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'integrations',
sectionLabel: '模型供应商',
groupKey: `provider:${provider}`,
label: provider,
contextLabel: 'AI / 模型供应商',
terms: [provider, 'AI Provider', 'LLM', ...flattenRecordValues({ ...objectAt(providers, provider), provider })],
highlightText: provider,
icon: Bot,
dynamic: true,
}))
})
webIds.forEach((provider) => {
targets.push(makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'tools',
sectionLabel: '工具调用',
groupKey: `web_search:${provider}`,
label: provider,
contextLabel: 'AI / 工具调用 / Web Search',
terms: [provider, 'Web Search', ...flattenRecordValues({ ...objectAt(webProviders, provider), provider })],
highlightText: provider,
icon: Search,
dynamic: true,
}))
})
targets.push(makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'tools',
sectionLabel: '工具调用',
groupKey: 'ocr:config',
label: text(ocr.provider, 'paddleocr'),
contextLabel: 'AI / 工具调用 / OCR',
terms: ['OCR', ...flattenRecordValues(ocr)],
highlightText: text(ocr.provider, 'OCR'),
icon: FileText,
dynamic: true,
}))
} catch {
// Dynamic search degrades to static targets when this optional index fails.
}
try {
const presetsResponse = await axios.get(apiPath('/settings/integrations/ai-provider/presets'))
dataArray(presetsResponse.data).forEach((preset) => {
const provider = text(preset.provider, '')
if (!provider) return
targets.push(makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'integrations',
sectionLabel: '模型供应商',
groupKey: `provider:${provider}`,
label: provider,
contextLabel: 'AI / 模型预设',
terms: flattenRecordValues(preset, 24),
highlightText: provider,
icon: Bot,
dynamic: true,
}))
})
} catch {
// Optional preset index.
}
try {
const promptsResponse = await axios.get(apiPath('/settings/ai-prompts'))
dataArray(promptsResponse.data).forEach((prompt) => {
const key = text(prompt.key || prompt.id || prompt.label, '')
const label = text(prompt.label || prompt.key, key)
if (!key || !label) return
targets.push(makeTarget({
routePath: '/ai',
routeLabel: 'AI',
sectionKey: 'prompts',
sectionLabel: '提示词',
groupKey: `prompt:${key}`,
label,
contextLabel: 'AI / 提示词',
terms: flattenRecordValues(prompt, 24),
highlightText: label,
icon: FileText,
dynamic: true,
}))
})
} catch {
// Optional prompt index.
}
const endpointResults = await Promise.allSettled(dynamicEndpoints
.filter((endpoint) => visiblePaths.has(endpoint.routePath))
.map(async (endpoint) => {
const response = await axios.get(apiPath(endpoint.url))
return { endpoint, rows: dataArray(response.data) }
}))
endpointResults.forEach((result) => {
if (result.status !== 'fulfilled') return
const { endpoint, rows } = result.value
rows.slice(0, 80).forEach((row, index) => {
const values = flattenRecordValues(row)
const label = text(row.name || row.title || row.id || row.key || row.provider || values[0], `${endpoint.sectionLabel} ${index + 1}`)
targets.push(makeTarget({
routePath: endpoint.routePath,
routeLabel: endpoint.routeLabel,
sectionKey: endpoint.sectionKey,
sectionLabel: endpoint.sectionLabel,
label,
contextLabel: `${endpoint.routeLabel} / ${endpoint.sectionLabel}`,
terms: values,
highlightText: label,
icon: endpoint.icon,
dynamic: true,
}))
})
})
return targets
}
export function searchAdminTargets(targets: AdminSearchTarget[], query: string) {
const normalized = query.trim().toLowerCase()
if (!normalized) return targets.slice(0, 12)
return targets
.map((target) => {
const terms = target.terms.map((term) => term.toLowerCase())
const haystack = terms.join(' ')
if (!haystack.includes(normalized)) return null
const exact = terms.some((term) => term === normalized)
const starts = terms.some((term) => term.startsWith(normalized))
const label = target.label.toLowerCase()
const score = label === normalized ? 0 : label.startsWith(normalized) ? 1 : exact ? 2 : starts ? 3 : target.groupKey ? 4 : 5
return { target, score }
})
.filter((item): item is { target: AdminSearchTarget; score: number } => item !== null)
.sort((a, b) => a.score - b.score || a.target.label.localeCompare(b.target.label))
.map((item) => item.target)
.slice(0, 20)
}