Merge pull request #6514 from menloresearch/feat/web-gtag

feat: Add GA Measurement and change keyboard bindings on web
This commit is contained in:
Dinh Long Nguyen 2025-09-18 20:45:41 +07:00 committed by GitHub
parent 645548e931
commit 359dd8f41e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 259 additions and 44 deletions

View File

@ -14,6 +14,7 @@ jobs:
pull-requests: write
env:
JAN_API_BASE: "https://api.jan.ai/jan/v1"
GA_MEASUREMENT_ID: "G-YK53MX8M8M"
CLOUDFLARE_PROJECT_NAME: "jan-server-web"
steps:
- uses: actions/checkout@v4

View File

@ -0,0 +1,17 @@
/**
* Google Analytics utility functions
*/
/**
* Track custom events with Google Analytics
*/
export function trackEvent(
eventName: string,
parameters?: Record<string, unknown>
) {
if (!window.gtag) {
return
}
window.gtag('event', eventName, parameters)
}

View File

@ -55,4 +55,10 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
// Authentication (Google OAuth) - enabled for web only
[PlatformFeature.AUTHENTICATION]: !isPlatformTauri(),
// Google Analytics - enabled for web only
[PlatformFeature.GOOGLE_ANALYTICS]: !isPlatformTauri(),
// Alternate shortcut bindings - enabled for web only (to avoid browser conflicts)
[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]: !isPlatformTauri(),
}

View File

@ -57,4 +57,10 @@ export enum PlatformFeature {
// Authentication (Google OAuth, user profiles)
AUTHENTICATION = 'authentication',
// Google Analytics tracking (web-only)
GOOGLE_ANALYTICS = 'googleAnalytics',
// Alternate keyboard shortcut bindings (web-only, to avoid browser conflicts)
ALTERNATE_SHORTCUT_BINDINGS = 'alternateShortcutBindings',
}

View File

@ -0,0 +1,41 @@
/**
* Shortcuts Configuration
* Centralized shortcut definitions based on platform capabilities
*/
import { PlatformFeatures } from '../platform/const'
import { PlatformFeature } from '../platform/types'
import { ShortcutAction, type ShortcutMap } from './types'
/**
* Platform-specific shortcut mappings
* Uses alternate bindings for web to avoid browser conflicts
*/
export const PlatformShortcuts: ShortcutMap = {
// Toggle sidebar - same on both platforms (no browser conflict)
[ShortcutAction.TOGGLE_SIDEBAR]: {
key: 'b',
usePlatformMetaKey: true,
},
// New chat - different per platform to avoid browser "new window" conflict
[ShortcutAction.NEW_CHAT]: PlatformFeatures[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]
? { key: 'Enter', usePlatformMetaKey: true }
: { key: 'n', usePlatformMetaKey: true },
// Go to settings - different per platform to avoid browser "preferences" conflict
[ShortcutAction.GO_TO_SETTINGS]: PlatformFeatures[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]
? { key: '.', usePlatformMetaKey: true }
: { key: ',', usePlatformMetaKey: true },
// Zoom shortcuts - same on both platforms (standard shortcuts)
[ShortcutAction.ZOOM_IN]: {
key: '+',
usePlatformMetaKey: true,
},
[ShortcutAction.ZOOM_OUT]: {
key: '-',
usePlatformMetaKey: true,
},
}

View File

@ -0,0 +1,9 @@
/**
* Shortcuts - Centralized keyboard shortcut system
*
* Provides platform-aware keyboard shortcuts that avoid browser conflicts
* on web while maintaining familiar shortcuts on desktop.
*/
export * from './types'
export * from './const'

View File

@ -0,0 +1,23 @@
/**
* Keyboard Shortcut Types
* Defines semantic actions and shortcut specifications
*/
export enum ShortcutAction {
NEW_CHAT = 'newChat',
TOGGLE_SIDEBAR = 'toggleSidebar',
GO_TO_SETTINGS = 'goSettings',
ZOOM_IN = 'zoomIn',
ZOOM_OUT = 'zoomOut',
}
export interface ShortcutSpec {
key: string
usePlatformMetaKey?: boolean
altKey?: boolean
shiftKey?: boolean
ctrlKey?: boolean
metaKey?: boolean
}
export type ShortcutMap = Record<ShortcutAction, ShortcutSpec>

View File

