From bb3a240c2025bca06a77f9358f0b069bc4e5f6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=9B=E5=87=BB=E7=9A=84Nick=E6=AF=9B?= <8921429+qkmao@user.noreply.gitee.com> Date: Sun, 25 Jan 2026 11:00:01 +0800 Subject: [PATCH 1/2] feat: add Volcengine provider with configurable Ark models --- src/main/agent/runtime.ts | 28 +++- src/main/ipc/models.ts | 73 +++++++++- src/main/storage.ts | 3 +- src/main/types.ts | 11 +- src/preload/index.d.ts | 8 ++ src/preload/index.ts | 12 ++ .../src/components/chat/ApiKeyDialog.tsx | 3 +- .../src/components/chat/ModelSwitcher.tsx | 80 +++++++++-- .../components/chat/VolcengineModelDialog.tsx | 136 ++++++++++++++++++ src/renderer/src/lib/store.ts | 16 +++ src/renderer/src/types.ts | 3 +- 11 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/components/chat/VolcengineModelDialog.tsx diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd..bc5b619 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" -import { getDefaultModel } from "../ipc/models" +import { getDefaultModel, getModelConfig } from "../ipc/models" import { getApiKey, getThreadCheckpointPath } from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" @@ -62,11 +62,14 @@ export async function closeCheckpointer(threadId: string): Promise { function getModelInstance( modelId?: string ): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { - const model = modelId || getDefaultModel() - console.log("[Runtime] Using model:", model) + const selectedModelId = modelId || getDefaultModel() + const modelConfig = getModelConfig(selectedModelId) + const provider = modelConfig?.provider + const model = modelConfig?.model ?? selectedModelId + console.log("[Runtime] Using model:", selectedModelId) // Determine provider from model ID - if (model.startsWith("claude")) { + if (provider === "anthropic" || model.startsWith("claude")) { const apiKey = getApiKey("anthropic") console.log("[Runtime] Anthropic API key present:", !!apiKey) if (!apiKey) { @@ -77,6 +80,7 @@ function getModelInstance( anthropicApiKey: apiKey }) } else if ( + provider === "openai" || model.startsWith("gpt") || model.startsWith("o1") || model.startsWith("o3") || @@ -91,7 +95,7 @@ function getModelInstance( model, openAIApiKey: apiKey }) - } else if (model.startsWith("gemini")) { + } else if (provider === "google" || model.startsWith("gemini")) { const apiKey = getApiKey("google") console.log("[Runtime] Google API key present:", !!apiKey) if (!apiKey) { @@ -101,6 +105,20 @@ function getModelInstance( model, apiKey: apiKey }) + } else if (provider === "volcengine" || model.startsWith("ep-")) { + const apiKey = getApiKey("volcengine") + console.log("[Runtime] Volcengine API key present:", !!apiKey) + if (!apiKey) { + throw new Error("Volcengine API key not configured") + } + return new ChatOpenAI({ + model, + apiKey, + useResponsesApi: true, + configuration: { + baseURL: "https://ark.cn-beijing.volces.com/api/v3" + } + }) } // Default to model string (let deepagents handle it) diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b..4a96c5d 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -3,6 +3,7 @@ import Store from "electron-store" import * as fs from "fs/promises" import * as path from "path" import type { + AddCustomModelParams, ModelConfig, Provider, SetApiKeyParams, @@ -19,11 +20,17 @@ const store = new Store({ cwd: getOpenworkDir() }) +type StoredCustomModel = Omit + +const CUSTOM_MODELS_KEY = "customModels" +const DEFAULT_MODEL_ID = "claude-sonnet-4-5-20250929" + // Provider configurations const PROVIDERS: Omit[] = [ { id: "anthropic", name: "Anthropic" }, { id: "openai", name: "OpenAI" }, - { id: "google", name: "Google" } + { id: "google", name: "Google" }, + { id: "volcengine", name: "Volcengine" } ] // Available models configuration (updated Jan 2026) @@ -204,11 +211,29 @@ const AVAILABLE_MODELS: ModelConfig[] = [ } ] +function getStoredCustomModels(): StoredCustomModel[] { + return store.get(CUSTOM_MODELS_KEY, []) as StoredCustomModel[] +} + +function setStoredCustomModels(models: StoredCustomModel[]): void { + store.set(CUSTOM_MODELS_KEY, models) +} + +function getAllModels(): ModelConfig[] { + const baseModels = AVAILABLE_MODELS.map((model) => ({ ...model, custom: false })) + const customModels = getStoredCustomModels().map((model) => ({ + ...model, + available: true, + custom: true + })) + return [...baseModels, ...customModels] +} + export function registerModelHandlers(ipcMain: IpcMain): void { // List available models ipcMain.handle("models:list", async () => { // Check which models have API keys configured - return AVAILABLE_MODELS.map((model) => ({ + return getAllModels().map((model) => ({ ...model, available: hasApiKey(model.provider) })) @@ -224,6 +249,44 @@ export function registerModelHandlers(ipcMain: IpcMain): void { store.set("defaultModel", modelId) }) + // Add custom model (e.g., Volcengine Ark endpoint) + ipcMain.handle("models:addCustom", async (_event, params: AddCustomModelParams) => { + const id = params.id.trim() + if (!id) { + throw new Error("Model ID is required") + } + + if (params.provider !== "volcengine") { + throw new Error("Custom models are only supported for Volcengine") + } + + const existing = new Set(getAllModels().map((model) => model.id)) + if (existing.has(id)) { + throw new Error(`Model ID already exists: ${id}`) + } + + const model = (params.model || id).trim() + const name = (params.name || id).trim() + const description = params.description?.trim() || "Custom Volcengine Ark model" + + const next = [ + ...getStoredCustomModels(), + { id, provider: params.provider, model, name, description } + ] + setStoredCustomModels(next) + }) + + // Delete custom model + ipcMain.handle("models:deleteCustom", async (_event, modelId: string) => { + const next = getStoredCustomModels().filter((model) => model.id !== modelId) + setStoredCustomModels(next) + + const defaultModel = store.get("defaultModel", DEFAULT_MODEL_ID) as string + if (defaultModel === modelId) { + store.set("defaultModel", DEFAULT_MODEL_ID) + } + }) + // Set API key for a provider (stored in ~/.openwork/.env) ipcMain.handle("models:setApiKey", async (_event, { provider, apiKey }: SetApiKeyParams) => { setApiKey(provider, apiKey) @@ -519,5 +582,9 @@ export function registerModelHandlers(ipcMain: IpcMain): void { export { getApiKey } from "../storage" export function getDefaultModel(): string { - return store.get("defaultModel", "claude-sonnet-4-5-20250929") as string + return store.get("defaultModel", DEFAULT_MODEL_ID) as string +} + +export function getModelConfig(modelId: string): ModelConfig | undefined { + return getAllModels().find((model) => model.id === modelId) } diff --git a/src/main/storage.ts b/src/main/storage.ts index d09686c..2b3cc8f 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -11,7 +11,8 @@ const ENV_VAR_NAMES: Record = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY", - ollama: "" // Ollama doesn't require an API key + ollama: "", // Ollama doesn't require an API key + volcengine: "ARK_API_KEY" } export function getOpenworkDir(): string { diff --git a/src/main/types.ts b/src/main/types.ts index e0ebab3..003dabe 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -80,7 +80,7 @@ export interface Run { } // Provider configuration -export type ProviderId = "anthropic" | "openai" | "google" | "ollama" +export type ProviderId = "anthropic" | "openai" | "google" | "ollama" | "volcengine" export interface Provider { id: ProviderId @@ -88,6 +88,14 @@ export interface Provider { hasApiKey: boolean } +export interface AddCustomModelParams { + id: string + provider: ProviderId + model?: string + name?: string + description?: string +} + // Model configuration export interface ModelConfig { id: string @@ -96,6 +104,7 @@ export interface ModelConfig { model: string description?: string available: boolean + custom?: boolean } // Subagent types (from deepagentsjs) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 51e74c4..cfbf57b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -52,6 +52,14 @@ interface CustomAPI { setDefault: (modelId: string) => Promise setApiKey: (provider: string, apiKey: string) => Promise getApiKey: (provider: string) => Promise + addCustomModel: (params: { + id: string + provider: string + model?: string + name?: string + description?: string + }) => Promise + deleteCustomModel: (modelId: string) => Promise } workspace: { get: (threadId?: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ffb2b36..6c78807 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,6 +146,18 @@ const api = { getApiKey: (provider: string): Promise => { return ipcRenderer.invoke("models:getApiKey", provider) }, + addCustomModel: (params: { + id: string + provider: string + model?: string + name?: string + description?: string + }): Promise => { + return ipcRenderer.invoke("models:addCustom", params) + }, + deleteCustomModel: (modelId: string): Promise => { + return ipcRenderer.invoke("models:deleteCustom", modelId) + }, deleteApiKey: (provider: string): Promise => { return ipcRenderer.invoke("models:deleteApiKey", provider) } diff --git a/src/renderer/src/components/chat/ApiKeyDialog.tsx b/src/renderer/src/components/chat/ApiKeyDialog.tsx index 95b5981..579faba 100644 --- a/src/renderer/src/components/chat/ApiKeyDialog.tsx +++ b/src/renderer/src/components/chat/ApiKeyDialog.tsx @@ -21,7 +21,8 @@ interface ApiKeyDialogProps { const PROVIDER_INFO: Record = { anthropic: { placeholder: "sk-ant-...", envVar: "ANTHROPIC_API_KEY" }, openai: { placeholder: "sk-...", envVar: "OPENAI_API_KEY" }, - google: { placeholder: "AIza...", envVar: "GOOGLE_API_KEY" } + google: { placeholder: "AIza...", envVar: "GOOGLE_API_KEY" }, + volcengine: { placeholder: "ark-...", envVar: "ARK_API_KEY" } } export function ApiKeyDialog({ diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 45ea665..cabe401 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from "react" -import { ChevronDown, Check, AlertCircle, Key } from "lucide-react" +import { ChevronDown, Check, AlertCircle, Key, Plus } from "lucide-react" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Button } from "@/components/ui/button" import { useAppStore } from "@/lib/store" import { useCurrentThread } from "@/lib/thread-context" import { cn } from "@/lib/utils" import { ApiKeyDialog } from "./ApiKeyDialog" +import { VolcengineModelDialog } from "./VolcengineModelDialog" import type { Provider, ProviderId } from "@/types" // Provider icons as simple SVG components @@ -33,10 +34,35 @@ function GoogleIcon({ className }: { className?: string }): React.JSX.Element { ) } +function VolcengineIcon({ className }: { className?: string }): React.JSX.Element { + return ( + + Volcengine + + + + + + ) +} + const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, google: GoogleIcon, + volcengine: VolcengineIcon, ollama: () => null // No icon for ollama yet } @@ -44,7 +70,8 @@ const PROVIDER_ICONS: Record> = { const FALLBACK_PROVIDERS: Provider[] = [ { id: "anthropic", name: "Anthropic", hasApiKey: false }, { id: "openai", name: "OpenAI", hasApiKey: false }, - { id: "google", name: "Google", hasApiKey: false } + { id: "google", name: "Google", hasApiKey: false }, + { id: "volcengine", name: "Volcengine", hasApiKey: false } ] interface ModelSwitcherProps { @@ -56,6 +83,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme const [selectedProviderId, setSelectedProviderId] = useState(null) const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) const [apiKeyProvider, setApiKeyProvider] = useState(null) + const [volcDialogOpen, setVolcDialogOpen] = useState(false) const { models, providers, loadModels, loadProviders } = useAppStore() const { currentModel, setCurrentModel } = useCurrentThread(threadId) @@ -104,6 +132,13 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme } } + function handleVolcengineDialogClose(isOpen: boolean): void { + setVolcDialogOpen(isOpen) + if (!isOpen) { + loadModels() + } + } + return ( <> @@ -173,9 +208,17 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme

API key required for {selectedProvider.name}

- +
+ + {selectedProvider.id === "volcengine" && ( + + )} +
) : ( // Show models list with scrollable area @@ -206,13 +249,24 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme {/* Configure API key link for providers that have a key */} {selectedProvider?.hasApiKey && ( - +
+ + {selectedProvider.id === "volcengine" && ( + + )} +
)} )} @@ -226,6 +280,8 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme onOpenChange={handleApiKeyDialogClose} provider={apiKeyProvider} /> + + ) } diff --git a/src/renderer/src/components/chat/VolcengineModelDialog.tsx b/src/renderer/src/components/chat/VolcengineModelDialog.tsx new file mode 100644 index 0000000..cd121a3 --- /dev/null +++ b/src/renderer/src/components/chat/VolcengineModelDialog.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react" +import { Loader2, Trash2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useAppStore } from "@/lib/store" + +interface VolcengineModelDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function VolcengineModelDialog({ + open, + onOpenChange +}: VolcengineModelDialogProps): React.JSX.Element { + const [modelId, setModelId] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const { models, addCustomModel, deleteCustomModel } = useAppStore() + + const customModels = models.filter((model) => model.custom && model.provider === "volcengine") + + useEffect(() => { + if (open) { + setModelId("") + setError(null) + } + }, [open]) + + async function handleSave(): Promise { + const trimmed = modelId.trim() + if (!trimmed) { + setError("Model ID is required") + return + } + + setSaving(true) + setError(null) + try { + await addCustomModel({ + id: trimmed, + provider: "volcengine", + model: trimmed, + name: trimmed, + description: "Custom Volcengine Ark model" + }) + onOpenChange(false) + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to add model" + setError(message) + } finally { + setSaving(false) + } + } + + async function handleDelete(modelId: string): Promise { + try { + await deleteCustomModel(modelId) + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to delete model" + setError(message) + } + } + + return ( + + + + Add Volcengine Model + + Enter your Volcengine Ark endpoint ID (e.g., ep-xxxxxxxxxxxxxxxxx). + + + +
+
+ setModelId(e.target.value)} + placeholder="ep-... ..." + autoFocus + /> +

+ This value will be used as both the model ID and endpoint. +

+ {error &&

{error}

} +
+ + {customModels.length > 0 && ( +
+
+ Existing Models +
+
+ {customModels.map((model) => ( +
+ {model.id} + +
+ ))} +
+
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts index 05f6ddd..861d01b 100644 --- a/src/renderer/src/lib/store.ts +++ b/src/renderer/src/lib/store.ts @@ -36,6 +36,14 @@ interface AppState { loadProviders: () => Promise setApiKey: (providerId: string, apiKey: string) => Promise deleteApiKey: (providerId: string) => Promise + addCustomModel: (params: { + id: string + provider: string + model?: string + name?: string + description?: string + }) => Promise + deleteCustomModel: (modelId: string) => Promise // Panel actions setRightPanelTab: (tab: "todos" | "files" | "subagents") => void @@ -162,6 +170,14 @@ export const useAppStore = create((set, get) => ({ await get().loadProviders() await get().loadModels() }, + addCustomModel: async (params) => { + await window.api.models.addCustomModel(params) + await get().loadModels() + }, + deleteCustomModel: async (modelId: string) => { + await window.api.models.deleteCustomModel(modelId) + await get().loadModels() + }, // Panel actions setRightPanelTab: (tab: "todos" | "files" | "subagents") => { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 08033b4..16e2f47 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -24,7 +24,7 @@ export interface Run { } // Provider configuration -export type ProviderId = "anthropic" | "openai" | "google" | "ollama" +export type ProviderId = "anthropic" | "openai" | "google" | "ollama" | "volcengine" export interface Provider { id: ProviderId @@ -39,6 +39,7 @@ export interface ModelConfig { model: string description?: string available: boolean + custom?: boolean } // Subagent types (from deepagentsjs) From 34d736ef3aee61e96dd5e27ac20827eee65a768c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=9B=E5=87=BB=E7=9A=84Nick=E6=AF=9B?= <8921429+qkmao@user.noreply.gitee.com> Date: Sun, 25 Jan 2026 11:26:09 +0800 Subject: [PATCH 2/2] align Volcengine icon color with provider list style --- src/renderer/src/components/chat/ModelSwitcher.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index cabe401..d62afec 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -40,19 +40,19 @@ function VolcengineIcon({ className }: { className?: string }): React.JSX.Elemen Volcengine )