spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc, isTauriHost } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import type {
  DesktopPromptOptimizeResponse,
  DesktopWakeupResponse,
  TargetTool,
  WakeupProfile,
} from '@/lib/types/desktop'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ErrorBanner } from '@/components/error-banner'
import { Loader2, Wand2, Zap } from 'lucide-react'

const TARGETS: TargetTool[] = ['claude', 'codex', 'opencode']
const PROFILES: WakeupProfile[] = ['developer', 'project']

type Mode = 'wakeup' | 'prompt'

export function WakeupPage() {
  const cfg = useConfig()
  const [mode, setMode] = React.useState<Mode>('wakeup')
  const [task, setTask] = React.useState('')
  const [filesRaw, setFilesRaw] = React.useState('')
  const [target, setTarget] = React.useState<TargetTool>('claude')
  const [profile, setProfile] = React.useState<WakeupProfile>('developer')
  const [provider, setProvider] = React.useState('')
  const [sessionId, setSessionId] = React.useState('')

  const wakeupMutation = useMutation({
    mutationFn: () => {
      const files = filesRaw
        .split(/[\n,]/)
        .map((s) => s.trim())
        .filter(Boolean)
      return ipc.buildWakeup({
        config_path: cfg.configPath,
        vault_root_override: cfg.vaultRoot.trim() || null,
        cwd: cfg.cwd,
        task,
        files,
        target,
        profile,
      })
    },
  })

  const promptMutation = useMutation({
    mutationFn: () => {
      const files = filesRaw
        .split(/[\n,]/)
        .map((s) => s.trim())
        .filter(Boolean)
      return ipc.optimizePrompt({
        config_path: cfg.configPath,
        vault_root_override: cfg.vaultRoot.trim() || null,
        cwd: cfg.cwd,
        task,
        files,
        target,
        profile,
        provider: provider.trim() || null,
        session_id: sessionId.trim() || null,
      })
    },
  })

  const wakeup = wakeupMutation.data ?? null
  const prompt = promptMutation.data ?? null
  const loading = wakeupMutation.isPending || promptMutation.isPending
  const error = wakeupMutation.error ?? promptMutation.error
  const errorEnvelope = error ? asEnvelope(error) : null

  function run() {
    if (mode === 'wakeup') wakeupMutation.mutate()
    else promptMutation.mutate()
  }

  return (
    <div className="space-y-4">
      <Tabs value={mode} onValueChange={(v) => setMode(v as Mode)}>
        <TabsList>
          <TabsTrigger value="wakeup" className="gap-1.5">
            <Wand2 className="h-3.5 w-3.5" />
            唤醒包
          </TabsTrigger>
          <TabsTrigger value="prompt" className="gap-1.5">
            <Zap className="h-3.5 w-3.5" />
            Prompt 优化
          </TabsTrigger>
        </TabsList>
      </Tabs>

      <div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
        <Card>
          <CardHeader>
            <CardTitle className="text-base">
              {mode === 'wakeup' ? '生成唤醒包' : '生成优化后的 Prompt'}
            </CardTitle>
            <CardDescription>
              {mode === 'wakeup'
                ? '根据 profile 聚合身份 / 工作风格 / 近期上下文 / 约束 / 决策 / 事件。'
                : '叠加 context + wakeup 两段,生成一段可直接丢给目标 AI 的 combined prompt。'}
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-4">
            <div className="grid gap-2">
              <Label htmlFor="task">任务</Label>
              <Textarea
                id="task"
                rows={3}
                value={task}
                onChange={(e) => setTask(e.target.value)}
                placeholder="resume spool lifecycle work"
              />
            </div>
            <div className="grid gap-2">
              <Label htmlFor="files">相关文件(逗号 / 换行分隔)</Label>
              <Textarea
                id="files"
                rows={2}
                value={filesRaw}
                onChange={(e) => setFilesRaw(e.target.value)}
                placeholder="src/lifecycle_service.rs"
              />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="grid gap-2">
                <Label>target</Label>
                <Select value={target} onValueChange={(v) => setTarget(v as TargetTool)}>
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {TARGETS.map((t) => (
                      <SelectItem key={t} value={t}>
                        {t}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
              <div className="grid gap-2">
                <Label>profile</Label>
                <Select value={profile} onValueChange={(v) => setProfile(v as WakeupProfile)}>
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {PROFILES.map((p) => (
                      <SelectItem key={p} value={p}>
                        {p}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
            </div>
            {mode === 'prompt' && (
              <div className="grid grid-cols-2 gap-3">
                <div className="grid gap-2">
                  <Label>provider(可选)</Label>
                  <Input
                    value={provider}
                    onChange={(e) => setProvider(e.target.value)}
                    placeholder="claude / codex"
                  />
                </div>
                <div className="grid gap-2">
                  <Label>session_id(可选)</Label>
                  <Input
                    value={sessionId}
                    onChange={(e) => setSessionId(e.target.value)}
                  />
                </div>
              </div>
            )}
            <div className="flex items-center gap-3 pt-2">
              <Button
                onClick={run}
                disabled={loading || !task.trim() || !cfg.cwd.trim()}
              >
                {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
                {mode === 'wakeup' ? '生成唤醒包' : '生成 Prompt'}
              </Button>
              {!isTauriHost() && <Badge variant="muted">Bridge 未就绪</Badge>}
            </div>
            {errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle className="text-base">输出</CardTitle>
          </CardHeader>
          <CardContent className="space-y-4">
            {mode === 'wakeup' && wakeup && <WakeupResult response={wakeup} />}
            {mode === 'prompt' && prompt && <PromptResult response={prompt} />}
            {((mode === 'wakeup' && !wakeup) || (mode === 'prompt' && !prompt)) &&
              !loading && (
                <p className="text-sm text-muted-foreground">尚未生成。</p>
              )}
          </CardContent>
        </Card>
      </div>
    </div>
  )
}

function WakeupResult({ response }: { response: DesktopWakeupResponse }) {
  const knowledgeIndex = response.packet?.knowledge_index
  const maintenanceHints = response.packet?.maintenance_hints ?? []
  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">
        vault_root: <span className="text-foreground">{response.used_vault_root}</span>
      </p>
      {maintenanceHints.length > 0 && (
        <div className="rounded-md border border-yellow-500/30 bg-yellow-500/5 p-3">
          <Label className="mb-1 block text-xs text-yellow-600">维护提醒</Label>
          <ul className="list-disc pl-4 text-xs text-yellow-700">
            {maintenanceHints.map((hint, i) => (
              <li key={i}>{hint}</li>
            ))}
          </ul>
        </div>
      )}
      {knowledgeIndex && (
        <div>
          <Label className="mb-1 block text-xs">知识导航 (INDEX)</Label>
          <pre className="max-h-[200px] overflow-auto rounded-md border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed whitespace-pre-wrap">
            {knowledgeIndex}
          </pre>
        </div>
      )}
      <div>
        <Label className="mb-1 block text-xs">Packet</Label>
        <pre className="max-h-[480px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
          {JSON.stringify(response.packet, null, 2)}
        </pre>
      </div>
    </div>
  )
}

function PromptResult({ response }: { response: DesktopPromptOptimizeResponse }) {
  const combined = typeof response.combined_prompt === 'string' ? response.combined_prompt : ''
  const ctx = typeof response.context_prompt === 'string' ? response.context_prompt : ''
  const wake = typeof response.wakeup_prompt === 'string' ? response.wakeup_prompt : ''
  return (
    <Tabs defaultValue="combined">
      <TabsList>
        <TabsTrigger value="combined">Combined</TabsTrigger>
        <TabsTrigger value="context">Context</TabsTrigger>
        <TabsTrigger value="wakeup">Wakeup</TabsTrigger>
        <TabsTrigger value="raw">Raw JSON</TabsTrigger>
      </TabsList>
      <TabsContent value="combined">
        <pre className="max-h-[480px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed whitespace-pre-wrap">
          {combined}
        </pre>
      </TabsContent>
      <TabsContent value="context">
        <pre className="max-h-[480px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed whitespace-pre-wrap">
          {ctx}
        </pre>
      </TabsContent>
      <TabsContent value="wakeup">
        <pre className="max-h-[480px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed whitespace-pre-wrap">
          {wake}
        </pre>
      </TabsContent>
      <TabsContent value="raw">
        <pre className="max-h-[480px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
          {JSON.stringify(response, null, 2)}
        </pre>
      </TabsContent>
    </Tabs>
  )
}