@ -0,0 +1,62 @@
import { useEffect } from 'react'
import { useLocation } from '@tanstack/react-router'
export function GoogleAnalyticsProvider() {
const location = useLocation()
useEffect(() => {
// Check if GA ID is properly configured
if (!GA_MEASUREMENT_ID || GA_MEASUREMENT_ID === 'G-XXXXXXXXXX') {
console.warn(
'Google Analytics not initialized: Invalid GA_MEASUREMENT_ID'
)
return
}
// Load Google Analytics script
const script = document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`
// Handle loading errors
script.onerror = () => {
console.warn('Failed to load Google Analytics script')
}
document.head.appendChild(script)
// Initialize gtag
window.dataLayer = window.dataLayer || []
window.gtag = function (...args: unknown[]) {
window.dataLayer?.push(args)
}
window.gtag('js', new Date())
window.gtag('config', GA_MEASUREMENT_ID, {
send_page_view: false, // We'll manually track page views
})
return () => {
// Cleanup: Remove script on unmount
if (script.parentNode) {
script.parentNode.removeChild(script)
}
}
}, [])
// Track page views on route change
useEffect(() => {
if (!window.gtag) {
return
}
window.gtag('event', 'page_view', {
page_path: location.pathname + location.search,
page_location: window.location.href,
page_title: document.title,
})
}, [location])
return null
}

View File

@ -2,34 +2,37 @@ import { useKeyboardShortcut } from '@/hooks/useHotkeys'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { useRouter } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { PlatformShortcuts, ShortcutAction } from '@/lib/shortcuts'
export function KeyboardShortcutsProvider() {
const { open, setLeftPanel } = useLeftPanel()
const router = useRouter()
// Toggle Sidebar (⌘/Ctrl B)
// Get shortcut specs from centralized configuration
const sidebarShortcut = PlatformShortcuts[ShortcutAction.TOGGLE_SIDEBAR]
const newChatShortcut = PlatformShortcuts[ShortcutAction.NEW_CHAT]
const settingsShortcut = PlatformShortcuts[ShortcutAction.GO_TO_SETTINGS]
// Toggle Sidebar
useKeyboardShortcut({
key: 'b',
usePlatformMetaKey: true,
...sidebarShortcut,
callback: () => {
setLeftPanel(!open)
},
})
// New Chat (⌘/Ctrl N)
// New Chat
useKeyboardShortcut({
key: 'n',
usePlatformMetaKey: true,
...newChatShortcut,
excludeRoutes: [route.home],
callback: () => {
router.navigate({ to: route.home })
},
})
// Go to Settings (⌘/Ctrl ,)
// Go to Settings
useKeyboardShortcut({
key: ',',
usePlatformMetaKey: true,
...settingsShortcut,
callback: () => {
router.navigate({ to: route.settings.general })
},

View File

@ -15,6 +15,7 @@ import { ToasterProvider } from '@/providers/ToasterProvider'
import { useAnalytic } from '@/hooks/useAnalytic'
import { PromptAnalytic } from '@/containers/analytics/PromptAnalytic'
import { AnalyticProvider } from '@/providers/AnalyticProvider'
import { GoogleAnalyticsProvider } from '@/providers/GoogleAnalyticsProvider'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import ToolApproval from '@/containers/dialogs/ToolApproval'
@ -110,6 +111,7 @@ const AppLayout = () => {
return (
<Fragment>
<AnalyticProvider />
{PlatformFeatures[PlatformFeature.GOOGLE_ANALYTICS] && <GoogleAnalyticsProvider />}
<KeyboardShortcutsProvider />
<main className="relative h-svh text-sm antialiased select-none bg-app">
{/* Fake absolute panel top to enable window drag */}

View File

@ -4,6 +4,7 @@ import SettingsMenu from '@/containers/SettingsMenu'
import HeaderPage from '@/containers/HeaderPage'
import { Card, CardItem } from '@/containers/Card'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { ShortcutAction, PlatformShortcuts, type ShortcutSpec } from '@/lib/shortcuts'
import { PlatformMetaKey } from '@/containers/PlatformMetaKey'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -11,6 +12,75 @@ export const Route = createFileRoute(route.settings.shortcuts as any)({
component: Shortcuts,
})
interface ShortcutLabelProps {
action: ShortcutAction
className?: string
}
/**
* Renders a keyboard shortcut label consistently across platforms
*/
function ShortcutLabel({ action, className = '' }: ShortcutLabelProps) {
const spec = PlatformShortcuts[action]
return (
<div className={`flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md ${className}`}>
<span className="font-medium">
<ShortcutKeys spec={spec} />
</span>
</div>
)
}
/**
* Renders the key combination for a shortcut spec
*/
function ShortcutKeys({ spec }: { spec: ShortcutSpec }) {
const parts: React.ReactNode[] = []
// Helper function to format key names consistently
const formatKey = (key: string) => {
const lowerKey = key.toLowerCase()
if (lowerKey === 'enter') return 'Enter'
if (lowerKey === 'shift') return 'Shift'
if (lowerKey === 'ctrl') return 'Ctrl'
if (lowerKey === 'alt') return 'Alt'
return key.toUpperCase()
}
// Add modifier keys
if (spec.usePlatformMetaKey) {
parts.push(<PlatformMetaKey key="meta" />)
}
if (spec.ctrlKey) {
parts.push('Ctrl')
}
if (spec.metaKey) {
parts.push('⌘')
}
if (spec.altKey) {
parts.push('Alt')
}
if (spec.shiftKey) {
parts.push('Shift')
}
// Add the main key with proper formatting
parts.push(formatKey(spec.key))
// Join with spaces
return (
<>
{parts.map((part, index) => (
<span key={index}>
{part}
{index < parts.length - 1 && ' '}
</span>
))}
</>
)
}
function Shortcuts() {
const { t } = useTranslation()
@ -28,46 +98,22 @@ function Shortcuts() {
<CardItem
title={t('settings:shortcuts.newChat')}
description={t('settings:shortcuts.newChatDesc')}
actions={
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
<span className="font-medium">
<PlatformMetaKey /> N
</span>
</div>
}
actions={<ShortcutLabel action={ShortcutAction.NEW_CHAT} />}
/>
<CardItem
title={t('settings:shortcuts.toggleSidebar')}
description={t('settings:shortcuts.toggleSidebarDesc')}
actions={
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
<span className="font-medium">
<PlatformMetaKey /> B
</span>
</div>
}
actions={<ShortcutLabel action={ShortcutAction.TOGGLE_SIDEBAR} />}
/>
<CardItem
title={t('settings:shortcuts.zoomIn')}
description={t('settings:shortcuts.zoomInDesc')}
actions={
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
<span className="font-medium">
<PlatformMetaKey /> +
</span>
</div>
}
actions={<ShortcutLabel action={ShortcutAction.ZOOM_IN} />}
/>
<CardItem
title={t('settings:shortcuts.zoomOut')}
description={t('settings:shortcuts.zoomOutDesc')}
actions={
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
<span className="font-medium">
<PlatformMetaKey /> -
</span>
</div>
}
actions={<ShortcutLabel action={ShortcutAction.ZOOM_OUT} />}
/>
</Card>
@ -102,13 +148,7 @@ function Shortcuts() {
<CardItem
title={t('settings:shortcuts.goToSettings')}
description={t('settings:shortcuts.goToSettingsDesc')}
actions={
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
<span className="font-medium">
<PlatformMetaKey /> ,
</span>
</div>
}
actions={<ShortcutLabel action={ShortcutAction.GO_TO_SETTINGS} />}
/>
</Card>
</div>

View File

@ -21,7 +21,10 @@ declare global {
declare const POSTHOG_HOST: string
declare const MODEL_CATALOG_URL: string
declare const AUTO_UPDATER_DISABLED: boolean
declare const GA_MEASUREMENT_ID: string
interface Window {
core: AppCore | undefined
gtag?: (...args: unknown[]) => void
dataLayer?: unknown[]
}
}

View File

@ -62,6 +62,7 @@ export default defineConfig(({ mode }) => {
POSTHOG_KEY: JSON.stringify(env.POSTHOG_KEY),
POSTHOG_HOST: JSON.stringify(env.POSTHOG_HOST),
GA_MEASUREMENT_ID: JSON.stringify(env.GA_MEASUREMENT_ID),
MODEL_CATALOG_URL: JSON.stringify(
'https://raw.githubusercontent.com/menloresearch/model-catalog/main/model_catalog.json'
),

View File

@ -58,6 +58,7 @@ export default defineConfig({
VERSION: JSON.stringify(process.env.npm_package_version || '1.0.0'),
POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_KEY || ''),
POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_HOST || ''),
GA_MEASUREMENT_ID: JSON.stringify(process.env.GA_MEASUREMENT_ID),
MODEL_CATALOG_URL: JSON.stringify(process.env.MODEL_CATALOG_URL || ''),
},
server: {