Compare commits

...

4 Commits

Author SHA1 Message Date
zhayujie
a67cae69ba Merge branch 'feat-theme-system' 2026-07-21 18:13:29 +08:00
zhayujie
b64733d64a feat(desktop): add client extension points 2026-07-21 18:13:14 +08:00
zhayujie
76d6186bd3 docs: update 2.1.4 release overview 2026-07-20 18:00:46 +08:00
zhayujie
e8d9fb4e51 feat(desktop): add data-driven theme system 2026-07-19 18:37:06 +08:00
28 changed files with 1222 additions and 81 deletions

3
desktop/.gitignore vendored
View File

@@ -4,3 +4,6 @@ release/
*.log
.DS_Store
Thumbs.db
flavors/
resources/app-config.json
resources/themes/

View File

@@ -15,7 +15,10 @@
"build:renderer": "vite build",
"dist": "npm run build && electron-builder",
"dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win"
"dist:win": "npm run build && electron-builder --win",
"flavor:apply": "node scripts/apply-flavor.mjs",
"flavor:clear": "node scripts/apply-flavor.mjs --clear",
"dist:flavor": "node scripts/apply-flavor.mjs $FLAVOR && npm run build && electron-builder && node scripts/apply-flavor.mjs --clear"
},
"dependencies": {
"electron-updater": "^6.8.9",
@@ -60,7 +63,9 @@
"from": "resources",
"to": ".",
"filter": [
"icon.png"
"icon.png",
"app-config.json",
"themes/**"
]
}
],

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
// Stage or clear a flavor into resources/ before packaging.
//
// A flavor lives in flavors/<name>/ and may contain:
// app-config.json → copied to resources/app-config.json
// themes/<id>/... → copied to resources/themes/<id>/...
//
// Usage:
// node scripts/apply-flavor.mjs <name> # stage flavors/<name> into resources/
// node scripts/apply-flavor.mjs --clear # remove staged app-config.json + themes/
//
// The standard build ships neither app-config.json nor resources/themes, so it
// keeps the default theme and free switching. Always --clear after a flavored
// build so the repo's resources/ stays clean.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, '..')
const resourcesDir = path.join(root, 'resources')
const configFile = path.join(resourcesDir, 'app-config.json')
const resThemesDir = path.join(resourcesDir, 'themes')
function rimraf(target) {
fs.rmSync(target, { recursive: true, force: true })
}
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true })
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name)
const d = path.join(dest, entry.name)
if (entry.isDirectory()) copyDir(s, d)
else fs.copyFileSync(s, d)
}
}
function clear() {
rimraf(configFile)
rimraf(resThemesDir)
console.log('[flavor] cleared resources/app-config.json and resources/themes')
}
function apply(name) {
const flavorDir = path.join(root, 'flavors', name)
if (!fs.existsSync(flavorDir)) {
console.error(`[flavor] no such flavor: flavors/${name}`)
process.exit(1)
}
clear()
const srcConfig = path.join(flavorDir, 'app-config.json')
if (fs.existsSync(srcConfig)) {
fs.mkdirSync(resourcesDir, { recursive: true })
fs.copyFileSync(srcConfig, configFile)
console.log(`[flavor] staged app-config.json from flavors/${name}`)
}
const srcThemes = path.join(flavorDir, 'themes')
if (fs.existsSync(srcThemes)) {
copyDir(srcThemes, resThemesDir)
console.log(`[flavor] staged themes from flavors/${name}`)
}
}
const arg = process.argv[2]
if (!arg || arg === '--clear') clear()
else apply(arg)

View File

@@ -0,0 +1,92 @@
import { ipcMain, net } from 'electron'
// A small HTTP relay so the renderer can reach external HTTPS endpoints from
// the main process (the file:// renderer origin is otherwise blocked by CORS).
// It's deliberately generic and carries no product-specific knowledge; any
// optional extension can use it. Requests are limited to https to avoid it
// becoming an open local proxy.
export interface RelayRequest {
url: string
method?: string
headers?: Record<string, string>
// Stringified body (callers serialize JSON/form themselves).
body?: string
}
export interface RelayResponse {
ok: boolean
status: number
headers: Record<string, string>
body: string
}
const MAX_BODY_BYTES = 8 * 1024 * 1024
function relay(req: RelayRequest): Promise<RelayResponse> {
return new Promise((resolve, reject) => {
let parsed: URL
try {
parsed = new URL(req.url)
} catch {
reject(new Error('invalid url'))
return
}
if (parsed.protocol !== 'https:') {
reject(new Error('only https is allowed'))
return
}
const request = net.request({
method: req.method || 'GET',
url: req.url,
})
if (req.headers) {
for (const [k, v] of Object.entries(req.headers)) request.setHeader(k, v)
}
request.on('response', (response) => {
const chunks: Buffer[] = []
let size = 0
let aborted = false
response.on('data', (chunk: Buffer) => {
if (aborted) return
size += chunk.length
if (size > MAX_BODY_BYTES) {
aborted = true
reject(new Error('response too large'))
return
}
chunks.push(chunk)
})
response.on('end', () => {
if (aborted) return
const headers: Record<string, string> = {}
for (const [k, v] of Object.entries(response.headers)) {
headers[k] = Array.isArray(v) ? v.join(', ') : String(v)
}
const status = response.statusCode || 0
resolve({
ok: status >= 200 && status < 300,
status,
headers,
body: Buffer.concat(chunks).toString('utf8'),
})
})
})
request.on('error', (err) => reject(err))
if (req.body != null) request.write(req.body)
request.end()
})
}
export function setupHttpRelayIPC() {
ipcMain.handle('http-relay', async (_event, req: RelayRequest) => {
try {
return await relay(req)
} catch (e) {
return { ok: false, status: 0, headers: {}, body: String((e as Error).message) }
}
})
}

View File

@@ -6,10 +6,13 @@ import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
import { setupThemeIPC, loadAppConfig } from './themes'
import { setupHttpRelayIPC } from './http-relay'
// 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')
// Force the product name so the Dock/menu shows the app name even in dev mode,
// where the default Electron binary would otherwise report "Electron". The name
// can be overridden by the bundled app-config (appName); defaults to CowAgent.
app.setName(loadAppConfig()?.appName || 'CowAgent')
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
@@ -306,6 +309,8 @@ app.whenReady().then(async () => {
}
setupIPC()
setupThemeIPC()
setupHttpRelayIPC()
createWindow()
buildAppMenu(() => mainWindow)
// No menu-bar tray on macOS — the Dock + window controls are enough there.

View File

@@ -43,6 +43,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// Themes (bundled + user themes from ~/.cow/themes), assets inlined.
listThemes: () => ipcRenderer.invoke('themes-list') as Promise<Record<string, unknown>[]>,
getThemesDir: () => ipcRenderer.invoke('themes-dir') as Promise<string>,
// Optional app config (first-run default theme + display name). Null in
// the standard build.
getAppConfig: () =>
ipcRenderer.invoke('app-config-get') as Promise<{ defaultTheme?: string; appName?: string } | null>,
// Generic HTTPS relay via the main process (bypasses the renderer's CORS
// restrictions for external endpoints). Optional extensions may use it.
httpRelay: (req: {
url: string
method?: string
headers?: Record<string, string>
body?: string
}) =>
ipcRenderer.invoke('http-relay', req) as Promise<{
ok: boolean
status: number
headers: Record<string, string>
body: string
}>,
// Auto-update: trigger checks/download/install and subscribe to status. The
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),

