spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import React, { useState, useEffect, useCallback } from 'react'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { open } from '@tauri-apps/plugin-dialog'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { RulesEditor } from '@/components/rules-editor'
import { useConfig } from '@/state/config'
import { useI18n } from '@/state/i18n'
import { isTauriHost } from '@/lib/api/desktop'
import {
  ArrowLeft,
  Check,
  FolderOpen,
  Globe,
  HardDrive,
  Info,
  Loader2,
  Shield,
  Wrench,
  X,
} from 'lucide-react'

type Props = { onBack: () => void }

type ToolStatus = {
  id: string
  name: string
  config_dir: string
  installed: boolean
  spool_injected: boolean
  hooks_installed: boolean
}

export function SettingsPage({ onBack }: Props) {
  const cfg = useConfig()
  const { lang, setLang: setI18nLang } = useI18n()
  const [tools, setTools] = useState<ToolStatus[]>([])

  const handleHeaderMouseDown = useCallback((e: React.MouseEvent<HTMLElement>) => {
    if (e.button !== 0) return
    const target = e.target as HTMLElement
    if (target.closest('button') || target.closest('a') || target.closest('input')) return
    void getCurrentWindow().startDragging()
  }, [])
  const [detecting, setDetecting] = useState(false)
  const [exportMsg, setExportMsg] = useState('')
  const [importMsg, setImportMsg] = useState('')
  const [installMsg, setInstallMsg] = useState('')
  const [hookMsg, setHookMsg] = useState('')
  useEffect(() => {
    detectTools()
  }, [])

  async function detectTools() {
    if (!isTauriHost()) return
    setDetecting(true)
    try {
      const res = await tauriInvoke<{ tools: ToolStatus[] }>('desktop_detect_ai_tools')
      setTools(res.tools)
    } catch {
      // ignore
    }
    setDetecting(false)
  }

  async function handleExport() {
    if (!isTauriHost()) return
    try {
      const res = await tauriInvoke<{ path: string; records: number }>('desktop_export_data')
      setExportMsg(`已导出 ${res.records} 条记录到 ${res.path}`)
    } catch (e) {
      setExportMsg(`导出失败: ${e}`)
    }
  }

  async function handleImport() {
    if (!isTauriHost()) return
    setImportMsg('')
    try {
      const selected = await open({
        title: '选择导入文件',
        filters: [{ name: 'JSONL', extensions: ['jsonl', 'json'] }],
        multiple: false,
      })
      if (!selected) return
      const path = typeof selected === 'string' ? selected : selected
      const res = await tauriInvoke<{ imported_records: number }>('desktop_import_data', { path })
      setImportMsg(`已导入 ${res.imported_records} 条记录`)
    } catch (e) {
      setImportMsg(`导入失败: ${e}`)
    }
  }

  function switchLang(l: 'zh' | 'en') {
    setI18nLang(l)
  }

  async function handleInstallAll(clientId: string) {
    if (!isTauriHost()) return
    setInstallMsg('')
    setHookMsg('')
    try {
      const [mcpRes, hookRes] = await Promise.all([
        tauriInvoke<{ success: boolean; status: string }>('desktop_install_tool', { client: clientId }),
        tauriInvoke<{ success: boolean; changed: boolean }>('desktop_install_hooks', { client: clientId }),
      ])
      const mcpOk = mcpRes.success
      const hookOk = hookRes.success
      setInstallMsg(
        mcpOk && hookOk
          ? `${clientId} MCP + Hooks 注入成功`
          : `MCP: ${mcpRes.status}, Hooks: ${hookOk ? 'ok' : 'failed'}`
      )
      detectTools()
    } catch (e) {
      setInstallMsg(`注入失败: ${e}`)
    }
  }

  return (
    <div className="flex h-screen flex-col">
      <header
        onMouseDown={handleHeaderMouseDown}
        className="flex h-11 shrink-0 items-center gap-3 border-b border-border-subtle px-5 pl-[76px] select-none cursor-default"
      >
        <button
          onClick={onBack}
          className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
        >
          <ArrowLeft className="h-4 w-4" />
        </button>
        <span className="text-sm font-semibold">设置</span>
      </header>

      <div className="flex-1 overflow-auto p-5 space-y-5">
        {/* 知识库 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <FolderOpen className="h-4 w-4 text-primary" />
              知识库
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-3">
            <div className="grid gap-1.5">
              <Label htmlFor="vault-root" className="text-xs text-muted-foreground">
                Obsidian Vault 路径
              </Label>
              <Input
                id="vault-root"
                value={cfg.vaultRoot}
                placeholder="/Users/you/Documents/KnowledgeVault"
                onChange={(e) => cfg.setVaultRoot(e.target.value)}
                className="text-sm"
              />
            </div>
            <div className="grid gap-1.5">
              <Label htmlFor="default-actor" className="text-xs text-muted-foreground">
                默认 actor
              </Label>
              <Input
                id="default-actor"
                value={cfg.defaultActor}
                placeholder="long"
                onChange={(e) => cfg.setDefaultActor(e.target.value)}
                className="text-sm"
              />
              <p className="text-[10px] text-muted-foreground">
                动作 confirm 弹窗的 actor 字段会自动预填这个值。
              </p>
            </div>
          </CardContent>
        </Card>

        {/* AI 工具检测 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <Wrench className="h-4 w-4 text-primary" />
              AI 工具
              {detecting && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
            </CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-2">
              {tools.map((tool) => {
                const fullyInjected = tool.spool_injected && tool.hooks_installed
                return (
                  <div
                    key={tool.id}
                    className="flex items-center justify-between rounded-md border px-3 py-2"
                  >
                    <div className="flex items-center gap-2 min-w-0">
                      <span className="text-sm font-medium">{tool.name}</span>
                      {tool.installed ? (
                        <Check className="h-3 w-3 shrink-0 text-green-500" />
                      ) : (
                        <X className="h-3 w-3 shrink-0 text-muted-foreground" />
                      )}
                      {tool.installed && (
                        <div className="flex items-center gap-1">
                          <Badge
                            variant={tool.spool_injected ? 'default' : 'secondary'}
                            className="text-[10px] h-4"
                          >
                            MCP
                          </Badge>
                          <Badge
                            variant={tool.hooks_installed ? 'default' : 'secondary'}
                            className="text-[10px] h-4"
                          >
                            Hooks
                          </Badge>
                        </div>
                      )}
                    </div>
                    {tool.installed && !fullyInjected && (
                      <button
                        onClick={() => handleInstallAll(tool.id)}
                        className="ml-2 shrink-0 rounded border px-2 py-0.5 text-[10px] hover:bg-primary/10 hover:border-primary/30 transition-colors"
                      >
                        一键注入
                      </button>
                    )}
                    {!tool.installed && (
                      <Badge variant="outline" className="text-[10px]">未安装</Badge>
                    )}
                  </div>
                )
              })}
              {tools.length === 0 && !detecting && (
                <p className="text-xs text-muted-foreground">点击检测按钮扫描本地 AI 工具</p>
              )}
            </div>
            {installMsg && (
              <p className="mt-2 text-xs text-muted-foreground">{installMsg}</p>
            )}
            {hookMsg && (
              <p className="mt-1 text-xs text-muted-foreground">{hookMsg}</p>
            )}
            <button
              onClick={detectTools}
              className="mt-3 rounded-md border px-3 py-1.5 text-xs hover:bg-muted transition-colors"
            >
              重新检测
            </button>
          </CardContent>
        </Card>

        {/* 规则 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <Shield className="h-4 w-4 text-primary" />
              自定义规则
            </CardTitle>
          </CardHeader>
          <CardContent>
            <RulesEditor />
          </CardContent>
        </Card>

        {/* 数据 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <HardDrive className="h-4 w-4 text-primary" />
              数据
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-3">
            <div className="flex gap-2">
              <button
                onClick={handleExport}
                className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted transition-colors"
              >
                导出记忆
              </button>
              <button
                onClick={handleImport}
                className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted transition-colors"
              >
                导入记忆
              </button>
            </div>
            {exportMsg && (
              <p className="text-xs text-muted-foreground">{exportMsg}</p>
            )}
            {importMsg && (
              <p className="text-xs text-muted-foreground">{importMsg}</p>
            )}
          </CardContent>
        </Card>

        {/* 语言 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <Globe className="h-4 w-4 text-primary" />
              语言
            </CardTitle>
          </CardHeader>
          <CardContent>
            <div className="flex gap-2">
              <button
                onClick={() => switchLang('zh')}
                className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
                  lang === 'zh'
                    ? 'bg-primary/10 border-primary/20 text-primary'
                    : 'text-muted-foreground hover:bg-muted'
                }`}
              >
                中文
              </button>
              <button
                onClick={() => switchLang('en')}
                className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
                  lang === 'en'
                    ? 'bg-primary/10 border-primary/20 text-primary'
                    : 'text-muted-foreground hover:bg-muted'
                }`}
              >
                English
              </button>
            </div>
          </CardContent>
        </Card>

        {/* 关于 */}
        <Card>
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-sm">
              <Info className="h-4 w-4 text-primary" />
              关于
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-1">
            <p className="text-xs">
              <span className="text-muted-foreground">版本:</span>0.1.0
            </p>
            <p className="text-xs">
              <span className="text-muted-foreground">运行时:</span>Tauri v2
            </p>
            <p className="text-xs">
              <span className="text-muted-foreground">配置:</span>
              <code className="font-mono text-[10px]">{cfg.configPath || '~/.spool/config.toml'}</code>
            </p>
          </CardContent>
        </Card>
      </div>
    </div>
  )
}