Add a guided setup after signup, and a no-model notice in conversations

The first super admin now lands on Settings > Setup after signing up: a
skippable walk through the app name, connecting a model provider and
picking models, ending with where to go next. It stays under Settings to
come back to.

While a bot has no text model, its conversation shows a notice in the
thread (centred when empty): super admins get Connect a provider and
Guided setup, everyone else is told the super admin sets it up. The
models modal keeps it current.

The Models page's data and save logic move into useModelsOverview, shared
with the setup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 18:26:20 +01:00
co-authored by Claude Opus 5.5
parent c9a24c05af
commit 1e7834ba7f
19 changed files with 556 additions and 39 deletions
@@ -0,0 +1,41 @@
import { Bot, Monitor, SlidersHorizontal, Users, Variable } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import { SettingsComputersPath, SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import type { SetupLink } from "../(partials)/setup-link-row";
/**
* Where to go once the models are in: the rest is set up from the pages
* themselves.
*/
export const SetupNextLinks: SetupLink[] = [
{
title: "Meet the coordinator",
description: "Ask it to put a crew together, or just say hello.",
href: AdminHomePath,
Icon: Bot,
},
{
title: "Computers",
description: "The main computer starts by itself. Add more, or check it's running.",
href: SettingsComputersPath,
Icon: Monitor,
},
{
title: "Users",
description: "Add the people who'll work with your bots.",
href: "/admin/settings/users",
Icon: Users,
},
{
title: "Environment",
description: "Keys and settings your bots' tools need on their computers.",
href: "/admin/settings/environment",
Icon: Variable,
},
{
title: "General",
description: "The app's name and icon, run limits and how long things are kept.",
href: SettingsDefaultPath,
Icon: SlidersHorizontal,
},
];
@@ -0,0 +1,11 @@
/**
* The setup's steps, in order. Every one can be skipped.
*/
export const SetupSteps = [
{ key: "welcome", title: "Welcome" },
{ key: "provider", title: "Provider" },
{ key: "models", title: "Models" },
{ key: "ready", title: "Ready" },
] as const;
export type SetupStepKey = (typeof SetupSteps)[number]["key"];
@@ -0,0 +1,24 @@
import type { LucideIcon } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import SettingsListRow from "../../(partials)/settings-list-row";
export type SetupLink = {
title: string;
description: string;
href: string;
Icon: LucideIcon;
};
/**
* One place to go on from the setup.
*/
export default function SetupLinkRow({ title, description, href, Icon }: SetupLink) {
return (
<SettingsListRow
avatar={<IconTile Icon={Icon} />}
title={title}
href={href}
subtitle={description}
/>
);
}
@@ -0,0 +1,42 @@
import { twMerge } from "tailwind-merge";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
number: number;
current: boolean;
done: boolean;
onSelect: () => void;
};
/**
* One step in the setup's trail: its number and name. Any step can be
* opened, in any order.
*/
export default function SetupStepItem({ title, number, current, done, onSelect }: Props) {
return (
<Button
title={`Step ${number}: ${title}`}
type="button"
variant="ghost"
color="text"
aria-current={current ? "step" : undefined}
className={twMerge(
"gap-2! px-2! py-1! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
current && "text-ink dark:text-ink font-bold",
)}
onClick={onSelect}
>
<Span
className={twMerge(
"size-5 shrink-0 flex items-center justify-center rounded-full border border-solid border-hairline-strong text-[11px] leading-none",
(current || done) && "bg-ink text-on-ink border-ink",
)}
>
{number}
</Span>
<Span className="text-[13px] leading-5">{title}</Span>
</Button>
);
}
@@ -0,0 +1,72 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
type Props = {
is_first: boolean;
is_last: boolean;
onBack: () => void;
onNext: () => void;
};
/**
* Under each step: skip the whole setup, go back, or go on. The last step
* goes to the bots.
*/
export default function SetupStepNav({ is_first, is_last, onBack, onNext }: Props) {
return (
<Row className="w-full gap-2! items-center justify-between py-6">
{is_last ? (
<Row />
) : (
<Button
title="Skip the setup"
type="button"
variant="ghost"
color="gray"
size="small"
href={AdminHomePath}
>
Skip setup
</Button>
)}
<Row className="gap-2! ml-auto">
{is_first ? null : (
<Button
title="Back"
type="button"
variant="outlined"
color="gray"
size="small"
beforeIcon={<ArrowLeft size={14} aria-hidden />}
onClick={onBack}
>
Back
</Button>
)}
{is_last ? (
<Button
title="Go to your bots"
type="button"
size="small"
href={AdminHomePath}
afterIcon={<ArrowRight size={14} aria-hidden />}
>
Start chatting
</Button>
) : (
<Button
title="Next step"
type="button"
size="small"
afterIcon={<ArrowRight size={14} aria-hidden />}
onClick={onNext}
>
Next
</Button>
)}
</Row>
</Row>
);
}
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import { SetupSteps } from "../(data)/setup-steps";
import SetupStepItem from "./setup-step-item";
type Props = {
index: number;
onSelect: (index: number) => void;
};
/**
* Where the setup is: every step, the current one marked.
*/
export default function SetupStepsTrail({ index, onSelect }: Props) {
return (
<Row
aria-label="Setup steps"
className="w-full gap-1! md:gap-2! pt-6 pb-2 border-0 border-b border-solid border-hairline"
>
{SetupSteps.map((step, i) => (
<SetupStepItem
key={step.key}
title={step.title}
number={i + 1}
current={i == index}
done={i < index}
onSelect={() => onSelect(i)}
/>
))}
</Row>
);
}
@@ -0,0 +1,30 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { CircleCheck, CircleAlert } from "lucide-react";
import type { HarnessOverview } from "@/src/types";
type Props = {
text?: HarnessOverview;
};
/**
* Whether the bots have a model to think with yet, and which.
*/
export default function SetupTextModelStatus({ text }: Props) {
const current = text?.current;
return (
<Row className="w-full gap-2! flex-nowrap items-start rounded-default border border-solid border-hairline bg-surface px-4 py-3">
{current ? (
<CircleCheck size={16} aria-hidden className="shrink-0 mt-0.5" />
) : (
<CircleAlert size={16} aria-hidden className="shrink-0 mt-0.5" />
)}
<Span className="text-[13px] leading-5 text-ink">
{current
? `Your bots think with ${current.name || current.slug} on ${current.provider_name}.`
: "Your bots have no model to think with yet. Add a key to a provider (OpenRouter has free models) or add another provider."}
</Span>
</Row>
);
}
@@ -0,0 +1,109 @@
import { useContext, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import { SiteData } from "@/src/data/site-data";
import { AppContext } from "@/src/pages/__root";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import InstallNameForm from "../../general/(partials)/install-name-form";
import HarnessSettingsForm from "../../models/(partials)/harness-settings-form";
import ProvidersGroup from "../../models/(partials)/providers-group";
import useModelsOverview from "../../models/(hooks)/use-models-overview";
import { SetupSteps } from "../(data)/setup-steps";
import { SetupNextLinks } from "../(data)/setup-next-links";
import SetupLinkRow from "../(partials)/setup-link-row";
import SetupStepNav from "../(partials)/setup-step-nav";
import SetupStepsTrail from "../(partials)/setup-steps-trail";
import SetupTextModelStatus from "../(partials)/setup-text-model-status";
/**
* The guided setup a new install opens on after the super admin signs
* up, and that stays under Settings to come back to: name the app,
* connect a provider, pick the models, then where to go next. Each step
* saves on its own and any can be skipped; the provider and model steps
* share their data, so a key added shows in the models at once.
*/
export default function SetupSection() {
const { pageProps } = useContext(AppContext);
const { overview, version, notice, update, saveSettings } = useModelsOverview();
const [index, setIndex] = useState(0);
if (!overview) return null;
const step = SetupSteps[index]!.key;
const text = overview.harnesses.find((harness) => harness.harness == "text");
const go = (next: number) => {
setIndex(Math.max(0, Math.min(SetupSteps.length - 1, next)));
};
return (
<>
<SetupStepsTrail index={index} onSelect={go} />
{step == "welcome" ? (
<>
<SettingsGroup title={`Welcome to ${SiteData["SiteName"]}`}>
<Span className="text-sm leading-6 text-ink">
{`A few steps get your crew working: connect where models come from, then pick what your bots think, see, hear and speak with. Every step saves on its own, and you can skip any of them. This setup stays under Settings to come back to.`}
</Span>
</SettingsGroup>
<SettingsGroup
title="App name"
description="The name this install goes by when it's added to a home screen or app list. Optional."
>
<InstallNameForm install_name={pageProps?.install_name} />
</SettingsGroup>
</>
) : null}
{step == "provider" ? (
<>
<SettingsGroup
title="Connect a provider"
description="Your bots need at least one model provider. OpenRouter is already added: give it a key and its free models work right away."
>
<SetupTextModelStatus text={text} />
</SettingsGroup>
<ProvidersGroup providers={overview.providers} onChanged={update} />
</>
) : null}
{step == "models" ? (
<>
<SettingsGroup
title="Pick models"
description="Text picks a free model by itself until you choose one. Transcription, Speech and Embeddings (knowledgebase search) need a model picked before they work."
>
<SetupTextModelStatus text={text} />
</SettingsGroup>
<HarnessSettingsForm
key={version}
harnesses={overview.harnesses}
notice={notice}
save={saveSettings}
/>
</>
) : null}
{step == "ready" ? (
<>
<SettingsGroup title="You're set">
<SetupTextModelStatus text={text} />
</SettingsGroup>
<SettingsGroup
title="Next"
description="Everything else is set up from its own page, whenever you need it."
>
<SettingsList>
{SetupNextLinks.map((link) => (
<SetupLinkRow key={link.href} {...link} />
))}
</SettingsList>
</SettingsGroup>
</>
) : null}
<SetupStepNav
is_first={index == 0}
is_last={index == SetupSteps.length - 1}
onBack={() => go(index - 1)}
onNext={() => go(index + 1)}
/>
</>
);
}
@@ -0,0 +1,10 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { server as modelsServer } from "../models/index.server";
/**
* Super admins only, with the same data as the Models page: the setup's
* provider and model steps edit it.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) =>
await modelsServer(params);
+17
View File
@@ -0,0 +1,17 @@
import { SiteData } from "@/src/data/site-data";
import SetupSection from "./(sections)/setup-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsSetupPage() {
return (
<SettingsShell
title="Setup"
description={`Get ${SiteData["SiteName"]} ready to work, a step at a time.`}
>
<SetupSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Setup" });