198
desktop/src/main/themes.ts Normal file
View File

@@ -0,0 +1,198 @@
import { app, ipcMain } from 'electron'
import path from 'path'
import fs from 'fs'
import os from 'os'
// Themes come from two sources and are merged at load time:
//
// 1. Bundled themes — resources/themes/ shipped inside the app package
// (read-only). Present only in builds produced from a flavor; absent
// in the standard build.
// 2. User themes — ~/.cow/themes/, the shared data dir users can add to
// (via a future in-app store/import). Each theme is its own folder:
//
// <id>/
// ├── theme.json (required)
// ├── wallpaper.jpg (optional)
// └── logo.svg (optional)
//
// An optional app config (resources/app-config.json) can set a first-run
// default theme and a display name. When it's absent the app behaves exactly
// as the standard build (default theme, free switching).
const THEMES_DIRNAME = 'themes'
// Max bytes for an inlined image (mirrors the theme spec limit).
const MAX_IMAGE_BYTES = 16 * 1024 * 1024
const IMAGE_MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.gif': 'image/gif',
}
function cowRoot(): string {
// Honor an explicit override, else default to ~/.cow to match the backend.
return process.env.COW_HOME || path.join(os.homedir(), '.cow')
}
export function themesDir(): string {
return path.join(cowRoot(), THEMES_DIRNAME)
}
// Directory holding themes bundled inside the app package (read-only). In dev
// it maps to the repo's resources/; in a packaged app to process.resourcesPath.
// Returns null when that folder doesn't exist (the standard build).
function bundledThemesDir(): string | null {
const base = app.isPackaged
? path.join(process.resourcesPath, THEMES_DIRNAME)
: path.resolve(__dirname, '../../resources', THEMES_DIRNAME)
try {
return fs.statSync(base).isDirectory() ? base : null
} catch {
return null
}
}
// Optional app config bundled with the app. Absent in the standard build.
export interface AppConfig {
defaultTheme?: string
appName?: string
// Optional override for the auto-update feed base URL. When set, the updater
// uses it as-is instead of the default build's feed.
updateFeedUrl?: string
}
function appConfigPath(): string {
return app.isPackaged
? path.join(process.resourcesPath, 'app-config.json')
: path.resolve(__dirname, '../../resources', 'app-config.json')
}
export function loadAppConfig(): AppConfig | null {
try {
const raw = fs.readFileSync(appConfigPath(), 'utf8')
const parsed = JSON.parse(raw) as AppConfig
if (!parsed || typeof parsed !== 'object') return null
return parsed
} catch {
return null // no app config → standard behavior
}
}
function ensureThemesDir(): string {
const dir = themesDir()
try {
fs.mkdirSync(dir, { recursive: true })
} catch {
// Non-fatal: scanning will simply return no themes.
}
return dir
}
// Read an image inside a theme folder and return a data: URL, or null. The
// path is constrained to the theme folder to avoid escaping via '..'.
function inlineImage(themeFolder: string, rel: string): string | null {
if (!rel || typeof rel !== 'string') return null
const resolved = path.resolve(themeFolder, rel)
if (resolved !== themeFolder && !resolved.startsWith(themeFolder + path.sep)) return null
let stat: fs.Stats
try {
stat = fs.statSync(resolved)
} catch {
return null
}
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null
const ext = path.extname(resolved).toLowerCase()
const mime = IMAGE_MIME[ext]
if (!mime) return null
try {
const buf = fs.readFileSync(resolved)
return `data:${mime};base64,${buf.toString('base64')}`
} catch {
return null
}
}
// Walk a theme object and replace any wallpaper/logo image *file references*
// with inlined data URLs so the renderer can use them directly.
function inlineThemeAssets(theme: Record<string, unknown>, themeFolder: string) {
for (const appearance of ['light', 'dark'] as const) {
const app_ = theme[appearance] as Record<string, unknown> | undefined
const wp = app_?.wallpaper as Record<string, unknown> | undefined
if (wp && typeof wp.image === 'string') {
const url = inlineImage(themeFolder, wp.image)
if (url) wp.image = url
else delete wp.image
}
}
const identity = theme.identity as Record<string, unknown> | undefined
if (identity && typeof identity.logo === 'string') {
const url = inlineImage(themeFolder, identity.logo)
if (url) identity.logo = url
else delete identity.logo
}
}
// Scan one directory for theme folders and return validated, asset-inlined themes.
function scanDir(dir: string): Record<string, unknown>[] {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return []
}
const themes: Record<string, unknown>[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const folder = path.join(dir, entry.name)
const jsonPath = path.join(folder, 'theme.json')
let raw: string
try {
raw = fs.readFileSync(jsonPath, 'utf8')
} catch {
continue // no theme.json in this folder
}
let theme: Record<string, unknown>
try {
theme = JSON.parse(raw)
} catch (e) {
console.warn(`[themes] invalid theme.json in ${entry.name}:`, (e as Error).message)
continue
}
// Default the id to the folder name so it's always stable/unique.
if (!theme.id || typeof theme.id !== 'string') theme.id = entry.name
if (!theme.name || typeof theme.name !== 'string') theme.name = String(theme.id)
inlineThemeAssets(theme, folder)
themes.push(theme)
}
return themes
}
// Merge bundled themes (read-only, shipped in the package) with user themes
// (~/.cow/themes). Bundled themes take precedence on id conflicts so a shipped
// theme can't be shadowed by a user folder of the same id.
export function loadThemes(): Record<string, unknown>[] {
ensureThemesDir()
const byId = new Map<string, Record<string, unknown>>()
for (const theme of scanDir(themesDir())) byId.set(String(theme.id), theme)
const bundled = bundledThemesDir()
if (bundled) {
for (const theme of scanDir(bundled)) byId.set(String(theme.id), theme)
}
return [...byId.values()]
}
export function setupThemeIPC() {
ipcMain.handle('themes-list', () => {
try {
return loadThemes()
} catch (e) {
console.warn('[themes] load failed:', (e as Error).message)
return []
}
})
ipcMain.handle('themes-dir', () => themesDir())
ipcMain.handle('app-config-get', () => loadAppConfig())
}

View File

