mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-22 14:48:41 +08:00
feat(desktop): add client extension points
This commit is contained in:
92
desktop/src/main/http-relay.ts
Normal file
92
desktop/src/main/http-relay.ts
Normal 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) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import { PythonBackend } from './python-manager'
|
|||||||
import { buildAppMenu } from './menu'
|
import { buildAppMenu } from './menu'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
|
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
|
||||||
import { setupThemeIPC } from './themes'
|
import { setupThemeIPC, loadAppConfig } from './themes'
|
||||||
|
import { setupHttpRelayIPC } from './http-relay'
|
||||||
|
|
||||||
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
// 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".
|
// where the default Electron binary would otherwise report "Electron". The name
|
||||||
app.setName('CowAgent')
|
// can be overridden by the bundled app-config (appName); defaults to CowAgent.
|
||||||
|
app.setName(loadAppConfig()?.appName || 'CowAgent')
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let pythonBackend: PythonBackend | null = null
|
let pythonBackend: PythonBackend | null = null
|
||||||
@@ -308,6 +310,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
setupIPC()
|
setupIPC()
|
||||||
setupThemeIPC()
|
setupThemeIPC()
|
||||||
|
setupHttpRelayIPC()
|
||||||
createWindow()
|
createWindow()
|
||||||
buildAppMenu(() => mainWindow)
|
buildAppMenu(() => mainWindow)
|
||||||
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getAppConfig: () =>
|
getAppConfig: () =>
|
||||||
ipcRenderer.invoke('app-config-get') as Promise<{ defaultTheme?: string; appName?: string } | null>,
|
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
|
// Auto-update: trigger checks/download/install and subscribe to status. The
|
||||||
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
||||||
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ function bundledThemesDir(): string | null {
|
|||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
defaultTheme?: string
|
defaultTheme?: string
|
||||||
appName?: 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 {
|
function appConfigPath(): string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from 'path'
|
|||||||
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
||||||
// whereas `import pkg from 'electron-updater'` yields undefined.
|
// whereas `import pkg from 'electron-updater'` yields undefined.
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
|
import { loadAppConfig } from './themes'
|
||||||
|
|
||||||
// Status payloads pushed to the renderer over the 'update-status' channel.
|
// Status payloads pushed to the renderer over the 'update-status' channel.
|
||||||
// The renderer drives the NavRail badge + update panel from these.
|
// The renderer drives the NavRail badge + update panel from these.
|
||||||
@@ -33,6 +34,11 @@ function isLegacyWindows(): boolean {
|
|||||||
return Number.isFinite(major) && major < 10
|
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
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (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
|
// 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
|
// 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.
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
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
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
|
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import MemoryPage from './pages/MemoryPage'
|
|||||||
import ChannelsPage from './pages/ChannelsPage'
|
import ChannelsPage from './pages/ChannelsPage'
|
||||||
import TasksPage from './pages/TasksPage'
|
import TasksPage from './pages/TasksPage'
|
||||||
import LogsPage from './pages/LogsPage'
|
import LogsPage from './pages/LogsPage'
|
||||||
|
import { product } from '@product'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const backend = useBackend()
|
const backend = useBackend()
|
||||||
@@ -37,6 +38,11 @@ const App: React.FC = () => {
|
|||||||
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||||
// the main UI render.
|
// the main UI render.
|
||||||
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
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(() => {
|
useEffect(() => {
|
||||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
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.
|
// configured (and not dismissed earlier this session); no persisted flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backend.status !== 'ready' || authState !== 'ok') return
|
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
|
let cancelled = false
|
||||||
apiClient
|
apiClient
|
||||||
.getModels()
|
.getModels()
|
||||||
@@ -130,8 +138,14 @@ const App: React.FC = () => {
|
|||||||
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
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 isChat = location.pathname === '/'
|
||||||
const showSessions = isChat && !sessionsCollapsed
|
const showSessions = isChat && !sessionsCollapsed && !showProductGate
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-base text-content">
|
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||||
@@ -156,11 +170,19 @@ const App: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0" />
|
<div className="flex-1 min-w-0" />
|
||||||
|
{product.slots?.HeaderRight && (
|
||||||
|
<div className="titlebar-no-drag flex items-center">
|
||||||
|
<product.slots.HeaderRight />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isWin && <WindowControls />}
|
{isWin && <WindowControls />}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
|
||||||
|
{showProductGate && ProductGate ? (
|
||||||
|
<ProductGate onAuthenticated={() => setProductAuthed(true)} />
|
||||||
|
) : (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
||||||
<Route path="/knowledge" element={<KnowledgePage 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 */}
|
{/* Legacy /models route now lives as a tab inside settings */}
|
||||||
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
|
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
|
||||||
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
|
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
|
||||||
|
{product.routes?.map((r) => (
|
||||||
|
<Route key={r.path} path={r.path} element={r.element} />
|
||||||
|
))}
|
||||||
</Routes>
|
</Routes>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useTheme } from '../hooks/useTheme'
|
|||||||
import { usePlatform } from '../hooks/usePlatform'
|
import { usePlatform } from '../hooks/usePlatform'
|
||||||
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
|
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
|
||||||
import UpdateBanner from '../components/UpdateBanner'
|
import UpdateBanner from '../components/UpdateBanner'
|
||||||
|
import { product } from '@product'
|
||||||
|
|
||||||
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
|
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
|
||||||
// in sync with desktop/package.json "version"; the packaged app overrides this
|
// in sync with desktop/package.json "version"; the packaged app overrides this
|
||||||
@@ -220,7 +221,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions: a single "more" entry (with version + update dot) that
|
{/* 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}>
|
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<FooterMenu
|
<FooterMenu
|
||||||
@@ -246,27 +249,36 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
|
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
|
||||||
{/* Single clickable entry: version label (left) + the three dots
|
{/* Left side: either the built-in "more" entry (version + dots) or,
|
||||||
(right) form one button; the whole block opens the popover. The
|
when an extension provides one and hides the built-in menu, its
|
||||||
version is the packaged app version, also what auto-update
|
footer slot (e.g. an account avatar). */}
|
||||||
compares against. Collapsed: dots only, version hidden. */}
|
{product.slots?.NavRailFooter && product.nav?.hideFooterMenu ? (
|
||||||
<button
|
<div className={collapsed ? '' : 'flex-1 min-w-0'}>
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
<product.slots.NavRailFooter />
|
||||||
title={t('menu_more')}
|
</div>
|
||||||
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'
|
!product.nav?.hideFooterMenu && (
|
||||||
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
|
<button
|
||||||
>
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
{!collapsed && version && (
|
title={t('menu_more')}
|
||||||
<span className="text-[12px] truncate">{`v${version}`}</span>
|
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'
|
||||||
<MoreHorizontal size={17} className="flex-shrink-0" />
|
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
|
||||||
{pendingUpdate && (
|
>
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
|
{!collapsed && version && (
|
||||||
)}
|
<span className="text-[12px] truncate">{`v${version}`}</span>
|
||||||
</button>
|
)}
|
||||||
|
<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')}>
|
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
||||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||||
@@ -335,17 +347,21 @@ const FooterMenu: React.FC<{
|
|||||||
: t('update_check')
|
: t('update_check')
|
||||||
return (
|
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">
|
<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) */}
|
{/* External destinations (skill hub, docs, website, feedback). An
|
||||||
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
|
extension may hide this group to keep the menu to app actions only. */}
|
||||||
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
{!product.nav?.hideExternalLinks && (
|
||||||
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
<>
|
||||||
<MenuItem
|
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
|
||||||
icon={<MessageSquareWarning size={16} />}
|
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
||||||
label={t('menu_feedback')}
|
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
||||||
onClick={() => onOpenLink(FEEDBACK_URL)}
|
<MenuItem
|
||||||
/>
|
icon={<MessageSquareWarning size={16} />}
|
||||||
|
label={t('menu_feedback')}
|
||||||
<div className="my-1 border-t border-subtle" />
|
onClick={() => onOpenLink(FEEDBACK_URL)}
|
||||||
|
/>
|
||||||
|
<div className="my-1 border-t border-subtle" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* App actions below: update, theme, language, logs */}
|
{/* App actions below: update, theme, language, logs */}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
|
import { product } from '@product'
|
||||||
import BasicSettings from './settings/BasicSettings'
|
import BasicSettings from './settings/BasicSettings'
|
||||||
import ModelsTab from './settings/ModelsTab'
|
import ModelsTab from './settings/ModelsTab'
|
||||||
|
|
||||||
@@ -13,13 +14,15 @@ type Tab = 'basic' | 'models'
|
|||||||
|
|
||||||
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
|
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const modelsTabHidden = product.models?.hideModelsTab === true
|
||||||
// Allow deep-linking to the models tab via /settings?tab=models.
|
// 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 [tab, setTab] = useState<Tab>(initial)
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string }[] = [
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
{ key: 'basic', label: t('settings_tab_basic') },
|
{ 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 (
|
return (
|
||||||
@@ -47,8 +50,12 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'basic' ? (
|
{tab === 'basic' || modelsTabHidden ? (
|
||||||
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
|
<BasicSettings
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
onLangChange={onLangChange}
|
||||||
|
onOpenModels={modelsTabHidden ? undefined : () => setTab('models')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ModelsTab baseUrl={baseUrl} />
|
<ModelsTab baseUrl={baseUrl} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
|
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
|
||||||
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
|
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
import { product } from '@product'
|
||||||
import type { ConfigData, ProviderMeta } from '../../types'
|
import type { ConfigData, ProviderMeta } from '../../types'
|
||||||
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
|
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 {
|
interface BasicSettingsProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
onLangChange?: () => void
|
onLangChange?: () => void
|
||||||
@@ -22,6 +27,11 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
const [showCustom, setShowCustom] = useState(false)
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
const [modelStatus, setModelStatus] = useState('')
|
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
|
// agent card
|
||||||
const [maxTokens, setMaxTokens] = useState(100000)
|
const [maxTokens, setMaxTokens] = useState(100000)
|
||||||
const [maxTurns, setMaxTurns] = useState(20)
|
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] || ''
|
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
|
||||||
setProvider(current)
|
setProvider(current)
|
||||||
const meta = data.providers?.[current] as ProviderMeta | undefined
|
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 || []
|
const presets = meta?.models || []
|
||||||
if (data.model && presets.length && !presets.includes(data.model)) {
|
if (data.model && presets.length && !presets.includes(data.model)) {
|
||||||
setShowCustom(true)
|
setShowCustom(true)
|
||||||
@@ -99,8 +113,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveModelConfig = async () => {
|
const saveModelConfig = async () => {
|
||||||
const finalModel = showCustom ? customModel.trim() : model
|
const finalModel = CustomModelPicker ? model : showCustom ? customModel.trim() : model
|
||||||
const isLinkai = provider === 'linkai'
|
// 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 {
|
try {
|
||||||
await apiClient.updateConfig({
|
await apiClient.updateConfig({
|
||||||
model: finalModel,
|
model: finalModel,
|
||||||
@@ -116,6 +132,27 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
setTimeout(() => setModelStatus(''), 2000)
|
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 () => {
|
const saveAgentConfig = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.updateConfig({
|
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 */}
|
{/* Model — provider/model selection only; credentials live in Models tab */}
|
||||||
<Card icon={<Cpu size={16} />} title={t('config_model')}>
|
<Card icon={<Cpu size={16} />} title={t('config_model')}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Field label={t('config_provider')}>
|
{!hideProviderSelect && (
|
||||||
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
|
<Field label={t('config_provider')}>
|
||||||
</Field>
|
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<Field label={t('config_model_name')}>
|
<Field label={t('config_model_name')}>
|
||||||
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
|
{CustomModelPicker ? (
|
||||||
{showCustom && (
|
<CustomModelPicker value={model} onChange={setModel} />
|
||||||
<TextInput
|
) : (
|
||||||
className="mt-2 font-mono"
|
<>
|
||||||
value={customModel}
|
<Dropdown
|
||||||
onChange={(e) => setCustomModel(e.target.value)}
|
value={showCustom ? '__custom__' : model}
|
||||||
placeholder={t('config_custom_model_hint')}
|
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>
|
</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.
|
{/* Guide users to the Models tab for API key / base config.
|
||||||
When the selected provider has no credentials, surface a warning. */}
|
When the selected provider has no credentials, surface a warning. */}
|
||||||
{onOpenModels && (
|
{onOpenModels && (
|
||||||
@@ -240,7 +316,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SaveRow status={modelStatus} onSave={saveModelConfig} />
|
<SaveRow
|
||||||
|
status={modelStatus}
|
||||||
|
onSave={async () => {
|
||||||
|
await saveModelConfig()
|
||||||
|
if (showManagedApiKey && apiKeyDirty) await saveApiKey()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState
|
|||||||
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
|
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
|
||||||
import CapabilityCard from './CapabilityCard'
|
import CapabilityCard from './CapabilityCard'
|
||||||
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
|
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 {
|
interface ModelsTabProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -330,7 +334,9 @@ const VendorModal: React.FC<{
|
|||||||
label: localizedLabel(p.label),
|
label: localizedLabel(p.label),
|
||||||
hint: p.configured ? t('models_configured') : undefined,
|
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) => {
|
const onPick = (val: string) => {
|
||||||
|
|||||||
6
desktop/src/renderer/src/product/default/index.ts
Normal file
6
desktop/src/renderer/src/product/default/index.ts
Normal 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 = {}
|
||||||
77
desktop/src/renderer/src/product/types.ts
Normal file
77
desktop/src/renderer/src/product/types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ export interface ElectronAPI {
|
|||||||
// Optional app config: first-run default theme + display name. Null when
|
// Optional app config: first-run default theme + display name. Null when
|
||||||
// the build ships no app config (standard build).
|
// the build ships no app config (standard build).
|
||||||
getAppConfig?: () => Promise<{ defaultTheme?: string; appName?: string } | null>
|
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.
|
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
|
||||||
checkForUpdate?: (lang?: string) => Promise<void>
|
checkForUpdate?: (lang?: string) => Promise<void>
|
||||||
downloadUpdate?: (lang?: string) => Promise<void>
|
downloadUpdate?: (lang?: string) => Promise<void>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./src/renderer/**/*.{html,tsx,ts}'],
|
content: ['./src/renderer/**/*.{html,tsx,ts}', ...productContent],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/renderer/src/*"],
|
||||||
|
"@product": ["src/renderer/src/product/default"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/renderer"]
|
"include": ["src/renderer"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
root: path.resolve(__dirname, 'src/renderer'),
|
root: path.resolve(__dirname, 'src/renderer'),
|
||||||
@@ -17,6 +36,8 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src/renderer/src'),
|
'@': path.resolve(__dirname, 'src/renderer/src'),
|
||||||
|
'@product': productDir,
|
||||||
|
...sharedDepAliases,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user