import { Context } from '@koishijs/client' import { computed, defineComponent, inject, onBeforeUnmount, onMounted, type ComputedRef, watch, h } from 'vue' type NavSection = { key: 'tools' | 'models' | 'search' | 'services' | 'debug' title: string } type NavGroup = { title: string sections: NavSection[] } const PLUGIN_NAMES = new Set([ 'isthattrue', // legacy package name 'chatluna-fact-check', 'koishi-plugin-isthattrue', // legacy package name 'koishi-plugin-chatluna-fact-check', ]) const NAV_GROUPS: NavGroup[] = [ { title: '工具与模型', sections: [ { key: 'tools', title: '工具注册' }, { key: 'models', title: 'LLM AI 接入' }, ], }, { title: '搜索与服务', sections: [ { key: 'search', title: '搜索策略' }, { key: 'services', title: '外部服务' }, ], }, { title: '调试/兼容', sections: [ { key: 'debug', title: '调试与排障' }, ], }, ] const NAV_SECTIONS: NavSection[] = NAV_GROUPS.flatMap((group) => group.sections) const SECTION_TITLE_ALIASES: Record = { tools: ['工具注册', 'Fact Check 工具', 'Deep Search 工具', 'Web Fetch 工具'], models: ['LLM AI 接入', '模型接入', 'AI 模型接入'], search: ['搜索策略', '搜索配置', '超时配置', '排序与策略', '最大字数'], services: ['外部服务', 'Grok 网络搜索', 'Jina Reader 配置'], debug: ['调试与排障', '调试'], } const STYLE_ID = 'isthattrue-nav-style' function ensureStyle() { if (document.getElementById(STYLE_ID)) return const style = document.createElement('style') style.id = STYLE_ID style.textContent = ` .isthattrue-nav { position: fixed; top: 260px; right: 60px; z-index: 1000; width: 140px; max-width: 90vw; user-select: none; } .isthattrue-nav-header { padding: 6px 10px; border-radius: 999px; border: 1px solid var(--k-color-border, #4b5563); background: color-mix(in srgb, var(--k-color-bg, #1f2937) 94%, white); display: flex; align-items: center; justify-content: space-between; cursor: move; touch-action: none; } .isthattrue-nav-handle { color: var(--k-text-light, #9ca3af); font-size: 14px; line-height: 1; } .isthattrue-nav-toggle { border: none; background: transparent; color: var(--k-text-light, #9ca3af); cursor: pointer; padding: 0; font-size: 14px; line-height: 1; } .isthattrue-nav-body { margin-top: 8px; display: flex; flex-direction: column; gap: 2px; } .isthattrue-nav.collapsed .isthattrue-nav-body { display: none; } .isthattrue-nav-item { border: none; background: transparent; color: var(--k-text, #d1d5db); text-align: left; padding: 6px 4px; cursor: pointer; font-size: 14px; line-height: 1.4; } .isthattrue-nav-item:hover { color: var(--k-color-primary, #4f7cff); } .isthattrue-nav-item.active { color: var(--k-color-primary, #4f7cff); } .isthattrue-nav-group { margin-top: 4px; padding: 6px 4px 2px; font-size: 12px; font-weight: 600; color: var(--k-text-light, #9ca3af); opacity: 0.9; } /* Shrink nested sub-section headers inside intersect groups (e.g. DeepSearch sub-sections) */ .k-schema-group .k-schema-group .k-schema-header { font-size: 0.85em; margin-top: 0.4em; margin-bottom: 0.2em; } ` document.head.appendChild(style) } function normalizeText(text: string) { return text.replace(/\s+/g, '').trim() } function getSectionNodes() { return Array.from(document.querySelectorAll( '.k-schema-section-title, .k-schema-header, h2.k-schema-header' )) } function findHeaderBySection(section: NavSection) { const targets = [section.title, ...(SECTION_TITLE_ALIASES[section.key] || [])] .map(item => normalizeText(item)) .filter(Boolean) const headers = getSectionNodes() for (const header of headers) { const text = normalizeText(header.textContent || '') if (!text) continue if (targets.some(target => text.includes(target))) return header } return null } function matchSectionByHeaderText(text: string): NavSection | undefined { const normalized = normalizeText(text) return NAV_SECTIONS.find((section) => { const candidates = [section.title, ...(SECTION_TITLE_ALIASES[section.key] || [])] .map(item => normalizeText(item)) .filter(Boolean) return candidates.some(candidate => normalized.includes(candidate)) }) } function mountFloatingNav() { ensureStyle() const existing = document.querySelector('.isthattrue-nav') existing?.remove() const root = document.createElement('div') root.className = 'isthattrue-nav' root.innerHTML = `
⋮⋮
` document.body.appendChild(root) const body = root.querySelector('.isthattrue-nav-body')! const toggle = root.querySelector('.isthattrue-nav-toggle')! const header = root.querySelector('.isthattrue-nav-header')! const itemMap = new Map() for (const group of NAV_GROUPS) { const groupTitle = document.createElement('div') groupTitle.className = 'isthattrue-nav-group' groupTitle.textContent = group.title body.appendChild(groupTitle) for (const section of group.sections) { const button = document.createElement('button') button.type = 'button' button.className = 'isthattrue-nav-item' button.textContent = section.title button.addEventListener('click', () => { const target = findHeaderBySection(section) if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }) } }) body.appendChild(button) itemMap.set(section.key, button) } } toggle.addEventListener('click', (event) => { event.stopPropagation() const collapsed = root.classList.toggle('collapsed') toggle.textContent = collapsed ? '⌃' : '⌄' }) let dragStartX = 0 let dragStartY = 0 let startRight = 0 let startTop = 0 header.addEventListener('pointerdown', (event) => { const target = event.target as HTMLElement if (target.closest('.isthattrue-nav-toggle')) return event.preventDefault() header.setPointerCapture(event.pointerId) dragStartX = event.clientX dragStartY = event.clientY startRight = parseFloat(root.style.right || '60') startTop = parseFloat(root.style.top || '260') }) header.addEventListener('pointermove', (event) => { if (!header.hasPointerCapture(event.pointerId)) return const dx = event.clientX - dragStartX const dy = event.clientY - dragStartY root.style.top = `${Math.max(0, startTop + dy)}px` root.style.right = `${Math.max(0, startRight - dx)}px` }) const onPointerEnd = (event: PointerEvent) => { if (header.hasPointerCapture(event.pointerId)) { header.releasePointerCapture(event.pointerId) } } header.addEventListener('pointerup', onPointerEnd) header.addEventListener('pointercancel', onPointerEnd) let observer: IntersectionObserver | null = null const refreshActive = () => { observer?.disconnect() observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue const text = (entry.target.textContent || '').trim() const section = matchSectionByHeaderText(text) if (!section) continue for (const item of itemMap.values()) item.classList.remove('active') itemMap.get(section.key)?.classList.add('active') break } }, { root: null, rootMargin: '-20% 0px -60% 0px', threshold: 0, }) const headers = getSectionNodes() for (const node of headers) { const text = node.textContent || '' if (matchSectionByHeaderText(text)) { observer.observe(node) } } } const mutationObserver = new MutationObserver(() => { window.setTimeout(refreshActive, 200) }) mutationObserver.observe(document.body, { childList: true, subtree: true }) window.setTimeout(refreshActive, 300) return () => { observer?.disconnect() mutationObserver.disconnect() root.remove() } } const FactCheckDetailsLoader = defineComponent({ name: 'FactCheckDetailsLoader', setup() { const pluginName = inject>('plugin:name') const isOwn = computed(() => { const current = pluginName?.value return !!current && PLUGIN_NAMES.has(current) }) let dispose: (() => void) | null = null const tryMount = () => { dispose?.() dispose = null if (!isOwn.value) return dispose = mountFloatingNav() } onMounted(tryMount) watch(isOwn, tryMount) onBeforeUnmount(() => dispose?.()) return () => h('div', { style: { display: 'none' } }) }, }) export default (ctx: Context) => { ctx.slot({ type: 'plugin-details', component: FactCheckDetailsLoader, order: -999, }) }