Skip to content

Commit d92a701

Browse files
committed
feat(web): first-launch onboarding on the OpenKB Studio frontend
Re-implements the setup wizard natively in the new (#189) React/TS frontend, using its conventions — react-i18next (common namespace, en+zh parity), shadcn Dialog/Button, and the config/kb API clients. Shown when the global config reports no LLM key (has_api_key=false): 1. Welcome. 2. Connect your model — model + API key + optional custom base URL, saved via patchGlobalConfig (RFC 7386 merge-patch; the key is only sent when typed and is never returned by the API). 3. Create your first knowledge base — createKb, then navigates to it. "Skip for now" dismisses it. Wired in App.tsx via a getGlobalConfig() check on mount. Build passes (i18n:check key-parity + no-hardcoded-CJK guard, tsc -b, vite build); verified rendering steps 1–2 via headless Chromium against a freshly-built frontend served by openkb-api. Claude-Session: https://claude.ai/code/session_01KZyUSGAzVL9yxpsWWPv6Y2
1 parent 970f685 commit d92a701

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

frontend/src/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
} from "@/components/ui/dialog"
2020
import { Button } from "@/components/ui/button"
2121
import { getApiBase, getToken, onUnauthorized, setConnection } from "@/api/client"
22+
import { getGlobalConfig } from "@/api/config"
23+
import Onboarding from "@/components/Onboarding"
2224
import Home from "@/pages/Home"
2325
import ChatSession from "@/pages/ChatSession"
2426
import KbList from "@/pages/KbList"
@@ -126,6 +128,7 @@ function ConnectionDialog({
126128

127129
export default function App() {
128130
const [authOpen, setAuthOpen] = useState(false)
131+
const [onboardOpen, setOnboardOpen] = useState(false)
129132

130133
const isDesktopShell =
131134
typeof (window as { __OPENKB_DESKTOP__?: unknown }).__OPENKB_DESKTOP__ !== "undefined"
@@ -136,6 +139,14 @@ export default function App() {
136139
onUnauthorized(() => setAuthOpen(true))
137140
}, [])
138141

142+
// First-launch onboarding: open the setup wizard when no LLM key is
143+
// configured yet. Silent on failure (e.g. a 401 handles its own prompt).
144+
useEffect(() => {
145+
getGlobalConfig()
146+
.then((c) => setOnboardOpen(!c.has_api_key))
147+
.catch(() => {})
148+
}, [])
149+
139150
return (
140151
<MotionConfig reducedMotion="user">
141152
<div className="ambient-ground h-screen w-screen flex overflow-hidden">
@@ -180,6 +191,7 @@ export default function App() {
180191
</main>
181192
</div>
182193
<ConnectionDialog open={authOpen} onOpenChange={setAuthOpen} />
194+
<Onboarding open={onboardOpen} onClose={() => setOnboardOpen(false)} />
183195
<Toaster />
184196
</div>
185197
</MotionConfig>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useState } from "react"
2+
import { useNavigate } from "react-router"
3+
import { useTranslation } from "react-i18next"
4+
import { toast } from "sonner"
5+
import { Sparkles, KeyRound, FolderPlus, Loader2 } from "lucide-react"
6+
import {
7+
Dialog,
8+
DialogContent,
9+
DialogHeader,
10+
DialogTitle,
11+
DialogDescription,
12+
DialogFooter,
13+
} from "@/components/ui/dialog"
14+
import { Button } from "@/components/ui/button"
15+
import { patchGlobalConfig, type GlobalConfigPatch } from "@/api/config"
16+
import { createKb } from "@/api/kb"
17+
18+
const inputCls =
19+
"mt-1.5 w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-accent-brand"
20+
21+
/**
22+
* First-launch setup: welcome → connect the model (patchGlobalConfig) → create
23+
* the first knowledge base (createKb). Shown when the global config has no API
24+
* key configured yet. "Skip for now" dismisses it.
25+
*/
26+
export default function Onboarding({ open, onClose }: { open: boolean; onClose: () => void }) {
27+
const { t } = useTranslation("common")
28+
const navigate = useNavigate()
29+
const [step, setStep] = useState(1)
30+
const [model, setModel] = useState("gpt-5.4")
31+
const [apiKey, setApiKey] = useState("")
32+
const [baseUrl, setBaseUrl] = useState("")
33+
const [kbName, setKbName] = useState("my-kb")
34+
const [busy, setBusy] = useState(false)
35+
36+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
37+
38+
const saveConfig = async () => {
39+
setBusy(true)
40+
try {
41+
const patch: GlobalConfigPatch = { config: { model: model.trim() } }
42+
if (apiKey.trim()) patch.api_key = apiKey.trim()
43+
if (baseUrl.trim()) patch.openai_api_base = baseUrl.trim()
44+
await patchGlobalConfig(patch)
45+
setStep(3)
46+
} catch (e) {
47+
toast.error(errText(e))
48+
} finally {
49+
setBusy(false)
50+
}
51+
}
52+
53+
const createFirstKb = async () => {
54+
const name = kbName.trim()
55+
if (!name) return
56+
setBusy(true)
57+
try {
58+
await createKb({ kb: name })
59+
toast.success(t("onboarding.createdToast", { name }))
60+
onClose()
61+
navigate(`/kb/${encodeURIComponent(name)}`)
62+
} catch (e) {
63+
toast.error(errText(e))
64+
} finally {
65+
setBusy(false)
66+
}
67+
}
68+
69+
return (
70+
<Dialog
71+
open={open}
72+
onOpenChange={(v) => {
73+
if (!v) onClose()
74+
}}
75+
>
76+
<DialogContent className="sm:max-w-md">
77+
{step === 1 && (
78+
<>
79+
<DialogHeader>
80+
<DialogTitle className="flex items-center gap-2">
81+
<Sparkles className="w-4 h-4 text-accent-brand" />
82+
{t("onboarding.welcomeTitle")}
83+
</DialogTitle>
84+
<DialogDescription>{t("onboarding.welcomeBody")}</DialogDescription>
85+
</DialogHeader>
86+
<DialogFooter>
87+
<Button variant="ghost" onClick={onClose}>
88+
{t("onboarding.skip")}
89+
</Button>
90+
<Button onClick={() => setStep(2)}>{t("onboarding.getStarted")}</Button>
91+
</DialogFooter>
92+
</>
93+
)}
94+
95+
{step === 2 && (
96+
<>
97+
<DialogHeader>
98+
<DialogTitle className="flex items-center gap-2">
99+
<KeyRound className="w-4 h-4 text-accent-brand" />
100+
{t("onboarding.configTitle")}
101+
</DialogTitle>
102+
<DialogDescription>{t("onboarding.configBody")}</DialogDescription>
103+
</DialogHeader>
104+
<div className="space-y-3">
105+
<div>
106+
<label className="text-[12px] font-medium text-muted-foreground">
107+
{t("onboarding.modelLabel")}
108+
</label>
109+
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
110+
</div>
111+
<div>
112+
<label className="text-[12px] font-medium text-muted-foreground">
113+
{t("onboarding.apiKeyLabel")}
114+
</label>
115+
<input
116+
type="password"
117+
value={apiKey}
118+
onChange={(e) => setApiKey(e.target.value)}
119+
placeholder={t("onboarding.apiKeyPlaceholder")}
120+
className={inputCls}
121+
/>
122+
</div>
123+
<div>
124+
<label className="text-[12px] font-medium text-muted-foreground">
125+
{t("onboarding.baseUrlLabel")}
126+
</label>
127+
<input
128+
value={baseUrl}
129+
onChange={(e) => setBaseUrl(e.target.value)}
130+
placeholder={t("onboarding.baseUrlPlaceholder")}
131+
className={inputCls}
132+
/>
133+
</div>
134+
</div>
135+
<DialogFooter>
136+
<Button variant="ghost" onClick={() => setStep(1)}>
137+
{t("onboarding.back")}
138+
</Button>
139+
<Button onClick={saveConfig} disabled={busy}>
140+
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
141+
{t("onboarding.continue")}
142+
</Button>
143+
</DialogFooter>
144+
</>
145+
)}
146+
147+
{step === 3 && (
148+
<>
149+
<DialogHeader>
150+
<DialogTitle className="flex items-center gap-2">
151+
<FolderPlus className="w-4 h-4 text-accent-brand" />
152+
{t("onboarding.kbTitle")}
153+
</DialogTitle>
154+
<DialogDescription>{t("onboarding.kbBody")}</DialogDescription>
155+
</DialogHeader>
156+
<div>
157+
<label className="text-[12px] font-medium text-muted-foreground">
158+
{t("onboarding.kbNameLabel")}
159+
</label>
160+
<input
161+
value={kbName}
162+
onChange={(e) => setKbName(e.target.value)}
163+
placeholder="my-kb"
164+
className={inputCls}
165+
/>
166+
</div>
167+
<DialogFooter>
168+
<Button variant="ghost" onClick={() => setStep(2)}>
169+
{t("onboarding.back")}
170+
</Button>
171+
<Button onClick={createFirstKb} disabled={busy || !kbName.trim()}>
172+
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
173+
{t("onboarding.createFinish")}
174+
</Button>
175+
</DialogFooter>
176+
</>
177+
)}
178+
</DialogContent>
179+
</Dialog>
180+
)
181+
}

frontend/src/locales/en/common.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,26 @@
5050
"connector": {
5151
"tooltip": "Cloud sync is in development · vote for it on GitHub",
5252
"vote": "Want it? Vote"
53+
},
54+
"onboarding": {
55+
"welcomeTitle": "Welcome to OpenKB",
56+
"welcomeBody": "Turn your documents into an interlinked, always-current knowledge base. Two quick steps and you're ready.",
57+
"getStarted": "Get started",
58+
"skip": "Skip for now",
59+
"configTitle": "Connect your model",
60+
"configBody": "OpenKB uses an LLM to compile and answer. Paste an API key — it is stored locally and never leaves your machine.",
61+
"modelLabel": "LLM model",
62+
"apiKeyLabel": "LLM API key",
63+
"apiKeyPlaceholder": "Paste your API key",
64+
"baseUrlLabel": "Custom base URL (optional)",
65+
"baseUrlPlaceholder": "e.g. http://localhost:11434 (Ollama, LM Studio)",
66+
"continue": "Continue",
67+
"back": "Back",
68+
"kbTitle": "Create your first knowledge base",
69+
"kbBody": "A knowledge base holds your documents and the compiled wiki. You can add more later.",
70+
"kbNameLabel": "Knowledge base name",
71+
"createFinish": "Create & finish",
72+
"saving": "Saving…",
73+
"createdToast": "Created {{name}}"
5374
}
5475
}

frontend/src/locales/zh/common.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,26 @@
5050
"connector": {
5151
"tooltip": "云端同步开发中 · 去 GitHub 投票表达需求",
5252
"vote": "想要它?去投票"
53+
},
54+
"onboarding": {
55+
"welcomeTitle": "欢迎使用 OpenKB",
56+
"welcomeBody": "把你的文档编译成互联、自动保持更新的知识库。两步即可开始。",
57+
"getStarted": "开始设置",
58+
"skip": "暂时跳过",
59+
"configTitle": "连接你的模型",
60+
"configBody": "OpenKB 用大模型来编译和回答。粘贴一个 API 密钥即可——它只存在本机,不会外传。",
61+
"modelLabel": "模型",
62+
"apiKeyLabel": "LLM API 密钥",
63+
"apiKeyPlaceholder": "粘贴 API 密钥",
64+
"baseUrlLabel": "自定义 Base URL(可选)",
65+
"baseUrlPlaceholder": "如 http://localhost:11434(Ollama、LM Studio)",
66+
"continue": "继续",
67+
"back": "上一步",
68+
"kbTitle": "创建你的第一个知识库",
69+
"kbBody": "知识库存放你的文档和编译出的 wiki。之后可以再建更多。",
70+
"kbNameLabel": "知识库名称",
71+
"createFinish": "创建并完成",
72+
"saving": "保存中…",
73+
"createdToast": "已创建 {{name}}"
5374
}
5475
}

0 commit comments

Comments
 (0)