249 lines · 8.0 KB · TypeScript
RawDownload
import { useCallback } from "react";import { Option, Schema } from "effect";import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";import {  getDefaultModel,  getModelOptions,  normalizeModelSlug,  resolveSelectableModel,} from "@t3tools/shared/model";import { useLocalStorage } from "./hooks/useLocalStorage";import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";const MAX_CUSTOM_MODEL_COUNT = 32;export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);export type TimestampFormat = typeof TimestampFormat.Type;export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";export type ProviderCustomModelConfig = {  provider: ProviderKind;  settingsKey: CustomModelSettingsKey;  defaultSettingsKey: CustomModelSettingsKey;  title: string;  description: string;  placeholder: string;  example: string;}; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {  codex: new Set(getModelOptions("codex").map((option) => option.slug)),  claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),}; const withDefaults =  <    S extends Schema.Top & Schema.WithoutConstructorDefault,    D extends S["~type.make.in"] & S["Encoded"],  >(    fallback: () => D,  ) =>  (schema: S) =>    schema.pipe(      Schema.withConstructorDefault(() => Option.some(fallback())),      Schema.withDecodingDefault(() => fallback()),    ); export const AppSettingsSchema = Schema.Struct({  codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),  codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),  newProjectBasePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),  defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),  confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),  enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),  timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),  customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),  customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),  textGenerationModel: Schema.optional(TrimmedNonEmptyString),});export type AppSettings = typeof AppSettingsSchema.Type;export interface AppModelOption {  slug: string;  name: string;  isCustom: boolean;} const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {  codex: {    provider: "codex",    settingsKey: "customCodexModels",    defaultSettingsKey: "customCodexModels",    title: "Codex",    description: "Save additional Codex model slugs for the picker and `/model` command.",    placeholder: "your-codex-model-slug",    example: "gpt-6.7-codex-ultra-preview",  },  claudeAgent: {    provider: "claudeAgent",    settingsKey: "customClaudeModels",    defaultSettingsKey: "customClaudeModels",    title: "Claude",    description: "Save additional Claude model slugs for the picker and `/model` command.",    placeholder: "your-claude-model-slug",    example: "claude-sonnet-5-0",  },};export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs(  models: Iterable<string | null | undefined>,  provider: ProviderKind = "codex",): string[] {  const normalizedModels: string[] = [];  const seen = new Set<string>();  const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];   for (const candidate of models) {    const normalized = normalizeModelSlug(candidate, provider);    if (      !normalized ||      normalized.length > MAX_CUSTOM_MODEL_LENGTH ||      builtInModelSlugs.has(normalized) ||      seen.has(normalized)    ) {      continue;    }     seen.add(normalized);    normalizedModels.push(normalized);    if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {      break;    }  }   return normalizedModels;} function normalizeAppSettings(settings: AppSettings): AppSettings {  return {    ...settings,    customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),    customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),  };} export function getCustomModelsForProvider(  settings: Pick<AppSettings, CustomModelSettingsKey>,  provider: ProviderKind,): readonly string[] {  return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];} export function getDefaultCustomModelsForProvider(  defaults: Pick<AppSettings, CustomModelSettingsKey>,  provider: ProviderKind,): readonly string[] {  return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];} export function patchCustomModels(  provider: ProviderKind,  models: string[],): Partial<Pick<AppSettings, CustomModelSettingsKey>> {  return {    [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,  };} export function getCustomModelsByProvider(  settings: Pick<AppSettings, CustomModelSettingsKey>,): Record<ProviderKind, readonly string[]> {  return {    codex: getCustomModelsForProvider(settings, "codex"),    claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),  };} export function getAppModelOptions(  provider: ProviderKind,  customModels: readonly string[],  selectedModel?: string | null,): AppModelOption[] {  const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({    slug,    name,    isCustom: false,  }));  const seen = new Set(options.map((option) => option.slug));  const trimmedSelectedModel = selectedModel?.trim().toLowerCase();   for (const slug of normalizeCustomModelSlugs(customModels, provider)) {    if (seen.has(slug)) {      continue;    }     seen.add(slug);    options.push({      slug,      name: slug,      isCustom: true,    });  }   const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);  const selectedModelMatchesExistingName =    typeof trimmedSelectedModel === "string" &&    options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);  if (    normalizedSelectedModel &&    !seen.has(normalizedSelectedModel) &&    !selectedModelMatchesExistingName  ) {    options.push({      slug: normalizedSelectedModel,      name: normalizedSelectedModel,      isCustom: true,    });  }   return options;} export function resolveAppModelSelection(  provider: ProviderKind,  customModels: Record<ProviderKind, readonly string[]>,  selectedModel: string | null | undefined,): string {  const customModelsForProvider = customModels[provider];  const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);  return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);} export function getCustomModelOptionsByProvider(  settings: Pick<AppSettings, CustomModelSettingsKey>,): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {  const customModelsByProvider = getCustomModelsByProvider(settings);  return {    codex: getAppModelOptions("codex", customModelsByProvider.codex),    claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),  };} export function useAppSettings() {  const [settings, setSettings] = useLocalStorage(    APP_SETTINGS_STORAGE_KEY,    DEFAULT_APP_SETTINGS,    AppSettingsSchema,  );   const updateSettings = useCallback(    (patch: Partial<AppSettings>) => {      setSettings((prev) => normalizeAppSettings({ ...prev, ...patch }));    },    [setSettings],  );   const resetSettings = useCallback(() => {    setSettings(DEFAULT_APP_SETTINGS);  }, [setSettings]);   return {    settings,    updateSettings,    resetSettings,    defaults: DEFAULT_APP_SETTINGS,  } as const;}