From d37466387bdc089f2a14c89c3f276af00e43d8cb Mon Sep 17 00:00:00 2001 From: warrofua <41028474+warrofua@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:30:34 -0500 Subject: [PATCH] feat: add DeepSeek support and settings UI --- src/main/agent/deepseek-model.ts | 192 +++++++++++++++ src/main/agent/runtime.ts | 15 +- src/main/ipc/models.ts | 20 +- src/main/storage.ts | 1 + src/main/types.ts | 2 +- .../src/components/chat/ApiKeyDialog.tsx | 3 +- .../components/chat/ContextUsageIndicator.tsx | 4 + .../src/components/chat/ModelSwitcher.tsx | 15 +- .../components/settings/SettingsDialog.tsx | 219 ++++++++++++++++++ .../src/components/sidebar/ThreadSidebar.tsx | 34 ++- src/renderer/src/types.ts | 2 +- 11 files changed, 491 insertions(+), 16 deletions(-) create mode 100644 src/main/agent/deepseek-model.ts create mode 100644 src/renderer/src/components/settings/SettingsDialog.tsx diff --git a/src/main/agent/deepseek-model.ts b/src/main/agent/deepseek-model.ts new file mode 100644 index 0000000..14dd5c3 --- /dev/null +++ b/src/main/agent/deepseek-model.ts @@ -0,0 +1,192 @@ +import { + ChatOpenAICompletions, + completionsApiContentBlockConverter, + convertStandardContentMessageToCompletionsMessage, + messageToOpenAIRole +} from '@langchain/openai' +import { + AIMessage, + BaseMessage, + ToolMessage, + convertToProviderContentBlock, + isDataContentBlock +} from '@langchain/core/messages' +import { convertLangChainToolCallToOpenAI } from '@langchain/core/output_parsers/openai_tools' +import type OpenAI from 'openai' + +type ChatCompletionMessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam + +function convertMessagesToCompletionsMessageParamsWithReasoning({ + messages +}: { + messages: BaseMessage[] +}): ChatCompletionMessageParam[] { + return messages.flatMap((message) => { + if ( + 'response_metadata' in message && + message.response_metadata && + 'output_version' in message.response_metadata && + message.response_metadata.output_version === 'v1' + ) { + return convertStandardContentMessageToCompletionsMessage({ + message: message as Parameters[0]['message'] + }) + } + + let role = messageToOpenAIRole(message as Parameters[0]) + const rawContent = (message as AIMessage).content + const content = Array.isArray(rawContent) + ? rawContent.map((block) => { + if (isDataContentBlock(block)) { + return convertToProviderContentBlock(block, completionsApiContentBlockConverter) + } + return block + }) + : rawContent + + const completionParam: Record = { role, content } + + if ('name' in message && message.name != null) completionParam.name = message.name + + if ( + 'additional_kwargs' in message && + message.additional_kwargs && + 'function_call' in message.additional_kwargs && + message.additional_kwargs.function_call != null + ) { + completionParam.function_call = message.additional_kwargs.function_call + } + + if (AIMessage.isInstance(message) && message.tool_calls?.length) { + completionParam.tool_calls = message.tool_calls.map(convertLangChainToolCallToOpenAI) + } else { + if ( + 'additional_kwargs' in message && + message.additional_kwargs && + 'tool_calls' in message.additional_kwargs && + message.additional_kwargs.tool_calls != null + ) { + completionParam.tool_calls = message.additional_kwargs.tool_calls + } + if (ToolMessage.isInstance(message) && message.tool_call_id != null) { + completionParam.tool_call_id = message.tool_call_id + } + } + + const reasoningContent = + 'additional_kwargs' in message ? (message.additional_kwargs?.reasoning_content as unknown) : undefined + if (reasoningContent !== undefined) { + completionParam.reasoning_content = reasoningContent + } + + if ( + 'additional_kwargs' in message && + message.additional_kwargs && + message.additional_kwargs.audio && + typeof message.additional_kwargs.audio === 'object' && + 'id' in message.additional_kwargs.audio + ) { + const audioMessage = { + role: 'assistant' as const, + audio: { id: String(message.additional_kwargs.audio.id) } + } + return [ + completionParam as unknown as ChatCompletionMessageParam, + audioMessage as unknown as ChatCompletionMessageParam + ] + } + + return completionParam as unknown as ChatCompletionMessageParam + }) +} + +export class DeepSeekChatOpenAI extends ChatOpenAICompletions { + protected _convertCompletionsMessageToBaseMessage( + message: OpenAI.Chat.Completions.ChatCompletionMessage, + rawResponse: OpenAI.Chat.Completions.ChatCompletion + ) { + const baseMessage = super._convertCompletionsMessageToBaseMessage(message, rawResponse) + const reasoningContent = (message as { reasoning_content?: unknown }).reasoning_content + if (AIMessage.isInstance(baseMessage) && reasoningContent != null) { + baseMessage.additional_kwargs = { + ...baseMessage.additional_kwargs, + reasoning_content: reasoningContent + } + } + return baseMessage + } + + public async _generate( + messages: Parameters[0], + options: Parameters[1], + _runManager: Parameters[2] + ) { + const usageMetadata: Record = {} + const params = this.invocationParams(options) + + if (params.stream) { + throw new Error('DeepSeek streaming is disabled to preserve reasoning_content.') + } + + const messagesMapped = convertMessagesToCompletionsMessageParamsWithReasoning({ messages }) + + const data = await this.completionWithRetry( + { + ...params, + stream: false, + messages: messagesMapped + }, + { + signal: options?.signal, + ...options?.options + } + ) + + const usage = data?.usage + if (usage?.completion_tokens) usageMetadata.output_tokens = usage.completion_tokens + if (usage?.prompt_tokens) usageMetadata.input_tokens = usage.prompt_tokens + if (usage?.total_tokens) usageMetadata.total_tokens = usage.total_tokens + + const generations: Array<{ + text: string + message: AIMessage + generationInfo?: Record + }> = [] + for (const part of data?.choices ?? []) { + const text = part.message?.content ?? '' + const generation: { + text: string + message: AIMessage + generationInfo?: Record + } = { + text, + message: this._convertCompletionsMessageToBaseMessage( + part.message ?? { role: 'assistant' }, + data + ) as AIMessage + } + generation.generationInfo = { + ...(part.finish_reason ? { finish_reason: part.finish_reason } : {}), + ...(part.logprobs ? { logprobs: part.logprobs } : {}) + } + if (AIMessage.isInstance(generation.message)) { + generation.message.usage_metadata = usageMetadata as unknown as AIMessage['usage_metadata'] + } + generation.message = new AIMessage( + Object.fromEntries(Object.entries(generation.message).filter(([key]) => !key.startsWith('lc_'))) + ) + generations.push(generation) + } + + return { + generations, + llmOutput: { + tokenUsage: { + promptTokens: usageMetadata.input_tokens, + completionTokens: usageMetadata.output_tokens, + totalTokens: usageMetadata.total_tokens + } + } + } + } +} diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd..55ed416 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -5,6 +5,7 @@ import { getApiKey, getThreadCheckpointPath } from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" import { ChatGoogleGenerativeAI } from "@langchain/google-genai" +import { DeepSeekChatOpenAI } from "./deepseek-model" import { SqlJsSaver } from "../checkpointer/sqljs-saver" import { LocalSandbox } from "./local-sandbox" @@ -61,7 +62,7 @@ export async function closeCheckpointer(threadId: string): Promise { // Get the appropriate model instance based on configuration function getModelInstance( modelId?: string -): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { +): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | DeepSeekChatOpenAI | string { const model = modelId || getDefaultModel() console.log("[Runtime] Using model:", model) @@ -91,6 +92,18 @@ function getModelInstance( model, openAIApiKey: apiKey }) + } else if (model.startsWith("deepseek")) { + const apiKey = getApiKey("deepseek") + console.log("[Runtime] DeepSeek API key present:", !!apiKey) + if (!apiKey) { + throw new Error("DeepSeek API key not configured") + } + return new DeepSeekChatOpenAI({ + model, + apiKey, + configuration: { baseURL: "https://api.deepseek.com" }, + streaming: false + }) } else if (model.startsWith("gemini")) { const apiKey = getApiKey("google") console.log("[Runtime] Google API key present:", !!apiKey) diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b..4edf9c1 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -23,7 +23,8 @@ const store = new Store({ const PROVIDERS: Omit[] = [ { id: "anthropic", name: "Anthropic" }, { id: "openai", name: "OpenAI" }, - { id: "google", name: "Google" } + { id: "google", name: "Google" }, + { id: "deepseek", name: "DeepSeek" } ] // Available models configuration (updated Jan 2026) @@ -161,6 +162,23 @@ const AVAILABLE_MODELS: ModelConfig[] = [ description: "Cost-efficient variant with faster response times", available: true }, + // DeepSeek models (OpenAI-compatible) + { + id: "deepseek-chat", + name: "DeepSeek Chat (V3)", + provider: "deepseek", + model: "deepseek-chat", + description: "General-purpose chat model with strong coding performance", + available: true + }, + { + id: "deepseek-reasoner", + name: "DeepSeek Reasoner (R1)", + provider: "deepseek", + model: "deepseek-reasoner", + description: "Reasoning-focused model for complex tasks", + available: true + }, // Google Gemini models { id: "gemini-3-pro-preview", diff --git a/src/main/storage.ts b/src/main/storage.ts index d09686c..79dd57d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -11,6 +11,7 @@ const ENV_VAR_NAMES: Record = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY", + deepseek: "DEEPSEEK_API_KEY", ollama: "" // Ollama doesn't require an API key } diff --git a/src/main/types.ts b/src/main/types.ts index e0ebab3..e0f1cdd 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" | "deepseek" | "ollama" export interface Provider { id: ProviderId diff --git a/src/renderer/src/components/chat/ApiKeyDialog.tsx b/src/renderer/src/components/chat/ApiKeyDialog.tsx index 95b5981..ef29ddd 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" }, + deepseek: { placeholder: "sk-...", envVar: "DEEPSEEK_API_KEY" } } export function ApiKeyDialog({ diff --git a/src/renderer/src/components/chat/ContextUsageIndicator.tsx b/src/renderer/src/components/chat/ContextUsageIndicator.tsx index 1215521..5aa89bf 100644 --- a/src/renderer/src/components/chat/ContextUsageIndicator.tsx +++ b/src/renderer/src/components/chat/ContextUsageIndicator.tsx @@ -23,6 +23,9 @@ const MODEL_CONTEXT_LIMITS: Record = { "o1-mini": 128_000, o3: 200_000, "o3-mini": 200_000, + // DeepSeek models + "deepseek-chat": 128_000, + "deepseek-reasoner": 128_000, // Google models "gemini-3-pro-preview": 2_000_000, "gemini-3-flash-preview": 1_000_000, @@ -53,6 +56,7 @@ function getContextLimit(modelId: string): number { // Infer from model name patterns if (modelId.includes("claude")) return 200_000 if (modelId.includes("gpt-4o") || modelId.includes("o1") || modelId.includes("o3")) return 128_000 + if (modelId.includes("deepseek")) return 128_000 if (modelId.includes("gemini")) return 1_000_000 return DEFAULT_CONTEXT_LIMIT diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 45ea665..450ac18 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -33,10 +33,19 @@ function GoogleIcon({ className }: { className?: string }): React.JSX.Element { ) } +function DeepSeekIcon({ className }: { className?: string }) { + return ( + + + + ) +} + const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, google: GoogleIcon, + deepseek: DeepSeekIcon, ollama: () => null // No icon for ollama yet } @@ -44,7 +53,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: "deepseek", name: "DeepSeek", hasApiKey: false } ] interface ModelSwitcherProps { @@ -87,6 +97,9 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme function handleModelSelect(modelId: string): void { setCurrentModel(modelId) + window.api.models.setDefault(modelId).catch((error) => { + console.error('[ModelSwitcher] Failed to persist default model:', error) + }) setOpen(false) } diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..6d3ac99 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react' +import { Eye, EyeOff, Check, AlertCircle, Loader2 } 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 { Separator } from '@/components/ui/separator' + +interface SettingsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +interface ProviderConfig { + id: string + name: string + envVar: string + placeholder: string +} + +const PROVIDERS: ProviderConfig[] = [ + { + id: 'anthropic', + name: 'Anthropic', + envVar: 'ANTHROPIC_API_KEY', + placeholder: 'sk-ant-...' + }, + { + id: 'openai', + name: 'OpenAI', + envVar: 'OPENAI_API_KEY', + placeholder: 'sk-...' + }, + { + id: 'google', + name: 'Google AI', + envVar: 'GOOGLE_API_KEY', + placeholder: 'AIza...' + }, + { + id: 'deepseek', + name: 'DeepSeek', + envVar: 'DEEPSEEK_API_KEY', + placeholder: 'sk-...' + } +] + +export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + const [apiKeys, setApiKeys] = useState>({}) + const [savedKeys, setSavedKeys] = useState>({}) + const [showKeys, setShowKeys] = useState>({}) + const [saving, setSaving] = useState>({}) + const [loading, setLoading] = useState(true) + + // Load existing settings on mount + useEffect(() => { + if (open) { + loadApiKeys() + } + }, [open]) + + async function loadApiKeys() { + setLoading(true) + const keys: Record = {} + const saved: Record = {} + + for (const provider of PROVIDERS) { + try { + const key = await window.api.models.getApiKey(provider.id) + if (key) { + // Show masked version + keys[provider.id] = '••••••••••••••••' + saved[provider.id] = true + } else { + keys[provider.id] = '' + saved[provider.id] = false + } + } catch (e) { + keys[provider.id] = '' + saved[provider.id] = false + } + } + + setApiKeys(keys) + setSavedKeys(saved) + setLoading(false) + } + + async function saveApiKey(providerId: string) { + const key = apiKeys[providerId] + if (!key || key === '••••••••••••••••') return + + setSaving((prev) => ({ ...prev, [providerId]: true })) + + try { + await window.api.models.setApiKey(providerId, key) + setSavedKeys((prev) => ({ ...prev, [providerId]: true })) + setApiKeys((prev) => ({ ...prev, [providerId]: '••••••••••••••••' })) + setShowKeys((prev) => ({ ...prev, [providerId]: false })) + } catch (e) { + console.error('Failed to save API key:', e) + } finally { + setSaving((prev) => ({ ...prev, [providerId]: false })) + } + } + + function handleKeyChange(providerId: string, value: string) { + // If user starts typing on a masked field, clear it + if (apiKeys[providerId] === '••••••••••••••••' && value.length > 16) { + value = value.slice(16) + } + setApiKeys((prev) => ({ ...prev, [providerId]: value })) + setSavedKeys((prev) => ({ ...prev, [providerId]: false })) + } + + function toggleShowKey(providerId: string) { + setShowKeys((prev) => ({ ...prev, [providerId]: !prev[providerId] })) + } + + return ( + + + + Settings + + Configure API keys for model providers. Keys are stored securely on your device. + + + + + +
+
API KEYS
+ + {loading ? ( +
+ +
+ ) : ( +
+ {PROVIDERS.map((provider) => ( +
+
+ + {savedKeys[provider.id] ? ( + + + Configured + + ) : apiKeys[provider.id] ? ( + + + Unsaved + + ) : ( + Not set + )} +
+
+
+ handleKeyChange(provider.id, e.target.value)} + placeholder={provider.placeholder} + className="pr-10" + /> + +
+ +
+

+ Environment variable: {provider.envVar} +

+
+ ))} +
+ )} +
+ +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/ThreadSidebar.tsx b/src/renderer/src/components/sidebar/ThreadSidebar.tsx index 54f4184..b8d7495 100644 --- a/src/renderer/src/components/sidebar/ThreadSidebar.tsx +++ b/src/renderer/src/components/sidebar/ThreadSidebar.tsx @@ -1,10 +1,11 @@ import { useState } from "react" -import { Plus, MessageSquare, Trash2, Pencil, Loader2 } from "lucide-react" +import { Plus, MessageSquare, Trash2, Pencil, Loader2, Settings } from "lucide-react" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { useAppStore } from "@/lib/store" import { useThreadStream } from "@/lib/thread-context" import { cn, formatRelativeTime, truncate } from "@/lib/utils" +import { SettingsDialog } from "@/components/settings/SettingsDialog" import { ContextMenu, ContextMenuContent, @@ -125,6 +126,7 @@ export function ThreadSidebar(): React.JSX.Element { const [editingThreadId, setEditingThreadId] = useState(null) const [editingTitle, setEditingTitle] = useState("") + const [settingsOpen, setSettingsOpen] = useState(false) const startEditing = (threadId: string, currentTitle: string): void => { setEditingThreadId(threadId) @@ -152,15 +154,25 @@ export function ThreadSidebar(): React.JSX.Element { ) } diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 08033b4..84d5c89 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" | "deepseek" | "ollama" export interface Provider { id: ProviderId