From cfdd8e9be64074733b9812ef0d36a2f06ac3d4a5 Mon Sep 17 00:00:00 2001 From: warrofua <41028474+warrofua@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:33:23 -0500 Subject: [PATCH] feat: add deepseek support, settings access, and model persistence --- src/main/agent/deepseek-model.ts | 192 +++++++++++++ src/main/agent/runtime.ts | 55 +++- src/main/ipc/models.ts | 30 +- src/main/storage.ts | 1 + src/main/types.ts | 2 +- src/preload/index.d.ts | 4 + src/preload/index.ts | 8 + .../src/components/chat/ApiKeyDialog.tsx | 3 +- .../components/chat/ContextUsageIndicator.tsx | 4 + .../src/components/chat/ModelSwitcher.tsx | 15 +- .../components/settings/SettingsDialog.tsx | 265 ++++++++++++++++++ .../src/components/sidebar/ThreadSidebar.tsx | 34 ++- src/renderer/src/components/ui/switch.tsx | 30 ++ src/renderer/src/lib/thread-context.tsx | 18 +- src/renderer/src/types.ts | 2 +- 15 files changed, 642 insertions(+), 21 deletions(-) create mode 100644 src/main/agent/deepseek-model.ts create mode 100644 src/renderer/src/components/settings/SettingsDialog.tsx create mode 100644 src/renderer/src/components/ui/switch.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..0d8e92e 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" -import { getDefaultModel } from "../ipc/models" -import { getApiKey, getThreadCheckpointPath } from "../storage" +import Store from "electron-store" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" import { ChatGoogleGenerativeAI } from "@langchain/google-genai" +import { getDefaultModel } from "../ipc/models" +import { getApiKey, getOpenworkDir, getThreadCheckpointPath } from "../storage" +import { DeepSeekChatOpenAI } from "./deepseek-model" import { SqlJsSaver } from "../checkpointer/sqljs-saver" import { LocalSandbox } from "./local-sandbox" @@ -15,6 +17,34 @@ import type * as _lcZodTypes from "@langchain/core/utils/types" import { BASE_SYSTEM_PROMPT } from "./system-prompt" +function ensureGraphInterruptHasInterrupts(): void { + const errorProto = Error.prototype as { interrupts?: unknown } + if (Object.prototype.hasOwnProperty.call(errorProto, "interrupts")) { + return + } + + Object.defineProperty(errorProto, "interrupts", { + configurable: true, + get() { + if (this && (this.name === "GraphInterrupt" || this.name === "NodeInterrupt")) { + return [] + } + return undefined + } + }) +} + +ensureGraphInterruptHasInterrupts() + +const settingsStore = new Store({ + name: "settings", + cwd: getOpenworkDir() +}) + +function getAutoApproveExecute(): boolean { + return Boolean(settingsStore.get("autoApproveExecute", false)) +} + /** * Generate the full system prompt for the agent. * @@ -61,7 +91,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) @@ -89,7 +119,19 @@ function getModelInstance( } return new ChatOpenAI({ model, - openAIApiKey: apiKey + 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") @@ -163,6 +205,9 @@ export async function createAgentRuntime(options: CreateAgentRuntimeOptions) { The workspace root is: ${workspacePath}` + const autoApproveExecute = getAutoApproveExecute() + const interruptOn = autoApproveExecute ? undefined : { execute: true } + const agent = createDeepAgent({ model, checkpointer, @@ -171,7 +216,7 @@ The workspace root is: ${workspacePath}` // Custom filesystem prompt for absolute paths (requires deepagents update) filesystemSystemPrompt, // Require human approval for all shell commands - interruptOn: { execute: true } + ...(interruptOn ? { interruptOn } : {}) } as Parameters[0]) console.log("[Runtime] Deep agent created with LocalSandbox at:", workspacePath) diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b..a179c24 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", @@ -224,6 +242,16 @@ export function registerModelHandlers(ipcMain: IpcMain): void { store.set("defaultModel", modelId) }) + // Get auto-approve setting for shell commands + ipcMain.handle('settings:getAutoApprove', async () => { + return store.get('autoApproveExecute', false) as boolean + }) + + // Set auto-approve setting for shell commands + ipcMain.handle('settings:setAutoApprove', async (_event, value: boolean) => { + store.set('autoApproveExecute', Boolean(value)) + }) + // Set API key for a provider (stored in ~/.openwork/.env) ipcMain.handle("models:setApiKey", async (_event, { provider, apiKey }: SetApiKeyParams) => { setApiKey(provider, apiKey) 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/preload/index.d.ts b/src/preload/index.d.ts index 51e74c4..7c09fcd 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -53,6 +53,10 @@ interface CustomAPI { setApiKey: (provider: string, apiKey: string) => Promise getApiKey: (provider: string) => Promise } + settings: { + getAutoApproveExecute: () => Promise + setAutoApproveExecute: (value: boolean) => Promise + } workspace: { get: (threadId?: string) => Promise set: (threadId: string | undefined, path: string | null) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ffb2b36..938db03 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -150,6 +150,14 @@ const api = { return ipcRenderer.invoke("models:deleteApiKey", provider) } }, + settings: { + getAutoApproveExecute: (): Promise => { + return ipcRenderer.invoke('settings:getAutoApprove') + }, + setAutoApproveExecute: (value: boolean): Promise => { + return ipcRenderer.invoke('settings:setAutoApprove', value) + } + }, workspace: { get: (threadId?: string): Promise => { return ipcRenderer.invoke("workspace:get", threadId) 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..e51fcd4 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,265 @@ +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' +import { Switch } from '@/components/ui/switch' + +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) + const [autoApproveExecute, setAutoApproveExecute] = useState(false) + const [savingAutoApprove, setSavingAutoApprove] = useState(false) + + // 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) + + try { + const autoApprove = await window.api.settings.getAutoApproveExecute() + setAutoApproveExecute(Boolean(autoApprove)) + } catch (e) { + setAutoApproveExecute(false) + } + + 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] })) + } + + async function handleAutoApproveChange(value: boolean) { + setAutoApproveExecute(value) + setSavingAutoApprove(true) + try { + await window.api.settings.setAutoApproveExecute(value) + } catch (e) { + console.error('Failed to update auto-approve setting:', e) + setAutoApproveExecute(!value) + } finally { + setSavingAutoApprove(false) + } + } + + 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} +

+
+ ))} +
+ )} +
+ + + +
+
AGENT
+
+
+
Auto-approve shell commands
+

+ Run the `execute` tool without manual approval prompts. +

+
+ +
+
+ + + +
+ +
+
+
+ ) +} 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/components/ui/switch.tsx b/src/renderer/src/components/ui/switch.tsx new file mode 100644 index 0000000..6be7cf7 --- /dev/null +++ b/src/renderer/src/components/ui/switch.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import * as SwitchPrimitive from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = 'Switch' + +export { Switch } diff --git a/src/renderer/src/lib/thread-context.tsx b/src/renderer/src/lib/thread-context.tsx index 6368a6c..33f1266 100644 --- a/src/renderer/src/lib/thread-context.tsx +++ b/src/renderer/src/lib/thread-context.tsx @@ -652,6 +652,21 @@ export function ThreadProvider({ children }: { children: ReactNode }) { [getThreadActions, updateThreadState] ) + const loadDefaultModel = useCallback( + async (threadId: string) => { + const actions = getThreadActions(threadId) + try { + const modelId = await window.api.models.getDefault() + if (modelId) { + actions.setCurrentModel(modelId) + } + } catch (error) { + console.error('[ThreadContext] Failed to load default model:', error) + } + }, + [getThreadActions] + ) + const initializeThread = useCallback( (threadId: string) => { if (initializedThreadsRef.current.has(threadId)) return @@ -666,8 +681,9 @@ export function ThreadProvider({ children }: { children: ReactNode }) { }) loadThreadHistory(threadId) + loadDefaultModel(threadId) }, - [loadThreadHistory] + [loadThreadHistory, loadDefaultModel] ) const cleanupThread = useCallback((threadId: string) => { 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