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
+3 -2
View File
@@ -54,7 +54,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Auth ### Auth
- **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`. - **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only, which then opens the skippable guided setup `/admin/settings/setup`: app name, provider, models, where to go next; it stays under Settings); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`.
- **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request. - **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request.
- **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`. - **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`.
- **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`). - **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`).
@@ -91,6 +91,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any. - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. - **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **No model:** while the bot has no text model there (`bot_text_model` null, kept live by the models modal), the thread shows `NoModelNotice` (centred when empty): super admins get Connect a provider / Guided setup, others are told the super admin sets it up.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes. - **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes.
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
@@ -226,7 +227,7 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
`AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/chatrooms`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout. `AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/chatrooms`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
- **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside. - **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`). - **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `setup` (super admin; shares the Models page's server props and `useModelsOverview`), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`).
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`). - Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process. - **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`. - **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
+10 -1
View File
@@ -10,6 +10,7 @@ import {
LockKeyhole, LockKeyhole,
Monitor, Monitor,
Plug, Plug,
Rocket,
Users, Users,
SlidersHorizontal, SlidersHorizontal,
UserRound, UserRound,
@@ -38,6 +39,8 @@ export type SettingsPage = {
export const SettingsComputersPath = "/admin/settings/computers"; export const SettingsComputersPath = "/admin/settings/computers";
export const SettingsBackupsPath = "/admin/settings/backups"; export const SettingsBackupsPath = "/admin/settings/backups";
export const SettingsDatabasePath = "/admin/settings/database"; export const SettingsDatabasePath = "/admin/settings/database";
export const SettingsSetupPath = "/admin/settings/setup";
export const SettingsModelsPath = "/admin/settings/models";
/** /**
* The `/admin/settings/*` sub pages, listed in the rail on every settings * The `/admin/settings/*` sub pages, listed in the rail on every settings
@@ -49,6 +52,12 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/general", url: "/admin/settings/general",
Icon: SlidersHorizontal, Icon: SlidersHorizontal,
}, },
{
title: "Setup",
url: SettingsSetupPath,
Icon: Rocket,
super_admin_only: true,
},
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound }, { title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{ {
title: "Change password", title: "Change password",
@@ -71,7 +80,7 @@ export const SettingsPages: SettingsPage[] = [
}, },
{ {
title: "Models", title: "Models",
url: "/admin/settings/models", url: SettingsModelsPath,
Icon: CpuAi, Icon: CpuAi,
super_admin_only: true, super_admin_only: true,
}, },
@@ -8,7 +8,7 @@ import Dropdown from "@moduletrace/twui/elements/Dropdown";
import useStatus from "@moduletrace/twui/hooks/useStatus"; import useStatus from "@moduletrace/twui/hooks/useStatus";
import { CpuAi, EllipsisVertical, Monitor } from "lucide-react"; import { CpuAi, EllipsisVertical, Monitor } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types"; import type { BotTextModel, BotWithLastMessage } from "@/src/types";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button"; import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
@@ -27,6 +27,12 @@ type Props = {
*/ */
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
has_other_unread?: boolean; has_other_unread?: boolean;
/**
* The text model the bot runs on, kept by the conversation (which
* shows a notice while there's none).
*/
model?: BotTextModel | null;
onModelChange: (model: BotTextModel | null) => void;
}; };
/** /**
@@ -40,10 +46,11 @@ export default function ConversationHeader({
bot, bot,
workspace, workspace,
has_other_unread, has_other_unread,
model,
onModelChange,
}: Props) { }: Props) {
const { computers } = useContext(BotComputersContext); const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [model, setModel] = useState(pageProps?.bot_text_model);
/** /**
* The menu's toggle shows its change at once; a push (here or from * The menu's toggle shows its change at once; a push (here or from
* another tab) brings the live bot's. * another tab) brings the live bot's.
@@ -143,7 +150,7 @@ export default function ConversationHeader({
(h) => h.harness == "text", (h) => h.harness == "text",
)?.current; )?.current;
setModel( onModelChange(
current current
? { ? {
name: current.name, name: current.name,
@@ -1,4 +1,5 @@
import { useMemo, type RefObject } from "react"; import { useMemo, type ReactNode, type RefObject } from "react";
import { twMerge } from "tailwind-merge";
import isAwaitingReply from "../(utils)/is-awaiting-reply"; import isAwaitingReply from "../(utils)/is-awaiting-reply";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { import type {
@@ -37,6 +38,11 @@ type Props = {
* The conversation went on in another session: no reply is due here. * The conversation went on in another session: no reply is due here.
*/ */
moved_on?: boolean; moved_on?: boolean;
/**
* Shown after the messages, or in the middle of an empty thread, e.g.
* that the bot has no model to answer with.
*/
notice?: ReactNode;
/** /**
* Quotes text selected in a message in the composer. * Quotes text selected in a message in the composer.
*/ */
@@ -78,6 +84,7 @@ export default function MessageThread({
receiving, receiving,
has_task, has_task,
moved_on, moved_on,
notice,
onQuote, onQuote,
older, older,
}: Props) { }: Props) {
@@ -97,7 +104,10 @@ export default function MessageThread({
role="log" role="log"
aria-live="polite" aria-live="polite"
aria-label={`Conversation with ${bot.name}`} aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6" className={twMerge(
"w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6",
notice && !messages.length && "grow justify-center",
)}
> >
{older?.has_more ? ( {older?.has_more ? (
<OlderMessagesLoader <OlderMessagesLoader
@@ -121,6 +131,7 @@ export default function MessageThread({
group={group} group={group}
/> />
))} ))}
{notice}
<BotThinkingRow <BotThinkingRow
bot={bot} bot={bot}
progress={progress} progress={progress}
@@ -0,0 +1,66 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ArrowRight, CpuAi } from "lucide-react";
import { SettingsModelsPath, SettingsSetupPath } from "@/src/dict/settings-pages-dict";
import { AppContext } from "@/src/pages/__root";
import IconTile from "@/src/components/general/icon-tile";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
type Props = {
bot_name: string;
};
/**
* Shown in the conversation while the bot has no text model to run on
* (nothing would answer a message). A super admin is sent to connect a
* provider, or to the guided setup; anyone else is told who can.
*/
export default function NoModelNotice({ bot_name }: Props) {
const { pageProps } = useContext(AppContext);
const can_set_up = isPassThroughUser({ user_types: pageProps?.user_types });
return (
<Stack
role="status"
className="w-full max-w-[440px] mx-auto gap-3! items-start rounded-default border border-solid border-hairline bg-surface px-5 py-4"
>
<Row className="gap-3! flex-nowrap items-center">
<IconTile Icon={CpuAi} />
<Span className="text-sm font-bold leading-5 text-ink">
{can_set_up ? "Connect a model provider" : "No model set up yet"}
</Span>
</Row>
<Span className="text-[13px] leading-5 text-ink-secondary">
{can_set_up
? `${bot_name} has no model to think with, so nothing would answer yet. Add a provider key (OpenRouter has free models) and pick what your bots run on.`
: `${bot_name} has no model to think with, so nothing would answer yet. The super admin connects one in Settings.`}
</Span>
{can_set_up ? (
<Row className="w-full gap-2! justify-end">
<Button
title="Open the guided setup"
type="button"
variant="outlined"
color="gray"
size="small"
href={SettingsSetupPath}
>
Guided setup
</Button>
<Button
title="Connect a provider"
type="button"
size="small"
href={SettingsModelsPath}
afterIcon={<ArrowRight size={14} aria-hidden />}
>
Connect a provider
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -39,6 +39,7 @@ import useConversationHistory from "../(hooks)/use-conversation-history";
import mergeLoadedHistory from "../(utils)/merge-loaded-history"; import mergeLoadedHistory from "../(utils)/merge-loaded-history";
import LoadingLine from "@/src/components/general/loading-line"; import LoadingLine from "@/src/components/general/loading-line";
import SessionMovedBar from "../(partials)/session-moved-bar"; import SessionMovedBar from "../(partials)/session-moved-bar";
import NoModelNotice from "../(partials)/no-model-notice";
import syncSessionQuery from "../(utils)/sync-session-query"; import syncSessionQuery from "../(utils)/sync-session-query";
import announceShownSession from "@/src/functions/frontend/announce-shown-session"; import announceShownSession from "@/src/functions/frontend/announce-shown-session";
@@ -85,6 +86,11 @@ export default function Conversation() {
*/ */
const [moved_to_session_id, setMovedToSessionId] = useState<number | null>(null); const [moved_to_session_id, setMovedToSessionId] = useState<number | null>(null);
const [has_other_unread, setHasOtherUnread] = useState(false); const [has_other_unread, setHasOtherUnread] = useState(false);
/**
* The text model the bot runs on here; none shows a notice in the
* thread. A save in the models modal updates it.
*/
const [text_model, setTextModel] = useState(pageProps?.bot_text_model || null);
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>( const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
null, null,
); );
@@ -391,6 +397,8 @@ export default function Conversation() {
bot={live_bot} bot={live_bot}
workspace={workspace} workspace={workspace}
has_other_unread={has_other_unread} has_other_unread={has_other_unread}
model={text_model}
onModelChange={setTextModel}
/> />
{history.loading ? ( {history.loading ? (
<LoadingLine label="Loading the conversation" /> <LoadingLine label="Loading the conversation" />
@@ -409,6 +417,11 @@ export default function Conversation() {
receiving={pending_messages.length > 0} receiving={pending_messages.length > 0}
has_task={Boolean(progress.task_id)} has_task={Boolean(progress.task_id)}
moved_on={Boolean(moved_to_session_id)} moved_on={Boolean(moved_to_session_id)}
notice={
!text_model && !history.loading ? (
<NoModelNotice bot_name={live_bot.name || "This bot"} />
) : null
}
older={older} older={older}
onQuote={({ text }) => onQuote={({ text }) =>
composer.current?.insertQuote({ text }) composer.current?.insertQuote({ text })
@@ -0,0 +1,45 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { HarnessOverviewPayload } from "@/src/types";
import postHarnessApi from "../(functions)/post-harness-api";
import type changedHarnessSettings from "../(utils)/changed-harness-settings";
/**
* The providers and harness settings a page edits (the Models settings
* and the setup), starting from the page's props and updated from the
* fresh data every save returns. `version` remounts the harness form when
* the data changes under it; `notice` is the last save's message, kept
* across that remount.
*/
export default function useModelsOverview() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.harness_overview);
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const update = (next: HarnessOverviewPayload) => {
setOverview(next);
setNotice(undefined);
setVersion((prev) => prev + 1);
};
const saveSettings = async ({
settings,
}: {
settings: ReturnType<typeof changedHarnessSettings>;
}) => {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) {
update(res.singleRes);
setNotice(res.msg || "Saved.");
}
return res;
};
return { overview, version, notice, update, saveSettings };
}
@@ -1,30 +1,16 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import HarnessSettingsForm from "../(partials)/harness-settings-form"; import HarnessSettingsForm from "../(partials)/harness-settings-form";
import ProvidersGroup from "../(partials)/providers-group"; import ProvidersGroup from "../(partials)/providers-group";
import postHarnessApi from "../(functions)/post-harness-api"; import useModelsOverview from "../(hooks)/use-models-overview";
/** /**
* Providers first (where models come from), then what each harness uses. * Providers first (where models come from), then what each harness uses.
* Both update from the fresh data every save returns. * Both update from the fresh data every save returns.
*/ */
export default function ModelsSettingsSection() { export default function ModelsSettingsSection() {
const { pageProps } = useContext(AppContext); const { overview, version, notice, update, saveSettings } = useModelsOverview();
const [overview, setOverview] = useState(pageProps?.harness_overview);
/**
* Remounts the harness form when the data changes under it.
*/
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
if (!overview) return null; if (!overview) return null;
const update = (next: typeof overview) => {
setOverview(next);
setNotice(undefined);
setVersion((prev) => prev + 1);
};
return ( return (
<> <>
<ProvidersGroup providers={overview.providers} onChanged={update} /> <ProvidersGroup providers={overview.providers} onChanged={update} />
@@ -32,19 +18,7 @@ export default function ModelsSettingsSection() {
key={version} key={version}
harnesses={overview.harnesses} harnesses={overview.harnesses}
notice={notice} notice={notice}
save={async ({ settings }) => { save={saveSettings}
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) {
update(res.singleRes);
setNotice(res.msg || "Saved.");
}
return res;
}}
/> />
</> </>
); );
@@ -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" });
@@ -1,5 +1,5 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import { AdminHomePath } from "@/src/dict/admin-sections-dict"; import { SettingsSetupPath } from "@/src/dict/settings-pages-dict";
import type useFormInit from "@/src/hooks/use-form-init"; import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, SignupFormObject } from "@/src/types"; import type { ApiReqParams, SignupFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -42,8 +42,12 @@ export default async function submitSignupForm({
}, },
); );
/**
* The first (super admin) account opens on the guided setup, which
* can be skipped.
*/
if (res.success) { if (res.success) {
window.location.pathname = AdminHomePath; window.location.pathname = SettingsSetupPath;
} else { } else {
setStatus({ setStatus({
error: true, error: true,