diff --git a/.gitignore b/.gitignore
index c64230cc..1d73ecf6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,7 @@ desktop/build/*
!desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh
+
+# Icon authoring scratch dir: intermediate assets used to produce the final
+# icons. Only the finished icons under desktop/resources/ should be committed.
+desktop/resources/.icon-work/
diff --git a/desktop/package.json b/desktop/package.json
index 68dd56b1..5b1eaff2 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -60,9 +60,7 @@
"from": "resources",
"to": ".",
"filter": [
- "icon.png",
- "trayTemplate.png",
- "trayTemplate@2x.png"
+ "icon.png"
]
}
],
diff --git a/desktop/resources/icon.icns b/desktop/resources/icon.icns
index 367dc94f..be56144e 100644
Binary files a/desktop/resources/icon.icns and b/desktop/resources/icon.icns differ
diff --git a/desktop/resources/icon.ico b/desktop/resources/icon.ico
new file mode 100644
index 00000000..71f45667
Binary files /dev/null and b/desktop/resources/icon.ico differ
diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png
index 81a01b9f..402d75d6 100644
Binary files a/desktop/resources/icon.png and b/desktop/resources/icon.png differ
diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts
index 54d3d9c4..36816f5e 100644
--- a/desktop/src/main/index.ts
+++ b/desktop/src/main/index.ts
@@ -7,6 +7,10 @@ import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
+// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
+// where the default Electron binary would otherwise report "Electron".
+app.setName('CowAgent')
+
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
@@ -43,16 +47,6 @@ function getIconPath(ext: string = 'png'): string | undefined {
return undefined
}
-// Resolve the optional monochrome macOS menu-bar template icon. Returns
-// undefined when the asset isn't present, so the tray falls back gracefully.
-function getTrayTemplatePath(): string | undefined {
- const file = 'trayTemplate.png'
- const p = isDev
- ? path.resolve(__dirname, '../../resources', file)
- : path.join(process.resourcesPath, file)
- return fs.existsSync(p) ? p : undefined
-}
-
const isMac = process.platform === 'darwin'
const isWin = process.platform === 'win32'
@@ -224,6 +218,12 @@ function setupIPC() {
ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload())
ipcMain.handle('update-install', () => quitAndInstall())
+
+ // Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
+ // to pick a sensible default UI language on first run before any paint.
+ ipcMain.on('get-system-locale', (event) => {
+ event.returnValue = app.getLocale() || app.getSystemLocale?.() || ''
+ })
}
function emitMaximizeState() {
@@ -265,17 +265,18 @@ app.whenReady().then(async () => {
setupIPC()
createWindow()
buildAppMenu(() => mainWindow)
- createTray({
- getWindow: () => mainWindow,
- iconPath: getIconPath('png'),
- // Monochrome menu-bar icon for macOS; falls back to the colored icon when
- // the template asset is absent (see createTray).
- templateIconPath: getTrayTemplatePath(),
- onQuit: () => {
- isQuitting = true
- app.quit()
- },
- })
+ // No menu-bar tray on macOS — the Dock + window controls are enough there.
+ // Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
+ if (!isMac) {
+ createTray({
+ getWindow: () => mainWindow,
+ iconPath: getIconPath('png'),
+ onQuit: () => {
+ isQuitting = true
+ app.quit()
+ },
+ })
+ }
await startBackend()
// Wire auto-update and do a first silent check a few seconds after launch so
diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts
index 42cf6e11..3e7de874 100644
--- a/desktop/src/main/preload.ts
+++ b/desktop/src/main/preload.ts
@@ -50,4 +50,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
platform: process.platform,
+ // OS UI language (e.g. "zh-CN"), read synchronously so the renderer can pick
+ // a default language on first run. Falls back to '' if unavailable.
+ systemLocale: (() => {
+ try {
+ return ipcRenderer.sendSync('get-system-locale') as string
+ } catch {
+ return ''
+ }
+ })(),
})
diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts
index a043e767..a26818ac 100644
--- a/desktop/src/main/tray.ts
+++ b/desktop/src/main/tray.ts
@@ -6,31 +6,21 @@ interface TrayDeps {
getWindow: () => BrowserWindow | null
// Colored icon used on Windows/Linux trays.
iconPath?: string
- // Monochrome (black + alpha) template icon for the macOS menu bar; renders
- // correctly in both light and dark menu bars when set as a template image.
- templateIconPath?: string
// Called when the user picks "Quit" so the app can fully exit.
onQuit: () => void
}
-// Build a system tray icon with a minimal menu. The tray lets users restore the
-// window after closing it to the background and start a new chat quickly.
-export function createTray({ getWindow, iconPath, templateIconPath, onQuit }: TrayDeps): Tray | null {
+// Build a system tray icon with a minimal menu (Windows/Linux only — macOS
+// uses the Dock instead). Lets users restore the window after closing it to the
+// background and start a new chat quickly.
+export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
if (tray) return tray
+ if (!iconPath) return null
- const isMac = process.platform === 'darwin'
- // Prefer the monochrome template icon on macOS (menu-bar convention).
- const sourcePath = isMac && templateIconPath ? templateIconPath : iconPath
- if (!sourcePath) return null
-
- let image = nativeImage.createFromPath(sourcePath)
+ let image = nativeImage.createFromPath(iconPath)
if (image.isEmpty()) return null
// Tray icons render small; resize to avoid an oversized image on some platforms.
image = image.resize({ width: 18, height: 18 })
- // A template image must be pure black + alpha; macOS then auto-inverts it for
- // light/dark menu bars. Only mark it as such when we actually loaded the
- // dedicated template asset (a colored icon as template would render as a blob).
- if (isMac && templateIconPath) image.setTemplateImage(true)
tray = new Tray(image)
tray.setToolTip(app.name)
diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx
index adfef1bb..e479924e 100644
--- a/desktop/src/renderer/src/App.tsx
+++ b/desktop/src/renderer/src/App.tsx
@@ -10,6 +10,8 @@ import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore'
import { initUpdateListener } from './store/updateStore'
+import { useOnboardingStore } from './store/onboardingStore'
+import OnboardingWizard from './components/OnboardingWizard'
import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
@@ -27,12 +29,44 @@ const App: React.FC = () => {
const navigate = useNavigate()
const { isWin } = usePlatform()
const { sessionsCollapsed, toggleSessions } = useUIStore()
+ const onboardingOpen = useOnboardingStore((s) => s.open)
+ const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0)
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
+ // First-run check: once the backend is ready, decide whether to show the
+ // onboarding wizard. It's config-driven — shown whenever the chat model isn't
+ // configured (and not dismissed earlier this session); no persisted flag.
+ useEffect(() => {
+ if (backend.status !== 'ready') return
+ let cancelled = false
+ apiClient
+ .getModels()
+ .then((data) => {
+ if (cancelled) return
+ const chat = data.capabilities?.chat
+ // "Configured" needs a chat provider+model AND that provider's API key
+ // set. A default config can ship a model name with no key, which
+ // shouldn't count as ready — otherwise we'd skip onboarding for users
+ // who still need to enter a key.
+ const providerId = chat?.current_provider
+ const provider = data.providers?.find((p) => p.id === providerId)
+ const keyReady = !!provider && (provider.configured || (provider.is_custom && !!provider.custom_name))
+ const configured = !!providerId && !!chat?.current_model && keyReady
+ maybeOpenOnboarding(configured)
+ })
+ .catch(() => {
+ // If models can't be loaded, fall back to the flag-only decision.
+ if (!cancelled) maybeOpenOnboarding(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [backend.status, maybeOpenOnboarding])
+
// Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), [])
@@ -62,6 +96,7 @@ const App: React.FC = () => {
return (
+ {onboardingOpen && }
{showSessions && }
diff --git a/desktop/src/renderer/src/components/OnboardingWizard.tsx b/desktop/src/renderer/src/components/OnboardingWizard.tsx
new file mode 100644
index 00000000..cde8baa2
--- /dev/null
+++ b/desktop/src/renderer/src/components/OnboardingWizard.tsx
@@ -0,0 +1,293 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { Sparkles, KeyRound, Loader2, ArrowRight, ArrowLeft, ExternalLink } from 'lucide-react'
+import { t, getLang, setLang, type Lang } from '../i18n'
+import apiClient from '../api/client'
+import type { ModelsData } from '../types'
+import { Field, Dropdown, TextInput, type DropdownOption } from '../pages/settings/primitives'
+import { resolveModels, providerLabel } from '../pages/settings/modelsHelpers'
+import { useOnboardingStore } from '../store/onboardingStore'
+
+interface OnboardingWizardProps {
+ // Called after the wizard finishes so the host can refresh language/state.
+ onDone: () => void
+}
+
+const TOTAL_STEPS = 2
+
+// Optional "where to get an API key" console link, per provider.
+const PROVIDER_KEY_CONSOLE: Record = {
+ linkai: 'https://link-ai.tech/console/interface',
+}
+
+// First-run guided setup: language -> chat model (provider + key + model).
+// After saving the model the user goes straight into the chat (no extra
+// confirmation step). Rendered as a full-screen overlay above the main UI;
+// reuses the same models API and primitives as the settings page.
+const OnboardingWizard: React.FC = ({ onDone }) => {
+ const finish = useOnboardingStore((s) => s.finish)
+
+ const [step, setStep] = useState(1)
+ const [lang, setLangState] = useState(getLang())
+ const [models, setModels] = useState(null)
+
+ // Step 2 form state.
+ const [provider, setProvider] = useState('')
+ const [apiKey, setApiKey] = useState('')
+ const [apiBase, setApiBase] = useState('')
+ const [model, setModel] = useState('')
+
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Load the models console data once for the provider/model dropdowns.
+ useEffect(() => {
+ apiClient
+ .getModels()
+ .then(setModels)
+ .catch(() => setError(t('onboarding_save_failed')))
+ }, [])
+
+ // Persist the auto-detected default language on first show so the pre-selected
+ // option (driven by OS locale) also reaches the backend, even if the user
+ // doesn't tap the language buttons.
+ useEffect(() => {
+ if (!localStorage.getItem('cow_lang')) switchLang(lang)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const providerOptions: DropdownOption[] = useMemo(() => {
+ const chat = models?.capabilities?.chat
+ const ids = chat?.providers || []
+ return ids.map((id) => ({ value: id, label: providerLabel(models, id) }))
+ }, [models])
+
+ const modelOptions: DropdownOption[] = useMemo(() => {
+ return resolveModels(models, provider, models?.capabilities?.chat?.provider_models).map((o) => ({
+ value: o.value,
+ label: o.value,
+ hint: o.hint,
+ }))
+ }, [models, provider])
+
+ // The currently selected provider's api_base placeholder/default, if any.
+ const providerMeta = models?.providers?.find((p) => p.id === provider)
+ const apiBasePlaceholder = providerMeta?.api_base_placeholder || providerMeta?.api_base_default
+
+ const handleProvider = (id: string) => {
+ setProvider(id)
+ setApiBase('')
+ const first = resolveModels(models, id, models?.capabilities?.chat?.provider_models)[0]
+ setModel(first?.value || '')
+ }
+
+ const switchLang = (next: Lang) => {
+ setLang(next)
+ setLangState(next)
+ // Mirror the choice to the backend so the agent/logs use the same language
+ // (matches BasicSettings). Non-blocking: the UI already switched locally.
+ apiClient.updateConfig({ cow_lang: next }).catch(() => {})
+ }
+
+ // Step 1 (language) can always advance; step 2 needs a provider, key, model.
+ const canNext = step === 1 || (!!provider && !!apiKey.trim() && !!model)
+
+ const goNext = async () => {
+ setError('')
+ // Step 1 (language) just advances to the model step.
+ if (step === 1) {
+ setStep(2)
+ return
+ }
+ // Step 2 is the last step: persist the provider credentials, point the chat
+ // capability at it, then finish straight into the chat (no extra step).
+ setSaving(true)
+ try {
+ await apiClient.modelsAction({
+ action: 'set_provider',
+ provider_id: provider,
+ api_key: apiKey.trim(),
+ ...(apiBase.trim() ? { api_base: apiBase.trim() } : {}),
+ })
+ await apiClient.modelsAction({
+ action: 'set_capability',
+ capability: 'chat',
+ provider_id: provider,
+ model,
+ })
+ } catch {
+ setSaving(false)
+ setError(t('onboarding_save_failed'))
+ return
+ }
+ setSaving(false)
+ complete()
+ }
+
+ const goBack = () => {
+ setError('')
+ setStep((s) => Math.max(1, s - 1))
+ }
+
+ const complete = () => {
+ finish()
+ onDone()
+ }
+
+ const stepLabel = t('onboarding_step').replace('{n}', String(step)).replace('{total}', String(TOTAL_STEPS))
+
+ return (
+
+ {/* Step 2: back to language. */}
+ {step === 2 && (
+
+ )}
+ {/* Skip is available on every step: dismiss and go straight to chat. */}
+
+ {/* Primary action: advance on step 1, save + finish on the last step. */}
+
+
+
+
+
+ )
+}
+
+export default OnboardingWizard
diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts
index a786c365..3aa79317 100644
--- a/desktop/src/renderer/src/i18n.ts
+++ b/desktop/src/renderer/src/i18n.ts
@@ -37,6 +37,29 @@ const translations: Record> = {
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
+ // onboarding
+ onboarding_welcome_title: '欢迎使用 CowAgent',
+ onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
+ onboarding_lang_label: '界面语言',
+ onboarding_model_title: '配置对话模型',
+ onboarding_model_desc: '选择模型厂商并填入 API Key,即可开始使用。',
+ onboarding_provider: '模型厂商',
+ onboarding_select_provider: '选择厂商',
+ onboarding_apikey: 'API Key',
+ onboarding_apikey_placeholder: '输入 API 密钥',
+ onboarding_key_guide: '没有秘钥?前往创建',
+ onboarding_apibase: 'API 地址(可选)',
+ onboarding_model: '模型',
+ onboarding_select_model: '选择模型',
+ onboarding_done_title: '一切就绪',
+ onboarding_done_desc: '配置完成,开始你的第一次对话吧。',
+ onboarding_next: '下一步',
+ onboarding_back: '上一步',
+ onboarding_skip: '跳过',
+ onboarding_finish: '开始对话',
+ onboarding_step: '第 {n} / {total} 步',
+ onboarding_saving: '保存中...',
+ onboarding_save_failed: '保存失败,请检查后重试',
sessions_title: '会话',
session_new: '新对话',
session_rename: '重命名',
@@ -310,6 +333,29 @@ const translations: Record> = {
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
+ // onboarding
+ onboarding_welcome_title: 'Welcome to CowAgent',
+ onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
+ onboarding_lang_label: 'Language',
+ onboarding_model_title: 'Set up your chat model',
+ onboarding_model_desc: 'Pick a provider and paste its API key to get started.',
+ onboarding_provider: 'Provider',
+ onboarding_select_provider: 'Select a provider',
+ onboarding_apikey: 'API Key',
+ onboarding_apikey_placeholder: 'Enter your API key',
+ onboarding_key_guide: 'No key yet? Create one',
+ onboarding_apibase: 'API base (optional)',
+ onboarding_model: 'Model',
+ onboarding_select_model: 'Select a model',
+ onboarding_done_title: 'All set',
+ onboarding_done_desc: 'Setup complete. Start your first conversation.',
+ onboarding_next: 'Next',
+ onboarding_back: 'Back',
+ onboarding_skip: 'Skip',
+ onboarding_finish: 'Start chatting',
+ onboarding_step: 'Step {n} of {total}',
+ onboarding_saving: 'Saving...',
+ onboarding_save_failed: 'Save failed, please check and retry',
sessions_title: 'Chats',
session_new: 'New chat',
session_rename: 'Rename',
@@ -549,7 +595,16 @@ const translations: Record> = {
export type Lang = 'zh' | 'en'
-let currentLang: Lang = (localStorage.getItem('cow_lang') as Lang) || 'zh'
+// First-run default: follow the OS language so zh-* systems start in Chinese
+// and everyone else in English. Once the user picks a language it's persisted
+// in cow_lang and always wins. Falls back to 'en' when the locale is unknown.
+function detectDefaultLang(): Lang {
+ const locale = (window.electronAPI?.systemLocale || navigator.language || '').toLowerCase()
+ return locale.startsWith('zh') ? 'zh' : 'en'
+}
+
+const savedLang = localStorage.getItem('cow_lang') as Lang | null
+let currentLang: Lang = savedLang === 'zh' || savedLang === 'en' ? savedLang : detectDefaultLang()
export function t(key: string): string {
return translations[currentLang]?.[key] || translations['en']?.[key] || key
diff --git a/desktop/src/renderer/src/store/onboardingStore.ts b/desktop/src/renderer/src/store/onboardingStore.ts
new file mode 100644
index 00000000..3da27df6
--- /dev/null
+++ b/desktop/src/renderer/src/store/onboardingStore.ts
@@ -0,0 +1,42 @@
+import { create } from 'zustand'
+
+// Onboarding is config-driven: the wizard auto-opens whenever the chat model
+// isn't configured yet, and stops appearing once it is — no persisted "seen"
+// flag that could strand a user who skipped without finishing setup.
+//
+// `dismissedThisSession` is an in-memory guard so that skipping doesn't
+// immediately re-open the wizard within the same run; it resets on relaunch,
+// so an unconfigured app will guide the user again next time.
+
+interface OnboardingState {
+ // Whether the wizard overlay is currently visible.
+ open: boolean
+ // True if the user skipped/finished during THIS app session (not persisted).
+ dismissedThisSession: boolean
+ // Decide whether to auto-open on launch. Opens only when chat isn't
+ // configured AND it wasn't dismissed earlier this session.
+ maybeOpen: (chatConfigured: boolean) => void
+ // Open manually (e.g. from a "setup guide" entry point later).
+ openWizard: () => void
+ // Finish/skip: close and don't auto-reopen this session.
+ finish: () => void
+ // Close without marking dismissed (rarely used; kept for symmetry).
+ close: () => void
+}
+
+export const useOnboardingStore = create((set) => ({
+ open: false,
+ dismissedThisSession: false,
+
+ maybeOpen: (chatConfigured) =>
+ set((s) => {
+ if (chatConfigured || s.dismissedThisSession) return { open: false }
+ return { open: true }
+ }),
+
+ openWizard: () => set({ open: true }),
+
+ finish: () => set({ open: false, dismissedThisSession: true }),
+
+ close: () => set({ open: false }),
+}))
diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts
index 3070d938..50018b4e 100644
--- a/desktop/src/renderer/src/types.ts
+++ b/desktop/src/renderer/src/types.ts
@@ -23,6 +23,8 @@ export interface ElectronAPI {
installUpdate?: () => Promise
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string
+ // OS UI language (e.g. "zh-CN"); used to default the language on first run.
+ systemLocale?: string
}
// Mirrors UpdateStatus in src/main/updater.ts.