mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): first-run onboarding, OS-language default, native polish
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -54,3 +54,7 @@ desktop/build/*
|
|||||||
!desktop/build/cowagent-backend.spec
|
!desktop/build/cowagent-backend.spec
|
||||||
!desktop/build/requirements-desktop.txt
|
!desktop/build/requirements-desktop.txt
|
||||||
!desktop/build/build-backend.sh
|
!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/
|
||||||
|
|||||||
@@ -60,9 +60,7 @@
|
|||||||
"from": "resources",
|
"from": "resources",
|
||||||
"to": ".",
|
"to": ".",
|
||||||
"filter": [
|
"filter": [
|
||||||
"icon.png",
|
"icon.png"
|
||||||
"trayTemplate.png",
|
|
||||||
"trayTemplate@2x.png"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Binary file not shown.
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 103 KiB |
@@ -7,6 +7,10 @@ import { buildAppMenu } from './menu'
|
|||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
|
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 mainWindow: BrowserWindow | null = null
|
||||||
let pythonBackend: PythonBackend | null = null
|
let pythonBackend: PythonBackend | null = null
|
||||||
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
|
// 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
|
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 isMac = process.platform === 'darwin'
|
||||||
const isWin = process.platform === 'win32'
|
const isWin = process.platform === 'win32'
|
||||||
|
|
||||||
@@ -224,6 +218,12 @@ function setupIPC() {
|
|||||||
ipcMain.handle('update-check', () => checkForUpdates())
|
ipcMain.handle('update-check', () => checkForUpdates())
|
||||||
ipcMain.handle('update-download', () => startDownload())
|
ipcMain.handle('update-download', () => startDownload())
|
||||||
ipcMain.handle('update-install', () => quitAndInstall())
|
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() {
|
function emitMaximizeState() {
|
||||||
@@ -265,17 +265,18 @@ app.whenReady().then(async () => {
|
|||||||
setupIPC()
|
setupIPC()
|
||||||
createWindow()
|
createWindow()
|
||||||
buildAppMenu(() => mainWindow)
|
buildAppMenu(() => mainWindow)
|
||||||
createTray({
|
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
||||||
getWindow: () => mainWindow,
|
// Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
|
||||||
iconPath: getIconPath('png'),
|
if (!isMac) {
|
||||||
// Monochrome menu-bar icon for macOS; falls back to the colored icon when
|
createTray({
|
||||||
// the template asset is absent (see createTray).
|
getWindow: () => mainWindow,
|
||||||
templateIconPath: getTrayTemplatePath(),
|
iconPath: getIconPath('png'),
|
||||||
onQuit: () => {
|
onQuit: () => {
|
||||||
isQuitting = true
|
isQuitting = true
|
||||||
app.quit()
|
app.quit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
await startBackend()
|
await startBackend()
|
||||||
|
|
||||||
// Wire auto-update and do a first silent check a few seconds after launch so
|
// Wire auto-update and do a first silent check a few seconds after launch so
|
||||||
|
|||||||
@@ -50,4 +50,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
platform: process.platform,
|
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 ''
|
||||||
|
}
|
||||||
|
})(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,31 +6,21 @@ interface TrayDeps {
|
|||||||
getWindow: () => BrowserWindow | null
|
getWindow: () => BrowserWindow | null
|
||||||
// Colored icon used on Windows/Linux trays.
|
// Colored icon used on Windows/Linux trays.
|
||||||
iconPath?: string
|
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.
|
// Called when the user picks "Quit" so the app can fully exit.
|
||||||
onQuit: () => void
|
onQuit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a system tray icon with a minimal menu. The tray lets users restore the
|
// Build a system tray icon with a minimal menu (Windows/Linux only — macOS
|
||||||
// window after closing it to the background and start a new chat quickly.
|
// uses the Dock instead). Lets users restore the window after closing it to the
|
||||||
export function createTray({ getWindow, iconPath, templateIconPath, onQuit }: TrayDeps): Tray | null {
|
// background and start a new chat quickly.
|
||||||
|
export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
|
||||||
if (tray) return tray
|
if (tray) return tray
|
||||||
|
if (!iconPath) return null
|
||||||
|
|
||||||
const isMac = process.platform === 'darwin'
|
let image = nativeImage.createFromPath(iconPath)
|
||||||
// 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)
|
|
||||||
if (image.isEmpty()) return null
|
if (image.isEmpty()) return null
|
||||||
// Tray icons render small; resize to avoid an oversized image on some platforms.
|
// Tray icons render small; resize to avoid an oversized image on some platforms.
|
||||||
image = image.resize({ width: 18, height: 18 })
|
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 = new Tray(image)
|
||||||
tray.setToolTip(app.name)
|
tray.setToolTip(app.name)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { usePlatform } from './hooks/usePlatform'
|
|||||||
import { useUIStore } from './store/uiStore'
|
import { useUIStore } from './store/uiStore'
|
||||||
import { useSessionStore } from './store/sessionStore'
|
import { useSessionStore } from './store/sessionStore'
|
||||||
import { initUpdateListener } from './store/updateStore'
|
import { initUpdateListener } from './store/updateStore'
|
||||||
|
import { useOnboardingStore } from './store/onboardingStore'
|
||||||
|
import OnboardingWizard from './components/OnboardingWizard'
|
||||||
import apiClient from './api/client'
|
import apiClient from './api/client'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
@@ -27,12 +29,44 @@ const App: React.FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isWin } = usePlatform()
|
const { isWin } = usePlatform()
|
||||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||||
|
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||||
|
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||||
const [, forceUpdate] = useState(0)
|
const [, forceUpdate] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||||
}, [backend.status, 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).
|
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||||
useEffect(() => initUpdateListener(), [])
|
useEffect(() => initUpdateListener(), [])
|
||||||
|
|
||||||
@@ -62,6 +96,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-base text-content">
|
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||||
|
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
|
||||||
<NavRail onLangChange={handleLangChange} />
|
<NavRail onLangChange={handleLangChange} />
|
||||||
|
|
||||||
{showSessions && <SessionList />}
|
{showSessions && <SessionList />}
|
||||||
|
|||||||
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal file
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<OnboardingWizardProps> = ({ onDone }) => {
|
||||||
|
const finish = useOnboardingStore((s) => s.finish)
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [lang, setLangState] = useState<Lang>(getLang())
|
||||||
|
const [models, setModels] = useState<ModelsData | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-base">
|
||||||
|
<div className="w-full max-w-lg px-8">
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
i + 1 === step ? 'w-8 bg-accent' : i + 1 < step ? 'w-4 bg-accent/50' : 'w-4 bg-surface-2'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||||
|
<Sparkles size={30} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold text-content">{t('onboarding_welcome_title')}</h1>
|
||||||
|
<p className="text-sm text-content-secondary">{t('onboarding_welcome_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-xs mx-auto text-left">
|
||||||
|
<Field label={t('onboarding_lang_label')}>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(['zh', 'en'] as Lang[]).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() => switchLang(l)}
|
||||||
|
className={`px-4 py-2.5 rounded-btn border text-sm font-medium cursor-pointer transition-colors ${
|
||||||
|
lang === l
|
||||||
|
? 'border-accent bg-accent-soft text-accent'
|
||||||
|
: 'border-strong text-content-secondary hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l === 'zh' ? '简体中文' : 'English'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||||
|
<KeyRound size={28} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-content">{t('onboarding_model_title')}</h1>
|
||||||
|
<p className="text-sm text-content-secondary">{t('onboarding_model_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('onboarding_provider')}>
|
||||||
|
<Dropdown
|
||||||
|
value={provider}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder={t('onboarding_select_provider')}
|
||||||
|
onChange={handleProvider}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{provider && (
|
||||||
|
<>
|
||||||
|
<Field label={t('onboarding_apikey')}>
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={t('onboarding_apikey_placeholder')}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
{PROVIDER_KEY_CONSOLE[provider] && (
|
||||||
|
<a
|
||||||
|
href={PROVIDER_KEY_CONSOLE[provider]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('onboarding_key_guide')}
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{providerMeta?.api_base_field && (
|
||||||
|
<Field label={t('onboarding_apibase')}>
|
||||||
|
<TextInput
|
||||||
|
value={apiBase}
|
||||||
|
onChange={(e) => setApiBase(e.target.value)}
|
||||||
|
placeholder={apiBasePlaceholder || ''}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t('onboarding_model')}>
|
||||||
|
<Dropdown
|
||||||
|
value={model}
|
||||||
|
options={modelOptions}
|
||||||
|
placeholder={t('onboarding_select_model')}
|
||||||
|
onChange={setModel}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer controls */}
|
||||||
|
<div className="mt-10 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-content-tertiary">{stepLabel}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Step 2: back to language. */}
|
||||||
|
{step === 2 && (
|
||||||
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={15} />
|
||||||
|
{t('onboarding_back')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Skip is available on every step: dismiss and go straight to chat. */}
|
||||||
|
<button
|
||||||
|
onClick={complete}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-btn text-sm font-medium text-content-tertiary hover:text-content cursor-pointer transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('onboarding_skip')}
|
||||||
|
</button>
|
||||||
|
{/* Primary action: advance on step 1, save + finish on the last step. */}
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!canNext || saving}
|
||||||
|
className="px-5 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 size={15} className="animate-spin" />}
|
||||||
|
{saving
|
||||||
|
? t('onboarding_saving')
|
||||||
|
: step === TOTAL_STEPS
|
||||||
|
? t('onboarding_finish')
|
||||||
|
: t('onboarding_next')}
|
||||||
|
{!saving && <ArrowRight size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnboardingWizard
|
||||||
@@ -37,6 +37,29 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
update_restart: '重启以更新',
|
update_restart: '重启以更新',
|
||||||
update_later: '稍后',
|
update_later: '稍后',
|
||||||
update_latest: '已是最新版本',
|
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: '会话',
|
sessions_title: '会话',
|
||||||
session_new: '新对话',
|
session_new: '新对话',
|
||||||
session_rename: '重命名',
|
session_rename: '重命名',
|
||||||
@@ -310,6 +333,29 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
update_restart: 'Restart to update',
|
update_restart: 'Restart to update',
|
||||||
update_later: 'Later',
|
update_later: 'Later',
|
||||||
update_latest: 'You are up to date',
|
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',
|
sessions_title: 'Chats',
|
||||||
session_new: 'New chat',
|
session_new: 'New chat',
|
||||||
session_rename: 'Rename',
|
session_rename: 'Rename',
|
||||||
@@ -549,7 +595,16 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
|
|
||||||
export type Lang = 'zh' | 'en'
|
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 {
|
export function t(key: string): string {
|
||||||
return translations[currentLang]?.[key] || translations['en']?.[key] || key
|
return translations[currentLang]?.[key] || translations['en']?.[key] || key
|
||||||
|
|||||||
42
desktop/src/renderer/src/store/onboardingStore.ts
Normal file
42
desktop/src/renderer/src/store/onboardingStore.ts
Normal file
@@ -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<OnboardingState>((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 }),
|
||||||
|
}))
|
||||||
@@ -23,6 +23,8 @@ export interface ElectronAPI {
|
|||||||
installUpdate?: () => Promise<void>
|
installUpdate?: () => Promise<void>
|
||||||
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
||||||
platform: string
|
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.
|
// Mirrors UpdateStatus in src/main/updater.ts.
|
||||||
|
|||||||
Reference in New Issue
Block a user