spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { useConfig } from '@/state/config'
import { isTauriHost } from '@/lib/api/desktop'
import { BrainCircuit, Check, Loader2 } from 'lucide-react'

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

export function OnboardingWizard() {
  const cfg = useConfig()
  const [step, setStep] = React.useState(1)
  const [configPath, setConfigPath] = React.useState('')
  const [vaultRoot, setVaultRoot] = React.useState('')
  const [actor, setActor] = React.useState('')
  const [tools, setTools] = React.useState<ToolStatus[]>([])
  const [detecting, setDetecting] = React.useState(false)
  const [installing, setInstalling] = React.useState<string | null>(null)
  const [msg, setMsg] = React.useState('')

  async function pickConfig() {
    const sel = await openDialog({ multiple: false, filters: [{ name: 'TOML', extensions: ['toml'] }] })
    if (typeof sel === 'string') setConfigPath(sel)
  }
  async function pickVault() {
    const sel = await openDialog({ directory: true, multiple: false })
    if (typeof sel === 'string') setVaultRoot(sel)
  }

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

  async function installAll() {
    if (!isTauriHost()) return
    setMsg('')
    for (const tool of tools) {
      if (tool.spool_injected && tool.hooks_installed) continue
      setInstalling(tool.id)
      try {
        await Promise.all([
          !tool.spool_injected ? tauriInvoke('desktop_install_tool', { client: tool.id }) : Promise.resolve(),
          !tool.hooks_installed ? tauriInvoke('desktop_install_hooks', { client: tool.id }) : Promise.resolve(),
        ])
      } catch { /* ignore */ }
    }
    setInstalling(null)
    setMsg('注入完成')
    detectTools()
  }

  React.useEffect(() => {
    if (step === 4) detectTools()
  }, [step])

  function finish() {
    cfg.setConfigPath(configPath)
    if (vaultRoot.trim()) cfg.setVaultRoot(vaultRoot)
    if (actor.trim()) cfg.setDefaultActor(actor)
  }

  const allReady = tools.length > 0 && tools.every(t => t.spool_injected && t.hooks_installed)

  return (
    <div className="flex h-screen flex-col bg-background">
      {/* macOS traffic-light drag zone */}
      <div
        className="h-11 shrink-0 select-none cursor-default"
        onMouseDown={(e) => {
          if (e.button === 0) void getCurrentWindow().startDragging()
        }}
      />
      <div className="flex flex-1 items-center justify-center p-6">
      <Card className="w-full max-w-md">
        <CardHeader className="text-center">
          <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-md bg-primary/10">
            <BrainCircuit className="h-6 w-6 text-primary" />
          </div>
          <CardTitle>欢迎使用 spool</CardTitle>
          <CardDescription>Step {step} / 4</CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          {step === 1 && (
            <div className="space-y-3">
              <Label>配置文件路径 (.toml)</Label>
              <p className="text-xs text-muted-foreground">spool 用 toml 配置路由 vault 和 ledger</p>
              <div className="flex gap-2">
                <Input value={configPath} onChange={e => setConfigPath(e.target.value)} placeholder="/path/to/spool.toml" />
                <Button variant="outline" onClick={pickConfig}>浏览</Button>
              </div>
              <Button className="w-full" disabled={!configPath.trim()} onClick={() => setStep(2)}>下一步</Button>
            </div>
          )}
          {step === 2 && (
            <div className="space-y-3">
              <Label>Vault 根目录(可选)</Label>
              <p className="text-xs text-muted-foreground">留空则使用 config 中的默认值</p>
              <div className="flex gap-2">
                <Input value={vaultRoot} onChange={e => setVaultRoot(e.target.value)} placeholder="/path/to/vault" />
                <Button variant="outline" onClick={pickVault}>浏览</Button>
              </div>
              <div className="flex gap-2">
                <Button variant="outline" className="flex-1" onClick={() => setStep(1)}>上一步</Button>
                <Button className="flex-1" onClick={() => setStep(3)}>下一步</Button>
              </div>
            </div>
          )}
          {step === 3 && (
            <div className="space-y-3">
              <Label>你的名字(默认 actor)</Label>
              <p className="text-xs text-muted-foreground">用作 lifecycle 操作的 actor 字段</p>
              <Input value={actor} onChange={e => setActor(e.target.value)} placeholder="long" />
              <div className="flex gap-2">
                <Button variant="outline" className="flex-1" onClick={() => setStep(2)}>上一步</Button>
                <Button className="flex-1" onClick={() => setStep(4)}>下一步</Button>
              </div>
            </div>
          )}
          {step === 4 && (
            <div className="space-y-3">
              <div>
                <Label>连接 AI 工具</Label>
                <p className="text-xs text-muted-foreground mt-1">
                  注入 MCP 让 AI 读写记忆,注入 Hooks 让 AI 自动提炼对话
                </p>
              </div>
              {detecting ? (
                <div className="flex items-center gap-2 text-xs text-muted-foreground">
                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
                  检测中…
                </div>
              ) : tools.length === 0 ? (
                <p className="text-xs text-muted-foreground">未检测到已安装的 AI 工具</p>
              ) : (
                <div className="space-y-2">
                  {tools.map(tool => {
                    const fullyInjected = tool.spool_injected && tool.hooks_installed
                    const busy = installing === tool.id
                    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>
                          <Check className="h-3 w-3 shrink-0 text-green-500" />
                          <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>
                        {!fullyInjected && (
                          <button
                            onClick={async () => {
                              setInstalling(tool.id)
                              try {
                                await Promise.all([
                                  !tool.spool_injected ? tauriInvoke('desktop_install_tool', { client: tool.id }) : Promise.resolve(),
                                  !tool.hooks_installed ? tauriInvoke('desktop_install_hooks', { client: tool.id }) : Promise.resolve(),
                                ])
                              } catch { /* ignore */ }
                              setInstalling(null)
                              detectTools()
                            }}
                            disabled={installing !== null}
                            className="ml-2 shrink-0 rounded border px-2 py-0.5 text-[10px] hover:bg-primary/10 hover:border-primary/30 transition-colors disabled:opacity-50"
                          >
                            {busy ? <Loader2 className="h-2.5 w-2.5 animate-spin inline" /> : '一键注入'}
                          </button>
                        )}
                      </div>
                    )
                  })}
                </div>
              )}
              {tools.length > 0 && !allReady && (
                <Button
                  variant="outline"
                  className="w-full text-xs"
                  onClick={installAll}
                  disabled={installing !== null}
                >
                  {installing ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
                  一键注入全部
                </Button>
              )}
              {msg && <p className="text-xs text-green-600">{msg}</p>}
              <div className="flex gap-2">
                <Button variant="outline" className="flex-1" onClick={() => setStep(3)}>上一步</Button>
                <Button className="flex-1" onClick={finish}>
                  {allReady ? '完成' : '跳过'}
                </Button>
              </div>
            </div>
          )}
        </CardContent>
      </Card>
      </div>
    </div>
  )
}