Studio frontend: reduce and tighten code comments (#6099)
Trim and tighten code comments across studio/frontend TS/JS. Comment-only: every changed file verified code-identical to main via the TypeScript printer signature comparison.
This commit is contained in:
parent
187144d4e7
commit
85314ed162
130 changed files with 1553 additions and 2141 deletions
|
|
@ -48,11 +48,10 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
|
|||
|
||||
const win = getCurrentWindow();
|
||||
// Decide first-launch vs restore from the on-disk state file BEFORE touching the
|
||||
// window. Probing the window itself after restoreStateCurrent is unreliable:
|
||||
// on GTK, set_size against a hidden window is deferred until show(), so
|
||||
// innerSize() reads a stale value and any baseline fallback would overwrite the
|
||||
// queued restore. On macOS the same probe works, hence the inconsistency
|
||||
// between previous iterations of this code.
|
||||
// window. Probing the window after restoreStateCurrent is unreliable: on GTK,
|
||||
// set_size on a hidden window is deferred until show(), so innerSize() reads a
|
||||
// stale value and a baseline fallback would overwrite the queued restore. On
|
||||
// macOS the same probe works, hence the inconsistency between prior iterations.
|
||||
const hasSavedState = await invoke<boolean>("has_saved_window_state");
|
||||
if (!isCurrent()) return;
|
||||
|
||||
|
|
@ -60,9 +59,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
|
|||
if (!isCurrent()) return;
|
||||
|
||||
if (hasSavedState) {
|
||||
// Subsequent launch: the plugin handles size, position, and maximized,
|
||||
// with built-in off-screen protection (monitor-intersection check) for
|
||||
// positions saved on a now-disconnected display.
|
||||
// Subsequent launch: plugin restores size/position/maximized, with built-in
|
||||
// off-screen protection for positions saved on a now-disconnected display.
|
||||
await restoreStateCurrent(
|
||||
StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED,
|
||||
);
|
||||
|
|
@ -87,8 +85,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
|
|||
if (!isCurrent()) return;
|
||||
await win.show();
|
||||
if (!isCurrent()) return;
|
||||
// Apply constraints after restore/show. Setting constraints before plugin restore
|
||||
// can emit a Resized event and overwrite the plugin's cached saved size.
|
||||
// Apply constraints after restore/show: doing so before plugin restore can emit
|
||||
// a Resized event and overwrite the plugin's cached saved size.
|
||||
await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ function isChatOnlyAllowed(pathname: string): boolean {
|
|||
|
||||
export const Route = createRootRoute({
|
||||
beforeLoad: async ({ location }) => {
|
||||
// Ensure platform info is fetched before checking chat-only guard.
|
||||
// fetchDeviceType caches after first call, so subsequent navigations are instant.
|
||||
// Fetch platform info before the chat-only guard. fetchDeviceType caches,
|
||||
// so later navigations are instant.
|
||||
await fetchDeviceType();
|
||||
const chatOnly = usePlatformStore.getState().isChatOnly();
|
||||
if (chatOnly && !isChatOnlyAllowed(location.pathname)) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { useSettingsDialogStore } from "@/features/settings";
|
|||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
// /settings is a deep link to the modal. Open it, then redirect home.
|
||||
// Tab title is driven by useSettingsDialogStore in __root.tsx since the
|
||||
// redirect means /settings never stays matched; staticData is just a
|
||||
// safety net if beforeLoad ever stops throwing.
|
||||
// /settings deep-links the modal: open it, then redirect home. Tab title is
|
||||
// driven by useSettingsDialogStore in __root.tsx since the redirect means
|
||||
// /settings never stays matched; staticData is a safety net if beforeLoad
|
||||
// ever stops throwing.
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/settings",
|
||||
|
|
|
|||
|
|
@ -138,11 +138,8 @@ function getTourId(pathname: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4
|
||||
// and #5 of the 5-path definition). Slicing to the first three paths
|
||||
// keeps the test-tube outline + horizontal cap + liquid line, dropping
|
||||
// the bubbles. The original export stays untouched, and HugeiconsIcon
|
||||
// renders this trimmed array exactly the same way.
|
||||
// TestTube01Icon's last 2 paths are interior bubbles; slice to the first
|
||||
// 3 (outline + cap + liquid line) to drop them. Original export untouched.
|
||||
const TestTubeOutlineIcon = TestTube01Icon.slice(
|
||||
0,
|
||||
3,
|
||||
|
|
@ -261,11 +258,11 @@ export function AppSidebar() {
|
|||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
// Bottom fade hides at the very bottom (and for short, non-scrolling lists)
|
||||
// so the last row isn't washed out - Gemini-style.
|
||||
// Bottom fade hides at the very bottom / for short lists so the last row
|
||||
// isn't washed out (Gemini-style).
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
// Driven only from onScroll + a content-change effect below. Deliberately NO
|
||||
// ResizeObserver: its callback-driven setState created a render loop (React
|
||||
// Driven only from onScroll + a content-change effect below. No
|
||||
// ResizeObserver: its callback-driven setState caused a render loop (React
|
||||
// #185). Both setters bail out when unchanged, so neither path can loop.
|
||||
const syncScrollState = (el: HTMLDivElement) => {
|
||||
const nextScrolled = el.scrollTop > 0;
|
||||
|
|
@ -311,10 +308,9 @@ export function AppSidebar() {
|
|||
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
||||
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
||||
|
||||
// Recompute the bottom-fade state on mount and whenever the list height can
|
||||
// change (items load, sections collapse/expand, route switches the visible
|
||||
// list) - onScroll never fires for short, non-scrolling lists. Guarded
|
||||
// setState below means this can't loop even if a dep is a fresh reference.
|
||||
// Recompute bottom-fade on mount and whenever list height can change
|
||||
// (items load, sections toggle, route switch) - onScroll never fires for
|
||||
// short, non-scrolling lists. Guarded setState below can't loop.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
|
@ -761,9 +757,8 @@ export function AppSidebar() {
|
|||
label={t("shell.navigation.search")}
|
||||
active={false}
|
||||
onClick={() => {
|
||||
// Search is read-only over chat history and never runs
|
||||
// inference, so it stays available while training (unlike
|
||||
// New chat, which is gated on `chatDisabled`).
|
||||
// Search is read-only and never runs inference, so it stays
|
||||
// available while training (unlike New chat, gated on chatDisabled).
|
||||
useChatSearchStore.getState().open();
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
|
|
@ -796,8 +791,7 @@ export function AppSidebar() {
|
|||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
{/* Train has its own labelled section when expanded; surface it as
|
||||
a plain icon here only while the sidebar is collapsed. */}
|
||||
{/* Train has a labelled section when expanded; plain icon here only when collapsed. */}
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label={t("shell.navigation.train")}
|
||||
|
|
@ -899,12 +893,10 @@ export function AppSidebar() {
|
|||
<SidebarGroupContent className="px-2">
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
// An explicit sidebar selection wins. Otherwise highlight
|
||||
// the active job only while the "Current Run" tab is the
|
||||
// view - that covers a live run (it auto-switches there) and
|
||||
// a just-finished/errored run you're still viewing, while
|
||||
// keeping the Configure tab unhighlighted even though
|
||||
// `activeJobId` stays pinned to the last job.
|
||||
// Explicit selection wins. Otherwise highlight the active
|
||||
// job only while the "Current Run" tab is the view, keeping
|
||||
// the Configure tab unhighlighted even though activeJobId
|
||||
// stays pinned to the last job.
|
||||
const isActiveRun =
|
||||
selectedHistoryRunId != null
|
||||
? run.id === selectedHistoryRunId
|
||||
|
|
@ -988,10 +980,9 @@ export function AppSidebar() {
|
|||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
|
||||
{/* Fade above the profile box, shown only while there's more list below
|
||||
the fold; at the very bottom (or for short lists) it fades out so the
|
||||
last row shows fully (Gemini-style). `right-2` keeps it clear of the
|
||||
8px scrollbar gutter so the scrollbar isn't faded out. */}
|
||||
{/* Fade above the profile box, shown only when there's more list below
|
||||
the fold; at the bottom (or short lists) it fades so the last row
|
||||
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
|
|
@ -1082,8 +1073,8 @@ export function AppSidebar() {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
// Best-effort server-side revocation; ignore network errors
|
||||
// so the local clear path still runs and the user lands on /login.
|
||||
// Best-effort server revocation; ignore network errors so
|
||||
// the local clear still runs and the user lands on /login.
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ const AttachmentUI: FC = () => {
|
|||
throw new Error(`Unknown attachment type: ${type as string}`);
|
||||
}
|
||||
});
|
||||
// Include filename in accessible name so screen readers distinguish
|
||||
// same-typed attachments. Sighted users get it via the tooltip.
|
||||
// Filename in accessible name lets screen readers distinguish same-typed
|
||||
// attachments. Sighted users get it via the tooltip.
|
||||
const accessibleName = name
|
||||
? `${typeLabel} attachment: ${name}`
|
||||
: `${typeLabel} attachment`;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import {
|
|||
} from "@streamdown/code";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
|
||||
// Fence tags LLMs/users commonly write that shiki doesn't expose as aliases.
|
||||
// Keys are lower-cased input; values are canonical shiki language ids.
|
||||
// Common fence tags shiki doesn't expose as aliases.
|
||||
// Keys: lower-cased input; values: canonical shiki language ids.
|
||||
const LANGUAGE_ALIAS_OVERRIDES: Record<string, BundledLanguage> = {
|
||||
objectivec: "objective-c",
|
||||
"obj-c": "objective-c",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,10 @@ import oneDarkPro from "@shikijs/themes/one-dark-pro";
|
|||
import oneLight from "@shikijs/themes/one-light";
|
||||
import type { ThemeRegistrationAny } from "shiki";
|
||||
|
||||
// Canonical Atom One Dark / One Light themes, shipped by `@shikijs/themes`.
|
||||
// We only override the background so the code block blends into the app's
|
||||
// `--code-block` surface instead of painting its own. Every token color and
|
||||
// scope mapping is left intact — that's what gives consistent multi-language
|
||||
// highlighting (including Objective-C, Go, Rust, etc.) out of the box.
|
||||
// Canonical Atom One Dark / One Light themes from `@shikijs/themes`. Only the
|
||||
// background is overridden so the code block blends into the app's `--code-block`
|
||||
// surface; all token colors/scopes are kept intact for consistent multi-language
|
||||
// highlighting out of the box.
|
||||
const withTransparentBg = (theme: ThemeRegistrationAny): ThemeRegistrationAny => ({
|
||||
...theme,
|
||||
bg: "transparent",
|
||||
|
|
|
|||
|
|
@ -378,10 +378,7 @@ function ImageContentFilterError({
|
|||
|
||||
export type ImageActionsProps = {
|
||||
part: ImageMessagePart;
|
||||
/**
|
||||
* Wire to your own generation call to show a regenerate button. The button
|
||||
* renders only when this is set and the part carries a `prompt`.
|
||||
*/
|
||||
/** Shows a regenerate button (only when set and the part has a `prompt`). */
|
||||
onRegenerate?: () => void | Promise<void>;
|
||||
className?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -157,8 +157,7 @@ const UNSAFE_SVG_RE =
|
|||
|
||||
function sanitizeSvg(source: string): string | null {
|
||||
if (UNSAFE_SVG_RE.test(source)) return null;
|
||||
// Strip XML declaration (<?xml ...?>) -- not needed for data URI
|
||||
// rendering and can cause issues with some renderers.
|
||||
// Strip XML declaration: unneeded for data URIs and breaks some renderers.
|
||||
return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
|
||||
}
|
||||
|
||||
|
|
@ -381,11 +380,10 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
|
||||
|
||||
// Coalesce markdown re-parses to one per animation frame while streaming: the
|
||||
// runtime notifies on every token (hundreds/sec) and the monitor can't paint
|
||||
// that fast. When not streaming we return live text rather than the throttled
|
||||
// state, so the final text never lags and a reused instance (parts are keyed by
|
||||
// index) shows a completed message's text immediately instead of a stale frame.
|
||||
// Coalesce markdown re-parses to one per frame while streaming: tokens arrive
|
||||
// hundreds/sec, faster than the monitor can paint. When not streaming we return
|
||||
// live text (not the throttled state) so final text never lags and a reused
|
||||
// instance (parts keyed by index) shows completed text instead of a stale frame.
|
||||
function useRafCoalescedText(text: string, isStreaming: boolean): string {
|
||||
const [displayed, setDisplayed] = useState(text);
|
||||
const pendingRef = useRef(text);
|
||||
|
|
@ -408,9 +406,9 @@ function useRafCoalescedText(text: string, isStreaming: boolean): string {
|
|||
}
|
||||
}, [text, isStreaming]);
|
||||
|
||||
// Unmount cleanup. Cancel the in-flight rAF and null the handle so a
|
||||
// StrictMode remount isn't gated out by a stale id. Kept separate from the
|
||||
// scheduling effect so it doesn't cancel mid-stream and defeat the throttle.
|
||||
// Unmount cleanup: cancel the in-flight rAF and null the handle so a
|
||||
// StrictMode remount isn't gated by a stale id. Separate from the scheduling
|
||||
// effect so it doesn't cancel mid-stream and defeat the throttle.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
|
|
|
|||
|
|
@ -52,10 +52,9 @@ export const MessageTiming: FC<{
|
|||
// Anthropic-only cache-write count.
|
||||
const cacheWrites = custom?.contextUsage?.cacheWriteTokens ?? 0;
|
||||
|
||||
// Guard unphysical tok/s: llama.cpp emits predicted_ms=0 on no-op
|
||||
// turns, blowing the rate up to Infinity. Require >=1 token AND a
|
||||
// non-zero decode window AND a finite rate. Fast cached single-token
|
||||
// responses (sub-10ms) are legitimate and must stay visible.
|
||||
// Guard unphysical tok/s: llama.cpp emits predicted_ms=0 on no-op turns,
|
||||
// blowing the rate up to Infinity. Require >=1 token, a non-zero decode
|
||||
// window, and a finite rate. Fast cached sub-10ms responses are legit.
|
||||
const hasPredicted =
|
||||
(st?.predicted_n ?? 0) >= 1 && (st?.predicted_ms ?? 0) > 0;
|
||||
const predictedRate =
|
||||
|
|
|
|||
|
|
@ -32,12 +32,9 @@ export interface FolderBrowserProps {
|
|||
|
||||
function splitBreadcrumb(path: string): { label: string; value: string }[] {
|
||||
if (!path) return [];
|
||||
// Distinguish path styles BEFORE normalizing separators. On POSIX
|
||||
// backslashes are valid filename characters, so we cannot blindly
|
||||
// rewrite ``\`` -> ``/`` -- doing so would mangle directory names
|
||||
// like ``my\backup`` into ``my/backup`` and produce breadcrumb
|
||||
// values that 404 on the server. Only Windows-style absolute paths
|
||||
// (drive letter, or UNC ``\\server\share``) get the conversion.
|
||||
// Detect path style BEFORE normalizing: on POSIX, `\` is a valid filename
|
||||
// char, so blindly rewriting `\` -> `/` mangles names like `my\backup` into
|
||||
// 404ing breadcrumbs. Only Windows-style paths (drive letter, or UNC) convert.
|
||||
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
|
||||
const isUnc = /^\\\\/.test(path);
|
||||
const isWindows = isWindowsDrive || isUnc;
|
||||
|
|
@ -57,11 +54,9 @@ function splitBreadcrumb(path: string): { label: string; value: string }[] {
|
|||
return parts;
|
||||
}
|
||||
|
||||
// Windows-ish drive path (C:, D:): first segment is the drive. Use
|
||||
// ``C:/`` (drive-absolute) as the crumb value so clicking the drive
|
||||
// root navigates to the root of the drive rather than the
|
||||
// drive-relative current working directory on that drive (``C:``
|
||||
// alone resolves to ``CWD-on-C``, not ``C:\``).
|
||||
// Windows drive path (C:, D:): first segment is the drive. Use `C:/` as the
|
||||
// crumb value so clicking the drive root navigates to the drive root, not the
|
||||
// drive-relative CWD (`C:` alone resolves to CWD-on-C, not `C:\`).
|
||||
if (/^[A-Za-z]:$/.test(segments[0])) {
|
||||
const driveRoot = `${segments[0]}/`;
|
||||
let cur = driveRoot;
|
||||
|
|
@ -102,8 +97,8 @@ export function FolderBrowser({
|
|||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Forward the signal so cancelled navigation actually cancels the
|
||||
// backend enumeration instead of just discarding the response.
|
||||
// Forward the signal so cancelled navigation aborts the backend
|
||||
// enumeration, not just the response.
|
||||
browseFolders(target, hidden, ctrl.signal)
|
||||
.then((res) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
|
@ -112,17 +107,13 @@ export function FolderBrowser({
|
|||
})
|
||||
.catch((err) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Surface the error, but if the very first request (typically
|
||||
// a typo'd or denylisted ``initialPath``) fails AND the
|
||||
// browser is empty (no ``data`` to render against), fall
|
||||
// back to the user's HOME so the modal is navigable instead
|
||||
// of an irrecoverable dead end.
|
||||
// Surface the error; if the first request (e.g. a bad initialPath)
|
||||
// fails, fall back to HOME so the modal stays navigable.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
if (opts?.fallbackOnError && target !== undefined) {
|
||||
// Re-issue without a target -> backend defaults to HOME.
|
||||
// Don't recurse if HOME itself fails (paranoia: shouldn't
|
||||
// happen since the sandbox allowlist always includes HOME).
|
||||
// Don't recurse if HOME itself fails (allowlist always has HOME).
|
||||
queueMicrotask(() => navigate(undefined, hidden));
|
||||
}
|
||||
})
|
||||
|
|
@ -133,15 +124,13 @@ export function FolderBrowser({
|
|||
[],
|
||||
);
|
||||
|
||||
// Fetch when the dialog opens. Only re-run when the dialog transitions
|
||||
// closed -> open; subsequent navigation is driven by `navigate()` so we
|
||||
// don't want `path` in the dependency list here.
|
||||
// Fetch only on closed -> open; later navigation is driven by `navigate()`,
|
||||
// so `path` is deliberately kept out of the dependency list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// ``fallbackOnError``: if the user-supplied ``initialPath`` is bad
|
||||
// (typo, denylisted, deleted) we recover into HOME instead of
|
||||
// showing an empty modal with no breadcrumbs/entries.
|
||||
// fallbackOnError: recover into HOME if initialPath is bad, rather than
|
||||
// showing an empty modal.
|
||||
navigate(initialPath, showHidden, { fallbackOnError: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ function dedupe(values: string[]): string[] {
|
|||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
/** Normalize a string for fuzzy search: lowercase, strip separators. */
|
||||
/** Lowercase and strip separators for fuzzy search. */
|
||||
function normalizeForSearch(s: string): string {
|
||||
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
|
||||
}
|
||||
|
|
@ -296,14 +296,14 @@ function GgufVariantExpander({
|
|||
[gpuGb, gpuBudgetGb, totalBudgetGb],
|
||||
);
|
||||
|
||||
// If the backend-recommended variant is OOM, pick the largest fitting
|
||||
// variant instead; if all are OOM, recommend the smallest one.
|
||||
// If the recommended variant is OOM, pick the largest fitting one;
|
||||
// if all are OOM, recommend the smallest.
|
||||
const effectiveRecommended = useMemo(() => {
|
||||
if (!variants || !gpuGb || gpuGb <= 0) return defaultVariant;
|
||||
const defaultV = variants.find((v) => v.quant === defaultVariant);
|
||||
if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
|
||||
return defaultVariant;
|
||||
// Default is OOM -- pick largest non-OOM variant (best quality that fits)
|
||||
// Largest non-OOM variant (best quality that fits)
|
||||
const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
|
||||
if (fitting.length > 0) {
|
||||
fitting.sort((a, b) => b.size_bytes - a.size_bytes);
|
||||
|
|
@ -460,8 +460,8 @@ function isGgufRepo(id: string, hintedIsGguf?: boolean): boolean {
|
|||
|
||||
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
|
||||
function extractParamLabel(id: string): string | undefined {
|
||||
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
|
||||
const name = id.split("/").pop() ?? id;
|
||||
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
|
||||
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
|
||||
return match ? `${match[1]}B` : undefined;
|
||||
}
|
||||
|
|
@ -504,11 +504,10 @@ export function HubModelPicker({
|
|||
const { results, isLoading, isLoadingMore, fetchMore } =
|
||||
useHfModelSearch(debouncedQuery);
|
||||
|
||||
// Sets of lowercased repo ids that the store or HF search have
|
||||
// confirmed are GGUF. Absence means "no hint" and lets hasGgufSuffix
|
||||
// take over as fallback, rather than conflating unknown with known-
|
||||
// not-GGUF. Keys are lowercased so that store IDs and HF search IDs
|
||||
// that differ only by casing still match the same hint.
|
||||
// Lowercased repo ids confirmed GGUF by the store or HF search.
|
||||
// Absence means "no hint" -> hasGgufSuffix is the fallback (don't
|
||||
// conflate unknown with known-not-GGUF). Lowercased so store and HF
|
||||
// IDs differing only by casing match the same hint.
|
||||
const modelGgufIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const model of models) {
|
||||
|
|
@ -538,8 +537,8 @@ export function HubModelPicker({
|
|||
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
|
||||
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
|
||||
|
||||
// Cached (already downloaded) repos -- use module-level cache so
|
||||
// re-mounting the popover does not flash an empty "Downloaded" section.
|
||||
// Cached (downloaded) repos -- module-level cache avoids flashing an
|
||||
// empty "Downloaded" section when the popover re-mounts.
|
||||
const [cachedGguf, setCachedGguf] =
|
||||
useState<CachedGgufRepo[]>(_cachedGgufCache);
|
||||
const [cachedModels, setCachedModels] =
|
||||
|
|
@ -548,8 +547,7 @@ export function HubModelPicker({
|
|||
_cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
|
||||
const [cachedReady, setCachedReady] = useState(alreadyCached);
|
||||
|
||||
// LM Studio local models -- module-level cache so re-mounting the
|
||||
// popover does not flash an empty section (same pattern as GGUF/models).
|
||||
// LM Studio local models -- module-level cache, same pattern as above.
|
||||
const [lmStudioModels, setLmStudioModels] =
|
||||
useState<LocalModelInfo[]>(_lmStudioCache);
|
||||
const [customFolderModels, setCustomFolderModels] =
|
||||
|
|
@ -589,24 +587,20 @@ export function HubModelPicker({
|
|||
}, []);
|
||||
|
||||
const handleAddFolder = useCallback(async (overridePath?: string) => {
|
||||
// Accept an explicit path so the folder browser can submit the
|
||||
// chosen path in the same tick it calls `setFolderInput`; reading
|
||||
// `folderInput` alone would race the state update.
|
||||
// Explicit path lets the folder browser submit in the same tick it
|
||||
// calls `setFolderInput`; reading `folderInput` would race the update.
|
||||
const raw = overridePath !== undefined ? overridePath : folderInput;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || folderLoading) return;
|
||||
setFolderError(null);
|
||||
setFolderLoading(true);
|
||||
// True when the request originated from the folder browser's
|
||||
// ``onSelect`` (one-click "Use this folder"). In that flow the
|
||||
// typed-input panel is closed, so the inline ``folderError``
|
||||
// paragraph is invisible. Surface failures via toast instead so
|
||||
// the action doesn't appear to silently no-op when the backend
|
||||
// rejects (denylisted path, sandbox 403, etc.).
|
||||
// From the folder browser's one-click "Use this folder": the typed-
|
||||
// input panel is closed, so the inline folderError is invisible.
|
||||
// Surface failures (denylisted path, sandbox 403, etc.) via toast.
|
||||
const fromBrowser = overridePath !== undefined;
|
||||
try {
|
||||
const created = await addScanFolder(trimmed);
|
||||
// Backend returns existing row for duplicates, so deduplicate
|
||||
// Backend returns the existing row for duplicates, so dedupe.
|
||||
const next = _scanFoldersCache.some((f) => f.id === created.id || f.path === created.path)
|
||||
? _scanFoldersCache
|
||||
: [..._scanFoldersCache, created];
|
||||
|
|
@ -632,7 +626,7 @@ export function HubModelPicker({
|
|||
const handleRemoveFolder = useCallback(async (id: number) => {
|
||||
try {
|
||||
await removeScanFolder(id);
|
||||
// Optimistic update so the folder disappears immediately
|
||||
// Optimistic: drop it immediately.
|
||||
const next = _scanFoldersCache.filter((f) => f.id !== id);
|
||||
_scanFoldersCache = next;
|
||||
setScanFolders(next);
|
||||
|
|
@ -662,18 +656,17 @@ export function HubModelPicker({
|
|||
}, [refreshLocalModelsList]);
|
||||
|
||||
useEffect(() => {
|
||||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
|
||||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached).
|
||||
refreshLocalModelsList();
|
||||
refreshScanFolders();
|
||||
listRecommendedFolders()
|
||||
.then(setRecommendedFolders)
|
||||
.catch(() => {});
|
||||
|
||||
// Always refetch cached GGUF/model lists. The module-level caches give
|
||||
// an instant render with stale data (no spinner flash), but newly
|
||||
// downloaded repos won't appear unless we re-hit the backend on every
|
||||
// mount. Initial state already has cachedReady=alreadyCached, so the
|
||||
// background refresh is invisible when we already had data.
|
||||
// Always refetch cached GGUF/model lists. The module-level caches render
|
||||
// instantly with stale data (no spinner flash), but newly downloaded
|
||||
// repos need a fresh backend hit. cachedReady=alreadyCached initially,
|
||||
// so the background refresh is invisible when we already had data.
|
||||
let done = 0;
|
||||
const check = () => {
|
||||
if (++done >= 2) setCachedReady(true);
|
||||
|
|
@ -694,8 +687,8 @@ export function HubModelPicker({
|
|||
.finally(check);
|
||||
}, [refreshLocalModelsList, refreshScanFolders]);
|
||||
|
||||
// Deduplicate: don't show downloaded models in the recommended list.
|
||||
// Compare case-insensitively since HF cache lowercases repo IDs.
|
||||
// Hide downloaded models from the recommended list. Case-insensitive
|
||||
// since the HF cache lowercases repo IDs.
|
||||
const downloadedSet = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const c of cachedGguf) s.add(c.repo_id.toLowerCase());
|
||||
|
|
@ -756,10 +749,9 @@ export function HubModelPicker({
|
|||
return recommendedIds.filter((id) => normalizeForSearch(id).includes(q));
|
||||
}, [showHfSection, debouncedQuery, recommendedIds]);
|
||||
|
||||
// Fetch VRAM info for visible models, plus any models surfaced by a search
|
||||
// query so that filtered recommended models also show VRAM badges.
|
||||
// Skip GGUF repos: they have no safetensors metadata and the render layer
|
||||
// already shows a static "GGUF" badge instead of VRAM data.
|
||||
// VRAM info for visible models plus any surfaced by a search query, so
|
||||
// filtered recommended models also show VRAM badges. Skip GGUF repos:
|
||||
// no safetensors metadata, and the render layer shows a "GGUF" badge.
|
||||
const idsForVram = useMemo(() => {
|
||||
const ids = showHfSection
|
||||
? [...new Set([...visibleRecommendedIds, ...filteredRecommendedIds])]
|
||||
|
|
@ -851,9 +843,8 @@ export function HubModelPicker({
|
|||
);
|
||||
|
||||
// Sentinel + IntersectionObserver for recommended infinite scroll.
|
||||
// We disconnect after each fire so the observer doesn't loop while
|
||||
// React re-renders; the effect re-creates it on the next page.
|
||||
// Uses a callback ref for the sentinel so we detect mount/unmount reliably.
|
||||
// Disconnect after each fire so it doesn't loop during re-render; the
|
||||
// effect re-creates it next page. Callback ref detects mount/unmount.
|
||||
const [recommendedSentinel, setRecommendedSentinel] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => {
|
||||
|
|
@ -872,7 +863,7 @@ export function HubModelPicker({
|
|||
},
|
||||
{ threshold: 0, root },
|
||||
);
|
||||
// Small delay so the browser finishes layout after the previous page render
|
||||
// Small delay so layout settles after the previous page render.
|
||||
const timer = setTimeout(() => obs.observe(recommendedSentinel), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
|
|
@ -1184,9 +1175,8 @@ export function HubModelPicker({
|
|||
onSelect={(picked) => {
|
||||
setFolderInput(picked);
|
||||
setFolderError(null);
|
||||
// One-click UX: the "Use this folder" button submits
|
||||
// the scan folder directly. Pass the path explicitly
|
||||
// because `folderInput` state hasn't flushed yet.
|
||||
// Pass the path explicitly: `folderInput` state hasn't
|
||||
// flushed yet when "Use this folder" submits.
|
||||
void handleAddFolder(picked);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -358,18 +358,18 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
|||
}
|
||||
}, [isReasoningStreaming]);
|
||||
|
||||
// Reset dismissed flag when a new stream starts
|
||||
// Reset dismissed flag on new stream.
|
||||
useEffect(() => {
|
||||
if (isReasoningStreaming) {
|
||||
setDismissedWhileStreaming(false);
|
||||
}
|
||||
}, [isReasoningStreaming]);
|
||||
|
||||
// Derived: open during streaming (unless dismissed), or if user manually opened after
|
||||
// Open while streaming (unless dismissed), or once manually opened.
|
||||
const isOpen = (isReasoningStreaming && !dismissedWhileStreaming) || manualOpen;
|
||||
const variant = isOpen ? "outline" : "ghost";
|
||||
|
||||
// Allow closing during streaming (matches ChatGPT)
|
||||
// Allow closing during streaming (matches ChatGPT).
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (isReasoningStreaming) {
|
||||
|
|
|
|||
|
|
@ -128,9 +128,8 @@ function Source({
|
|||
|
||||
interface SourceData {
|
||||
/**
|
||||
* Stable per-citation key. Two Anthropic document citations into
|
||||
* different spans of the same source share a ``url``, so React keys
|
||||
* on ``id`` to keep each footnote distinct.
|
||||
* Stable per-citation key. Two Anthropic citations into different spans of
|
||||
* the same source share a `url`, so React keys on `id` to keep them distinct.
|
||||
*/
|
||||
id: string;
|
||||
url: string;
|
||||
|
|
@ -185,7 +184,6 @@ const SourcesGroup: FC = () => {
|
|||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Extract source parts from the message
|
||||
const sources: SourceData[] = [];
|
||||
if (message.content) {
|
||||
for (const part of message.content) {
|
||||
|
|
@ -220,7 +218,6 @@ const SourcesGroup: FC = () => {
|
|||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) return;
|
||||
|
||||
// Find the top of the first child as baseline
|
||||
const firstTop = children[0].offsetTop;
|
||||
let rowCount = 1;
|
||||
let prevTop = firstTop;
|
||||
|
|
@ -263,17 +260,12 @@ const SourcesGroup: FC = () => {
|
|||
|
||||
return (
|
||||
<div className="relative mt-2 mb-3">
|
||||
{/* Hidden measurement container. Renders all badges off-screen so we
|
||||
can read each child's offsetTop and decide how many fit in two
|
||||
rows. Wrapped in an absolute, h-0, overflow-hidden box so the
|
||||
measurement pills do NOT contribute to the viewport's scrollable
|
||||
overflow region. Without this clip, every hidden source row
|
||||
adds ~30px to scrollHeight, producing a phantom empty scroll
|
||||
area below the message: visible to users as unbounded blank
|
||||
space below the assistant action bar. The inner div still
|
||||
flex-wraps its children for measurement; offsetTop reads
|
||||
correctly because the wrapper is positioned (absolute) and the
|
||||
children's offsetTop is measured relative to it. */}
|
||||
{/* Hidden measurement container: renders all badges off-screen to read
|
||||
each child's offsetTop and decide how many fit in two rows. The
|
||||
absolute/h-0/overflow-hidden wrapper clips the pills so they don't add
|
||||
to scrollHeight (~30px per row) and create a phantom empty scroll area
|
||||
below the message. offsetTop reads correctly because the wrapper is
|
||||
positioned (absolute) and the flex-wrapped children measure against it. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute pointer-events-none overflow-hidden h-0 w-full left-0 top-0"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Shared aria-labels for the Think pill (toggle + effort dropdown) so both
|
||||
// controls use the same pre-load / unsupported-model wording.
|
||||
// Shared aria-labels for the Think pill toggle + effort dropdown, so both
|
||||
// use the same pre-load / unsupported-model wording.
|
||||
|
||||
export interface ThinkToggleState {
|
||||
reasoningLockedOn: boolean;
|
||||
|
|
@ -27,8 +27,7 @@ export interface ThinkEffortState {
|
|||
reasoningEffort: string;
|
||||
}
|
||||
|
||||
// Locked-on isn't special-cased: the effort dropdown stays interactive
|
||||
// (users can still pick an effort level).
|
||||
// Locked-on isn't special-cased: the effort dropdown stays interactive.
|
||||
export function thinkEffortAriaLabel(state: ThinkEffortState): string {
|
||||
if (!state.modelLoaded) return "Thinking (model not loaded)";
|
||||
if (state.reasoningDisabled)
|
||||
|
|
|
|||
|
|
@ -124,20 +124,18 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
|
||||
// True while a file is dragged anywhere over the chat page (not just the
|
||||
// composer), so the composer can show its "Drop files here" affordance.
|
||||
// True while a file is dragged anywhere over the chat page, so the composer
|
||||
// can show its "Drop files here" affordance.
|
||||
const PageDragContext = createContext(false);
|
||||
|
||||
// Gap (px) between the last message and the floating composer. The bottom
|
||||
// spacer tracks composer height plus this gap so the chat can always be
|
||||
// scrolled fully above the composer.
|
||||
// Gap (px) between last message and floating composer; bottom spacer tracks
|
||||
// composer height plus this gap so chat can scroll fully above the composer.
|
||||
const COMPOSER_SCROLL_GAP_PX = 24;
|
||||
// The scroll-to-bottom footer sits 10px below the spacer top.
|
||||
const FOOTER_GAP_BELOW_SPACER_PX = 10;
|
||||
// Composer shrinks this soon after a run start (send clears the chips)
|
||||
// apply immediately: the run-start pin owns the bottom, so the clamp is
|
||||
// the intended glide. Covers instant responses where isRunning is
|
||||
// already false by the time the dock resize is observed.
|
||||
// Window after a run start during which composer shrinks apply immediately:
|
||||
// the run-start pin owns the bottom, so the clamp is the intended glide.
|
||||
// Covers instant responses where isRunning is already false by resize time.
|
||||
const RUN_SHRINK_WINDOW_MS = 1000;
|
||||
|
||||
export const Thread: FC<{
|
||||
|
|
@ -145,10 +143,9 @@ export const Thread: FC<{
|
|||
hideWelcome?: boolean;
|
||||
targetThreadId?: string;
|
||||
}> = ({ hideComposer, hideWelcome, targetThreadId }) => {
|
||||
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
|
||||
// to prevent the streaming-mutation race that makes the viewport snap
|
||||
// back to the bottom while the user is scrolling up (see the hook for
|
||||
// the full explanation).
|
||||
// Intent-aware autoscroll replaces assistant-ui's built-in autoscroll to
|
||||
// prevent the streaming-mutation race that snaps the viewport back to the
|
||||
// bottom while the user scrolls up (see the hook for the full explanation).
|
||||
const { ref: viewportRef, context: autoScrollContext } =
|
||||
useIntentAwareAutoScroll();
|
||||
|
||||
|
|
@ -167,10 +164,9 @@ export const Thread: FC<{
|
|||
? null
|
||||
: composerHeight + COMPOSER_SCROLL_GAP_PX - FOOTER_GAP_BELOW_SPACER_PX;
|
||||
|
||||
// The viewport element is owned by the autoscroll hook; mirror it
|
||||
// locally for the spacer clamp math below. State, not a ref: the keyed
|
||||
// provider below remounts the viewport on thread switches, and the
|
||||
// scroll listener effect must re-attach to the new element.
|
||||
// Viewport element is owned by the autoscroll hook; mirror it locally for
|
||||
// the spacer clamp math. State, not a ref: the keyed provider remounts the
|
||||
// viewport on thread switches and the scroll listener must re-attach.
|
||||
const [viewportEl, setViewportEl] = useState<HTMLElement | null>(null);
|
||||
const composedViewportRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
|
|
@ -180,13 +176,13 @@ export const Thread: FC<{
|
|||
[viewportRef],
|
||||
);
|
||||
|
||||
// Bottom spacer sizing. Invariant: the chat never moves on its own when
|
||||
// the composer resizes.
|
||||
// - Grow (attachment added, multiline input): grow the spacer at once.
|
||||
// Growth below the scroll position is invisible and only adds room.
|
||||
// Bottom spacer sizing. Invariant: chat never moves on its own on composer
|
||||
// resize.
|
||||
// - Grow (attachment added, multiline): grow at once; growth below the
|
||||
// scroll position is invisible and only adds room.
|
||||
// - Shrink (attachment removed): shrinking scrollHeight near the bottom
|
||||
// would clamp scrollTop and yank the chat down. Defer the shrink until
|
||||
// it is invisible (user scrolled up) or a bottom-pinning moment.
|
||||
// clamps scrollTop and yanks the chat down. Defer until invisible (user
|
||||
// scrolled up) or a bottom-pinning moment.
|
||||
// Applied imperatively so a remounted spacer can be sized from refs even
|
||||
// when composerHeight did not change (e.g. thread switch).
|
||||
const spacerElRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -214,8 +210,8 @@ export const Thread: FC<{
|
|||
const spacerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
spacerElRef.current = node;
|
||||
// Fresh mounts (thread switch, first message) start at the desired
|
||||
// size; deferral state from a previous mount is moot.
|
||||
// Fresh mounts (thread switch, first message) start at desired size;
|
||||
// deferral state from a previous mount is moot.
|
||||
const desired = desiredSpacerPxRef.current;
|
||||
if (node && desired != null) {
|
||||
applySpacerPx(desired);
|
||||
|
|
@ -249,7 +245,7 @@ export const Thread: FC<{
|
|||
aui.thread().getState().isRunning ||
|
||||
performance.now() - runStartAtRef.current < RUN_SHRINK_WINDOW_MS;
|
||||
// At the bottom the shrink only drops blank spacer, so apply it now
|
||||
// instead of stranding dead space until the next pin.
|
||||
// rather than strand dead space until the next pin.
|
||||
if (
|
||||
runOwnsBottom ||
|
||||
distance >= applied - desired ||
|
||||
|
|
@ -260,20 +256,20 @@ export const Thread: FC<{
|
|||
// else: deferred; released on scroll or a bottom-pinning event.
|
||||
}
|
||||
if (prev != null && composerHeight > prev) {
|
||||
// The chat is now above the new bottom. Detach as if the user had
|
||||
// scrolled up so no later signal re-pins and shoves the chat up.
|
||||
// Scrolling back down re-attaches; explicit pins still work.
|
||||
// Mid-run growth comes from tool-status rows, not the user, and
|
||||
// detaching then would break streaming autoscroll, so skip it.
|
||||
// Chat is now above the new bottom. Detach as if the user scrolled up
|
||||
// so no later signal re-pins and shoves the chat up (scrolling back
|
||||
// down re-attaches; explicit pins still work). Skip mid-run: that
|
||||
// growth is tool-status rows, not the user, and detaching would break
|
||||
// streaming autoscroll.
|
||||
if (!aui.thread().getState().isRunning) {
|
||||
autoScrollContext.detachFromBottom();
|
||||
}
|
||||
}
|
||||
}, [composerHeight, hideComposer, autoScrollContext, aui, applySpacerPx, viewportEl]);
|
||||
|
||||
// Drop deferred spacer excess as soon as the user has scrolled far
|
||||
// enough above the bottom that the shrink cannot clamp scrollTop.
|
||||
// Keyed on viewportEl so the listener follows viewport remounts.
|
||||
// Drop deferred spacer excess once the user has scrolled far enough above
|
||||
// the bottom that the shrink cannot clamp scrollTop. Keyed on viewportEl
|
||||
// so the listener follows viewport remounts.
|
||||
useEffect(() => {
|
||||
const el = viewportEl;
|
||||
if (!el) {
|
||||
|
|
@ -303,10 +299,10 @@ export const Thread: FC<{
|
|||
useAuiEvent("thread.initialize", releaseSpacerExcess);
|
||||
useAuiEvent("threadListItem.switchedTo", releaseSpacerExcess);
|
||||
|
||||
// Page-wide drag-and-drop: dropping a file anywhere on the chat page (not
|
||||
// just on the composer) attaches it and shows the composer drop affordance.
|
||||
// The composer's own dropzone still handles drops on the box itself; its
|
||||
// handler calls preventDefault, so the page handler skips them (no double-add).
|
||||
// Page-wide drag-and-drop: dropping a file anywhere on the chat page
|
||||
// attaches it and shows the composer drop affordance. The composer's own
|
||||
// dropzone handles drops on the box and calls preventDefault, so the page
|
||||
// handler skips them (no double-add).
|
||||
const [pageDragging, setPageDragging] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
const hasFiles = (e: ReactDragEvent) =>
|
||||
|
|
@ -332,8 +328,8 @@ export const Thread: FC<{
|
|||
// Compare panes hide this composer and use the shared composer's own
|
||||
// dropzone, so don't capture drops into a hidden composer here.
|
||||
if (hideComposer) return;
|
||||
// Drops on the composer box are handled by its own dropzone, which calls
|
||||
// preventDefault; skip those here so the file isn't added twice.
|
||||
// Drops on the composer box are handled by its dropzone (preventDefault);
|
||||
// skip those here so the file isn't added twice.
|
||||
if (e.defaultPrevented) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
|
@ -391,10 +387,9 @@ export const Thread: FC<{
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom slack so the last message has breathing room above the
|
||||
sticky scroll-to-bottom button (and the floating composer in
|
||||
single mode). Without this, content would butt against the
|
||||
sticky footer and feel cramped. */}
|
||||
{/* Bottom slack so the last message has room above the sticky
|
||||
scroll-to-bottom button (and floating composer in single mode),
|
||||
instead of butting against the footer. */}
|
||||
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
|
||||
<div
|
||||
ref={spacerRef}
|
||||
|
|
@ -562,8 +557,8 @@ const ThreadComposerDock: FC<{
|
|||
}> = ({ disabled, threadId, onHeightChange }) => {
|
||||
const { overlay } = useGeneratedImageOverlay();
|
||||
|
||||
// Report the dock's rendered height so the viewport can reserve matching
|
||||
// scroll space when attachments or multiline input grow the composer.
|
||||
// Report dock height so the viewport reserves matching scroll space when
|
||||
// attachments or multiline input grow the composer.
|
||||
const dockRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = dockRef.current;
|
||||
|
|
@ -608,12 +603,12 @@ const ThreadComposerDock: FC<{
|
|||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
// State and action both come from our IntentAwareScrollProvider (scoped
|
||||
// per Thread, so compare panes are independent). We deliberately
|
||||
// avoid `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to
|
||||
// stay off assistant-ui's internal autoscroll path — see the hook
|
||||
// for why. The button stays mounted and toggles via CSS; unmounting
|
||||
// would trip the hook's MutationObserver as a content change.
|
||||
// State and action both come from our IntentAwareScrollProvider (per-Thread
|
||||
// scope, so compare panes are independent). We avoid
|
||||
// `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to stay off
|
||||
// assistant-ui's internal autoscroll path (see the hook). The button stays
|
||||
// mounted and toggles via CSS; unmounting would trip the hook's
|
||||
// MutationObserver as a content change.
|
||||
const isAtBottom = useIsThreadAtBottom();
|
||||
const scrollToBottom = useScrollThreadToBottom();
|
||||
return (
|
||||
|
|
@ -634,9 +629,9 @@ const ThreadScrollToBottom: FC = () => {
|
|||
const pickRandom = <T,>(arr: T[]): T =>
|
||||
arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Each greeting carries the sloth picture that best fits it, so a given line
|
||||
// always shows the same mascot. Greeting varies by local time; name-bearing
|
||||
// lines drop the name when none is set.
|
||||
// Each greeting carries its matching sloth picture so a line always shows the
|
||||
// same mascot. Greeting varies by local time; name-bearing lines drop the
|
||||
// name when none is set.
|
||||
type Welcome = { text: string; sloth: string };
|
||||
const DEFAULT_WELCOME: Welcome = {
|
||||
text: "What’s on your mind today?",
|
||||
|
|
@ -645,9 +640,8 @@ const DEFAULT_WELCOME: Welcome = {
|
|||
|
||||
function buildWelcome(hour: number, name: string): Welcome {
|
||||
const g = (text: string, sloth: string): Welcome => ({ text, sloth });
|
||||
// Use the name on roughly a third of the lines per time of day: only the
|
||||
// direct salutations where it reads most naturally. Everything else stays
|
||||
// name-free so the greeting doesn't feel repetitive.
|
||||
// Use the name on ~a third of lines (only direct salutations where it reads
|
||||
// naturally); the rest stay name-free so greetings don't feel repetitive.
|
||||
const base: Welcome[] = [
|
||||
g(name ? `Good to see you, ${name}.` : "Good to see you.", "large sloth wave.png"),
|
||||
g("Ready when you are.", "large sloth thumbs.png"),
|
||||
|
|
@ -696,7 +690,7 @@ const ThreadWelcome: FC<{
|
|||
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
|
||||
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-start pt-[28vh]">
|
||||
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-9 px-4">
|
||||
{/* Center the whole greeting (sloth + title) over the composer. */}
|
||||
{/* Center the greeting (sloth + title) over the composer. */}
|
||||
<div className="flex flex-row items-center justify-center gap-[15px]">
|
||||
<img
|
||||
src={currentEmojiSrc}
|
||||
|
|
@ -784,8 +778,8 @@ const Composer: FC<{
|
|||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
|
||||
// With more than 4 pills showing, collapse them to icons only to cut clutter.
|
||||
// Search and Code always show; Images, Canvas and MCP are conditional.
|
||||
// More than 4 pills: collapse to icons only. Search and Code always show;
|
||||
// Images, Canvas and MCP are conditional.
|
||||
const pillsCompact =
|
||||
2 +
|
||||
(supportsBuiltinImageGeneration ? 1 : 0) +
|
||||
|
|
@ -802,7 +796,7 @@ const Composer: FC<{
|
|||
// Expand only once the input wraps to a second line, not on first keystroke.
|
||||
// Latch until cleared so it can't flip-flop at the wrap boundary.
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Cache line metrics so getComputedStyle runs once, not on every keystroke.
|
||||
// Cache line metrics so getComputedStyle runs once, not per keystroke.
|
||||
const lineMetricsRef = useRef<{ lineHeight: number; padding: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -844,8 +838,8 @@ const Composer: FC<{
|
|||
const referenceThreadId = threadId ?? activeThreadId ?? null;
|
||||
const hasSendableContent =
|
||||
composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
|
||||
// Two-row layout shows once the input wraps or a tool is on. Tools pre-select
|
||||
// before a model loads, so an active toggle expands the composer either way.
|
||||
// Two-row layout shows once the input wraps or a tool is on. Tools can
|
||||
// pre-select before a model loads, so an active toggle expands it either way.
|
||||
const composerExpanded =
|
||||
isMultiline ||
|
||||
hasAttachments ||
|
||||
|
|
@ -857,7 +851,7 @@ const Composer: FC<{
|
|||
mcpEnabledForChat;
|
||||
// react-textarea-autosize re-measures only on value change or window resize,
|
||||
// not on the width swap from expanding, so it keeps the taller height and
|
||||
// leaves a stray blank row. Nudge a resize whenever the input width changes.
|
||||
// leaves a stray blank row. Nudge a resize whenever input width changes.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") {
|
||||
|
|
@ -872,8 +866,8 @@ const Composer: FC<{
|
|||
return;
|
||||
}
|
||||
lastWidth = width;
|
||||
// Re-measure after layout settles. An immediate dispatch races autosize's
|
||||
// own measurement (stale pre-expand width); 0ms + 64ms wins it, no flash.
|
||||
// Re-measure after layout settles. An immediate dispatch races
|
||||
// autosize's own measurement (stale pre-expand width); 0ms + 64ms wins.
|
||||
while (pending.length) {
|
||||
clearTimeout(pending.pop());
|
||||
}
|
||||
|
|
@ -893,8 +887,8 @@ const Composer: FC<{
|
|||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
// Docked composer opens upward; the centered welcome composer opens downward
|
||||
// by default and only flips up via collision detection when it would not fit.
|
||||
// Docked composer opens upward; the welcome composer opens downward by
|
||||
// default and only flips up via collision detection when it won't fit.
|
||||
const effectiveMenuSide = menuSide ?? "bottom";
|
||||
const shouldBlockSend = useCallback(
|
||||
() =>
|
||||
|
|
@ -1027,17 +1021,17 @@ const Composer: FC<{
|
|||
onSubmit={handleSubmit}
|
||||
>
|
||||
{isTauri ? (
|
||||
// Phase 1 native model drops own Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
|
||||
// Phase 1 native model owns Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri once Phase 1d adds token bridging.
|
||||
<div className="aui-composer-attachment-dropzone unsloth-composer-surface">
|
||||
{composerContent}
|
||||
</div>
|
||||
) : (
|
||||
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative">
|
||||
{composerContent}
|
||||
{/* Gemini-style drop affordance: shown only while a file is dragged
|
||||
over the composer. Absolutely positioned + pointer-events-none so
|
||||
the dashed outline adds no layout shift and the drop still lands. */}
|
||||
{/* Gemini-style drop affordance, shown while a file is dragged over
|
||||
the composer. Absolute + pointer-events-none so the outline adds
|
||||
no layout shift and the drop still lands. */}
|
||||
<div
|
||||
className={cn(
|
||||
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[32px] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
|
||||
|
|
@ -1063,13 +1057,12 @@ function isNativeComposing(event: Event) {
|
|||
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||
}
|
||||
|
||||
// Fallback timeout for stuck IME composition. When Chrome on Windows talks
|
||||
// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after
|
||||
// the candidate is committed, so `composingRef` stays true and Send stays
|
||||
// disabled. Every compositionupdate / non-composing input resets the timer;
|
||||
// only a true gap-after-commit lets it fire. 2500ms is well above a normal
|
||||
// candidate-window pause but short enough to recover before the user
|
||||
// notices the Send button is stuck.
|
||||
// Fallback timeout for stuck IME composition. With Chrome on Windows against
|
||||
// a WSL-hosted Studio (issue #5546), `compositionend` never fires after the
|
||||
// candidate commits, so `composingRef` stays true and Send stays disabled.
|
||||
// Every compositionupdate / non-composing input resets the timer; only a true
|
||||
// gap-after-commit lets it fire. 2500ms is above a normal candidate-window
|
||||
// pause but short enough to recover before the user notices Send is stuck.
|
||||
const IME_STUCK_TIMEOUT_MS = 2500;
|
||||
|
||||
function useImeComposerInputHandlers() {
|
||||
|
|
@ -1153,13 +1146,12 @@ function useImeComposerInputHandlers() {
|
|||
);
|
||||
|
||||
// If the watchdog cleared the composing flags during a long candidate-window
|
||||
// pause, a subsequent IME keypress (browser-side isComposing=true / IME
|
||||
// keyCode 229) would otherwise reach handleSubmit with composingRef=false
|
||||
// and submit the preedit text. Re-arm composingRef synchronously from the
|
||||
// native event so the form-submit gate keeps blocking until compositionend.
|
||||
// Re-arm the watchdog at the same time — otherwise the WSL+Chrome path
|
||||
// this PR targets (no compositionend, no follow-up input event) would
|
||||
// leave composingRef pinned true indefinitely and Send blocked again.
|
||||
// pause, a later IME keypress (isComposing=true / keyCode 229) would reach
|
||||
// handleSubmit with composingRef=false and submit the preedit text. Re-arm
|
||||
// composingRef synchronously from the native event so the submit gate keeps
|
||||
// blocking until compositionend. Re-arm the watchdog too, or the WSL+Chrome
|
||||
// path (no compositionend, no follow-up input) would pin composingRef true
|
||||
// forever and block Send again.
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) {
|
||||
|
|
@ -1207,8 +1199,8 @@ const ComposerAudioMenuItem: FC = () => {
|
|||
);
|
||||
|
||||
// Build the input on document.body, not in the menu: selecting the item
|
||||
// closes the dropdown, which would unmount a menu-rendered input before the
|
||||
// OS picker returns and drop the file.
|
||||
// closes the dropdown, unmounting a menu-rendered input before the OS picker
|
||||
// returns and dropping the file.
|
||||
const pickAudio = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
|
|
@ -1573,9 +1565,8 @@ const WebSearchToggle: FC = () => {
|
|||
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
// External providers (OpenAI today) expose a server-side web_search tool
|
||||
// even when the local tool runtime is unavailable — gate the Search pill
|
||||
// on either source so it lights up on external models too. Mirror of
|
||||
// shared-composer's searchDisabled.
|
||||
// even without the local tool runtime; gate the pill on either source so it
|
||||
// lights up on external models too. Mirror of shared-composer's searchDisabled.
|
||||
const supportsBuiltinWebSearch = useChatRuntimeStore(
|
||||
(s) => s.supportsBuiltinWebSearch,
|
||||
);
|
||||
|
|
@ -1594,7 +1585,7 @@ const WebSearchToggle: FC = () => {
|
|||
: undefined;
|
||||
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
|
||||
// Disable only when a loaded model lacks the capability; with no model the
|
||||
// tool can still be pre-selected and reflected, matching the + menu.
|
||||
// tool can still be pre-selected, matching the + menu.
|
||||
const disabled = modelLoaded && !(supportsTools || supportsBuiltinWebSearch);
|
||||
|
||||
return (
|
||||
|
|
@ -1605,9 +1596,8 @@ const WebSearchToggle: FC = () => {
|
|||
const next = !toolsEnabled;
|
||||
setToolsEnabled(next);
|
||||
// Kimi's $web_search builtin requires thinking=disabled (see
|
||||
// https://platform.kimi.ai/docs/guide/use-web-search). Keep
|
||||
// the two pills mutually exclusive so the visible state always
|
||||
// matches what the backend ends up sending.
|
||||
// https://platform.kimi.ai/docs/guide/use-web-search). Keep the two
|
||||
// pills mutually exclusive so visible state matches what's sent.
|
||||
if (isKimiExternal) {
|
||||
setReasoningEnabled(!next, { persist: false });
|
||||
applyQwenThinkingParams(!next);
|
||||
|
|
@ -1630,18 +1620,17 @@ const CodeToolsToggle: FC = () => {
|
|||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
// External providers have no local tool runtime, but Anthropic's
|
||||
// Claude 4.x dispatches code_execution_20250825 server-side. The
|
||||
// chat-page resolver stashes that capability in the runtime store
|
||||
// (next to supportsBuiltinWebSearch). Mirror of shared-composer's
|
||||
// codeDisabled so this pill lights up in active threads too.
|
||||
// External providers have no local tool runtime, but Anthropic's Claude 4.x
|
||||
// dispatches code_execution_20250825 server-side; the chat-page resolver
|
||||
// stashes that capability in the runtime store (next to
|
||||
// supportsBuiltinWebSearch). Mirror of shared-composer's codeDisabled.
|
||||
const supportsBuiltinCodeExecution = useChatRuntimeStore(
|
||||
(s) => s.supportsBuiltinCodeExecution,
|
||||
);
|
||||
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
|
||||
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
|
||||
// Disable only when a loaded model lacks the capability; with no model the
|
||||
// tool can still be pre-selected and reflected, matching the + menu.
|
||||
// tool can still be pre-selected, matching the + menu.
|
||||
const disabled = modelLoaded && !(supportsTools || supportsBuiltinCodeExecution);
|
||||
|
||||
return (
|
||||
|
|
@ -1672,9 +1661,8 @@ const ImagesToggle: FC = () => {
|
|||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
// OpenAI cloud Responses-API models advertise image_generation as a
|
||||
// server-side tool; no local runtime fallback exists. Mirror of
|
||||
// shared-composer's imageDisabled / showImagePill so the in-thread
|
||||
// composer surfaces the same control as the empty-state composer.
|
||||
// server-side tool; no local runtime fallback. Mirror of shared-composer's
|
||||
// imageDisabled / showImagePill so this composer matches the empty state.
|
||||
const supportsBuiltinImageGeneration = useChatRuntimeStore(
|
||||
(s) => s.supportsBuiltinImageGeneration,
|
||||
);
|
||||
|
|
@ -1755,10 +1743,9 @@ const ToolStatusDisplay: FC = () => {
|
|||
|
||||
setElapsed(0);
|
||||
|
||||
// Debounce badge visibility by 300ms when the badge is not
|
||||
// already on screen. Once visible from a prior tool, consecutive
|
||||
// tools show immediately so the badge does not flicker. Fast
|
||||
// tool calls that all complete under 300ms never show the badge.
|
||||
// Debounce visibility by 300ms when the badge isn't already on screen.
|
||||
// Once visible from a prior tool, later tools show immediately so it
|
||||
// doesn't flicker; tool calls under 300ms never show the badge.
|
||||
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (!visibleRef.current) {
|
||||
showTimer = setTimeout(() => setVisible(true), 300);
|
||||
|
|
@ -1790,8 +1777,8 @@ const ToolStatusDisplay: FC = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
// Plus menu: attachment and workflow actions. Opens downward in the centered
|
||||
// welcome composer; the docked composer passes side="top" to open upward.
|
||||
// Plus menu: attachment and workflow actions. Opens downward in the welcome
|
||||
// composer; the docked composer passes side="top" to open upward.
|
||||
const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
||||
side = "bottom",
|
||||
}) => {
|
||||
|
|
@ -1806,7 +1793,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
const setMcpEnabledForChat = useChatRuntimeStore(
|
||||
(s) => s.setMcpEnabledForChat,
|
||||
);
|
||||
// Capability gating, mirroring the visible pills so menu and pills agree on
|
||||
// Capability gating mirrors the visible pills so menu and pills agree on
|
||||
// what a loaded model supports (a tool the backend drops must not look on).
|
||||
const modelLoaded = useChatRuntimeStore(
|
||||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
|
|
@ -1889,8 +1876,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
sideOffset={2}
|
||||
avoidCollisions={true}
|
||||
className="unsloth-plus-menu w-[212px]"
|
||||
// Don't refocus the + on close; the restored focus showed a stray
|
||||
// focus-visible ring.
|
||||
// Don't refocus the + on close; restored focus showed a stray ring.
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<ComposerPrimitive.AddAttachment asChild={true}>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@
|
|||
import { Loader2Icon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Spinner shown while a tool call is running: a clean circular arc with a
|
||||
* rounded cap (lucide Loader2 / LoaderCircle), animated. Inherits the current
|
||||
* text color so it matches the surrounding font.
|
||||
*/
|
||||
/** Spinner shown while a tool call runs. Inherits text color to match its surroundings. */
|
||||
export function ToolCallSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
|
|
|
|||
|
|
@ -217,8 +217,8 @@ const ToolGroupImpl: FC<
|
|||
),
|
||||
);
|
||||
|
||||
// Single tool calls and artifacts render directly so cards never hide inside
|
||||
// a collapsed tool group.
|
||||
// Render single tool calls and artifacts directly so cards never hide in a
|
||||
// collapsed group.
|
||||
if (toolCount <= 1 || containsArtifactTool) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,9 @@ import {
|
|||
} from "./tool-fallback";
|
||||
|
||||
/**
|
||||
* Renders the synthetic `_toolEvent` chunks emitted by
|
||||
* `_stream_anthropic` when Anthropic's `code_execution_20250825` tool
|
||||
* fires. The backend collapses Anthropic's two sub-tools
|
||||
* (`bash_code_execution`, `text_editor_code_execution`) into a single
|
||||
* `tool_name: "code_execution"`, with `arguments.kind` ("bash" or
|
||||
* "text_editor") and a per-kind argument shape:
|
||||
* Renders synthetic `_toolEvent` chunks from `_stream_anthropic` for the
|
||||
* `code_execution_20250825` tool. The backend collapses Anthropic's two
|
||||
* sub-tools into `tool_name: "code_execution"` with `arguments.kind`:
|
||||
*
|
||||
* kind=bash: { command: "<shell command>" }
|
||||
* kind=text_editor: { command: "view"|"create"|"str_replace", path, ... }
|
||||
|
|
@ -141,9 +138,8 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
completedLabel = commandLabel ? `Ran \`${commandLabel}\`` : "Ran command";
|
||||
}
|
||||
|
||||
// Collapse the card once the model has resumed streaming prose after
|
||||
// the tool call. Mirrors WebSearchToolUI's behavior so the tool-card
|
||||
// doesn't crowd the final answer once the run is done.
|
||||
// Collapse the card once the model resumes streaming prose after the tool
|
||||
// call (mirrors WebSearchToolUI) so it doesn't crowd the final answer.
|
||||
const hasText = useAuiState(({ message }) =>
|
||||
message.content.some(
|
||||
(p) =>
|
||||
|
|
|
|||
|
|
@ -21,26 +21,14 @@ import {
|
|||
|
||||
/**
|
||||
* Renders the synthetic `_toolEvent` chunks emitted by
|
||||
* `_stream_openai_responses` when OpenAI's Responses-API
|
||||
* `image_generation` tool fires. The backend stashes the base64
|
||||
* PNG/WebP/JPEG (the gpt-image backbone output) on an `image_b64`
|
||||
* field of the tool_end event so the JSON result stays small, and the
|
||||
* adapter repackages it into a structured `result` shape:
|
||||
*
|
||||
* {
|
||||
* image_b64: string,
|
||||
* image_mime: string, // e.g. "image/png"
|
||||
* size?: string, // "1024x1024" etc
|
||||
* quality?: string,
|
||||
* background?: string,
|
||||
* }
|
||||
*
|
||||
* The corresponding `tool_start` carries the prompt as
|
||||
* `args.prompt` (after gpt-image's revision pass) plus `args.kind:
|
||||
* "image"`. Without this component the generic ToolFallback would
|
||||
* print the prompt as JSON args text with an empty Result block --
|
||||
* which is exactly the "no image" symptom users hit before this UI
|
||||
* landed.
|
||||
* `_stream_openai_responses` when OpenAI's Responses-API `image_generation`
|
||||
* tool fires. The backend stashes the base64 image on `image_b64` of the
|
||||
* tool_end event (keeping the JSON small); the adapter repackages it into a
|
||||
* structured `result` (image_b64, image_mime e.g. "image/png", size? e.g.
|
||||
* "1024x1024", quality?, background?).
|
||||
* The `tool_start` carries the revised prompt as `args.prompt` plus
|
||||
* `args.kind: "image"`. Without this, ToolFallback would print the prompt as
|
||||
* JSON with an empty Result block (the "no image" symptom).
|
||||
*/
|
||||
interface ImageGenerationArgs {
|
||||
prompt?: string;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function CopyBtn({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Render code with syntax highlighting via Streamdown + shiki. No extra borders — inherits parent container. */
|
||||
/** Syntax-highlighted code via Streamdown + shiki; inherits parent container. */
|
||||
function HighlightedCode({ code: source, language }: { code: string; language: string }) {
|
||||
const markdown = useMemo(
|
||||
() => `\`\`\`${language}\n${truncate(source)}\n\`\`\``,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ import { BrowserIcon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
// Context7 assistant-ui docs: tool UIs can read streaming args via
|
||||
// useToolArgsStatus, so render_html does not need to wait for tool completion.
|
||||
// Per Context7 assistant-ui docs: tool UIs read streaming args via
|
||||
// useToolArgsStatus, so render_html need not wait for tool completion.
|
||||
type RenderHtmlArgs = Record<string, unknown> & {
|
||||
code?: string;
|
||||
title?: string;
|
||||
|
|
@ -41,9 +41,8 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
const isRunning = status?.type === "running";
|
||||
const codeIsStreaming = propStatus.code === "streaming";
|
||||
|
||||
// Surface the backend error when the tool call completed with invalid
|
||||
// args. Backend success results start with "Rendered HTML artifact";
|
||||
// error results start with "Error:".
|
||||
// Surface the backend error when the tool call completed with invalid args.
|
||||
// Success results start with "Rendered HTML artifact"; errors with "Error:".
|
||||
const errorText =
|
||||
status?.type === "complete" &&
|
||||
typeof result === "string" &&
|
||||
|
|
|
|||
|
|
@ -25,9 +25,8 @@ const RE_URL = /URL:\s*(.+)/;
|
|||
const RE_SNIPPET = /Snippet:\s*(.+)/s;
|
||||
|
||||
/**
|
||||
* Reject anything that is not a real http(s) URL. Web-search / web-fetch
|
||||
* output is provider-controlled, so hostile ``javascript:`` / ``data:``
|
||||
* lines must not reach the Source badge's <a href>.
|
||||
* Reject non-http(s) URLs. Web-search/fetch output is provider-controlled,
|
||||
* so hostile `javascript:` / `data:` lines must not reach a Source <a href>.
|
||||
*/
|
||||
function isSafeHttpUrl(raw: string): boolean {
|
||||
const value = raw.trim();
|
||||
|
|
|
|||
|
|
@ -18,57 +18,42 @@ import {
|
|||
/**
|
||||
* Intent-aware autoscroll for a Thread viewport.
|
||||
*
|
||||
* Why we don't reuse assistant-ui's built-in autoscroll:
|
||||
* `useThreadViewportAutoScroll` runs unconditionally whenever
|
||||
* `ThreadPrimitive.Viewport` is mounted. Even with every
|
||||
* `scrollToBottomOn*` prop disabled, it still installs observers
|
||||
* that write `isAtBottom` to the shared viewport store on every
|
||||
* layout change. On sidebar toggles, browser resizes, or
|
||||
* mobile↔desktop breakpoint crossings, that write races our scroll
|
||||
* correction — whoever writes last wins, and the scroll-to-bottom
|
||||
* button flickers or sticks depending on observer ordering.
|
||||
* Why not assistant-ui's built-in: `useThreadViewportAutoScroll` always
|
||||
* installs observers that write `isAtBottom` to the shared store on every
|
||||
* layout change, even with `scrollToBottomOn*` disabled. On resizes /
|
||||
* breakpoint crossings that write races our correction and the
|
||||
* scroll-to-bottom button flickers depending on observer ordering.
|
||||
*
|
||||
* Strategy:
|
||||
* - Own `isAtBottom` as local state (exposed via
|
||||
* `useIsThreadAtBottom`). Upstream can still write to its own
|
||||
* store; nobody reads from it.
|
||||
* - Drive the viewport with a single rAF loop governed by a follow
|
||||
* deadline (`followUntilRef`). Any signal that can invalidate
|
||||
* bottom alignment (resize, mutation, AUI event, programmatic
|
||||
* scroll) extends the deadline; the loop pins to the bottom and
|
||||
* reports `isAtBottom=true` until it expires, then settles on
|
||||
* pure DOM observation.
|
||||
* - Detect user intent (wheel up, touch swipe up, scroll direction)
|
||||
* to detach. While detached, resize/mutation signals don't extend
|
||||
* the deadline, so the button appears and stays. Re-attach when
|
||||
* the user scrolls *down* within 24px of the bottom.
|
||||
* - Own `isAtBottom` as local state (via `useIsThreadAtBottom`); nobody
|
||||
* reads upstream's store.
|
||||
* - Drive the viewport via a single rAF loop with a follow deadline
|
||||
* (`followUntilRef`). Any signal that can invalidate bottom alignment
|
||||
* extends the deadline; the loop pins and reports `isAtBottom=true`
|
||||
* until it expires, then settles on DOM observation.
|
||||
* - Detect user intent (wheel up, swipe up, scroll direction) to detach.
|
||||
* While detached, resize/mutation don't extend the deadline. Re-attach
|
||||
* when the user scrolls down within 24px of the bottom.
|
||||
*/
|
||||
|
||||
// 2px, not 1: subpixel rounding on HiDPI displays can leave a fractional
|
||||
// gap at the "true" bottom that a 1px threshold reports as not-at-bottom.
|
||||
// 2px, not 1: HiDPI subpixel rounding can leave a fractional gap that a
|
||||
// 1px threshold reports as not-at-bottom.
|
||||
const AT_BOTTOM_THRESHOLD_PX = 2;
|
||||
const RE_ATTACH_THRESHOLD_PX = 24;
|
||||
const TOUCH_MOVE_THRESHOLD_PX = 4;
|
||||
// Cumulative upward movement (summed across scroll events) that
|
||||
// counts as a deliberate detach. Summed rather than per-event so that
|
||||
// slow 1-px-per-event sources — middle-click autoscroll, scrollbar
|
||||
// drags, some trackpads — accumulate instead of slipping under a
|
||||
// per-event threshold forever.
|
||||
// Cumulative upward movement counting as a deliberate detach. Summed (not
|
||||
// per-event) so slow 1px-per-event sources (middle-click autoscroll,
|
||||
// scrollbar drags, some trackpads) accumulate instead of slipping under.
|
||||
const UPWARD_DETACH_THRESHOLD_PX = 2;
|
||||
// Window during which the viewport pins to the bottom through
|
||||
// layout/content races. Extends on every resize/mutation, so streaming
|
||||
// keeps the viewport pinned as long as content keeps arriving; settles
|
||||
// this long after the last change.
|
||||
// Window the viewport stays pinned through layout/content races. Extends
|
||||
// on every resize/mutation, so streaming keeps it pinned; settles this
|
||||
// long after the last change.
|
||||
const FOLLOW_SETTLE_MS = 600;
|
||||
// Maximum stabilizer compensation. The stabilizer is meant to absorb
|
||||
// sub-frame transients (~5-15px shiki re-renders, ~8px action-bar
|
||||
// reservation drift). Anything larger is almost certainly an intentional
|
||||
// content removal — message delete, regenerate's old-content clear,
|
||||
// reasoning-panel collapse — and should *not* be silently padded over,
|
||||
// which would leave persistent empty space below the last message.
|
||||
// Above this threshold we release the stabilizer immediately and let
|
||||
// the autoscroll re-pin to the new content height, which is the natural
|
||||
// behavior the user expects for those actions.
|
||||
// Max stabilizer compensation. Absorbs sub-frame transients (~5-15px shiki
|
||||
// re-renders, ~8px action-bar drift). Larger shrinks are intentional
|
||||
// content removals (delete, regenerate clear, reasoning collapse); padding
|
||||
// those over leaves empty space, so above this we release and let
|
||||
// autoscroll re-pin to the new height.
|
||||
const STABILIZER_MAX_PX = 64;
|
||||
|
||||
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
|
||||
|
|
@ -78,11 +63,10 @@ type AutoScrollContextValue = {
|
|||
getIsAtBottom: () => boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/**
|
||||
* Mark the user as detached from the bottom, as if they had scrolled
|
||||
* up. Called when the composer grows and the bottom spacer grows with
|
||||
* it: the chat is then above the new bottom, and observer-driven pins
|
||||
* must not shove it up. Scrolling back to the bottom re-attaches;
|
||||
* explicit pins (run start, scroll-to-bottom button) still work.
|
||||
* Mark the user as detached, as if they scrolled up. Called when the
|
||||
* composer (and bottom spacer) grow: the chat is then above the new
|
||||
* bottom and observer-driven pins must not shove it up. Scrolling back
|
||||
* re-attaches; explicit pins (run start, button) still work.
|
||||
*/
|
||||
detachFromBottom: () => void;
|
||||
};
|
||||
|
|
@ -191,24 +175,18 @@ export function useIntentAwareAutoScroll(): {
|
|||
const atBottomStrict = (): boolean =>
|
||||
distanceFromBottom() <= AT_BOTTOM_THRESHOLD_PX;
|
||||
|
||||
// True only when there is room to scroll upward. Guards the
|
||||
// wheel/touch detach paths: a wheel-up or swipe-down gesture on
|
||||
// a viewport with nothing above (short thread, or already at
|
||||
// the top) can't express intent to leave the bottom and must
|
||||
// not flip userDetachedRef — otherwise later streaming updates
|
||||
// skip extendFollow and auto-follow stays dead for the session.
|
||||
// Room to scroll upward. Guards wheel/touch detach: a gesture on a
|
||||
// viewport with nothing above can't express intent to leave the
|
||||
// bottom and must not flip userDetachedRef, else later streaming
|
||||
// skips extendFollow and auto-follow stays dead for the session.
|
||||
const canScrollUp = (): boolean => el.scrollTop > 0;
|
||||
|
||||
// True when a nested scrollable ancestor of the event target
|
||||
// (e.g. the reasoning panel's own overflow-y-auto region, or
|
||||
// any tool output with internal scroll) has room above and
|
||||
// will therefore consume the upward delta before it reaches
|
||||
// the viewport. Walking stops at the viewport element itself,
|
||||
// so only intermediate inner scrollers count.
|
||||
//
|
||||
// Wheel and touchmove events bubble, so without this check a
|
||||
// user reading back through a long reasoning pane mid-stream
|
||||
// would falsely detach the outer viewport.
|
||||
// True when a nested scrollable ancestor of the target (reasoning
|
||||
// panel, tool output) has room above and will consume the upward
|
||||
// delta before it reaches the viewport. Walk stops at the viewport,
|
||||
// so only intermediate inner scrollers count. Without this, wheel/
|
||||
// touchmove bubbling would falsely detach while reading a long
|
||||
// reasoning pane mid-stream.
|
||||
const innerScrollWillConsumeUpward = (
|
||||
target: EventTarget | null,
|
||||
): boolean => {
|
||||
|
|
@ -230,10 +208,8 @@ export function useIntentAwareAutoScroll(): {
|
|||
return false;
|
||||
};
|
||||
|
||||
// Stabilizer state — see `stabilize` below for the full
|
||||
// explanation. Lives in this closure so it resets naturally
|
||||
// whenever the viewport remounts (Compare-pane swap, thread
|
||||
// switch with remount, etc.).
|
||||
// Stabilizer state (see `stabilize`). In this closure so it resets
|
||||
// when the viewport remounts (Compare-pane swap, thread switch).
|
||||
let stabilizerPx = 0;
|
||||
let maxContentHeight = 0;
|
||||
|
||||
|
|
@ -255,11 +231,10 @@ export function useIntentAwareAutoScroll(): {
|
|||
const detach = (): void => {
|
||||
userDetachedRef.current = true;
|
||||
followUntilRef.current = 0;
|
||||
// The stabilizer is only meaningful while we're actively
|
||||
// pinning to the bottom. Once the user scrolls up, drop any
|
||||
// residual padding so the bottom stays flush whenever they
|
||||
// come back. Safe here because the user is mid-content —
|
||||
// shrinking scrollHeight cannot cap their scrollTop.
|
||||
// The stabilizer only matters while pinning. Once the user
|
||||
// scrolls up, drop residual padding so the bottom stays flush on
|
||||
// return. Safe mid-content: shrinking scrollHeight can't cap their
|
||||
// scrollTop.
|
||||
releaseStabilizer();
|
||||
maxContentHeight = el.scrollHeight;
|
||||
};
|
||||
|
|
@ -270,12 +245,10 @@ export function useIntentAwareAutoScroll(): {
|
|||
}
|
||||
};
|
||||
|
||||
// Single rAF loop. While within the follow window and not
|
||||
// detached, pin the viewport to the bottom and report
|
||||
// isAtBottom=true every frame. Otherwise settle on whatever the
|
||||
// DOM says. Scheduling is edge-triggered: scroll/resize/mutation
|
||||
// events call requestTick(), and the loop self-perpetuates only
|
||||
// as long as pinning is still active.
|
||||
// Single rAF loop. Within the follow window and not detached, pin to
|
||||
// bottom and report isAtBottom=true each frame; otherwise settle on
|
||||
// the DOM. Edge-triggered: scroll/resize/mutation call requestTick(),
|
||||
// and the loop self-perpetuates only while pinning is active.
|
||||
const tick = (): void => {
|
||||
rafId = null;
|
||||
const following =
|
||||
|
|
@ -304,8 +277,8 @@ export function useIntentAwareAutoScroll(): {
|
|||
requestTick();
|
||||
};
|
||||
|
||||
// Programmatic detach (see detachFromBottom). Same effect as the
|
||||
// user scrolling up; the tick refresh updates isAtBottom.
|
||||
// Programmatic detach (see detachFromBottom). Same as scrolling up;
|
||||
// the tick refresh updates isAtBottom.
|
||||
detachImplRef.current = () => {
|
||||
detach();
|
||||
requestTick();
|
||||
|
|
@ -363,17 +336,12 @@ export function useIntentAwareAutoScroll(): {
|
|||
extendFollow();
|
||||
}
|
||||
} else if (delta < 0 && !userDetachedRef.current) {
|
||||
// Upward: sum across events. Middle-click autoscroll and
|
||||
// some trackpads deliver 1px-per-event scrolls that each
|
||||
// slip under a per-event threshold; summing catches them.
|
||||
//
|
||||
// Count distance-from-bottom growth, not raw scrollTop
|
||||
// delta. When content above collapses (reasoning panels
|
||||
// auto-closing after streaming, tool outputs auto-hiding),
|
||||
// browsers scroll-anchor to keep visible content stable:
|
||||
// scrollTop decreases but scrollHeight decreases by the
|
||||
// same amount, so distance is unchanged. Those layout-
|
||||
// induced deltas must not flip user intent.
|
||||
// Upward: sum across events to catch 1px-per-event sources
|
||||
// (middle-click autoscroll, some trackpads) that slip under a
|
||||
// per-event threshold. Count distance-from-bottom growth, not
|
||||
// raw scrollTop delta: when content above collapses, browsers
|
||||
// scroll-anchor so scrollTop and scrollHeight drop together and
|
||||
// distance is unchanged. Those layout deltas must not flip intent.
|
||||
const distanceDelta = distanceNow - lastDistanceFromBottom;
|
||||
if (distanceDelta > 0) {
|
||||
upwardAccumulator += distanceDelta;
|
||||
|
|
@ -393,37 +361,25 @@ export function useIntentAwareAutoScroll(): {
|
|||
|
||||
// Scroll stabilizer.
|
||||
//
|
||||
// Problem: when a trailing code block finalizes at stream end
|
||||
// (Streamdown flips `isAnimating` → false, shiki re-renders the
|
||||
// <pre> with highlight spans), the block's rendered height
|
||||
// briefly dips and then recovers a frame later. That dip shrinks
|
||||
// `scrollHeight`, which the browser handles by *synchronously*
|
||||
// capping `scrollTop` to the new (smaller) `scrollHeight −
|
||||
// clientHeight`. The cap is visible as a one-frame upward jump;
|
||||
// the recovery a frame or two later is the "snap back" the user
|
||||
// perceives as a flicker. No amount of programmatic re-scrolling
|
||||
// can prevent this — once `scrollHeight` drops, the cap has
|
||||
// already happened and `scrollTop` cannot be pushed past the new
|
||||
// max.
|
||||
// Problem: when a trailing code block finalizes (shiki re-renders the
|
||||
// <pre> with highlight spans), its height briefly dips then recovers.
|
||||
// The dip shrinks `scrollHeight`, so the browser synchronously caps
|
||||
// `scrollTop` to the new max — a one-frame upward jump, then a
|
||||
// "snap back". Re-scrolling can't help: once scrollHeight drops the
|
||||
// cap has happened and scrollTop can't exceed the new max.
|
||||
//
|
||||
// Fix: keep `scrollHeight` monotonic across the follow window.
|
||||
// We track the maximum *content* height (scrollHeight minus our
|
||||
// own padding contribution) seen during follow, and compensate
|
||||
// for any shortfall by writing the deficit into a CSS custom
|
||||
// property `--aui-scroll-stabilizer`, which the viewport's
|
||||
// `padding-bottom` reads. A 5px content shrink instantly grows
|
||||
// the padding by 5px, so the browser sees no scrollHeight change
|
||||
// and never caps scrollTop. As content naturally grows past its
|
||||
// prior high-water mark (e.g. the next message streams in), the
|
||||
// padding shrinks back toward zero.
|
||||
// Fix: keep `scrollHeight` monotonic across the follow window. Track
|
||||
// max content height (scrollHeight minus our padding) and write any
|
||||
// shortfall into CSS var `--aui-scroll-stabilizer`, read by the
|
||||
// viewport's padding-bottom. A 5px shrink grows padding 5px so the
|
||||
// browser sees no scrollHeight change. Padding shrinks back to zero
|
||||
// as content grows past its prior high-water mark.
|
||||
//
|
||||
// Self-contained: lives entirely on the viewport element via a
|
||||
// CSS variable. Doesn't touch the composer, the action bar, the
|
||||
// message footer, the spacer, or any other UI.
|
||||
// Self-contained: lives on the viewport element via a CSS variable,
|
||||
// touching no other UI.
|
||||
//
|
||||
// Returns the post-adjustment scrollHeight so a single layout
|
||||
// read per observer callback can feed both stabilization and
|
||||
// pinning, avoiding a redundant flush.
|
||||
// Returns the post-adjustment scrollHeight so one layout read per
|
||||
// observer callback feeds both stabilization and pinning.
|
||||
const stabilize = (): number => {
|
||||
const sh = el.scrollHeight;
|
||||
const currentContent = sh - stabilizerPx;
|
||||
|
|
@ -431,9 +387,8 @@ export function useIntentAwareAutoScroll(): {
|
|||
!userDetachedRef.current &&
|
||||
performance.now() < followUntilRef.current;
|
||||
if (!followActive) {
|
||||
// Outside the follow window we stop adjusting, but we keep
|
||||
// `maxContentHeight` aligned with reality so the next follow
|
||||
// session starts from the current content size, not stale.
|
||||
// Outside the follow window: stop adjusting but keep
|
||||
// maxContentHeight current so the next session isn't stale.
|
||||
maxContentHeight = currentContent;
|
||||
return sh;
|
||||
}
|
||||
|
|
@ -441,14 +396,11 @@ export function useIntentAwareAutoScroll(): {
|
|||
maxContentHeight = currentContent;
|
||||
}
|
||||
const shrink = maxContentHeight - currentContent;
|
||||
// Large shrinks (over STABILIZER_MAX_PX) are intentional content
|
||||
// removals — message delete, regenerate clearing the old
|
||||
// assistant turn, reasoning-panel collapse. Compensating for
|
||||
// those would leave persistent empty space at the bottom of the
|
||||
// viewport, which the user reads as "weird empty gap." Release
|
||||
// the stabilizer instead and rebase the high-water mark; the
|
||||
// pinIfFollowing call right after will smoothly re-anchor to
|
||||
// the new (smaller) bottom.
|
||||
// Large shrinks (over STABILIZER_MAX_PX) are intentional removals
|
||||
// (delete, regenerate clear, reasoning collapse). Compensating
|
||||
// would leave an empty gap, so release the stabilizer and rebase
|
||||
// the high-water mark; the pinIfFollowing call below re-anchors to
|
||||
// the new bottom.
|
||||
if (shrink > STABILIZER_MAX_PX) {
|
||||
maxContentHeight = currentContent;
|
||||
if (stabilizerPx !== 0) {
|
||||
|
|
@ -468,10 +420,9 @@ export function useIntentAwareAutoScroll(): {
|
|||
return currentContent + stabilizerPx;
|
||||
};
|
||||
|
||||
// Synchronous pin-to-bottom. Observer callbacks run in the event-
|
||||
// loop's "update the rendering" step (after layout, before paint),
|
||||
// so the scrollTo here is composited in the same frame as the
|
||||
// mutation that triggered the observer.
|
||||
// Synchronous pin-to-bottom. Observer callbacks run after layout,
|
||||
// before paint, so this scrollTo composites in the same frame as the
|
||||
// triggering mutation.
|
||||
const pinIfFollowing = (scrollHeight: number): void => {
|
||||
if (userDetachedRef.current) {
|
||||
return;
|
||||
|
|
@ -485,10 +436,8 @@ export function useIntentAwareAutoScroll(): {
|
|||
el.scrollTo({ top: scrollHeight, behavior: "instant" });
|
||||
};
|
||||
|
||||
// All three layout-change signals fan in here so there's a
|
||||
// single place to understand "what runs when the viewport's
|
||||
// content shape changes". Order matters: extend first so the
|
||||
// stabilizer sees the follow window as active; stabilize before
|
||||
// All three layout-change signals fan in here. Order matters: extend
|
||||
// first so the stabilizer sees follow as active; stabilize before
|
||||
// pinning so we scroll to the post-adjustment scrollHeight.
|
||||
const onLayoutChange = (): void => {
|
||||
extendFollow();
|
||||
|
|
@ -501,18 +450,17 @@ export function useIntentAwareAutoScroll(): {
|
|||
const mutationObserver = new MutationObserver(onLayoutChange);
|
||||
const onViewportResize = onLayoutChange;
|
||||
|
||||
// Fresh attach (a new viewport element) always starts pinned.
|
||||
// Rebinds to the SAME element must not pin or reset detach state:
|
||||
// the Viewport composes refs with an identity that changes on
|
||||
// re-render, so React re-runs the ref (null, then same element)
|
||||
// on unrelated renders such as composer resizes. Pinning here
|
||||
// would yank the chat to the bottom on every such render. The
|
||||
// observers below are re-installed either way.
|
||||
// Fresh attach always starts pinned. Rebinds to the SAME element must
|
||||
// not pin or reset detach state: the Viewport's composed ref identity
|
||||
// changes on re-render, so React re-runs the ref (null, then same
|
||||
// element) on unrelated renders (composer resizes); pinning would
|
||||
// yank the chat to bottom each time. Observers are re-installed either
|
||||
// way.
|
||||
if (!isRebind) {
|
||||
userDetachedRef.current = false;
|
||||
|
||||
// Pin to bottom when the ref first attaches. Covers the case
|
||||
// where `thread.initialize` fires before the ref is bound.
|
||||
// Pin on first attach, covering thread.initialize firing before the
|
||||
// ref is bound.
|
||||
extendFollow();
|
||||
if (el.scrollHeight > el.clientHeight) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
|
||||
|
|
@ -521,23 +469,19 @@ export function useIntentAwareAutoScroll(): {
|
|||
}
|
||||
requestTick();
|
||||
|
||||
// Observe the border box, not the content box. The stabilizer
|
||||
// writes `padding-bottom`, which shrinks the content box; if we
|
||||
// observed that, every stabilizer adjustment would echo back as
|
||||
// a resize and re-enter onLayoutChange. Border-box stays put
|
||||
// through padding changes but still tracks parent-driven
|
||||
// resizes (window, sidebar toggle) — which is all we need.
|
||||
// Observe the border box, not content box. The stabilizer writes
|
||||
// padding-bottom (shrinks the content box); observing that would echo
|
||||
// every adjustment back as a resize into onLayoutChange. Border-box is
|
||||
// stable through padding changes but still tracks parent resizes.
|
||||
resizeObserver.observe(el, { box: "border-box" });
|
||||
mutationObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
// Layout-affecting attributes only. Excludes `style`, which
|
||||
// elements may mutate in response to viewport state (feedback
|
||||
// loop). `class` catches Tailwind show/hide; `hidden` /
|
||||
// `aria-hidden` / `aria-expanded` / `data-state` catch Radix
|
||||
// and native collapsible toggles that change scrollHeight
|
||||
// without triggering the viewport ResizeObserver.
|
||||
// Layout-affecting attributes only. Excludes `style` (elements may
|
||||
// mutate it in response to viewport state → feedback loop). `class`
|
||||
// catches Tailwind show/hide; the rest catch Radix/native
|
||||
// collapsibles that change scrollHeight without a viewport resize.
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
"class",
|
||||
|
|
@ -551,10 +495,9 @@ export function useIntentAwareAutoScroll(): {
|
|||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// ResizeObserver above covers browser-window resizes (they resize
|
||||
// the viewport element). visualViewport.resize is the only signal
|
||||
// for iOS software-keyboard changes, where the visual viewport
|
||||
// shrinks without the viewport element's clientHeight changing.
|
||||
// ResizeObserver covers window resizes. visualViewport.resize is the
|
||||
// only signal for iOS software-keyboard changes, where the visual
|
||||
// viewport shrinks without the element's clientHeight changing.
|
||||
window.visualViewport?.addEventListener("resize", onViewportResize);
|
||||
|
||||
return () => {
|
||||
|
|
@ -580,10 +523,9 @@ export function useIntentAwareAutoScroll(): {
|
|||
[setIsAtBottom],
|
||||
);
|
||||
|
||||
// Thread lifecycle moments that always pin to the bottom, regardless
|
||||
// of prior detach state. "auto" respects CSS smooth scrolling for
|
||||
// runStart so new turns glide in; "instant" snaps for load/switch
|
||||
// where any animation would just be wasted motion.
|
||||
// Thread lifecycle moments that always pin, regardless of detach state.
|
||||
// "auto" respects CSS smooth scroll for runStart (new turns glide in);
|
||||
// "instant" snaps for load/switch where animation is wasted.
|
||||
const pinToBottom = useCallback((behavior: ScrollBehavior) => {
|
||||
userDetachedRef.current = false;
|
||||
scrollImplRef.current(behavior);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@ import {
|
|||
interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called after the shutdown API returns success, right before we replace
|
||||
* document.body with the "Server stopped" page. Callers use this to remove
|
||||
* their beforeunload listener — otherwise the browser would prompt
|
||||
* "Leave site?" when the user tries to close the final tab. */
|
||||
/** Called after shutdown succeeds, before we replace document.body. Lets
|
||||
* callers remove their beforeunload listener so the browser doesn't prompt
|
||||
* "Leave site?" when closing the final tab. */
|
||||
onAfterShutdown?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ export function ShutdownDialog({
|
|||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network error — shutdown request never reached the server
|
||||
// Network error: request never reached the server
|
||||
toastError("Could not reach server");
|
||||
setStopping(false);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function WindowTitlebar({
|
|||
const appWindow = await getAppWindow();
|
||||
setMaximized(await appWindow.isMaximized());
|
||||
} catch {
|
||||
// If a window permission is not ready yet, keep the previous visual state.
|
||||
// Window permission not ready yet: keep previous visual state.
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const env = {
|
|||
BASE_URL: import.meta.env.BASE_URL,
|
||||
} as const;
|
||||
|
||||
// ── Platform / device type ──────────────────────────────────
|
||||
// Platform / device type
|
||||
|
||||
export type DeviceType = "mac" | "windows" | "linux" | string;
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ interface PlatformState {
|
|||
isChatOnly: () => boolean;
|
||||
}
|
||||
|
||||
// Client-side platform detection as fallback when backend isn't ready yet.
|
||||
// Client-side fallback when backend isn't ready yet.
|
||||
function detectLocalPlatform(): DeviceType {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
|
@ -55,9 +55,9 @@ export async function fetchDeviceType(): Promise<DeviceType> {
|
|||
return deviceType;
|
||||
}
|
||||
} catch {
|
||||
// Backend not ready — use client-side detection so chat-only guard
|
||||
// still works on initial load (important for macOS). Keep fetched=false
|
||||
// so a later call retries against the backend.
|
||||
// Backend not ready: use client-side detection so chat-only guard works
|
||||
// on initial load (important for macOS). Keep fetched=false so a later
|
||||
// call retries against the backend.
|
||||
const deviceType = detectLocalPlatform();
|
||||
const chatOnly = deviceType === "mac";
|
||||
usePlatformStore.setState({ deviceType, chatOnly, fetched: false });
|
||||
|
|
|
|||
|
|
@ -168,9 +168,8 @@ export async function authFetch(
|
|||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
// fetch TypeError = offline | backend down | CORS/DNS. In Tauri
|
||||
// it's always backend-down; in the web build distinguish offline
|
||||
// so the user gets the right recovery path.
|
||||
// fetch TypeError = offline | backend down | CORS/DNS. Tauri is always
|
||||
// backend-down; the web build distinguishes offline for the right message.
|
||||
if (!isTauri && typeof navigator !== "undefined" && navigator.onLine === false) {
|
||||
throw new Error(
|
||||
"You appear to be offline. Check your network connection and try again.",
|
||||
|
|
@ -227,10 +226,9 @@ async function postLogout(accessToken: string | null): Promise<Response | null>
|
|||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Server-side revoke. If the access token is expired the 401 fires
|
||||
// BEFORE revoke runs; rotate via the refresh token and retry so the
|
||||
// refresh family is actually revoked. Generation bump in finally
|
||||
// invalidates any in-flight refresh from before this call.
|
||||
// Server-side revoke. If the access token is expired, the 401 fires before
|
||||
// revoke runs; rotate via the refresh token and retry so the refresh family
|
||||
// is revoked. The finally generation bump invalidates in-flight refreshes.
|
||||
try {
|
||||
let response = await postLogout(getAuthToken());
|
||||
if (response && response.status === 401 && getRefreshToken()) {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import type { ReactElement } from "react";
|
|||
import type { SyntheticEvent } from "react";
|
||||
import { refreshSession } from "../api";
|
||||
|
||||
// Bootstrap credentials injected into index.html by the backend
|
||||
// (only present while default admin must_change_password is true)
|
||||
// Bootstrap credentials injected into index.html by the backend (only present
|
||||
// while default admin must_change_password is true)
|
||||
declare global {
|
||||
interface Window {
|
||||
__UNSLOTH_BOOTSTRAP__?: { username: string; password: string };
|
||||
|
|
@ -93,9 +93,9 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
|
|||
let canceled = false;
|
||||
|
||||
async function initializeAuthForm(): Promise<void> {
|
||||
// Always check the server first — localStorage flags can be stale
|
||||
// (e.g. tokens from a previous install attempt). The server's
|
||||
// /api/auth/status is the source of truth for requires_password_change.
|
||||
// Always check the server first; localStorage flags can be stale (e.g.
|
||||
// tokens from a previous install). /api/auth/status is the source of
|
||||
// truth for requires_password_change.
|
||||
try {
|
||||
const response = await fetch(apiUrl("/api/auth/status"));
|
||||
if (!response.ok) throw new Error("Failed to load auth status.");
|
||||
|
|
@ -109,7 +109,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
|
|||
setMustChangePassword(result.requires_password_change);
|
||||
}
|
||||
|
||||
// Redirect between login ↔ change-password based on server state
|
||||
// Redirect between login / change-password per server state
|
||||
if (mode === "login" && result.requires_password_change) {
|
||||
navigate({ to: "/change-password" });
|
||||
return;
|
||||
|
|
@ -119,8 +119,8 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
|
|||
return;
|
||||
}
|
||||
|
||||
// On login page, if user already has a valid session and no
|
||||
// password change is required, skip straight to the app.
|
||||
// On login, skip to the app if a valid session exists and no
|
||||
// password change is required.
|
||||
if (isLoginMode && !result.requires_password_change) {
|
||||
if (hasRefreshToken()) {
|
||||
const refreshed = await refreshSession();
|
||||
|
|
@ -293,12 +293,12 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
|
|||
storeAuthTokens(token.access_token, token.refresh_token);
|
||||
navigate({ to: getPostAuthRoute() });
|
||||
} catch (err: unknown) {
|
||||
// The backend already returns the correct, PATH-based command
|
||||
// ("unsloth studio reset-password"), which the installer puts on PATH on
|
||||
// every platform. Do NOT rewrite it to a relative Windows path like
|
||||
// ".\unsloth_studio\Scripts\unsloth.exe ..." -- that only resolves when the
|
||||
// terminal happens to be inside the Studio home dir, so it fails with
|
||||
// CommandNotFoundException everywhere else. Show the backend message as-is.
|
||||
// The backend returns the correct PATH-based command ("unsloth studio
|
||||
// reset-password"), which the installer puts on PATH on every platform.
|
||||
// Do NOT rewrite it to a relative Windows path like
|
||||
// ".\unsloth_studio\Scripts\unsloth.exe ..." -- that only resolves inside
|
||||
// the Studio home dir and fails with CommandNotFoundException elsewhere.
|
||||
// Show the backend message as-is.
|
||||
const msg = err instanceof Error ? err.message : "Auth failed.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ export function storeAuthTokens(
|
|||
accessToken: string,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
// Callers set must_change_password via setMustChangePassword(). Routing it
|
||||
// through here would let CodeQL trace the boolean to localStorage and flag
|
||||
// the (deliberate) JWT writes as sensitive-info storage.
|
||||
// must_change_password is set via setMustChangePassword(), not here: routing
|
||||
// it through would let CodeQL trace the boolean into localStorage and flag the
|
||||
// deliberate JWT writes as sensitive-info storage.
|
||||
if (!canUseStorage()) return;
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken);
|
||||
|
|
@ -54,10 +54,9 @@ export function clearAuthTokens(): void {
|
|||
localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY);
|
||||
}
|
||||
|
||||
// Encode the flag as key presence (literal "1" or absence) so localStorage
|
||||
// receives a constant, not a derived boolean. Breaks the CodeQL data flow
|
||||
// from TokenResponse.must_change_password into localStorage.setItem; the
|
||||
// stored value is a route hint (/change-password vs /chat), not a secret.
|
||||
// Flag stored as key presence (constant "1" or absence), not a derived boolean,
|
||||
// so CodeQL doesn't flow must_change_password into localStorage.setItem. The
|
||||
// value is a route hint (/change-password vs /chat), not a secret.
|
||||
export function mustChangePassword(): boolean {
|
||||
if (!canUseStorage()) return false;
|
||||
return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) !== null;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ type TauriAutoAuthOptions = {
|
|||
force?: boolean;
|
||||
};
|
||||
|
||||
// Concurrency guard: multiple route guards can call tauriAutoAuth simultaneously.
|
||||
// Without this, the first-launch password-change could race with itself.
|
||||
// Concurrency guard: multiple route guards can call tauriAutoAuth at once;
|
||||
// without this the first-launch password-change could race with itself.
|
||||
let pending: { promise: Promise<boolean>; force: boolean } | null = null;
|
||||
let lastTauriAuthFailure: string | null = null;
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ async function doTauriAutoAuth(options: TauriAutoAuthOptions): Promise<boolean>
|
|||
return true;
|
||||
}
|
||||
|
||||
// Try refreshing existing session
|
||||
// Try refreshing an existing session.
|
||||
if (!options.force && hasRefreshToken()) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed && hasAuthToken() && !mustChangePassword()) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { isCustomProviderType } from "./external-providers";
|
||||
|
||||
/**
|
||||
* Registry logos live at `public/provider-logos/{provider_type}.{ext}` where `provider_type`
|
||||
* matches `PROVIDER_REGISTRY` keys exactly (lowercase). Extension varies by asset (svg preferred).
|
||||
* Registry logos at `public/provider-logos/{provider_type}.{ext}`; key matches
|
||||
* `PROVIDER_REGISTRY` (lowercase). Extension varies per asset (svg preferred).
|
||||
*/
|
||||
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
|
||||
openai: "svg",
|
||||
|
|
@ -41,9 +41,8 @@ interface ApiProviderLogoProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders the logo for a registry provider type when `provider_type.{ext}` exists under
|
||||
* `public/provider-logos/`.
|
||||
* OpenAI's asset is black-on-transparent; it is inverted in dark mode for contrast.
|
||||
* Renders a registry provider's logo when its asset exists under
|
||||
* `public/provider-logos/`. OpenAI's is inverted in dark mode for contrast.
|
||||
*/
|
||||
export function ApiProviderLogo({ providerType, className, title }: ApiProviderLogoProps) {
|
||||
const src = apiProviderLogoSrc(providerType);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ interface ServerUsage {
|
|||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
// External prompt-cache fields (see _build_usage_chunk in
|
||||
// external_provider.py). cache_creation is Anthropic-only.
|
||||
// external_provider.py); cache_creation is Anthropic-only.
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens?: number;
|
||||
};
|
||||
|
|
@ -107,9 +107,9 @@ type RunMessage = RunMessages[number];
|
|||
/** Tracks which user messages were sent with an audio file (messageId → filename). */
|
||||
export const sentAudioNames = new Map<string, string>();
|
||||
|
||||
// Synthetic provider-side tool names; backend stamps args._server_tool
|
||||
// so user functions with the same name aren't dropped. Mirror of
|
||||
// _SERVER_SIDE_BUILTIN_TOOL_NAMES on the backend.
|
||||
// Synthetic provider-side tool names; backend stamps args._server_tool so
|
||||
// user functions with the same name aren't dropped. Mirror of backend
|
||||
// _SERVER_SIDE_BUILTIN_TOOL_NAMES.
|
||||
const SERVER_SIDE_BUILTIN_TOOL_NAMES = new Set<string>([
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
|
|
@ -118,10 +118,10 @@ const SERVER_SIDE_BUILTIN_TOOL_NAMES = new Set<string>([
|
|||
]);
|
||||
|
||||
/**
|
||||
* Whether a persisted tool-call part is provider-side synthetic and
|
||||
* should be stripped from outbound history. Match on the
|
||||
* args._server_tool marker or a Gemini native_part payload — no shape
|
||||
* heuristic, because user functions can legitimately share a name.
|
||||
* Whether a persisted tool-call part is provider-side synthetic and should
|
||||
* be stripped from outbound history. Matches on the args._server_tool marker
|
||||
* or a Gemini native_part payload (no shape heuristic, since user functions
|
||||
* can legitimately share a name).
|
||||
*/
|
||||
function isServerSideBuiltinToolPart(
|
||||
toolNameLower: string,
|
||||
|
|
@ -176,23 +176,19 @@ export function useThreadAutosaveHandle(): ThreadAutosaveHandle {
|
|||
}
|
||||
|
||||
/**
|
||||
* Match error messages that indicate the request filled or would fill
|
||||
* the KV cache, so the UI can show a dedicated toast pointing at the
|
||||
* ``Context Length`` setting.
|
||||
* Match error messages indicating the request filled (or would fill) the KV
|
||||
* cache, so the UI can toast a pointer at the ``Context Length`` setting.
|
||||
*
|
||||
* Two wordings reach the client and both must hit:
|
||||
*
|
||||
* 1. The raw llama-server text when ``--no-context-shift`` trips --
|
||||
* 1. Raw llama-server text when ``--no-context-shift`` trips:
|
||||
* "the request exceeds the available context size (N tokens)".
|
||||
* 2. The rewritten friendly text emitted by
|
||||
* ``backend/routes/inference.py::_friendly_error`` -- "Message too
|
||||
* long: X tokens exceeds the Y-token context window. Try
|
||||
* increasing the Context Length ..." This is the one most users
|
||||
* see on the streaming GGUF path.
|
||||
* 2. The friendly rewrite from
|
||||
* ``backend/routes/inference.py::_friendly_error``: "Message too long:
|
||||
* ... context window. Try increasing the Context Length ..." (the one
|
||||
* most users see on the streaming GGUF path).
|
||||
*
|
||||
* We match on substrings rather than full regexes because both layers
|
||||
* have drifted across versions (llama.cpp master has tweaked the
|
||||
* phrasing; ``_friendly_error`` has gone through several copy edits).
|
||||
* Match substrings, not full regexes: both layers have drifted across
|
||||
* versions (llama.cpp phrasing and _friendly_error copy edits).
|
||||
*/
|
||||
export function isContextLimitError(message: string): boolean {
|
||||
if (!message) return false;
|
||||
|
|
@ -224,10 +220,10 @@ async function updateStoredChatThreadEventually(
|
|||
}
|
||||
|
||||
/**
|
||||
* Return ``raw`` when it is a safe-to-navigate http(s) URL, or "" otherwise.
|
||||
* Return ``raw`` when it is a safe-to-navigate http(s) URL, else "".
|
||||
* Rejects non-string input, CR/LF (header injection), and non-http(s)
|
||||
* schemes (``javascript:`` / ``data:`` / ``vbscript:``) so provider /
|
||||
* tool-controlled strings cannot land in an <a href>.
|
||||
* schemes (``javascript:`` / ``data:`` / ``vbscript:``) so provider/tool-
|
||||
* controlled strings cannot land in an <a href>.
|
||||
*/
|
||||
function isSafeNavigableSourceUrl(raw: unknown): string {
|
||||
if (typeof raw !== "string") return "";
|
||||
|
|
@ -264,10 +260,9 @@ function documentCitationToSource(
|
|||
"";
|
||||
const docIndex =
|
||||
typeof cit.document_index === "number" ? cit.document_index : undefined;
|
||||
// Only treat ``source`` as a navigable URL when it is real http(s);
|
||||
// Only treat ``source`` as navigable when it is real http(s);
|
||||
// search_result_location can carry a free-form id (e.g. ``kb-doc-42``)
|
||||
// or a hostile ``javascript:`` / ``data:`` / ``vbscript:`` string.
|
||||
// Fall back to a stable doc anchor otherwise.
|
||||
// or a hostile scheme. Fall back to a stable doc anchor otherwise.
|
||||
const url =
|
||||
isSafeNavigableSourceUrl(source) || `#anthropic-doc-${docIndex ?? fallbackIdx}`;
|
||||
const title = docTitle || source || `Document ${fallbackIdx + 1}`;
|
||||
|
|
@ -277,9 +272,8 @@ function documentCitationToSource(
|
|||
const description =
|
||||
cited.length > 240 ? `${cited.slice(0, 240)}...` : cited;
|
||||
// Anthropic numbers inline [N] per citation, not per source URL.
|
||||
// Fold citation type + position-bearing fields into the id so two
|
||||
// distinct citations on the same source (or two search_result_locations
|
||||
// with different search_result_index) keep separate Sources entries.
|
||||
// Fold citation type + position-bearing fields into the id so distinct
|
||||
// citations on the same source keep separate Sources entries.
|
||||
const citationType =
|
||||
typeof cit.type === "string" ? String(cit.type) : "";
|
||||
const positionParts = [
|
||||
|
|
@ -333,8 +327,8 @@ function parseSourcesFromResult(raw: string): {
|
|||
const snippetMatch = block.match(/Snippet:\s*(.+)/);
|
||||
if (titleMatch && urlMatch) {
|
||||
// Drop blocks whose ``URL:`` is not safe http(s); provider/tool
|
||||
// output is attacker-controllable so a hostile ``javascript:`` /
|
||||
// ``data:`` line must not reach the Sources panel <a href>.
|
||||
// output is attacker-controllable, so a hostile scheme must not
|
||||
// reach the Sources panel <a href>.
|
||||
const url = isSafeNavigableSourceUrl(urlMatch[1]);
|
||||
if (!url) continue;
|
||||
const snippet = snippetMatch?.[1]?.trim();
|
||||
|
|
@ -363,21 +357,18 @@ function estimateTokenCount(text: string): number | undefined {
|
|||
* Normalize a streamed `delta.content` to a plain text string.
|
||||
*
|
||||
* OpenAI Chat Completions originally typed `delta.content` as a string, but
|
||||
* a number of providers now emit it as an array of structured content parts.
|
||||
* Concatenating that with `cumulativeText += delta` would stringify each
|
||||
* part as `[object Object]` — this function is the guard against that.
|
||||
* some providers now emit an array of structured content parts; concatenating
|
||||
* those directly would stringify each as `[object Object]`. This guards that.
|
||||
*
|
||||
* Handled part shapes:
|
||||
* { type: "text" | "output_text", text | content: "..." } → text body
|
||||
* { type: "thinking" | "reasoning", thinking | text: "..." } → wrapped as
|
||||
* inline `<think>...</think>` so the downstream parser
|
||||
* (`parseAssistantContent`) lifts it into a reasoning part the same way
|
||||
* it does for providers that emit thinking inline. Without this wrap,
|
||||
* Mistral magistral and similar reasoning-part providers would lose
|
||||
* their thinking panel.
|
||||
* inline `<think>...</think>` so `parseAssistantContent` lifts it into
|
||||
* a reasoning part (else Mistral magistral and similar reasoning-part
|
||||
* providers lose their thinking panel).
|
||||
*
|
||||
* Unknown part types are skipped — better to drop a stray field than to
|
||||
* stringify an object and pollute the rendered chat with `[object Object]`.
|
||||
* Unknown part types are skipped — better to drop a stray field than
|
||||
* stringify an object into the rendered chat.
|
||||
*/
|
||||
function extractDeltaText(delta: unknown): string {
|
||||
const extractReasoningText = (payload: unknown): string => {
|
||||
|
|
@ -564,8 +555,8 @@ function toOpenAIImageEditReferenceMessage(
|
|||
|
||||
// Refusal flag stamped on assistant metadata when the backend emits the
|
||||
// `anthropic_refusal` _toolEvent. We drop the refused pair from the next
|
||||
// request body (Anthropic guidance: leaving refusals in context keeps
|
||||
// refusing). Metadata (not text) prevents content from spoofing a reset.
|
||||
// request body (Anthropic: leaving refusals in context keeps refusing).
|
||||
// Using metadata, not text, prevents content from spoofing a reset.
|
||||
function isAnthropicRefusalMessage(message: RunMessage): boolean {
|
||||
if (message.role !== "assistant") return false;
|
||||
const metadata = (message as { metadata?: unknown }).metadata as
|
||||
|
|
@ -618,8 +609,8 @@ function collectAssistantToolCalls(
|
|||
hasNativePart,
|
||||
);
|
||||
if (isServerSideBuiltin) {
|
||||
// Gemini code_execution / image_generation still need to round-
|
||||
// trip the native_part payload for native replay; drop the rest.
|
||||
// Gemini code_execution / image_generation must round-trip the
|
||||
// native_part payload for native replay; drop the rest.
|
||||
if (!hasNativePart) continue;
|
||||
}
|
||||
const argumentsStr =
|
||||
|
|
@ -640,8 +631,8 @@ function collectAssistantToolCalls(
|
|||
},
|
||||
};
|
||||
// Promote args.google to extra_content.google so the backend
|
||||
// native_part replay branch can find it. The backend only inspects
|
||||
// extra_content, not function.arguments.
|
||||
// native_part replay branch finds it (it only inspects
|
||||
// extra_content, not function.arguments).
|
||||
if (tc.extra_content !== undefined) {
|
||||
entry.extra_content = tc.extra_content;
|
||||
} else if (argsGoogle) {
|
||||
|
|
@ -670,7 +661,7 @@ function collectToolResultMessages(
|
|||
if (part.type !== "tool-call") continue;
|
||||
const tc = part as ToolCallMessagePart;
|
||||
const result = (tc as { result?: unknown }).result;
|
||||
// Skip provider-side builtins; see isServerSideBuiltinToolPart.
|
||||
// Skip provider-side builtins; see isServerSideBuiltinToolPart().
|
||||
const argsObj =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
|
|
@ -702,8 +693,8 @@ function collectToolResultMessages(
|
|||
let content: string;
|
||||
if (typeof result === "string") {
|
||||
// Backend ChatMessage validator rejects role="tool" with empty
|
||||
// content; serialise a sentinel JSON so legitimately empty tool
|
||||
// outputs still round-trip the follow-up turn to the provider.
|
||||
// content; serialise sentinel JSON so legitimately empty tool
|
||||
// outputs still round-trip to the provider.
|
||||
content = result.length > 0 ? result : JSON.stringify({ result: "" });
|
||||
} else {
|
||||
try {
|
||||
|
|
@ -774,8 +765,8 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
|
|||
"[audio]",
|
||||
);
|
||||
if (isAnthropicRefusalMessage(message)) {
|
||||
// Prune refused assistant turn from outbound history; the
|
||||
// rendered transcript still shows the user-visible notice.
|
||||
// Prune refused assistant turn from outbound history; the rendered
|
||||
// transcript still shows the user-visible notice.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -795,9 +786,9 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
|
|||
};
|
||||
if (toolCalls.length > 0) {
|
||||
base.tool_calls = toolCalls;
|
||||
// OpenAI requires content === null on assistant turns whose
|
||||
// payload is entirely tool_calls (matches the wire shape Gemini
|
||||
// expects for the next functionCall replay).
|
||||
// OpenAI requires content === null on assistant turns that are
|
||||
// entirely tool_calls (matches the wire shape Gemini expects for
|
||||
// the next functionCall replay).
|
||||
if (!textContent && imageParts.length === 0) {
|
||||
base.content = null;
|
||||
}
|
||||
|
|
@ -830,7 +821,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Image in message.content (e.g. compare view appends content with image parts)
|
||||
// Image in message.content (e.g. compare view).
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type === "image" && "image" in part) {
|
||||
const encoded = extractImageBase64(part.image);
|
||||
|
|
@ -838,7 +829,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
// Image in message.attachments (e.g. chat composer)
|
||||
// Image in message.attachments (e.g. chat composer).
|
||||
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
|
||||
for (const attachment of message.attachments ?? []) {
|
||||
for (const part of attachment.content ?? []) {
|
||||
|
|
@ -858,7 +849,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
|
|||
}
|
||||
|
||||
function findLatestUserAudioBase64(messages: RunMessages): string | undefined {
|
||||
// Check message content parts (from compare view's CompareMessagePart with type: "audio")
|
||||
// Message content parts (compare view CompareMessagePart type: "audio").
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (!message || message.role !== "user") continue;
|
||||
|
|
@ -877,7 +868,7 @@ function findLatestUserAudioBase64(messages: RunMessages): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
// Check the runtime store (from main composer's audio upload)
|
||||
// Runtime store (main composer's audio upload).
|
||||
const pendingAudio = useChatRuntimeStore.getState().pendingAudioBase64;
|
||||
return pendingAudio ?? undefined;
|
||||
}
|
||||
|
|
@ -893,8 +884,8 @@ async function resolveUseAdapter(
|
|||
if (!thread?.pairId) {
|
||||
return undefined;
|
||||
}
|
||||
// model1/model2 threads don't use the adapter toggle — each side
|
||||
// loads its own model via /api/inference/load before generation.
|
||||
// model1/model2 threads skip the adapter toggle — each side loads
|
||||
// its own model via /api/inference/load before generation.
|
||||
if (thread.modelType === "model1" || thread.modelType === "model2") {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -962,9 +953,9 @@ function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Auto-load the smallest downloaded model when the user tries to chat
|
||||
* without selecting one. Prefers GGUF (picks smallest cached variant),
|
||||
* falls back to smallest cached safetensors model.
|
||||
* Auto-load the smallest downloaded model when the user chats without
|
||||
* selecting one. Prefers GGUF (smallest cached variant), then smallest
|
||||
* cached safetensors model.
|
||||
*/
|
||||
// Cap cascade so broken cached repos can't spam /api/inference/load.
|
||||
const MAX_AUTO_LOAD_ATTEMPTS = 3;
|
||||
|
|
@ -1009,8 +1000,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
listCachedModels().catch(() => []),
|
||||
]);
|
||||
|
||||
// Try GGUF first: pick the repo with the smallest total size,
|
||||
// then pick its smallest downloaded variant.
|
||||
// GGUF first: smallest-total-size repo, then its smallest variant.
|
||||
if (ggufRepos.length > 0) {
|
||||
const sorted = [...ggufRepos].sort((a, b) => a.size_bytes - b.size_bytes);
|
||||
for (const repo of sorted) {
|
||||
|
|
@ -1053,7 +1043,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
...store.params,
|
||||
maxTokens: loadResp.context_length ?? 131072,
|
||||
});
|
||||
// Add model to store so the selector shows the name
|
||||
// Add to store so the selector shows the name.
|
||||
const autoModel: ChatModelSummary = {
|
||||
id: repo.repo_id,
|
||||
name: loadResp.display_name ?? repo.repo_id,
|
||||
|
|
@ -1101,7 +1091,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
}
|
||||
|
||||
// Fall back to safetensors models
|
||||
// Fall back to safetensors models.
|
||||
if (modelRepos.length > 0) {
|
||||
const sorted = [...modelRepos].sort(
|
||||
(a, b) => a.size_bytes - b.size_bytes,
|
||||
|
|
@ -1171,7 +1161,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
}
|
||||
|
||||
// Cap also gates the default download so the total /api/inference/load
|
||||
// Cap also gates the default download, so total /api/inference/load
|
||||
// budget across cached + fallback is MAX_AUTO_LOAD_ATTEMPTS, not +1.
|
||||
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) {
|
||||
toast.dismiss(toastId);
|
||||
|
|
@ -1182,7 +1172,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
// No cached models found — try downloading a small default GGUF
|
||||
// No cached models — try downloading a small default GGUF.
|
||||
toast("Downloading a small model…", {
|
||||
id: toastId,
|
||||
description:
|
||||
|
|
@ -1275,8 +1265,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
async *run({ messages, abortSignal, unstable_threadId }) {
|
||||
await useChatRuntimeStore.getState().hydratePersistedSettings();
|
||||
let runtime = useChatRuntimeStore.getState();
|
||||
// Capture the thread ID once at the start so it stays stable even if
|
||||
// the user switches chats while waiting for model load / auto-load.
|
||||
// Capture the thread ID once so it stays stable even if the user
|
||||
// switches chats while waiting for model load / auto-load.
|
||||
const resolvedThreadId =
|
||||
(unstable_threadId ?? runtime.activeThreadId) || undefined;
|
||||
const sandboxSessionId = await resolveSandboxSessionId(resolvedThreadId);
|
||||
|
|
@ -1305,7 +1295,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
};
|
||||
|
||||
// Wait for in-progress model load to finish before inferring
|
||||
// Wait for in-progress model load before inferring.
|
||||
if (runtime.modelLoading) {
|
||||
toast.info("Waiting for model to finish loading…");
|
||||
try {
|
||||
|
|
@ -1317,7 +1307,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
|
||||
if (!useChatRuntimeStore.getState().params.checkpoint) {
|
||||
// Auto-load the smallest downloaded model
|
||||
let loaded: boolean;
|
||||
let blockedByTrustRemoteCode: boolean;
|
||||
try {
|
||||
|
|
@ -1342,7 +1331,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-read store after potential auto-load / model ready wait
|
||||
// Re-read store after auto-load / model-ready wait.
|
||||
runtime = useChatRuntimeStore.getState();
|
||||
const { params } = runtime;
|
||||
const {
|
||||
|
|
@ -1405,8 +1394,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
throw new Error("Missing connection API key.");
|
||||
}
|
||||
|
||||
// Image-generation flag (OpenAI cloud + Responses-capable model).
|
||||
// Computed first so Gemini image mode can suppress Search/Code.
|
||||
// Image-generation flag (OpenAI cloud + Responses-capable model);
|
||||
// computed first so Gemini image mode can suppress Search/Code.
|
||||
const imageGenerationEnabledForThisTurn = Boolean(
|
||||
externalProvider &&
|
||||
externalSelection &&
|
||||
|
|
@ -1444,9 +1433,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
),
|
||||
);
|
||||
// Fetch pill is independent of Search (Anthropic bills web_fetch
|
||||
// separately from web_search). Sourced from `webFetchToolsEnabled`;
|
||||
// on providers without web_fetch the toggle is forced off in
|
||||
// chat-page's runtime setState.
|
||||
// separately). Sourced from `webFetchToolsEnabled`; on providers
|
||||
// without web_fetch the toggle is forced off in chat-page setState.
|
||||
const webFetchEnabledForThisTurn = Boolean(
|
||||
externalProvider &&
|
||||
webFetchToolsEnabled &&
|
||||
|
|
@ -1482,7 +1470,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
|
||||
// toOpenAIMessages emits assistant tool_calls + role="tool"
|
||||
// follow-ups; the backend Gemini translator rebuilds the
|
||||
// functionCall/functionResponse parts (with thoughtSignature).
|
||||
// functionCall / functionResponse parts (with thoughtSignature).
|
||||
const outboundMessages = survivingMessages
|
||||
.flatMap(toOpenAIMessages)
|
||||
.filter((message): message is NonNullable<typeof message> =>
|
||||
|
|
@ -1508,9 +1496,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
// OpenAIChatMessage is a structural superset of SerializedMessage
|
||||
// for the role/content axis the outbound pipeline consumes; cast
|
||||
// through unknown since referenceMessage carries no tool_calls
|
||||
// (the image_edit reference is a plain assistant turn).
|
||||
// on the role/content axis; cast through unknown since
|
||||
// referenceMessage carries no tool_calls (plain assistant turn).
|
||||
outboundMessages.splice(
|
||||
insertAt,
|
||||
0,
|
||||
|
|
@ -1545,10 +1532,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
const webLabel = providerShipsWebFetch
|
||||
? "web search or web fetch"
|
||||
: "web search";
|
||||
// Treat search and fetch as a single "any web tool" axis so
|
||||
// the guard only warns when neither pill is on; checking
|
||||
// webSearchEnabledForThisTurn alone mis-fired when only Fetch
|
||||
// was on and suppressed live web_fetch calls.
|
||||
// Treat search and fetch as one "any web tool" axis so the guard
|
||||
// only warns when neither pill is on; checking webSearch alone
|
||||
// mis-fired when only Fetch was on and suppressed web_fetch.
|
||||
const anyWebEnabledForThisTurn =
|
||||
webSearchEnabledForThisTurn || webFetchEnabledForThisTurn;
|
||||
if (
|
||||
|
|
@ -1625,15 +1611,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
|
||||
// Scan post-prune history so a refused user turn's image/audio
|
||||
// doesn't gate or mis-attribute the next non-refused turn.
|
||||
// doesn't gate or mis-attribute the next turn.
|
||||
const imageBase64 = findLatestUserImageBase64(survivingMessages);
|
||||
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
|
||||
const hasOutboundImage = Boolean(imageBase64);
|
||||
|
||||
// Keep render_html local-only and mirror the backend image-turn gate.
|
||||
// Artifacts are independent of Search/Code: if a local tool-capable
|
||||
// model has Artifacts enabled, expose render_html even when no other
|
||||
// tool pills are active.
|
||||
// Artifacts are independent of Search/Code: a local tool-capable model
|
||||
// with Artifacts on exposes render_html even with no other pills active.
|
||||
const renderHtmlToolEnabledForThisTurn = Boolean(
|
||||
!isExternalRequest &&
|
||||
supportsTools &&
|
||||
|
|
@ -1652,10 +1637,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
|
||||
addSystemInstruction(outboundMessages, artifactInstruction);
|
||||
|
||||
// Block when ANY image is in the outbound payload (current or
|
||||
// prior turns) and the loaded model can't process images. Keeps
|
||||
// the gate simple: once a chat contains an image, a non-vision
|
||||
// model can't respond — user starts a new chat to switch models.
|
||||
// Block when ANY image is in the outbound payload (current or prior
|
||||
// turns) and the loaded model can't process images. Once a chat
|
||||
// contains an image, a non-vision model can't respond — the user
|
||||
// starts a new chat to switch models.
|
||||
if (imageBase64) {
|
||||
const activeModel = runtime.models.find(
|
||||
(m) => m.id === params.checkpoint,
|
||||
|
|
@ -1672,10 +1657,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
});
|
||||
if (imageGateReason) {
|
||||
toast.error(imageGateReason);
|
||||
// Flip the per-thread running flag on→off so the compare-mode
|
||||
// waitForRunEnd resolves instead of hanging. This gate fires
|
||||
// before the streaming path's setThreadRunning(true), so the
|
||||
// wait promise would otherwise never settle.
|
||||
// Flip the per-thread running flag on→off so compare-mode
|
||||
// waitForRunEnd resolves instead of hanging: this gate fires
|
||||
// before the streaming path's setThreadRunning(true).
|
||||
const gatedThreadKey = resolvedThreadId || "__default";
|
||||
runtime.setThreadRunning(gatedThreadKey, true);
|
||||
runtime.setThreadRunning(gatedThreadKey, false);
|
||||
|
|
@ -1683,7 +1667,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
throw new Error(imageGateReason);
|
||||
}
|
||||
}
|
||||
// Clear pending audio from store after extracting (consumed on send)
|
||||
// Clear pending audio from store after extracting (consumed on send).
|
||||
if (audioBase64) {
|
||||
const audioName = runtime.pendingAudioName;
|
||||
if (audioName) {
|
||||
|
|
@ -1785,8 +1769,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
let reasoningStartAt: number | null = null;
|
||||
let reasoningDuration = 0;
|
||||
// True while wrapping a `delta.reasoning_content` stream in
|
||||
// <think>...</think> for parseAssistantContent. Lives outside
|
||||
// the SSE loop because the close tag fires when content arrives.
|
||||
// <think>...</think> for parseAssistantContent. Lives outside the
|
||||
// SSE loop because the close tag fires when content arrives.
|
||||
let reasoningContentOpen = false;
|
||||
// Tool call parts, cumulative; result lands on tool_end.
|
||||
const toolCallParts: ToolCallMessagePart[] = [];
|
||||
|
|
@ -1819,9 +1803,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
);
|
||||
return [...otherToolParts, ...textParts, ...imageToolParts];
|
||||
};
|
||||
// Anthropic document_citations tool_event payload, converted to
|
||||
// Sources-panel source parts at end-of-stream so the inline [N]
|
||||
// markers have matching entries.
|
||||
// Anthropic document_citations payload, converted to Sources-panel
|
||||
// parts at end-of-stream so inline [N] markers have matching entries.
|
||||
const documentCitationParts: Array<{
|
||||
type: "source";
|
||||
sourceType: "url";
|
||||
|
|
@ -1830,16 +1813,16 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
title: string;
|
||||
metadata?: { description: string };
|
||||
}> = [];
|
||||
// Latched on the `anthropic_refusal` tool event; stamped onto the
|
||||
// final assistant metadata as `custom.anthropicRefusal` to drive
|
||||
// the history-prune above.
|
||||
// Latched on the `anthropic_refusal` tool event; stamped onto final
|
||||
// assistant metadata as `custom.anthropicRefusal` to drive the
|
||||
// history-prune above.
|
||||
let anthropicRefusalSeen = false;
|
||||
let serverMetadata: {
|
||||
usage?: ServerUsage;
|
||||
timings?: ServerTimings;
|
||||
} | null = null;
|
||||
|
||||
// Per-run cancellation token so a delayed stop POST cannot match
|
||||
// Per-run cancellation token so a delayed stop POST can't match
|
||||
// the next run on the same thread.
|
||||
const cancelId =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
|
|
@ -1854,10 +1837,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// Plain fetch, not authFetch: authFetch redirects to login on
|
||||
// 401, which would kick the user out mid-stop.
|
||||
const token = getAuthToken();
|
||||
// Use apiUrl so the cancel POST reaches the right origin in
|
||||
// Tauri production builds (where the webview origin is not the
|
||||
// backend at 127.0.0.1:<port>). Browser/dev builds get the empty
|
||||
// base, so the path is unchanged there.
|
||||
// Use apiUrl so the cancel POST reaches the right origin in Tauri
|
||||
// production builds (webview origin != backend at 127.0.0.1:<port>).
|
||||
// Browser/dev builds get the empty base, so the path is unchanged.
|
||||
void fetch(apiUrl("/api/inference/cancel"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -1948,8 +1930,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
anthropicCodeExecContainerId = null;
|
||||
}
|
||||
// Pre-send container validation (OpenAI). Stale ids drop
|
||||
// silently and fall through to lazy-create. On list-call
|
||||
// failure, skip and rely on the backend's retry path.
|
||||
// silently and fall through to lazy-create; on list-call
|
||||
// failure, rely on the backend's retry path.
|
||||
let activeContainerIds: Set<string> | null = null;
|
||||
if (externalProvider.providerType === "openai") {
|
||||
try {
|
||||
|
|
@ -1972,8 +1954,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
openaiCodeExecContainerId = null;
|
||||
}
|
||||
}
|
||||
// Cross-thread inheritance: reuse the most recently used
|
||||
// container from any other thread; opt-out via the picker.
|
||||
// Cross-thread inheritance: reuse the most recent container
|
||||
// from any other thread; opt-out via the picker.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
|
|
@ -1985,7 +1967,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
for (const t of others) {
|
||||
if (t.id === resolvedThreadId) continue;
|
||||
if (!t.openaiCodeExecContainerId) continue;
|
||||
// Skip ids not in active set; null on source thread so
|
||||
// Skip ids not in active set; null the source thread so
|
||||
// the next pass doesn't re-pick a dead id.
|
||||
if (
|
||||
activeContainerIds &&
|
||||
|
|
@ -2006,9 +1988,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
/* fall through to lazy-create below */
|
||||
}
|
||||
}
|
||||
// Pre-create our own container (vs container_auto) so it
|
||||
// shows up in the picker with a friendly name and the
|
||||
// configured TTL. Falls back to container_auto on failure.
|
||||
// Pre-create our own container (vs container_auto) so it shows
|
||||
// in the picker with a friendly name and the configured TTL.
|
||||
// Falls back to container_auto on failure.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
|
|
@ -2022,10 +2004,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
baseUrl: externalProvider.baseUrl || null,
|
||||
},
|
||||
{
|
||||
// Friendly English-word name so the container
|
||||
// is human-readable in the picker list (e.g.
|
||||
// "kestrel-3f9c") instead of a thread-id slug
|
||||
// or OpenAI's default blank name.
|
||||
// Friendly English-word name so the container is
|
||||
// human-readable in the picker (e.g. "kestrel-3f9c")
|
||||
// instead of a thread-id slug or blank default.
|
||||
name: pickFriendlyContainerName(),
|
||||
ttlMinutes: ttlToUse,
|
||||
},
|
||||
|
|
@ -2035,10 +2016,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
openaiCodeExecContainerId: created.id,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Fall back to backend's container_auto path on
|
||||
// failure — keeps the chat moving; the next turn
|
||||
// can retry. The auto-created container will be
|
||||
// unnamed, but the chat doesn't break.
|
||||
// Fall back to the backend's container_auto path on
|
||||
// failure — keeps the chat moving (the auto-created
|
||||
// container is unnamed); the next turn can retry.
|
||||
openaiCodeExecContainerId = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2047,8 +2027,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
model: externalSelection.modelId,
|
||||
messages: outboundMessages,
|
||||
stream: true,
|
||||
// Reasoning-class models (OpenAI gpt-5.x / o3) reject temperature
|
||||
// and top_p; only forward when the active provider supports them.
|
||||
// Reasoning-class models (OpenAI gpt-5.x / o3) reject
|
||||
// temperature and top_p; forward only when supported.
|
||||
...(externalCapabilities?.temperature !== false
|
||||
? { temperature: params.temperature }
|
||||
: {}),
|
||||
|
|
@ -2072,8 +2052,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
...(externalCapabilities?.presencePenalty
|
||||
? { presence_penalty: params.presencePenalty }
|
||||
: {}),
|
||||
// Compose the enabled_tools list from the active pills;
|
||||
// backend maps each name to the provider's tool schema.
|
||||
// enabled_tools from active pills; backend maps each name
|
||||
// to the provider's tool schema.
|
||||
...(webSearchEnabledForThisTurn ||
|
||||
webFetchEnabledForThisTurn ||
|
||||
codeExecEnabledForThisTurn ||
|
||||
|
|
@ -2128,8 +2108,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
? { prompt_cache_ttl: externalProvider.promptCacheTtl }
|
||||
: {}),
|
||||
// Anthropic fast mode (Opus 4.6 / 4.7 only); backend
|
||||
// silently drops on unsupported models as a second
|
||||
// line of defence.
|
||||
// silently drops on unsupported models as a backstop.
|
||||
...(params.fastMode &&
|
||||
providerSupportsFastMode(
|
||||
externalProvider.providerType,
|
||||
|
|
@ -2230,8 +2209,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
|
||||
// Emit tool-call content parts for assistant-ui.
|
||||
// On tool_start: add a new tool-call part (renders in "running" state).
|
||||
// On tool_end: set result on the existing part (transitions to "complete").
|
||||
// tool_start: add a part (renders "running").
|
||||
// tool_end: set result on the part (transitions to "complete").
|
||||
const toolEvent = (
|
||||
chunk as unknown as { _toolEvent?: Record<string, unknown> }
|
||||
)._toolEvent;
|
||||
|
|
@ -2253,8 +2232,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
continue;
|
||||
}
|
||||
if (toolEvent.type === "document_citations") {
|
||||
// Convert Anthropic citations_delta footnotes into
|
||||
// Sources-panel entries matching the inline [N] markers.
|
||||
// Convert citations_delta footnotes into Sources-panel
|
||||
// entries matching the inline [N] markers.
|
||||
const cits = toolEvent.citations;
|
||||
if (Array.isArray(cits)) {
|
||||
cits.forEach((entry, idx) => {
|
||||
|
|
@ -2286,8 +2265,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
continue;
|
||||
}
|
||||
if (toolEvent.type === "anthropic_refusal") {
|
||||
// Latch the backend refusal signal so the final
|
||||
// message metadata can drive the prune.
|
||||
// Latch the backend refusal signal so final message
|
||||
// metadata can drive the prune.
|
||||
anthropicRefusalSeen = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -2346,7 +2325,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
imageB64
|
||||
) {
|
||||
// Backend keeps base64 on separate image_b64 /
|
||||
// image_mime fields so logs stay small; repackage.
|
||||
// image_mime fields so logs stay small; repackage here.
|
||||
parsedResult = {
|
||||
image_b64: imageB64,
|
||||
image_mime:
|
||||
|
|
@ -2359,8 +2338,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
};
|
||||
} else if (imgIdx !== -1) {
|
||||
const text = rawResult.slice(0, imgIdx);
|
||||
// Fall back to "_default" to match the backend sandbox directory
|
||||
// used when no session_id is provided (see tools.py _get_workdir).
|
||||
// Fall back to "_default" to match the backend sandbox
|
||||
// dir used when no session_id (see tools.py _get_workdir).
|
||||
const sessionId = sandboxSessionId || "_default";
|
||||
try {
|
||||
const images = JSON.parse(
|
||||
|
|
@ -2386,7 +2365,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// Merge tool_end native_part into args.google so the
|
||||
// outbound translator replays both start (executableCode)
|
||||
// and end (result / inlineData) on the same turn.
|
||||
// Concatenate parts so each keeps its own thoughtSignature.
|
||||
// Concatenate so each part keeps its own thoughtSignature.
|
||||
const endGoogle = (
|
||||
toolEvent as { google?: { native_part?: unknown } }
|
||||
).google;
|
||||
|
|
@ -2410,9 +2389,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
string,
|
||||
unknown
|
||||
>;
|
||||
// Extract part entries from either parts:[...] or
|
||||
// legacy single-object native_part. Legacy
|
||||
// thoughtSignature always belongs on executableCode.
|
||||
// Extract part entries from parts:[...] or legacy
|
||||
// single-object native_part. Legacy thoughtSignature
|
||||
// always belongs on executableCode.
|
||||
const collectParts = (
|
||||
native: Record<string, unknown>,
|
||||
): Record<string, unknown>[] => {
|
||||
|
|
@ -2466,8 +2445,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
};
|
||||
}
|
||||
}
|
||||
// Cumulative yield. orderAssistantContent puts search/
|
||||
// code before text and generated images after.
|
||||
// Cumulative yield; orderAssistantContent puts search/code
|
||||
// before text and generated images after.
|
||||
const textParts = pinTextThoughtSignature(
|
||||
parseAssistantContent(cumulativeText),
|
||||
);
|
||||
|
|
@ -2485,7 +2464,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
continue;
|
||||
}
|
||||
|
||||
// OpenAI-standard usage chunk: choices=[], usage populated
|
||||
// OpenAI-standard usage chunk: choices=[], usage populated.
|
||||
if (chunk.choices?.length === 0 && chunk.usage) {
|
||||
serverMetadata = {
|
||||
usage: chunk.usage,
|
||||
|
|
@ -2497,8 +2476,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
|
||||
totalChunks += 1;
|
||||
// Latch the chunk's `model` field so the openrouter/free
|
||||
// chip can show the chosen underlying model.
|
||||
// Latch the chunk's `model` field so the openrouter/free chip
|
||||
// shows the chosen underlying model.
|
||||
if (
|
||||
isExternalRequest &&
|
||||
externalProvider?.providerType === "openrouter" &&
|
||||
|
|
@ -2517,7 +2496,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
const rawDelta = chunk.choices?.[0]?.delta?.content;
|
||||
// Normalize structured delta.content (mistral magistral) to text.
|
||||
// Normalize structured delta.content (mistral magistral).
|
||||
const delta = extractDeltaText(rawDelta);
|
||||
// Latest Gemini text-part thoughtSignature for next-turn replay.
|
||||
const deltaExtraContent = (
|
||||
|
|
@ -2539,15 +2518,15 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Kimi / DeepSeek stream thinking via delta.reasoning_content.
|
||||
// Wrap inline as <think>...</think> for parseAssistantContent.
|
||||
// Kimi / DeepSeek stream thinking via delta.reasoning_content;
|
||||
// wrap inline as <think>...</think> for parseAssistantContent.
|
||||
const rawReasoning = (
|
||||
chunk.choices?.[0]?.delta as
|
||||
| { reasoning_content?: unknown }
|
||||
| undefined
|
||||
)?.reasoning_content;
|
||||
// OpenRouter ships reasoning as delta.reasoning_details[]
|
||||
// regardless of underlying provider; merge into the same wrap path.
|
||||
// regardless of provider; merge into the same wrap path.
|
||||
const rawReasoningDetails = (
|
||||
chunk.choices?.[0]?.delta as
|
||||
| { reasoning_details?: unknown }
|
||||
|
|
@ -2567,7 +2546,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
reasoningFromDetails;
|
||||
// OpenAI delta.tool_calls: streams fragments by index;
|
||||
// accumulate into one part. extra_content carries Gemini 3
|
||||
// thoughtSignature for next-turn replay.
|
||||
// thoughtSignature for replay.
|
||||
const rawDeltaToolCalls = (
|
||||
chunk.choices?.[0]?.delta as
|
||||
| { tool_calls?: unknown }
|
||||
|
|
@ -2588,9 +2567,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
const idx =
|
||||
typeof call.index === "number" ? call.index : undefined;
|
||||
const stableId = call.id;
|
||||
// Match an existing fragment by id first (canonical),
|
||||
// then by index slot. Fall back to a freshly-minted
|
||||
// tool_call_<n> id for streams that send neither.
|
||||
// Match an existing fragment by id first (canonical), then
|
||||
// by index slot; fall back to a minted tool_call_<n> id
|
||||
// for streams that send neither.
|
||||
let existing = stableId
|
||||
? toolCallParts.find((p) => p.toolCallId === stableId)
|
||||
: undefined;
|
||||
|
|
@ -2777,9 +2756,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
throw streamError;
|
||||
}
|
||||
}
|
||||
// If the stream ended while we were still inside a
|
||||
// delta.reasoning_content block (Kimi / DeepSeek path), close
|
||||
// the open <think> tag so the reasoning panel parses cleanly.
|
||||
// If the stream ended inside a delta.reasoning_content block
|
||||
// (Kimi / DeepSeek), close the open <think> tag so the reasoning
|
||||
// panel parses cleanly.
|
||||
if (reasoningContentOpen) {
|
||||
cumulativeText += "</think>";
|
||||
reasoningContentOpen = false;
|
||||
|
|
@ -2787,9 +2766,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
settleFirstTokenOk();
|
||||
|
||||
// Extract source parts from completed web_search and web_fetch
|
||||
// tool calls. Both emit the same `Title:` / `URL:` / `Snippet:`
|
||||
// block shape from the Anthropic backend, so the parser does
|
||||
// not need to branch on tool name.
|
||||
// calls. Both emit the same `Title:` / `URL:` / `Snippet:` block
|
||||
// shape, so the parser need not branch on tool name.
|
||||
const sourceParts = toolCallParts.flatMap((tc) => {
|
||||
if (
|
||||
(tc.toolName !== "web_search" && tc.toolName !== "web_fetch") ||
|
||||
|
|
@ -2818,8 +2796,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
const cacheWriteTokens = meta?.usage?.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Gate on the captured checkpoint still being active so a late
|
||||
// completion from provider A doesn't populate the bar after the
|
||||
// user switched to provider B mid-stream.
|
||||
// completion from provider A doesn't populate the bar after a
|
||||
// mid-stream switch to provider B.
|
||||
if (
|
||||
meta?.usage &&
|
||||
typeof meta.usage.prompt_tokens === "number" &&
|
||||
|
|
@ -2882,10 +2860,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
if (!abortSignal.aborted) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (isContextLimitError(msg)) {
|
||||
// llama-server was launched with --no-context-shift, so it
|
||||
// returns a hard error instead of silently dropping old
|
||||
// turns from the KV cache. Point the user at the exact
|
||||
// control that raises the ceiling.
|
||||
// llama-server runs with --no-context-shift, returning a hard
|
||||
// error instead of silently dropping old KV-cache turns. Point
|
||||
// the user at the control that raises the ceiling.
|
||||
toast.error("Context limit reached", {
|
||||
description:
|
||||
"The conversation has filled the model's context window. " +
|
||||
|
|
|
|||
|
|
@ -141,9 +141,8 @@ export interface DownloadProgressResponse {
|
|||
expected_bytes: number;
|
||||
progress: number;
|
||||
/**
|
||||
* Resolved on-disk path of the snapshot dir (or cache repo root if no
|
||||
* snapshot exists yet). Null when nothing has been written to the
|
||||
* cache for this repo.
|
||||
* On-disk path of the snapshot dir (or cache repo root if no snapshot yet).
|
||||
* Null when nothing has been written to the cache for this repo.
|
||||
*/
|
||||
cache_path: string | null;
|
||||
}
|
||||
|
|
@ -168,9 +167,8 @@ export type ModelLoadPhase = "mmap" | "ready" | null;
|
|||
|
||||
export interface LoadProgressResponse {
|
||||
/**
|
||||
* Load phase: ``"mmap"`` while the llama-server subprocess is paging
|
||||
* weight shards into RAM, ``"ready"`` once it has reported healthy,
|
||||
* or ``null`` when no load is in flight.
|
||||
* Load phase: "mmap" while llama-server pages weight shards into RAM,
|
||||
* "ready" once healthy, or null when no load is in flight.
|
||||
*/
|
||||
phase: ModelLoadPhase;
|
||||
bytes_loaded: number;
|
||||
|
|
@ -179,10 +177,9 @@ export interface LoadProgressResponse {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch the active GGUF load's mmap/upload progress. Complements
|
||||
* ``getDownloadProgress`` / ``getGgufDownloadProgress`` for the window
|
||||
* between "download complete" and "chat ready", which for large MoE
|
||||
* models can be several minutes of otherwise-opaque spinning.
|
||||
* Fetch the active GGUF load's mmap/upload progress. Complements the download
|
||||
* progress endpoints for the "download complete" -> "chat ready" window, which
|
||||
* for large MoE models can be several minutes of otherwise-opaque spinning.
|
||||
*/
|
||||
export async function getLoadProgress(): Promise<LoadProgressResponse> {
|
||||
const response = await authFetch(`/api/inference/load-progress`);
|
||||
|
|
@ -551,16 +548,14 @@ export async function buildBackendChatExport(): Promise<{
|
|||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
// Legacy-Dexie import ledger. The server-side source of truth that
|
||||
// replaces the boolean localStorage sentinel
|
||||
// (`unsloth_chat_legacy_imported_to_studio_db`) so a studio.db wipe
|
||||
// makes the import recoverable.
|
||||
// Legacy-Dexie import ledger: server-side source of truth replacing the
|
||||
// boolean localStorage sentinel, so a studio.db wipe keeps the import
|
||||
// recoverable.
|
||||
export async function listChatImportLedger(): Promise<Set<string>> {
|
||||
const response = await authFetch("/api/chat/import-ledger");
|
||||
// Backend deployments that don't have this endpoint yet behave the
|
||||
// same as an empty ledger -- caller treats every legacy thread as
|
||||
// un-imported and tries to import. The UPSERT semantics in
|
||||
// syncChatMessages prevent duplicates, so this fallback is safe.
|
||||
// Backends without this endpoint behave like an empty ledger -- caller
|
||||
// re-imports every legacy thread. syncChatMessages UPSERTs prevent
|
||||
// duplicates, so this fallback is safe.
|
||||
if (response.status === 404 || response.status === 405) return new Set();
|
||||
const data = await parseJsonOrThrow<{ threadIds: string[] }>(response);
|
||||
return new Set(data.threadIds);
|
||||
|
|
@ -569,9 +564,9 @@ export async function listChatImportLedger(): Promise<Set<string>> {
|
|||
export interface RecordChatImportLedgerResult {
|
||||
accepted: number;
|
||||
inserted: number;
|
||||
// false when the backend predates /api/chat/import-ledger (404/405/501)
|
||||
// so the caller can avoid poisoning the localStorage perf hint -- the
|
||||
// next launch will retry the (idempotent) import.
|
||||
// false when the backend predates /api/chat/import-ledger (404/405/501) so
|
||||
// the caller avoids poisoning the localStorage perf hint; next launch
|
||||
// retries the (idempotent) import.
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -633,11 +628,10 @@ export async function browseFolders(
|
|||
if (path !== undefined && path !== null) params.set("path", path);
|
||||
if (showHidden) params.set("show_hidden", "true");
|
||||
const qs = params.toString();
|
||||
// Forward the AbortSignal through authFetch -> fetch so that a
|
||||
// navigation cancelled in the FolderBrowser (rapid breadcrumb / row /
|
||||
// hidden-toggle clicks) actually cancels the in-flight HTTP request
|
||||
// server-side, instead of merely dropping the response client-side
|
||||
// while the backend keeps walking large directory trees.
|
||||
// Forward the AbortSignal through authFetch -> fetch so a cancelled
|
||||
// FolderBrowser navigation actually cancels the in-flight request
|
||||
// server-side, instead of just dropping the response while the backend
|
||||
// keeps walking large directory trees.
|
||||
const response = await authFetch(
|
||||
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
|
||||
signal ? { signal } : undefined,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Wrappers for the three OpenAI shell-tool container management
|
||||
* endpoints exposed by the backend (studio/backend/routes/inference.py).
|
||||
* Each one proxies to OpenAI's /v1/containers REST surface using the
|
||||
* user's encrypted API key. Backend rejects any base URL that isn't
|
||||
* api.openai.com — the shell tool only exists on the managed cloud.
|
||||
* Wrappers for the backend's three OpenAI shell-tool container endpoints
|
||||
* (studio/backend/routes/inference.py). Each proxies to OpenAI's
|
||||
* /v1/containers using the user's encrypted API key. Backend rejects any
|
||||
* base URL but api.openai.com (the shell tool is managed-cloud only).
|
||||
*/
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
|
|
@ -110,9 +109,8 @@ export async function deleteOpenAIContainer(
|
|||
}),
|
||||
},
|
||||
);
|
||||
// 404 = container already gone (deleted elsewhere, or expired-then-purged).
|
||||
// Treat as idempotent success so a stale list entry doesn't surface as a
|
||||
// confusing error — the caller will refresh and the entry will disappear.
|
||||
// 404 = container already gone; treat as idempotent success so a stale
|
||||
// list entry doesn't surface as a confusing error.
|
||||
if (!response.ok && response.status !== 204 && response.status !== 404) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,10 +140,9 @@ export async function deleteProviderConfig(providerId: string): Promise<void> {
|
|||
const response = await authFetch(`/api/providers/${providerId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// Treat 404 as success: another browser (or tab) already deleted this
|
||||
// provider on the backend, so locally pruning the stale cache is the
|
||||
// correct follow-up. Without this, the caller would throw and the user
|
||||
// would be stuck with an entry they cannot remove from the UI.
|
||||
// Treat 404 as success: another tab already deleted this provider, so pruning
|
||||
// the stale cache is correct. Otherwise the caller throws and the user is stuck
|
||||
// with an entry they cannot remove from the UI.
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,9 @@ function buildHtmlFence(source: string): string {
|
|||
const fence = "`".repeat(longestBacktickRun + 1);
|
||||
return `${fence}html\n${source}\n${fence}`;
|
||||
}
|
||||
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
|
||||
// trap. Granting same-origin sandbox privileges would weaken isolation, so
|
||||
// keyboard users can reach Studio controls here while fully interactive artifact
|
||||
// content remains a known sandbox limitation.
|
||||
// Sandboxed artifact iframes are deliberately outside the overlay focus trap:
|
||||
// granting same-origin sandbox privileges would weaken isolation, so reaching
|
||||
// interactive artifact content via keyboard is a known sandbox limitation.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,8 @@ export function ArtifactHtmlFrame({
|
|||
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
|
||||
}, [allowNetworkAccess, code]);
|
||||
const postArtifactHtml = useCallback(() => {
|
||||
// The sandboxed frame intentionally has an opaque origin ("null").
|
||||
// A wildcard target is required here;
|
||||
// the payload is sent only to this iframe's contentWindow.
|
||||
// Sandboxed frame has an opaque origin ("null"), so a wildcard target is
|
||||
// required; the payload only reaches this iframe's contentWindow.
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "unsloth:artifact-html", html: artifactHtml },
|
||||
"*",
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export function hashArtifactCode(code: string): string {
|
|||
export function createArtifactId(input: ChatArtifactInput): string {
|
||||
const threadSegment = input.threadId || "no-thread";
|
||||
const messageSegment = input.sourceMessageId || "transient";
|
||||
// Backend tool call IDs (call_0, call_1, …) reset per request, so
|
||||
// the message ID is needed to scope them to a specific turn.
|
||||
// Backend tool call IDs (call_0, call_1, …) reset per request, so the
|
||||
// message ID is needed to scope them to a specific turn.
|
||||
const parts = [input.source, threadSegment, messageSegment];
|
||||
|
||||
if (input.source === "tool" && input.sourceToolCallId) {
|
||||
|
|
|
|||
|
|
@ -84,10 +84,9 @@ function isValidAddress(value: string): boolean {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
// Anything else is treated as a local command (stdio); the backend gates
|
||||
// whether stdio servers are allowed on this host. Reject other URL schemes
|
||||
// only when the command itself is a URL; "://" is fine inside an argument
|
||||
// (e.g. a database connection string passed to the server).
|
||||
// Otherwise it's a local command (stdio); the backend gates whether those
|
||||
// are allowed. Reject only when the command itself is a URL; "://" is fine
|
||||
// inside an argument (e.g. a DB connection string passed to the server).
|
||||
return !trimmed.split(/\s+/)[0].includes("://");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -367,9 +367,8 @@ function modelMatchesDeleted(
|
|||
}
|
||||
|
||||
/**
|
||||
* Detect if this is a LoRA base-vs-fine-tuned compare.
|
||||
* Returns true when the loaded checkpoint is a LoRA — in that case
|
||||
* we use the fast simultaneous base/lora adapter-toggle path.
|
||||
* True when the loaded checkpoint is a LoRA, meaning a base-vs-fine-tuned
|
||||
* compare that uses the fast simultaneous adapter-toggle path.
|
||||
*/
|
||||
function useIsLoraCompare(): boolean {
|
||||
return useChatRuntimeStore((s) => {
|
||||
|
|
@ -424,15 +423,12 @@ const CompareContent = memo(function CompareContent({
|
|||
});
|
||||
|
||||
/**
|
||||
* A single column in the compare layout. Hosts one ChatRuntimeProvider
|
||||
* and one Thread rendered with hideComposer — the composer is shared
|
||||
* across both panes and rendered outside the pane flex.
|
||||
* A single column in the compare layout: one ChatRuntimeProvider and one
|
||||
* Thread with hideComposer (the composer is shared across panes).
|
||||
*
|
||||
* Each pane is a flex item with `flex-1 basis-0 min-h-0 min-w-0` so on
|
||||
* mobile (flex-col) they share height equally, and on desktop (flex-row)
|
||||
* they share width equally. The `min-*` constraints are required for
|
||||
* the inner viewport to scroll internally instead of spilling into the
|
||||
* page.
|
||||
* Each pane is `flex-1 basis-0 min-h-0 min-w-0` so panes share height
|
||||
* (mobile flex-col) or width (desktop flex-row) equally. The `min-*`
|
||||
* constraints let the inner viewport scroll instead of spilling.
|
||||
*/
|
||||
function ComparePane({
|
||||
modelType,
|
||||
|
|
@ -476,16 +472,13 @@ function ComparePane({
|
|||
}
|
||||
|
||||
/**
|
||||
* Shared shell for both compare variants. A vertical flex column with
|
||||
* the two panes as siblings and the shared composer docked at the
|
||||
* bottom. On mobile the panes stack (flex-col); on desktop they sit
|
||||
* side by side (md:flex-row).
|
||||
* Shared shell for both compare variants: a flex column with the two panes
|
||||
* as siblings and the shared composer docked at the bottom. Panes stack on
|
||||
* mobile (flex-col), sit side by side on desktop (md:flex-row).
|
||||
*
|
||||
* Flex is used rather than CSS grid for the pane container so that
|
||||
* viewport sizing stays stable across viewport-size transitions. Grid
|
||||
* rows with 1fr were triggering resize thrash in assistant-ui's
|
||||
* autoscroll hook on breakpoint crossings, leaving it stuck in a
|
||||
* scroll-to-bottom loop.
|
||||
* Flex, not grid, for the pane container: grid rows with 1fr triggered
|
||||
* resize thrash in assistant-ui's autoscroll on breakpoint crossings,
|
||||
* leaving it stuck in a scroll-to-bottom loop.
|
||||
*/
|
||||
function CompareShell({
|
||||
handlesRef,
|
||||
|
|
@ -594,10 +587,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
});
|
||||
|
||||
/**
|
||||
* Per-pane header rendered inside GeneralCompareContent. Contains the
|
||||
* model selector aligned with the global topbar height. The left pane
|
||||
* reserves room for the mobile sidebar trigger; the right pane reserves
|
||||
* room for the global settings button.
|
||||
* Per-pane header (inside GeneralCompareContent) with the model selector,
|
||||
* aligned to the global topbar height. Left pane reserves room for the
|
||||
* mobile sidebar trigger; right pane for the global settings button.
|
||||
*/
|
||||
function GeneralCompareHeader({
|
||||
models,
|
||||
|
|
@ -1087,9 +1079,8 @@ export function ChatPage(): ReactElement {
|
|||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
|
||||
// Tracks the latest non-compare view so exiting compare can restore it even
|
||||
// when compare was opened from a path that does not set viewBeforeCompareRef
|
||||
// (e.g. the composer + menu).
|
||||
// Latest non-compare view, so exiting compare can restore it even when
|
||||
// compare was opened from a path that doesn't set viewBeforeCompareRef.
|
||||
const lastNonCompareViewRef = useRef<ChatSearch | null>(null);
|
||||
useEffect(() => {
|
||||
if (!search.compare) {
|
||||
|
|
@ -1249,15 +1240,11 @@ export function ChatPage(): ReactElement {
|
|||
preferredEffort,
|
||||
effortLevels,
|
||||
);
|
||||
// Per-provider default effort. Anthropic gets the highest available
|
||||
// level (xhigh on 4.6/4.7, high on 4.5) since Claude's adaptive
|
||||
// thinking adjusts cost per turn — sitting at the top of the dial
|
||||
// gives users the strongest answers and the model can still skip
|
||||
// thinking when the turn is trivial. OpenAI gets "high" by default
|
||||
// — the gpt-5.x reasoning models accept high across the board and
|
||||
// it's the right cost/quality sweet spot for Responses-API tools
|
||||
// (web search included). Everyone else gets "medium" as a balanced
|
||||
// default. Users can pick another level via the Think dropdown.
|
||||
// Per-provider default effort. Anthropic gets the highest level since
|
||||
// Claude's adaptive thinking adjusts cost per turn (top of dial =
|
||||
// strongest answers, still skips thinking when trivial). OpenAI gets
|
||||
// "high" (gpt-5.x accept it across the board; good cost/quality for
|
||||
// Responses-API tools). Everyone else "medium". Overridable via Think.
|
||||
const isAnthropic = provider?.providerType === "anthropic";
|
||||
const isOpenAI = provider?.providerType === "openai";
|
||||
const anthropicTopEffort = effortLevels.includes("xhigh")
|
||||
|
|
@ -1298,19 +1285,15 @@ export function ChatPage(): ReactElement {
|
|||
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
|
||||
provider?.providerType,
|
||||
);
|
||||
// Kimi's k2.6/k2.5 default to thinking enabled on the server side
|
||||
// (per https://platform.kimi.ai/docs/models). Mirror that default
|
||||
// in the UI so the Think pill comes up clicked when the user picks
|
||||
// a Kimi model. The Search pill stays off by default; the mutual-
|
||||
// exclusion handlers in the composer flip the two when needed.
|
||||
// Kimi's k2.6/k2.5 default to thinking enabled server-side (per
|
||||
// https://platform.kimi.ai/docs/models). Mirror that so the Think pill
|
||||
// comes up clicked for Kimi models. Search stays off; the composer's
|
||||
// mutual-exclusion handlers flip the two when needed.
|
||||
const isKimi = provider?.providerType === "kimi";
|
||||
// Web search is on by default for the two providers we trust most
|
||||
// for it: Anthropic (web_search_20250305 server tool, structured
|
||||
// citations) and OpenAI (/v1/responses web_search, structured
|
||||
// citations). Other providers stay off-by-default — OpenRouter's
|
||||
// plugins shape and Kimi's $web_search builtin still work when the
|
||||
// user opts in via the pill, but they're a notch less reliable so
|
||||
// we don't pre-enable them.
|
||||
// Web search on by default only for the two providers we trust most:
|
||||
// Anthropic and OpenAI (both with structured citations). Others stay
|
||||
// off-by-default; OpenRouter and Kimi work on opt-in but are less
|
||||
// reliable, so we don't pre-enable them.
|
||||
const searchOnByDefault =
|
||||
supportsBuiltinWebSearch &&
|
||||
(provider?.providerType === "anthropic" ||
|
||||
|
|
@ -1345,15 +1328,11 @@ export function ChatPage(): ReactElement {
|
|||
: true
|
||||
: state.reasoningEnabled,
|
||||
supportsPreserveThinking: false,
|
||||
// External models never give us a local tool runtime (no
|
||||
// python sandbox), so `supportsTools` must be false. The three
|
||||
// `supportsBuiltin*` flags pick up the slack for providers that
|
||||
// run the tool server-side: `supportsBuiltinWebSearch` lights
|
||||
// up the Search pill (OpenAI / Anthropic / OpenRouter / Kimi),
|
||||
// `supportsBuiltinCodeExecution` lights up the Code pill
|
||||
// (Anthropic Claude 4.x and OpenAI gpt-5.5), and
|
||||
// `supportsBuiltinImageGeneration` lights up the Images pill
|
||||
// (OpenAI cloud Responses-API models only).
|
||||
// External models have no local tool runtime, so `supportsTools` is
|
||||
// false. The `supportsBuiltin*` flags cover providers that run tools
|
||||
// server-side: WebSearch lights the Search pill (OpenAI/Anthropic/
|
||||
// OpenRouter/Kimi), CodeExecution the Code pill (Claude 4.x, gpt-5.5),
|
||||
// ImageGeneration the Images pill (OpenAI cloud Responses-API only).
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch,
|
||||
supportsBuiltinCodeExecution,
|
||||
|
|
@ -1485,9 +1464,9 @@ export function ChatPage(): ReactElement {
|
|||
useEffect(() => {
|
||||
if (view.mode !== "single") return;
|
||||
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
|
||||
// view intentionally excludes __LOCALID_ threads (they fall through to
|
||||
// { mode: "single" } with no threadId/nonce). Don't close an artifact
|
||||
// whose thread is the currently active local thread.
|
||||
// view excludes __LOCALID_ threads (they fall through to mode:"single"
|
||||
// with no threadId/nonce). Don't close an artifact whose thread is the
|
||||
// active local thread.
|
||||
if (
|
||||
selectedArtifact.threadId &&
|
||||
selectedArtifact.threadId === activeThreadId
|
||||
|
|
@ -1593,9 +1572,8 @@ export function ChatPage(): ReactElement {
|
|||
preferredEffort,
|
||||
effortLevels,
|
||||
);
|
||||
// Same per-provider default policy as the useEffect path above:
|
||||
// Anthropic picks the highest available level, OpenAI picks
|
||||
// "high", everyone else picks "medium".
|
||||
// Same per-provider default policy as the useEffect above:
|
||||
// Anthropic highest level, OpenAI "high", everyone else "medium".
|
||||
const isAnthropic = selectedProvider?.providerType === "anthropic";
|
||||
const isOpenAI = selectedProvider?.providerType === "openai";
|
||||
const anthropicTopEffort = effortLevels.includes("xhigh")
|
||||
|
|
@ -1617,9 +1595,8 @@ export function ChatPage(): ReactElement {
|
|||
? "medium"
|
||||
: clampedEffort
|
||||
: store.reasoningEffort;
|
||||
// Clear any cached router-picked openrouter/free model unless the
|
||||
// user is staying on openrouter/free — otherwise the chip would
|
||||
// keep showing a stale ":<chosen>" suffix from a previous model.
|
||||
// Clear any cached router-picked openrouter/free model unless staying
|
||||
// on openrouter/free, else the chip keeps a stale ":<chosen>" suffix.
|
||||
const stillOnOpenRouterFree =
|
||||
selectedProvider?.providerType === "openrouter" &&
|
||||
selectedExternal?.modelId === "openrouter/free";
|
||||
|
|
@ -1644,13 +1621,12 @@ export function ChatPage(): ReactElement {
|
|||
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
|
||||
selectedProvider?.providerType,
|
||||
);
|
||||
// See sibling useEffect above: Kimi's k2.x default to thinking
|
||||
// enabled, so the Think pill comes up clicked. Search pill stays
|
||||
// off by default; mutual exclusion flips them via the composer.
|
||||
// See sibling useEffect: Kimi's k2.x default to thinking enabled
|
||||
// (Think pill clicked). Search stays off; the composer's mutual
|
||||
// exclusion flips them.
|
||||
const isKimi = selectedProvider?.providerType === "kimi";
|
||||
// Mirror of sibling useEffect: Anthropic and OpenAI get Search
|
||||
// on-by-default since their server tools emit structured
|
||||
// citations end-to-end. OpenRouter and Kimi stay off-by-default.
|
||||
// Mirror of sibling useEffect: Anthropic/OpenAI get Search on by
|
||||
// default (structured citations end-to-end); others stay off.
|
||||
const searchOnByDefault =
|
||||
supportsBuiltinWebSearch &&
|
||||
(selectedProvider?.providerType === "anthropic" ||
|
||||
|
|
@ -1676,9 +1652,8 @@ export function ChatPage(): ReactElement {
|
|||
ggufMaxContextLength: null,
|
||||
ggufNativeContextLength: null,
|
||||
activeNativePathToken: null,
|
||||
// Clear previous-model counters; the relaxed external-provider
|
||||
// render gate would otherwise show stale stats until the next
|
||||
// completion overwrites them.
|
||||
// Clear previous-model counters, else the relaxed external-provider
|
||||
// render gate shows stale stats until the next completion.
|
||||
contextUsage: null,
|
||||
supportsReasoning: reasoningCaps.supportsReasoning,
|
||||
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
|
||||
|
|
@ -1694,14 +1669,10 @@ export function ChatPage(): ReactElement {
|
|||
: true
|
||||
: store.reasoningEnabled,
|
||||
supportsPreserveThinking: false,
|
||||
// External models have no local tool runtime → supportsTools
|
||||
// stays false. The three supportsBuiltin* flags carry the
|
||||
// server-side capability info for each pill:
|
||||
// - Search → providerSupportsBuiltinWebSearch
|
||||
// - Code → providerSupportsBuiltinCodeExecution
|
||||
// (Anthropic Claude 4.x + OpenAI gpt-5.5)
|
||||
// - Images → providerSupportsBuiltinImageGeneration
|
||||
// (OpenAI cloud Responses-API models)
|
||||
// External models have no local tool runtime → supportsTools false.
|
||||
// The supportsBuiltin* flags carry server-side capability per pill:
|
||||
// Search, Code (Claude 4.x + gpt-5.5), Images (OpenAI cloud
|
||||
// Responses-API).
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch,
|
||||
supportsBuiltinCodeExecution,
|
||||
|
|
@ -1813,8 +1784,8 @@ export function ChatPage(): ReactElement {
|
|||
}, [currentProjectId, navigate, search]);
|
||||
|
||||
const exitCompare = useCallback(() => {
|
||||
// Prefer the explicit save; fall back to the last non-compare view so the
|
||||
// composer + menu path also returns to where the user started.
|
||||
// Prefer the explicit save; fall back to the last non-compare view so
|
||||
// the composer + menu path also returns where the user started.
|
||||
const saved = viewBeforeCompareRef.current ?? lastNonCompareViewRef.current;
|
||||
// No saved view (compare opened by direct URL); fall back to a fresh chat.
|
||||
if (!saved) {
|
||||
|
|
@ -1823,9 +1794,8 @@ export function ChatPage(): ReactElement {
|
|||
}
|
||||
viewBeforeCompareRef.current = null;
|
||||
navigate({ to: "/chat", search: saved });
|
||||
// Restore usage from the last assistant message, but only if it
|
||||
// matches the currently active checkpoint. Without this guard the
|
||||
// relaxed render gate would show stale stats from another model.
|
||||
// Restore usage from the last assistant message, only if it matches the
|
||||
// active checkpoint, else the relaxed render gate shows stale stats.
|
||||
const threadId =
|
||||
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
|
||||
if (threadId) {
|
||||
|
|
@ -1845,7 +1815,7 @@ export function ChatPage(): ReactElement {
|
|||
const usageModelId =
|
||||
(usage as { modelId?: unknown }).modelId;
|
||||
// Scope by modelId when present; reject if no active checkpoint
|
||||
// (model-scoped usage cannot be attributed to "nothing").
|
||||
// (model-scoped usage can't be attributed to "nothing").
|
||||
if (typeof usageModelId === "string" && usageModelId) {
|
||||
if (!activeCheckpoint || usageModelId !== activeCheckpoint) {
|
||||
return;
|
||||
|
|
@ -1895,15 +1865,13 @@ export function ChatPage(): ReactElement {
|
|||
.flatMap((provider) =>
|
||||
provider.models.map((model) => {
|
||||
// For OpenRouter's free router we know which underlying free
|
||||
// model the gateway actually picked once a stream completes
|
||||
// (chat-adapter latches `chunk.model` into the runtime store).
|
||||
// Render the chip as `openrouter:<short-chosen>` — drop the
|
||||
// redundant `/free` from the router id and the org prefix
|
||||
// from the chosen id (e.g.
|
||||
// openrouter/free + inclusionai/ring-2.6-1t-20260508:free
|
||||
// -> openrouter:ring-2.6-1t-20260508:free
|
||||
// ). The `:free` suffix on the chosen id already conveys
|
||||
// 'free model', so the leading `/free` is noise.
|
||||
// model the gateway picked once a stream completes (chat-adapter
|
||||
// latches `chunk.model`). Render the chip as
|
||||
// `openrouter:<short-chosen>`, dropping the redundant `/free`
|
||||
// and the chosen id's org prefix (e.g. openrouter/free +
|
||||
// inclusionai/ring-2.6-1t-20260508:free ->
|
||||
// openrouter:ring-2.6-1t-20260508:free). The `:free` suffix
|
||||
// already conveys "free model".
|
||||
let displayName = model;
|
||||
if (
|
||||
provider.providerType === "openrouter" &&
|
||||
|
|
@ -2117,8 +2085,8 @@ export function ChatPage(): ReactElement {
|
|||
<GuidedTour {...tour.tourProps} />
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<NativeModelDropOverlay state={nativeModelDropState} />
|
||||
{/* Bottom fade under the top bar so messages dissolve as they scroll
|
||||
beneath it (Gemini / unsloth-sidebar style), instead of a hard cut. */}
|
||||
{/* Fade under the top bar so messages dissolve as they scroll
|
||||
beneath it, instead of a hard cut. */}
|
||||
{view.mode !== "compare" && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
|
|
|||
|
|
@ -326,9 +326,8 @@ export function ChatProvidersSettings({
|
|||
return;
|
||||
}
|
||||
// Seed default_models only for curated providers (catalog too large to
|
||||
// enumerate — defaults are the suggestion shortlist). Remote-mode cloud
|
||||
// providers and local OpenAI-compat presets stay empty until the user
|
||||
// clicks "Load available models".
|
||||
// enumerate). Remote cloud providers and local OpenAI-compat presets stay
|
||||
// empty until the user clicks "Load available models".
|
||||
const seedDefaults = entry.model_list_mode === "curated";
|
||||
setAvailableModels(seedDefaults ? [...entry.default_models] : []);
|
||||
setSelectedModelIds([]);
|
||||
|
|
@ -425,10 +424,9 @@ export function ChatProvidersSettings({
|
|||
updatedAt,
|
||||
};
|
||||
});
|
||||
// Trust the backend response when it succeeds. An empty array means
|
||||
// every connection was removed (often from another browser/tab) and
|
||||
// the local cache should mirror that, otherwise the stale entries
|
||||
// become un-removable in this browser until localStorage is cleared.
|
||||
// Trust the backend response. An empty array means every connection was
|
||||
// removed (often from another tab); mirror that locally, else stale
|
||||
// entries become un-removable here until localStorage is cleared.
|
||||
onProvidersChange(syncedProviders);
|
||||
} catch (error) {
|
||||
// Only surface a toast for real failures, not for the silent
|
||||
|
|
|
|||
|
|
@ -127,19 +127,11 @@ export function InfoHint({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Editable numeric value display.
|
||||
*
|
||||
* Renders as a single <input> that *looks* like text by default —
|
||||
* transparent background, no border, no ring — and only shows a faint
|
||||
* surface tint on hover/focus to signal editability. When unfocused,
|
||||
* the input shows the formatted display string (`displayValue ?? value`,
|
||||
* so labels like "Off" / "Max" still render); on focus, it switches to
|
||||
* the raw numeric value, selects it, and accepts free text input.
|
||||
* Commit happens on blur or Enter; Escape reverts. The clamp-to-range
|
||||
* happens on commit so users can type intermediate values without the
|
||||
* input fighting them mid-keystroke. Single component shared by every
|
||||
* slider value and the Context Length input so the click-to-edit
|
||||
* affordance is consistent across the panel.
|
||||
* Editable numeric value display, shared by every slider value and the Context
|
||||
* Length input. An <input> that looks like text (shows `displayValue ?? value`,
|
||||
* so "Off"/"Max" labels render) until focus, when it swaps to the raw number,
|
||||
* selects it, and accepts free text. Commits on blur/Enter, reverts on Escape.
|
||||
* Clamping happens on commit so typing intermediate values isn't fought.
|
||||
*/
|
||||
function snapToStep(
|
||||
value: number,
|
||||
|
|
@ -205,7 +197,7 @@ function NumericValueInput({
|
|||
cancelBlurCommitRef.current = false;
|
||||
setDraft(String(value));
|
||||
setFocused(true);
|
||||
// Defer the select() so it runs after the value swap above.
|
||||
// Defer select() so it runs after the value swap above.
|
||||
const target = e.currentTarget;
|
||||
requestAnimationFrame(() => target.select());
|
||||
}}
|
||||
|
|
@ -331,10 +323,9 @@ function CollapsibleSection({
|
|||
}: {
|
||||
label: string;
|
||||
/**
|
||||
* When set, the label text becomes an external link (e.g. to the feature's
|
||||
* GitHub PR) instead of part of the collapse toggle. The chevron still
|
||||
* toggles open/close, so we render the two as siblings rather than nesting
|
||||
* an <a> inside the <button> (invalid HTML).
|
||||
* When set, the label becomes an external link (e.g. the feature's GitHub PR)
|
||||
* instead of part of the toggle. The chevron still toggles, so link and button
|
||||
* are siblings rather than an <a> nested in a <button> (invalid HTML).
|
||||
*/
|
||||
labelHref?: string;
|
||||
children?: ReactNode;
|
||||
|
|
@ -412,17 +403,16 @@ interface ChatSettingsPanelProps {
|
|||
onParamsChange: (params: InferenceParams) => void;
|
||||
isExternalModel?: boolean;
|
||||
/**
|
||||
* Sampling-param capability set for the active external provider, or `null`
|
||||
* for local models (in which case every knob is rendered). Drives the
|
||||
* per-param visibility in the sampling section.
|
||||
* Sampling-param capabilities for the active external provider, or `null` for
|
||||
* local models (every knob rendered). Drives per-param sampling visibility.
|
||||
*/
|
||||
providerCapabilities?: ProviderCapabilities | null;
|
||||
activeExternalProvider?: ExternalProviderConfig | null;
|
||||
onExternalProviderChange?: (provider: ExternalProviderConfig) => void;
|
||||
/**
|
||||
* Backend provider type for the active external model (e.g. "kimi",
|
||||
* "anthropic", "openai"), or `null` for local models. Drives the
|
||||
* per-provider Max Tokens floor in the slider.
|
||||
* "anthropic", "openai"), or `null` for local models. Drives the per-provider
|
||||
* Max Tokens floor in the slider.
|
||||
*/
|
||||
externalProviderType?: string | null;
|
||||
onReloadModel?: () => void;
|
||||
|
|
@ -440,9 +430,8 @@ export function ChatSettingsPanel({
|
|||
externalProviderType = null,
|
||||
onReloadModel,
|
||||
}: ChatSettingsPanelProps) {
|
||||
// For non-external (local) models we show every knob — providerCapabilities
|
||||
// is only consulted when `isExternalModel` is true. An external model with an
|
||||
// unknown provider falls back to the OpenAI-compat shape via
|
||||
// Local models show every knob; providerCapabilities is only consulted when
|
||||
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
|
||||
// getProviderCapabilities, so these flags never undercount support.
|
||||
const showTemperature =
|
||||
!isExternalModel || Boolean(providerCapabilities?.temperature);
|
||||
|
|
@ -724,8 +713,7 @@ export function ChatSettingsPanel({
|
|||
const settingsContent = (
|
||||
<>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header sits outside the scroll area so the scrollbar never shifts the
|
||||
close button. */}
|
||||
{/* Header is outside the scroll area so the scrollbar never shifts the close button. */}
|
||||
<div className="flex h-[48px] shrink-0 items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
|
||||
{isMobile ? (
|
||||
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function getSeverityColor(percent: number): {
|
|||
|
||||
export const ContextUsageBar: FC<{
|
||||
used: number;
|
||||
// null on external providers (no known window); bar then hides the ratio.
|
||||
// null on external providers (unknown window); bar hides the ratio.
|
||||
total?: number | null;
|
||||
cached?: number;
|
||||
// Anthropic-only (billed at the write premium).
|
||||
|
|
|
|||
|
|
@ -17,15 +17,10 @@ function clampProgress(value: number): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Split a composed progress label like
|
||||
* "22.8 of 122.3 GB • 330.1 MB/s • 5m 9s left"
|
||||
* into a primary chunk ("22.8 of 122.3 GB") that can sit next to the
|
||||
* percent, and a secondary chunk ("330.1 MB/s • 5m 9s left") that can
|
||||
* live on its own row. This keeps either line from overflowing into a
|
||||
* ragged wrap when the rate/ETA part shows up mid-download.
|
||||
*
|
||||
* Labels without " • " (e.g. "22.8 GB downloaded") are returned
|
||||
* primary-only, so the secondary row simply doesn't render.
|
||||
* Split a composed progress label like "22.8 of 122.3 GB • 330.1 MB/s • 5m 9s
|
||||
* left" into a primary chunk (next to the percent) and a secondary chunk (its
|
||||
* own row), so neither line wraps raggedly once rate/ETA appears mid-download.
|
||||
* Labels without " • " return primary-only, so the secondary row doesn't render.
|
||||
*/
|
||||
function splitProgressLabel(
|
||||
label: string | null | undefined,
|
||||
|
|
@ -46,8 +41,7 @@ export function ModelLoadDescription({
|
|||
progressLabel,
|
||||
}: ModelLoadDescriptionProps) {
|
||||
const hasProgress = typeof progressPercent === "number";
|
||||
// Split once at the top of the render so the JSX below stays flat --
|
||||
// no IIFE required. splitProgressLabel is a trivial string op.
|
||||
// Split once at the top so the JSX below stays flat (no IIFE).
|
||||
const { primary: labelPrimary, secondary: labelSecondary } =
|
||||
splitProgressLabel(progressLabel);
|
||||
|
||||
|
|
@ -116,9 +110,8 @@ export function ModelLoadInlineStatus({
|
|||
className="flex shrink-0 items-center gap-1 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80"
|
||||
title={progressLabel ?? undefined}
|
||||
>
|
||||
{/* Inline layout is horizontal and tight -- show only the
|
||||
primary (bytes) chunk; the full label (with rate/ETA)
|
||||
stays available via the tooltip. */}
|
||||
{/* Tight inline layout: show only the primary (bytes) chunk;
|
||||
full label (rate/ETA) stays in the tooltip. */}
|
||||
<span>{splitProgressLabel(progressLabel).primary}</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.round(clampProgress(progressPercent))}%
|
||||
|
|
|
|||
|
|
@ -2,29 +2,21 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Settings-sheet section for OpenAI shell-tool container management.
|
||||
* Renders only when:
|
||||
* - active provider is OpenAI cloud (api.openai.com base URL), AND
|
||||
* - the active model is gpt-5.5 or gpt-5.5-pro (the only families
|
||||
* where the shell tool is wired through today).
|
||||
* Settings-sheet section for OpenAI shell-tool container management. Renders
|
||||
* only when the active provider is OpenAI cloud (api.openai.com) and the model
|
||||
* is gpt-5.5 / gpt-5.5-pro (the only families wired to the shell tool today).
|
||||
*
|
||||
* Surfaces three controls:
|
||||
* 1. Default container idle-timeout (minutes). Persists on the
|
||||
* provider record; pre-fills the create dialog and is used by
|
||||
* the chat-adapter's lazy-create path on the first turn of a
|
||||
* thread.
|
||||
* 2. Container picker for the *active thread* — pick any of the
|
||||
* user's existing OpenAI containers, or "Auto-create per thread"
|
||||
* (default; lets the auto-create path manage it).
|
||||
* 3. Create-new-container inline form. Refresh + delete actions
|
||||
* per row.
|
||||
* Controls:
|
||||
* 1. Default container idle-timeout (minutes). Persisted on the provider
|
||||
* record; pre-fills the create dialog and feeds the chat-adapter's
|
||||
* lazy-create path on a thread's first turn.
|
||||
* 2. Container picker for the active thread (an existing OpenAI container or
|
||||
* auto-create per thread).
|
||||
* 3. Create-new-container inline form, with per-row refresh + delete.
|
||||
*
|
||||
* State persistence:
|
||||
* - TTL → ExternalProviderConfig.openaiContainerTtlMinutes
|
||||
* - Active container for this thread → ThreadRecord.openaiCodeExecContainerId
|
||||
*
|
||||
* No new global stores — list is fetched on open / refresh and held
|
||||
* in component state.
|
||||
* Persistence: TTL -> ExternalProviderConfig.openaiContainerTtlMinutes;
|
||||
* active container -> ThreadRecord.openaiCodeExecContainerId. No global stores;
|
||||
* the list is fetched on open / refresh and held in component state.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
|
@ -63,10 +55,9 @@ import {
|
|||
const DEFAULT_TTL_MINUTES = 20;
|
||||
const TTL_MIN = 1;
|
||||
const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
|
||||
// Cadence for re-fetching the container list while the section is
|
||||
// mounted. OpenAI's container TTL flips at minute granularity, so 30s
|
||||
// is fast enough that an expired container loses its ACTIVE pill within
|
||||
// half a minute without hammering /v1/containers.
|
||||
// Re-fetch cadence while the section is mounted. OpenAI's TTL flips at minute
|
||||
// granularity, so 30s drops an expired container's ACTIVE pill within half a
|
||||
// minute without hammering /v1/containers.
|
||||
const REFRESH_POLL_MS = 30_000;
|
||||
|
||||
function ageLabel(epochSeconds: number | null | undefined): string {
|
||||
|
|
@ -89,10 +80,9 @@ function shortContainerId(id: string): string {
|
|||
}
|
||||
|
||||
function isContainerRunning(c: OpenAIContainerSummary): boolean {
|
||||
// OpenAI's containers API reports `status: "running"` while idle TTL is
|
||||
// valid and `status: "expired"` once the idle window has passed. Treat
|
||||
// a missing status as running so we don't false-positive on any older
|
||||
// payloads that didn't include the field.
|
||||
// OpenAI reports `status: "running"` while idle TTL is valid and "expired"
|
||||
// afterward. Treat a missing status as running so older payloads without the
|
||||
// field don't false-positive.
|
||||
return c.status == null || c.status === "running";
|
||||
}
|
||||
|
||||
|
|
@ -114,32 +104,27 @@ export function OpenAICodeExecSection({
|
|||
const [creating, setCreating] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
// Ids that have been deleted in this session. Once tombstoned, an id
|
||||
// stays hidden from the picker for the lifetime of the page — OpenAI's
|
||||
// /containers list can keep returning a freshly-deleted id for an
|
||||
// undocumented and variable amount of time, and an automatic re-show
|
||||
// creates more confusion than it solves. Refreshing the page resets
|
||||
// the tombstone naturally.
|
||||
// Ids deleted this session. A tombstoned id stays hidden for the page's
|
||||
// lifetime: OpenAI's /containers list can keep returning a freshly-deleted
|
||||
// id for an undocumented, variable time, and auto-reshowing it confuses more
|
||||
// than it helps. A page refresh resets the tombstone.
|
||||
const [tombstones, setTombstones] = useState<Set<string>>(() => new Set());
|
||||
// Ids optimistically inserted after a successful create but not yet
|
||||
// confirmed by a /v1/containers list response. OpenAI's list endpoint
|
||||
// is eventually consistent — a freshly-created container can be absent
|
||||
// for several seconds. We render the row immediately with a "Creating"
|
||||
// pill, then drop it from this set once a refresh sees the id.
|
||||
// Ids optimistically inserted after a create but not yet confirmed by a
|
||||
// /v1/containers list. That endpoint is eventually consistent (a new
|
||||
// container can be absent for several seconds), so we render the row at once
|
||||
// with a "Creating" pill and drop it once a refresh sees the id.
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
|
||||
// Ref mirror so `refresh()` can read the current pending set without
|
||||
// re-binding when it changes (the callback is in a useEffect dep).
|
||||
// Ref mirror so `refresh()` reads the current pending set without re-binding
|
||||
// when it changes (the callback is a useEffect dep).
|
||||
const pendingIdsRef = useRef<Set<string>>(pendingIds);
|
||||
useEffect(() => {
|
||||
pendingIdsRef.current = pendingIds;
|
||||
}, [pendingIds]);
|
||||
// One-shot follow-up refresh scheduled after a create, to catch the
|
||||
// common case where the server list lags the create response by a few
|
||||
// seconds. Tracked so we can clear it on unmount.
|
||||
// One-shot follow-up refresh after a create, for when the server list lags
|
||||
// the create response by a few seconds. Tracked so we clear it on unmount.
|
||||
const pendingRetryRef = useRef<number | null>(null);
|
||||
// Target row for the destructive confirmation dialog. Held in state
|
||||
// (rather than blocking with window.confirm) so the dialog sits inside
|
||||
// the settings sheet instead of a native browser alert.
|
||||
// Target row for the delete-confirmation dialog. Held in state (not
|
||||
// window.confirm) so the dialog sits inside the settings sheet.
|
||||
const [pendingDelete, setPendingDelete] =
|
||||
useState<OpenAIContainerSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
|
@ -174,15 +159,15 @@ export function OpenAICodeExecSection({
|
|||
}, [activeThreadId]);
|
||||
|
||||
// Hide just-deleted containers even if OpenAI's list still returns them.
|
||||
// This is the single chokepoint — every downstream view (sorted picker,
|
||||
// auto-bind candidate, all-containers list) derives from visibleContainers.
|
||||
// Single chokepoint: every downstream view (sorted picker, auto-bind
|
||||
// candidate, all-containers list) derives from visibleContainers.
|
||||
const visibleContainers = useMemo(() => {
|
||||
if (tombstones.size === 0) return containers;
|
||||
return containers.filter((c) => !tombstones.has(c.id));
|
||||
}, [containers, tombstones]);
|
||||
|
||||
// Containers sorted newest-first by lastActiveAt so the dropdown's
|
||||
// default (auto-bind target) shows up first.
|
||||
// Newest-first by lastActiveAt so the dropdown default (auto-bind target)
|
||||
// shows up first.
|
||||
const sortedContainers = useMemo(
|
||||
() =>
|
||||
[...visibleContainers].sort(
|
||||
|
|
@ -191,19 +176,18 @@ export function OpenAICodeExecSection({
|
|||
[visibleContainers],
|
||||
);
|
||||
|
||||
// First running container by lastActiveAt — the auto-bind target and
|
||||
// also what we surface visually before Dexie catches up.
|
||||
// First running container by lastActiveAt: the auto-bind target and what we
|
||||
// surface visually before Dexie catches up.
|
||||
const firstRunningContainer = useMemo(
|
||||
() => sortedContainers.find(isContainerRunning) ?? null,
|
||||
[sortedContainers],
|
||||
);
|
||||
|
||||
// What the picker should treat as "active" right now. We decouple
|
||||
// this from `activeContainerId` (Dexie state) so the user immediately
|
||||
// sees the most-recent running container while the auto-bind effect's
|
||||
// async write propagates. If the Dexie-bound container has since
|
||||
// expired, fall back to the first running candidate — the stale-bind
|
||||
// sweeper below will clear Dexie shortly after.
|
||||
// What the picker treats as "active" now. Decoupled from `activeContainerId`
|
||||
// (Dexie state) so the user sees the most-recent running container while the
|
||||
// auto-bind effect's async write propagates. If the Dexie-bound container
|
||||
// expired, fall back to the first running candidate; the stale-bind sweeper
|
||||
// clears Dexie shortly after.
|
||||
const boundContainer = useMemo(
|
||||
() => sortedContainers.find((c) => c.id === activeContainerId) ?? null,
|
||||
[sortedContainers, activeContainerId],
|
||||
|
|
@ -223,8 +207,8 @@ export function OpenAICodeExecSection({
|
|||
});
|
||||
const serverIds = new Set(list.map((c) => c.id));
|
||||
setContainers((prev) => {
|
||||
// Preserve optimistic inserts the server hasn't acknowledged
|
||||
// yet so they don't disappear on the reconciling refresh.
|
||||
// Preserve optimistic inserts the server hasn't acknowledged yet so
|
||||
// they don't disappear on the reconciling refresh.
|
||||
const orphans = prev.filter(
|
||||
(c) => !serverIds.has(c.id) && pendingIdsRef.current.has(c.id),
|
||||
);
|
||||
|
|
@ -248,11 +232,9 @@ export function OpenAICodeExecSection({
|
|||
}
|
||||
}, [apiKey, provider.baseUrl]);
|
||||
|
||||
// Fetch once when the section mounts (or provider changes), then
|
||||
// poll on a low cadence so an expired container's ACTIVE pill clears
|
||||
// without the user clicking the refresh button. Also re-fetch when
|
||||
// the tab regains visibility — covers the common case of leaving the
|
||||
// sheet open across a long idle period.
|
||||
// Fetch on mount (or provider change), then poll on a low cadence so an
|
||||
// expired container's ACTIVE pill clears without a manual refresh. Also
|
||||
// re-fetch when the tab regains visibility (sheet left open while idle).
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => {
|
||||
|
|
@ -276,21 +258,17 @@ export function OpenAICodeExecSection({
|
|||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-bind the active thread to the most-recently-active container
|
||||
// whenever the thread has none set and at least one container exists
|
||||
// on the user's OpenAI account. Sorting by `lastActiveAt` matches
|
||||
// what feels "most recent" from the user's perspective.
|
||||
// Auto-bind the active thread to the most-recently-active container when the
|
||||
// thread has none and at least one container exists on the account. Sorting
|
||||
// by `lastActiveAt` matches what feels "most recent" to the user.
|
||||
//
|
||||
// We eagerly materialize the thread row via `ensureThreadRecord` so
|
||||
// the bind lands before the user has sent a first
|
||||
// message. This does NOT create anything at OpenAI — only a local
|
||||
// ThreadRecord — so it does not bypass the user's expectation that
|
||||
// a fresh OpenAI container is not created until first send.
|
||||
// `ensureThreadRecord` eagerly materializes the thread row so the bind lands
|
||||
// before the first message. This creates nothing at OpenAI (only a local
|
||||
// ThreadRecord), so a fresh OpenAI container is still not created until first
|
||||
// send.
|
||||
//
|
||||
// If `containers` is empty (no OpenAI containers exist yet), this
|
||||
// effect short-circuits: the picker renders an empty-state hint and
|
||||
// the chat-adapter's lazy-create path will mint the first container
|
||||
// on first send.
|
||||
// If no containers exist yet, this short-circuits: the picker shows an
|
||||
// empty-state hint and the chat-adapter mints the first container on send.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!activeThreadId ||
|
||||
|
|
@ -330,14 +308,12 @@ export function OpenAICodeExecSection({
|
|||
|
||||
const onPick = async (value: string) => {
|
||||
if (!activeThreadId || !value) return;
|
||||
// value is always a container id now — the "Auto-create per thread"
|
||||
// option has been removed in favour of always defaulting to the
|
||||
// most-recently-active container. The chat-adapter still handles
|
||||
// the no-containers-exist case (lazy-create on first send).
|
||||
// value is always a container id now; "Auto-create per thread" was removed
|
||||
// in favour of defaulting to the most-recently-active container. The
|
||||
// chat-adapter still handles the no-containers case (lazy-create on send).
|
||||
//
|
||||
// ensureThreadRecord materializes the thread row eagerly (modelType
|
||||
// "base" — settings sheet is single-thread-mode only) so the update
|
||||
// actually lands when the user hasn't sent a message yet.
|
||||
// ensureThreadRecord eagerly materializes the thread row (modelType "base":
|
||||
// settings sheet is single-thread only) so the update lands before send.
|
||||
try {
|
||||
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
|
||||
const updated = await updateStoredChatThread(activeThreadId, {
|
||||
|
|
@ -360,9 +336,9 @@ export function OpenAICodeExecSection({
|
|||
toast.error("Container name is required");
|
||||
return;
|
||||
}
|
||||
// TTL inherits from the section-level "Idle timeout" control —
|
||||
// there is no per-container override on the form. Read it at
|
||||
// submit time so a last-second change to the TTL row applies.
|
||||
// TTL inherits from the section-level "Idle timeout" control (no
|
||||
// per-container override). Read at submit time so a last-second TTL change
|
||||
// applies.
|
||||
const ttlMinutes =
|
||||
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
|
||||
setCreating(true);
|
||||
|
|
@ -374,10 +350,9 @@ export function OpenAICodeExecSection({
|
|||
toast.success(`Created container ${name}`);
|
||||
setCreateName("");
|
||||
setCreateOpen(false);
|
||||
// Optimistic insert + "Creating" pill. OpenAI's /v1/containers
|
||||
// list endpoint is eventually consistent and can omit the new
|
||||
// container for several seconds — without this, the row only
|
||||
// shows up on the next 30s poll or a manual refresh.
|
||||
// Optimistic insert + "Creating" pill. The /v1/containers list is
|
||||
// eventually consistent and can omit the new container for seconds;
|
||||
// without this the row only appears on the next poll or manual refresh.
|
||||
setContainers((prev) =>
|
||||
prev.some((c) => c.id === created.id) ? prev : [created, ...prev],
|
||||
);
|
||||
|
|
@ -387,9 +362,8 @@ export function OpenAICodeExecSection({
|
|||
next.add(created.id);
|
||||
return next;
|
||||
});
|
||||
// Follow-up refresh ~5s later to reconcile the optimistic row
|
||||
// with the server's view once /v1/containers catches up. One
|
||||
// shot; the regular poll covers any longer tail.
|
||||
// Follow-up refresh ~5s later to reconcile the optimistic row once
|
||||
// /v1/containers catches up. One shot; the poll covers a longer tail.
|
||||
if (pendingRetryRef.current != null) {
|
||||
window.clearTimeout(pendingRetryRef.current);
|
||||
}
|
||||
|
|
@ -397,9 +371,8 @@ export function OpenAICodeExecSection({
|
|||
pendingRetryRef.current = null;
|
||||
void refresh();
|
||||
}, 5000);
|
||||
// Auto-bind the just-created container to the active thread.
|
||||
// ensureThreadRecord first so the bind lands even when the user
|
||||
// creates a container before sending the first message.
|
||||
// Auto-bind the new container to the active thread. ensureThreadRecord
|
||||
// first so the bind lands even if no message has been sent yet.
|
||||
if (activeThreadId) {
|
||||
try {
|
||||
await ensureThreadRecord({
|
||||
|
|
@ -420,8 +393,7 @@ export function OpenAICodeExecSection({
|
|||
} finally {
|
||||
setCreating(false);
|
||||
// Refresh even on failure: the request may have partially succeeded
|
||||
// server-side (created container, lost response), and a re-fetch
|
||||
// keeps the picker in sync with OpenAI's actual state.
|
||||
// (container created, response lost); a re-fetch keeps the picker in sync.
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
|
@ -435,8 +407,8 @@ export function OpenAICodeExecSection({
|
|||
{ apiKey, baseUrl: provider.baseUrl || null },
|
||||
id,
|
||||
);
|
||||
// Tombstone the id so the picker hides it immediately even if
|
||||
// OpenAI's list keeps returning it for a while.
|
||||
// Tombstone the id so the picker hides it at once even if OpenAI's list
|
||||
// keeps returning it for a while.
|
||||
setTombstones((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
|
|
@ -460,9 +432,8 @@ export function OpenAICodeExecSection({
|
|||
} finally {
|
||||
setDeleting(false);
|
||||
setPendingDelete(null);
|
||||
// Always refresh so a stale list entry (e.g. container deleted
|
||||
// elsewhere, or already expired) is purged from the UI even when
|
||||
// the delete call itself errored.
|
||||
// Always refresh so a stale list entry (deleted elsewhere or expired) is
|
||||
// purged even when the delete call errored.
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
|
@ -496,9 +467,8 @@ export function OpenAICodeExecSection({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Single container list. Clicking a row binds it to the active
|
||||
thread and the ACTIVE pill marks which one — no separate
|
||||
picker needed. */}
|
||||
{/* Container list. Clicking a row binds it to the active thread; the
|
||||
ACTIVE pill marks which one (no separate picker). */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -518,10 +488,9 @@ export function OpenAICodeExecSection({
|
|||
</Button>
|
||||
</div>
|
||||
{sortedContainers.length === 0 ? (
|
||||
// Quiet placeholder with the same muted border as row cards
|
||||
// so an empty section doesn't masquerade as an active control.
|
||||
// The first container is minted by the chat-adapter on first
|
||||
// send (lazy-create) and appears here after the next refresh.
|
||||
// Quiet placeholder with the same muted border as row cards so an
|
||||
// empty section doesn't look like an active control. The first
|
||||
// container is lazy-created on first send and appears after refresh.
|
||||
<div className="flex h-9 w-full items-center rounded-md border border-dashed border-border/60 bg-muted/20 px-2 text-xs text-muted-foreground">
|
||||
None yet - one will be created on first send.
|
||||
</div>
|
||||
|
|
@ -566,9 +535,8 @@ export function OpenAICodeExecSection({
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
{/* min-w-0 + truncate keeps long OpenAI container ids
|
||||
from spilling under the trash button on narrow
|
||||
settings sheets. */}
|
||||
{/* min-w-0 + truncate keeps long container ids from
|
||||
spilling under the trash button on narrow sheets. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
|
|
@ -619,10 +587,9 @@ export function OpenAICodeExecSection({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Create new — inline single-row edit that visually echoes a
|
||||
container card. TTL is inherited from the section's top
|
||||
"Idle timeout" control (no per-container override), which
|
||||
keeps the form light and avoids a duplicated input. */}
|
||||
{/* Create new: inline single-row edit echoing a container card. TTL is
|
||||
inherited from the top "Idle timeout" control (no per-container
|
||||
override), keeping the form light. */}
|
||||
{createOpen ? (
|
||||
<div className="flex items-center gap-1 rounded-md border border-border/60 bg-muted/20 px-1.5 py-1">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -45,9 +45,8 @@ export { db };
|
|||
/**
|
||||
* Wraps Dexie liveQuery for React state updates.
|
||||
*
|
||||
* Important: include every semantic query input in `deps` (filters, sort keys,
|
||||
* IDs, etc). `querier` identity is intentionally ignored to avoid re-subscribing
|
||||
* on every render when callers pass inline functions.
|
||||
* Include every semantic query input in `deps` (filters, sort keys, IDs). `querier`
|
||||
* identity is ignored to avoid re-subscribing every render on inline functions.
|
||||
*/
|
||||
export function useLiveQuery<T>(
|
||||
querier: () => Promise<T>,
|
||||
|
|
|
|||
|
|
@ -17,33 +17,29 @@ export interface ExternalProviderConfig {
|
|||
/** Whether to ask supported hosted providers to use prompt caching. */
|
||||
enablePromptCaching?: boolean;
|
||||
/**
|
||||
* Anthropic prompt-cache TTL bucket. Only meaningful when
|
||||
* `enablePromptCaching` is true and the provider supports the choice
|
||||
* (Anthropic today). Maps to `prompt_cache_ttl` on the backend, which
|
||||
* attaches `cache_control.ttl` to the cache marker. Omitted = inherit
|
||||
* Anthropic's default 5-minute pool, same as before this knob existed.
|
||||
* Anthropic prompt-cache TTL bucket. Only meaningful when `enablePromptCaching`
|
||||
* is true and the provider supports the choice (Anthropic today). Maps to
|
||||
* backend `prompt_cache_ttl`, which sets `cache_control.ttl` on the cache
|
||||
* marker. Omitted = inherit Anthropic's default 5-minute pool.
|
||||
*/
|
||||
promptCacheTtl?: "5m" | "1h";
|
||||
/** User-pinned: the loaded vLLM model supports `enable_thinking`. */
|
||||
isReasoningModel?: boolean;
|
||||
/**
|
||||
* Default idle-timeout (in minutes) for newly created OpenAI shell
|
||||
* containers. Pre-fills the "Create container" dialog and is the
|
||||
* TTL the auto-create-per-thread path POSTs to /v1/containers with.
|
||||
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
|
||||
* Default idle-timeout (minutes) for new OpenAI shell containers. Pre-fills the
|
||||
* "Create container" dialog and is the TTL the auto-create-per-thread path POSTs
|
||||
* to /v1/containers. OpenAI's hard default is 20. Only for OpenAI cloud.
|
||||
*/
|
||||
openaiContainerTtlMinutes?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// Gemini supports prompt caching, but the wire flow requires a
|
||||
// separate POST to /v1beta/cachedContents to create the cache before
|
||||
// the generateContent call can reference it; the boolean Studio
|
||||
// currently emits on enable_prompt_caching is not enough on its own.
|
||||
// Until that two-step orchestration ships we keep the picker off so
|
||||
// the toggle does not silently no-op for Gemini users. See
|
||||
// https://ai.google.dev/gemini-api/docs/caching.
|
||||
// Gemini supports prompt caching, but the wire flow needs a separate POST to
|
||||
// /v1beta/cachedContents before generateContent can reference the cache; the
|
||||
// enable_prompt_caching boolean alone isn't enough. Until that two-step flow
|
||||
// ships, keep the picker off so the toggle doesn't silently no-op for Gemini.
|
||||
// See https://ai.google.dev/gemini-api/docs/caching.
|
||||
const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]);
|
||||
|
||||
export function supportsProviderPromptCaching(
|
||||
|
|
@ -53,10 +49,9 @@ export function supportsProviderPromptCaching(
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether the provider lets the user choose between a short and a long
|
||||
* prompt-cache pool. Anthropic exposes both a 5m and a 1h ephemeral
|
||||
* pool via `cache_control.ttl`; OpenAI's automatic prompt cache has no
|
||||
* equivalent user-selectable knob, so it stays off the picker.
|
||||
* Whether the provider lets the user choose between a short and long prompt-cache
|
||||
* pool. Anthropic exposes 5m and 1h ephemeral pools via `cache_control.ttl`;
|
||||
* OpenAI's automatic cache has no equivalent knob, so it stays off the picker.
|
||||
*/
|
||||
const PROMPT_CACHE_TTL_PROVIDER_TYPES = new Set(["anthropic"]);
|
||||
|
||||
|
|
@ -74,8 +69,8 @@ export function isPromptCacheTtl(value: unknown): value is "5m" | "1h" {
|
|||
return typeof value === "string" && PROMPT_CACHE_TTL_VALUES.has(value as "5m" | "1h");
|
||||
}
|
||||
|
||||
// Provider types that expose the connection-level "reasoning model"
|
||||
// toggle. vLLM's OpenAI-compat endpoint doesn't advertise this per model.
|
||||
// Provider types exposing the connection-level "reasoning model" toggle.
|
||||
// vLLM's OpenAI-compat endpoint doesn't advertise this per model.
|
||||
const REASONING_TOGGLE_PROVIDER_TYPES = new Set(["vllm"]);
|
||||
|
||||
export function supportsProviderReasoningToggle(
|
||||
|
|
@ -245,10 +240,9 @@ export function toExternalBackendProviderType(
|
|||
providerType: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!providerType) return undefined;
|
||||
// vLLM's /v1/responses applies the loaded model's chat template, which
|
||||
// 400s on strict-alternation templates (e.g. Gemma 3). Pass the actual
|
||||
// type through so the backend routes vLLM to /v1/chat/completions instead
|
||||
// of the OpenAI Responses path used for gpt-5.x.
|
||||
// vLLM's /v1/responses applies the loaded model's chat template, which 400s on
|
||||
// strict-alternation templates (e.g. Gemma 3). Pass the type through so the
|
||||
// backend routes vLLM to /v1/chat/completions instead of the Responses path.
|
||||
if (providerType === "vllm") return "vllm";
|
||||
if (providerType === "ollama") return "ollama";
|
||||
if (providerType === "llama_cpp") return "llama_cpp";
|
||||
|
|
@ -419,10 +413,8 @@ export function loadExternalProviders(): ExternalProviderConfig[] {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the raw (encrypted or legacy plaintext) key map from localStorage.
|
||||
* Values are opaque strings — either AES-GCM ciphertext or legacy plaintext.
|
||||
*/
|
||||
/** Load the raw key map from localStorage. Values are opaque strings: either
|
||||
* AES-GCM ciphertext or legacy plaintext. */
|
||||
function loadRawKeyMap(): Record<string, string> {
|
||||
if (!canUseStorage()) return {};
|
||||
try {
|
||||
|
|
@ -457,7 +449,7 @@ export function saveExternalProviders(
|
|||
if (!canUseStorage()) return;
|
||||
try {
|
||||
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
|
||||
// Prune keys for removed providers — works on raw ciphertext, no decryption needed
|
||||
// Prune keys for removed providers (works on raw ciphertext, no decryption)
|
||||
const allowedIds = new Set(providers.map((provider) => provider.id));
|
||||
const keys = loadRawKeyMap();
|
||||
const pruned: Record<string, string> = {};
|
||||
|
|
@ -472,10 +464,7 @@ export function saveExternalProviders(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a provider API key from localStorage.
|
||||
* Returns "" if no key is stored.
|
||||
*/
|
||||
/** Retrieve a provider API key from localStorage; "" if none stored. */
|
||||
export function getExternalProviderApiKey(
|
||||
providerId: string,
|
||||
): string {
|
||||
|
|
@ -483,9 +472,7 @@ export function getExternalProviderApiKey(
|
|||
return keys[providerId] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a provider API key in localStorage.
|
||||
*/
|
||||
/** Store a provider API key in localStorage. */
|
||||
export function setExternalProviderApiKey(
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
|
|
|
|||
|
|
@ -167,11 +167,9 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string {
|
|||
return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`;
|
||||
}
|
||||
|
||||
// Canonicalises any value the backend reports (or persisted state holds)
|
||||
// onto the five UI-facing modes the Speculative Decoding dropdown
|
||||
// understands: "auto" / "mtp" / "ngram" / "mtp+ngram" / "off" / null.
|
||||
// Mirrors backend _canonicalize_spec_mode so old persisted "default" /
|
||||
// "draft-mtp" / "ngram-mod" / chain values round-trip cleanly.
|
||||
// Canonicalises any backend/persisted value onto the Speculative Decoding
|
||||
// dropdown's modes ("auto"/"mtp"/"ngram"/"mtp+ngram"/"off"/null). Mirrors
|
||||
// backend _canonicalize_spec_mode so legacy persisted values round-trip.
|
||||
function normalizeSpeculativeType(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim().toLowerCase();
|
||||
|
|
@ -276,9 +274,8 @@ export function useChatModelRuntime() {
|
|||
listLoras(),
|
||||
]);
|
||||
|
||||
// Cancellation can land while the requests above are in flight (e.g. the
|
||||
// user cancels a load during this refresh). Bail before writing any
|
||||
// backend state back into the store -- cancelLoading already cleared it.
|
||||
// Cancellation can land while the requests above are in flight. Bail
|
||||
// before writing backend state back -- cancelLoading already cleared it.
|
||||
if (signal?.aborted) return;
|
||||
|
||||
setModels(listRes.models.map(toChatModelSummary));
|
||||
|
|
@ -331,12 +328,10 @@ export function useChatModelRuntime() {
|
|||
const currentSpecType = normalizeSpeculativeType(
|
||||
statusRes.speculative_type,
|
||||
);
|
||||
// Refresh runs both on F5 (fresh store needs hydration) AND right
|
||||
// after a fresh load (store was already set by the load path). For
|
||||
// the user-configurable model params we only hydrate when the shadow
|
||||
// `loaded*` field is still null -- that signals "not yet hydrated".
|
||||
// Otherwise we'd clobber the values the load path just applied and
|
||||
// the UI would appear to revert the user's changes.
|
||||
// Refresh runs on F5 (needs hydration) and right after a load (store
|
||||
// already set). For user-configurable params, only hydrate when the
|
||||
// shadow `loaded*` field is null ("not yet hydrated"); otherwise we'd
|
||||
// clobber what the load path just applied and revert the user.
|
||||
const prevState = useChatRuntimeStore.getState();
|
||||
const clampedReasoningEffort = clampLocalReasoningEffort(
|
||||
prevState.reasoningEffort,
|
||||
|
|
@ -355,15 +350,12 @@ export function useChatModelRuntime() {
|
|||
supportsPreserveThinking,
|
||||
supportsTools,
|
||||
// Reset per-turn reasoning flag so:
|
||||
// 1. models that do not support reasoning do not inherit a stale
|
||||
// off state from a prior model, and
|
||||
// 2. local reasoning-effort models (where the composer hides
|
||||
// the Off option via supportsReasoningOff=false) cannot end
|
||||
// up with reasoningEnabled=false carried over from an
|
||||
// external model where Off was selected — the composer would
|
||||
// keep showing "Think: <level>" via effectiveReasoningEnabled,
|
||||
// but the chat-adapter would omit the kwarg and the Harmony
|
||||
// template would fall back to its own default effort.
|
||||
// 1. non-reasoning models don't inherit a stale off state, and
|
||||
// 2. local reasoning-effort models (Off hidden via
|
||||
// supportsReasoningOff=false) don't carry reasoningEnabled=false
|
||||
// from an external model where Off was selected -- the composer
|
||||
// would still show "Think: <level>" but the adapter would omit
|
||||
// the kwarg, so Harmony falls back to its default effort.
|
||||
reasoningEnabled: supportsReasoning
|
||||
? reasoningStyle === "reasoning_effort"
|
||||
? true
|
||||
|
|
@ -600,15 +592,12 @@ export function useChatModelRuntime() {
|
|||
}
|
||||
if (abortCtrl.signal.aborted) throw new Error("Cancelled");
|
||||
|
||||
// Reset Speculative Decoding to Auto whenever the user
|
||||
// switches to a different model. Spec strategy is a
|
||||
// per-model decision: a sub-3B non-MTP GGUF that ran with
|
||||
// "Off" should not carry that choice into a 27B MTP GGUF
|
||||
// where Auto would auto-promote to draft-mtp. The user can
|
||||
// still pick a forced mode on the new model; this just
|
||||
// clears the stale prior-model choice so the backend's
|
||||
// platform-aware path runs by default. Same applies to
|
||||
// spec_draft_n_max which is MTP-only.
|
||||
// Reset Speculative Decoding to Auto on model switch: spec
|
||||
// strategy is per-model, so a sub-3B non-MTP GGUF's "Off" must
|
||||
// not carry into a 27B MTP GGUF where Auto auto-promotes to
|
||||
// draft-mtp. Clears the stale prior choice so the backend's
|
||||
// platform-aware path runs by default; same for spec_draft_n_max
|
||||
// (MTP-only). The user can still force a mode on the new model.
|
||||
if (currentCheckpoint && currentCheckpoint !== modelId) {
|
||||
useChatRuntimeStore.setState({
|
||||
speculativeType: null,
|
||||
|
|
@ -852,17 +841,15 @@ export function useChatModelRuntime() {
|
|||
);
|
||||
loadToastIdRef.current = toastId;
|
||||
|
||||
// Poll download progress for non-cached models (GGUF and non-GGUF).
|
||||
// Then, once the download wraps (or for already-cached models),
|
||||
// poll the llama-server mmap phase so "Starting model..." no
|
||||
// longer looks frozen for several minutes on large MoE models.
|
||||
// Poll download progress for non-cached models, then (after download
|
||||
// or for cached models) poll the llama-server mmap phase so "Starting
|
||||
// model..." doesn't look frozen for minutes on large MoE models.
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const expectedBytes =
|
||||
typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0;
|
||||
|
||||
// Rolling window of byte samples for rate / ETA estimation.
|
||||
// Shared across download + mmap phases so the estimator doesn't
|
||||
// reset when the phase flips.
|
||||
// Rolling window of byte samples for rate/ETA estimation, shared
|
||||
// across download + mmap phases so it survives phase flips.
|
||||
type Sample = { t: number; b: number };
|
||||
const MIN_SAMPLES = 3;
|
||||
const MIN_WINDOW = 3_000; // ms
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
|
|||
);
|
||||
|
||||
// Legacy-only chats can exist before server-side history import finishes.
|
||||
// Fill just the missing ids from the legacy-aware path instead of issuing
|
||||
// one request per thread up front.
|
||||
// Fill only the missing ids via the legacy path instead of one request per
|
||||
// thread up front.
|
||||
const missingThreadIds = allThreadIds.filter(
|
||||
(threadId) => !messagesByThread.has(threadId),
|
||||
);
|
||||
|
|
@ -163,8 +163,7 @@ export function useChatSearchIndex(enabled: boolean): {
|
|||
setLoading(true);
|
||||
buildIndex()
|
||||
.then((result) => {
|
||||
// Drop out-of-order responses so a slower rebuild can't clobber
|
||||
// a fresher one.
|
||||
// Drop out-of-order responses so a slower rebuild can't clobber a fresher one.
|
||||
if (cancelled || seq !== requestSeqRef.current) return;
|
||||
setItems(result);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,9 +61,8 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
|
|||
return items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so
|
||||
// each quiet window produces at most one O(N) fetch; requestSeq
|
||||
// discards stale responses.
|
||||
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so each quiet
|
||||
// window produces at most one O(N) fetch; requestSeq discards stale responses.
|
||||
const SIDEBAR_REFRESH_DEBOUNCE_MS = 300;
|
||||
|
||||
export function useChatSidebarItems(options?: {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Tracks which of the given keys are active and the order in which each became
|
||||
// active, so opt-in composer pills (Canvas, MCP) render in the order they were
|
||||
// toggled on rather than a fixed order.
|
||||
// Tracks which keys are active and their activation order, so opt-in
|
||||
// composer pills (Canvas, MCP) render in toggle-on order, not a fixed one.
|
||||
export function usePillActivationOrder(states: Record<string, boolean>): string[] {
|
||||
const [order, setOrder] = useState<string[]>(() =>
|
||||
Object.keys(states).filter((key) => states[key]),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,17 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Compute rate (bytes/sec) and ETA (seconds) from a time-series of
|
||||
* cumulative ``bytes`` values, using a rolling window of recent samples.
|
||||
* Compute rate (bytes/sec) and ETA (seconds) from a time-series of cumulative
|
||||
* ``bytes`` values, using a rolling window of recent samples.
|
||||
*
|
||||
* Shared between the chat-flow download toast, the training-start
|
||||
* overlay, and the model-load phase UI. All three have the same shape:
|
||||
* a counter that rises monotonically from 0 toward ``totalBytes``, polled
|
||||
* on an interval. The derived stats are identical regardless of whether
|
||||
* the bytes came from an HTTP download or an mmap page-in.
|
||||
* Shared by the chat-flow download toast, training-start overlay, and
|
||||
* model-load phase UI: all three are a counter rising monotonically from 0
|
||||
* toward ``totalBytes``, polled on an interval, regardless of HTTP download or
|
||||
* mmap page-in.
|
||||
*
|
||||
* Stability rule: ``stable`` stays ``false`` until we've observed at
|
||||
* least 3 samples spanning ≥3 seconds. That keeps the UI from flashing
|
||||
* wildly varying rates during the first tick or two when the denominator
|
||||
* is effectively zero.
|
||||
* Stability: ``stable`` stays ``false`` until at least 3 samples spanning >=3s,
|
||||
* so the UI doesn't flash wild rates during the first tick or two when the
|
||||
* denominator is ~0.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -2,25 +2,17 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Friendly default names for auto-created OpenAI shell containers.
|
||||
* Used by the chat-adapter when the lazy-create path fires (Code pill
|
||||
* on, no thread container yet, user has set a non-default TTL). The
|
||||
* goal is a human-memorable label like "otter" or "harbor" instead of
|
||||
* "chat-abc12345" — the user can still rename via the Studio-side
|
||||
* alias map.
|
||||
* Friendly default names for auto-created OpenAI shell containers, used by the
|
||||
* chat-adapter's lazy-create path (Code pill on, no thread container, non-default
|
||||
* TTL). Goal: a memorable label like "otter" instead of "chat-abc12345"; users
|
||||
* can still rename via the Studio alias map.
|
||||
*
|
||||
* The list is curated to:
|
||||
* - Be unambiguous, non-offensive nouns from natural categories
|
||||
* (animals, plants, geography, materials, weather).
|
||||
* - Avoid technical / political / brand words that might read as
|
||||
* odd in a chat UI.
|
||||
* - Stay reasonably small so the bundle cost is negligible (~200
|
||||
* entries × ~7 bytes ≈ 1.5 KB).
|
||||
* The list is curated to be unambiguous, non-offensive nouns from natural
|
||||
* categories (animals, plants, geography, materials, weather), avoid
|
||||
* technical/political/brand words, and stay small (~1.5 KB).
|
||||
*
|
||||
* Collisions are tolerated — the container's real unique key is its
|
||||
* ``cntr_*`` id, not its name. A short random hex suffix is appended
|
||||
* to make accidental same-name collisions visually distinct in the
|
||||
* picker list.
|
||||
* Collisions are tolerated: the real unique key is the ``cntr_*`` id, not the
|
||||
* name. A short random hex suffix keeps same-name picks visually distinct.
|
||||
*/
|
||||
|
||||
const WORDS = [
|
||||
|
|
@ -223,20 +215,15 @@ function randomHexSuffix(): string {
|
|||
) {
|
||||
return crypto.randomUUID().replace(/-/g, "").slice(0, 4);
|
||||
}
|
||||
// Older browser fallback. Math.random is fine here — this is a
|
||||
// display suffix, not a security token.
|
||||
// Older-browser fallback. Math.random is fine: display suffix, not a token.
|
||||
return Math.floor(Math.random() * 0xffff)
|
||||
.toString(16)
|
||||
.padStart(4, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single English-word name with a short random hex suffix.
|
||||
*
|
||||
* Example output: "kestrel-3f9c", "harbor-a012".
|
||||
*
|
||||
* The suffix keeps containers visually distinguishable in the picker
|
||||
* when the same word recurs across creations.
|
||||
* Returns an English-word name with a short random hex suffix (e.g.
|
||||
* "kestrel-3f9c"), so repeated words stay distinguishable in the picker.
|
||||
*/
|
||||
export function pickFriendlyContainerName(): string {
|
||||
const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container";
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ const MCP_PRESETS: readonly McpPreset[] = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset
|
||||
// toggle reuses its row instead of creating duplicates.
|
||||
// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset toggle
|
||||
// reuses its row instead of duplicating.
|
||||
function normalizeMcpUrl(url: string): string {
|
||||
return (url || "").trim().toLowerCase().replace(/\/+$/, "");
|
||||
}
|
||||
|
|
@ -110,8 +110,8 @@ export function McpComposerButton({
|
|||
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
|
||||
const [hintKey, setHintKey] = useState<string | null>(null);
|
||||
|
||||
// Grey out only when a loaded model lacks tool support; with no model yet MCP
|
||||
// can still be pre-selected, matching the other composer tools.
|
||||
// Grey out only when a loaded model lacks tool support; with no model yet,
|
||||
// MCP can still be pre-selected, like the other composer tools.
|
||||
const usable = !modelLoaded || supportsTools;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
|
@ -178,10 +178,9 @@ export function McpComposerButton({
|
|||
}
|
||||
}
|
||||
|
||||
// One dropdown row. Enabled rows get a green underlay and a tick that
|
||||
// becomes an X on hover so a click removes them. A hint shows as a tooltip
|
||||
// driven by row hover; the tooltip anchor is pointer-events-none so the whole
|
||||
// row stays clickable (a Radix TooltipTrigger would swallow the select).
|
||||
// One dropdown row. Enabled rows get a green underlay and a tick that becomes
|
||||
// an X on hover (click removes). The hint tooltip anchor is pointer-events-none
|
||||
// so the row stays clickable (a Radix TooltipTrigger would swallow the select).
|
||||
const renderRow = (opts: {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@
|
|||
/**
|
||||
* Per-provider sampling parameter capability matrix.
|
||||
*
|
||||
* Values are derived from each provider's published chat-completion docs as of
|
||||
* 2026-05. They describe which of our UI knobs map cleanly onto the provider's
|
||||
* request body; the panel hides params a provider does not accept so users
|
||||
* cannot dial a value that gets silently dropped or rejected.
|
||||
*
|
||||
* "Local" models (anything that is not an external provider) are represented by
|
||||
* a null capability — every knob renders for them.
|
||||
* Derived from each provider's published chat-completion docs (2026-05). The
|
||||
* panel hides params a provider does not accept so users cannot dial a value
|
||||
* that gets silently dropped or rejected. "Local" models (non-external) use a
|
||||
* null capability — every knob renders for them.
|
||||
*/
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
|
|
@ -48,9 +45,8 @@ export type ExternalReasoningCapabilities = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Prefer a stored reasoning effort level that exists in ``effortLevels``,
|
||||
* mapping legacy "xhigh" to "max" when the model only exposes the latter
|
||||
* (Claude 4.6 adaptive thinking).
|
||||
* Pick a stored reasoning effort level that exists in `effortLevels`, mapping
|
||||
* legacy "xhigh" to "max" when only the latter is exposed (Claude 4.6).
|
||||
*/
|
||||
export function clampReasoningEffortToLevels(
|
||||
preferred: ExternalReasoningCapabilities["reasoningEffortLevels"][number],
|
||||
|
|
@ -122,8 +118,8 @@ const EXTERNAL_MAX_OUTPUT_TOKENS_BY_MODEL: Array<{
|
|||
|
||||
/**
|
||||
* Documented per-model output cap; unknown ids fall back to
|
||||
* `EXTERNAL_MAX_OUTPUT_TOKENS` (32k). OpenRouter ids are
|
||||
* `provider/model`; the prefix is stripped before matching.
|
||||
* `EXTERNAL_MAX_OUTPUT_TOKENS` (32k). OpenRouter `provider/model` ids have the
|
||||
* prefix stripped before matching.
|
||||
*/
|
||||
export function getExternalMaxOutputTokens(
|
||||
providerType: string | null | undefined,
|
||||
|
|
@ -192,16 +188,14 @@ export function providerSupportsBuiltinWebSearch(
|
|||
modelId?: string | null | undefined,
|
||||
baseUrl?: string | null | undefined,
|
||||
): boolean {
|
||||
// Gemini ships grounded search via `tools: [{googleSearch: {}}]` on
|
||||
// every chat-capable model. Most image-tier ids (`-image`,
|
||||
// `nano-banana`) reject text-tool wiring because the
|
||||
// responseModalities path is mutually exclusive with text tools, but
|
||||
// Google explicitly documents Search grounding on the Gemini 3 image
|
||||
// family (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
|
||||
// nano-banana-pro). Allow Search on those; hide on older image ids.
|
||||
// Custom Gemini OpenAI-compat proxies (non-Google bases) skip the
|
||||
// native translator on the backend, so native tool envelopes never
|
||||
// reach them -- hide the pill there.
|
||||
// Gemini ships grounded search via `tools: [{googleSearch: {}}]` on every
|
||||
// chat-capable model. Most image-tier ids reject text-tool wiring (the
|
||||
// responseModalities path excludes text tools), but Google documents Search
|
||||
// grounding on the Gemini 3 image family (gemini-3-pro-image-preview,
|
||||
// gemini-3.1-flash-image-preview, nano-banana-pro), so allow it there and
|
||||
// hide on older
|
||||
// image ids. Custom Gemini OpenAI-compat proxies skip the backend's native
|
||||
// translator, so native tool envelopes never reach them -- hide the pill.
|
||||
if (providerType === "gemini") {
|
||||
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
|
|
@ -247,8 +241,8 @@ export function providerSupportsFastMode(
|
|||
): boolean {
|
||||
if (providerType !== "anthropic") return false;
|
||||
if (!modelId) return false;
|
||||
// Family boundary ("" or "-") required so IDs like "claude-opus-4-70"
|
||||
// / "claude-opus-4-7b" do not match.
|
||||
// Family boundary ("" or "-") required so IDs like "claude-opus-4-70" or
|
||||
// "claude-opus-4-7b" do not match.
|
||||
return ANTHROPIC_FAST_MODE_MODEL_PREFIXES.some(
|
||||
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`),
|
||||
);
|
||||
|
|
@ -269,15 +263,13 @@ export function providerSupportsFastMode(
|
|||
* thread's stored `openaiCodeExecContainerId`. Documented at
|
||||
* https://developers.openai.com/api/docs/guides/tools-shell
|
||||
*
|
||||
* Returns false for every other provider. The backend additionally
|
||||
* gates the OpenAI shell tool on `is_openai_cloud` so custom
|
||||
* OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report
|
||||
* `provider_type="openai"` never receive the tool — but in practice
|
||||
* none of those catalogs surface the `gpt-5.5` ids anyway, so the
|
||||
* frontend prefix match is enough.
|
||||
* Returns false for every other provider. The backend also gates the OpenAI
|
||||
* shell tool on `is_openai_cloud` so custom OpenAI-compat servers reporting
|
||||
* `provider_type="openai"` never receive it; in practice those catalogs don't
|
||||
* surface the `gpt-5.5` ids, so the frontend prefix match suffices.
|
||||
*
|
||||
* v1 wires the tools themselves; file uploads (Anthropic
|
||||
* `container_upload` / OpenAI `input_file`) are a deliberate follow-up.
|
||||
* v1 wires the tools only; file uploads (Anthropic `container_upload` / OpenAI
|
||||
* `input_file`) are a deliberate follow-up.
|
||||
*/
|
||||
const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
|
||||
"claude-opus-4-7",
|
||||
|
|
@ -286,29 +278,26 @@ const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
|
|||
"claude-opus-4-5",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
// Deprecated upstream but the registry still exposes the ids, so the
|
||||
// pill should remain functional for users on those snapshots.
|
||||
// Deprecated upstream but the registry still exposes these ids; keep the
|
||||
// pill working for users on those snapshots.
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4",
|
||||
] as const;
|
||||
|
||||
// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate
|
||||
// gpt-5.5; gpt-5.5-pro is included because the family share the same
|
||||
// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
|
||||
// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc.
|
||||
// OpenAI cloud shell-tool gating. Docs only show gpt-5.5; gpt-5.5-pro shares
|
||||
// the same /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
|
||||
// match doesn't collide with e.g. a hypothetical `gpt-5.5-turbo`.
|
||||
const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Strict check that a provider configuration points at OpenAI's
|
||||
* managed cloud (api.openai.com) or Azure OpenAI Foundry
|
||||
* (*.openai.azure.com), as opposed to a custom OpenAI-compat backend
|
||||
* (ollama / llama.cpp / vLLM / generic "custom" preset). The shell and
|
||||
* image-generation tools only exist on cloud backends; sending them to
|
||||
* anything else 400s the request. Mirror of the backend's
|
||||
* Strict check that a provider config points at OpenAI managed cloud
|
||||
* (api.openai.com) or Azure OpenAI Foundry (*.openai.azure.com), not a custom
|
||||
* OpenAI-compat backend. The shell and image-generation tools exist only on
|
||||
* cloud backends; sending them elsewhere 400s. Mirrors the backend's
|
||||
* `_is_openai_family_cloud` host check.
|
||||
*/
|
||||
function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean {
|
||||
|
|
@ -357,16 +346,13 @@ export function providerSupportsBuiltinCodeExecution(
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether the selected external provider/model exposes OpenAI's
|
||||
* Responses-API server-side image_generation tool. Lit on for OpenAI
|
||||
* cloud (`api.openai.com`) when the picked model is a Responses-API
|
||||
* family id (gpt-5.x today). The backend additionally gates on
|
||||
* `is_openai_cloud`; mirror that here so the pill is hidden on custom
|
||||
* OpenAI-compat backends (ollama / llama.cpp / vLLM) that report
|
||||
* `provider_type="openai"` but would 400 on a `{type:"image_generation"}`
|
||||
* tool. See backend/core/inference/external_provider.py near line 2770
|
||||
* for the dispatch and backend/tests/test_openai_image_generation.py
|
||||
* for the round-trip coverage.
|
||||
* Whether the selected provider/model exposes OpenAI's Responses-API
|
||||
* image_generation tool. On for OpenAI cloud (`api.openai.com`) with a
|
||||
* Responses-API family id (gpt-5.x). Mirrors the backend's `is_openai_cloud`
|
||||
* gate so the pill hides on custom OpenAI-compat backends reporting
|
||||
* `provider_type="openai"` that would 400 on a `{type:"image_generation"}` tool.
|
||||
* See backend/core/inference/external_provider.py (~line 2770) for dispatch and
|
||||
* backend/tests/test_openai_image_generation.py for round-trip coverage.
|
||||
*/
|
||||
const OPENAI_IMAGE_GENERATION_MODEL_PREFIXES = [
|
||||
"gpt-5.5-pro",
|
||||
|
|
@ -394,14 +380,11 @@ export function providerSupportsBuiltinImageGeneration(
|
|||
);
|
||||
}
|
||||
if (providerType === "gemini") {
|
||||
// Gemini's Nano Banana image-output ids carry either `-image` (e.g.
|
||||
// `gemini-2.5-flash-image`, `gemini-3.1-flash-image-preview`) or the
|
||||
// `nano-banana` alias (`nano-banana-pro-preview`). The backend flips
|
||||
// generationConfig.responseModalities to ["TEXT", "IMAGE"] when one
|
||||
// is picked, and translates inlineData parts into the same image_b64
|
||||
// tool_end envelope the OpenAI path emits so the chat UI renders the
|
||||
// picture inline. Custom Gemini OpenAI-compat proxies skip the
|
||||
// native translator on the backend, so hide the image pill there.
|
||||
// Gemini Nano Banana image-output ids carry `-image` or the `nano-banana`
|
||||
// alias. The backend flips responseModalities to ["TEXT", "IMAGE"] and maps
|
||||
// inlineData parts into the same image_b64 tool_end envelope as the OpenAI
|
||||
// path so the UI renders inline. Custom Gemini OpenAI-compat proxies skip
|
||||
// the native translator, so hide the image pill there.
|
||||
// See https://ai.google.dev/gemini-api/docs/image-generation.
|
||||
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
|
||||
return normalized.includes("-image") || normalized.includes("nano-banana");
|
||||
|
|
@ -420,13 +403,11 @@ function isGeminiImageModel(modelId: string): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether the saved Gemini connection points at a custom
|
||||
* OpenAI-compatible gateway (any non-Google host). The backend
|
||||
* `_is_openai_compatible` mirrors this to route those connections
|
||||
* through `/chat/completions` instead of the native translator, so
|
||||
* native Gemini tool envelopes (googleSearch, codeExecution,
|
||||
* responseModalities) never reach them. Hide the corresponding
|
||||
* Studio pills here so the request, builder, and UI agree.
|
||||
* Whether the saved Gemini connection points at a custom OpenAI-compat gateway
|
||||
* (any non-Google host). The backend `_is_openai_compatible` routes these
|
||||
* through `/chat/completions` instead of the native translator, so native Gemini
|
||||
* tool envelopes never reach them. Hide the matching Studio pills here so the
|
||||
* request, builder, and UI agree.
|
||||
*/
|
||||
export function isGeminiCustomOpenAICompatBase(
|
||||
baseUrl: string | null | undefined,
|
||||
|
|
@ -458,17 +439,13 @@ function geminiImageModelAllowsGoogleSearch(modelId: string): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Per-provider minimum on the outbound max_tokens. Kimi's docs require
|
||||
* `max_tokens >= 16000` whenever a thinking model is in use so the
|
||||
* reasoning_content and final answer both fit in the budget — anything
|
||||
* lower truncates the response mid-stream. Other providers don't have a
|
||||
* documented floor, so they fall through to the generic min of 64 in
|
||||
* the slider.
|
||||
* Per-provider minimum on the outbound max_tokens. Kimi requires
|
||||
* `max_tokens >= 16000` for thinking models so reasoning_content and the answer
|
||||
* both fit; lower truncates mid-stream. Others fall through to the generic 64.
|
||||
*
|
||||
* The chat-adapter resolves the effective floor on send and bumps the
|
||||
* outbound max_tokens up to this value if the user's stored maxTokens
|
||||
* sits below it. The settings panel reflects the same floor as the
|
||||
* slider min so the displayed value never drifts from what's sent.
|
||||
* The chat-adapter resolves the floor on send and bumps maxTokens up to it if
|
||||
* the stored value is below; the settings slider min reflects the same floor so
|
||||
* the displayed value never drifts from what's sent.
|
||||
*/
|
||||
const EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER: Record<string, number> = {
|
||||
kimi: 16000,
|
||||
|
|
@ -528,11 +505,11 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
|||
presencePenalty: false,
|
||||
},
|
||||
mistral: OPENAI_COMPAT_BASE,
|
||||
// Gemini's native generationConfig accepts temperature, topP, topK and
|
||||
// presencePenalty (plus a separate frequencyPenalty we do not surface
|
||||
// today). minP and repetitionPenalty are not part of the contract --
|
||||
// see https://ai.google.dev/api/rest/v1beta/GenerationConfig. Backend
|
||||
// request shaping lives in _stream_gemini in
|
||||
// Gemini's generationConfig accepts temperature, topP, topK and
|
||||
// presencePenalty (plus a frequencyPenalty we don't surface). minP and
|
||||
// repetitionPenalty aren't part of the contract --
|
||||
// see https://ai.google.dev/api/rest/v1beta/GenerationConfig. Backend shaping
|
||||
// lives in _stream_gemini in
|
||||
// studio/backend/core/inference/external_provider.py.
|
||||
gemini: {
|
||||
temperature: true,
|
||||
|
|
@ -542,11 +519,9 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
|||
repetitionPenalty: false,
|
||||
presencePenalty: true,
|
||||
},
|
||||
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and
|
||||
// top_p to fixed defaults and 400s on any other value:
|
||||
// "invalid temperature: only 1 is allowed for this model".
|
||||
// Hide both sliders so the user is not offered knobs the model
|
||||
// silently overrides. Backend additionally strips these fields via
|
||||
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and top_p to
|
||||
// fixed defaults and 400s on any other value ("invalid temperature: only 1 is
|
||||
// allowed for this model"). Hide both sliders. Backend also strips these via
|
||||
// PROVIDER_REGISTRY['kimi']['body_omit'].
|
||||
kimi: {
|
||||
temperature: false,
|
||||
|
|
@ -570,9 +545,9 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
|||
// OpenRouter silently drops params the target model does not support, so we
|
||||
// surface every knob and let the gateway handle the per-model fan-out.
|
||||
openrouter: ALL_SUPPORTED,
|
||||
// Local OpenAI-compatible connections are proxied through the OpenAI backend
|
||||
// path, but vLLM/Ollama/llama.cpp users often want top_k / min_p /
|
||||
// repetition controls, so be permissive.
|
||||
// Local OpenAI-compat connections go through the OpenAI backend path, but
|
||||
// vLLM/Ollama/llama.cpp users often want top_k/min_p/repetition controls, so
|
||||
// be permissive.
|
||||
custom: ALL_SUPPORTED,
|
||||
vllm: ALL_SUPPORTED,
|
||||
ollama: ALL_SUPPORTED,
|
||||
|
|
@ -733,11 +708,11 @@ function withReasoningEffortStyle(caps: ReasoningCaps): ExternalReasoningCapabil
|
|||
}
|
||||
|
||||
function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
|
||||
// Kimi exposes a boolean thinking toggle rather than an effort scale.
|
||||
// - kimi-k2.6: thinking enabled by default, toggleable
|
||||
// via extra_body: {thinking: {type: enabled|disabled}}
|
||||
// - kimi-k2-thinking: thinking always on, no off switch
|
||||
// - kimi-k2.5 (and anything else): no thinking
|
||||
// Kimi exposes a boolean thinking toggle, not an effort scale.
|
||||
// - kimi-k2.6: on by default, toggleable via
|
||||
// extra_body: {thinking: {type: enabled|disabled}}
|
||||
// - kimi-k2-thinking: always on, no off switch
|
||||
// - kimi-k2.5 (and others): no thinking
|
||||
if (modelId === "kimi-k2-thinking") {
|
||||
return withEnableThinkingStyle({
|
||||
supportsReasoning: true,
|
||||
|
|
@ -754,16 +729,13 @@ function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCap
|
|||
}
|
||||
|
||||
// Gemini's thinking ladder.
|
||||
// - Gemini 3.x (3 / 3.1 / 3.5, Pro + Flash + Flash-Lite) and the
|
||||
// gemini-pro-latest / gemini-flash-latest aliases use the new
|
||||
// `thinkingConfig.thinkingLevel` string field (LOW/MEDIUM/HIGH/
|
||||
// MINIMAL). Pro tier rejects MINIMAL.
|
||||
// - Gemini 2.5 Flash + 2.5 Pro stay on the integer
|
||||
// `thinkingConfig.thinkingBudget` (0=off on Flash, -1=dynamic,
|
||||
// N>0=cap; Pro rejects 0).
|
||||
// - 2.5 Flash-Lite: no native thinking surfaced; leave it off.
|
||||
// - Image-tier ids (`*-image*`, `nano-banana-pro-preview`): image
|
||||
// generation path -- no reasoning controls.
|
||||
// - Gemini 3.x (Pro + Flash + Flash-Lite) and the gemini-pro-latest /
|
||||
// gemini-flash-latest aliases use the string `thinkingConfig.thinkingLevel`
|
||||
// (LOW/MEDIUM/HIGH/MINIMAL); Pro tier rejects MINIMAL.
|
||||
// - Gemini 2.5 Flash + 2.5 Pro use the integer `thinkingConfig.thinkingBudget`
|
||||
// (0=off on Flash, -1=dynamic, N>0=cap; Pro rejects 0).
|
||||
// - 2.5 Flash-Lite: no native thinking surfaced; leave off.
|
||||
// - Image-tier ids: image generation path -- no reasoning controls.
|
||||
const GEMINI3_PRO_PREFIXES = [
|
||||
"gemini-3.5-pro",
|
||||
"gemini-3.1-pro",
|
||||
|
|
@ -795,9 +767,8 @@ function resolveGeminiReasoningCapabilities(
|
|||
// Image generation; no thinking knob.
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
// Gemini 2.5 Flash-Lite supports `thinkingBudget` with `0` = off and
|
||||
// a positive range starting at 512 (the backend maps "minimal" to
|
||||
// that floor at external_provider._stream_gemini). Check this branch
|
||||
// Gemini 2.5 Flash-Lite: `thinkingBudget` 0 = off, positive range from 512
|
||||
// (backend maps "minimal" to that floor in _stream_gemini). Check this branch
|
||||
// BEFORE the broader `gemini-2.5-flash` prefix.
|
||||
// https://ai.google.dev/gemini-api/docs/thinking
|
||||
if (m.startsWith("gemini-2.5-flash-lite")) {
|
||||
|
|
@ -815,10 +786,10 @@ function resolveGeminiReasoningCapabilities(
|
|||
});
|
||||
}
|
||||
if (GEMINI3_PRO_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 3.x Pro: thinkingLevel supports low/medium/high per
|
||||
// Gemini 3.x Pro: thinkingLevel low/medium/high; cannot fully disable, and
|
||||
// "minimal" is rejected on Pro. Refs:
|
||||
// https://ai.google.dev/gemini-api/docs/thinking and
|
||||
// https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro.
|
||||
// Cannot fully disable thinking; "minimal" is rejected on Pro.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
|
|
@ -826,8 +797,8 @@ function resolveGeminiReasoningCapabilities(
|
|||
});
|
||||
}
|
||||
if (GEMINI3_FLASH_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal
|
||||
// is the closest to "off" Google offers on Gemini 3.
|
||||
// Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal is the
|
||||
// closest to "off" Google offers on Gemini 3.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
|
|
@ -840,9 +811,9 @@ function resolveGeminiReasoningCapabilities(
|
|||
});
|
||||
}
|
||||
if (GEMINI25_PRO_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects with
|
||||
// "only works in thinking mode"); backend coerces to a small
|
||||
// positive budget. The picker still hides the off switch.
|
||||
// Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects "only works in
|
||||
// thinking mode"); backend coerces to a small positive budget. Hide the off
|
||||
// switch in the picker.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
|
|
@ -907,9 +878,8 @@ function resolveConnectionLevelReasoning(
|
|||
}
|
||||
|
||||
/**
|
||||
* resolve external-model thinking capabilities.
|
||||
* provider-specific matching lives in the OpenAI/Anthropic resolvers.
|
||||
* other providers default to no reasoning controls.
|
||||
* Resolve external-model thinking capabilities. Provider-specific matching lives
|
||||
* in the per-provider resolvers; others default to no reasoning controls.
|
||||
*/
|
||||
export function getExternalReasoningCapabilities(
|
||||
providerType: string | null | undefined,
|
||||
|
|
@ -951,10 +921,9 @@ export function getExternalReasoningCapabilities(
|
|||
const isMistralProvider = normalizedProvider === "mistral";
|
||||
const isOpenRouterProvider = normalizedProvider === "openrouter";
|
||||
if (isOpenRouterProvider) {
|
||||
// OpenRouter's unified `reasoning` parameter is accepted on every
|
||||
// chat-completion request; the gateway silently no-ops for models
|
||||
// that don't reason. Mandatory-reasoning ids are handled by the
|
||||
// early guard above; everything else exposes a toggleable control.
|
||||
// OpenRouter's unified `reasoning` param is accepted on every request; the
|
||||
// gateway no-ops for non-reasoning models. Mandatory-reasoning ids are
|
||||
// handled by the early guard; everything else gets a toggleable control.
|
||||
return {
|
||||
supportsReasoning: true,
|
||||
reasoningStyle: "enable_thinking",
|
||||
|
|
@ -966,10 +935,9 @@ export function getExternalReasoningCapabilities(
|
|||
if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching);
|
||||
if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching);
|
||||
if (normalizedProvider === "gemini") {
|
||||
// Custom Gemini OAI-compat gateways (LiteLLM, proxies) route
|
||||
// through /chat/completions which drops the Gemini-native
|
||||
// thinkingConfig payload. Hide the native thinking ladder so the
|
||||
// UI does not advertise a control the backend cannot honor.
|
||||
// Custom Gemini OAI-compat gateways route through /chat/completions, which
|
||||
// drops the native thinkingConfig payload. Hide the native thinking ladder
|
||||
// so the UI doesn't advertise a control the backend can't honor.
|
||||
if (isGeminiCustomOpenAICompatBase(options?.baseUrl)) {
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,9 +194,8 @@ class PDFAttachmentAdapter implements AttachmentAdapter {
|
|||
|
||||
class TextAttachmentAdapter implements AttachmentAdapter {
|
||||
// MIME is unreliable for source files, so also match by extension
|
||||
// (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers
|
||||
// svg, code, config and other plain-text formats; html keeps its own
|
||||
// adapter below.
|
||||
// (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers svg, code,
|
||||
// config and other plain-text formats; html keeps its own adapter below.
|
||||
accept = [
|
||||
"text/plain,text/markdown,text/csv,text/xml,text/json,text/css",
|
||||
"application/json,application/xml,image/svg+xml",
|
||||
|
|
@ -258,9 +257,7 @@ class HtmlAttachmentAdapter implements AttachmentAdapter {
|
|||
|
||||
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
|
||||
const html = await attachment.file.text();
|
||||
// Strip HTML tags to extract readable text
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
// Remove script and style elements
|
||||
for (const el of doc.querySelectorAll("script, style")) el.remove();
|
||||
const text = (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
|
|
@ -584,9 +581,9 @@ export async function ensureThreadRecord({
|
|||
try {
|
||||
await saveStoredChatThread(record);
|
||||
} catch (error) {
|
||||
// assistant-ui can issue overlapping first-message persistence calls.
|
||||
// If another call created the same thread while this one was waiting,
|
||||
// treat initialization as successful and let the message write continue.
|
||||
// assistant-ui can issue overlapping first-message persistence calls. If
|
||||
// another call created the same thread while this one waited, treat init as
|
||||
// successful and let the message write continue.
|
||||
const existingAfterRace = await listStoredChatThreads({
|
||||
includeArchived: true,
|
||||
}).catch(() => []);
|
||||
|
|
@ -866,7 +863,7 @@ function useStudioRuntimeAdapters(
|
|||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
|
||||
// Restore context usage from last assistant message if model matches
|
||||
// Restore context usage from last assistant message if model matches.
|
||||
const lastAssistant = [...msgs]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
|
|
@ -882,14 +879,14 @@ function useStudioRuntimeAdapters(
|
|||
}
|
||||
| undefined;
|
||||
const store = useChatRuntimeStore.getState();
|
||||
// Window check applies only when a local GGUF window is known;
|
||||
// external providers have ggufContextLength === null.
|
||||
// Window check applies only when a local GGUF window is known; external
|
||||
// providers have ggufContextLength === null.
|
||||
const withinLocalLimit =
|
||||
!store.ggufContextLength ||
|
||||
(savedUsage?.totalTokens ?? 0) <= store.ggufContextLength;
|
||||
// Legacy unscoped usage (no modelId) is only trusted when a
|
||||
// known local window bounds the totals, so we can't misattribute
|
||||
// an old local turn to a newly-selected external provider.
|
||||
// Legacy unscoped usage (no modelId) is trusted only when a known local
|
||||
// window bounds the totals, so an old local turn can't be misattributed
|
||||
// to a newly-selected external provider.
|
||||
const modelMatches = savedUsage?.modelId
|
||||
? savedUsage.modelId === store.params.checkpoint
|
||||
: typeof store.ggufContextLength === "number" &&
|
||||
|
|
@ -898,12 +895,10 @@ function useStudioRuntimeAdapters(
|
|||
store.setContextUsage(savedUsage);
|
||||
}
|
||||
|
||||
// If any message has a stored parentId, reconstruct the tree
|
||||
// so retries/regenerations load as branches instead of being
|
||||
// unrolled into a flat list. For mixed legacy/new threads
|
||||
// (old messages without parentId + new messages with), infer
|
||||
// sequential parents for old messages to preserve the chain.
|
||||
// Fall back to fromArray for fully legacy threads.
|
||||
// If any message has a stored parentId, reconstruct the tree so
|
||||
// retries/regenerations load as branches rather than a flat list. For
|
||||
// mixed legacy/new threads, infer sequential parents for old messages to
|
||||
// preserve the chain. Fall back to fromArray for fully legacy threads.
|
||||
const hasParentIds = msgs.some((m) => m.parentId != null);
|
||||
if (hasParentIds) {
|
||||
let previousId: string | null = null;
|
||||
|
|
@ -931,7 +926,7 @@ function useStudioRuntimeAdapters(
|
|||
return;
|
||||
}
|
||||
// Keep single-chat runtime state in sync once a new chat is first
|
||||
// persisted. Compare panes intentionally do not write global activeThreadId.
|
||||
// persisted. Compare panes intentionally don't write global activeThreadId.
|
||||
if (modelType === "base" && !pairId) {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
if (store.activeThreadId !== remoteId) {
|
||||
|
|
@ -1071,8 +1066,8 @@ function ThreadNewChatSwitch({
|
|||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
// Switch to a fresh local thread without persisting it yet.
|
||||
// Persistence still happens on first message append.
|
||||
// Switch to a fresh local thread without persisting it yet; persistence
|
||||
// still happens on first message append.
|
||||
void aui.threads().switchToNewThread();
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
}, [aui, isLoading, nonce]);
|
||||
|
|
@ -1099,8 +1094,8 @@ function ActiveThreadSync({
|
|||
}
|
||||
|
||||
// Exposes the current thread's cancelRun() via the shared store so external
|
||||
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream
|
||||
// before deleting the thread — mirroring the Stop → Trash sequence.
|
||||
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream before
|
||||
// deleting the thread, mirroring the Stop -> Trash sequence.
|
||||
function CancelRegistrar(): ReactElement | null {
|
||||
const aui = useAui();
|
||||
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export interface CompareHandle {
|
|||
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
// Inlined to avoid a new icon dependency. Kept in sync with the main composer.
|
||||
// Inlined to avoid a new icon dep. Kept in sync with the main composer.
|
||||
const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
|
|
@ -149,9 +149,9 @@ function isNativeComposing(event: Event) {
|
|||
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||
}
|
||||
|
||||
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
|
||||
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
|
||||
// IME commit, so the compose flag would otherwise stay true forever.
|
||||
// Mirrors the threshold in thread.tsx. Chrome on Windows-over-WSL (#5546)
|
||||
// never fires `compositionend` after IME commit, so the compose flag would
|
||||
// otherwise stay true forever.
|
||||
const IME_STUCK_TIMEOUT_MS = 2500;
|
||||
|
||||
function fileToBase64DataURL(file: File): Promise<string> {
|
||||
|
|
@ -187,8 +187,8 @@ function formatReasoningDisabledLabel(
|
|||
modelId?: string,
|
||||
): string {
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
// Magistral keeps the "none" wire value, but UX should present this floor
|
||||
// as "Medium" rather than a disabled state label.
|
||||
// Magistral keeps the "none" wire value, but UX presents this floor as
|
||||
// "Medium" rather than a disabled-state label.
|
||||
if (normalized.includes("magistral-medium-latest")) return "Medium";
|
||||
return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off";
|
||||
}
|
||||
|
|
@ -374,7 +374,7 @@ type CompareModelSelection = {
|
|||
ggufVariant?: string;
|
||||
};
|
||||
|
||||
// Tool icon plus an X overlay the CSS reveals on hover when the pill is active.
|
||||
// Tool icon plus an X overlay CSS reveals on hover when the pill is active.
|
||||
function PillGlyph({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="composer-pill-glyph">
|
||||
|
|
@ -396,8 +396,7 @@ export function SharedComposer({
|
|||
onExitCompare?: () => void;
|
||||
}): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
// Exit compare. Uses the parent's restore handler, or a fresh chat when
|
||||
// compare was opened by direct URL.
|
||||
// Exit compare: parent's restore handler, or fresh chat if opened by URL.
|
||||
const handleExitCompare = useCallback(() => {
|
||||
if (onExitCompare) {
|
||||
onExitCompare();
|
||||
|
|
@ -472,7 +471,7 @@ export function SharedComposer({
|
|||
const setMcpEnabledForChat = useChatRuntimeStore(
|
||||
(s) => s.setMcpEnabledForChat,
|
||||
);
|
||||
// Three most recently updated projects for the quick-access submenu.
|
||||
// Three most recently updated projects for the quick-access submenu
|
||||
const { projects } = useChatProjects();
|
||||
const recentProjects = [...projects]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
|
|
@ -507,11 +506,10 @@ export function SharedComposer({
|
|||
modelLoaded,
|
||||
});
|
||||
const isCompareMode = Boolean(model1?.id || model2?.id);
|
||||
// Attach-time gate. Compare mode defers to send: the catalog can lag
|
||||
// behind a model's real capabilities (e.g., a GGUF whose mmproj
|
||||
// arrives after the catalog snapshot), and we only sync the models[]
|
||||
// entry after ensureModelLoaded runs at send time. Single mode uses
|
||||
// the loaded model's runtime capability.
|
||||
// Attach-time gate. Compare mode defers to send: the catalog can lag a
|
||||
// model's real capabilities (e.g. a GGUF whose mmproj arrives after the
|
||||
// snapshot), and models[] only syncs after ensureModelLoaded at send time.
|
||||
// Single mode uses the loaded model's runtime capability.
|
||||
const attachUnavailableReason = isCompareMode ? null : imageUnavailableReason;
|
||||
const effectiveExternalModelId =
|
||||
selectedExternalProvider?.providerType === "openrouter" &&
|
||||
|
|
@ -547,11 +545,10 @@ export function SharedComposer({
|
|||
const reasoningLockedOn =
|
||||
effectiveSupportsReasoning &&
|
||||
(effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff);
|
||||
// Kimi's $web_search builtin mandates thinking=disabled per the docs at
|
||||
// https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay
|
||||
// clickable for Kimi, but turning one on flips the other off — the
|
||||
// click handlers below enforce this mutual exclusion so the visible
|
||||
// state always matches what the backend actually sends.
|
||||
// Kimi's $web_search builtin mandates thinking=disabled
|
||||
// (https://platform.kimi.ai/docs/guide/use-web-search). Both pills stay
|
||||
// clickable, but turning one on flips the other off; the click handlers
|
||||
// below enforce this so the visible state matches what the backend sends.
|
||||
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
|
||||
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
|
||||
const effectiveReasoningVisualEnabled =
|
||||
|
|
@ -563,15 +560,13 @@ export function SharedComposer({
|
|||
const thinkingActiveLook = isEffort
|
||||
? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled)
|
||||
: reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled);
|
||||
// Two-pill gating: Search pill lights up when the runtime has either
|
||||
// a local tool runtime (supportsTools, gives us our Code/python + local
|
||||
// web_search) OR a server-side web_search the provider runs for us
|
||||
// (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter
|
||||
// / Kimi). Code pill lights up on the local runtime OR when Anthropic
|
||||
// is selected with a model that accepts the server-side
|
||||
// code_execution_20250825 tool — see
|
||||
// providerSupportsBuiltinCodeExecution. Anthropic is the only external
|
||||
// provider that ships a code-execution tool today.
|
||||
// Two-pill gating: Search lights up on a local tool runtime (supportsTools:
|
||||
// Code/python + local web_search) OR a provider-run server-side web_search
|
||||
// (supportsBuiltinWebSearch: OpenAI/Anthropic/OpenRouter/Kimi). Code lights
|
||||
// up on the local runtime OR Anthropic with a model accepting the
|
||||
// server-side code_execution_20250825 tool (see
|
||||
// providerSupportsBuiltinCodeExecution). Anthropic is the only external
|
||||
// provider shipping a code-execution tool today.
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedExternalProvider?.providerType,
|
||||
effectiveExternalModelId,
|
||||
|
|
@ -586,24 +581,22 @@ export function SharedComposer({
|
|||
selectedExternalProvider?.providerType,
|
||||
);
|
||||
// Gemini rejects codeExecution alongside image modalities. Search is
|
||||
// blocked on older Gemini image ids but allowed on Gemini 3 image
|
||||
// models -- supportsBuiltinWebSearch already encodes the per-model
|
||||
// allowance, so we only disable Code unconditionally in Gemini
|
||||
// image mode.
|
||||
// blocked on older Gemini image ids but allowed on Gemini 3 image models
|
||||
// (supportsBuiltinWebSearch encodes the per-model allowance), so we only
|
||||
// disable Code unconditionally in Gemini image mode.
|
||||
const isExternalGemini = selectedExternalProvider?.providerType === "gemini";
|
||||
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
|
||||
const imageModeDisablesCode =
|
||||
isExternalGemini && imageToolsEnabled && !imageDisabled;
|
||||
// Image-tier Gemini models always reject codeExecution and reject
|
||||
// web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded
|
||||
// in supportsBuiltinWebSearch). Don't let the local `supportsTools`
|
||||
// runtime flag re-enable a pill the Gemini backend will silently
|
||||
// drop. Detect "external provider is Gemini AND model is image-tier"
|
||||
// and gate strictly on the provider builtin support.
|
||||
// web_search on older ids (Gemini 3.x Pro/Flash allow it, encoded in
|
||||
// supportsBuiltinWebSearch). Don't let local `supportsTools` re-enable a
|
||||
// pill the Gemini backend silently drops: detect image-tier Gemini and
|
||||
// gate strictly on provider builtin support.
|
||||
const isGeminiImageTier =
|
||||
isExternalGemini && supportsBuiltinImageGeneration;
|
||||
// Disable only when a loaded model lacks the capability; with no model the
|
||||
// tool can still be pre-selected and reflected, matching the + menu.
|
||||
// tool can still be pre-selected, matching the + menu.
|
||||
const searchDisabled =
|
||||
modelLoaded &&
|
||||
(isGeminiImageTier
|
||||
|
|
@ -615,14 +608,14 @@ export function SharedComposer({
|
|||
? true
|
||||
: !(supportsTools || supportsBuiltinCodeExecution))) ||
|
||||
imageModeDisablesCode;
|
||||
// Images pill is only ever lit on OpenAI cloud's Responses-API models
|
||||
// and Gemini Nano Banana family. No local tool runtime fallback.
|
||||
// Images pill lights only on OpenAI cloud Responses-API models and the
|
||||
// Gemini Nano Banana family. No local tool runtime fallback.
|
||||
const showImagePill = supportsBuiltinImageGeneration;
|
||||
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
|
||||
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
|
||||
const showWebFetchPill = supportsBuiltinWebFetch;
|
||||
// With more than 4 pills showing, collapse them to icons only to cut clutter.
|
||||
// Compare, Search and Code always show; the rest are conditional.
|
||||
// Above 4 pills, collapse to icons only to cut clutter. Compare, Search and
|
||||
// Code always show; the rest are conditional.
|
||||
const pillsCompact =
|
||||
3 +
|
||||
(showImagePill ? 1 : 0) +
|
||||
|
|
@ -630,8 +623,8 @@ export function SharedComposer({
|
|||
(artifactsEnabled ? 1 : 0) +
|
||||
(mcpEnabledForChat ? 1 : 0) >
|
||||
4;
|
||||
// Backwards-compatible alias for any other call site that may still
|
||||
// reference `toolsDisabled` (rare; both pills used it before).
|
||||
// Backwards-compatible alias for call sites still referencing
|
||||
// `toolsDisabled` (rare; both pills used it before).
|
||||
const toolsDisabled = codeDisabled;
|
||||
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
|
||||
const clearPendingAudioStore = useChatRuntimeStore(
|
||||
|
|
@ -753,11 +746,11 @@ export function SharedComposer({
|
|||
const isGeneralizedCompare =
|
||||
hasCompareHandles && Boolean(model1?.id && model2?.id);
|
||||
|
||||
// Generalized compare requires both panes to have a model. A
|
||||
// half-selected send either races to an empty bubble with bogus
|
||||
// tok/s (#5569) or leaves the empty pane with a dangling prompt.
|
||||
// hasCompareHandles is true only in GeneralCompareContent, so
|
||||
// LoraCompare and single-pane chats are unaffected.
|
||||
// Generalized compare requires both panes to have a model. A half-
|
||||
// selected send either races to an empty bubble with bogus tok/s (#5569)
|
||||
// or leaves the empty pane with a dangling prompt. hasCompareHandles is
|
||||
// true only in GeneralCompareContent, so LoraCompare and single-pane
|
||||
// chats are unaffected.
|
||||
if (hasCompareHandles && !isGeneralizedCompare) {
|
||||
toast.error("Pick a model in each pane to compare", {
|
||||
description:
|
||||
|
|
@ -771,10 +764,10 @@ export function SharedComposer({
|
|||
!isGeneralizedCompare &&
|
||||
imageUnavailableReason
|
||||
) {
|
||||
// Single mode: the loaded model's runtime capability is known
|
||||
// here. Compare mode defers — each ensureModelLoaded below sets
|
||||
// loadedIsMultimodal for its side, and the chat-adapter's
|
||||
// pre-stream gate runs per-side against that fresh state.
|
||||
// Single mode: the loaded model's runtime capability is known here.
|
||||
// Compare mode defers: each ensureModelLoaded sets loadedIsMultimodal
|
||||
// for its side, and the chat-adapter's pre-stream gate runs per-side
|
||||
// against that fresh state.
|
||||
toast.error(imageUnavailableReason);
|
||||
return;
|
||||
}
|
||||
|
|
@ -869,10 +862,9 @@ export function SharedComposer({
|
|||
supportsTools: resp.supports_tools ?? false,
|
||||
loadedIsMultimodal: isMultimodalResponse(resp),
|
||||
});
|
||||
// Sync the models[] entry with the load response so the
|
||||
// attach/send gates read fresh capabilities. /api/models/list
|
||||
// can lag behind a model's actual state (e.g., a GGUF whose
|
||||
// mmproj was downloaded after the catalog snapshot).
|
||||
// Sync the models[] entry with the load response so attach/send gates
|
||||
// read fresh capabilities. /api/models/list can lag a model's actual
|
||||
// state (e.g. a GGUF whose mmproj arrived after the snapshot).
|
||||
const currentModels = useChatRuntimeStore.getState().models;
|
||||
const idx = currentModels.findIndex((m) => m.id === sel.id);
|
||||
const synced = {
|
||||
|
|
@ -982,13 +974,12 @@ export function SharedComposer({
|
|||
const busy = running || comparing;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
|
||||
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
|
||||
// watchdog (#5546) cleared it during a long candidate-window pause; this
|
||||
// keeps a follow-up click-Send from submitting preedit text. Re-arm the
|
||||
// watchdog on the same path — without it the WSL+Chrome no-compositionend
|
||||
// case would leave composingRef pinned forever after an IME keypress and
|
||||
// re-lock Send.
|
||||
// IME composition (JP/CN/KR): Enter commits the candidate, don't hijack it
|
||||
// (#5318). Re-pin composingRef in case the stuck watchdog (#5546) cleared
|
||||
// it during a long candidate-window pause, so a follow-up click-Send won't
|
||||
// submit preedit text. Re-arm the watchdog on the same path; without it the
|
||||
// WSL+Chrome no-compositionend case pins composingRef forever after an IME
|
||||
// keypress and re-locks Send.
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) {
|
||||
composingRef.current = true;
|
||||
refreshStuckImeTimer();
|
||||
|
|
@ -1019,8 +1010,8 @@ export function SharedComposer({
|
|||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
// Phase 1 native model drops own Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
|
||||
// Phase 1 native model drops own Tauri local-path drops. Restore
|
||||
// browser attachment drops in Tauri once Phase 1d adds token bridging.
|
||||
if (isTauri) return;
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
|
|
@ -1071,11 +1062,11 @@ export function SharedComposer({
|
|||
value={text}
|
||||
onChange={(e) => {
|
||||
// ALWAYS mirror the DOM value into React state, even during IME
|
||||
// composition. The controlled `value` prop must match the DOM at
|
||||
// all times, otherwise any unrelated parent re-render reconciles
|
||||
// the textarea back to the stored value mid-composition — wiping
|
||||
// the IME preedit AND prior committed text (e.g. Tab cycling
|
||||
// candidates erases earlier words). Issue #5318.
|
||||
// composition: the controlled `value` must match the DOM at all
|
||||
// times, else an unrelated parent re-render reconciles the textarea
|
||||
// back to the stored value mid-composition, wiping the IME preedit
|
||||
// AND prior committed text (e.g. Tab-cycling candidates erases
|
||||
// earlier words). #5318.
|
||||
setCompositionState(isNativeComposing(e.nativeEvent));
|
||||
setText(e.target.value);
|
||||
}}
|
||||
|
|
@ -1093,8 +1084,8 @@ export function SharedComposer({
|
|||
placeholder="Send to both models..."
|
||||
className="composer-input"
|
||||
rows={1}
|
||||
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
|
||||
// from the first strong character; no effect on LTR scripts.
|
||||
// dir="auto" detects RTL (Arabic/Hebrew/Persian/Urdu) from the first
|
||||
// strong character; no effect on LTR scripts.
|
||||
dir="auto"
|
||||
/>
|
||||
<div className="composer-action-wrapper">
|
||||
|
|
@ -1127,8 +1118,8 @@ export function SharedComposer({
|
|||
open={newProjectOpen}
|
||||
onOpenChange={setNewProjectOpen}
|
||||
/>
|
||||
{/* Same + side menu as the single-chat composer (ComposerToolsMenu),
|
||||
wired to the compare composer's own file/audio inputs and tools. */}
|
||||
{/* Same + menu as single-chat (ComposerToolsMenu), wired to the
|
||||
compare composer's own file/audio inputs and tools. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -1241,8 +1232,8 @@ export function SharedComposer({
|
|||
{mcpEnabledForChat ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{/* RAG hidden temporarily */}
|
||||
{/* Always active: this menu only renders in compare mode.
|
||||
Ticked like Web search/Code; click toggles it off. */}
|
||||
{/* Always active: this menu only renders in compare mode. Ticked
|
||||
like Web search/Code; click toggles it off. */}
|
||||
<DropdownMenuItem
|
||||
className="text-primary font-medium"
|
||||
onSelect={handleExitCompare}
|
||||
|
|
@ -1303,7 +1294,7 @@ export function SharedComposer({
|
|||
const next = !toolsEnabled;
|
||||
setToolsEnabled(next);
|
||||
// Kimi's $web_search builtin requires thinking=disabled
|
||||
// (https://platform.kimi.ai/docs/guide/use-web-search). Toggle
|
||||
// (https://platform.kimi.ai/docs/guide/use-web-search): toggle
|
||||
// the Think pill off when Search is on, mirroring the backend.
|
||||
if (isKimiExternal) {
|
||||
setReasoningEnabled(!next, { persist: false });
|
||||
|
|
@ -1564,9 +1555,8 @@ export function SharedComposer({
|
|||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
// Mutual exclusion: Kimi's $web_search builtin
|
||||
// requires thinking off, so turning thinking on flips
|
||||
// the Search pill off (and vice versa).
|
||||
// Mutual exclusion: Kimi's $web_search builtin requires
|
||||
// thinking off, so turning thinking on flips Search off.
|
||||
if (isKimiExternal && next && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,11 @@ export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
|
|||
"unsloth_chat_web_fetch_tools_enabled";
|
||||
|
||||
// External provider selection is encoded into `params.checkpoint` as
|
||||
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
|
||||
// Omits `checkpoint` because the local-model side is mirrored by the
|
||||
// backend's `/api/inference/status.active_model` response. External
|
||||
// selections have no such backend mirror, so without explicit
|
||||
// localStorage persistence here the user's external pick is silently
|
||||
// reset to the default on every page refresh.
|
||||
// `external::<providerId>::<modelId>`. PersistedChatSettings omits `checkpoint`
|
||||
// because the local-model side is mirrored by the backend's
|
||||
// /api/inference/status.active_model. External selections have no such mirror,
|
||||
// so without explicit localStorage persistence here the user's external pick
|
||||
// is reset to the default on every refresh.
|
||||
const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint";
|
||||
|
||||
function loadLastExternalCheckpoint(): string | null {
|
||||
|
|
@ -61,13 +60,13 @@ function saveLastExternalCheckpoint(value: string | null): void {
|
|||
if (value && isExternalModelId(value)) {
|
||||
window.localStorage.setItem(LAST_EXTERNAL_CHECKPOINT_KEY, value);
|
||||
} else {
|
||||
// Clearing on a switch to a local / empty checkpoint means the
|
||||
// next refresh won't override the now-active local selection.
|
||||
// Clear on switch to a local/empty checkpoint so the next refresh
|
||||
// won't override the now-active local selection.
|
||||
window.localStorage.removeItem(LAST_EXTERNAL_CHECKPOINT_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Storage quota / private-mode failures are non-fatal -- the
|
||||
// selection just won't survive the refresh.
|
||||
// Storage quota / private-mode failures are non-fatal; selection just
|
||||
// won't survive the refresh.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +102,9 @@ function warnSettingsPersistenceFailure(): void {
|
|||
});
|
||||
}
|
||||
|
||||
// Coalesce setting writes into one pendingPatch (deep merge for nested
|
||||
// keys), flush on a trailing-edge debounce, flush on beforeunload so a
|
||||
// pending patch survives tab close. Slider drag ticks now produce one
|
||||
// HTTP write per quiet window instead of one per tick.
|
||||
// Coalesce setting writes into one pendingPatch (deep merge for nested keys),
|
||||
// flush on a trailing-edge debounce and on beforeunload so a pending patch
|
||||
// survives tab close. Slider drags produce one HTTP write per quiet window.
|
||||
type SettingsPatch = Parameters<typeof savePersistedChatSettingsPatch>[0];
|
||||
|
||||
const SETTINGS_DEBOUNCE_MS = 400;
|
||||
|
|
@ -156,9 +154,9 @@ function saveSettingsPatch(patch: SettingsPatch): void {
|
|||
}, SETTINGS_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Best-effort flush of any pending patch when the tab closes. keepalive
|
||||
// lets the PUT outlive the unload; without it the browser cancels the
|
||||
// fetch and the user's last slider drag is dropped.
|
||||
// Best-effort flush of any pending patch on tab close. keepalive lets the PUT
|
||||
// outlive the unload; without it the browser cancels the fetch and the user's
|
||||
// last slider drag is dropped.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (pendingTimer !== null) clearTimeout(pendingTimer);
|
||||
|
|
@ -191,9 +189,9 @@ export function loadOptionalBool(key: string): boolean | null {
|
|||
|
||||
/**
|
||||
* Resolve the web-search / code-execution pill state to apply when a model
|
||||
* loads. Honors the user's persisted preference so loading a tool-capable
|
||||
* model never silently re-enables a pill the user turned off; falls back to
|
||||
* the model's capability only when no preference has been expressed.
|
||||
* loads. Honors the user's persisted preference so a tool-capable model never
|
||||
* re-enables a pill the user turned off; falls back to the model's capability
|
||||
* only when no preference has been expressed.
|
||||
*/
|
||||
export function resolveToolsEnabledOnLoad(supportsTools: boolean): {
|
||||
toolsEnabled: boolean;
|
||||
|
|
@ -255,12 +253,10 @@ type ChatRuntimeStore = {
|
|||
reasoningAlwaysOn: boolean;
|
||||
reasoningEnabled: boolean;
|
||||
/**
|
||||
* The model id the OpenRouter router actually picked for the most recent
|
||||
* stream when the active checkpoint is the openrouter/free meta-model.
|
||||
* Updated each time a chunk arrives carrying a non-empty `model` field
|
||||
* that differs from the requested id. Cleared when a non-OpenRouter
|
||||
* model is selected. Used purely for UI display — appended after
|
||||
* `openrouter/free:` in the active model chip.
|
||||
* The model id the OpenRouter router picked for the most recent stream when
|
||||
* the active checkpoint is the openrouter/free meta-model. Updated when a
|
||||
* chunk's `model` field differs from the requested id; cleared on a
|
||||
* non-OpenRouter model. UI display only (appended after `openrouter/free:`).
|
||||
*/
|
||||
lastOpenRouterChosenModel: string | null;
|
||||
reasoningStyle: ReasoningStyle;
|
||||
|
|
@ -271,35 +267,29 @@ type ChatRuntimeStore = {
|
|||
preserveThinking: boolean;
|
||||
supportsTools: boolean;
|
||||
/**
|
||||
* Whether the active external provider exposes a server-side
|
||||
* web_search tool (OpenAI's /v1/responses today). Distinct from
|
||||
* `supportsTools` — that flag governs the local tool runtime (Code,
|
||||
* python sandbox, our DuckDuckGo web_search). This one only enables
|
||||
* the chat composer's Search pill for external models. Local models
|
||||
* keep `supportsTools` only.
|
||||
* Whether the active external provider exposes a server-side web_search tool
|
||||
* (OpenAI's /v1/responses today). Distinct from `supportsTools` (the local
|
||||
* tool runtime): this only enables the composer's Search pill for external
|
||||
* models. Local models keep `supportsTools` only.
|
||||
*/
|
||||
supportsBuiltinWebSearch: boolean;
|
||||
/**
|
||||
* Whether the active external provider exposes a server-side
|
||||
* code-execution tool (Anthropic's `code_execution_20250825` on the
|
||||
* Claude 4.x family). Distinct from `supportsTools` for the same
|
||||
* reason as `supportsBuiltinWebSearch`: external providers don't
|
||||
* give us a local tool runtime, but Anthropic dispatches code
|
||||
* execution server-side. Read by both composers' Code pill gate.
|
||||
* Whether the active external provider exposes a server-side code-execution
|
||||
* tool (Anthropic's `code_execution_20250825` on Claude 4.x). Distinct from
|
||||
* `supportsTools` like supportsBuiltinWebSearch: Anthropic dispatches it
|
||||
* server-side. Read by both composers' Code pill gate.
|
||||
*/
|
||||
supportsBuiltinCodeExecution: boolean;
|
||||
/**
|
||||
* Whether the active external provider exposes a server-side
|
||||
* image-generation tool (OpenAI's Responses-API `image_generation`
|
||||
* today). Gates the chat composer's Images pill. Local models never
|
||||
* receive the tool because their runtime cannot dispatch it.
|
||||
* Whether the active external provider exposes a server-side image-generation
|
||||
* tool (OpenAI's Responses-API `image_generation`). Gates the composer's
|
||||
* Images pill. Local models never receive it (their runtime can't dispatch it).
|
||||
*/
|
||||
supportsBuiltinImageGeneration: boolean;
|
||||
/**
|
||||
* Whether the active external provider exposes a server-side
|
||||
* web_fetch tool (Anthropic's `web_fetch_20250910` /
|
||||
* `web_fetch_20260209`). Gates the composer's Fetch pill,
|
||||
* independent of Search.
|
||||
* Whether the active external provider exposes a server-side web_fetch tool
|
||||
* (Anthropic's `web_fetch_20250910` / `web_fetch_20260209`). Gates the
|
||||
* composer's Fetch pill, independent of Search.
|
||||
*/
|
||||
supportsBuiltinWebFetch: boolean;
|
||||
toolsEnabled: boolean;
|
||||
|
|
@ -596,10 +586,9 @@ function setScalarSettingVersion<K extends ScalarSettingKey>(
|
|||
|
||||
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
||||
settingsHydrated: false,
|
||||
// Hydrate the last external checkpoint into params.checkpoint so the
|
||||
// external picker selection survives a page refresh. Local model
|
||||
// checkpoints are re-derived from the backend in useChatModelRuntime
|
||||
// and intentionally NOT persisted here.
|
||||
// Hydrate the last external checkpoint so the external picker survives a
|
||||
// refresh. Local checkpoints are re-derived from the backend in
|
||||
// useChatModelRuntime and intentionally NOT persisted here.
|
||||
params: (() => {
|
||||
const persistedExternal = loadLastExternalCheckpoint();
|
||||
return persistedExternal
|
||||
|
|
@ -699,9 +688,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
return nextState;
|
||||
});
|
||||
} catch {
|
||||
// Hydrate failed: treat as hydrated-with-defaults so future
|
||||
// setParams calls reach saveSettingsPatch (which surfaces its
|
||||
// own toast on real network failure).
|
||||
// Hydrate failed: treat as hydrated-with-defaults so future setParams
|
||||
// calls reach saveSettingsPatch (which toasts on real network failure).
|
||||
warnSettingsPersistenceFailure();
|
||||
set({ settingsHydrated: true });
|
||||
} finally {
|
||||
|
|
@ -715,16 +703,15 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
set({ modelRequiresTrustRemoteCode }),
|
||||
setParams: (params) =>
|
||||
set((state) => {
|
||||
// Bump version unconditionally so a late hydration response
|
||||
// won't clobber a pre-hydrate user edit; only the HTTP write
|
||||
// is gated on settingsHydrated.
|
||||
// Bump version unconditionally so a late hydration response won't clobber
|
||||
// a pre-hydrate user edit; only the HTTP write is gated on settingsHydrated.
|
||||
const changedParams = getChangedInferenceParams(params, state.params);
|
||||
if (state.settingsHydrated && hasKeys(changedParams)) {
|
||||
saveSettingsPatch({ inferenceParams: changedParams });
|
||||
}
|
||||
// Mirror setCheckpoint: the local model load path can mutate
|
||||
// params.checkpoint via setParams() before setCheckpoint runs,
|
||||
// leaving stale per-turn counters under the new checkpoint.
|
||||
// Mirror setCheckpoint: the local load path can mutate params.checkpoint
|
||||
// via setParams() before setCheckpoint runs, leaving stale per-turn
|
||||
// counters under the new checkpoint.
|
||||
const checkpointChanged = state.params.checkpoint !== params.checkpoint;
|
||||
return {
|
||||
params,
|
||||
|
|
@ -787,19 +774,17 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
setModelsError: (modelsError) => set({ modelsError }),
|
||||
setCheckpoint: (modelId, ggufVariant) =>
|
||||
set((state) => {
|
||||
// Persist external selections so they survive a page refresh.
|
||||
// Local model ids are NOT persisted here -- they get re-derived
|
||||
// from the backend's `/api/inference/status.active_model` on
|
||||
// mount, and a stale persisted local id would race against the
|
||||
// freshly-loaded model. See LAST_EXTERNAL_CHECKPOINT_KEY notes.
|
||||
// Persist external selections so they survive a refresh. Local ids are
|
||||
// NOT persisted -- they're re-derived from the backend on mount, and a
|
||||
// stale persisted local id would race the freshly-loaded model. See
|
||||
// LAST_EXTERNAL_CHECKPOINT_KEY notes.
|
||||
saveLastExternalCheckpoint(isExternalModelId(modelId) ? modelId : null);
|
||||
// Clear stale per-turn usage when the model changes; the relaxed
|
||||
// external-provider render gate would otherwise show old counters
|
||||
// until the next completion overwrites them.
|
||||
// Clear stale per-turn usage on model change; the relaxed external-provider
|
||||
// render gate would otherwise show old counters until the next completion.
|
||||
const checkpointChanged = state.params.checkpoint !== modelId;
|
||||
// Clamp maxTokens to the new model's cap on switch into an
|
||||
// external model so a value carried over from a prior local
|
||||
// session does not render above the slider's max.
|
||||
// Clamp maxTokens to the new model's cap when switching into an external
|
||||
// model so a value carried over from a local session doesn't exceed the
|
||||
// slider's max.
|
||||
let nextMaxTokens = state.params.maxTokens;
|
||||
if (checkpointChanged && isExternalModelId(modelId)) {
|
||||
const parsed = parseExternalModelId(modelId);
|
||||
|
|
@ -831,10 +816,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
|
||||
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
|
||||
clearCheckpoint: () => {
|
||||
// Mirror setCheckpoint's persistence behavior: dropping the
|
||||
// checkpoint must also clear any stored external selection so
|
||||
// the next refresh doesn't snap back to a model the user
|
||||
// intentionally cleared.
|
||||
// Mirror setCheckpoint's persistence: dropping the checkpoint must also
|
||||
// clear any stored external selection so the next refresh doesn't snap
|
||||
// back to a model the user intentionally cleared.
|
||||
saveLastExternalCheckpoint(null);
|
||||
return set((state) => ({
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export function buildChatTourSteps({
|
|||
];
|
||||
|
||||
if (canCompare) {
|
||||
// Compare now lives in the + menu, so there is no sidebar button to anchor
|
||||
// to; the view step enters compare on its own and explains it.
|
||||
// Compare lives in the + menu (no sidebar button to anchor to); this step
|
||||
// enters compare on its own and explains it.
|
||||
steps.push({
|
||||
id: "compare-view",
|
||||
target: "chat-compare-view",
|
||||
|
|
|
|||
|
|
@ -37,29 +37,24 @@ export interface ThreadRecord {
|
|||
archived: boolean;
|
||||
createdAt: number;
|
||||
/**
|
||||
* OpenAI shell tool container id captured from a prior response on
|
||||
* this thread. When set, the next turn reuses it via
|
||||
* `environment.type="container_reference"` so the model can read
|
||||
* files it wrote earlier in the conversation. When null/undefined,
|
||||
* the next turn auto-creates a fresh container.
|
||||
* OpenAI shell tool container id from a prior response. When set, the
|
||||
* next turn reuses it via `environment.type="container_reference"` so
|
||||
* the model can read files it wrote earlier; else auto-creates one.
|
||||
*
|
||||
* OpenAI containers expire after ~20 min of inactivity by default;
|
||||
* if a stale id is sent, the backend surfaces an
|
||||
* `_toolEvent.type="container_invalidated"` and the chat-adapter
|
||||
* clears this field so the following turn falls back to auto-create.
|
||||
* Containers expire after ~20 min idle; on a stale id the backend
|
||||
* emits `_toolEvent.type="container_invalidated"` and the chat-adapter
|
||||
* clears this field so the next turn falls back to auto-create.
|
||||
*/
|
||||
openaiCodeExecContainerId?: string | null;
|
||||
/**
|
||||
* Anthropic code_execution container id captured from a prior
|
||||
* response on this thread. When set, the next turn sends a
|
||||
* top-level `container` field on /v1/messages so filesystem state
|
||||
* (files, packages, variables) persists across turns. When
|
||||
* null/undefined, Anthropic auto-creates a fresh container.
|
||||
* Anthropic code_execution container id from a prior response. When
|
||||
* set, the next turn sends a top-level `container` on /v1/messages so
|
||||
* filesystem state (files, packages, variables) persists; else auto-
|
||||
* creates one.
|
||||
*
|
||||
* Anthropic containers expire after ~1 hour by default; on a stale
|
||||
* id the backend surfaces `_toolEvent.type="container_invalidated"`
|
||||
* and the chat-adapter clears this field so the following turn
|
||||
* falls back to auto-create.
|
||||
* Containers expire after ~1 hour; on a stale id the backend emits
|
||||
* `_toolEvent.type="container_invalidated"` and the chat-adapter
|
||||
* clears this field so the next turn falls back to auto-create.
|
||||
*/
|
||||
anthropicCodeExecContainerId?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,18 +44,16 @@ export interface LoadModelRequest {
|
|||
chat_template_override?: string | null;
|
||||
cache_type_kv?: string | null;
|
||||
/**
|
||||
* Speculative decoding mode for GGUF models. Canonical values:
|
||||
* "auto" (platform-aware: MTP on MTP GGUFs, ngram-mod fallback for
|
||||
* sub-3B), "mtp" (force draft-mtp only on both GPU and CPU), "ngram"
|
||||
* (force ngram-mod only), "mtp+ngram" (force ngram-mod + draft-mtp
|
||||
* chain on both platforms), or "off". Legacy values "default" /
|
||||
* "draft-mtp" / "ngram-mod" / "ngram-simple" are still accepted by
|
||||
* the backend.
|
||||
* Speculative decoding mode for GGUF models. Canonical values: "auto"
|
||||
* (platform-aware: MTP on MTP GGUFs, ngram-mod fallback for sub-3B), "mtp"
|
||||
* (force draft-mtp), "ngram" (force ngram-mod), "mtp+ngram" (ngram-mod +
|
||||
* draft-mtp chain), "off". Legacy "default"/"draft-mtp"/"ngram-mod"/
|
||||
* "ngram-simple" are still accepted by the backend.
|
||||
*/
|
||||
speculative_type?: string | null;
|
||||
/**
|
||||
* Override --spec-draft-n-max for MTP speculative decoding. Only
|
||||
* applied when speculative_type resolves to "mtp" or "mtp+ngram".
|
||||
* Override --spec-draft-n-max for MTP speculative decoding. Applied only
|
||||
* when speculative_type resolves to "mtp" or "mtp+ngram".
|
||||
*/
|
||||
spec_draft_n_max?: number | null;
|
||||
}
|
||||
|
|
@ -221,13 +219,11 @@ export type OpenAIMessageContentPart =
|
|||
export type OpenAIMessageContent = string | OpenAIMessageContentPart[];
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions tool_call shape. Assistant turns echo back
|
||||
* function/tool calls as `tool_calls`; the matching tool result rides
|
||||
* on a separate `role="tool"` message keyed by `tool_call_id`.
|
||||
* `extra_content.google.thought_signature` is the Gemini-specific
|
||||
* round-trip field the backend translator both emits (on `delta.
|
||||
* tool_calls`) and consumes (when rebuilding the native functionCall
|
||||
* part on the next turn).
|
||||
* OpenAI Chat Completions tool_call shape. Assistant turns echo function calls
|
||||
* as `tool_calls`; the matching result rides on a separate `role="tool"`
|
||||
* message keyed by `tool_call_id`. `extra_content.google.thought_signature` is
|
||||
* the Gemini round-trip field the backend translator emits (on `delta.
|
||||
* tool_calls`) and consumes (when rebuilding the functionCall part next turn).
|
||||
*/
|
||||
export interface OpenAIToolCallPart {
|
||||
id?: string;
|
||||
|
|
@ -289,36 +285,28 @@ export interface OpenAIChatCompletionsRequest {
|
|||
encrypted_api_key?: string;
|
||||
provider_base_url?: string | null;
|
||||
/**
|
||||
* Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For
|
||||
* Gemini the backend also accepts the cached-content resource name
|
||||
* (`cachedContents/...`) as a string, which is forwarded as
|
||||
* `generationConfig.cachedContent` on the native streamGenerateContent
|
||||
* request.
|
||||
* Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For Gemini the
|
||||
* backend also accepts a cached-content resource name (`cachedContents/...`)
|
||||
* string, forwarded as `generationConfig.cachedContent`.
|
||||
*/
|
||||
enable_prompt_caching?: boolean | string | null;
|
||||
/**
|
||||
* OpenAI shell-tool container id captured from the prior response in
|
||||
* this chat thread. When set and the Code pill is on, the backend
|
||||
* routes the next /v1/responses call with
|
||||
* `environment.type="container_reference"` so filesystem state
|
||||
* persists across turns. Unset → backend uses
|
||||
* `environment.type="container_auto"` and OpenAI creates a fresh
|
||||
* container. Only meaningful for OpenAI cloud + gpt-5.5 family.
|
||||
* OpenAI shell-tool container id from the prior response in this thread. When
|
||||
* set and the Code pill is on, the backend routes the next /v1/responses with
|
||||
* `environment.type="container_reference"` so filesystem state persists; unset
|
||||
* → `container_auto` (fresh container). OpenAI cloud + gpt-5.5 family only.
|
||||
*/
|
||||
openai_code_exec_container_id?: string | null;
|
||||
/**
|
||||
* Anthropic code_execution container id captured from the prior
|
||||
* response in this chat thread. When set and the Code pill is on,
|
||||
* the backend forwards a top-level `container` field on
|
||||
* /v1/messages so filesystem state persists across turns. Unset →
|
||||
* Anthropic auto-creates a fresh container. Only meaningful for
|
||||
* the Anthropic provider with `code_execution` in `enabled_tools`.
|
||||
* Anthropic code_execution container id from the prior response in this
|
||||
* thread. When set and the Code pill is on, the backend forwards a top-level
|
||||
* `container` on /v1/messages so filesystem state persists; unset →
|
||||
* auto-created. Anthropic provider with `code_execution` in `enabled_tools`.
|
||||
*/
|
||||
anthropic_code_exec_container_id?: string | null;
|
||||
/**
|
||||
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; backend drops
|
||||
* silently on every other model + provider. See
|
||||
* https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; dropped silently elsewhere.
|
||||
* See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
*/
|
||||
fast_mode?: boolean | null;
|
||||
}
|
||||
|
|
@ -327,16 +315,15 @@ export interface OpenAIChatDelta {
|
|||
role?: string;
|
||||
content?: string | null;
|
||||
/**
|
||||
* Streamed assistant tool calls. The Gemini and OpenAI Responses
|
||||
* translators emit incremental `tool_calls` deltas (function name +
|
||||
* arguments fragments) so the chat-adapter can render tool cards as
|
||||
* they arrive.
|
||||
* Streamed assistant tool calls. The Gemini and OpenAI Responses translators
|
||||
* emit incremental deltas (function name + arguments fragments) so the
|
||||
* chat-adapter can render tool cards as they arrive.
|
||||
*/
|
||||
tool_calls?: OpenAIToolCallPart[];
|
||||
/**
|
||||
* Provider-specific passthrough. Gemini ships `thoughtSignature`,
|
||||
* citations, `native_part`, etc., here so the round-trip can replay
|
||||
* them on follow-up turns without bleeding into other providers.
|
||||
* Provider-specific passthrough. Gemini ships `thoughtSignature`, citations,
|
||||
* `native_part`, etc., here so the round-trip can replay them on follow-up
|
||||
* turns without bleeding into other providers.
|
||||
*/
|
||||
extra_content?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export interface InferenceParams {
|
|||
/** Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust. */
|
||||
trustRemoteCode?: boolean;
|
||||
/**
|
||||
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; higher OTPS at
|
||||
* 6x standard Opus pricing. Default false.
|
||||
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; higher OTPS at 6x Opus
|
||||
* pricing. Default false.
|
||||
* https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
*/
|
||||
fastMode?: boolean;
|
||||
|
|
|
|||
|
|
@ -43,11 +43,10 @@ type ThreadListArgs = {
|
|||
};
|
||||
|
||||
// localStorage perf-hint that the Dexie -> studio.db import already
|
||||
// finished in a previous session. NOT consulted by the import gate
|
||||
// itself -- the server-side ledger (chat_legacy_imports) is the source
|
||||
// of truth so a studio.db wipe stays recoverable. The hint only short-
|
||||
// circuits the listing paths' "should I also surface Dexie threads?"
|
||||
// branches once the ledger has covered everything.
|
||||
// finished. NOT the import gate -- the server-side ledger
|
||||
// (chat_legacy_imports) is the source of truth so a studio.db wipe stays
|
||||
// recoverable. The hint only short-circuits the listing paths' "also
|
||||
// surface Dexie threads?" branches once the ledger has covered everything.
|
||||
const LEGACY_CHAT_IMPORT_KEY = "unsloth_chat_legacy_imported_to_studio_db";
|
||||
|
||||
let legacyChatImportPromise: Promise<void> | null = null;
|
||||
|
|
@ -265,9 +264,9 @@ async function backfillLegacyThreadFields(
|
|||
}
|
||||
}
|
||||
|
||||
// Fast-path: ask IndexedDB whether the "unsloth-chat" database exists
|
||||
// without opening it. Modern Chromium / Firefox / Safari support this;
|
||||
// older browsers return undefined and we fall through to the next probe.
|
||||
// Fast-path: check whether the "unsloth-chat" DB exists without opening
|
||||
// it. Supported on modern Chromium/Firefox/Safari; older browsers return
|
||||
// undefined and we fall through to the next probe.
|
||||
async function dexieDbAbsent(): Promise<boolean> {
|
||||
if (typeof indexedDB === "undefined") return true;
|
||||
const dbs = (indexedDB as IDBFactory).databases;
|
||||
|
|
@ -291,24 +290,22 @@ async function dexieIsEmpty(): Promise<boolean> {
|
|||
]);
|
||||
return threadCount === 0 && messageCount === 0;
|
||||
} catch {
|
||||
// Dexie threw (corrupted DB / version mismatch / quota). Returning
|
||||
// false forces the slow path, which uses the same Dexie under the
|
||||
// hood; that path will throw too and the import promise gets reset
|
||||
// so the next caller can retry rather than silently doing nothing.
|
||||
// Dexie threw (corrupt DB / version mismatch / quota). Returning
|
||||
// false forces the slow path (same Dexie underneath); it'll throw
|
||||
// too and reset the import promise so the next caller can retry.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importLegacyChatsIfNeeded(): Promise<void> {
|
||||
// Session-level cache: same tab, repeated sidebar mounts share one
|
||||
// import. localStorage is NOT consulted here -- the server-side ledger
|
||||
// is the source of truth so a studio.db wipe still re-triggers the
|
||||
// import even if the browser kept its old hint.
|
||||
// Session-level cache: repeated sidebar mounts in the same tab share
|
||||
// one import. localStorage is NOT consulted -- the server-side ledger
|
||||
// is the source of truth, so a studio.db wipe re-triggers the import
|
||||
// even if the browser kept its old hint.
|
||||
if (legacyChatImportPromise) return legacyChatImportPromise;
|
||||
|
||||
legacyChatImportPromise = (async () => {
|
||||
// Fast-path: no Dexie database at all. New user, never had the
|
||||
// browser-only Studio. ~0.1 ms, zero network.
|
||||
// Fast-path: no Dexie DB -- new user, never had browser-only Studio.
|
||||
if (await dexieDbAbsent()) {
|
||||
markLegacyChatImportDone();
|
||||
return;
|
||||
|
|
@ -336,10 +333,9 @@ async function importLegacyChatsIfNeeded(): Promise<void> {
|
|||
const unimportedIds: string[] = [];
|
||||
const unimportedThreads: ThreadRecord[] = [];
|
||||
|
||||
// "Unimported" = missing from the ledger. We also include threads
|
||||
// already present in the backend (without a ledger row) so the ledger
|
||||
// gets backfilled for old-FE-then-new-FE users -- otherwise the next
|
||||
// launch would redo the diff for the same threads forever.
|
||||
// "Unimported" = missing from the ledger. Include threads already in
|
||||
// the backend (without a ledger row) so the ledger gets backfilled
|
||||
// for old-FE-then-new-FE users; else the next launch re-diffs forever.
|
||||
for (const thread of legacyThreads) {
|
||||
if (isChatThreadDeleted(thread.id)) continue;
|
||||
if (importedThreadIds.has(thread.id)) continue;
|
||||
|
|
@ -406,14 +402,12 @@ async function importLegacyChatsIfNeeded(): Promise<void> {
|
|||
result = await recordChatImportLedger(newlyImportedIds);
|
||||
} catch {
|
||||
// Network error: leave the perf hint alone so the next launch
|
||||
// retries. The import itself is idempotent via UPSERT, no
|
||||
// duplicates.
|
||||
// retries. Import is idempotent via UPSERT, so no duplicates.
|
||||
return;
|
||||
}
|
||||
// Only flip the localStorage hint when the backend actually has the
|
||||
// ledger. On older deployments (404/405/501) the hint would lie:
|
||||
// "import done" while the ledger stays empty, defeating recovery
|
||||
// when studio.db gets wiped later.
|
||||
// Only flip the hint when the backend actually has the ledger. On
|
||||
// older deployments (404/405/501) it would lie ("import done" with an
|
||||
// empty ledger), defeating recovery after a studio.db wipe.
|
||||
if (result.supported) {
|
||||
markLegacyChatImportDone();
|
||||
}
|
||||
|
|
@ -576,8 +570,8 @@ export async function listStoredChatThreadsWithMessages(
|
|||
): Promise<ThreadRecord[]> {
|
||||
const threads = await listStoredChatThreads(args);
|
||||
if (threads.length === 0) return [];
|
||||
// One batched HTTP call instead of N. Per-thread legacy Dexie
|
||||
// fallback only fires when the batch result is empty.
|
||||
// One batched HTTP call instead of N. Per-thread legacy Dexie fallback
|
||||
// only fires when the batch result is empty.
|
||||
const threadIds = threads.map((t) => t.id);
|
||||
let backendByThread: Map<string, MessageRecord[]>;
|
||||
try {
|
||||
|
|
@ -728,8 +722,8 @@ export interface ClearStoredChatsResult {
|
|||
}
|
||||
|
||||
export async function clearStoredChats(): Promise<ClearStoredChatsResult> {
|
||||
// Clear both sides independently and report each outcome so the
|
||||
// toast can distinguish full vs partial success.
|
||||
// Clear both sides independently and report each outcome so the toast
|
||||
// can distinguish full vs partial success.
|
||||
const [backendThreadsResult, legacyThreads] = await Promise.all([
|
||||
listChatThreads()
|
||||
.then((threads) => ({ ok: true as const, threads }))
|
||||
|
|
@ -752,8 +746,8 @@ export async function clearStoredChats(): Promise<ClearStoredChatsResult> {
|
|||
failedThreadIds: [],
|
||||
};
|
||||
try {
|
||||
// Defer the history refresh until Dexie clear and tombstone state are
|
||||
// finalized, so listeners never observe the composite clear mid-flight.
|
||||
// Defer the history refresh until Dexie clear and tombstones finalize,
|
||||
// so listeners never observe the composite clear mid-flight.
|
||||
await clearBackendChats({ notify: false });
|
||||
result.backend = "cleared";
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Tombstones mask deleted threads in the Dexie read fallback. Each
|
||||
* carries a `deletedAt` timestamp so old entries can be GC'd, keeping
|
||||
* localStorage bounded. Reads accept both the legacy plain-string
|
||||
* format and the new {id, deletedAt} tuple form.
|
||||
* Tombstones mask deleted threads in the Dexie read fallback. Each carries a
|
||||
* `deletedAt` so old entries can be GC'd, keeping localStorage bounded. Reads
|
||||
* accept both the legacy plain-string format and the {id, deletedAt} tuple.
|
||||
*/
|
||||
|
||||
interface Tombstone {
|
||||
|
|
@ -62,8 +61,7 @@ function gc(): void {
|
|||
for (const [id, t] of deletedThreads) {
|
||||
if (t.deletedAt < cutoff) deletedThreads.delete(id);
|
||||
}
|
||||
// Cap absolute size: drop oldest if we somehow exceed the limit
|
||||
// (e.g. a script clearing thousands of threads at once).
|
||||
// Cap size: drop oldest if we exceed the limit (e.g. a bulk thread clear).
|
||||
if (deletedThreads.size > TOMBSTONE_MAX_COUNT) {
|
||||
const sorted = Array.from(deletedThreads.entries()).sort(
|
||||
(a, b) => a[1].deletedAt - b[1].deletedAt,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* assistant-ui does not expose a public `deleteMessage` on `ThreadRuntime` / `MessageRuntime`
|
||||
* in our version, but it already implements branch-safe deletion inside `MessageRepository`.
|
||||
* We import that helper from `@assistant-ui/core/internal`, the package's exported internal
|
||||
* surface. Avoid importing the deeper `runtime/utils/message-repository` path directly: newer
|
||||
* `@assistant-ui/core` releases no longer export arbitrary deep paths.
|
||||
* assistant-ui exposes no public `deleteMessage` in our version, but
|
||||
* `MessageRepository` already does branch-safe deletion. We import it from
|
||||
* `@assistant-ui/core/internal` (the exported internal surface); avoid the
|
||||
* deeper `runtime/utils/message-repository` path since newer releases no
|
||||
* longer export arbitrary deep paths.
|
||||
*
|
||||
* **Maintainability:** treat this file as the only place that imports `MessageRepository` from
|
||||
* `@assistant-ui/core`. When bumping `@assistant-ui/react` / `@assistant-ui/core`, re-run chat
|
||||
* delete + reload smoke tests; the path or API may change without a semver signal on “public”
|
||||
* surface area.
|
||||
* Keep this file the only importer of `MessageRepository`. When bumping
|
||||
* `@assistant-ui/react` / `core`, re-run chat delete + reload smoke tests;
|
||||
* the path or API may change without a semver signal.
|
||||
*/
|
||||
import { MessageRepository } from "@assistant-ui/core/internal";
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,9 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Format a byte-per-second rate as a human-readable string.
|
||||
*
|
||||
* 512 → "512 B/s"
|
||||
* 1_234_567 → "1.2 MB/s"
|
||||
* 1_234_567_890 → "1.15 GB/s"
|
||||
*
|
||||
* Returns `"--"` for non-finite or non-positive inputs so callers can
|
||||
* render the label safely before the first stable sample arrives.
|
||||
* Format a byte-per-second rate (e.g. 1_234_567 → "1.2 MB/s"). Returns `"--"`
|
||||
* for non-finite or non-positive inputs so the label renders safely before the
|
||||
* first stable sample.
|
||||
*/
|
||||
export function formatRate(bytesPerSecond: number): string {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "--";
|
||||
|
|
@ -21,13 +16,8 @@ export function formatRate(bytesPerSecond: number): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Format an ETA (in seconds) as a short human-readable string.
|
||||
*
|
||||
* 47 → "47s"
|
||||
* 125 → "2m 5s"
|
||||
* 3725 → "1h 2m"
|
||||
*
|
||||
* Returns `"--"` for non-finite or non-positive inputs.
|
||||
* Format an ETA in seconds as a short string (e.g. 125 → "2m 5s", 3725 →
|
||||
* "1h 2m"). Returns `"--"` for non-finite or non-positive inputs.
|
||||
*/
|
||||
export function formatEta(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "--";
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export function getImageInputUnavailableReason({
|
|||
activeModel?: ChatModelSummary;
|
||||
isExternalModel: boolean;
|
||||
// true/false = caller knows; null/undefined = unknown (default-allow).
|
||||
// External selections aren't in runtime.models[], so callers should
|
||||
// resolve provider-type capability and pass it here.
|
||||
// External selections aren't in runtime.models[], so callers resolve
|
||||
// provider-type capability and pass it here.
|
||||
externalSupportsVision?: boolean | null;
|
||||
// Fallback toast label when activeModel is missing.
|
||||
externalModelLabel?: string | null;
|
||||
|
|
@ -40,11 +40,10 @@ export function getImageInputUnavailableReason({
|
|||
return null;
|
||||
}
|
||||
if (!modelLoaded) return "Load a model before adding images.";
|
||||
// loadedIsMultimodal is true for vision OR audio. Can't tell them apart
|
||||
// from that one flag, so only block when activeModel confirms
|
||||
// audio-only: audio capability set AND isVision === false. Otherwise
|
||||
// trust the load response. The models-list entry might be stale, or
|
||||
// not even there yet (gets auto-injected after load).
|
||||
// loadedIsMultimodal is true for vision OR audio; that one flag can't tell
|
||||
// them apart, so only block when activeModel confirms audio-only (audio
|
||||
// capability set AND isVision === false). Otherwise trust the load
|
||||
// response: the models-list entry may be stale or not yet injected.
|
||||
if (loadedIsMultimodal) {
|
||||
const isAudioOnly =
|
||||
Boolean(activeModel?.isAudio || activeModel?.hasAudioInput) &&
|
||||
|
|
|
|||
|
|
@ -8,11 +8,9 @@ type ContentPart = NonNullable<ChatModelRunResult["content"]>[number];
|
|||
const THINK_OPEN_TAG = "<think>";
|
||||
const THINK_CLOSE_TAG = "</think>";
|
||||
|
||||
// ContentPart from @assistant-ui/react has readonly fields, so we cannot
|
||||
// do `last.text += text` to coalesce adjacent same-type parts — tsc fails
|
||||
// with TS2540 "Cannot assign to 'text' because it is a read-only property".
|
||||
// Instead, replace the last element with a fresh merged object: same
|
||||
// allocation cost as the mutation path but type-safe.
|
||||
// ContentPart from @assistant-ui/react has readonly fields, so coalescing via
|
||||
// `last.text += text` fails (TS2540). Instead replace the last element with a
|
||||
// fresh merged object: same allocation cost as mutation but type-safe.
|
||||
|
||||
function appendTextPart(parts: ContentPart[], text: string): void {
|
||||
if (!text) return;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@
|
|||
/**
|
||||
* Pure, framework-free math behind {@link useTransferStats}.
|
||||
*
|
||||
* Split out so it can be unit-tested without a DOM/React renderer, and
|
||||
* so the training-start overlay, the chat download toast, and the
|
||||
* model-load phase UI all share the exact same rate/ETA semantics.
|
||||
*
|
||||
* No React, no `useRef`/`useState`, no timers -- the caller owns the
|
||||
* sample buffer and clock. This is the "what is the rate right now
|
||||
* given these samples" question, nothing more.
|
||||
* Split out for unit-testing without React, and so the training-start overlay,
|
||||
* chat download toast, and model-load UI share identical rate/ETA semantics.
|
||||
* No React/timers -- the caller owns the sample buffer and clock.
|
||||
*/
|
||||
|
||||
export type TransferSample = { t: number; b: number };
|
||||
|
|
@ -19,10 +15,9 @@ export type TransferStats = {
|
|||
rateBytesPerSecond: number;
|
||||
etaSeconds: number;
|
||||
/**
|
||||
* False until the window has at least {@link MIN_SAMPLES} samples
|
||||
* spanning ≥ {@link MIN_WINDOW_SECONDS} with strictly forward progress.
|
||||
* Consumers should hide rate/ETA while this is false so the UI doesn't
|
||||
* flicker "123 GB/s" during the first tick.
|
||||
* False until the window has ≥ {@link MIN_SAMPLES} samples spanning ≥
|
||||
* {@link MIN_WINDOW_SECONDS} with forward progress. Hide rate/ETA while false
|
||||
* so the UI doesn't flicker "123 GB/s" during the first tick.
|
||||
*/
|
||||
stable: boolean;
|
||||
};
|
||||
|
|
@ -32,11 +27,9 @@ export const MIN_WINDOW_SECONDS = 3;
|
|||
export const MAX_WINDOW_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Mutate ``samples`` in place: append the new sample, drop any that
|
||||
* fell out of the rolling window, and clear the buffer if the counter
|
||||
* went backwards (user cancelled + restarted a download, etc.).
|
||||
*
|
||||
* Returns the same array for chain-ability.
|
||||
* Mutate ``samples`` in place: append the sample, drop any out of the rolling
|
||||
* window, and clear the buffer if the counter went backwards (cancel + restart).
|
||||
* Returns the same array for chaining.
|
||||
*/
|
||||
export function appendSample(
|
||||
samples: TransferSample[],
|
||||
|
|
@ -56,13 +49,11 @@ export function appendSample(
|
|||
}
|
||||
|
||||
/**
|
||||
* Derive {@link TransferStats} from a window of cumulative-byte samples
|
||||
* plus the known total.
|
||||
*
|
||||
* Derive {@link TransferStats} from a window of cumulative-byte samples plus the
|
||||
* known total.
|
||||
* * Needs ≥ {@link MIN_SAMPLES} samples spanning ≥ {@link MIN_WINDOW_SECONDS}
|
||||
* seconds before it will report ``stable: true``.
|
||||
* * ETA is clamped to 0 when: no progress, no total known, or the
|
||||
* counter already hit the total.
|
||||
* seconds before reporting ``stable: true``.
|
||||
* * ETA clamps to 0 when there's no progress, no total, or total is hit.
|
||||
*/
|
||||
export function computeTransferStats(
|
||||
samples: readonly TransferSample[],
|
||||
|
|
|
|||
|
|
@ -37,10 +37,8 @@ export interface ExportOperationResponse {
|
|||
success: boolean;
|
||||
message: string;
|
||||
/**
|
||||
* Optional extras returned by the backend. The export endpoints set
|
||||
* `details.output_path` to the resolved on-disk directory of the
|
||||
* saved model when a local save was requested. Hub-only pushes leave
|
||||
* `details` undefined.
|
||||
* Optional backend extras. Local saves set `details.output_path` to the
|
||||
* saved model's on-disk directory; hub-only pushes leave it undefined.
|
||||
*/
|
||||
details?: { output_path?: string | null } & Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ import { streamExportLogs, type ExportLogEntry } from "../api/export-api";
|
|||
import { collapseAnim } from "../anim";
|
||||
import { EXPORT_METHODS, type ExportMethod } from "../constants";
|
||||
|
||||
// Max log lines kept in the dialog's local state. Matches the backend
|
||||
// ring buffer's maxlen so the UI shows the full scrollback captured
|
||||
// server side.
|
||||
// Max log lines kept in local state. Matches the backend ring buffer's maxlen
|
||||
// so the UI shows the full server-side scrollback.
|
||||
const MAX_LOG_LINES = 4000;
|
||||
|
||||
interface UseExportLogsResult {
|
||||
|
|
@ -38,14 +37,10 @@ interface UseExportLogsResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the live export log SSE stream while `exporting` is
|
||||
* true, and accumulate lines in local state. Lines from a previous
|
||||
* action are cleared:
|
||||
*
|
||||
* - when a new export starts (`exporting` flips to true), and
|
||||
* - when the user switches export method, dialog opens fresh, or
|
||||
* the dialog closes — so re-opening into a different action's
|
||||
* screen doesn't show the prior screen's saved output.
|
||||
* Subscribe to the live export log SSE stream while `exporting` is true and
|
||||
* accumulate lines. Prior-action lines are cleared when a new export starts and
|
||||
* when the user switches method / reopens / closes the dialog, so reopening
|
||||
* into a different action doesn't show stale output.
|
||||
*/
|
||||
function useExportLogs(
|
||||
exporting: boolean,
|
||||
|
|
@ -56,10 +51,9 @@ function useExportLogs(
|
|||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset log state whenever the user moves to a different screen --
|
||||
// either by switching export method or by reopening the dialog -- so
|
||||
// each (open × method) tuple shows only its own run history. The
|
||||
// streaming effect below additionally clears on new export start.
|
||||
// Reset log state on screen change (method switch or dialog reopen) so each
|
||||
// (open × method) shows only its own run history. The streaming effect below
|
||||
// additionally clears on new export start.
|
||||
useEffect(() => {
|
||||
setLines([]);
|
||||
setError(null);
|
||||
|
|
@ -74,17 +68,15 @@ function useExportLogs(
|
|||
|
||||
const abortCtrl = new AbortController();
|
||||
let cancelled = false;
|
||||
// Track the highest seq we've observed on a `log` event so we can
|
||||
// resume the stream via `since=` / `Last-Event-ID` after a drop.
|
||||
// The backend's SSE `id:` field carries this as ExportLogEvent.id.
|
||||
// Highest seq seen on a `log` event, to resume via `since=` / Last-Event-ID
|
||||
// after a drop. The backend's SSE `id:` field carries this as ExportLogEvent.id.
|
||||
let lastSeq: number | null = null;
|
||||
// Exponential backoff with jitter, capped. Reset on every
|
||||
// successful connection so flaky networks don't accumulate delay.
|
||||
// Capped exponential backoff with jitter; reset on each successful connect
|
||||
// so flaky networks don't accumulate delay.
|
||||
let backoffMs = 500;
|
||||
const MAX_BACKOFF_MS = 5000;
|
||||
// Flipped by a terminal event (explicit `complete` from the
|
||||
// backend or a non-transient error we choose not to retry). Stops
|
||||
// the outer reconnect loop even if `exporting` is still true.
|
||||
// Set by a terminal event (backend `complete` or a non-retryable error) to
|
||||
// stop the reconnect loop even while `exporting` is still true.
|
||||
let terminated = false;
|
||||
|
||||
const run = async () => {
|
||||
|
|
@ -115,9 +107,8 @@ function useExportLogs(
|
|||
return next;
|
||||
});
|
||||
} else if (event.event === "complete") {
|
||||
// Backend signalled the run is fully drained -- stop
|
||||
// trying to reconnect even though `exporting` may not
|
||||
// have flipped false yet on this tick.
|
||||
// Run fully drained -- stop reconnecting even if `exporting`
|
||||
// hasn't flipped false yet on this tick.
|
||||
terminated = true;
|
||||
} else if (event.event === "error" && event.error) {
|
||||
setError(event.error);
|
||||
|
|
@ -128,17 +119,15 @@ function useExportLogs(
|
|||
if (cancelled) return;
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// Fall through to the backoff path below; a fetch-level
|
||||
// failure is retryable the same way a clean EOF is.
|
||||
// Fall through to backoff; a fetch-level failure retries like a clean EOF.
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
if (cancelled || terminated) return;
|
||||
|
||||
// Exponential backoff with jitter before reconnecting. The
|
||||
// backend's ring buffer plus Last-Event-ID resume means we
|
||||
// don't lose lines across the retry as long as the reconnect
|
||||
// happens within the buffer's lifetime (~4000 lines).
|
||||
// Exponential backoff with jitter before reconnecting. Ring buffer +
|
||||
// Last-Event-ID resume means no lines lost across the retry as long as
|
||||
// the reconnect lands within the buffer's lifetime (~4000 lines).
|
||||
const delay = backoffMs + Math.floor(Math.random() * 250);
|
||||
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
||||
try {
|
||||
|
|
@ -163,9 +152,8 @@ function useExportLogs(
|
|||
}
|
||||
};
|
||||
|
||||
// run()'s own try/catch handles every failure path we care about;
|
||||
// swallow anything that somehow escapes so React's dev overlay
|
||||
// doesn't flag an unhandled rejection on dialog close.
|
||||
// run() handles every failure path; swallow stragglers so React's dev
|
||||
// overlay doesn't flag an unhandled rejection on dialog close.
|
||||
void run().catch(() => {});
|
||||
|
||||
return () => {
|
||||
|
|
@ -179,10 +167,9 @@ function useExportLogs(
|
|||
}
|
||||
|
||||
/**
|
||||
* Tick every second while `exporting` is true and report elapsed
|
||||
* seconds. Powers the "Working… 27s" badge in the log header so the
|
||||
* panel doesn't look frozen during long single-step phases (cache
|
||||
* file copy, GGUF conversion) when no new lines are arriving.
|
||||
* Tick every second while `exporting` is true and report elapsed seconds.
|
||||
* Powers the "Working… 27s" badge so the panel doesn't look frozen during long
|
||||
* single-step phases (cache copy, GGUF conversion) with no new lines.
|
||||
*/
|
||||
function useElapsedSeconds(exporting: boolean): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
|
@ -209,8 +196,7 @@ function formatElapsed(seconds: number): string {
|
|||
}
|
||||
|
||||
function formatLogLine(entry: ExportLogEntry): string {
|
||||
// Strip trailing carriage returns that tqdm-style progress leaves
|
||||
// in the stream so the scrollback doesn't render funky boxes.
|
||||
// Strip trailing CRs from tqdm-style progress so scrollback doesn't render boxes.
|
||||
return entry.line.replace(/\r+$/g, "");
|
||||
}
|
||||
|
||||
|
|
@ -240,9 +226,8 @@ interface ExportDialogProps {
|
|||
exportError: string | null;
|
||||
exportSuccess: boolean;
|
||||
/**
|
||||
* Resolved on-disk realpath of the most recent successful export.
|
||||
* Surfaced on the Export Complete screen so users can find their
|
||||
* model. Null when the export only pushed to the Hub.
|
||||
* Resolved on-disk realpath of the latest successful export, shown on the
|
||||
* Export Complete screen. Null when the export only pushed to the Hub.
|
||||
*/
|
||||
exportOutputPath: string | null;
|
||||
}
|
||||
|
|
@ -272,8 +257,7 @@ export function ExportDialog({
|
|||
exportSuccess,
|
||||
exportOutputPath,
|
||||
}: ExportDialogProps) {
|
||||
// Live log capture is useful for any export path executed by the
|
||||
// backend worker, including LoRA adapter-only export.
|
||||
// Live log capture applies to any backend-worker export path, incl. LoRA-only.
|
||||
const showLogPanel =
|
||||
exportMethod === "merged" ||
|
||||
exportMethod === "gguf" ||
|
||||
|
|
@ -285,8 +269,7 @@ export function ExportDialog({
|
|||
const elapsedSeconds = useElapsedSeconds(exporting && showLogPanel);
|
||||
|
||||
const logScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
// Auto-scroll to bottom whenever a new line arrives, unless the
|
||||
// user has scrolled up to read earlier output.
|
||||
// Auto-scroll to bottom on each new line, unless the user scrolled up.
|
||||
const [followTail, setFollowTail] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -461,9 +444,8 @@ export function ExportDialog({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Success banner for log-driven exports.
|
||||
Keep users on the log screen after completion so they can
|
||||
inspect conversion output before closing. */}
|
||||
{/* Success banner: keep users on the log screen after completion so
|
||||
they can inspect conversion output before closing. */}
|
||||
{exportSuccess && showLogPanel && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="mt-0.5 size-4 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -121,9 +121,8 @@ export function ExportPage() {
|
|||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
// Resolved on-disk path of the most recent successful export, surfaced
|
||||
// on the Export Complete screen so the user can find their model
|
||||
// without digging through the server log. Null for Hub-only pushes.
|
||||
// On-disk path of the last successful export, shown on the Export Complete
|
||||
// screen so the user can find their model. Null for Hub-only pushes.
|
||||
const [exportOutputPath, setExportOutputPath] = useState<string | null>(null);
|
||||
|
||||
const hfComboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -438,11 +437,9 @@ export function ExportPage() {
|
|||
});
|
||||
}
|
||||
|
||||
// 2. Run export based on method. Capture the resolved output_path
|
||||
// (when the backend wrote a local copy) so the success screen can
|
||||
// show the user the realpath of their saved model. For multi-quant
|
||||
// GGUF runs, the directory is the same for every quant so we just
|
||||
// keep the last response.
|
||||
// 2. Run export. Capture the resolved output_path (when the backend
|
||||
// wrote a local copy) for the success screen. Multi-quant GGUF runs
|
||||
// share one directory, so we just keep the last response.
|
||||
let lastOutputPath: string | null = null;
|
||||
if (exportMethod === "merged") {
|
||||
if (isAdapter) {
|
||||
|
|
|
|||
|
|
@ -39,10 +39,7 @@ function formatLR(value: number): string {
|
|||
return `${rounded}e${exp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step learning rate up in a scientific-notation-friendly sequence:
|
||||
* 1e-4 -> 2e-4 -> 3e-4 -> ... -> 9e-4 -> 1e-3 -> 2e-3 -> ...
|
||||
*/
|
||||
/** Step the LR in a scientific sequence: 1e-4 -> 2e-4 -> ... -> 9e-4 -> 1e-3 -> ... */
|
||||
function stepLR(value: number, direction: 1 | -1): number {
|
||||
if (value <= 0) return 1e-5;
|
||||
const exp = Math.floor(Math.log10(value) + 1e-9);
|
||||
|
|
@ -116,8 +113,7 @@ export function HyperparametersStep() {
|
|||
const maxStepsSliderMax = Math.max(500, maxSteps, 30);
|
||||
const epochsSliderMax = Math.max(10, epochs, 1);
|
||||
|
||||
// Use model's max_position_embeddings to cap context length options.
|
||||
// Fall back to 65536 (64K) if not available.
|
||||
// Cap context length by model's max_position_embeddings; fall back to 64K.
|
||||
const maxCtx = maxPositionEmbeddings ?? 65536;
|
||||
const contextLengthOptions = useMemo(
|
||||
() => CONTEXT_LENGTHS.filter((len) => len <= maxCtx),
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export function ModelSelectionStep() {
|
|||
return applyPriorityOrdering(ids);
|
||||
}, [hfResults]);
|
||||
|
||||
// Match Studio behavior: only show exception signals (OOM/TIGHT) in training flows.
|
||||
// Match Studio: only show exception signals (OOM/TIGHT) in training flows.
|
||||
const vramMap = useMemo(() => {
|
||||
const fitMap = buildModelVramMap(
|
||||
hfResults,
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export function ModelTypeStep(): ReactElement {
|
|||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
{/* Invisible spacer matching RadioGroupItem (size-4 flex) in other cards */}
|
||||
{/* Invisible spacer matching RadioGroupItem (size-4) in other cards */}
|
||||
<div className="size-4 shrink-0" aria-hidden="true" />
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Read the JWT `sub` claim for display purposes only (not verified).
|
||||
*/
|
||||
/** Read the JWT `sub` claim for display only (not verified). */
|
||||
export function decodeJwtSubject(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -50,9 +50,7 @@ function encodeCanvasWithinLimit(
|
|||
return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale the image and keep transparency when present while staying localStorage-friendly.
|
||||
*/
|
||||
/** Downscale the image, preserving transparency, to stay localStorage-friendly. */
|
||||
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
|
||||
const img = await loadImage(file);
|
||||
const w = img.naturalWidth;
|
||||
|
|
|
|||
|
|
@ -108,11 +108,9 @@ export function ExecutionsView({
|
|||
[datasetColumnNames, hiddenDatasetColumns],
|
||||
);
|
||||
|
||||
// Columns where at least one row has text long enough that it would wrap at
|
||||
// the default narrow width. We give those columns a wider min-width so the
|
||||
// text is readable without clicking anything. The table's wrapper already
|
||||
// scrolls horizontally, so a few wide columns just add a horizontal
|
||||
// scrollbar instead of squeezing everything into the viewport.
|
||||
// Columns with at least one long-text row get a wider min-width so the text
|
||||
// is readable without clicking. The wrapper scrolls horizontally, so wide
|
||||
// columns just add a scrollbar instead of squeezing the viewport.
|
||||
const wideColumns = useMemo(() => {
|
||||
const result = new Set<string>();
|
||||
if (!selectedExecution) {
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ export function InlineModel(props: InlineModelProps): ReactElement {
|
|||
);
|
||||
}
|
||||
|
||||
// model_config branch - mirror the local-aware provider sync from the
|
||||
// dialog path so inline edits clear stale local-only metadata without
|
||||
// synthesizing the legacy "local" placeholder.
|
||||
// model_config branch: mirror the dialog path's local-aware provider sync so
|
||||
// inline edits clear stale local-only metadata without synthesizing the
|
||||
// legacy "local" placeholder.
|
||||
const localNames = props.localProviderNames ?? new Set<string>();
|
||||
const modelConfig = props.config;
|
||||
const isLinkedToLocal = localNames.has(modelConfig.provider);
|
||||
|
|
|
|||
|
|
@ -556,8 +556,8 @@ export function LocalRecipeModelSelector({
|
|||
return;
|
||||
}
|
||||
} catch {
|
||||
// Non-GGUF local models commonly have no variant endpoint. Fall
|
||||
// through to regular selection so users can still choose them.
|
||||
// Non-GGUF local models commonly have no variant endpoint;
|
||||
// fall through to regular selection so they stay choosable.
|
||||
} finally {
|
||||
setProbingVariantModelId(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,8 @@ export function ModelConfigDialog({
|
|||
const skipHealthCheckId = `${config.id}-skip-health-check`;
|
||||
const providerAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const providerInputRef = useRef(config.provider);
|
||||
// Sync providerInputRef with the current provider value. Updating a ref in
|
||||
// an effect (vs reading/writing it during render) satisfies the
|
||||
// react-hooks/refs rule and keeps the combobox blur path stable across
|
||||
// re-renders.
|
||||
// Sync providerInputRef in an effect (not during render) to satisfy the
|
||||
// react-hooks/refs rule and keep the combobox blur path stable.
|
||||
useEffect(() => {
|
||||
providerInputRef.current = config.provider;
|
||||
}, [config.provider]);
|
||||
|
|
|
|||
|
|
@ -563,10 +563,9 @@ export function useRecipeExecutions({
|
|||
|
||||
resetForRecipe();
|
||||
|
||||
// Seed previewRows from the recipe's original run.rows (read from the
|
||||
// loaded JSON, not the rebuilt payload (the builder hardcodes 5).
|
||||
// Templates ship their own suggested preview size (e.g. GitHub Support
|
||||
// Bot: 10); we honor it so users don't see a surprise 5.
|
||||
// Seed previewRows from the recipe's original run.rows (the loaded JSON, not
|
||||
// the rebuilt payload, which hardcodes 5). Templates ship a suggested preview
|
||||
// size (e.g. GitHub Support Bot: 10); honor it so users don't see a surprise 5.
|
||||
if (
|
||||
typeof initialRunRows === "number" &&
|
||||
Number.isFinite(initialRunRows) &&
|
||||
|
|
@ -794,8 +793,8 @@ export function useRecipeExecutions({
|
|||
}
|
||||
|
||||
// Flip to the Runs pane before validation starts. Validation can re-crawl
|
||||
// the seed (multiple seconds for the github_repo reader), and runExecution()
|
||||
// later no-ops this callback if the view has already been flipped.
|
||||
// the seed (seconds for the github_repo reader); runExecution() later no-ops
|
||||
// this callback if the view has already been flipped.
|
||||
onExecutionStart?.();
|
||||
|
||||
const normalizedRows = sanitizeExecutionRows(rows, kind);
|
||||
|
|
@ -811,11 +810,11 @@ export function useRecipeExecutions({
|
|||
return false;
|
||||
}
|
||||
|
||||
// Recipe and Chat share one singleton local inference backend. This
|
||||
// direct load is a point-in-time handoff to job creation, not a lease:
|
||||
// if Chat swaps models after this succeeds, the backend will reject or
|
||||
// run against the active backend state. A future generation token should
|
||||
// be validated across this load and the `/jobs` loaded-model gate.
|
||||
// Recipe and Chat share one singleton local inference backend. This direct
|
||||
// load is a point-in-time handoff to job creation, not a lease: if Chat
|
||||
// swaps models after this succeeds, the backend runs against current state.
|
||||
// A future generation token should be validated across this load and the
|
||||
// `/jobs` loaded-model gate.
|
||||
const restorePrevious = await prepareLocalModelForExecution(payload);
|
||||
if (restorePrevious === false) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -78,16 +78,15 @@ const EDGE_TYPES: EdgeTypes = {
|
|||
const COMPLETE_ISLAND_VISIBLE_MS = 7_000;
|
||||
const TAB_SWITCH_FIT_DELAY_MS = 110;
|
||||
/**
|
||||
* Maximum RAF iterations to wait for React Flow's ResizeObserver to populate
|
||||
* `node.measured` dimensions before calling fitView. ~20 frames ≈ 333 ms at
|
||||
* 60 fps — more than enough for the render → layout → ResizeObserver cycle.
|
||||
* Max RAF iterations to wait for React Flow's ResizeObserver to populate
|
||||
* `node.measured` before calling fitView. ~20 frames ≈ 333 ms at 60 fps,
|
||||
* ample for the render → layout → ResizeObserver cycle.
|
||||
*/
|
||||
const MAX_FIT_VIEW_RETRIES = 20;
|
||||
/**
|
||||
* After all target nodes appear measured, wait this many extra stable frames
|
||||
* before firing fitView. This absorbs `updateNodeInternals` calls from
|
||||
* InternalsSync and individual node mount effects that can transiently reset
|
||||
* measurements.
|
||||
* Extra stable frames to wait after target nodes appear measured before
|
||||
* firing fitView, absorbing `updateNodeInternals` calls from InternalsSync
|
||||
* and node mount effects that can transiently reset measurements.
|
||||
*/
|
||||
const FIT_VIEW_STABLE_FRAMES = 3;
|
||||
|
||||
|
|
@ -227,10 +226,10 @@ export function RecipeStudioPage({
|
|||
},
|
||||
[viewModeStorageKey],
|
||||
);
|
||||
// Easy mode has no canvas overlay/progress island, so once a run starts the
|
||||
// user sees the Run button stuck on "Running..." with nothing else changing.
|
||||
// Flip to the Runs pane so they land where progress is actually rendered.
|
||||
// Advanced (editor) keeps its island and stays put.
|
||||
// Easy mode has no canvas overlay/progress island, so a started run would
|
||||
// leave the Run button stuck on "Running..." with nothing else changing.
|
||||
// Flip to the Runs pane where progress is rendered. Advanced (editor) keeps
|
||||
// its island and stays put.
|
||||
const handleExecutionStart = useCallback(() => {
|
||||
setActiveView((currentView) =>
|
||||
currentView === "easy" ? "executions" : currentView,
|
||||
|
|
@ -396,10 +395,9 @@ export function RecipeStudioPage({
|
|||
const runBusy = previewLoading || fullLoading || executionLocked;
|
||||
const islandExecution = activeExecution ?? recentCompletedExecution;
|
||||
|
||||
// Easy mode runs a full run (artifact persisted, tracked in Runs pane)
|
||||
// using runFull. runFull requires a non-empty fullRunName but the Easy form
|
||||
// has no run-name input, so seed a default here as soon as Easy is active.
|
||||
// User can still rename it from Advanced/Runs dialogs before clicking Run.
|
||||
// Easy mode uses runFull (artifact persisted, tracked in Runs pane), which
|
||||
// requires a non-empty fullRunName. The Easy form has no run-name input, so
|
||||
// seed a default once Easy is active; user can rename from Advanced/Runs.
|
||||
useEffect(() => {
|
||||
if (!supportsEasyMode) return;
|
||||
if (activeView !== "easy") return;
|
||||
|
|
@ -527,22 +525,21 @@ export function RecipeStudioPage({
|
|||
return;
|
||||
}
|
||||
if (retries >= MAX_FIT_VIEW_RETRIES) {
|
||||
// Timed out waiting — fit with whatever we have (graceful fallback).
|
||||
// Timed out: fit with whatever we have (graceful fallback).
|
||||
doFit();
|
||||
return;
|
||||
}
|
||||
const targets = getFitViewTargetNodes(reactFlowInstance.getNodes());
|
||||
if (allTargetsMeasured(targets)) {
|
||||
stableCount++;
|
||||
// Wait a few extra frames after measurements appear to let
|
||||
// updateNodeInternals (InternalsSync, node mount effects) settle.
|
||||
// Extra frames after measurements appear let updateNodeInternals
|
||||
// (InternalsSync, node mount effects) settle.
|
||||
if (stableCount >= FIT_VIEW_STABLE_FRAMES) {
|
||||
doFit();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Measurements were reset (e.g. by updateNodeInternals) — restart
|
||||
// the stability counter.
|
||||
// Measurements reset (e.g. by updateNodeInternals): restart counter.
|
||||
stableCount = 0;
|
||||
}
|
||||
retries++;
|
||||
|
|
@ -808,10 +805,9 @@ export function RecipeStudioPage({
|
|||
updateConfig={updateConfig}
|
||||
onRun={() => {
|
||||
// Easy mode is a full run (artifact persisted, tracked in
|
||||
// the Runs pane) capped at the user's row count. runFull
|
||||
// requires a non-empty fullRunName; the effect below
|
||||
// populates one on mount so the closure in runFull is
|
||||
// already up to date by the time the user clicks Run.
|
||||
// Runs) capped at the user's row count. runFull requires a
|
||||
// non-empty fullRunName; the effect above populates one on
|
||||
// mount so runFull's closure is current by click time.
|
||||
void runFull();
|
||||
}}
|
||||
runLoading={fullLoading || executionLocked}
|
||||
|
|
|
|||
|
|
@ -756,10 +756,9 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
configs = applyRenameToConfigs(configs, oldName, newName);
|
||||
}
|
||||
|
||||
// When a provider toggles between local and external, keep already
|
||||
// linked model_config nodes in sync. applyRenameToConfigs above has
|
||||
// already propagated any name change, so providerName here is the
|
||||
// post-rename value.
|
||||
// When a provider toggles local/external, keep linked model_config nodes
|
||||
// in sync. applyRenameToConfigs above already propagated any name change,
|
||||
// so providerName here is the post-rename value.
|
||||
if (current.kind === "model_provider" && next.kind === "model_provider") {
|
||||
const prevIsLocal = current.is_local === true;
|
||||
const nextIsLocal = next.is_local === true;
|
||||
|
|
|
|||
|
|
@ -27,12 +27,9 @@ function isAuxNode(node: Node): boolean {
|
|||
/**
|
||||
* Returns the primary workflow nodes that fitView should target.
|
||||
*
|
||||
* Excludes markdown notes and aux (LLM input overlay) nodes so the viewport
|
||||
* is framed around the primary workflow blocks. Falls back to all nodes if
|
||||
* filtering would leave an empty set.
|
||||
*
|
||||
* The returned array contains full {@link Node} objects so callers can inspect
|
||||
* `node.measured` without a second lookup pass.
|
||||
* Excludes markdown notes and aux (LLM input overlay) nodes; falls back to
|
||||
* all nodes if filtering would leave an empty set. Returns full {@link Node}
|
||||
* objects so callers can inspect `node.measured` without a second lookup.
|
||||
*/
|
||||
export function getFitViewTargetNodes(nodes: Node[]): Node[] {
|
||||
const primary = nodes.filter(
|
||||
|
|
@ -42,9 +39,9 @@ export function getFitViewTargetNodes(nodes: Node[]): Node[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Builds a standard {@link FitViewOptions} object targeting the primary
|
||||
* workflow nodes. Every call site that invokes `fitView` should go through
|
||||
* this helper so zoom, padding, and node filtering stay consistent.
|
||||
* Builds a standard {@link FitViewOptions} targeting the primary workflow
|
||||
* nodes. All `fitView` call sites go through this so zoom, padding, and
|
||||
* node filtering stay consistent.
|
||||
*/
|
||||
export function buildFitViewOptions(
|
||||
nodes: Node[],
|
||||
|
|
|
|||
|
|
@ -387,10 +387,10 @@ export function applyRecipeConnection(
|
|||
nextBaseEdges,
|
||||
);
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
// Keep model_config.provider in sync when a graph drag changes the link.
|
||||
// Local providers now require an explicit selected load id; do not synthesize
|
||||
// the legacy "local" placeholder. External relinks clear local-only GGUF
|
||||
// metadata, while legacy placeholders are normalized back to empty.
|
||||
// Keep model_config.provider in sync when a drag changes the link.
|
||||
// Local providers need an explicit load id; don't synthesize the legacy
|
||||
// "local" placeholder. External relinks clear local-only GGUF metadata;
|
||||
// legacy placeholders normalize back to empty.
|
||||
const isSourceLocal = source.is_local === true;
|
||||
const isLegacyLocalPlaceholder =
|
||||
target.model.trim().toLowerCase() === "local";
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@ type LayoutOptions = {
|
|||
configs?: Record<string, NodeConfig>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pipeline rank order used to enforce a logical flow even for disconnected nodes.
|
||||
* Lower rank = earlier in the pipeline.
|
||||
*/
|
||||
/** Pipeline rank for logical flow; lower = earlier. */
|
||||
function getPipelineRank(config: NodeConfig | undefined): number {
|
||||
if (!config) {
|
||||
return 2;
|
||||
|
|
@ -65,17 +62,15 @@ function getEdgeWeight(edgeType: string | undefined): number {
|
|||
|
||||
/**
|
||||
* Build phantom edges between disconnected data-pipeline nodes so dagre
|
||||
* respects the pipeline rank order even when blocks aren't wired together.
|
||||
*
|
||||
* Groups nodes by rank, then inserts invisible edges from the last node of
|
||||
* rank N to the first node of rank N+1 when no real edge already connects them.
|
||||
* respects pipeline rank order even when blocks aren't wired together.
|
||||
* Groups by rank, then inserts invisible edges from the last node of rank N
|
||||
* to the first of rank N+1 when no real edge already connects them.
|
||||
*/
|
||||
function buildPhantomEdges(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
configs: Record<string, NodeConfig>,
|
||||
): Edge[] {
|
||||
// Group nodes by rank
|
||||
const byRank = new Map<number, string[]>();
|
||||
for (const node of nodes) {
|
||||
const rank = getPipelineRank(configs[node.id]);
|
||||
|
|
@ -94,7 +89,6 @@ function buildPhantomEdges(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check if any real edge already connects these rank groups
|
||||
const hasRealEdge = edges.some(
|
||||
(e) => currentIds.includes(e.source) && nextIds.includes(e.target),
|
||||
);
|
||||
|
|
@ -102,7 +96,7 @@ function buildPhantomEdges(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Insert one phantom edge from last node in current rank to first in next
|
||||
// One phantom edge: last node in current rank to first in next.
|
||||
phantoms.push({
|
||||
id: `phantom-${ranks[i]}-${ranks[i + 1]}`,
|
||||
source: currentIds[currentIds.length - 1],
|
||||
|
|
@ -129,7 +123,7 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
configs,
|
||||
} = options;
|
||||
|
||||
// When configs are provided, filter out infra and aux nodes from dagre
|
||||
// With configs, filter infra and aux nodes out of dagre.
|
||||
const hasConfigs = configs && Object.keys(configs).length > 0;
|
||||
const dataNodes = hasConfigs
|
||||
? nodes.filter((n) => !(isInfraNode(n.id, configs) || isAuxNode(n.id)))
|
||||
|
|
@ -146,7 +140,7 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
)
|
||||
: edges;
|
||||
|
||||
// Build phantom edges to enforce pipeline rank ordering for disconnected nodes
|
||||
// Phantom edges enforce rank ordering for disconnected nodes.
|
||||
const phantomEdges = hasConfigs
|
||||
? buildPhantomEdges(dataNodes, dataEdges, configs)
|
||||
: [];
|
||||
|
|
@ -175,7 +169,7 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
|
||||
dagre.layout(graph);
|
||||
|
||||
// Build position map from dagre results (data nodes only)
|
||||
// Position map from dagre results (data nodes only).
|
||||
const layoutedPositions = new Map<string, { x: number; y: number }>();
|
||||
for (const node of dataNodes) {
|
||||
const pos = graph.node(node.id);
|
||||
|
|
@ -187,7 +181,7 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
});
|
||||
}
|
||||
|
||||
// Apply positions: data nodes get dagre positions, infra/aux keep original
|
||||
// Data nodes get dagre positions; infra/aux keep original.
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const position = layoutedPositions.get(node.id);
|
||||
if (!position) {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ export function buildModelProvider(
|
|||
config: ModelProviderConfig,
|
||||
errors: string[],
|
||||
): Record<string, unknown> {
|
||||
// Local providers do not use any of the advanced request overrides -
|
||||
// the backend overrides endpoint/api_key/provider_type and strips the
|
||||
// extra fields in _inject_local_providers. Skip parsing the hidden
|
||||
// JSON inputs here so imported or hydrated recipes with stale extra
|
||||
// headers/body cannot block the client-side validation step.
|
||||
// Local providers ignore advanced request overrides: the backend overrides
|
||||
// endpoint/api_key/provider_type and strips extras in _inject_local_providers.
|
||||
// Skip parsing hidden JSON inputs so stale headers/body in imported recipes
|
||||
// can't block client-side validation.
|
||||
if (config.is_local === true) {
|
||||
return {
|
||||
name: config.name,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function buildSamplerParams(
|
|||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
// UI historically used "uuid4" as a "format". data_designer uuid sampler is always uuid4.
|
||||
// UI historically used "uuid4" as a format; data_designer uuid is always uuid4.
|
||||
if (raw.toLowerCase() === "uuid4") {
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@ export function CreateKeyForm({
|
|||
onCreated(result.key);
|
||||
setName("");
|
||||
} catch {
|
||||
// API helpers in ../api/api-keys.ts throw generic English Error
|
||||
// messages; always use the translated message so zh-CN users do not
|
||||
// see English text bleed through from internal exceptions.
|
||||
// api-keys.ts throws English Error messages; use the translated one so
|
||||
// zh-CN users don't see English bleed through from internal exceptions.
|
||||
onError(t("settings.apiKeys.createError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -116,21 +116,20 @@ export function SettingsDialog() {
|
|||
showCloseButton={false}
|
||||
overlayClassName="bg-background/40"
|
||||
onCloseAutoFocus={(e) => {
|
||||
// Restore focus to the element that triggered openDialog().
|
||||
// Radix's FocusScope races our rAF-scheduled tab-button focus
|
||||
// and loses the previous-focus reference, so we restore by hand.
|
||||
// Restore focus to the element that triggered openDialog(). Radix's
|
||||
// FocusScope races our rAF-scheduled tab focus and loses the
|
||||
// previous-focus reference, so restore it by hand.
|
||||
if (opener && opener.isConnected) {
|
||||
e.preventDefault();
|
||||
opener.focus({ preventScroll: true });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
// Cap at 820px but shrink to the viewport so we don't clip
|
||||
// on iPad-portrait widths (640-820px) where the fixed
|
||||
// `w-[820px]` overflows by 26px on each side.
|
||||
// Cap at 820px but shrink to the viewport so it doesn't clip on
|
||||
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
|
||||
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
|
||||
// Soft shadow only, no outline ring. Pin --radius to the light value
|
||||
// so the corner rounding is the same in dark mode.
|
||||
// Soft shadow, no outline ring. Pin --radius to the light value so
|
||||
// corner rounding matches in dark mode.
|
||||
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
|
||||
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ export type SettingsTab =
|
|||
interface SettingsDialogState {
|
||||
open: boolean;
|
||||
activeTab: SettingsTab;
|
||||
// Element focused at the moment openDialog() ran. Radix's FocusScope
|
||||
// would normally track this, but the rAF-scheduled focus() in
|
||||
// settings-dialog.tsx races its previous-focus capture, leaving focus
|
||||
// on <body> after close. We restore explicitly via onCloseAutoFocus.
|
||||
// Element focused when openDialog() ran. Radix's FocusScope normally tracks
|
||||
// this, but the rAF-scheduled focus() in settings-dialog.tsx races its
|
||||
// previous-focus capture, leaving focus on <body> after close. We restore
|
||||
// explicitly via onCloseAutoFocus.
|
||||
opener: HTMLElement | null;
|
||||
openDialog: (tab?: SettingsTab) => void;
|
||||
closeDialog: () => void;
|
||||
|
|
|
|||
|
|
@ -32,9 +32,8 @@ function resolveTheme(theme: Theme): ResolvedTheme {
|
|||
|
||||
function applyToDocument(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
// Keep "dark"/"light" mutually exclusive. next-themes (via Sonner)
|
||||
// adds "light" on first mount; without the explicit toggle we'd end
|
||||
// up with `class="light dark"` after a switch.
|
||||
// Keep "dark"/"light" mutually exclusive: next-themes (via Sonner) adds
|
||||
// "light" on first mount, so without this toggle we'd get "light dark".
|
||||
const cl = document.documentElement.classList;
|
||||
cl.toggle("dark", resolved === "dark");
|
||||
cl.toggle("light", resolved === "light");
|
||||
|
|
@ -72,15 +71,14 @@ function getServerSnapshot(): Theme {
|
|||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for setting the theme. All writers (the Settings
|
||||
* dialog's segmented control AND the sidebar dropdown's animated toggler)
|
||||
* must route through this so the DOM class, localStorage, and React
|
||||
* subscribers stay in sync.
|
||||
* Single source of truth for setting the theme. All writers (Settings dialog
|
||||
* control, sidebar dropdown toggler) route through this so the DOM class,
|
||||
* localStorage, and React subscribers stay in sync.
|
||||
*/
|
||||
export function setTheme(next: Theme): void {
|
||||
if (typeof window === "undefined") return;
|
||||
// Persist "system" explicitly so next-themes (mounted with
|
||||
// defaultTheme="light") doesn't clobber the choice on reload.
|
||||
// Persist "system" explicitly so next-themes (defaultTheme="light")
|
||||
// doesn't clobber the choice on reload.
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -32,9 +32,8 @@ export function ApiKeysTab() {
|
|||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
|
||||
// API helpers in ../api/api-keys.ts throw generic English Error messages
|
||||
// ("Failed to load API access", etc.). Always use the translated message
|
||||
// so zh-CN users do not see those English strings bleed through.
|
||||
// ../api/api-keys.ts throws generic English errors; use the translated
|
||||
// message so zh-CN users don't see English strings bleed through.
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
|
|||
|
|
@ -30,15 +30,10 @@ import { SettingsRow } from "../components/settings-row";
|
|||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
// Keys cleared by "Reset all local preferences".
|
||||
//
|
||||
// NEVER include auth / session keys here — resetting them would log the user
|
||||
// out, which is not what users expect from a "reset preferences" button.
|
||||
//
|
||||
// Explicitly EXCLUDED:
|
||||
// - "unsloth_auth_token" (auth: access token)
|
||||
// - "unsloth_auth_refresh_token" (auth: refresh token)
|
||||
// - "unsloth_auth_must_change_password" (auth: forced password change flag)
|
||||
// - "unsloth_onboarding_done" (session: would force re-onboarding)
|
||||
// NEVER include auth/session keys here — clearing them would log the user out
|
||||
// or force re-onboarding. Explicitly excluded: unsloth_auth_token,
|
||||
// unsloth_auth_refresh_token, unsloth_auth_must_change_password,
|
||||
// unsloth_onboarding_done.
|
||||
const PREFS_KEYS: string[] = [
|
||||
// Appearance
|
||||
"theme",
|
||||
|
|
@ -70,10 +65,8 @@ const PREFS_KEYS: string[] = [
|
|||
"tour:studio:v1",
|
||||
];
|
||||
|
||||
// Set to true from resetAllPrefs so the unmount-commit effect skips writing
|
||||
// back the in-memory draft — otherwise the cleanup would re-persist the old
|
||||
// HF token into localStorage after it was just cleared, and the subsequent
|
||||
// reload would read the re-written value.
|
||||
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
|
||||
// in-memory draft, else cleanup would re-persist the just-cleared HF token.
|
||||
let resetInProgress = false;
|
||||
|
||||
function resetAllPrefs() {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function HistoricalTrainingView({
|
|||
const [detail, setDetail] = useState<TrainingRunDetailResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Derive loading from detail/error -- no separate state needed
|
||||
// Derive loading from detail/error; no separate state.
|
||||
const loading = detail === null && error === null;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -114,7 +114,7 @@ export function HistoricalTrainingView({
|
|||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
// Reset on runId change so loading derives correctly for the next fetch
|
||||
// Reset on runId change so loading derives correctly for the next fetch.
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ function Sparkline({
|
|||
const w = 120;
|
||||
const gradientId = `sparkFill-${id}`;
|
||||
|
||||
// Build points with vertical padding so the stroke isn't clipped
|
||||
// Vertical padding so the stroke isn't clipped.
|
||||
const pts = values.map((v, i) => ({
|
||||
x: (i / (values.length - 1)) * w,
|
||||
y: pad + (1 - (v - min) / range) * (h - pad * 2),
|
||||
|
|
@ -206,7 +206,7 @@ export function HistoryCardGrid({
|
|||
}, [runs.length]);
|
||||
|
||||
const fetchRuns = useCallback(async (offset = 0, append = false, limit = PAGE_SIZE) => {
|
||||
// Cancel any in-flight poll so its stale response can't clobber this fresher fetch
|
||||
// Cancel any in-flight poll so its stale response can't clobber this fetch.
|
||||
pollControllerRef.current?.abort();
|
||||
userControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
|
|
@ -261,7 +261,7 @@ export function HistoryCardGrid({
|
|||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
// Poll while any run is still "running" so the card shows live progress
|
||||
// Poll while any run is "running" so cards show live progress.
|
||||
const hasRunningRun = runs.some((r) => r.status === "running");
|
||||
const visibleCount = runs.length;
|
||||
useEffect(() => {
|
||||
|
|
@ -275,11 +275,11 @@ export function HistoryCardGrid({
|
|||
try {
|
||||
const limit = Math.max(PAGE_SIZE, visibleCount);
|
||||
const result = await listTrainingRuns(limit, 0, controller.signal);
|
||||
if (pollIdRef.current !== pid) return; // stale poll — discard
|
||||
if (pollIdRef.current !== pid) return; // stale poll
|
||||
setRuns(result.runs);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
// silently handle — poll will retry
|
||||
// ignore; poll will retry
|
||||
}
|
||||
}, RUNNING_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
|
|
@ -294,11 +294,11 @@ export function HistoryCardGrid({
|
|||
try {
|
||||
await deleteTrainingRun(deleteTarget);
|
||||
emitTrainingRunDeleted(deleteTarget);
|
||||
// Re-fetch preserving visible count so offsets stay consistent for "Load more"
|
||||
// Re-fetch preserving visible count so "Load more" offsets stay consistent.
|
||||
const currentCount = runs.length - 1;
|
||||
const limit = Math.max(PAGE_SIZE, currentCount);
|
||||
fetchRuns(0, false, limit).catch(() => {
|
||||
// Refresh failed — card is already removed, no stale display
|
||||
// Refresh failed; card is already removed, no stale display.
|
||||
});
|
||||
} catch {
|
||||
setDeleteError(translate("studio.history.deleteError"));
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ const FROM_CANONICAL: Record<string, Record<string, string>> = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Remap a column→role mapping between formats.
|
||||
* Normalises every role to canonical chatml first, then maps to the target format.
|
||||
* Remap a column→role mapping between formats: normalise to canonical chatml,
|
||||
* then map to the target format.
|
||||
*/
|
||||
export function remapRolesForFormat(
|
||||
mapping: Record<string, string>,
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ export function DatasetPreviewDialog({
|
|||
);
|
||||
const { isStarting, startError, startTrainingRun } = useTrainingActions();
|
||||
|
||||
// If the backend reports image data, treat as VLM even if the prop
|
||||
// hasn't caught up yet (isDatasetImage may still be null in the store).
|
||||
// Treat backend-reported image data as VLM even if the prop hasn't caught up.
|
||||
const effectiveIsAudio = !!data?.is_audio;
|
||||
const effectiveIsVlm = isVlm || !!data?.is_image;
|
||||
|
||||
|
|
@ -119,7 +118,7 @@ export function DatasetPreviewDialog({
|
|||
});
|
||||
|
||||
if (result.success && result.suggested_mapping) {
|
||||
// Remap from chatml roles (user/assistant/system) to format-specific roles
|
||||
// Remap chatml roles to format-specific roles
|
||||
const table = ROLE_REMAP[datasetFormat];
|
||||
const mapped: Record<string, string> = {};
|
||||
for (const [col, role] of Object.entries(result.suggested_mapping)) {
|
||||
|
|
@ -155,14 +154,12 @@ export function DatasetPreviewDialog({
|
|||
setManualMapping(remapRolesForFormat(manualMapping, datasetFormat));
|
||||
}, [datasetFormat]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle role change for a column
|
||||
const handleRoleChange = useCallback(
|
||||
(colName: string, role: string | undefined) => {
|
||||
const next = { ...manualMapping };
|
||||
// Remove this column's previous role
|
||||
delete next[colName];
|
||||
if (role) {
|
||||
// Remove any other column that had this role (each role can only be assigned once)
|
||||
// Each role maps to one column, so drop any other column holding it
|
||||
for (const [col, r] of Object.entries(next)) {
|
||||
if (r === role) delete next[col];
|
||||
}
|
||||
|
|
@ -230,7 +227,6 @@ export function DatasetPreviewDialog({
|
|||
const rows = data?.preview_samples ?? [];
|
||||
const columns = data?.columns ?? [];
|
||||
|
||||
// Determine source label
|
||||
const sourceLabel = useMemo(() => {
|
||||
if (!datasetName) return "";
|
||||
if (datasetSource === "huggingface") {
|
||||
|
|
@ -243,7 +239,6 @@ export function DatasetPreviewDialog({
|
|||
return `Local Files (${datasetName})`;
|
||||
}, [datasetName, datasetSource, datasetSubset, datasetSplit]);
|
||||
|
||||
// Build TanStack Table columns from the column names
|
||||
const tableColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
|
||||
if (!columns.length) return [];
|
||||
|
||||
|
|
@ -507,10 +502,7 @@ export function DatasetPreviewDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue