Unverified 提交 e2589242 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add dynamic language model catalog support (#2914)

## Summary - fetch the builtin language model catalog from api.dyad.sh with local fallback data and alias resolution - migrate theme generation, auto mode, and help bot to use dynamic catalog aliases - add E2E coverage for both remote-catalog and fallback behavior ## Test plan - npm run fmt - npm run lint:fix - npm run ts - npm test - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/dynamic_models.spec.ts (run outside sandbox) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2914" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 ea36c831
import { expect } from "@playwright/test";
import { testWithConfig } from "./helpers/test_helper";
const testWithRemoteCatalog = testWithConfig({
preLaunchHook: async ({ fakeLlmPort }) => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL = `http://localhost:${fakeLlmPort}/api/language-model-catalog`;
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
const testWithFallbackCatalog = testWithConfig({
preLaunchHook: async ({ fakeLlmPort }) => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL = `http://localhost:${fakeLlmPort}/missing-language-model-catalog`;
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
testWithRemoteCatalog(
"dynamic models - uses remote catalog when API is available",
async ({ po }) => {
await po.setUp();
await po.page.getByTestId("model-picker").click();
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("GPT 5.2", { exact: true })).toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote Only", { exact: true }),
).toBeVisible();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
await expect(
po.page.getByText("Google Remote", { exact: true }),
).toBeVisible();
await expect(
po.page.getByText("Anthropic Remote", { exact: true }),
).toBeVisible();
await expect(
po.page.getByText("OpenAI Remote", { exact: true }),
).toBeVisible();
},
);
testWithFallbackCatalog(
"dynamic models - falls back to local catalog when API is unavailable",
async ({ po }) => {
await po.setUp();
await po.page.getByTestId("model-picker").click();
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("GPT 5.2", { exact: true })).toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote Only", { exact: true }),
).not.toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote", { exact: true }),
).not.toBeVisible();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
await expect(po.page.getByText("Google", { exact: true })).toBeVisible();
await expect(
po.page.getByText("Anthropic Remote", { exact: true }),
).not.toBeVisible();
await expect(
po.page.getByText("OpenAI Remote", { exact: true }),
).not.toBeVisible();
},
);
import { expect } from "@playwright/test";
import { testWithConfig, Timeout } from "./helpers/test_helper";
const testWithRealCatalog = testWithConfig({
preLaunchHook: async () => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL =
"https://api.dyad.sh/v1/language-model-catalog";
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
testWithRealCatalog(
"dynamic models - loads real catalog from api.dyad.sh",
async ({ po }) => {
await po.setUp();
// Open model picker and wait for providers to load from real API
await po.page.getByTestId("model-picker").click();
// Wait for loading to finish (real API may take a moment)
await expect(po.page.getByText("Loading models...")).not.toBeVisible({
timeout: Timeout.MEDIUM,
});
// Verify primary providers appear from the real catalog
await expect(po.page.getByText("OpenAI", { exact: true })).toBeVisible();
await expect(po.page.getByText("Anthropic", { exact: true })).toBeVisible();
// Select OpenAI submenu and verify models submenu header appears
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("OpenAI Models")).toBeVisible({
timeout: Timeout.SHORT,
});
// Close the model picker
await po.page.keyboard.press("Escape");
// Navigate to Themes and verify theme generation model options from real API
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify the "Model Selection" label is visible and at least one model
// option button is rendered from the real catalog
const dialog = po.page.getByRole("dialog");
await expect(dialog.getByText("Model Selection")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// The real catalog provides 3 theme generation model options;
// verify at least one is rendered as a button after the label
await expect(dialog.getByText("Generate Theme Prompt")).toBeVisible();
},
);
......@@ -22,6 +22,7 @@ export interface ElectronConfig {
userDataDir: string;
fakeLlmPort: number;
}) => Promise<void>;
postLaunchHook?: () => Promise<void>;
showSetupScreen?: boolean;
}
......@@ -145,6 +146,9 @@ export const test = base.extend<{
});
await use(electronApp);
if (electronConfig.postLaunchHook) {
await electronConfig.postLaunchHook();
}
// Why are we doing a force kill on Windows?
//
// Otherwise, Playwright will just hang on the test cleanup
......
import { z } from "zod";
export const ProviderIdSchema = z.enum([
"openai",
"anthropic",
"google",
"vertex",
"openrouter",
"xai",
]);
export const ThemeGenerationAliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
]);
export const AliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
"dyad/auto/openai",
"dyad/auto/anthropic",
"dyad/auto/google",
"dyad/help-bot/default",
]);
export const CatalogProviderSchema = z.object({
id: ProviderIdSchema,
displayName: z.string(),
type: z.literal("cloud"),
hasFreeTier: z.boolean().optional(),
websiteUrl: z.string().url().optional(),
secondary: z.boolean().optional(),
supportsThinking: z.boolean().optional(),
gatewayPrefix: z.string().optional(),
});
export const CatalogModelSchema = z.object({
apiName: z.string(),
displayName: z.string(),
description: z.string(),
tag: z.string().optional(),
tagColor: z.string().optional(),
dollarSigns: z.number().int().nonnegative().optional(),
temperature: z.number().optional(),
maxOutputTokens: z.number().int().positive().optional(),
contextWindow: z.number().int().positive().optional(),
lifecycle: z
.object({
stage: z.enum(["stable", "preview", "deprecated"]).optional(),
})
.optional(),
});
export const CatalogAliasSchema = z.object({
id: AliasIdSchema,
resolvedModel: z.object({
providerId: ProviderIdSchema,
apiName: z.string(),
}),
displayName: z.string().optional(),
purpose: z.enum(["theme-generation", "auto-mode", "help-bot"]).optional(),
});
export const LanguageModelCatalogResponseSchema = z.object({
version: z.string(),
expiresAt: z.string().datetime(),
providers: z.array(CatalogProviderSchema),
modelsByProvider: z.record(z.string(), z.array(CatalogModelSchema)),
aliases: z.array(CatalogAliasSchema),
curatedSelections: z.object({
themeGenerationOptions: z.array(
z.object({
id: ThemeGenerationAliasIdSchema,
label: z.string(),
}),
),
}),
});
export type LanguageModelCatalogResponse = z.infer<
typeof LanguageModelCatalogResponseSchema
>;
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
const providers = [
{
id: "openai",
displayName: "OpenAI",
type: "cloud",
websiteUrl: "https://platform.openai.com/docs/models",
supportsThinking: true,
},
{
id: "anthropic",
displayName: "Anthropic",
type: "cloud",
websiteUrl: "https://docs.anthropic.com/en/docs/about-claude/models",
supportsThinking: true,
},
{
id: "google",
displayName: "Google AI Studio",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://ai.google.dev/gemini-api/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "vertex",
displayName: "Google Vertex AI",
type: "cloud",
websiteUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "openrouter",
displayName: "OpenRouter",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://openrouter.ai/models",
},
{
id: "xai",
displayName: "xAI",
type: "cloud",
websiteUrl: "https://docs.x.ai/docs/models",
},
] satisfies z.infer<typeof CatalogProviderSchema>[];
const modelsByProvider = {
openai: [
{
apiName: "gpt-5.2",
displayName: "GPT 5.2",
description: "OpenAI's latest flagship model",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5.1-codex",
displayName: "GPT 5.1 Codex",
description: "OpenAI model optimized for coding workflows",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-mini",
displayName: "GPT 5 Mini",
description: "OpenAI lightweight model for faster lower-cost tasks",
dollarSigns: 2,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-nano",
displayName: "GPT 5 Nano",
description: "OpenAI compact budget-friendly model",
dollarSigns: 1,
temperature: 1,
contextWindow: 400_000,
},
],
anthropic: [
{
apiName: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
description: "Anthropic fast and high-quality coding model",
dollarSigns: 5,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
{
apiName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Anthropic most capable model",
dollarSigns: 6,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
],
google: [
{
apiName: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
description: "Google's highest-quality Gemini model",
dollarSigns: 4,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-3-flash-preview",
displayName: "Gemini 3 Flash (Preview)",
description: "Google fast and affordable Gemini model",
dollarSigns: 2,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Google fast Gemini model with broad availability",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
vertex: [
{
apiName: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Vertex Gemini 2.5 Pro",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Vertex Gemini 2.5 Flash",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
openrouter: [
{
apiName: "openrouter/free",
displayName: "Free (OpenRouter)",
description: "A rotating free-tier OpenRouter model",
dollarSigns: 0,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 200_000,
},
{
apiName: "moonshotai/kimi-k2.5",
displayName: "Kimi K2.5",
description: "Moonshot AI's capable model via OpenRouter",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 256_000,
},
],
xai: [
{
apiName: "grok-4",
displayName: "Grok 4",
description: "xAI flagship model",
dollarSigns: 3,
temperature: 0,
contextWindow: 256_000,
},
],
} satisfies z.infer<
typeof LanguageModelCatalogResponseSchema.shape.modelsByProvider
>;
const aliases = [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
displayName: "Google",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-opus-4-6",
},
displayName: "Anthropic",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "OpenAI",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "Auto OpenAI",
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
displayName: "Auto Anthropic",
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3-flash-preview",
},
displayName: "Auto Google",
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5-nano",
},
displayName: "Help Bot",
purpose: "help-bot",
},
] satisfies z.infer<typeof CatalogAliasSchema>[];
export function buildLanguageModelCatalogResponse(
now = new Date(),
): LanguageModelCatalogResponse {
return LanguageModelCatalogResponseSchema.parse({
version: now.toISOString(),
expiresAt: new Date(now.getTime() + ONE_HOUR_IN_MS).toISOString(),
providers,
modelsByProvider,
aliases,
curatedSelections: {
themeGenerationOptions: [
{
id: "dyad/theme-generator/google",
label: "Google",
},
{
id: "dyad/theme-generator/anthropic",
label: "Anthropic",
},
{
id: "dyad/theme-generator/openai",
label: "OpenAI",
},
],
},
});
}
差异被折叠。
差异被折叠。
import { NextResponse } from "next/server";
import {
LanguageModelCatalogResponseSchema,
buildLanguageModelCatalogResponse,
} from "./catalog-data";
export async function GET() {
const body = buildLanguageModelCatalogResponse();
const validatedBody = LanguageModelCatalogResponseSchema.parse(body);
return NextResponse.json(validatedBody, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
......@@ -97,6 +97,18 @@ Each parallel Playwright worker gets its own fake LLM server on port `FAKE_LLM_B
When adding new test server URLs, update **both** the test fixtures (`e2e-tests/helpers/fixtures.ts`) and the Electron app source that consumes them. The app reads `process.env.FAKE_LLM_PORT` to build its `TEST_SERVER_BASE` URL — if you hardcode a port in app source, parallel workers will all hit the same server.
For app features that fetch `api.dyad.sh` directly, add a test-only env override in app code and point it at the worker-specific fake server during E2E. Without that override, E2E tests cannot deterministically exercise both the remote-success and local-fallback paths.
## Sandbox-related Electron launch failures
Packaged Electron E2E runs may fail inside the Codex sandbox before any test logic executes, with Playwright reporting `electron.launch: Process failed to launch!` and the Electron process exiting with `SIGABRT`.
If this happens:
1. Verify whether the failure reproduces on an existing known-good E2E spec.
2. Re-run the same `npm run e2e -- e2e-tests/<spec>` command outside the sandbox before treating it as an app regression.
3. If the test passes outside the sandbox, treat the sandbox launch failure as environmental rather than a product bug.
## Common flaky test patterns and fixes
- **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet.
......
......@@ -7,6 +7,7 @@ import { Loader2, Upload, X, Sparkles, Lock, Link } from "lucide-react";
import {
useGenerateThemePrompt,
useGenerateThemeFromUrl,
useThemeGenerationModelOptions,
} from "@/hooks/useCustomThemes";
import { ipc } from "@/ipc/types";
import { showError } from "@/lib/toast";
......@@ -23,9 +24,6 @@ import type {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size)
const MAX_IMAGES = 5;
// Default model for AI theme generation
const DEFAULT_THEME_GENERATION_MODEL: ThemeGenerationModel = "gemini-3-pro";
// Image stored with file path (for IPC) and blob URL (for preview)
interface ThemeImage {
path: string; // File path in temp directory
......@@ -59,9 +57,8 @@ export function AIGeneratorTab({
const [aiKeywords, setAiKeywords] = useState("");
const [aiGenerationMode, setAiGenerationMode] =
useState<ThemeGenerationMode>("inspired");
const [aiSelectedModel, setAiSelectedModel] = useState<ThemeGenerationModel>(
DEFAULT_THEME_GENERATION_MODEL,
);
const [aiSelectedModel, setAiSelectedModel] =
useState<ThemeGenerationModel>("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Track if dialog is open to prevent orphaned uploads from adding images after close
......@@ -76,6 +73,8 @@ export function AIGeneratorTab({
const isGenerating =
generatePromptMutation.isPending || generateFromUrlMutation.isPending;
const { userBudget } = useUserBudgetInfo();
const { themeGenerationModelOptions, isLoadingThemeGenerationModelOptions } =
useThemeGenerationModelOptions();
// Cleanup function to revoke blob URLs and delete temp files
const cleanupImages = useCallback(
......@@ -105,6 +104,20 @@ export function AIGeneratorTab({
isDialogOpenRef.current = isDialogOpen;
}, [isDialogOpen]);
useEffect(() => {
const firstModelId = themeGenerationModelOptions[0]?.id ?? "";
if (!firstModelId) {
return;
}
if (
!aiSelectedModel ||
!themeGenerationModelOptions.some((model) => model.id === aiSelectedModel)
) {
setAiSelectedModel(firstModelId);
}
}, [aiSelectedModel, themeGenerationModelOptions]);
// Keep a ref to current images for cleanup without causing effect re-runs
const aiImagesRef = useRef<ThemeImage[]>([]);
useEffect(() => {
......@@ -122,11 +135,11 @@ export function AIGeneratorTab({
}
setAiKeywords("");
setAiGenerationMode("inspired");
setAiSelectedModel(DEFAULT_THEME_GENERATION_MODEL);
setAiSelectedModel(themeGenerationModelOptions[0]?.id ?? "");
setInputSource("images");
setWebsiteUrl("");
}
}, [isDialogOpen, cleanupImages]);
}, [isDialogOpen, cleanupImages, themeGenerationModelOptions]);
const handleImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
......@@ -513,49 +526,38 @@ export function AIGeneratorTab({
{/* Model Selection */}
<div className="space-y-3">
<Label>Model Selection</Label>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setAiSelectedModel("gemini-3-pro")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gemini-3-pro"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">Gemini 3 Pro</span>
<span className="text-xs text-muted-foreground mt-1">
Most capable
</span>
</button>
<button
type="button"
onClick={() => setAiSelectedModel("claude-opus-4.5")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "claude-opus-4.5"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">Claude Opus 4.5</span>
<span className="text-xs text-muted-foreground mt-1">
Creative & detailed
</span>
</button>
<button
type="button"
onClick={() => setAiSelectedModel("gpt-5.2")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gpt-5.2"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">GPT 5.2</span>
<span className="text-xs text-muted-foreground mt-1">
Latest OpenAI
</span>
</button>
<div
className="grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3"
role="radiogroup"
aria-label="Model Selection"
>
{isLoadingThemeGenerationModelOptions ? (
<div className="col-span-full flex items-center justify-center py-3 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading models...
</div>
) : themeGenerationModelOptions.length === 0 ? (
<div className="col-span-full text-center py-3 text-sm text-muted-foreground">
No models available
</div>
) : (
themeGenerationModelOptions.map((modelOption) => (
<button
key={modelOption.id}
type="button"
role="radio"
aria-checked={aiSelectedModel === modelOption.id}
onClick={() => setAiSelectedModel(modelOption.id)}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === modelOption.id
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">{modelOption.label}</span>
</button>
))
)}
</div>
</div>
......@@ -563,6 +565,8 @@ export function AIGeneratorTab({
<Button
onClick={handleGenerate}
disabled={
isLoadingThemeGenerationModelOptions ||
!aiSelectedModel ||
isGenerating ||
(inputSource === "images" && aiImages.length === 0) ||
(inputSource === "url" && !websiteUrl.trim())
......
......@@ -7,6 +7,7 @@ import type {
GenerateThemePromptParams,
GenerateThemePromptResult,
GenerateThemeFromUrlParams,
ThemeGenerationModelOption,
} from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
......@@ -103,3 +104,20 @@ export function useGenerateThemeFromUrl() {
},
});
}
export function useThemeGenerationModelOptions() {
const query = useQuery({
queryKey: queryKeys.themeGenerationModelOptions.all,
queryFn: async (): Promise<ThemeGenerationModelOption[]> => {
return ipc.template.getThemeGenerationModelOptions();
},
meta: {
showErrorToast: true,
},
});
return {
themeGenerationModelOptions: query.data ?? [],
isLoadingThemeGenerationModelOptions: query.isLoading,
};
}
......@@ -10,6 +10,7 @@ import {
} from "@ai-sdk/openai";
import { createTypedHandler } from "./base";
import { helpContracts } from "../types/help";
import { resolveBuiltinModelAlias } from "../shared/remote_language_model_catalog";
const logger = log.scope("help-bot");
......@@ -48,11 +49,23 @@ export function registerHelpBotHandlers() {
baseURL: "https://helpchat.dyad.sh/v1",
apiKey,
});
const helpBotModel = await resolveBuiltinModelAlias(
"dyad/help-bot/default",
);
if (!helpBotModel || helpBotModel.providerId !== "openai") {
// Help bot requires OpenAI provider because it uses the OpenAI
// responses API via a custom baseURL (helpchat.dyad.sh).
throw new Error(
`Help bot requires an OpenAI model (got provider: ${helpBotModel?.providerId ?? "none"}). ` +
`The 'dyad/help-bot/default' alias must resolve to an OpenAI model.`,
);
}
let assistantContent = "";
const stream = streamText({
model: provider.responses("gpt-5-nano"),
model: provider.responses(helpBotModel.apiName),
providerOptions: {
openai: {
reasoningSummary: "auto",
......
......@@ -19,7 +19,10 @@ export interface ModelOption {
export const GPT_5_2_MODEL_NAME = "gpt-5.2";
export const SONNET_4_6 = "claude-sonnet-4-6";
export const OPUS_4_6 = "claude-opus-4-6";
export const GEMINI_3_FLASH = "gemini-3-flash-preview";
export const GEMINI_3_1_PRO_PREVIEW = "gemini-3.1-pro-preview";
export const GPT_5_NANO = "gpt-5-nano";
export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
openai: [
......
......@@ -5,12 +5,16 @@ import {
} from "@/db/schema";
import type { LanguageModelProvider, LanguageModel } from "@/ipc/types";
import { eq } from "drizzle-orm";
import log from "electron-log";
import {
LOCAL_PROVIDERS,
CLOUD_PROVIDERS,
LOCAL_PROVIDERS,
MODEL_OPTIONS,
PROVIDER_TO_ENV_VAR,
} from "./language_model_constants";
import { getBuiltinLanguageModelCatalog } from "./remote_language_model_catalog";
const logger = log.scope("language_model_helpers");
/**
* Fetches language model providers from both the database (custom) and hardcoded constants (cloud),
* merging them with custom providers taking precedence.
......@@ -37,27 +41,33 @@ export async function getLanguageModelProviders(): Promise<
});
}
// Get hardcoded cloud providers
const hardcodedProviders: LanguageModelProvider[] = [];
for (const providerKey in CLOUD_PROVIDERS) {
if (Object.prototype.hasOwnProperty.call(CLOUD_PROVIDERS, providerKey)) {
// Ensure providerKey is a key of PROVIDERS
const key = providerKey as keyof typeof CLOUD_PROVIDERS;
const providerDetails = CLOUD_PROVIDERS[key];
if (providerDetails) {
// Ensure providerDetails is not undefined
hardcodedProviders.push({
id: key,
name: providerDetails.displayName,
hasFreeTier: providerDetails.hasFreeTier,
websiteUrl: providerDetails.websiteUrl,
gatewayPrefix: providerDetails.gatewayPrefix,
secondary: providerDetails.secondary,
envVarName: PROVIDER_TO_ENV_VAR[key] ?? undefined,
type: "cloud",
// apiBaseUrl is not directly in PROVIDERS
});
}
const builtinCatalog = await getBuiltinLanguageModelCatalog();
logger.info("Loaded builtin catalog for provider list", {
source: builtinCatalog.source,
version: builtinCatalog.version,
providerCount: builtinCatalog.providers.length,
});
const hardcodedProviders: LanguageModelProvider[] = [
...builtinCatalog.providers,
];
// Merge in any CLOUD_PROVIDERS not present in the remote catalog
// (e.g. auto, azure, bedrock which are not in the remote API).
for (const [providerId, providerDetails] of Object.entries(CLOUD_PROVIDERS)) {
if (!hardcodedProviders.some((p) => p.id === providerId)) {
hardcodedProviders.push({
id: providerId,
name: providerDetails.displayName,
hasFreeTier: providerDetails.hasFreeTier,
websiteUrl: providerDetails.websiteUrl,
gatewayPrefix: providerDetails.gatewayPrefix,
secondary: providerDetails.secondary,
envVarName:
PROVIDER_TO_ENV_VAR[providerId as keyof typeof PROVIDER_TO_ENV_VAR] ??
undefined,
type: "cloud",
});
}
}
......@@ -134,16 +144,33 @@ export async function getLanguageModels({
// If it's a cloud provider, also get the hardcoded models
let hardcodedModels: LanguageModel[] = [];
if (provider.type === "cloud") {
if (providerId in MODEL_OPTIONS) {
const models = MODEL_OPTIONS[providerId] || [];
hardcodedModels = models.map((model) => ({
...model,
const builtinCatalog = await getBuiltinLanguageModelCatalog();
logger.info("Loading cloud models from builtin catalog", {
providerId,
source: builtinCatalog.source,
version: builtinCatalog.version,
hasProviderModels: providerId in builtinCatalog.modelsByProvider,
});
if (providerId in builtinCatalog.modelsByProvider) {
hardcodedModels = builtinCatalog.modelsByProvider[providerId] || [];
} else if (providerId in MODEL_OPTIONS) {
// Fall back to hardcoded MODEL_OPTIONS for providers not in the remote
// catalog (e.g. auto, azure, bedrock).
hardcodedModels = MODEL_OPTIONS[providerId].map((model) => ({
apiName: model.name,
type: "cloud",
displayName: model.displayName,
description: model.description,
tag: model.tag,
tagColor: model.tagColor,
maxOutputTokens: model.maxOutputTokens,
contextWindow: model.contextWindow,
temperature: model.temperature,
dollarSigns: model.dollarSigns,
type: "cloud" as const,
}));
} else {
console.warn(
`Provider "${providerId}" is cloud type but not found in MODEL_OPTIONS.`,
`Provider "${providerId}" is cloud type but not found in builtin catalog or MODEL_OPTIONS.`,
);
}
}
......
......@@ -246,6 +246,7 @@ export type {
DeleteCustomThemeParams,
ThemeGenerationMode,
ThemeGenerationModel,
ThemeGenerationModelOption,
ThemeInputSource,
CrawlStatus,
GenerateThemePromptParams,
......
......@@ -93,13 +93,17 @@ export type DeleteCustomThemeParams = z.infer<
export const ThemeGenerationModeSchema = z.enum(["inspired", "high-fidelity"]);
export type ThemeGenerationMode = z.infer<typeof ThemeGenerationModeSchema>;
export const ThemeGenerationModelSchema = z.enum([
"gemini-3-pro",
"claude-opus-4.5",
"gpt-5.2",
]);
export const ThemeGenerationModelSchema = z.string().min(1);
export type ThemeGenerationModel = z.infer<typeof ThemeGenerationModelSchema>;
export const ThemeGenerationModelOptionSchema = z.object({
id: z.string(),
label: z.string(),
});
export type ThemeGenerationModelOption = z.infer<
typeof ThemeGenerationModelOptionSchema
>;
// Theme input source (images or URL)
export const ThemeInputSourceSchema = z.enum(["images", "url"]);
export type ThemeInputSource = z.infer<typeof ThemeInputSourceSchema>;
......@@ -209,6 +213,12 @@ export const templateContracts = {
output: z.array(CustomThemeSchema),
}),
getThemeGenerationModelOptions: defineContract({
channel: "get-theme-generation-model-options",
input: z.void(),
output: z.array(ThemeGenerationModelOptionSchema),
}),
createCustomTheme: defineContract({
channel: "create-custom-theme",
input: CreateCustomThemeParamsSchema,
......
......@@ -15,13 +15,9 @@ import type {
} from "../../lib/schemas";
import { getEnvVar } from "./read_env";
import log from "electron-log";
import {
FREE_OPENROUTER_MODEL_NAMES,
GEMINI_3_FLASH,
GPT_5_2_MODEL_NAME,
SONNET_4_6,
} from "../shared/language_model_constants";
import { FREE_OPENROUTER_MODEL_NAMES } from "../shared/language_model_constants";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { resolveBuiltinModelAlias } from "../shared/remote_language_model_catalog";
import { LanguageModelProvider } from "@/ipc/types";
import {
createDyadEngine,
......@@ -35,24 +31,11 @@ import { createFallback } from "./fallback_ai_model";
const dyadEngineUrl = process.env.DYAD_ENGINE_URL;
const AUTO_MODELS = [
{
provider: "openai",
name: GPT_5_2_MODEL_NAME,
},
{
provider: "anthropic",
name: SONNET_4_6,
},
{
provider: "google",
name: GEMINI_3_FLASH,
},
{
provider: "google",
name: "gemini-2.5-flash",
},
];
const AUTO_MODEL_ALIASES = [
"dyad/auto/openai",
"dyad/auto/anthropic",
"dyad/auto/google",
] as const;
export interface ModelClient {
model: LanguageModel;
......@@ -113,7 +96,7 @@ export async function getModelClient(
// Do not use free variant (for openrouter).
const modelName = model.name.split(":free")[0];
const proModelClient = getProModelClient({
const proModelClient = await getProModelClient({
model,
settings,
provider,
......@@ -158,25 +141,30 @@ export async function getModelClient(
isEngineEnabled: false,
};
}
for (const autoModel of AUTO_MODELS) {
for (const autoModelAlias of AUTO_MODEL_ALIASES) {
const resolvedModel = await resolveBuiltinModelAlias(autoModelAlias);
if (!resolvedModel) {
continue;
}
const providerInfo = allProviders.find(
(p) => p.id === autoModel.provider,
(p) => p.id === resolvedModel.providerId,
);
const envVarName = providerInfo?.envVarName;
const apiKey =
settings.providerSettings?.[autoModel.provider]?.apiKey?.value ||
settings.providerSettings?.[resolvedModel.providerId]?.apiKey?.value ||
(envVarName ? getEnvVar(envVarName) : undefined);
if (apiKey) {
logger.log(
`Using provider: ${autoModel.provider} model: ${autoModel.name}`,
`Using provider: ${resolvedModel.providerId} model: ${resolvedModel.apiName}`,
);
// Recursively call with the specific model found
return await getModelClient(
{
provider: autoModel.provider,
name: autoModel.name,
provider: resolvedModel.providerId,
name: resolvedModel.apiName,
},
settings,
);
......@@ -190,7 +178,7 @@ export async function getModelClient(
return getRegularModelClient(model, settings, providerConfig);
}
function getProModelClient({
async function getProModelClient({
model,
settings,
provider,
......@@ -200,23 +188,52 @@ function getProModelClient({
settings: UserSettings;
provider: DyadEngineProvider;
modelId: string;
}): ModelClient {
}): Promise<ModelClient> {
if (
settings.selectedChatMode === "local-agent" &&
model.provider === "auto" &&
model.name === "auto"
) {
const providers = await getLanguageModelProviders();
const fallbackModels = await Promise.all(
AUTO_MODEL_ALIASES.map(async (aliasId) => {
const resolvedModel = await resolveBuiltinModelAlias(aliasId);
if (!resolvedModel) {
return null;
}
const resolvedProvider = providers.find(
(providerInfo) => providerInfo.id === resolvedModel.providerId,
);
const resolvedModelId = `${
resolvedProvider?.gatewayPrefix || ""
}${resolvedModel.apiName}`;
if (resolvedModel.providerId === "openai") {
return provider.responses(resolvedModel.apiName, {
providerId: resolvedModel.providerId,
});
}
return provider(resolvedModelId, {
providerId: resolvedModel.providerId,
});
}),
);
const validModels = fallbackModels.filter(
(candidate) => candidate !== null,
);
if (validModels.length === 0) {
throw new Error("No auto-mode models could be resolved from the catalog");
}
return {
// We need to do the fallback here (and not server-side)
// because GPT-5* models need to use responses API to get
// full functionality (e.g. thinking summaries).
model: createFallback({
models: [
// openai requires no prefix.
provider.responses(`${GPT_5_2_MODEL_NAME}`, { providerId: "openai" }),
provider(`anthropic/${SONNET_4_6}`, { providerId: "anthropic" }),
provider(`gemini/${GEMINI_3_FLASH}`, { providerId: "google" }),
],
models: validModels,
}),
// Using openAI as the default provider.
// TODO: we should remove this and rely on the provider id passed into the provider().
......
......@@ -183,6 +183,9 @@ export const queryKeys = {
customThemes: {
all: ["custom-themes"] as const,
},
themeGenerationModelOptions: {
all: ["theme-generation-model-options"] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Templates
......
......@@ -26,25 +26,17 @@ import type {
SaveThemeImageParams,
SaveThemeImageResult,
CleanupThemeImagesParams,
ThemeGenerationModelOption,
} from "@/ipc/types";
import { webCrawlResponseSchema } from "./local_agent/tools/web_crawl";
import {
getThemeGenerationModelOptions,
resolveBuiltinModelAlias,
} from "@/ipc/shared/remote_language_model_catalog";
const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger);
// Shared model map for theme generation (used by both image and URL handlers)
const THEME_GENERATION_MODEL_MAP: Record<
string,
{ provider: string; name: string }
> = {
"gemini-3-pro": { provider: "google", name: "gemini-3-pro-preview" },
"claude-opus-4.5": {
provider: "anthropic",
name: "claude-opus-4-5",
},
"gpt-5.2": { provider: "openai", name: "gpt-5.2" },
};
// Timeout for web crawl requests (120 seconds)
const WEB_CRAWL_TIMEOUT_MS = 120_000;
......@@ -327,6 +319,13 @@ export function registerThemesHandlers() {
}));
});
handle(
"get-theme-generation-model-options",
async (): Promise<ThemeGenerationModelOption[]> => {
return getThemeGenerationModelOptions();
},
);
// Create custom theme
handle(
"create-custom-theme",
......@@ -605,13 +604,21 @@ Modern dark theme with purple accents for testing.
}
// Validate and map model selection
const selectedModel = THEME_GENERATION_MODEL_MAP[params.model];
const selectedModel = await resolveBuiltinModelAlias(params.model);
if (!selectedModel) {
throw new Error("Invalid model selection");
throw new Error(
`Invalid model selection: alias "${params.model}" could not be resolved`,
);
}
// Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings);
const { modelClient } = await getModelClient(
{
provider: selectedModel.providerId,
name: selectedModel.apiName,
},
settings,
);
// Select system prompt based on generation mode
const systemPrompt =
......@@ -755,9 +762,11 @@ Modern theme extracted from website for testing.
}
// Validate and map model selection
const selectedModel = THEME_GENERATION_MODEL_MAP[params.model];
const selectedModel = await resolveBuiltinModelAlias(params.model);
if (!selectedModel) {
throw new Error("Invalid model selection");
throw new Error(
`Invalid model selection: alias "${params.model}" could not be resolved`,
);
}
// Get API key for Dyad Engine
......@@ -837,7 +846,13 @@ Modern theme extracted from website for testing.
logger.log(`Website crawled successfully: ${params.url}`);
// Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings);
const { modelClient } = await getModelClient(
{
provider: selectedModel.providerId,
name: selectedModel.apiName,
},
settings,
);
// Select system prompt based on generation mode
const systemPrompt =
......
......@@ -78,6 +78,166 @@ app.get("/health", (req, res) => {
res.send("OK");
});
app.get("/api/language-model-catalog", (req, res) => {
res.json({
version: "e2e-test-catalog-v1",
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
providers: [
{
id: "openai",
displayName: "OpenAI",
type: "cloud",
},
{
id: "anthropic",
displayName: "Anthropic",
type: "cloud",
},
{
id: "google",
displayName: "Google",
type: "cloud",
hasFreeTier: true,
gatewayPrefix: "gemini/",
},
],
modelsByProvider: {
openai: [
{
apiName: "gpt-5.2",
displayName: "GPT 5.2",
description: "Remote catalog OpenAI model",
},
{
apiName: "gpt-5",
temperature: 1,
displayName: "GPT 5",
description: "Remote catalog OpenAI model",
},
{
apiName: "gpt-5.2-remote-only",
displayName: "GPT 5.2 Remote Only",
description: "Remote-only catalog OpenAI model for E2E coverage",
},
],
anthropic: [
{
apiName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Remote catalog Anthropic model",
},
{
apiName: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
description: "Remote catalog Anthropic model",
},
{
apiName: "claude-opus-4-5",
displayName: "Claude Opus 4.5",
description: "Remote catalog Anthropic model",
maxOutputTokens: 32_000,
},
{
apiName: "claude-sonnet-4-20250514",
displayName: "Claude Sonnet 4",
description: "Remote catalog Anthropic model",
maxOutputTokens: 32_000,
},
],
google: [
{
apiName: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
description: "Remote catalog Google model",
},
{
apiName: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Remote catalog Google model",
maxOutputTokens: 65_535,
},
],
},
aliases: [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
displayName: "Google Remote",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
displayName: "Anthropic Remote",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "OpenAI Remote",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
purpose: "help-bot",
},
],
curatedSelections: {
themeGenerationOptions: [
{
id: "dyad/theme-generator/google",
label: "Google Remote",
},
{
id: "dyad/theme-generator/anthropic",
label: "Anthropic Remote",
},
{
id: "dyad/theme-generator/openai",
label: "OpenAI Remote",
},
],
},
});
});
// Ollama-specific endpoints
app.get("/ollama/api/tags", (req, res) => {
const ollamaModels = {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论