spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import { useQuery, useMutation, useQueryClient } 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,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ErrorBanner } from '@/components/error-banner'
import { AlertTriangle, Archive, CheckCircle2, Loader2 } from 'lucide-react'

export function LintPage() {
  const cfg = useConfig()
  const qc = useQueryClient()
  const configPath = cfg.configPath

  const lintQuery = useQuery({
    queryKey: qk.wikiLint(configPath),
    queryFn: () => ipc.wikiLint({ config_path: configPath }),
    enabled: configPath.trim().length > 0,
  })

  const archiveAllMutation = useMutation({
    mutationFn: async (suggestionIds: string[]) => {
      for (const recordId of suggestionIds) {
        await ipc.applyMemoryAction({
          config_path: configPath,
          record_id: recordId,
          action: 'archive',
          metadata: {
            actor: 'lint-cleanup',
            reason: 'archive-all',
            evidence_refs: [],
          },
        })
      }
      return suggestionIds
    },
    onSuccess: (ids) => {
      qc.invalidateQueries({ queryKey: qk.wikiLint(configPath) })
      qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
      for (const id of ids) {
        qc.invalidateQueries({ queryKey: qk.record(configPath, id) })
        qc.invalidateQueries({ queryKey: qk.history(configPath, id) })
      }
    },
  })

  const result = lintQuery.data ?? null
  const error = lintQuery.error ?? archiveAllMutation.error ?? null
  const errorEnvelope = error ? asEnvelope(error) : null
  const loading = lintQuery.isFetching
  const archiving = archiveAllMutation.isPending

  function archiveAll() {
    const suggestions = result?.report?.prune_suggestions ?? []
    if (suggestions.length === 0) return
    archiveAllMutation.mutate(suggestions.map((s) => s.record_id))
  }

  const report = result?.report

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold flex items-center gap-2">
          知识库健康检查
          {loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
        </h2>
      </div>

      {errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}

      {report && (
        <>
          <div className="grid gap-3 sm:grid-cols-4">
            <Card>
              <CardContent className="p-4 text-center">
                <p className="text-2xl font-bold">{report.total_active_records}</p>
                <p className="text-xs text-muted-foreground">活跃记录</p>
              </CardContent>
            </Card>
            <Card>
              <CardContent className="p-4 text-center">
                <p className="text-2xl font-bold">{report.prune_suggestions.length}</p>
                <p className="text-xs text-muted-foreground">可归档</p>
              </CardContent>
            </Card>
            <Card>
              <CardContent className="p-4 text-center">
                <p className="text-2xl font-bold">{report.broken_cross_refs.length}</p>
                <p className="text-xs text-muted-foreground">断链</p>
              </CardContent>
            </Card>
            <Card>
              <CardContent className="p-4 text-center">
                <p className="text-2xl font-bold">{report.orphan_notes.length}</p>
                <p className="text-xs text-muted-foreground">孤儿 note</p>
              </CardContent>
            </Card>
          </div>

          {report.prune_suggestions.length === 0 &&
           report.broken_cross_refs.length === 0 &&
           report.orphan_notes.length === 0 ? (
            <Card>
              <CardContent className="flex items-center gap-2 p-4">
                <CheckCircle2 className="h-5 w-5 text-green-500" />
                <span>知识库干净,无需清理。</span>
              </CardContent>
            </Card>
          ) : (
            <div className="space-y-4">
              {report.prune_suggestions.length > 0 && (
                <Card>
                  <CardHeader className="pb-2">
                    <div className="flex items-center justify-between">
                      <CardTitle className="text-sm flex items-center gap-1">
                        <AlertTriangle className="h-4 w-4 text-yellow-500" />
                        可归档 ({report.prune_suggestions.length})
                      </CardTitle>
                      <Button
                        size="sm"
                        variant="destructive"
                        className="h-7 text-xs"
                        disabled={archiving}
                        onClick={archiveAll}
                      >
                        {archiving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Archive className="mr-1 h-3.5 w-3.5" />}
                        全部归档
                      </Button>
                    </div>
                  </CardHeader>
                  <CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
                    {report.prune_suggestions.map((s) => (
                      <div key={s.record_id} className="flex items-center gap-2 rounded border border-muted px-2 py-1.5">
                        <Badge variant="outline" className="shrink-0 text-[10px]">{s.reason.kind}</Badge>
                        <span className="truncate text-xs">{s.title}</span>
                      </div>
                    ))}
                  </CardContent>
                </Card>
              )}

              {report.broken_cross_refs.length > 0 && (
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm flex items-center gap-1">
                      <AlertTriangle className="h-4 w-4 text-red-500" />
                      断链 ({report.broken_cross_refs.length})
                    </CardTitle>
                  </CardHeader>
                  <CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
                    {report.broken_cross_refs.map((b, i) => (
                      <div key={i} className="rounded border border-muted px-2 py-1.5">
                        <div className="flex items-center gap-2">
                          <span className="truncate text-xs font-medium">{b.title}</span>
                          <Badge variant="secondary" className="shrink-0 text-[10px]">{b.field}</Badge>
                        </div>
                        <p className="mt-0.5 truncate text-xs text-muted-foreground">
                          → <span className="text-red-500">{b.missing_target}</span>
                        </p>
                      </div>
                    ))}
                  </CardContent>
                </Card>
              )}

              {report.orphan_notes.length > 0 && (
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm flex items-center gap-1">
                      <AlertTriangle className="h-4 w-4 text-orange-500" />
                      孤儿 note ({report.orphan_notes.length})
                    </CardTitle>
                  </CardHeader>
                  <CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
                    {report.orphan_notes.map((o) => (
                      <div key={o.record_id} className="rounded border border-muted px-2 py-1.5">
                        <p className="truncate text-xs font-mono text-muted-foreground" title={o.record_id}>
                          {o.record_id}
                        </p>
                        <p className="truncate text-xs text-muted-foreground/60" title={o.relative_path}>
                          {o.relative_path.split('/').pop()}
                        </p>
                      </div>
                    ))}
                  </CardContent>
                </Card>
              )}
            </div>
          )}
        </>
      )}
    </div>
  )
}