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 ( +
+
+ {/* Progress dots */} +
+ {Array.from({ length: TOTAL_STEPS }).map((_, i) => ( + + ))} +
+ + {step === 1 && ( +
+
+ +
+
+

{t('onboarding_welcome_title')}

+

{t('onboarding_welcome_desc')}

+
+
+ +
+ {(['zh', 'en'] as Lang[]).map((l) => ( + + ))} +
+
+
+
+ )} + + {step === 2 && ( +
+
+
+ +
+

{t('onboarding_model_title')}

+

{t('onboarding_model_desc')}

+
+
+ + + + {provider && ( + <> + + setApiKey(e.target.value)} + placeholder={t('onboarding_apikey_placeholder')} + className="font-mono" + /> + {PROVIDER_KEY_CONSOLE[provider] && ( + + {t('onboarding_key_guide')} + + + )} + + {providerMeta?.api_base_field && ( + + setApiBase(e.target.value)} + placeholder={apiBasePlaceholder || ''} + className="font-mono" + /> + + )} + + + + + )} + {error &&

{error}

} +
+
+ )} + + {/* Footer controls */} +
+
{stepLabel}
+
+ {/* 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.