Finish the mobile performance pass
- Cache the model catalog once per user and each conversation's setting separately; clear both on logout and after model saves - Compress hashed assets asynchronously with a byte budget and set Vary - Memoize message parsing and throttle selection reads - Debounce draft writes to localStorage - Keep the composer editor on the page and focused, as before - Add a bundle budget check and run it in CI - Show a loading block while sessions load and a workspace avatar in the mobile conversation header
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { HarnessCatalogPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The install-wide models catalog, shared by every conversation's models
|
||||
* modal and cached once per user.
|
||||
*/
|
||||
export default async function fetchHarnessCatalog() {
|
||||
return await fetchApi<
|
||||
Record<string, never>,
|
||||
APIResponseObject<HarnessCatalogPayload>
|
||||
>(`/api/admin/harness/catalog`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import type { BotHarnessSettingsPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* What a conversation's models modal edits: per harness, the setting, the
|
||||
* models it can pick and what it runs on now.
|
||||
* What a conversation's models form edits, without the install-wide
|
||||
* catalog: the setting, what runs now, and what the default runs on.
|
||||
*/
|
||||
export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
APIResponseObject<BotHarnessSettingsPayload>
|
||||
>(`/api/admin/bots/harness-overview`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import composerDraftKey from "../(utils)/composer-draft-key";
|
||||
|
||||
type Params = {
|
||||
@@ -6,17 +6,46 @@ type Params = {
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long typing rests before the draft is written. `localStorage` is
|
||||
* synchronous, so writing on every keystroke makes a phone's input wait.
|
||||
*/
|
||||
const WriteDelayMs = 250;
|
||||
|
||||
/**
|
||||
* The composer's text (markdown), kept in `localStorage` per bot and
|
||||
* workspace until it's sent, so a reload or leaving the conversation
|
||||
* doesn't lose it. Read after mount, since the page renders on the server
|
||||
* first; `loaded` turns true once it has been, for that `key`.
|
||||
* first; `loaded` turns true once it has been, for that `key`. Writes are
|
||||
* debounced and flushed on unmount or when the conversation changes.
|
||||
*/
|
||||
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
|
||||
const [draft, setDraft] = useState({ key, text: "", loaded: false });
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pending = useRef<{ key: string; text: string } | null>(null);
|
||||
|
||||
const flush = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
|
||||
const next = pending.current;
|
||||
|
||||
pending.current = null;
|
||||
|
||||
if (!next) return;
|
||||
|
||||
try {
|
||||
if (next.text) localStorage.setItem(next.key, next.text);
|
||||
else localStorage.removeItem(next.key);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
flush();
|
||||
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
@@ -26,13 +55,16 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||
setDraft({ key, text, loaded: true });
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => () => flush(), []);
|
||||
|
||||
const setText = (text: string) => {
|
||||
setDraft({ key, text, loaded: true });
|
||||
|
||||
try {
|
||||
if (text) localStorage.setItem(key, text);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {}
|
||||
pending.current = { key, text };
|
||||
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
|
||||
timer.current = setTimeout(flush, WriteDelayMs);
|
||||
};
|
||||
|
||||
const current = draft.key == key;
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import type {
|
||||
HarnessCatalog,
|
||||
HarnessOverview,
|
||||
HarnessSettingOverview,
|
||||
} from "@/src/types";
|
||||
import {
|
||||
readHarnessCatalogCache,
|
||||
readHarnessSettingsCache,
|
||||
writeHarnessCatalogCache,
|
||||
writeHarnessSettingsCache,
|
||||
} from "@/src/utils/harness-cache";
|
||||
import fetchHarnessCatalog from "../(functions)/fetch-harness-catalog";
|
||||
import fetchHarnessOverview from "../(functions)/fetch-harness-overview";
|
||||
import {
|
||||
readHarnessOverviewCache,
|
||||
writeHarnessOverviewCache,
|
||||
} from "../(utils)/harness-overview-cache";
|
||||
harnessCatalogFromOverview,
|
||||
harnessSettingsFromOverview,
|
||||
mergeHarnessOverview,
|
||||
} from "../(utils)/harness-overview-parts";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
@@ -18,55 +31,118 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The models modal's data for a conversation. Shows what `localStorage`
|
||||
* last had at once, and fetches it again when there is none or it is
|
||||
* older than `AppData.HarnessOverviewCacheMs`. `setHarnesses` takes a
|
||||
* save's fresh data and keeps it.
|
||||
* The models modal's data for a conversation. The install-wide catalog is
|
||||
* cached once per user; the conversation's own setting is cached per bot
|
||||
* and workspace, and is small. Either shows what `localStorage` last had
|
||||
* at once and is fetched again when it is older than its
|
||||
* `AppData.Harness*CacheMs`. `setHarnesses` takes a save's fresh data and
|
||||
* keeps it.
|
||||
*/
|
||||
export default function useHarnessOverview({ bot_id, workspace_id, enabled }: Params) {
|
||||
const [harnesses, setState] = useState<HarnessOverview[]>();
|
||||
export default function useHarnessOverview({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
enabled,
|
||||
}: Params) {
|
||||
const [catalog, setCatalog] = useState<HarnessCatalog[]>();
|
||||
const [settings, setSettings] = useState<HarnessSettingOverview[]>();
|
||||
const [error, setError] = useState<string>();
|
||||
/**
|
||||
* Counts fetched updates, so a form showing the cached data remounts
|
||||
* with the fresh.
|
||||
* Counts fetched settings, so a form showing cached data remounts with
|
||||
* the fresh when it isn't being edited.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!enabled || !user_id || !bot_id) return;
|
||||
|
||||
let cancelled = false;
|
||||
const cached = readHarnessOverviewCache({ bot_id, workspace_id });
|
||||
const cached_catalog = readHarnessCatalogCache({ user_id });
|
||||
const cached_settings = readHarnessSettingsCache({ user_id, bot_id, workspace_id });
|
||||
|
||||
if (cached) setState(cached.harnesses);
|
||||
if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return;
|
||||
if (cached_catalog) setCatalog(cached_catalog.data.harnesses);
|
||||
if (cached_settings) setSettings(cached_settings.data.harnesses);
|
||||
|
||||
const now = Date.now();
|
||||
const stale = (at: number | undefined, max_age_ms: number) =>
|
||||
!at || now - at >= max_age_ms;
|
||||
const need_catalog = stale(cached_catalog?.at, AppData.HarnessCatalogCacheMs);
|
||||
const need_settings = stale(cached_settings?.at, AppData.HarnessOverviewCacheMs);
|
||||
|
||||
if (!need_catalog && !need_settings) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
fetchHarnessOverview({ bot_id, workspace_id })
|
||||
.then((res) => {
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
const res = await fetchHarnessCatalog();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setState(res.singleRes.harnesses);
|
||||
setVersion((prev) => prev + 1);
|
||||
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: res.singleRes.harnesses });
|
||||
} else if (!cached) {
|
||||
setCatalog(res.singleRes.harnesses);
|
||||
writeHarnessCatalogCache({ user_id, data: res.singleRes });
|
||||
} else if (!cached_catalog) {
|
||||
setError(res.msg || "Couldn't load the models.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && !cached) setError("Couldn't load the models.");
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled && !cached_catalog) setError("Couldn't load the models.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const res = await fetchHarnessOverview({ bot_id, workspace_id });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setSettings(res.singleRes.harnesses);
|
||||
setVersion((prev) => prev + 1);
|
||||
writeHarnessSettingsCache({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
data: res.singleRes,
|
||||
});
|
||||
} else if (!cached_settings) {
|
||||
setError(res.msg || "Couldn't load the models.");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && !cached_settings) setError("Couldn't load the models.");
|
||||
}
|
||||
};
|
||||
|
||||
void Promise.all([need_catalog ? loadCatalog() : null, need_settings ? loadSettings() : null]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, bot_id, workspace_id]);
|
||||
}, [enabled, user_id, bot_id, workspace_id]);
|
||||
|
||||
const harnesses = useMemo(
|
||||
() => mergeHarnessOverview({ catalog, settings }),
|
||||
[catalog, settings],
|
||||
);
|
||||
|
||||
/**
|
||||
* A save answers with the full overview: keep both parts of the cache
|
||||
* current, so the next open is instant and the catalog is not fetched
|
||||
* again.
|
||||
*/
|
||||
const setHarnesses = (fresh: HarnessOverview[]) => {
|
||||
setState(fresh);
|
||||
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: fresh });
|
||||
const next_catalog = harnessCatalogFromOverview({ harnesses: fresh });
|
||||
const next_settings = harnessSettingsFromOverview({ harnesses: fresh });
|
||||
|
||||
setCatalog(next_catalog);
|
||||
setSettings(next_settings);
|
||||
writeHarnessCatalogCache({ user_id, data: { harnesses: next_catalog } });
|
||||
writeHarnessSettingsCache({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
data: { harnesses: next_settings },
|
||||
});
|
||||
};
|
||||
|
||||
return { harnesses, error, version, setHarnesses };
|
||||
|
||||
@@ -48,14 +48,29 @@ export default function useMessageSelection({ ref }: Params) {
|
||||
setSelection({ text, rect: range.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading a selection on every scroll event is costly on a phone;
|
||||
* one read per frame is enough.
|
||||
*/
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
if (frame) return;
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
read();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", read);
|
||||
ref.current?.addEventListener("scroll", read, { passive: true });
|
||||
ref.current?.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const scroller = ref.current;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", read);
|
||||
scroller?.removeEventListener("scroll", read);
|
||||
scroller?.removeEventListener("scroll", onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ import MessageReplyQuote from "./message-reply-quote";
|
||||
import NeedsYouChip from "./needs-you-chip";
|
||||
import SpeakMessageButton from "./speak-message-button";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
@@ -52,7 +58,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
className="w-full gap-2.5! items-start break-words"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={RemarkPlugins}
|
||||
components={MessageMarkdownComponents}
|
||||
>
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
@@ -41,6 +41,15 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
* The pointer or focus reached the icon: start loading before the click.
|
||||
*/
|
||||
const [warm, setWarm] = useState(false);
|
||||
/**
|
||||
* The form has edits that a background refresh must not replace.
|
||||
*/
|
||||
const [dirty, setDirty] = useState(false);
|
||||
/**
|
||||
* The key the form remounts on. It only moves while the form is clean,
|
||||
* so a fetch that lands mid-edit leaves the edits alone.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
|
||||
const in_workspace = Boolean(workspace?.id);
|
||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
||||
@@ -50,11 +59,20 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
|
||||
user_id: Number(pageProps?.user?.id || 0),
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id || null,
|
||||
enabled: (open || warm) && can_edit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) setFormKey(version);
|
||||
}, [dirty, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDirty(false);
|
||||
}, [open]);
|
||||
|
||||
if (!bot.id) return null;
|
||||
|
||||
const target = (
|
||||
@@ -107,10 +125,11 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
</Stack>
|
||||
{harnesses?.length ? (
|
||||
<BotHarnessForm
|
||||
key={version}
|
||||
key={form_key}
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
harnesses={harnesses}
|
||||
onDirtyChange={setDirty}
|
||||
onSaved={(fresh) => {
|
||||
setHarnesses(fresh);
|
||||
onSaved(fresh);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* Holds the editor's place, the same size, while it loads.
|
||||
*/
|
||||
export default function ComposerInputFallback({ placeholder }: { placeholder: string }) {
|
||||
return (
|
||||
<Span className="grow min-w-0 self-center min-h-5 text-sm leading-5 text-ink-secondary truncate">
|
||||
{placeholder}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import useComposerEditor, { type ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
|
||||
export type ComposerInputApi = ReturnType<typeof useComposerEditor>;
|
||||
|
||||
type Props = Parameters<typeof useComposerEditor>[0] & {
|
||||
/**
|
||||
* Gets the editor's commands (`setMarkdown`, `clear`, `insertText`,
|
||||
* `insertQuote`) for the composer around it.
|
||||
*/
|
||||
api_ref: RefObject<ComposerInputApi | null>;
|
||||
onEditor: (editor: Editor | null) => void;
|
||||
onMenu: (menu: ComposerMentionMenu | null) => void;
|
||||
invalid?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's Tiptap editor, loaded on its own after the page (the
|
||||
* composer imports it lazily: Tiptap is the heaviest code on the page).
|
||||
* Hands its editor, mention menu and commands up to the composer.
|
||||
*/
|
||||
export default function ComposerInput({ api_ref, onEditor, onMenu, invalid, title, ...params }: Props) {
|
||||
const input = useComposerEditor(params);
|
||||
|
||||
api_ref.current = input;
|
||||
|
||||
useEffect(() => {
|
||||
onEditor(input.editor);
|
||||
}, [input.editor]);
|
||||
|
||||
useEffect(() => {
|
||||
onMenu(input.menu);
|
||||
}, [input.menu]);
|
||||
|
||||
useEffect(() => () => {
|
||||
api_ref.current = null;
|
||||
}, []);
|
||||
|
||||
return <ComposerEditor editor={input.editor} invalid={invalid} title={title} />;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
@@ -15,25 +14,18 @@ import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import transcribeVoice from "../(functions)/transcribe-voice";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import useComposerDraft from "../(hooks)/use-composer-draft";
|
||||
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import useComposerEditor from "../(hooks)/use-composer-editor";
|
||||
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerInputFallback from "./composer-input-fallback";
|
||||
import type { ComposerInputApi } from "./composer-input";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
import ComposerMentionList from "./composer-mention-list";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
|
||||
/**
|
||||
* Tiptap is the heaviest code on the page, so the editor loads on its own
|
||||
* once the page is up; a stand-in holds its place until then.
|
||||
*/
|
||||
const ComposerInput = lazy(() => import("./composer-input"));
|
||||
|
||||
/**
|
||||
* What the conversation can do to the composer from outside it.
|
||||
*/
|
||||
@@ -68,7 +60,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A Tiptap editor (`useComposerEditor`, loaded lazily by `ComposerInput`) writing markdown: Enter sends,
|
||||
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
|
||||
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
|
||||
* bot and `#` a workspace. Grows to eight lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
@@ -98,16 +90,17 @@ export default function Composer({
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const send_ref = useRef<() => void>(() => {});
|
||||
|
||||
const input = useRef<ComposerInputApi | null>(null);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
|
||||
const placeholder = `Message ${bot.name}`;
|
||||
const input = useComposerEditor({
|
||||
placeholder: `Message ${bot.name}`,
|
||||
bots,
|
||||
workspaces,
|
||||
onChange: ({ markdown }) => setText(markdown),
|
||||
onSubmit: () => send_ref.current(),
|
||||
onFiles: files.add,
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(
|
||||
handle_ref,
|
||||
() => ({ insertQuote: ({ text }) => input.current?.insertQuote({ text }) }),
|
||||
[],
|
||||
);
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
@@ -122,7 +115,7 @@ export default function Composer({
|
||||
|
||||
/** Out of React's render, since mention chips mount as it loads. */
|
||||
queueMicrotask(() => {
|
||||
input.current?.setMarkdown({ markdown: text });
|
||||
input.setMarkdown({ markdown: text });
|
||||
editor.commands.focus("end");
|
||||
});
|
||||
}, [editor, draft.loaded, draft.key]);
|
||||
@@ -145,7 +138,7 @@ export default function Composer({
|
||||
return;
|
||||
}
|
||||
|
||||
input.current?.insertText({ text: res.singleRes.text });
|
||||
input.insertText({ text: res.singleRes.text });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,7 +171,7 @@ export default function Composer({
|
||||
}
|
||||
|
||||
setText("");
|
||||
input.current?.clear();
|
||||
input.clear();
|
||||
files.clear();
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
@@ -211,7 +204,7 @@ export default function Composer({
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
{menu ? <ComposerMentionList menu={menu} /> : null}
|
||||
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -226,21 +219,7 @@ export default function Composer({
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Suspense fallback={<ComposerInputFallback placeholder={placeholder} />}>
|
||||
<ComposerInput
|
||||
api_ref={input}
|
||||
onEditor={setEditor}
|
||||
onMenu={setMenu}
|
||||
placeholder={placeholder}
|
||||
bots={bots}
|
||||
workspaces={workspaces}
|
||||
onChange={({ markdown }) => setText(markdown)}
|
||||
onSubmit={() => send_ref.current()}
|
||||
onFiles={files.add}
|
||||
invalid={Boolean(error)}
|
||||
title={error}
|
||||
/>
|
||||
</Suspense>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
|
||||
@@ -17,6 +17,10 @@ import NewSessionButton from "./new-session-button";
|
||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
|
||||
import HeaderMetaItem from "./header-meta-item";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -34,7 +38,11 @@ type Props = {
|
||||
* that open the computers and models modals. Kept here so a save or a
|
||||
* switch shows under the name at once.
|
||||
*/
|
||||
export default function ConversationHeader({ bot, workspace, has_other_unread }: Props) {
|
||||
export default function ConversationHeader({
|
||||
bot,
|
||||
workspace,
|
||||
has_other_unread,
|
||||
}: Props) {
|
||||
const { computers } = useContext(BotComputersContext);
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [model, setModel] = useState(pageProps?.bot_text_model);
|
||||
@@ -52,9 +60,27 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="flex md:hidden min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<Row className="gap-2! mb-0.5">
|
||||
<WorkspaceAvatar
|
||||
image_media_id={workspace?.image_media_id}
|
||||
size="xs"
|
||||
/>
|
||||
<Span size="smaller">
|
||||
{workspace.name || "Workspace"}
|
||||
</Span>
|
||||
</Row>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
|
||||
<HeaderMetaItem
|
||||
icon={Layers}
|
||||
label="Workspace"
|
||||
text={workspace.name || "Workspace"}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderMetaItem
|
||||
icon={CpuAi}
|
||||
@@ -80,17 +106,36 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
||||
workspace_id={workspace?.id || null}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
|
||||
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
|
||||
<NewSessionButton
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
/>
|
||||
<BotComputersModal
|
||||
bot={bot}
|
||||
workspace_name={
|
||||
workspace?.id
|
||||
? workspace.name || "This workspace"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<span className="hidden md:inline-flex">
|
||||
<BotModelsModal
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
onSaved={(harnesses) => {
|
||||
const current = harnesses.find((h) => h.harness == "text")?.current;
|
||||
const current = harnesses.find(
|
||||
(h) => h.harness == "text",
|
||||
)?.current;
|
||||
|
||||
setModel(current ? { name: current.name, provider_name: current.provider_name } : null);
|
||||
setModel(
|
||||
current
|
||||
? {
|
||||
name: current.name,
|
||||
provider_name: current.provider_name,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,12 @@ import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm, remarkBreaks];
|
||||
|
||||
/**
|
||||
* Ink bubble with paper text; inverts in dark mode. The text is markdown
|
||||
* (GFM; raw HTML is never rendered) as the composer writes it, a single
|
||||
@@ -18,7 +24,7 @@ export default function UserBubble({ text }: { text: string }) {
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={UserMarkdownComponents}>
|
||||
{linkComputerRefs({ text })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
|
||||
@@ -45,9 +45,13 @@ export default function Conversation() {
|
||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||
pageProps?.bot_messages || [],
|
||||
);
|
||||
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
|
||||
const [session_id, setSessionId] = useState<number | null>(
|
||||
pageProps?.bot_session?.id || null,
|
||||
);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(null);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* A quote picked in one session doesn't follow the thread to another.
|
||||
@@ -73,7 +77,9 @@ export default function Conversation() {
|
||||
computers: pageProps?.bot_computers || [],
|
||||
});
|
||||
|
||||
const speak = useSpeakMessage({ enabled: Boolean(pageProps?.bot_voice?.speech) });
|
||||
const speak = useSpeakMessage({
|
||||
enabled: Boolean(pageProps?.bot_voice?.speech),
|
||||
});
|
||||
|
||||
const progress = useTaskProgress({
|
||||
bot_id: bot?.id || 0,
|
||||
@@ -100,7 +106,10 @@ export default function Conversation() {
|
||||
* moves the thread onto it.
|
||||
*/
|
||||
const startsNewSession = (message: BotMessageWithAttachments) =>
|
||||
Boolean(message.session_id && (!session_id || Number(message.session_id) > session_id));
|
||||
Boolean(
|
||||
message.session_id &&
|
||||
(!session_id || Number(message.session_id) > session_id),
|
||||
);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-message",
|
||||
@@ -140,10 +149,15 @@ export default function Conversation() {
|
||||
* A reaction toggled here or in another tab: the pushed list replaces
|
||||
* the message's own.
|
||||
*/
|
||||
const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => {
|
||||
const applyReactions = ({
|
||||
message_id,
|
||||
reactions,
|
||||
}: BotMessageReactionData) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
Number(message.id) == message_id ? { ...message, reactions } : message,
|
||||
Number(message.id) == message_id
|
||||
? { ...message, reactions }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -168,7 +182,8 @@ export default function Conversation() {
|
||||
*/
|
||||
const message_actions = useMemo(
|
||||
() => ({
|
||||
onReply: ({ message }: { message: BotMessageWithAttachments }) => setReplyTo(message),
|
||||
onReply: ({ message }: { message: BotMessageWithAttachments }) =>
|
||||
setReplyTo(message),
|
||||
onReact: reactToMessage,
|
||||
}),
|
||||
[],
|
||||
@@ -178,54 +193,63 @@ export default function Conversation() {
|
||||
|
||||
return (
|
||||
<CrewBotsContext.Provider value={bots}>
|
||||
<CrewWorkspacesContext.Provider value={workspaces}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<BotMessageActionsContext.Provider value={message_actions}>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={messages}
|
||||
progress={progress.text}
|
||||
reply_progress={reply_progress}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
onQuote={({ text }) => composer.current?.insertQuote({ text })}
|
||||
/>
|
||||
<Composer
|
||||
handle_ref={composer}
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
voice={pageProps?.bot_voice}
|
||||
reply_to={reply_to}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSent={({ message }) => {
|
||||
setReplyTo(null);
|
||||
<CrewWorkspacesContext.Provider value={workspaces}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<BotMessageActionsContext.Provider
|
||||
value={message_actions}
|
||||
>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={messages}
|
||||
progress={progress.text}
|
||||
reply_progress={reply_progress}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
onQuote={({ text }) =>
|
||||
composer.current?.insertQuote({ text })
|
||||
}
|
||||
/>
|
||||
<Composer
|
||||
handle_ref={composer}
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
voice={pageProps?.bot_voice}
|
||||
reply_to={reply_to}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSent={({ message }) => {
|
||||
setReplyTo(null);
|
||||
|
||||
if (startsNewSession(message) && message.session_id != session_id) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id)
|
||||
? prev
|
||||
: [...prev, message],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</BotMessageActionsContext.Provider>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
</CrewWorkspacesContext.Provider>
|
||||
if (
|
||||
startsNewSession(message) &&
|
||||
message.session_id != session_id
|
||||
) {
|
||||
setSessionId(
|
||||
Number(message.session_id),
|
||||
);
|
||||
setMessages([message]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id)
|
||||
? prev
|
||||
: [...prev, message],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</BotMessageActionsContext.Provider>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
</CrewWorkspacesContext.Provider>
|
||||
</CrewBotsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Bumped when `HarnessOverview` changes shape, so an older cache is
|
||||
* fetched again instead of read.
|
||||
*/
|
||||
const CacheVersion = 1;
|
||||
|
||||
type Key = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
export type CachedHarnessOverview = {
|
||||
v: number;
|
||||
/**
|
||||
* Unix ms it was fetched.
|
||||
*/
|
||||
at: number;
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
function cacheKey({ bot_id, workspace_id }: Key) {
|
||||
return `${AppData.HarnessOverviewStorageKey}:${bot_id}:${workspace_id || 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models modal's data last fetched for this conversation, from
|
||||
* `localStorage`; null when there is none or it can't be read.
|
||||
*/
|
||||
export function readHarnessOverviewCache(key: Key): CachedHarnessOverview | null {
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey(key)) || "null");
|
||||
|
||||
return cached?.v == CacheVersion &&
|
||||
Array.isArray(cached.harnesses) &&
|
||||
typeof cached.at == "number"
|
||||
? cached
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHarnessOverviewCache({ harnesses, ...key }: Key & { harnesses: HarnessOverview[] }) {
|
||||
try {
|
||||
localStorage.setItem(cacheKey(key), JSON.stringify({ v: CacheVersion, at: Date.now(), harnesses }));
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import {
|
||||
harnessCatalogFromOverview,
|
||||
harnessSettingsFromOverview,
|
||||
mergeHarnessOverview,
|
||||
} from "./harness-overview-parts";
|
||||
|
||||
const overview: HarnessOverview = {
|
||||
harness: "text",
|
||||
title: "Text",
|
||||
description: "Chats.",
|
||||
required: true,
|
||||
automatic: true,
|
||||
model_id: 4,
|
||||
provider_id: 1,
|
||||
providers: [{ id: 1, name: "OpenRouter" }],
|
||||
fallback: false,
|
||||
enabled: true,
|
||||
options: { voice: "alloy" },
|
||||
models: [
|
||||
{
|
||||
id: 4,
|
||||
provider_id: 1,
|
||||
provider_name: "OpenRouter",
|
||||
slug: "deepseek",
|
||||
name: "DeepSeek",
|
||||
free: true,
|
||||
},
|
||||
],
|
||||
current: {
|
||||
id: 4,
|
||||
provider_id: 1,
|
||||
provider_name: "OpenRouter",
|
||||
slug: "deepseek",
|
||||
name: "DeepSeek",
|
||||
free: true,
|
||||
source: "pinned",
|
||||
},
|
||||
candidate_count: 2,
|
||||
inherit: false,
|
||||
};
|
||||
|
||||
describe("harness overview parts", () => {
|
||||
test("splits the install-wide catalog from the conversation's setting", () => {
|
||||
const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] });
|
||||
const [setting] = harnessSettingsFromOverview({ harnesses: [overview] });
|
||||
|
||||
expect(catalog).toMatchObject({
|
||||
harness: "text",
|
||||
title: "Text",
|
||||
providers: overview.providers,
|
||||
models: overview.models,
|
||||
});
|
||||
expect(setting).not.toHaveProperty("models");
|
||||
expect(setting).not.toHaveProperty("providers");
|
||||
expect(setting).toMatchObject({
|
||||
harness: "text",
|
||||
model_id: 4,
|
||||
provider_id: 1,
|
||||
fallback: false,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("merges a cached catalog and setting back into the form's shape", () => {
|
||||
const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] });
|
||||
const [setting] = harnessSettingsFromOverview({ harnesses: [overview] });
|
||||
|
||||
expect(
|
||||
mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] }),
|
||||
).toEqual([overview]);
|
||||
});
|
||||
|
||||
test("waits for both parts", () => {
|
||||
const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] });
|
||||
|
||||
expect(mergeHarnessOverview({ catalog: [catalog!], settings: [] })).toBeUndefined();
|
||||
expect(mergeHarnessOverview({ catalog: [], settings: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps a picked model the catalog no longer lists", () => {
|
||||
const [catalog] = harnessCatalogFromOverview({
|
||||
harnesses: [{ ...overview, models: [] }],
|
||||
});
|
||||
const [setting] = harnessSettingsFromOverview({ harnesses: [overview] });
|
||||
const [merged] = mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] })!;
|
||||
|
||||
expect(setting?.pinned_model).toEqual(overview.models[0]);
|
||||
expect(merged?.models).toEqual(overview.models);
|
||||
});
|
||||
|
||||
test("keeps a provider the catalog no longer lists", () => {
|
||||
const without_providers = { ...overview, providers: [], models: [] };
|
||||
const [catalog] = harnessCatalogFromOverview({ harnesses: [without_providers] });
|
||||
const [setting] = harnessSettingsFromOverview({ harnesses: [without_providers] });
|
||||
const [merged] = mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] })!;
|
||||
|
||||
expect(merged?.providers).toEqual([
|
||||
{ id: 1, name: "Unavailable provider" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
HarnessCatalog,
|
||||
HarnessOverview,
|
||||
HarnessSettingOverview,
|
||||
} from "@/src/types";
|
||||
|
||||
type Harnesses = {
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The install-wide part of a model overview: everything except the bot's
|
||||
* own settings.
|
||||
*/
|
||||
export function harnessCatalogFromOverview({ harnesses }: Harnesses): HarnessCatalog[] {
|
||||
return harnesses.map(({ harness, title, description, required, automatic, providers, models }) => ({
|
||||
harness,
|
||||
title,
|
||||
description,
|
||||
required,
|
||||
automatic,
|
||||
providers,
|
||||
models,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A bot's (or workspace's) part of a model overview: the settings, what
|
||||
* runs now, and what the default runs on. A picked model or provider the
|
||||
* catalog no longer lists travels with it, so it isn't lost.
|
||||
*/
|
||||
export function harnessSettingsFromOverview({ harnesses }: Harnesses): HarnessSettingOverview[] {
|
||||
return harnesses.map(({ title, description, required, automatic, providers, models, ...setting }) => {
|
||||
const pinned_model = setting.model_id
|
||||
? models.find((model) => model.id == setting.model_id)
|
||||
: undefined;
|
||||
const unavailable_provider =
|
||||
setting.provider_id && !providers.some((provider) => provider.id == setting.provider_id)
|
||||
? { id: setting.provider_id, name: "Unavailable provider" }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...setting,
|
||||
...(pinned_model ? { pinned_model } : {}),
|
||||
...(unavailable_provider ? { unavailable_provider } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A catalog and a conversation's settings as the models form wants them:
|
||||
* the catalog's list of models and providers under the setting. Undefined
|
||||
* until both parts are there.
|
||||
*/
|
||||
export function mergeHarnessOverview({
|
||||
catalog,
|
||||
settings,
|
||||
}: {
|
||||
catalog?: HarnessCatalog[] | null;
|
||||
settings?: HarnessSettingOverview[] | null;
|
||||
}): HarnessOverview[] | undefined {
|
||||
if (!catalog?.length || !settings?.length) return undefined;
|
||||
|
||||
const by_harness = new Map(catalog.map((entry) => [entry.harness, entry]));
|
||||
|
||||
return settings.flatMap((setting) => {
|
||||
const entry = by_harness.get(setting.harness);
|
||||
|
||||
if (!entry) return [];
|
||||
|
||||
const { pinned_model, unavailable_provider, ...rest } = setting;
|
||||
const models =
|
||||
pinned_model && !entry.models.some((model) => model.id == pinned_model.id)
|
||||
? [...entry.models, pinned_model]
|
||||
: entry.models;
|
||||
const providers =
|
||||
unavailable_provider &&
|
||||
!entry.providers.some((provider) => provider.id == unavailable_provider.id)
|
||||
? [...entry.providers, unavailable_provider]
|
||||
: entry.providers;
|
||||
|
||||
return [{ ...entry, ...rest, models, providers }];
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,11 @@ type Props = {
|
||||
* Gets the fresh harnesses after each save.
|
||||
*/
|
||||
onSaved?: (harnesses: HarnessOverview[]) => void;
|
||||
/**
|
||||
* Whether the form has unsaved edits, so a background refresh can
|
||||
* leave them alone.
|
||||
*/
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,7 +34,13 @@ type Props = {
|
||||
* runs on the defaults, so it saves the defaults themselves, which only a
|
||||
* super admin can.
|
||||
*/
|
||||
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
|
||||
export default function BotHarnessForm({
|
||||
bot,
|
||||
workspace_id,
|
||||
harnesses: initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
/**
|
||||
* Remounts the form when the data changes under it.
|
||||
@@ -49,6 +60,7 @@ export default function BotHarnessForm({ bot, workspace_id, harnesses: initial,
|
||||
key={version}
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
onDirtyChange={onDirtyChange}
|
||||
save={async ({ settings }) => {
|
||||
if (workspace_id) {
|
||||
const res = await saveWorkspaceHarnessSettings({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { HarnessOverviewPayload } from "@/src/types";
|
||||
import { clearHarnessCache } from "@/src/utils/harness-cache";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -15,11 +16,15 @@ type Params = {
|
||||
* data in `singleRes`, so the page updates in place.
|
||||
*/
|
||||
export default async function postHarnessApi({ path, body }: Params) {
|
||||
return await fetchApi<
|
||||
const res = await fetchApi<
|
||||
Record<string, unknown>,
|
||||
APIResponseObject<HarnessOverviewPayload>
|
||||
>(`/api/admin/harness/${path}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.success) clearHarnessCache();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import type { HarnessOverview, HarnessSettingsFormObject } from "@/src/types";
|
||||
@@ -20,6 +20,11 @@ type Props = {
|
||||
* The last save's message, kept by the page across the remount.
|
||||
*/
|
||||
notice?: string;
|
||||
/**
|
||||
* Whether there are unsaved edits, so a background refresh can leave
|
||||
* them alone.
|
||||
*/
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,6 +35,7 @@ export default function HarnessSettingsForm({
|
||||
harnesses,
|
||||
save,
|
||||
notice: saved_notice,
|
||||
onDirtyChange,
|
||||
}: Props) {
|
||||
const saved = toHarnessForm({ harnesses });
|
||||
/**
|
||||
@@ -47,6 +53,10 @@ export default function HarnessSettingsForm({
|
||||
const changed = changedHarnessSettings({ form, saved });
|
||||
const dirty = changed.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const discard = () => {
|
||||
setForm(saved);
|
||||
setStatus({});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import grabConversationHarnessOverview from "@/src/functions/backend/db/harness/grab-conversation-harness-overview";
|
||||
import grabConversationHarnessSettings from "@/src/functions/backend/db/harness/grab-conversation-harness-settings";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import type { BotHarnessSettingsPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
@@ -14,12 +14,13 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* What a conversation's models modal edits, loaded when it opens: the
|
||||
* What a conversation's models form edits, loaded when it opens: the
|
||||
* coordinator's defaults (super admins), or a worker's or the workspace's
|
||||
* models (super admins and admins).
|
||||
* models (super admins and admins). The install-wide catalog comes from
|
||||
* `api/admin/harness/catalog`, so it is cached once rather than per bot.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
APIResponseObject<BotHarnessSettingsPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
@@ -50,7 +51,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await grabConversationHarnessOverview({
|
||||
singleRes: await grabConversationHarnessSettings({
|
||||
bot: conversation.bot,
|
||||
workspace_id: scope,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabHarnessCatalog from "@/src/functions/backend/db/harness/grab-harness-catalog";
|
||||
import type { HarnessCatalogPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The install-wide models catalog: per harness, the providers and models
|
||||
* it can pick. The chat header's models modal fetches it once and keeps it
|
||||
* in `localStorage`, so every bot and workspace reuses it.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<HarnessCatalogPayload>
|
||||
> = async ({ req }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (!canManageCrew({ user_types })) {
|
||||
throw new AppError(`Only an admin can see the model catalog.`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await grabHarnessCatalog(),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -10,9 +10,12 @@ import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { clearHarnessCache } from "@/src/utils/harness-cache";
|
||||
|
||||
export default function LogoutPage() {
|
||||
useEffect(() => {
|
||||
clearHarnessCache();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.pathname = "/auth/login";
|
||||
}, 1000);
|
||||
|
||||
Reference in New Issue
Block a user