larkup 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.env +2 -0
  2. package/app/actions/vercel.ts +417 -0
  3. package/app/api/chat/route.ts +192 -0
  4. package/app/api/chat/status/route.ts +55 -0
  5. package/app/api/chat/suggestions/route.ts +115 -0
  6. package/app/api/config/route.ts +73 -0
  7. package/app/api/config/test/route.ts +67 -0
  8. package/app/api/config/test-embedding/route.ts +40 -0
  9. package/app/api/config/test-llm/route.ts +47 -0
  10. package/app/api/config/test-provider/route.ts +36 -0
  11. package/app/api/demo/route.ts +176 -0
  12. package/app/api/deploy/ssh/route.ts +340 -0
  13. package/app/api/documents/route.ts +90 -0
  14. package/app/api/firecrawl/local/route.ts +46 -0
  15. package/app/api/index/route.ts +113 -0
  16. package/app/api/jobs/[id]/route.ts +44 -0
  17. package/app/api/jobs/route.ts +116 -0
  18. package/app/api/parse-file/route.ts +39 -0
  19. package/app/api/search/google/route.ts +162 -0
  20. package/app/api/search/route.ts +29 -0
  21. package/app/api/server/download/route.ts +112 -0
  22. package/app/api/server/generate/route.ts +46 -0
  23. package/app/api/server/local/route.ts +42 -0
  24. package/app/api/servers/route.ts +137 -0
  25. package/app/api/vector-stores/install/route.ts +65 -0
  26. package/app/api/vector-stores/status/route.ts +26 -0
  27. package/app/chat/page.tsx +15 -0
  28. package/app/configure/page.tsx +53 -0
  29. package/app/data/page.tsx +15 -0
  30. package/app/demo/page.tsx +15 -0
  31. package/app/favicon.ico +0 -0
  32. package/app/globals.css +190 -0
  33. package/app/index-data/page.tsx +15 -0
  34. package/app/layout.tsx +51 -0
  35. package/app/page.tsx +34 -0
  36. package/app/server/page.tsx +22 -0
  37. package/app/simple/chat/page.tsx +17 -0
  38. package/app/simple/docs/page.tsx +21 -0
  39. package/app/simple/layout.tsx +9 -0
  40. package/app/simple/settings/page.tsx +627 -0
  41. package/app/template.tsx +16 -0
  42. package/app/themes.css +733 -0
  43. package/components/alerts/generic-alert.tsx +74 -0
  44. package/components/app-sidebar.tsx +187 -0
  45. package/components/app-topnav.tsx +195 -0
  46. package/components/chat/chat-settings-modal.tsx +209 -0
  47. package/components/chat/chat-workspace.tsx +348 -0
  48. package/components/chat/knowledge-base-result.tsx +97 -0
  49. package/components/chat/message-item.tsx +103 -0
  50. package/components/client-layout-wrapper.tsx +182 -0
  51. package/components/configure/configure-form.tsx +1683 -0
  52. package/components/configure/store-fields.tsx +152 -0
  53. package/components/data/corpus-panel.tsx +499 -0
  54. package/components/data/data-workspace.tsx +272 -0
  55. package/components/data/firecrawl-notice.tsx +236 -0
  56. package/components/data/jobs-panel.tsx +485 -0
  57. package/components/data/paste-panel.tsx +79 -0
  58. package/components/data/scrape-panel.tsx +964 -0
  59. package/components/data/upload-panel.tsx +714 -0
  60. package/components/demo/demo-workspace.tsx +419 -0
  61. package/components/global-settings.tsx +286 -0
  62. package/components/index/index-workspace.tsx +482 -0
  63. package/components/onboarding/simple-setup.tsx +702 -0
  64. package/components/onboarding/tech-setup.tsx +121 -0
  65. package/components/onboarding/welcome-screen.tsx +183 -0
  66. package/components/page-header.tsx +44 -0
  67. package/components/server/code-viewer.tsx +87 -0
  68. package/components/server/deploy-button.tsx +1390 -0
  69. package/components/server/server-workspace.tsx +392 -0
  70. package/components/simple/sdk-connect-dialog.tsx +201 -0
  71. package/components/simple/simple-index-button.tsx +83 -0
  72. package/components/simple/simple-sidebar.tsx +161 -0
  73. package/components/theme-customizer-provider.tsx +171 -0
  74. package/components/theme-switcher.tsx +445 -0
  75. package/components/ui/alert-dialog.tsx +187 -0
  76. package/components/ui/alert.tsx +76 -0
  77. package/components/ui/badge.tsx +52 -0
  78. package/components/ui/button.tsx +59 -0
  79. package/components/ui/card.tsx +103 -0
  80. package/components/ui/checkbox.tsx +28 -0
  81. package/components/ui/collapsible.tsx +21 -0
  82. package/components/ui/dialog.tsx +159 -0
  83. package/components/ui/dropdown-menu.tsx +281 -0
  84. package/components/ui/input.tsx +22 -0
  85. package/components/ui/label.tsx +20 -0
  86. package/components/ui/popover.tsx +90 -0
  87. package/components/ui/progress.tsx +83 -0
  88. package/components/ui/provider-icon.tsx +94 -0
  89. package/components/ui/scroll-area.tsx +55 -0
  90. package/components/ui/select.tsx +248 -0
  91. package/components/ui/separator.tsx +25 -0
  92. package/components/ui/sheet.tsx +138 -0
  93. package/components/ui/skeleton.tsx +13 -0
  94. package/components/ui/sonner.tsx +63 -0
  95. package/components/ui/switch.tsx +32 -0
  96. package/components/ui/table.tsx +116 -0
  97. package/components/ui/tabs.tsx +84 -0
  98. package/components/ui/textarea.tsx +18 -0
  99. package/components/ui/tooltip.tsx +66 -0
  100. package/components/workspace/delete-server-dialog.tsx +71 -0
  101. package/components/workspace/server-form-dialog.tsx +103 -0
  102. package/components/workspace/server-switcher.tsx +163 -0
  103. package/components/workspace/workspace-provider.tsx +177 -0
  104. package/components/workspace/workspace-top-bar.tsx +23 -0
  105. package/components.json +21 -0
  106. package/lib/error-formatter.ts +15 -0
  107. package/lib/utils.ts +6 -0
  108. package/next-env.d.ts +6 -0
  109. package/next.config.mjs +18 -0
  110. package/package.json +65 -0
  111. package/postcss.config.mjs +8 -0
  112. package/public/docker.png +0 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/github.svg +3 -0
  115. package/public/hetzner.svg +1 -0
  116. package/public/icons/aws.svg +38 -0
  117. package/public/icons/azure.svg +1 -0
  118. package/public/icons/chroma.png +0 -0
  119. package/public/icons/cohere.svg +30 -0
  120. package/public/icons/digital-ocean.webp +0 -0
  121. package/public/icons/firecrawl2.png +0 -0
  122. package/public/icons/gcp.svg +1 -0
  123. package/public/icons/gemini.svg +1 -0
  124. package/public/icons/jina.svg +1 -0
  125. package/public/icons/lancedb2.png +0 -0
  126. package/public/icons/mistral.svg +469 -0
  127. package/public/icons/nomic.png +0 -0
  128. package/public/icons/openai.svg +1 -0
  129. package/public/icons/pgvector2.png +0 -0
  130. package/public/icons/pinecone.png +0 -0
  131. package/public/icons/qdrant.svg +35 -0
  132. package/public/icons/supabase.png +0 -0
  133. package/public/icons/vercel copy.svg +1 -0
  134. package/public/icons/vercel.svg +1 -0
  135. package/public/icons/voyage-light.png +0 -0
  136. package/public/icons/weaviate.webp +0 -0
  137. package/public/logo-light.png +0 -0
  138. package/public/logo.png +0 -0
  139. package/public/logo9.png +0 -0
  140. package/public/vercel.svg +1 -0
  141. package/tsconfig.json +33 -0
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ function Tabs({
9
+ className,
10
+ orientation = "horizontal",
11
+ ...props
12
+ }: TabsPrimitive.Root.Props) {
13
+ return (
14
+ <TabsPrimitive.Root
15
+ data-slot="tabs"
16
+ data-orientation={orientation}
17
+ className={cn(
18
+ "group/tabs flex gap-2 data-horizontal:flex-col",
19
+ className,
20
+ )}
21
+ {...props}
22
+ />
23
+ );
24
+ }
25
+
26
+ const tabsListVariants = cva(
27
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
28
+ {
29
+ variants: {
30
+ variant: {
31
+ default: "bg-muted",
32
+ line: "gap-1 bg-transparent",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: "default",
37
+ },
38
+ },
39
+ );
40
+
41
+ function TabsList({
42
+ className,
43
+ variant = "default",
44
+ ...props
45
+ }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
46
+ return (
47
+ <TabsPrimitive.List
48
+ data-slot="tabs-list"
49
+ data-variant={variant}
50
+ className={cn(tabsListVariants({ variant }), className)}
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
57
+ return (
58
+ <TabsPrimitive.Tab
59
+ data-slot="tabs-trigger"
60
+ className={cn(
61
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:ring-[3px] focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
62
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
63
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
64
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
65
+ "border shadow-none! data-active:border-border cursor-pointer",
66
+ // "border shadow-none!",
67
+ className,
68
+ )}
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
75
+ return (
76
+ <TabsPrimitive.Panel
77
+ data-slot="tabs-content"
78
+ className={cn("flex-1 text-sm outline-none", className)}
79
+ {...props}
80
+ />
81
+ );
82
+ }
83
+
84
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ export { Textarea };
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ function TooltipProvider({
8
+ delay = 0,
9
+ ...props
10
+ }: TooltipPrimitive.Provider.Props) {
11
+ return (
12
+ <TooltipPrimitive.Provider
13
+ data-slot="tooltip-provider"
14
+ delay={delay}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
21
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
22
+ }
23
+
24
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
25
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
26
+ }
27
+
28
+ function TooltipContent({
29
+ className,
30
+ side = "top",
31
+ sideOffset = 4,
32
+ align = "center",
33
+ alignOffset = 0,
34
+ children,
35
+ ...props
36
+ }: TooltipPrimitive.Popup.Props &
37
+ Pick<
38
+ TooltipPrimitive.Positioner.Props,
39
+ "align" | "alignOffset" | "side" | "sideOffset"
40
+ >) {
41
+ return (
42
+ <TooltipPrimitive.Portal>
43
+ <TooltipPrimitive.Positioner
44
+ align={align}
45
+ alignOffset={alignOffset}
46
+ side={side}
47
+ sideOffset={sideOffset}
48
+ className="isolate z-50"
49
+ >
50
+ <TooltipPrimitive.Popup
51
+ data-slot="tooltip-content"
52
+ className={cn(
53
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ {children}
59
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
60
+ </TooltipPrimitive.Popup>
61
+ </TooltipPrimitive.Positioner>
62
+ </TooltipPrimitive.Portal>
63
+ );
64
+ }
65
+
66
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,71 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { toast } from "sonner"
5
+ import { Loader2 } from "lucide-react"
6
+ import { Button } from "@/components/ui/button"
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/ui/dialog"
15
+ import { useWorkspace, type WorkspaceServer } from "./workspace-provider"
16
+
17
+ interface Props {
18
+ target: WorkspaceServer | null
19
+ open: boolean
20
+ onOpenChange: (open: boolean) => void
21
+ }
22
+
23
+ export function DeleteServerDialog({ target, open, onOpenChange }: Props) {
24
+ const { deleteServer } = useWorkspace()
25
+ const [busy, setBusy] = useState(false)
26
+
27
+ async function confirm() {
28
+ if (!target) return
29
+ setBusy(true)
30
+ try {
31
+ await deleteServer(target.id)
32
+ toast.success(`Deleted "${target.name}".`)
33
+ onOpenChange(false)
34
+ } catch {
35
+ toast.error("Could not delete the server.")
36
+ } finally {
37
+ setBusy(false)
38
+ }
39
+ }
40
+
41
+ return (
42
+ <Dialog open={open} onOpenChange={onOpenChange}>
43
+ <DialogContent className="sm:max-w-md">
44
+ <DialogHeader>
45
+ <DialogTitle>Delete &ldquo;{target?.name}&rdquo;?</DialogTitle>
46
+ <DialogDescription>
47
+ This permanently removes the server&apos;s documents, index, vector
48
+ data, and generated files. This cannot be undone.
49
+ </DialogDescription>
50
+ </DialogHeader>
51
+ <DialogFooter>
52
+ <Button
53
+ variant="outline"
54
+ onClick={() => onOpenChange(false)}
55
+ disabled={busy}
56
+ >
57
+ Cancel
58
+ </Button>
59
+ <Button
60
+ variant="destructive"
61
+ onClick={() => void confirm()}
62
+ disabled={busy}
63
+ >
64
+ {busy && <Loader2 className="size-4 animate-spin" />}
65
+ Delete server
66
+ </Button>
67
+ </DialogFooter>
68
+ </DialogContent>
69
+ </Dialog>
70
+ )
71
+ }
@@ -0,0 +1,103 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { toast } from "sonner"
5
+ import { Loader2 } from "lucide-react"
6
+ import { Button } from "@/components/ui/button"
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/ui/dialog"
15
+ import { Input } from "@/components/ui/input"
16
+ import { Label } from "@/components/ui/label"
17
+ import { useWorkspace, type WorkspaceServer } from "./workspace-provider"
18
+
19
+ interface Props {
20
+ mode: "create" | "rename"
21
+ target?: WorkspaceServer | null
22
+ open: boolean
23
+ onOpenChange: (open: boolean) => void
24
+ }
25
+
26
+ export function ServerFormDialog({ mode, target, open, onOpenChange }: Props) {
27
+ const { createServer, renameServer } = useWorkspace()
28
+ const [name, setName] = useState("")
29
+ const [busy, setBusy] = useState(false)
30
+
31
+ // Seed the field whenever the dialog opens.
32
+ useEffect(() => {
33
+ if (open) setName(mode === "rename" ? (target?.name ?? "") : "")
34
+ }, [open, mode, target])
35
+
36
+ async function submit() {
37
+ const trimmed = name.trim()
38
+ if (!trimmed) {
39
+ toast.error("Enter a server name.")
40
+ return
41
+ }
42
+ setBusy(true)
43
+ try {
44
+ if (mode === "create") {
45
+ const created = await createServer(trimmed)
46
+ if (created) toast.success(`Created "${created.name}" and switched to it.`)
47
+ } else if (target) {
48
+ await renameServer(target.id, trimmed)
49
+ toast.success("Server renamed.")
50
+ }
51
+ onOpenChange(false)
52
+ } catch {
53
+ toast.error("Something went wrong. Try again.")
54
+ } finally {
55
+ setBusy(false)
56
+ }
57
+ }
58
+
59
+ return (
60
+ <Dialog open={open} onOpenChange={onOpenChange}>
61
+ <DialogContent className="sm:max-w-md">
62
+ <DialogHeader>
63
+ <DialogTitle>
64
+ {mode === "create" ? "New RAG server" : "Rename server"}
65
+ </DialogTitle>
66
+ <DialogDescription>
67
+ {mode === "create"
68
+ ? "Each server is an isolated RAG project with its own documents, index, and deployable artifact."
69
+ : "Give this server a clearer name."}
70
+ </DialogDescription>
71
+ </DialogHeader>
72
+
73
+ <div className="grid gap-2">
74
+ <Label htmlFor="server-name">Server name</Label>
75
+ <Input
76
+ id="server-name"
77
+ value={name}
78
+ autoFocus
79
+ placeholder="e.g. docs-assistant"
80
+ onChange={(e) => setName(e.target.value)}
81
+ onKeyDown={(e) => {
82
+ if (e.key === "Enter") void submit()
83
+ }}
84
+ />
85
+ </div>
86
+
87
+ <DialogFooter>
88
+ <Button
89
+ variant="outline"
90
+ onClick={() => onOpenChange(false)}
91
+ disabled={busy}
92
+ >
93
+ Cancel
94
+ </Button>
95
+ <Button onClick={() => void submit()} disabled={busy}>
96
+ {busy && <Loader2 className="size-4 animate-spin" />}
97
+ {mode === "create" ? "Create server" : "Save"}
98
+ </Button>
99
+ </DialogFooter>
100
+ </DialogContent>
101
+ </Dialog>
102
+ )
103
+ }
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Check,
6
+ ChevronsUpDown,
7
+ Pencil,
8
+ Plus,
9
+ Server,
10
+ Trash2,
11
+ } from "lucide-react";
12
+ import { cn } from "@/lib/utils";
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuGroup,
17
+ DropdownMenuItem,
18
+ DropdownMenuLabel,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuTrigger,
21
+ } from "@/components/ui/dropdown-menu";
22
+ import { useWorkspace, type WorkspaceServer } from "./workspace-provider";
23
+ import { ServerFormDialog } from "./server-form-dialog";
24
+ import { DeleteServerDialog } from "./delete-server-dialog";
25
+ import { Separator } from "../ui/separator";
26
+
27
+ export function ServerSwitcher() {
28
+ const { servers, activeServer, activateServer } = useWorkspace();
29
+ const [createOpen, setCreateOpen] = useState(false);
30
+ const [renameTarget, setRenameTarget] = useState<WorkspaceServer | null>(
31
+ null,
32
+ );
33
+ const [deleteTarget, setDeleteTarget] = useState<WorkspaceServer | null>(
34
+ null,
35
+ );
36
+
37
+ return (
38
+ <>
39
+ <DropdownMenu>
40
+ <DropdownMenuTrigger
41
+ render={
42
+ <button
43
+ type="button"
44
+ className="flex min-w-[200px] md:min-w-[240px] items-center gap-2 rounded-lg border border-border bg-card px-2 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
45
+ />
46
+ }
47
+ >
48
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary/12 text-primary">
49
+ <Server className="size-3.5" />
50
+ </span>
51
+ <span className="flex min-w-0 flex-1 flex-col">
52
+ {/* <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
53
+ Server
54
+ </span> */}
55
+ <span className="flex items-center gap-1.5">
56
+ <span className="truncate text-sm font-medium text-foreground">
57
+ {activeServer?.name ?? "No server"}
58
+ </span>
59
+ {activeServer?.running && <RunningDot />}
60
+ </span>
61
+ </span>
62
+ <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
63
+ </DropdownMenuTrigger>
64
+
65
+ <DropdownMenuContent
66
+ align="start"
67
+ sideOffset={6}
68
+ className="w-(--anchor-width) min-w-[260px]"
69
+ >
70
+ <DropdownMenuGroup>
71
+ <DropdownMenuLabel>Your servers</DropdownMenuLabel>
72
+ {servers.map((s) => {
73
+ const active = s.id === activeServer?.id;
74
+ return (
75
+ <DropdownMenuItem
76
+ key={s.id}
77
+ onClick={() => {
78
+ if (!active) void activateServer(s.id);
79
+ }}
80
+ className="gap-2"
81
+ >
82
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
83
+ <Server className="size-3.5" />
84
+ </span>
85
+ <span className="flex min-w-0 flex-1 flex-col">
86
+ <span className="truncate font-medium">{s.name}</span>
87
+ <span className="flex items-center gap-2 text-[11px] text-muted-foreground tabular-nums">
88
+ <span>{s.docCount} docs</span>
89
+ {s.indexed && <span>· indexed</span>}
90
+ {s.running && (
91
+ <span className="flex items-center gap-1 text-green-500">
92
+ <RunningDot />
93
+ live
94
+ </span>
95
+ )}
96
+ </span>
97
+ </span>
98
+ {active && <Check className="size-4 shrink-0 text-primary" />}
99
+ </DropdownMenuItem>
100
+ );
101
+ })}
102
+ </DropdownMenuGroup>
103
+
104
+ <DropdownMenuSeparator />
105
+
106
+ <DropdownMenuItem
107
+ onClick={() => setCreateOpen(true)}
108
+ className="gap-2 h-9"
109
+ >
110
+ <Plus className="size-4" />
111
+ New server
112
+ </DropdownMenuItem>
113
+ {/* <DropdownMenuSeparator /> */}
114
+ {activeServer && (
115
+ <DropdownMenuItem
116
+ onClick={() => setRenameTarget(activeServer)}
117
+ className="gap-2 h-9"
118
+ >
119
+ <Pencil className="size-4" />
120
+ Rename current
121
+ </DropdownMenuItem>
122
+ )}
123
+ {activeServer && servers.length > 1 && (
124
+ <DropdownMenuItem
125
+ variant="destructive"
126
+ onClick={() => setDeleteTarget(activeServer)}
127
+ className="gap-2"
128
+ >
129
+ <Trash2 className="size-4" />
130
+ Delete current
131
+ </DropdownMenuItem>
132
+ )}
133
+ </DropdownMenuContent>
134
+ </DropdownMenu>
135
+
136
+ <ServerFormDialog
137
+ mode="create"
138
+ open={createOpen}
139
+ onOpenChange={setCreateOpen}
140
+ />
141
+ <ServerFormDialog
142
+ mode="rename"
143
+ target={renameTarget}
144
+ open={renameTarget !== null}
145
+ onOpenChange={(o) => !o && setRenameTarget(null)}
146
+ />
147
+ <DeleteServerDialog
148
+ target={deleteTarget}
149
+ open={deleteTarget !== null}
150
+ onOpenChange={(o) => !o && setDeleteTarget(null)}
151
+ />
152
+ </>
153
+ );
154
+ }
155
+
156
+ function RunningDot() {
157
+ return (
158
+ <span className="relative flex size-2">
159
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
160
+ <span className="relative inline-flex size-2 rounded-full bg-green-500" />
161
+ </span>
162
+ );
163
+ }
@@ -0,0 +1,177 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ type ReactNode,
8
+ } from "react"
9
+ import useSWR, { mutate as globalMutate } from "swr"
10
+ import type { WorkspaceMode } from "@larkup/core/workspace"
11
+
12
+ /** A workspace server enriched with the per-server status the UI shows. */
13
+ export interface WorkspaceServer {
14
+ id: string
15
+ name: string
16
+ port: number
17
+ createdAt: string
18
+ updatedAt: string
19
+ docCount: number
20
+ indexed: boolean
21
+ running: boolean
22
+ endpoint: string
23
+ }
24
+
25
+ interface WorkspaceData {
26
+ username: string | null
27
+ activeServerId: string | null
28
+ servers: WorkspaceServer[]
29
+ mode: WorkspaceMode | null
30
+ }
31
+
32
+ interface WorkspaceContextValue {
33
+ isLoading: boolean
34
+ username: string | null
35
+ servers: WorkspaceServer[]
36
+ activeServer?: WorkspaceServer
37
+ /** No servers exist yet → run first-time onboarding. */
38
+ isFirstRun: boolean
39
+ /** User-selected mode: tech, simple, or null (not yet chosen). */
40
+ mode: WorkspaceMode | null
41
+ refresh: () => void
42
+ createServer: (name: string) => Promise<WorkspaceServer | undefined>
43
+ activateServer: (id: string) => Promise<void>
44
+ renameServer: (id: string, name: string) => Promise<void>
45
+ deleteServer: (id: string) => Promise<void>
46
+ setUsername: (name: string) => Promise<void>
47
+ setMode: (mode: WorkspaceMode) => Promise<void>
48
+ }
49
+
50
+ const fetcher = (url: string) => fetch(url).then((r) => r.json())
51
+
52
+ const WorkspaceContext = createContext<WorkspaceContextValue | null>(null)
53
+
54
+ export function useWorkspace() {
55
+ const ctx = useContext(WorkspaceContext)
56
+ if (!ctx) {
57
+ throw new Error("useWorkspace must be used within a WorkspaceProvider")
58
+ }
59
+ return ctx
60
+ }
61
+
62
+ export function WorkspaceProvider({ children }: { children: ReactNode }) {
63
+ const { data, isLoading, mutate } = useSWR<WorkspaceData>(
64
+ "/api/servers",
65
+ fetcher,
66
+ { revalidateOnFocus: false },
67
+ )
68
+
69
+ const servers = data?.servers ?? []
70
+ const activeServer = servers.find((s) => s.id === data?.activeServerId)
71
+ const isFirstRun = !isLoading && servers.length === 0
72
+
73
+ /** Revalidate every SWR key — used after switching the active server so all
74
+ * stage pages reflect the newly selected server. */
75
+ const refreshAllStages = useCallback(async () => {
76
+ await globalMutate(() => true)
77
+ }, [])
78
+
79
+ const createServer = useCallback(
80
+ async (name: string) => {
81
+ const res = await fetch("/api/servers", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ name }),
85
+ })
86
+ const json = await res.json()
87
+ await mutate()
88
+ await refreshAllStages()
89
+ return json.server as WorkspaceServer | undefined
90
+ },
91
+ [mutate, refreshAllStages],
92
+ )
93
+
94
+ const activateServer = useCallback(
95
+ async (id: string) => {
96
+ await fetch("/api/servers", {
97
+ method: "PATCH",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ action: "activate", serverId: id }),
100
+ })
101
+ await mutate()
102
+ await refreshAllStages()
103
+ },
104
+ [mutate, refreshAllStages],
105
+ )
106
+
107
+ const renameServer = useCallback(
108
+ async (id: string, name: string) => {
109
+ await fetch("/api/servers", {
110
+ method: "PATCH",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({ action: "rename", serverId: id, name }),
113
+ })
114
+ await mutate()
115
+ },
116
+ [mutate],
117
+ )
118
+
119
+ const deleteServer = useCallback(
120
+ async (id: string) => {
121
+ await fetch(`/api/servers?id=${encodeURIComponent(id)}`, {
122
+ method: "DELETE",
123
+ })
124
+ await mutate()
125
+ await refreshAllStages()
126
+ },
127
+ [mutate, refreshAllStages],
128
+ )
129
+
130
+ const setUsername = useCallback(
131
+ async (name: string) => {
132
+ await fetch("/api/servers", {
133
+ method: "PATCH",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ action: "setUsername", username: name }),
136
+ })
137
+ await mutate()
138
+ },
139
+ [mutate],
140
+ )
141
+
142
+ const setModeAction = useCallback(
143
+ async (mode: WorkspaceMode) => {
144
+ await fetch("/api/servers", {
145
+ method: "PATCH",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ action: "setMode", mode }),
148
+ })
149
+ await mutate()
150
+ },
151
+ [mutate],
152
+ )
153
+
154
+ const value: WorkspaceContextValue = {
155
+ isLoading,
156
+ username: data?.username ?? null,
157
+ servers,
158
+ activeServer,
159
+ isFirstRun,
160
+ mode: data?.mode ?? null,
161
+ refresh: () => {
162
+ void mutate()
163
+ },
164
+ createServer,
165
+ activateServer,
166
+ renameServer,
167
+ deleteServer,
168
+ setUsername,
169
+ setMode: setModeAction,
170
+ }
171
+
172
+ return (
173
+ <WorkspaceContext.Provider value={value}>
174
+ {children}
175
+ </WorkspaceContext.Provider>
176
+ )
177
+ }