import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ErrorBanner } from '@/components/error-banner'
import { Loader2 } from 'lucide-react'
interface IndexSection {
heading: string
entries: IndexEntry[]
}
interface IndexEntry {
title: string
memoryType: string | null
recordId: string | null
raw: string
}
function parseIndexMarkdown(markdown: string): IndexSection[] {
const sections: IndexSection[] = []
let current: IndexSection | null = null
for (const line of markdown.split('\n')) {
const headingMatch = line.match(/^##\s+(.+)/)
if (headingMatch) {
current = { heading: headingMatch[1], entries: [] }
sections.push(current)
continue
}
const entryMatch = line.match(/^-\s+(.+)/)
if (entryMatch && current) {
const raw = entryMatch[1]
const typeMatch = raw.match(/\[(\w+)\]/)
const recordIdMatch = raw.match(/`([^`]+)`/)
const titleMatch = raw.match(/\]\s*(.+?)\s*—/)
current.entries.push({
title: titleMatch ? titleMatch[1].trim() : raw.replace(/[★·]\s*/, '').replace(/\[.*?\]\s*/, '').replace(/\s*—.*/, '').trim(),
memoryType: typeMatch ? typeMatch[1] : null,
recordId: recordIdMatch ? recordIdMatch[1] : null,
raw,
})
}
}
return sections
}
export function IndexNavPage({
onNavigateToRecord,
}: {
onNavigateToRecord?: (recordId: string) => void
}) {
const cfg = useConfig()
const configPath = cfg.configPath
const indexQuery = useQuery({
queryKey: qk.wikiIndex(configPath, null),
queryFn: () =>
ipc.readWikiIndex({
config_path: configPath,
project_id: null,
}),
enabled: configPath.trim().length > 0,
})
const markdown = indexQuery.data?.markdown ?? null
const error = indexQuery.error
const errorEnvelope = error ? asEnvelope(error) : null
const loading = indexQuery.isFetching
const sections = React.useMemo(
() => (markdown ? parseIndexMarkdown(markdown) : []),
[markdown]
)
return (
<div className="space-y-4">
{errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
知识导航
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</CardTitle>
<CardDescription className="text-xs">
按 scope 分组的记忆索引
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{loading && sections.length === 0 && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{!loading && markdown === null && (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">
INDEX 尚未生成。
</p>
<p className="mt-2 text-xs text-muted-foreground">
运行 <code className="rounded bg-muted px-1 py-0.5">spool memory sync-index</code> 生成索引。
</p>
</div>
)}
{!loading && markdown !== null && sections.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">INDEX 为空。</p>
</div>
)}
{sections.length > 0 && (
<div className="space-y-4">
{sections.map((section) => (
<div key={section.heading}>
<h3 className="mb-2 text-sm font-medium text-foreground">
{section.heading}
</h3>
<ul className="space-y-1">
{section.entries.map((entry, i) => (
<li key={`${section.heading}-${i}`} className="flex items-center gap-2">
{entry.recordId && onNavigateToRecord ? (
<button
type="button"
className="text-left text-sm text-primary transition-colors hover:underline"
onClick={() => onNavigateToRecord(entry.recordId!)}
>
{entry.title}
</button>
) : (
<span className="text-sm">{entry.title}</span>
)}
{entry.memoryType && (
<Badge variant="outline" className="text-[10px]">
{entry.memoryType}
</Badge>
)}
</li>
))}
</ul>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}