@@ -7,6 +7,7 @@ import path from 'path'
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
// whereas `import pkg from 'electron-updater'` yields undefined.
import { autoUpdater } from 'electron-updater'
import { loadAppConfig } from './themes'
// Status payloads pushed to the renderer over the 'update-status' channel.
// The renderer drives the NavRail badge + update panel from these.
@@ -33,6 +34,11 @@ function isLegacyWindows(): boolean {
return Number.isFinite(major) && major < 10
}
// A bundled app-config may point the updater at a different feed origin. When
// set, that single URL is used as-is (no China/R2 dual-origin switching, which
// is specific to the default build's infrastructure). Absent -> default feed.
const CONFIGURED_FEED = (loadAppConfig()?.updateFeedUrl || '').trim()
// The update feed. Both entries hit the same Pages Function
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
// downloads to the China CDN mirror instead of R2. The feed metadata is
@@ -40,7 +46,10 @@ function isLegacyWindows(): boolean {
// to fall back from one download origin to the other. Legacy Windows appends a
// /legacy/ segment so it gets the win-legacy release instead of the standard.
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
const feedUrlFor = (china: boolean) => {
if (CONFIGURED_FEED) return CONFIGURED_FEED
return china ? `${FEED_BASE}?lang=zh` : FEED_BASE
}
// Which origin the current session prefers, derived from the app UI language
// (zh -> China mirror). Downloads that fail on the preferred origin retry once

View File

@@ -23,6 +23,7 @@ import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
import { product } from '@product'
const App: React.FC = () => {
const backend = useBackend()
@@ -37,6 +38,11 @@ const App: React.FC = () => {
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
// the main UI render.
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
const [productAuthed, setProductAuthed] = useState(false)
// Optional gate provided by '@product'. `product.auth` is constant for the
// whole build, so calling its hook conditionally is stable across renders.
// eslint-disable-next-line react-hooks/rules-of-hooks
const productRequiresAuth = product.auth ? product.auth.useRequiresAuth() : false
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
@@ -72,6 +78,8 @@ const App: React.FC = () => {
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready' || authState !== 'ok') return
// An extension may opt out of the built-in setup wizard.
if (product.onboarding?.enabled === false) return
let cancelled = false
apiClient
.getModels()
@@ -130,8 +138,14 @@ const App: React.FC = () => {
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
}
// Optional gate from '@product', shown after the local auth check passes.
// Rendered inside the layout (nav rail stays visible) so the app's features
// are on display while the login card sits in the content area.
const ProductGate = product.auth?.Gate
const showProductGate = !!(ProductGate && productRequiresAuth && !productAuthed)
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed
const showSessions = isChat && !sessionsCollapsed && !showProductGate
return (
<div className="flex h-screen overflow-hidden bg-base text-content">
@@ -156,11 +170,19 @@ const App: React.FC = () => {
</button>
)}
<div className="flex-1 min-w-0" />
{product.slots?.HeaderRight && (
<div className="titlebar-no-drag flex items-center">
<product.slots.HeaderRight />
</div>
)}
{isWin && <WindowControls />}
</header>
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
{showProductGate && ProductGate ? (
<ProductGate onAuthenticated={() => setProductAuthed(true)} />
) : (
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
@@ -172,7 +194,11 @@ const App: React.FC = () => {
{/* Legacy /models route now lives as a tab inside settings */}
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
{product.routes?.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
))}
</Routes>
)}
</div>
</div>
</div>

View File

@@ -1,57 +1,228 @@
import { useState, useEffect, useCallback } from 'react'
import {
COLOR_KEYS,
DEFAULT_THEME_ID,
getAllThemes,
getTheme,
registerRuntimeThemes,
SHAPE_KEYS,
tokenToCssVar,
type Theme,
type Wallpaper,
} from '../theme/themes'
export type ThemePref = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const STORAGE_KEY = 'cow_theme'
// Appearance preference (light/dark/system).
const PREF_KEY = 'cow_theme'
// Selected theme id (which visual theme is active).
const THEME_ID_KEY = 'cow_theme_id'
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function readStored(): ThemePref {
const saved = localStorage.getItem(STORAGE_KEY)
function readStoredPref(): ThemePref {
const saved = localStorage.getItem(PREF_KEY)
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
// First run: follow the OS appearance rather than forcing a fixed theme.
return 'system'
}
function applyTheme(resolved: ResolvedTheme) {
function readStoredThemeId(): string {
return localStorage.getItem(THEME_ID_KEY) || DEFAULT_THEME_ID
}
// Whether the user has ever explicitly chosen a theme. Used so a bundled
// first-run default only applies when the user hasn't made a choice yet.
function hasStoredThemeId(): boolean {
return localStorage.getItem(THEME_ID_KEY) != null
}
// All CSS variables a theme may inject, so we can fully reset before
// applying one (prevents stale overrides from leaking across switches).
const WALLPAPER_VARS = [
'--wallpaper-image',
'--wallpaper-position',
'--wallpaper-overlay',
'--glass-fill',
'--glass-blur',
'--surface-alpha',
] as const
function hexToRgb(hex: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
// Apply the resolved appearance (light/dark) as the .dark class, plus the
// active theme's overrides. Themes are pure data: we only write contract
// tokens as inline CSS variables on <html> (and toggle a wallpaper flag on
// <body>), so components restyle without any code change.
function applyAppearanceAndTheme(resolved: ResolvedTheme, themeId: string) {
const root = document.documentElement
root.classList.toggle('dark', resolved === 'dark')
const theme = getTheme(themeId)
if (theme.id === DEFAULT_THEME_ID) root.removeAttribute('data-theme')
else root.setAttribute('data-theme', theme.id)
activeSurface = {
light: theme.light?.colors?.bgSurface,
dark: theme.dark?.colors?.bgSurface,
}
// Reset everything a theme can touch, then re-apply the active one.
for (const key of COLOR_KEYS) root.style.removeProperty(tokenToCssVar(key))
for (const key of SHAPE_KEYS) root.style.removeProperty(tokenToCssVar(key))
for (const v of WALLPAPER_VARS) root.style.removeProperty(v)
// Shape tokens are appearance-independent.
if (theme.shape) {
for (const [k, v] of Object.entries(theme.shape)) {
if (v) root.style.setProperty(tokenToCssVar(k), v)
}
}
const appearance = resolved === 'dark' ? theme.dark : theme.light
if (appearance?.colors) {
for (const [k, v] of Object.entries(appearance.colors)) {
if (v) root.style.setProperty(tokenToCssVar(k), v)
}
}
applyWallpaper(resolved, appearance?.wallpaper)
}
// Render (or clear) the ambient wallpaper + frosted-glass panels.
function applyWallpaper(resolved: ResolvedTheme, wp?: Wallpaper) {
const root = document.documentElement
const body = document.body
if (!wp?.image) {
body.removeAttribute('data-wallpaper')
return
}
body.setAttribute('data-wallpaper', 'on')
root.style.setProperty('--wallpaper-image', `url("${wp.image}")`)
const fx = clamp01(wp.focusX ?? 0.5) * 100
const fy = clamp01(wp.focusY ?? 0.5) * 100
root.style.setProperty('--wallpaper-position', `${fx}% ${fy}%`)
// Default scrim: darker in dark mode, lighter in light mode.
const opacity = clamp01(wp.overlayOpacity ?? (resolved === 'dark' ? 0.4 : 0.5))
const scrim = resolved === 'dark' ? '0, 0, 0' : '255, 255, 255'
root.style.setProperty('--wallpaper-overlay', `rgba(${scrim}, ${opacity})`)
if (wp.glass) {
// Frosted glass: surfaces become translucent (so the wallpaper shows
// through) + blurred. We derive the tint from the theme's own surface
// color so it stays on-palette, then drive alpha via --surface-alpha.
const themeSurface =
(resolved === 'dark' ? getThemeSurface('dark') : getThemeSurface('light')) ??
(resolved === 'dark' ? '#141416' : '#ffffff')
const rgb = hexToRgb(themeSurface) ?? (resolved === 'dark' ? [20, 20, 22] : [255, 255, 255])
const alpha = resolved === 'dark' ? 0.55 : 0.62
root.style.setProperty('--glass-fill', `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`)
root.style.setProperty('--glass-blur', '20px')
// Also make the generic surface tokens translucent so existing cards
// (bg-surface) read as glass without touching component code.
root.style.setProperty('--surface-alpha', String(alpha))
}
}
// Cache of the active theme's surface color for glass tinting.
let activeSurface: { light?: string; dark?: string } = {}
function getThemeSurface(mode: 'light' | 'dark'): string | undefined {
return activeSurface[mode]
}
function clamp01(n: number): number {
return Math.min(1, Math.max(0, n))
}
// Apply the persisted appearance + theme once, before React renders, so the
// first paint already has the right colors (no flash of the default theme).
export function initThemeEarly() {
const pref = readStoredPref()
const resolved: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
applyAppearanceAndTheme(resolved, readStoredThemeId())
}
export function useTheme() {
const [pref, setPref] = useState<ThemePref>(readStored)
const [pref, setPref] = useState<ThemePref>(readStoredPref)
const [themeId, setThemeIdState] = useState<string>(readStoredThemeId)
const [themes, setThemes] = useState<Theme[]>(getAllThemes)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
readStoredPref() === 'system' ? getSystemTheme() : (readStoredPref() as ResolvedTheme)
)
// Display name; a bundled app config may override the default.
const [appName, setAppName] = useState<string>('CowAgent')
// Snapshot before any effect persists a value, so we can tell a genuine
// first run (no prior choice) from a user who explicitly picked a theme.
const [firstRun] = useState(() => !hasStoredThemeId())
// Load themes (bundled + user) and the optional app config once on mount.
// On a genuine first run, apply the config's default theme if one is set;
// otherwise just re-apply the current selection now its definition is loaded.
useEffect(() => {
let cancelled = false
Promise.all([
window.electronAPI?.listThemes?.() ?? Promise.resolve([]),
window.electronAPI?.getAppConfig?.() ?? Promise.resolve(null),
])
.then(([remote, config]) => {
if (cancelled) return
registerRuntimeThemes(remote)
setThemes(getAllThemes())
if (config?.appName) setAppName(config.appName)
// First-run default from the app config (if the theme exists).
if (firstRun && config?.defaultTheme && getTheme(config.defaultTheme).id === config.defaultTheme) {
setThemeIdState(config.defaultTheme) // triggers the apply effect below
return
}
// Re-apply the current selection now that its definition is loaded.
const next: ResolvedTheme = readStoredPref() === 'system' ? getSystemTheme() : (readStoredPref() as ResolvedTheme)
applyAppearanceAndTheme(next, readStoredThemeId())
})
.catch(() => {})
return () => {
cancelled = true
}
}, [firstRun])
// Re-apply whenever the appearance preference or theme changes.
useEffect(() => {
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
setResolved(next)
applyTheme(next)
localStorage.setItem(STORAGE_KEY, pref)
}, [pref])
applyAppearanceAndTheme(next, themeId)
localStorage.setItem(PREF_KEY, pref)
localStorage.setItem(THEME_ID_KEY, themeId)
}, [pref, themeId])
// Follow system changes only when preference is "system"
// Follow system changes only when preference is "system".
useEffect(() => {
if (pref !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const next = getSystemTheme()
setResolved(next)
applyTheme(next)
applyAppearanceAndTheme(next, themeId)
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [pref])
}, [pref, themeId])
const toggleTheme = useCallback(() => {
setPref(resolved === 'dark' ? 'light' : 'dark')
}, [resolved])
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
const setThemeId = useCallback((next: string) => setThemeIdState(next), [])
return { theme: resolved, pref, toggleTheme, setTheme }
return { theme: resolved, pref, themeId, themes, appName, toggleTheme, setTheme, setThemeId }
}

View File

@@ -85,6 +85,7 @@ const translations: Record<string, Record<string, string>> = {
menu_more: '更多',
menu_theme_light: '浅色模式',
menu_theme_dark: '深色模式',
menu_theme_picker: '主题',
menu_language: '语言',
menu_website: '官网',
menu_docs: '文档中心',
@@ -477,6 +478,7 @@ const translations: Record<string, Record<string, string>> = {
menu_more: 'More',
menu_theme_light: 'Light mode',
menu_theme_dark: 'Dark mode',
menu_theme_picker: 'Theme',
menu_language: 'Language',
menu_website: 'Website',
menu_docs: 'Documentation',

View File

@@ -52,6 +52,27 @@
--ai-bubble-bg: transparent;
--message-gap: 16px;
/* Shape tokens — themable (radius / fonts). Kept as variables so a
theme can reshape the whole UI without touching components. */
--radius-card: 12px;
--radius-btn: 8px;
--radius-sm: 6px;
--font-sans: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* Wallpaper layer — empty by default (solid theme). A theme sets these
to render an ambient background image behind the app. */
--wallpaper-image: none;
--wallpaper-position: 50% 50%;
--wallpaper-overlay: rgba(255, 255, 255, 0);
/* Panel fill used by .glass surfaces; opaque by default so non-glass
themes look identical to today. */
--glass-fill: var(--bg-surface);
--glass-blur: 0px;
/* 1 = fully opaque surfaces (default). A glass wallpaper theme lowers this
so bg-surface panels turn translucent and reveal the wallpaper. */
--surface-alpha: 1;
color-scheme: light;
}
@@ -107,6 +128,93 @@ body {
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
}
/* ============================================================
Wallpaper layer — ambient background behind the whole app.
Rendered as a fixed pseudo-element so it never scrolls and
sits under all content. Invisible unless a theme sets
--wallpaper-image (default 'none').
============================================================ */
#root {
position: relative;
isolation: isolate;
}
body[data-wallpaper='on']::before {
content: '';
position: fixed;
inset: 0;
z-index: -2;
background-image: var(--wallpaper-image);
background-size: cover;
background-position: var(--wallpaper-position);
background-repeat: no-repeat;
pointer-events: none;
}
/* Scrim over the wallpaper to keep foreground readable. */
body[data-wallpaper='on']::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
background: var(--wallpaper-overlay);
pointer-events: none;
}
/* When a wallpaper is active, let the app base show it through. The three
structural panels use bg-base; make that fill translucent so the ambient
image reads behind the UI. Surfaces/cards stay controlled by their own
tokens (or .glass) for readability. Zero component changes needed. */
body[data-wallpaper='on'] {
background: transparent;
}
body[data-wallpaper='on'] .bg-base {
background-color: color-mix(in srgb, var(--bg-base) 45%, transparent);
}
/* Glass mode (wallpaper + glass theme): turn the common surface panels
translucent + blurred so the wallpaper reads behind the whole UI, with a
subtle top border-highlight for a premium, layered material feel. All via
CSS only — components keep using bg-surface / bg-elevated unchanged. */
body[data-wallpaper='on'] .bg-surface,
body[data-wallpaper='on'] .bg-elevated {
background-color: color-mix(in srgb, var(--bg-surface) calc(var(--surface-alpha) * 100%), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}
/* Slightly brighten default borders over a wallpaper so panels keep clean
edges against the artwork. Kept subtle — no inset shadows on every bordered
element (that would look noisy on inputs/dividers). */
body[data-wallpaper='on'] .border-default {
border-color: color-mix(in srgb, var(--border-default) 78%, rgba(255, 255, 255, 0.12));
}
/* ---- New-chat home page under a wallpaper ------------------------
When a wallpaper is active, the empty chat home keeps its original
centered layout; the suggestion cards just get a premium frosted-glass
treatment so they read cleanly over the artwork. Only the empty state. */
body[data-wallpaper='on'] .chat-home .bg-surface {
background-color: color-mix(in srgb, var(--bg-surface) calc(var(--surface-alpha) * 100%), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18), inset 0 1px 0 0 rgba(255, 255, 255, 0.08);
}
/* Frosted-glass panel: translucent fill + blur so the wallpaper shows
through. Opt-in via the .glass class; a theme toggles the fill/blur
variables. Falls back to a solid surface when glass is off. */
.glass {
background: var(--glass-fill);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}
/* Smooth theme transition (respect reduced motion) */
body,
body * {

View File

@@ -21,8 +21,11 @@ import {
FileText,
Store,
MessageSquareWarning,
Palette,
Check,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { Theme } from '../theme/themes'
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
import brandLogo from '../assets/logo.png'
import { t, getLang, setLang, Lang } from '../i18n'
@@ -31,6 +34,7 @@ import { useTheme } from '../hooks/useTheme'
import { usePlatform } from '../hooks/usePlatform'
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
import UpdateBanner from '../components/UpdateBanner'
import { product } from '@product'
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
// in sync with desktop/package.json "version"; the packaged app overrides this
@@ -76,7 +80,7 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
const location = useLocation()
const navigate = useNavigate()
const { navCollapsed, toggleNav } = useUIStore()
const { theme, toggleTheme } = useTheme()
const { theme, toggleTheme, themeId, themes, appName, setThemeId } = useTheme()
// On macOS the top-left is occupied by the native traffic lights, so the
// brand mark is only shown on Windows/Linux where that corner is otherwise
// empty (mirrors the web console's sidebar logo).
@@ -178,7 +182,7 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
<div className="flex items-center gap-2 min-w-0 select-none">
<BrandLogo />
{!collapsed && (
<span className="text-[14px] font-semibold text-content truncate">CowAgent</span>
<span className="text-[14px] font-semibold text-content truncate">{appName}</span>
)}
</div>
)}
@@ -217,7 +221,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
</div>
{/* Footer actions: a single "more" entry (with version + update dot) that
opens an upward popover, plus the always-visible collapse toggle. */}
opens an upward popover, plus the always-visible collapse toggle. An
optional '@product' slot sits on the left (e.g. an account avatar),
taking the spot the "more" entry would otherwise occupy. */}
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
{menuOpen && (
<FooterMenu
@@ -230,6 +236,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
navigate('/logs')
}}
onTheme={toggleTheme}
themeId={themeId}
themes={themes}
onThemeId={setThemeId}
onLanguage={toggleLanguage}
onCheckUpdate={checkUpdate}
onOpenLink={(url) => {
@@ -240,27 +249,36 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
)}
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
{/* Single clickable entry: version label (left) + the three dots
(right) form one button; the whole block opens the popover. The
version is the packaged app version, also what auto-update
compares against. Collapsed: dots only, version hidden. */}
<button
onClick={() => setMenuOpen((o) => !o)}
title={t('menu_more')}
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
>
{!collapsed && version && (
<span className="text-[12px] truncate">{`v${version}`}</span>
)}
<MoreHorizontal size={17} className="flex-shrink-0" />
{pendingUpdate && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
)}
</button>
{/* Left side: either the built-in "more" entry (version + dots) or,
when an extension provides one and hides the built-in menu, its
footer slot (e.g. an account avatar). */}
{product.slots?.NavRailFooter && product.nav?.hideFooterMenu ? (
<div className={collapsed ? '' : 'flex-1 min-w-0'}>
<product.slots.NavRailFooter />
</div>
) : (
!product.nav?.hideFooterMenu && (
<button
onClick={() => setMenuOpen((o) => !o)}
title={t('menu_more')}
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
>
{!collapsed && version && (
<span className="text-[12px] truncate">{`v${version}`}</span>
)}
<MoreHorizontal size={17} className="flex-shrink-0" />
{pendingUpdate && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
)}
</button>
)
)}
{!collapsed && <div className="flex-1" />}
{!collapsed && !(product.slots?.NavRailFooter && product.nav?.hideFooterMenu) && (
<div className="flex-1" />
)}
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
@@ -312,12 +330,16 @@ const FooterMenu: React.FC<{
checking: boolean
pendingUpdate: boolean
upToDate: boolean
themeId: string
themes: Theme[]
onThemeId: (id: string) => void
onLogs: () => void
onTheme: () => void
onLanguage: () => void
onCheckUpdate: () => void
onOpenLink: (url: string) => void
}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => {
}> = ({ theme, checking, pendingUpdate, upToDate, themeId, themes, onThemeId, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => {
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const updateLabel = checking
? t('update_checking')
: upToDate
@@ -325,17 +347,21 @@ const FooterMenu: React.FC<{
: t('update_check')
return (
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
{/* External destinations first (skill hub, docs, website) */}
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />
{/* External destinations (skill hub, docs, website, feedback). An
extension may hide this group to keep the menu to app actions only. */}
{!product.nav?.hideExternalLinks && (
<>
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />
</>
)}
{/* App actions below: update, theme, language, logs */}
<MenuItem
@@ -350,6 +376,24 @@ const FooterMenu: React.FC<{
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
onClick={onTheme}
/>
<MenuItem
icon={<Palette size={16} />}
label={t('menu_theme_picker')}
trailing={themeMenuOpen ? '▾' : '▸'}
onClick={() => setThemeMenuOpen((o) => !o)}
/>
{themeMenuOpen &&
themes.map((th) => (
<button
key={th.id}
onClick={() => onThemeId(th.id)}
className="w-full flex items-center gap-2.5 pl-8 pr-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors"
>
<ThemeSwatch preview={th.preview ?? { accent: '#4abe6e', bg: '#111', surface: '#1c1c1f' }} />
<span className="flex-1 text-left truncate">{th.name}</span>
{themeId === th.id && <Check size={14} className="flex-shrink-0 text-accent" />}
</button>
))}
<MenuItem
icon={<Languages size={16} />}
label={t('menu_language')}
@@ -361,6 +405,17 @@ const FooterMenu: React.FC<{
)
}
// Tiny 3-color preview (bg / surface / accent) for the theme picker.
const ThemeSwatch: React.FC<{ preview: { accent: string; bg: string; surface: string } }> = ({
preview,
}) => (
<span className="flex-shrink-0 inline-flex h-4 w-4 rounded-full overflow-hidden border border-default">
<span className="w-1/3 h-full" style={{ background: preview.bg }} />
<span className="w-1/3 h-full" style={{ background: preview.surface }} />
<span className="w-1/3 h-full" style={{ background: preview.accent }} />
</span>
)
const MenuItem: React.FC<{
icon: React.ReactNode
label: string

View File

@@ -2,8 +2,12 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import { initThemeEarly } from './hooks/useTheme'
import './index.css'
// Apply persisted appearance + theme before first paint to avoid a flash.
initThemeEarly()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>

View File

@@ -241,7 +241,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
)}
{isEmpty ? (
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
<div data-home className="chat-home flex flex-col items-center justify-center h-full px-6 py-12">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { useLocation } from 'react-router-dom'
import { t } from '../i18n'
import { product } from '@product'
import BasicSettings from './settings/BasicSettings'
import ModelsTab from './settings/ModelsTab'
@@ -13,13 +14,15 @@ type Tab = 'basic' | 'models'
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
const location = useLocation()
const modelsTabHidden = product.models?.hideModelsTab === true
// Allow deep-linking to the models tab via /settings?tab=models.
const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const initial: Tab =
!modelsTabHidden && new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const [tab, setTab] = useState<Tab>(initial)
const tabs: { key: Tab; label: string }[] = [
{ key: 'basic', label: t('settings_tab_basic') },
{ key: 'models', label: t('settings_tab_models') },
...(modelsTabHidden ? [] : [{ key: 'models' as Tab, label: t('settings_tab_models') }]),
]
return (
@@ -47,8 +50,12 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) =>
))}
</div>
{tab === 'basic' ? (
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
{tab === 'basic' || modelsTabHidden ? (
<BasicSettings
baseUrl={baseUrl}
onLangChange={onLangChange}
onOpenModels={modelsTabHidden ? undefined : () => setTab('models')}
/>
) : (
<ModelsTab baseUrl={baseUrl} />
)}

View File

@@ -2,9 +2,14 @@ import React, { useState, useEffect } from 'react'
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
import apiClient from '../../api/client'
import { product } from '@product'
import type { ConfigData, ProviderMeta } from '../../types'
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
const CustomModelPicker = product.models?.ModelPicker
const hideProviderSelect = product.models?.hideProviderSelect === true
const showManagedApiKey = product.models?.showManagedApiKey === true
interface BasicSettingsProps {
baseUrl: string
onLangChange?: () => void
@@ -22,6 +27,11 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
const [showCustom, setShowCustom] = useState(false)
const [modelStatus, setModelStatus] = useState('')
// managed API key (shown only when the standalone models tab is hidden)
const [apiKey, setApiKey] = useState('')
const [apiKeyDirty, setApiKeyDirty] = useState(false)
const [apiKeyVisible, setApiKeyVisible] = useState(false)
// agent card
const [maxTokens, setMaxTokens] = useState(100000)
const [maxTurns, setMaxTurns] = useState(20)
@@ -64,6 +74,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
setProvider(current)
const meta = data.providers?.[current] as ProviderMeta | undefined
// Managed key: show the masked value for the current provider's key field.
const keyField = meta?.api_key_field
setApiKey((keyField && data.api_keys?.[keyField]) || '')
setApiKeyDirty(false)
const presets = meta?.models || []
if (data.model && presets.length && !presets.includes(data.model)) {
setShowCustom(true)
@@ -99,8 +113,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
}
const saveModelConfig = async () => {
const finalModel = showCustom ? customModel.trim() : model
const isLinkai = provider === 'linkai'
const finalModel = CustomModelPicker ? model : showCustom ? customModel.trim() : model
// With a managed model source the provider selector is hidden; route through
// the managed provider so credentials resolve consistently.
const isLinkai = CustomModelPicker ? true : provider === 'linkai'
try {
await apiClient.updateConfig({
model: finalModel,
@@ -116,6 +132,27 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
setTimeout(() => setModelStatus(''), 2000)
}
const currentKeyField = (config?.providers?.[provider] as ProviderMeta | undefined)?.api_key_field
const saveApiKey = async () => {
if (!apiKeyDirty || !currentKeyField) return
// Never save a masked value back as the real key.
if (MASK_RE.test(apiKey)) return
try {
await apiClient.updateConfig({ [currentKeyField]: apiKey })
setModelStatus(t('config_saved'))
setApiKeyDirty(false)
const fresh = await apiClient.getConfig()
setConfig(fresh)
const meta = fresh.providers?.[provider] as ProviderMeta | undefined
const keyField = meta?.api_key_field
setApiKey((keyField && fresh.api_keys?.[keyField]) || '')
} catch {
setModelStatus(t('config_save_error'))
}
setTimeout(() => setModelStatus(''), 2000)
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
@@ -200,21 +237,60 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
{/* Model — provider/model selection only; credentials live in Models tab */}
<Card icon={<Cpu size={16} />} title={t('config_model')}>
<div className="space-y-4">
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
{!hideProviderSelect && (
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
)}
<Field label={t('config_model_name')}>
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
{CustomModelPicker ? (
<CustomModelPicker value={model} onChange={setModel} />
) : (
<>
<Dropdown
value={showCustom ? '__custom__' : model}
options={modelOptions}
onChange={handleModelChange}
/>
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</>
)}
</Field>
{/* Managed API key: hidden by default, click the eye to reveal the
partially-masked value (e.g. sk-1****9aL7). Editable in place; if
left untouched (still contains a mask char) it is not overwritten. */}
{showManagedApiKey && currentKeyField && (
<Field label={t('onboarding_apikey')}>
<div className="relative">
<TextInput
type={apiKeyVisible ? 'text' : 'password'}
className="pr-10 font-mono"
value={apiKey}
placeholder="sk-..."
onChange={(e) => {
setApiKey(e.target.value.trim())
setApiKeyDirty(true)
}}
/>
<button
type="button"
onClick={() => setApiKeyVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{apiKeyVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</Field>
)}
{/* Guide users to the Models tab for API key / base config.
When the selected provider has no credentials, surface a warning. */}
{onOpenModels && (
@@ -240,7 +316,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
</button>
)}
<SaveRow status={modelStatus} onSave={saveModelConfig} />
<SaveRow
status={modelStatus}
onSave={async () => {
await saveModelConfig()
if (showManagedApiKey && apiKeyDirty) await saveApiKey()
}}
/>
</div>
</Card>

View File

@@ -20,6 +20,10 @@ import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
import CapabilityCard from './CapabilityCard'
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
import { product } from '@product'
// Whether the "add custom provider" entry is available. Defaults to true.
const allowCustomProviders = product.models?.allowCustomProviders !== false
interface ModelsTabProps {
baseUrl: string
@@ -330,7 +334,9 @@ const VendorModal: React.FC<{
label: localizedLabel(p.label),
hint: p.configured ? t('models_configured') : undefined,
})),
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
...(allowCustomProviders
? [{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') }]
: []),
]
const onPick = (val: string) => {

View File

@@ -0,0 +1,6 @@
import type { ProductExtension } from '../types'
// Default: no extensions. All behavior stays as-is. An alternate build can
// override the '@product' alias to point at its own module (see
// vite.config.ts) exporting a populated ProductExtension.
export const product: ProductExtension = {}

View File

@@ -0,0 +1,77 @@
// ============================================================
// Optional extension contract.
//
// The core imports a single `product` object from '@product'. By default
// that alias resolves to product/default (an empty object → no change).
// An alternate build can point the alias at another module (see
// vite.config.ts COW_PRODUCT_DIR) and fill in the fields below. Every
// field is optional; absent means "keep the default behavior". The core
// must degrade gracefully when a field is missing.
// ============================================================
import type React from 'react'
// Optional gate rendered before the main UI. When present, the core shows
// <Gate/> until the extension reports the session no longer needs it.
export interface ProductAuth {
Gate: React.FC<{ onAuthenticated: () => void }>
// Whether the gate is currently required. Implementations may use their
// own hooks/state internally.
useRequiresAuth: () => boolean
}
// Optional UI mount points the core renders if provided.
export interface ProductSlots {
// Rendered at the bottom of the nav rail.
NavRailFooter?: React.FC
// Rendered on the right side of the top titlebar strip.
HeaderRight?: React.FC
}
// Extra routes appended to the core <Routes>. Path is a HashRouter path.
export interface ProductRoute {
path: string
element: React.ReactNode
}
export interface ProductOnboarding {
// Set false to disable the built-in setup wizard. Defaults to enabled.
enabled?: boolean
}
export interface ProductModels {
// Set false to hide the "add custom provider" entry. Defaults to allowed.
allowCustomProviders?: boolean
// Set true to hide the standalone "models" settings tab. Defaults to shown.
hideModelsTab?: boolean
// Set true to hide the provider dropdown in basic settings (e.g. when the
// model list comes from a single managed source). Defaults to shown.
hideProviderSelect?: boolean
// Optional replacement for the model selection control in basic settings.
// Controlled: receives the current model id and reports changes. When set,
// the core renders this instead of its built-in model dropdown.
ModelPicker?: React.FC<{ value: string; onChange: (model: string) => void }>
// Set true to show a masked+editable API key field for the current provider
// inside basic settings, useful when the standalone models tab is hidden.
showManagedApiKey?: boolean
}
// Optional nav-rail customization. Lets a build tailor the footer menu's
// external destinations without touching core code.
export interface ProductNav {
// Set true to hide the built-in external links group (skill hub, docs,
// website, feedback). Defaults to shown.
hideExternalLinks?: boolean
// Set true to hide the built-in footer "more" entry (version label + menu),
// e.g. when an extension provides its own footer menu. The collapse toggle
// stays. Defaults to shown.
hideFooterMenu?: boolean
}
export interface ProductExtension {
auth?: ProductAuth
slots?: ProductSlots
routes?: ProductRoute[]
onboarding?: ProductOnboarding
models?: ProductModels
nav?: ProductNav
}

View File

@@ -0,0 +1,142 @@
// ============================================================
// Theme Contract — single source of truth for themes.
//
// A "theme" is pure data: it overrides semantic design tokens
// (colors, radius, shadow, font) and an optional background
// wallpaper. It never touches component code or DOM structure,
// so themes stay stable across UI refactors — as long as
// components keep consuming these tokens, old themes keep working.
//
// Contract rule: themes may ONLY set fields defined here. Adding a
// new token is an additive, versioned change (bump THEME_SPEC_VERSION).
//
// Themes are NOT bundled into the app. They live in ~/.cow/themes/<id>/
// (theme.json + images) and are loaded at runtime via the main process,
// which inlines images as data URLs. The only built-in theme is 'default'
// (a pure-color fallback that always works offline).
// ============================================================
// ---- Color tokens (per-appearance) -------------------------
export const COLOR_KEYS = [
'accent',
'accentHover',
'accentActive',
'accentSoft',
'accentContrast',
'bgBase',
'bgSurface',
'bgSurface2',
'bgElevated',
'bgInset',
'textPrimary',
'textSecondary',
'textTertiary',
'textDisabled',
'borderDefault',
'borderStrong',
'borderSubtle',
'shadowSm',
'shadowMd',
'shadowLg',
] as const
export type ColorKey = (typeof COLOR_KEYS)[number]
// ---- Shape tokens (appearance-independent) -----------------
// Radius / font apply regardless of light or dark.
export const SHAPE_KEYS = ['radiusCard', 'radiusBtn', 'radiusSm', 'fontSans', 'fontMono'] as const
export type ShapeKey = (typeof SHAPE_KEYS)[number]
// camelCase token -> --kebab-case CSS variable
export function tokenToCssVar(key: string): string {
return '--' + key.replace(/[A-Z0-9]/g, (m) => '-' + m.toLowerCase())
}
// ---- Wallpaper (Codex-style ambient background) ------------
export interface Wallpaper {
// Image URL (bundled asset path or data/file URL). Empty = solid color.
image?: string
focusX?: number // 0..1 horizontal focal point, default 0.5
focusY?: number // 0..1 vertical focal point, default 0.5
overlayOpacity?: number // 0..1 scrim strength over the image, default per-appearance
// Panels become translucent + blurred (frosted glass) so the wallpaper
// shows through. When false, panels stay solid (wallpaper only behind base).
glass?: boolean
}
export interface ThemeAppearance {
colors?: Partial<Record<ColorKey, string>>
wallpaper?: Wallpaper
}
// Optional per-theme identity overrides (logo + display name).
export interface ThemeIdentity {
logo?: string // data URL (inlined by main) or asset URL
appName?: string
}
export interface Theme {
id: string
name: string
specVersion?: number
// Optional preview swatch; when absent it's derived from colors.
preview?: { accent: string; bg: string; surface: string }
identity?: ThemeIdentity
// Appearance-independent shape tokens.
shape?: Partial<Record<ShapeKey, string>>
// Per-appearance colors + wallpaper.
light?: ThemeAppearance
dark?: ThemeAppearance
}
// Bump when the contract changes in a breaking way. theme.json files declare
// which version they target so imports can be validated.
export const THEME_SPEC_VERSION = 1
// 'default' uses the built-in :root / .dark values in index.css and
// applies no data-theme attribute nor overrides.
export const DEFAULT_THEME_ID = 'default'
// The only built-in theme: 'default' maps to the base :root / .dark values in
// index.css (no overrides). Everything else is loaded at runtime from
// ~/.cow/themes. This keeps the app bundle free of theme assets.
export const DEFAULT_THEME: Theme = {
id: DEFAULT_THEME_ID,
name: 'CowAgent',
preview: { accent: '#4abe6e', bg: '#f9fafb', surface: '#ffffff' },
}
// Runtime registry: default first, then whatever was loaded from ~/.cow.
let runtimeThemes: Theme[] = [DEFAULT_THEME]
// Basic shape validation so a malformed theme.json can't break the app.
function isValidTheme(x: unknown): x is Theme {
if (!x || typeof x !== 'object') return false
const s = x as Record<string, unknown>
return typeof s.id === 'string' && s.id.length > 0
}
// Derive a preview swatch from a theme's colors when it didn't ship one.
function derivePreview(theme: Theme): { accent: string; bg: string; surface: string } {
if (theme.preview) return theme.preview
const c = theme.dark?.colors ?? theme.light?.colors ?? {}
return {
accent: c.accent ?? '#4abe6e',
bg: c.bgBase ?? '#111111',
surface: c.bgSurface ?? '#1c1c1f',
}
}
// Replace the runtime theme list with default + validated remote themes.
export function registerRuntimeThemes(themes: unknown[]): void {
const valid = (themes ?? []).filter(isValidTheme).filter((t) => t.id !== DEFAULT_THEME_ID)
for (const t of valid) t.preview = derivePreview(t)
runtimeThemes = [DEFAULT_THEME, ...valid]
}
export function getAllThemes(): Theme[] {
return runtimeThemes
}
export function getTheme(id: string): Theme {
return runtimeThemes.find((t) => t.id === id) ?? runtimeThemes[0]
}

View File

@@ -21,6 +21,19 @@ export interface ElectronAPI {
onMenuAction?: (callback: (action: string) => void) => () => void
// Current app version string (e.g. "0.0.5").
getAppVersion?: () => Promise<string>
// Themes (bundled + user themes from ~/.cow/themes), images inlined.
listThemes?: () => Promise<Record<string, unknown>[]>
getThemesDir?: () => Promise<string>
// Optional app config: first-run default theme + display name. Null when
// the build ships no app config (standard build).
getAppConfig?: () => Promise<{ defaultTheme?: string; appName?: string } | null>
// Generic HTTPS relay via the main process (bypasses renderer CORS).
httpRelay?: (req: {
url: string
method?: string
headers?: Record<string, string>
body?: string
}) => Promise<{ ok: boolean; status: number; headers: Record<string, string>; body: string }>
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
checkForUpdate?: (lang?: string) => Promise<void>
downloadUpdate?: (lang?: string) => Promise<void>

View File

@@ -1,12 +1,21 @@
const path = require('path')
// When '@product' points outside this project (COW_PRODUCT_DIR), scan that
// directory too so classes used only there still get generated.
const productContent = process.env.COW_PRODUCT_DIR
? [path.join(process.env.COW_PRODUCT_DIR, '**/*.{tsx,ts}')]
: []
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/renderer/**/*.{html,tsx,ts}'],
content: ['./src/renderer/**/*.{html,tsx,ts}', ...productContent],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
// Driven by CSS variables so a skin can restyle typography.
sans: 'var(--font-sans)',
mono: 'var(--font-mono)',
},
colors: {
'danger-soft': 'var(--danger-soft)',
@@ -62,8 +71,9 @@ module.exports = {
lg: 'var(--shadow-lg)',
},
borderRadius: {
card: '12px',
btn: '8px',
card: 'var(--radius-card)',
btn: 'var(--radius-btn)',
sm: 'var(--radius-sm)',
},
animation: {
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',

View File

@@ -16,7 +16,12 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"esModuleInterop": true
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/src/*"],
"@product": ["src/renderer/src/product/default"]
}
},
"include": ["src/renderer"]
}

View File

@@ -2,6 +2,25 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// '@product' resolves to the built-in default (empty). An alternate build
// can set COW_PRODUCT_DIR to point at another module instead.
const productDir =
process.env.COW_PRODUCT_DIR || path.resolve(__dirname, 'src/renderer/src/product/default')
// When '@product' points outside this project, its files can't resolve shared
// deps from their own tree. Alias the shared runtime deps to this project's
// node_modules so an out-of-tree product module imports the same instances.
const nodeModules = path.resolve(__dirname, 'node_modules')
const sharedDepAliases = process.env.COW_PRODUCT_DIR
? {
react: path.join(nodeModules, 'react'),
'react-dom': path.join(nodeModules, 'react-dom'),
'react/jsx-runtime': path.join(nodeModules, 'react/jsx-runtime'),
'react-router-dom': path.join(nodeModules, 'react-router-dom'),
'lucide-react': path.join(nodeModules, 'lucide-react'),
}
: {}
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname, 'src/renderer'),
@@ -17,6 +36,8 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer/src'),
'@product': productDir,
...sharedDepAliases,
},
},
})

View File

@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
| [2.1.4](/ja/releases/v2.1.4) | 2026.07.20 | デスクトップクライアント改善ブラウザツール、Windows 署名、Win7/8 対応、MCP OAuth 認証、定期タスク・Feishu チャネル強化、データバックアップと復元、新モデル追加kimi-k3、gpt-5.6 |
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリースmacOS / Windows、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデル追加kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加MiniMax-M3、qwen3.7-plus など)、各種改善 |

View File

@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
| [2.1.4](/releases/v2.1.4) | 2026.07.20 | Desktop client improvements (browser tool, Windows signing, Win7/8 support), MCP OAuth, scheduled tasks and Feishu channel enhancements, data backup & restore, new models (kimi-k3, gpt-5.6) |
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |

View File

@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| [2.1.4](/zh/releases/v2.1.4) | 2026.07.20 | 桌面客户端优化浏览器工具、Windows 签名、Win7/8 支持、MCP OAuth 授权、定时任务与飞书通道增强、数据备份恢复、新模型接入kimi-k3、gpt-5.6 |
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布macOS / Windows、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |