diff --git a/AGENTS.md b/AGENTS.md index 16d8118..510b594 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker ### 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. - **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`). @@ -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. - **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. +- **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. - **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`). @@ -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. - **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`). - **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`. diff --git a/src/dict/settings-pages-dict.ts b/src/dict/settings-pages-dict.ts index 53748d8..90a75d4 100644 --- a/src/dict/settings-pages-dict.ts +++ b/src/dict/settings-pages-dict.ts @@ -10,6 +10,7 @@ import { LockKeyhole, Monitor, Plug, + Rocket, Users, SlidersHorizontal, UserRound, @@ -38,6 +39,8 @@ export type SettingsPage = { export const SettingsComputersPath = "/admin/settings/computers"; export const SettingsBackupsPath = "/admin/settings/backups"; 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 @@ -49,6 +52,12 @@ export const SettingsPages: SettingsPage[] = [ url: "/admin/settings/general", Icon: SlidersHorizontal, }, + { + title: "Setup", + url: SettingsSetupPath, + Icon: Rocket, + super_admin_only: true, + }, { title: "Profile", url: "/admin/settings/profile", Icon: UserRound }, { title: "Change password", @@ -71,7 +80,7 @@ export const SettingsPages: SettingsPage[] = [ }, { title: "Models", - url: "/admin/settings/models", + url: SettingsModelsPath, Icon: CpuAi, super_admin_only: true, }, diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx index 273fc80..8a6107e 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx @@ -8,7 +8,7 @@ import Dropdown from "@moduletrace/twui/elements/Dropdown"; import useStatus from "@moduletrace/twui/hooks/useStatus"; import { CpuAi, EllipsisVertical, Monitor } from "lucide-react"; 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 WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button"; @@ -27,6 +27,12 @@ type Props = { */ workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; 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, workspace, has_other_unread, + model, + onModelChange, }: Props) { const { computers } = useContext(BotComputersContext); 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 * another tab) brings the live bot's. @@ -143,7 +150,7 @@ export default function ConversationHeader({ (h) => h.harness == "text", )?.current; - setModel( + onModelChange( current ? { name: current.name, diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx index de90ead..5ccd703 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx @@ -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 Stack from "@moduletrace/twui/layout/Stack"; import type { @@ -37,6 +38,11 @@ type Props = { * The conversation went on in another session: no reply is due here. */ 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. */ @@ -78,6 +84,7 @@ export default function MessageThread({ receiving, has_task, moved_on, + notice, onQuote, older, }: Props) { @@ -97,7 +104,10 @@ export default function MessageThread({ role="log" aria-live="polite" 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 ? ( ))} + {notice} + + + + {can_set_up ? "Connect a model provider" : "No model set up yet"} + + + + {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.`} + + {can_set_up ? ( + + + + + ) : null} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index bc6a202..1009488 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -39,6 +39,7 @@ import useConversationHistory from "../(hooks)/use-conversation-history"; import mergeLoadedHistory from "../(utils)/merge-loaded-history"; import LoadingLine from "@/src/components/general/loading-line"; import SessionMovedBar from "../(partials)/session-moved-bar"; +import NoModelNotice from "../(partials)/no-model-notice"; import syncSessionQuery from "../(utils)/sync-session-query"; import announceShownSession from "@/src/functions/frontend/announce-shown-session"; @@ -85,6 +86,11 @@ export default function Conversation() { */ const [moved_to_session_id, setMovedToSessionId] = useState(null); 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( null, ); @@ -391,6 +397,8 @@ export default function Conversation() { bot={live_bot} workspace={workspace} has_other_unread={has_other_unread} + model={text_model} + onModelChange={setTextModel} /> {history.loading ? ( @@ -409,6 +417,11 @@ export default function Conversation() { receiving={pending_messages.length > 0} has_task={Boolean(progress.task_id)} moved_on={Boolean(moved_to_session_id)} + notice={ + !text_model && !history.loading ? ( + + ) : null + } older={older} onQuote={({ text }) => composer.current?.insertQuote({ text }) diff --git a/src/pages/admin/settings/models/(hooks)/use-models-overview.ts b/src/pages/admin/settings/models/(hooks)/use-models-overview.ts new file mode 100644 index 0000000..fd5c9df --- /dev/null +++ b/src/pages/admin/settings/models/(hooks)/use-models-overview.ts @@ -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(); + + const update = (next: HarnessOverviewPayload) => { + setOverview(next); + setNotice(undefined); + setVersion((prev) => prev + 1); + }; + + const saveSettings = async ({ + settings, + }: { + settings: ReturnType; + }) => { + 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 }; +} diff --git a/src/pages/admin/settings/models/(sections)/models-settings-section.tsx b/src/pages/admin/settings/models/(sections)/models-settings-section.tsx index 1d68513..5024cc3 100644 --- a/src/pages/admin/settings/models/(sections)/models-settings-section.tsx +++ b/src/pages/admin/settings/models/(sections)/models-settings-section.tsx @@ -1,30 +1,16 @@ -import { useContext, useState } from "react"; -import { AppContext } from "@/src/pages/__root"; import HarnessSettingsForm from "../(partials)/harness-settings-form"; 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. * Both update from the fresh data every save returns. */ export default function ModelsSettingsSection() { - const { pageProps } = useContext(AppContext); - 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(); + const { overview, version, notice, update, saveSettings } = useModelsOverview(); if (!overview) return null; - const update = (next: typeof overview) => { - setOverview(next); - setNotice(undefined); - setVersion((prev) => prev + 1); - }; - return ( <> @@ -32,19 +18,7 @@ export default function ModelsSettingsSection() { key={version} harnesses={overview.harnesses} notice={notice} - save={async ({ settings }) => { - const res = await postHarnessApi({ - path: "save-settings", - body: { settings }, - }); - - if (res.success && res.singleRes) { - update(res.singleRes); - setNotice(res.msg || "Saved."); - } - - return res; - }} + save={saveSettings} /> ); diff --git a/src/pages/admin/settings/setup/(data)/setup-next-links.ts b/src/pages/admin/settings/setup/(data)/setup-next-links.ts new file mode 100644 index 0000000..541a1b5 --- /dev/null +++ b/src/pages/admin/settings/setup/(data)/setup-next-links.ts @@ -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, + }, +]; diff --git a/src/pages/admin/settings/setup/(data)/setup-steps.ts b/src/pages/admin/settings/setup/(data)/setup-steps.ts new file mode 100644 index 0000000..f4315a2 --- /dev/null +++ b/src/pages/admin/settings/setup/(data)/setup-steps.ts @@ -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"]; diff --git a/src/pages/admin/settings/setup/(partials)/setup-link-row.tsx b/src/pages/admin/settings/setup/(partials)/setup-link-row.tsx new file mode 100644 index 0000000..6deb293 --- /dev/null +++ b/src/pages/admin/settings/setup/(partials)/setup-link-row.tsx @@ -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 ( + } + title={title} + href={href} + subtitle={description} + /> + ); +} diff --git a/src/pages/admin/settings/setup/(partials)/setup-step-item.tsx b/src/pages/admin/settings/setup/(partials)/setup-step-item.tsx new file mode 100644 index 0000000..de1d5a3 --- /dev/null +++ b/src/pages/admin/settings/setup/(partials)/setup-step-item.tsx @@ -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 ( + + ); +} diff --git a/src/pages/admin/settings/setup/(partials)/setup-step-nav.tsx b/src/pages/admin/settings/setup/(partials)/setup-step-nav.tsx new file mode 100644 index 0000000..e3821d4 --- /dev/null +++ b/src/pages/admin/settings/setup/(partials)/setup-step-nav.tsx @@ -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 ( + + {is_last ? ( + + ) : ( + + )} + + {is_first ? null : ( + + )} + {is_last ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/pages/admin/settings/setup/(partials)/setup-steps-trail.tsx b/src/pages/admin/settings/setup/(partials)/setup-steps-trail.tsx new file mode 100644 index 0000000..b008a01 --- /dev/null +++ b/src/pages/admin/settings/setup/(partials)/setup-steps-trail.tsx @@ -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 ( + + {SetupSteps.map((step, i) => ( + onSelect(i)} + /> + ))} + + ); +} diff --git a/src/pages/admin/settings/setup/(partials)/setup-text-model-status.tsx b/src/pages/admin/settings/setup/(partials)/setup-text-model-status.tsx new file mode 100644 index 0000000..0ee9e03 --- /dev/null +++ b/src/pages/admin/settings/setup/(partials)/setup-text-model-status.tsx @@ -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 ( + + {current ? ( + + ) : ( + + )} + + {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."} + + + ); +} diff --git a/src/pages/admin/settings/setup/(sections)/setup-section.tsx b/src/pages/admin/settings/setup/(sections)/setup-section.tsx new file mode 100644 index 0000000..27a9079 --- /dev/null +++ b/src/pages/admin/settings/setup/(sections)/setup-section.tsx @@ -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 ( + <> + + {step == "welcome" ? ( + <> + + + {`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.`} + + + + + + + ) : null} + {step == "provider" ? ( + <> + + + + + + ) : null} + {step == "models" ? ( + <> + + + + + + ) : null} + {step == "ready" ? ( + <> + + + + + + {SetupNextLinks.map((link) => ( + + ))} + + + + ) : null} + go(index - 1)} + onNext={() => go(index + 1)} + /> + + ); +} diff --git a/src/pages/admin/settings/setup/index.server.ts b/src/pages/admin/settings/setup/index.server.ts new file mode 100644 index 0000000..f8016c2 --- /dev/null +++ b/src/pages/admin/settings/setup/index.server.ts @@ -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 = async (params) => + await modelsServer(params); diff --git a/src/pages/admin/settings/setup/index.tsx b/src/pages/admin/settings/setup/index.tsx new file mode 100644 index 0000000..4634e95 --- /dev/null +++ b/src/pages/admin/settings/setup/index.tsx @@ -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 ( + + + + ); +} + +export const meta = settingsPageMeta({ label: "Setup" }); diff --git a/src/pages/auth/signup/(functions)/submit-signup-form.ts b/src/pages/auth/signup/(functions)/submit-signup-form.ts index e11843f..6223995 100644 --- a/src/pages/auth/signup/(functions)/submit-signup-form.ts +++ b/src/pages/auth/signup/(functions)/submit-signup-form.ts @@ -1,5 +1,5 @@ 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 { ApiReqParams, SignupFormObject } from "@/src/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) { - window.location.pathname = AdminHomePath; + window.location.pathname = SettingsSetupPath; } else { setStatus({ error: true,