diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 35856e08..49ab5176 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -455,6 +455,10 @@ export function setupIpcHandlers() { await repo.setConfig(args); return { success: true }; }, + 'models:getConfiguredModels': async () => { + const repo = container.resolve('modelConfigRepo'); + return repo.getAllConfiguredModels(); + }, 'oauth:connect': async (_event, args) => { return await connectProvider(args.provider, args.clientId?.trim()); }, diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 057550b2..1fbc557d 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -44,6 +44,10 @@ import { usePromptInputController, } from '@/components/ai-elements/prompt-input' import { toast } from 'sonner' +import { z } from 'zod' +import { ConfiguredModelEntry } from '@x/shared/src/models.js' + +type ConfiguredModel = z.infer export type StagedAttachment = { id: string @@ -68,15 +72,6 @@ const providerDisplayNames: Record = { 'openai-compatible': 'OpenAI-Compatible', } -interface ConfiguredModel { - flavor: string - model: string - apiKey?: string - baseURL?: string - headers?: Record - knowledgeGraphModel?: string -} - function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -157,45 +152,13 @@ function ChatInputInner({ const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) - // Load model config from disk (on mount and whenever tab becomes active) + // Load model config via IPC (on mount and whenever tab becomes active) const loadModelConfig = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - const models: ConfiguredModel[] = [] - if (parsed?.providers) { - for (const [flavor, entry] of Object.entries(parsed.providers)) { - const e = entry as Record - const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] - const singleModel = typeof e.model === 'string' ? e.model : '' - const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] - for (const model of allModels) { - if (model) { - models.push({ - flavor, - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) - } - } - } - } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) - setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) + const result = await window.ipc.invoke('models:getConfiguredModels', null) + setConfiguredModels(result.models) + if (result.activeModelKey) { + setActiveModelKey(result.activeModelKey) } } catch { // No config yet diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index d941336c..849a5a32 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -3,11 +3,15 @@ import { WorkDir } from "../config/config.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; +import { ConfiguredModelsResult, LlmProvider } from "@x/shared/dist/models.js"; + +type LlmProviderFlavor = z.infer["flavor"]; export interface IModelConfigRepo { ensureConfig(): Promise; getConfig(): Promise>; setConfig(config: z.infer): Promise; + getAllConfiguredModels(): Promise>; } const defaultConfig: z.infer = { @@ -56,4 +60,45 @@ export class FSModelConfigRepo implements IModelConfigRepo { const toWrite = { ...config, providers: existingProviders }; await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2)); } + + async getAllConfiguredModels(): Promise> { + const raw = await fs.readFile(this.configPath, "utf8"); + const parsed = JSON.parse(raw); + const models: z.infer["models"] = []; + + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers) as [LlmProviderFlavor, unknown][]) { + const e = entry as Record; + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []; + const singleModel = typeof e.model === "string" ? e.model : ""; + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []; + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }); + } + } + } + } + + const activeModelKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : ""; + + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}`; + const bKey = `${b.flavor}/${b.model}`; + if (aKey === activeModelKey) return -1; + if (bKey === activeModelKey) return 1; + return 0; + }); + + return { models, activeModelKey }; + } } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 557df845..fdc258d4 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig } from './models.js'; +import { LlmModelConfig, ConfiguredModelsResult } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -219,6 +219,10 @@ const ipcSchemas = { success: z.literal(true), }), }, + 'models:getConfiguredModels': { + req: z.null(), + res: ConfiguredModelsResult, + }, 'oauth:connect': { req: z.object({ provider: z.string(), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 30403f94..18ed684c 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -13,3 +13,17 @@ export const LlmModelConfig = z.object({ models: z.array(z.string()).optional(), knowledgeGraphModel: z.string().optional(), }); + +export const ConfiguredModelEntry = z.object({ + flavor: LlmProvider.shape.flavor, + model: z.string(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + knowledgeGraphModel: z.string().optional(), +}); + +export const ConfiguredModelsResult = z.object({ + models: z.array(ConfiguredModelEntry), + activeModelKey: z.string(), +});