Let users choose the metadata generation model (#3215)
Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
SquareTerminal,
|
||||
Code2,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
@@ -107,6 +108,7 @@ import {
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -184,6 +186,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
|
||||
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
||||
@@ -203,6 +206,8 @@ function renderHostSettingsContent(
|
||||
return <HostPairDevicePage serverId={view.serverId} />;
|
||||
case "agents":
|
||||
return <HostAgentsPage serverId={view.serverId} />;
|
||||
case "metadata":
|
||||
return <MetadataGenerationPage serverId={view.serverId} />;
|
||||
case "workspaces":
|
||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation";
|
||||
type SelectionMode = "automatic" | "preferred";
|
||||
|
||||
export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId);
|
||||
const snapshot = useProvidersSnapshot(serverId);
|
||||
const providers = useMemo(
|
||||
() => buildSelectableProviderSelectorProviders(snapshot.entries),
|
||||
[snapshot.entries],
|
||||
);
|
||||
const configuredProviders = config?.metadataGeneration.providers;
|
||||
const configuredProvider = configuredProviders?.[0] ?? null;
|
||||
const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic";
|
||||
const [draftMode, setDraftMode] = useState<SelectionMode | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const mode = draftMode ?? savedMode;
|
||||
|
||||
useEffect(() => {
|
||||
setDraftMode(null);
|
||||
}, [configuredProvider?.model, configuredProvider?.provider]);
|
||||
|
||||
const modeOptions = useMemo(
|
||||
() => [
|
||||
{ value: "automatic" as const, label: t("settings.metadataGeneration.automatic") },
|
||||
{ value: "preferred" as const, label: t("settings.metadataGeneration.preferred") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const saveProviders = useCallback(
|
||||
async (providersPatch: { provider: string; model?: string }[]) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await patchConfig({ metadataGeneration: { providers: providersPatch } });
|
||||
} catch (error) {
|
||||
setDraftMode(null);
|
||||
Alert.alert(
|
||||
t("settings.metadataGeneration.saveError"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(next: SelectionMode) => {
|
||||
setDraftMode(next);
|
||||
if (next === "automatic") {
|
||||
void saveProviders([]);
|
||||
}
|
||||
},
|
||||
[saveProviders],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(provider: AgentProvider, model: string) => {
|
||||
setDraftMode("preferred");
|
||||
void saveProviders([
|
||||
{ provider, ...(model ? { model } : {}) },
|
||||
...(configuredProviders?.slice(1) ?? []),
|
||||
]);
|
||||
},
|
||||
[configuredProviders, saveProviders],
|
||||
);
|
||||
|
||||
const handleSelectorOpen = useCallback(() => {
|
||||
snapshot.refetchIfStale(configuredProvider?.provider);
|
||||
}, [configuredProvider?.provider, snapshot]);
|
||||
const handleRetryProvider = useCallback(
|
||||
(provider: AgentProvider) => snapshot.refresh([provider]),
|
||||
[snapshot],
|
||||
);
|
||||
const docsLink = useMemo(
|
||||
() => (
|
||||
<ExternalLink
|
||||
href={METADATA_GENERATION_DOCS_URL}
|
||||
label={t("settings.metadataGeneration.docs")}
|
||||
/>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (isConfigLoading || !config) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.metadataGeneration.title")}
|
||||
trailing={docsLink}
|
||||
testID="metadata-generation-settings"
|
||||
>
|
||||
<Text style={styles.description}>{t("settings.metadataGeneration.description")}</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.metadataGeneration.selection")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{mode === "automatic"
|
||||
? t("settings.metadataGeneration.automaticHint")
|
||||
: t("settings.metadataGeneration.preferredHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
onValueChange={handleModeChange}
|
||||
size="sm"
|
||||
testID="metadata-generation-mode"
|
||||
/>
|
||||
</View>
|
||||
{mode === "preferred" ? (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.metadataGeneration.model")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.metadataGeneration.fallbackHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<CombinedModelSelector
|
||||
providers={providers}
|
||||
selectedProvider={configuredProvider?.provider ?? ""}
|
||||
selectedModel={configuredProvider?.model ?? ""}
|
||||
onSelect={handleModelSelect}
|
||||
isLoading={snapshot.isLoading || snapshot.isFetching}
|
||||
onOpen={handleSelectorOpen}
|
||||
onRetryProvider={handleRetryProvider}
|
||||
isRetryingProvider={snapshot.isRefreshing}
|
||||
disabled={isSaving}
|
||||
serverId={serverId}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={360}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.45,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
},
|
||||
loading: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 180,
|
||||
},
|
||||
spinnerColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user