mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +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/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/
|
||||
|
||||
@@ -60,9 +60,7 @@
|
||||
"from": "resources",
|
||||
"to": ".",
|
||||
"filter": [
|
||||
"icon.png",
|
||||
"trayTemplate.png",
|
||||
"trayTemplate@2x.png"
|
||||
"icon.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 { 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
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
})(),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
|
||||
<NavRail onLangChange={handleLangChange} />
|
||||
|
||||
{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_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<string, Record<string, string>> = {
|
||||
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<string, Record<string, string>> = {
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user