mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openMetadataGenerationSettings(page: Page) {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "metadata");
|
||||
await expectSettingsHeader(page, "Metadata");
|
||||
}
|
||||
|
||||
async function openManualMetadataModelPicker(page: Page) {
|
||||
await page.getByRole("button", { name: "Manual", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
}
|
||||
|
||||
test("chooses a metadata model and can return to automatic selection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-automatic.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Mock Load Test", { exact: true }).click();
|
||||
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-model-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-manual-persisted.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Automatic", exact: true }).click();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("replaces only the first configured metadata model", async ({ page }) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "mock", model: "five-minute-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
|
||||
.toEqual([
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
]);
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,7 @@ type HostSection =
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
|
||||
@@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
leading: {
|
||||
width: PROVIDER_ICON_SIZE,
|
||||
height: PROVIDER_ICON_SIZE,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -1762,12 +1762,27 @@ export const ar: TranslationResources = {
|
||||
projects: "المشاريع",
|
||||
connections: "اتصالات",
|
||||
agents: "Agents",
|
||||
metadata: "البيانات الوصفية",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
host: "نظرة عامة",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "إنشاء البيانات الوصفية",
|
||||
description:
|
||||
"اختر النموذج الذي يستخدمه Paseo لعناوين مساحات العمل وأسماء الفروع ورسائل الالتزام ومسودات طلبات السحب",
|
||||
selection: "اختيار النموذج",
|
||||
automatic: "تلقائي",
|
||||
preferred: "يدوي",
|
||||
automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
|
||||
preferredHint: "اختر النموذج الذي يستخدمه Paseo",
|
||||
model: "النموذج",
|
||||
fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
|
||||
docs: "الوثائق",
|
||||
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
browserData: {
|
||||
|
||||
@@ -1773,12 +1773,27 @@ export const en = {
|
||||
projects: "Projects",
|
||||
connections: "Connections",
|
||||
agents: "Agents",
|
||||
metadata: "Metadata",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
terminals: "Terminals",
|
||||
host: "Overview",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Metadata generation",
|
||||
description:
|
||||
"Choose the model Paseo uses for workspace titles, branch names, commit messages, and pull request drafts",
|
||||
selection: "Model selection",
|
||||
automatic: "Automatic",
|
||||
preferred: "Manual",
|
||||
automaticHint: "Paseo picks a fast available model",
|
||||
preferredHint: "Choose the model Paseo uses",
|
||||
model: "Model",
|
||||
fallbackHint: "If it is unavailable, Paseo falls back to another available model",
|
||||
docs: "Docs",
|
||||
saveError: "Unable to update metadata generation",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
browserData: {
|
||||
|
||||
@@ -1805,12 +1805,27 @@ export const es: TranslationResources = {
|
||||
projects: "Proyectos",
|
||||
connections: "Conexiones",
|
||||
agents: "Agents",
|
||||
metadata: "Metadatos",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
host: "Resumen",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Generación de metadatos",
|
||||
description:
|
||||
"Elige el modelo que Paseo usa para títulos de espacios de trabajo, nombres de ramas, mensajes de commit y borradores de pull requests",
|
||||
selection: "Selección de modelo",
|
||||
automatic: "Automática",
|
||||
preferred: "Manual",
|
||||
automaticHint: "Paseo elige un modelo rápido disponible",
|
||||
preferredHint: "Elige el modelo que usa Paseo",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
|
||||
docs: "Documentación",
|
||||
saveError: "No se pudo actualizar la generación de metadatos",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
browserData: {
|
||||
|
||||
@@ -1809,12 +1809,27 @@ export const fr: TranslationResources = {
|
||||
projects: "Projets",
|
||||
connections: "Relations",
|
||||
agents: "Agents",
|
||||
metadata: "Métadonnées",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
terminals: "Terminals",
|
||||
host: "Aperçu",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Génération de métadonnées",
|
||||
description:
|
||||
"Choisissez le modèle utilisé par Paseo pour les titres d’espaces de travail, les noms de branches, les messages de commit et les brouillons de pull request",
|
||||
selection: "Sélection du modèle",
|
||||
automatic: "Automatique",
|
||||
preferred: "Manuel",
|
||||
automaticHint: "Paseo choisit un modèle rapide disponible",
|
||||
preferredHint: "Choisissez le modèle utilisé par Paseo",
|
||||
model: "Modèle",
|
||||
fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
|
||||
docs: "Documentation",
|
||||
saveError: "Impossible de mettre à jour la génération de métadonnées",
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
browserData: {
|
||||
|
||||
@@ -1778,12 +1778,27 @@ export const ja: TranslationResources = {
|
||||
projects: "プロジェクト",
|
||||
connections: "接続",
|
||||
agents: "エージェント",
|
||||
metadata: "メタデータ",
|
||||
workspaces: "ワークスペース",
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
terminals: "ターミナル",
|
||||
host: "概要",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "メタデータ生成",
|
||||
description:
|
||||
"ワークスペースのタイトル、ブランチ名、コミットメッセージ、プルリクエストの下書きに使用するモデルを選択します",
|
||||
selection: "モデル選択",
|
||||
automatic: "自動",
|
||||
preferred: "手動",
|
||||
automaticHint: "Paseo が利用可能な高速モデルを選択します",
|
||||
preferredHint: "Paseo が使用するモデルを選択します",
|
||||
model: "モデル",
|
||||
fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
|
||||
docs: "ドキュメント",
|
||||
saveError: "メタデータ生成を更新できません",
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
browserData: {
|
||||
|
||||
@@ -1772,12 +1772,27 @@ export const ko: TranslationResources = {
|
||||
projects: "프로젝트",
|
||||
connections: "연결",
|
||||
agents: "에이전트",
|
||||
metadata: "메타데이터",
|
||||
workspaces: "워크스페이스",
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
terminals: "터미널",
|
||||
host: "개요",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "메타데이터 생성",
|
||||
description:
|
||||
"워크스페이스 제목, 브랜치 이름, 커밋 메시지 및 풀 리퀘스트 초안에 사용할 모델을 선택하세요",
|
||||
selection: "모델 선택",
|
||||
automatic: "자동",
|
||||
preferred: "수동",
|
||||
automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
|
||||
preferredHint: "Paseo에서 사용할 모델을 선택하세요",
|
||||
model: "모델",
|
||||
fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
|
||||
docs: "문서",
|
||||
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
browserData: {
|
||||
|
||||
@@ -1791,12 +1791,27 @@ export const ptBR: TranslationResources = {
|
||||
projects: "Projetos",
|
||||
connections: "Conexões",
|
||||
agents: "Agentes",
|
||||
metadata: "Metadados",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminais",
|
||||
host: "Visão geral",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Geração de metadados",
|
||||
description:
|
||||
"Escolha o modelo usado pelo Paseo para títulos de espaços de trabalho, nomes de branches, mensagens de commit e rascunhos de pull request",
|
||||
selection: "Seleção de modelo",
|
||||
automatic: "Automática",
|
||||
preferred: "Manual",
|
||||
automaticHint: "O Paseo escolhe um modelo rápido disponível",
|
||||
preferredHint: "Escolha o modelo que o Paseo usa",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
|
||||
docs: "Documentação",
|
||||
saveError: "Não foi possível atualizar a geração de metadados",
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
browserData: {
|
||||
|
||||
@@ -1796,12 +1796,27 @@ export const ru: TranslationResources = {
|
||||
projects: "Проекты",
|
||||
connections: "Соединения",
|
||||
agents: "Agents",
|
||||
metadata: "Метаданные",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
host: "Обзор",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Создание метаданных",
|
||||
description:
|
||||
"Выберите модель, которую Paseo использует для названий рабочих пространств, веток, сообщений коммитов и черновиков pull request",
|
||||
selection: "Выбор модели",
|
||||
automatic: "Автоматически",
|
||||
preferred: "Вручную",
|
||||
automaticHint: "Paseo выбирает быструю доступную модель",
|
||||
preferredHint: "Выберите модель, которую использует Paseo",
|
||||
model: "Модель",
|
||||
fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
|
||||
docs: "Документация",
|
||||
saveError: "Не удалось обновить создание метаданных",
|
||||
},
|
||||
general: {
|
||||
title: "Общий",
|
||||
browserData: {
|
||||
|
||||
@@ -1742,12 +1742,26 @@ export const zhCN: TranslationResources = {
|
||||
projects: "项目",
|
||||
connections: "连接",
|
||||
agents: "Agents",
|
||||
metadata: "元数据",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
host: "概览",
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "元数据生成",
|
||||
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
|
||||
selection: "模型选择",
|
||||
automatic: "自动",
|
||||
preferred: "手动",
|
||||
automaticHint: "Paseo 会选择一个可用的快速模型",
|
||||
preferredHint: "选择 Paseo 使用的模型",
|
||||
model: "模型",
|
||||
fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
|
||||
docs: "文档",
|
||||
saveError: "无法更新元数据生成设置",
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
browserData: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -248,6 +248,7 @@ describe("host settings section slugs", () => {
|
||||
expect(normalizeHostSectionSlug("connections")).toBe("connections");
|
||||
expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device");
|
||||
expect(normalizeHostSectionSlug("agents")).toBe("agents");
|
||||
expect(normalizeHostSectionSlug("metadata")).toBe("metadata");
|
||||
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
|
||||
expect(normalizeHostSectionSlug("projects")).toBe("projects");
|
||||
expect(normalizeHostSectionSlug("providers")).toBe("providers");
|
||||
|
||||
@@ -512,6 +512,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"connections",
|
||||
"pair-device",
|
||||
"agents",
|
||||
"metadata",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"usage",
|
||||
|
||||
@@ -18,8 +18,17 @@ class ProviderSnapshots {
|
||||
}
|
||||
|
||||
describe("resolveStructuredGenerationProviders", () => {
|
||||
test("uses explicit configured provider models without refreshing provider snapshots", async () => {
|
||||
const snapshots = new ProviderSnapshots([]);
|
||||
test("tries the configured model before dynamically discovered fallbacks", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "work-claude",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
@@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
expect(providers).toEqual([
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "work-claude", model: "claude-haiku-2026" },
|
||||
]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
|
||||
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
|
||||
@@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
|
||||
test("normalizes configured nested-provider aliases from the provider snapshot", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "opencode",
|
||||
@@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
|
||||
test("keeps explicit candidates when provider snapshots are in error state", async () => {
|
||||
@@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "configured-model" },
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders(
|
||||
options: ResolveStructuredGenerationProvidersOptions,
|
||||
): Promise<StructuredGenerationProvider[]> {
|
||||
const configuredProviders = readConfiguredProviders(options.daemonConfig);
|
||||
if (configuredProviders.length > 0) {
|
||||
const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
|
||||
if (explicitProviders.length === configuredProviders.length) {
|
||||
return dedupeProviders(explicitProviders);
|
||||
}
|
||||
|
||||
const providerEntries = await options.providerSnapshotManager.listProviders({
|
||||
cwd: options.cwd,
|
||||
wait: false,
|
||||
});
|
||||
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
|
||||
if (providers.length > 0) {
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
}
|
||||
|
||||
const providerEntries = await options.providerSnapshotManager.listProviders({
|
||||
cwd: options.cwd,
|
||||
wait: true,
|
||||
@@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders(
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
|
||||
function resolveConfiguredProviders(
|
||||
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
|
||||
providerEntries: readonly ProviderSnapshotEntry[],
|
||||
): StructuredGenerationProvider[] {
|
||||
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
|
||||
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
for (const configured of configuredProviders) {
|
||||
const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
|
||||
if (resolved) {
|
||||
providers.push(resolved);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveExplicitConfiguredProviders(
|
||||
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
|
||||
): StructuredGenerationProvider[] {
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
for (const configured of configuredProviders) {
|
||||
const provider = configured.provider.trim();
|
||||
const model = configured.model?.trim();
|
||||
if (!provider || !model) {
|
||||
continue;
|
||||
}
|
||||
providers.push({
|
||||
provider,
|
||||
model,
|
||||
...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveCurrentSelection(
|
||||
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
|
||||
readyEntries: readonly ProviderSnapshotEntry[],
|
||||
|
||||
@@ -37,9 +37,13 @@ Each default is a match on the model id or name, so the first enabled provider t
|
||||
|
||||
The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected.
|
||||
|
||||
## Configuring the providers
|
||||
## Choose a model
|
||||
|
||||
To control which models Paseo uses — for example to keep all metadata generation on one provider, or to prefer a local model — set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
|
||||
Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list.
|
||||
|
||||
## Configure a custom fallback order
|
||||
|
||||
To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -60,7 +64,7 @@ Each entry accepts:
|
||||
- `model` (optional) — a specific model id. Omit it to use that provider's default model.
|
||||
- `thinkingOptionId` (optional) — a reasoning/thinking level for models that support one. Falls back to the model's default if the value isn't valid for that model.
|
||||
|
||||
Restart the daemon after editing the file.
|
||||
The Settings screen replaces only the first entry and preserves the rest of a custom list. Restart the daemon after editing the file directly.
|
||||
|
||||
## Per-project instructions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user