Merge remote-tracking branch 'origin/dev' into feat/retain-interruption-message

# Conflicts:
#	web-app/src/containers/ChatInput.tsx
#	web-app/src/hooks/useChat.ts
This commit is contained in:
Vanalite 2025-10-02 10:56:10 +07:00
commit 34036d895a
24 changed files with 597 additions and 353 deletions

View File

@ -20,6 +20,7 @@
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual"
/> />
<title>Jan</title> <title>Jan</title>
<!-- INJECT_GOOGLE_ANALYTICS -->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -4,7 +4,6 @@ import TextareaAutosize from 'react-textarea-autosize'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrompt } from '@/hooks/usePrompt' import { usePrompt } from '@/hooks/usePrompt'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -68,8 +67,6 @@ const ChatInput = ({
const prompt = usePrompt((state) => state.prompt) const prompt = usePrompt((state) => state.prompt)
const setPrompt = usePrompt((state) => state.setPrompt) const setPrompt = usePrompt((state) => state.setPrompt)
const currentThreadId = useThreads((state) => state.currentThreadId) const currentThreadId = useThreads((state) => state.currentThreadId)
const updateThread = useThreads((state) => state.updateThread)
const { getFolderById } = useThreadManagement()
const { t } = useTranslation() const { t } = useTranslation()
const spellCheckChatInput = useGeneralSetting( const spellCheckChatInput = useGeneralSetting(
(state) => state.spellCheckChatInput (state) => state.spellCheckChatInput
@ -179,7 +176,7 @@ const ChatInput = ({
const mcpExtension = extensionManager.get<MCPExtension>(ExtensionTypeEnum.MCP) const mcpExtension = extensionManager.get<MCPExtension>(ExtensionTypeEnum.MCP)
const MCPToolComponent = mcpExtension?.getToolComponent?.() const MCPToolComponent = mcpExtension?.getToolComponent?.()
const handleSendMesage = (prompt: string) => { const handleSendMesage = async (prompt: string) => {
if (!selectedModel) { if (!selectedModel) {
setMessage('Please select a model to start chatting.') setMessage('Please select a model to start chatting.')
return return
@ -191,31 +188,10 @@ const ChatInput = ({
sendMessage( sendMessage(
prompt, prompt,
true, true,
uploadedFiles.length > 0 ? uploadedFiles : undefined uploadedFiles.length > 0 ? uploadedFiles : undefined,
projectId
) )
setUploadedFiles([]) setUploadedFiles([])
// Handle project assignment for new threads
if (projectId && !currentThreadId) {
const project = getFolderById(projectId)
if (project) {
// Use setTimeout to ensure the thread is created first
setTimeout(() => {
const newCurrentThreadId = useThreads.getState().currentThreadId
if (newCurrentThreadId) {
updateThread(newCurrentThreadId, {
metadata: {
project: {
id: project.id,
name: project.name,
updated_at: project.updated_at,
},
},
})
}
}, 100)
}
}
} }
useEffect(() => { useEffect(() => {

View File

@ -164,7 +164,7 @@ const LeftPanel = () => {
const getFilteredThreads = useThreads((state) => state.getFilteredThreads) const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
const threads = useThreads((state) => state.threads) const threads = useThreads((state) => state.threads)
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } = const { folders, addFolder, updateFolder, getFolderById } =
useThreadManagement() useThreadManagement()
// Project dialog states // Project dialog states
@ -205,19 +205,16 @@ const LeftPanel = () => {
setDeleteProjectConfirmOpen(true) setDeleteProjectConfirmOpen(true)
} }
const confirmProjectDelete = () => { const handleProjectDeleteClose = () => {
if (deletingProjectId) {
deleteFolder(deletingProjectId)
setDeleteProjectConfirmOpen(false) setDeleteProjectConfirmOpen(false)
setDeletingProjectId(null) setDeletingProjectId(null)
} }
}
const handleProjectSave = (name: string) => { const handleProjectSave = async (name: string) => {
if (editingProjectKey) { if (editingProjectKey) {
updateFolder(editingProjectKey, name) await updateFolder(editingProjectKey, name)
} else { } else {
const newProject = addFolder(name) const newProject = await addFolder(name)
// Navigate to the newly created project // Navigate to the newly created project
navigate({ navigate({
to: '/project/$projectId', to: '/project/$projectId',
@ -681,8 +678,8 @@ const LeftPanel = () => {
/> />
<DeleteProjectDialog <DeleteProjectDialog
open={deleteProjectConfirmOpen} open={deleteProjectConfirmOpen}
onOpenChange={setDeleteProjectConfirmOpen} onOpenChange={handleProjectDeleteClose}
onConfirm={confirmProjectDelete} projectId={deletingProjectId ?? undefined}
projectName={ projectName={
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
} }

View File

@ -47,9 +47,11 @@ const SortableItem = memo(
({ ({
thread, thread,
variant, variant,
currentProjectId,
}: { }: {
thread: Thread thread: Thread
variant?: 'default' | 'project' variant?: 'default' | 'project'
currentProjectId?: string
}) => { }) => {
const { const {
attributes, attributes,
@ -108,6 +110,18 @@ const SortableItem = memo(
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '') return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
}, [thread.title]) }, [thread.title])
const availableProjects = useMemo(() => {
return folders
.filter((f) => {
// Exclude the current project page we're on
if (f.id === currentProjectId) return false
// Exclude the project this thread is already assigned to
if (f.id === thread.metadata?.project?.id) return false
return true
})
.sort((a, b) => b.updated_at - a.updated_at)
}, [folders, currentProjectId, thread.metadata?.project?.id])
const assignThreadToProject = (threadId: string, projectId: string) => { const assignThreadToProject = (threadId: string, projectId: string) => {
const project = getFolderById(projectId) const project = getFolderById(projectId)
if (project && updateThread) { if (project && updateThread) {
@ -226,16 +240,14 @@ const SortableItem = memo(
<span>Add to project</span> <span>Add to project</span>
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent>
{folders.length === 0 ? ( {availableProjects.length === 0 ? (
<DropdownMenuItem disabled> <DropdownMenuItem disabled>
<span className="text-left-panel-fg/50"> <span className="text-left-panel-fg/50">
No projects available No projects available
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
folders availableProjects.map((folder) => (
.sort((a, b) => b.updated_at - a.updated_at)
.map((folder) => (
<DropdownMenuItem <DropdownMenuItem
key={folder.id} key={folder.id}
onClick={(e) => { onClick={(e) => {
@ -296,9 +308,10 @@ type ThreadListProps = {
isFavoriteSection?: boolean isFavoriteSection?: boolean
variant?: 'default' | 'project' variant?: 'default' | 'project'
showDate?: boolean showDate?: boolean
currentProjectId?: string
} }
function ThreadList({ threads, variant = 'default' }: ThreadListProps) { function ThreadList({ threads, variant = 'default', currentProjectId }: ThreadListProps) {
const sortedThreads = useMemo(() => { const sortedThreads = useMemo(() => {
return threads.sort((a, b) => { return threads.sort((a, b) => {
return (b.updated || 0) - (a.updated || 0) return (b.updated || 0) - (a.updated || 0)
@ -322,7 +335,7 @@ function ThreadList({ threads, variant = 'default' }: ThreadListProps) {
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{sortedThreads.map((thread, index) => ( {sortedThreads.map((thread, index) => (
<SortableItem key={index} thread={thread} variant={variant} /> <SortableItem key={index} thread={thread} variant={variant} currentProjectId={currentProjectId} />
))} ))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DialogEditModel } from '../dialogs/EditModel' import { DialogEditModel } from '../dialogs/EditModel'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
@ -38,8 +37,8 @@ vi.mock('sonner', () => ({
vi.mock('@/components/ui/dialog', () => ({ vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null, open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => ( DialogContent: ({ children, onKeyDown }: { children: React.ReactNode; onKeyDown?: (e: React.KeyboardEvent) => void }) => (
<div data-testid="dialog-content">{children}</div> <div data-testid="dialog-content" onKeyDown={onKeyDown}>{children}</div>
), ),
DialogHeader: ({ children }: { children: React.ReactNode }) => ( DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div> <div data-testid="dialog-header">{children}</div>
@ -181,4 +180,67 @@ describe('DialogEditModel - Basic Component Tests', () => {
expect(mockUpdateProvider).toBeDefined() expect(mockUpdateProvider).toBeDefined()
expect(mockSetProviders).toBeDefined() expect(mockSetProviders).toBeDefined()
}) })
it('should consolidate capabilities initialization without duplication', () => {
const providerWithCaps = {
provider: 'llamacpp',
active: true,
models: [
{
id: 'test-model.gguf',
displayName: 'Test Model',
capabilities: ['vision', 'tools'],
},
],
settings: [],
} as any
const { container } = render(
<DialogEditModel
provider={providerWithCaps}
modelId="test-model.gguf"
/>
)
// Should render without issues - capabilities helper function should work
expect(container).toBeInTheDocument()
})
it('should handle Enter key press with keyDown handler', () => {
const { container } = render(
<DialogEditModel
provider={mockProvider}
modelId="test-model.gguf"
/>
)
// Component should render with keyDown handler
expect(container).toBeInTheDocument()
})
it('should handle vision and tools capabilities', () => {
const providerWithAllCaps = {
provider: 'llamacpp',
active: true,
models: [
{
id: 'test-model.gguf',
displayName: 'Test Model',
capabilities: ['vision', 'tools', 'completion', 'embeddings', 'web_search', 'reasoning'],
},
],
settings: [],
} as any
const { container } = render(
<DialogEditModel
provider={providerWithAllCaps}
modelId="test-model.gguf"
/>
)
// Component should render without errors even with extra capabilities
// The capabilities helper should only extract vision and tools
expect(container).toBeInTheDocument()
})
}) })

View File

@ -1,4 +1,4 @@
import { useRef } from 'react' import { useRef, useMemo } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,26 +10,49 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement'
interface DeleteProjectDialogProps { interface DeleteProjectDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onConfirm: () => void projectId?: string
projectName?: string projectName?: string
} }
export function DeleteProjectDialog({ export function DeleteProjectDialog({
open, open,
onOpenChange, onOpenChange,
onConfirm, projectId,
projectName, projectName,
}: DeleteProjectDialogProps) { }: DeleteProjectDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const deleteButtonRef = useRef<HTMLButtonElement>(null) const deleteButtonRef = useRef<HTMLButtonElement>(null)
const threads = useThreads((state) => state.threads)
const { deleteFolderWithThreads } = useThreadManagement()
// Calculate thread stats for this project
const { threadCount, starredThreadCount } = useMemo(() => {
if (!projectId) return { threadCount: 0, starredThreadCount: 0 }
const projectThreads = Object.values(threads).filter(
(thread) => thread.metadata?.project?.id === projectId
)
const starredCount = projectThreads.filter(
(thread) => thread.isFavorite
).length
return {
threadCount: projectThreads.length,
starredThreadCount: starredCount,
}
}, [projectId, threads])
const handleConfirm = async () => {
if (!projectId) return
const handleConfirm = () => {
try { try {
onConfirm() await deleteFolderWithThreads(projectId)
toast.success( toast.success(
projectName projectName
? t('projects.deleteProjectDialog.successWithName', { projectName }) ? t('projects.deleteProjectDialog.successWithName', { projectName })
@ -42,12 +65,15 @@ export function DeleteProjectDialog({
} }
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleConfirm() await handleConfirm()
} }
} }
const hasStarredThreads = starredThreadCount > 0
const hasThreads = threadCount > 0
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
@ -59,8 +85,30 @@ export function DeleteProjectDialog({
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{t('projects.deleteProjectDialog.title')}</DialogTitle> <DialogTitle>{t('projects.deleteProjectDialog.title')}</DialogTitle>
<DialogDescription> <DialogDescription className="space-y-2">
{t('projects.deleteProjectDialog.description')} {hasStarredThreads ? (
<>
<p className="text-red-600 dark:text-red-400 font-semibold">
{t('projects.deleteProjectDialog.starredWarning')}
</p>
<p className="font-medium">
{t('projects.deleteProjectDialog.permanentDeleteWarning')}
</p>
</>
) : hasThreads ? (
<p>
{t('projects.deleteProjectDialog.permanentDelete')}
</p>
) : (
<p>
{t('projects.deleteProjectDialog.deleteEmptyProject', { projectName })}
</p>
)}
{hasThreads && (
<p className="text-sm text-muted-foreground mt-3">
{t('projects.deleteProjectDialog.saveThreadsAdvice')}
</p>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@ -17,9 +17,6 @@ import {
IconTool, IconTool,
IconAlertTriangle, IconAlertTriangle,
IconLoader2, IconLoader2,
// IconWorld,
// IconAtom,
// IconCodeCircle2,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
@ -46,69 +43,45 @@ export const DialogEditModel = ({
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [capabilities, setCapabilities] = useState<Record<string, boolean>>({ const [capabilities, setCapabilities] = useState<Record<string, boolean>>({
completion: false,
vision: false, vision: false,
tools: false, tools: false,
reasoning: false,
embeddings: false,
web_search: false,
}) })
// Initialize with the provided model ID or the first model if available // Initialize with the provided model ID or the first model if available
useEffect(() => { useEffect(() => {
// Only set the selected model ID if the dialog is not open to prevent switching during downloads if (isOpen && !selectedModelId || !isOpen) {
if (!isOpen) {
if (modelId) { if (modelId) {
setSelectedModelId(modelId) setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) { } else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id) setSelectedModelId(provider.models[0].id)
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [modelId, isOpen, selectedModelId, provider.models])
}, [modelId, isOpen]) // Add isOpen dependency to prevent switching when dialog is open
// Handle dialog opening - set the initial model selection
useEffect(() => {
if (isOpen && !selectedModelId) {
if (modelId) {
setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id)
}
}
}, [isOpen, selectedModelId, modelId, provider.models])
// Get the currently selected model // Get the currently selected model
const selectedModel = provider.models.find( const selectedModel = provider.models.find(
(m: Model) => m.id === selectedModelId (m: Model) => m.id === selectedModelId
) )
// Helper function to convert capabilities array to object
const capabilitiesToObject = (capabilitiesList: string[]) => ({
vision: capabilitiesList.includes('vision'),
tools: capabilitiesList.includes('tools'),
})
// Initialize capabilities and display name from selected model // Initialize capabilities and display name from selected model
useEffect(() => { useEffect(() => {
if (selectedModel) { if (selectedModel) {
const modelCapabilities = selectedModel.capabilities || [] const modelCapabilities = selectedModel.capabilities || []
setCapabilities({ const capsObject = capabilitiesToObject(modelCapabilities)
completion: modelCapabilities.includes('completion'),
vision: modelCapabilities.includes('vision'), setCapabilities(capsObject)
tools: modelCapabilities.includes('tools'), setOriginalCapabilities(capsObject)
embeddings: modelCapabilities.includes('embeddings'),
web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'),
})
// Use existing displayName if available, otherwise fall back to model ID // Use existing displayName if available, otherwise fall back to model ID
const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id
setDisplayName(displayNameValue) setDisplayName(displayNameValue)
setOriginalDisplayName(displayNameValue) setOriginalDisplayName(displayNameValue)
const originalCaps = {
completion: modelCapabilities.includes('completion'),
vision: modelCapabilities.includes('vision'),
tools: modelCapabilities.includes('tools'),
embeddings: modelCapabilities.includes('embeddings'),
web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'),
}
setOriginalCapabilities(originalCaps)
} }
}, [selectedModel]) }, [selectedModel])
@ -139,46 +112,27 @@ export const DialogEditModel = ({
setIsLoading(true) setIsLoading(true)
try { try {
let updatedModels = provider.models const nameChanged = displayName !== originalDisplayName
const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
// Update display name if changed // Build the update object for the selected model
if (displayName !== originalDisplayName) { const modelUpdate: Partial<Model> & { _userConfiguredCapabilities?: boolean } = {}
// Update the model in the provider models array with displayName
updatedModels = updatedModels.map((m: Model) => { if (nameChanged) {
if (m.id === selectedModelId) { modelUpdate.displayName = displayName
return {
...m,
displayName: displayName,
}
}
return m
})
setOriginalDisplayName(displayName)
} }
// Update capabilities if changed if (capabilitiesChanged) {
if ( modelUpdate.capabilities = Object.entries(capabilities)
JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)
) {
const updatedCapabilities = Object.entries(capabilities)
.filter(([, isEnabled]) => isEnabled) .filter(([, isEnabled]) => isEnabled)
.map(([capName]) => capName) .map(([capName]) => capName)
modelUpdate._userConfiguredCapabilities = true
}
// Find and update the model in the provider // Update the model in the provider models array
updatedModels = updatedModels.map((m: Model) => { const updatedModels = provider.models.map((m: Model) =>
if (m.id === selectedModelId) { m.id === selectedModelId ? { ...m, ...modelUpdate } : m
return { )
...m,
capabilities: updatedCapabilities,
// Mark that user has manually configured capabilities
_userConfiguredCapabilities: true,
}
}
return m
})
setOriginalCapabilities(capabilities)
}
// Update the provider with the updated models // Update the provider with the updated models
updateProvider(provider.provider, { updateProvider(provider.provider, {
@ -186,6 +140,10 @@ export const DialogEditModel = ({
models: updatedModels, models: updatedModels,
}) })
// Update original values
if (nameChanged) setOriginalDisplayName(displayName)
if (capabilitiesChanged) setOriginalCapabilities(capabilities)
// Show success toast and close dialog // Show success toast and close dialog
toast.success('Model updated successfully') toast.success('Model updated successfully')
setIsOpen(false) setIsOpen(false)
@ -201,14 +159,32 @@ export const DialogEditModel = ({
return null return null
} }
// Handle dialog close - reset to original values if not saved
const handleDialogChange = (open: boolean) => {
if (!open && hasUnsavedChanges()) {
// Reset to original values when closing without saving
setDisplayName(originalDisplayName)
setCapabilities(originalCapabilities)
}
setIsOpen(open)
}
// Handle keyboard events for Enter key
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && hasUnsavedChanges() && !isLoading) {
e.preventDefault()
handleSaveChanges()
}
}
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={handleDialogChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconPencil size={18} className="text-main-view-fg/50" /> <IconPencil size={18} className="text-main-view-fg/50" />
</div> </div>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent onKeyDown={handleKeyDown}>
<DialogHeader> <DialogHeader>
<DialogTitle className="line-clamp-1" title={selectedModel.id}> <DialogTitle className="line-clamp-1" title={selectedModel.id}>
{t('providers:editModel.title', { modelId: selectedModel.id })} {t('providers:editModel.title', { modelId: selectedModel.id })}
@ -292,58 +268,6 @@ export const DialogEditModel = ({
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
{/* <div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconCodeCircle2 className="size-4 text-main-view-fg/70" />
<span className="text-sm">
{t('providers:editModel.embeddings')}
</span>
</div>
<Tooltip>
<TooltipTrigger>
<Switch
id="embedding-capability"
disabled={true}
checked={capabilities.embeddings}
onCheckedChange={(checked) =>
handleCapabilityChange('embeddings', checked)
}
/>
</TooltipTrigger>
<TooltipContent>
{t('providers:editModel.notAvailable')}
</TooltipContent>
</Tooltip>
</div> */}
{/* <div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconWorld className="size-4 text-main-view-fg/70" />
<span className="text-sm">Web Search</span>
</div>
<Switch
id="web_search-capability"
checked={capabilities.web_search}
onCheckedChange={(checked) =>
handleCapabilityChange('web_search', checked)
}
/>
</div> */}
{/* <div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconAtom className="size-4 text-main-view-fg/70" />
<span className="text-sm">{t('reasoning')}</span>
</div>
<Switch
id="reasoning-capability"
checked={capabilities.reasoning}
onCheckedChange={(checked) =>
handleCapabilityChange('reasoning', checked)
}
/>
</div> */}
</div> </div>
</div> </div>

View File

@ -284,7 +284,7 @@ export const useChat = () => {
const setModelLoadError = useModelLoad((state) => state.setModelLoadError) const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
const router = useRouter() const router = useRouter()
const getCurrentThread = useCallback(async () => { const getCurrentThread = useCallback(async (projectId?: string) => {
let currentThread = retrieveThread() let currentThread = retrieveThread()
// Check if we're in temporary chat mode // Check if we're in temporary chat mode
@ -303,6 +303,19 @@ export const useChat = () => {
const selectedModel = useModelProvider.getState().selectedModel const selectedModel = useModelProvider.getState().selectedModel
const selectedProvider = useModelProvider.getState().selectedProvider const selectedProvider = useModelProvider.getState().selectedProvider
// Get project metadata if projectId is provided
let projectMetadata: { id: string; name: string; updated_at: number } | undefined
if (projectId) {
const project = await serviceHub.projects().getProjectById(projectId)
if (project) {
projectMetadata = {
id: project.id,
name: project.name,
updated_at: project.updated_at,
}
}
}
currentThread = await createThread( currentThread = await createThread(
{ {
id: selectedModel?.id ?? defaultModel(selectedProvider), id: selectedModel?.id ?? defaultModel(selectedProvider),
@ -310,7 +323,7 @@ export const useChat = () => {
}, },
isTemporaryMode ? 'Temporary Chat' : currentPrompt, isTemporaryMode ? 'Temporary Chat' : currentPrompt,
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0], assistants.find((a) => a.id === currentAssistant?.id) || assistants[0],
undefined, // no project metadata projectMetadata,
isTemporaryMode // pass temporary flag isTemporaryMode // pass temporary flag
) )
@ -330,7 +343,7 @@ export const useChat = () => {
}) })
} }
return currentThread return currentThread
}, [createThread, retrieveThread, router, setMessages]) }, [createThread, retrieveThread, router, setMessages, serviceHub])
const restartModel = useCallback( const restartModel = useCallback(
async (provider: ProviderObject, modelId: string) => { async (provider: ProviderObject, modelId: string) => {
@ -445,9 +458,10 @@ export const useChat = () => {
base64: string base64: string
dataUrl: string dataUrl: string
}>, }>,
projectId?: string,
continueFromMessageId?: string continueFromMessageId?: string
) => { ) => {
const activeThread = await getCurrentThread() const activeThread = await getCurrentThread(projectId)
const selectedProvider = useModelProvider.getState().selectedProvider const selectedProvider = useModelProvider.getState().selectedProvider
let activeProvider = getProviderByName(selectedProvider) let activeProvider = getProviderByName(selectedProvider)

View File

@ -1,56 +1,43 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware' import { getServiceHub } from '@/hooks/useServiceHub'
import { ulid } from 'ulidx'
import { localStorageKey } from '@/constants/localStorage'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import type { ThreadFolder } from '@/services/projects/types'
type ThreadFolder = { import { useEffect } from 'react'
id: string
name: string
updated_at: number
}
type ThreadManagementState = { type ThreadManagementState = {
folders: ThreadFolder[] folders: ThreadFolder[]
setFolders: (folders: ThreadFolder[]) => void setFolders: (folders: ThreadFolder[]) => void
addFolder: (name: string) => ThreadFolder addFolder: (name: string) => Promise<ThreadFolder>
updateFolder: (id: string, name: string) => void updateFolder: (id: string, name: string) => Promise<void>
deleteFolder: (id: string) => void deleteFolder: (id: string) => Promise<void>
deleteFolderWithThreads: (id: string) => Promise<void>
getFolderById: (id: string) => ThreadFolder | undefined getFolderById: (id: string) => ThreadFolder | undefined
getProjectById: (id: string) => Promise<ThreadFolder | undefined>
} }
export const useThreadManagement = create<ThreadManagementState>()( const useThreadManagementStore = create<ThreadManagementState>()((set, get) => ({
persist(
(set, get) => ({
folders: [], folders: [],
setFolders: (folders) => { setFolders: (folders) => {
set({ folders }) set({ folders })
}, },
addFolder: (name) => { addFolder: async (name) => {
const newFolder: ThreadFolder = { const projectsService = getServiceHub().projects()
id: ulid(), const newFolder = await projectsService.addProject(name)
name, const updatedProjects = await projectsService.getProjects()
updated_at: Date.now(), set({ folders: updatedProjects })
}
set((state) => ({
folders: [...state.folders, newFolder],
}))
return newFolder return newFolder
}, },
updateFolder: (id, name) => { updateFolder: async (id, name) => {
set((state) => ({ const projectsService = getServiceHub().projects()
folders: state.folders.map((folder) => await projectsService.updateProject(id, name)
folder.id === id const updatedProjects = await projectsService.getProjects()
? { ...folder, name, updated_at: Date.now() } set({ folders: updatedProjects })
: folder
),
}))
}, },
deleteFolder: (id) => { deleteFolder: async (id) => {
// Remove project metadata from all threads that belong to this project // Remove project metadata from all threads that belong to this project
const threadsState = useThreads.getState() const threadsState = useThreads.getState()
const threadsToUpdate = Object.values(threadsState.threads).filter( const threadsToUpdate = Object.values(threadsState.threads).filter(
@ -66,18 +53,64 @@ export const useThreadManagement = create<ThreadManagementState>()(
}) })
}) })
set((state) => ({ const projectsService = getServiceHub().projects()
folders: state.folders.filter((folder) => folder.id !== id), await projectsService.deleteProject(id)
})) const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
},
deleteFolderWithThreads: async (id) => {
// Get all threads that belong to this project
const threadsState = useThreads.getState()
const projectThreads = Object.values(threadsState.threads).filter(
(thread) => thread.metadata?.project?.id === id
)
// Delete threads from backend first
const serviceHub = getServiceHub()
for (const thread of projectThreads) {
await serviceHub.threads().deleteThread(thread.id)
}
// Delete threads from frontend state
for (const thread of projectThreads) {
threadsState.deleteThread(thread.id)
}
// Delete the project from storage
const projectsService = serviceHub.projects()
await projectsService.deleteProject(id)
const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
}, },
getFolderById: (id) => { getFolderById: (id) => {
return get().folders.find((folder) => folder.id === id) return get().folders.find((folder) => folder.id === id)
}, },
}),
{ getProjectById: async (id) => {
name: localStorageKey.threadManagement, const projectsService = getServiceHub().projects()
storage: createJSONStorage(() => localStorage), return await projectsService.getProjectById(id)
},
}))
export const useThreadManagement = () => {
const store = useThreadManagementStore()
// Load projects from service on mount
useEffect(() => {
const syncProjects = async () => {
try {
const projectsService = getServiceHub().projects()
const projects = await projectsService.getProjects()
useThreadManagementStore.setState({ folders: projects })
} catch (error) {
console.error('Error syncing projects:', error)
}
}
syncProjects()
}, [])
return store
} }
)
)

View File

@ -2,6 +2,18 @@
* Google Analytics utility functions * Google Analytics utility functions
*/ */
/**
* Track page views for SPA navigation
*/
export const pageview = (path: string) => {
if (!window.gtag) return
window.gtag('event', 'page_view', {
page_location: window.location.href,
page_path: path,
})
}
/** /**
* Track custom events with Google Analytics * Track custom events with Google Analytics
*/ */
@ -9,9 +21,7 @@ export function trackEvent(
eventName: string, eventName: string,
parameters?: Record<string, unknown> parameters?: Record<string, unknown>
) { ) {
if (!window.gtag) { if (!window.gtag) return
return
}
window.gtag('event', eventName, parameters) window.gtag('event', eventName, parameters)
} }

View File

@ -254,7 +254,11 @@
"projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.", "projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.",
"deleteProjectDialog": { "deleteProjectDialog": {
"title": "Delete Project", "title": "Delete Project",
"description": "Are you sure you want to delete this project? This action cannot be undone.", "permanentDelete": "This will permanently delete all threads.",
"permanentDeleteWarning": "This action will permanently delete ALL threads within the project!",
"deleteEmptyProject": "This action will delete project \"{{projectName}}\".",
"saveThreadsAdvice": "To save threads, move them to your thread list or another project before deleting.",
"starredWarning": "You still have starred threads within the project.",
"deleteButton": "Delete", "deleteButton": "Delete",
"successWithName": "Project \"{{projectName}}\" deleted successfully", "successWithName": "Project \"{{projectName}}\" deleted successfully",
"successWithoutName": "Project deleted successfully", "successWithoutName": "Project deleted successfully",
@ -366,3 +370,4 @@
} }
} }
} }

View File

@ -31,19 +31,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
const fetchUserData = useCallback(async () => { const fetchUserData = useCallback(async () => {
try { try {
const { setThreads } = useThreads.getState() const { setThreads } = useThreads.getState()
const { setMessages } = useMessages.getState()
// Fetch threads first // Fetch threads first
const threads = await serviceHub.threads().fetchThreads() const threads = await serviceHub.threads().fetchThreads()
setThreads(threads) setThreads(threads)
// Fetch messages for each thread
const messagePromises = threads.map(async (thread) => {
const messages = await serviceHub.messages().fetchMessages(thread.id)
setMessages(thread.id, messages)
})
await Promise.all(messagePromises)
} catch (error) { } catch (error) {
console.error('Failed to fetch user data:', error) console.error('Failed to fetch user data:', error)
} }

View File

@ -1,4 +1,3 @@
import { useMessages } from '@/hooks/useMessages'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { useAppUpdater } from '@/hooks/useAppUpdater' import { useAppUpdater } from '@/hooks/useAppUpdater'
@ -19,7 +18,6 @@ export function DataProvider() {
const { setProviders, selectedModel, selectedProvider, getProviderByName } = const { setProviders, selectedModel, selectedProvider, getProviderByName } =
useModelProvider() useModelProvider()
const { setMessages } = useMessages()
const { checkForUpdate } = useAppUpdater() const { checkForUpdate } = useAppUpdater()
const { setServers } = useMCPServers() const { setServers } = useMCPServers()
const { setAssistants, initializeWithLastUsed } = useAssistant() const { setAssistants, initializeWithLastUsed } = useAssistant()
@ -85,14 +83,8 @@ export function DataProvider() {
.fetchThreads() .fetchThreads()
.then((threads) => { .then((threads) => {
setThreads(threads) setThreads(threads)
threads.forEach((thread) =>
serviceHub
.messages()
.fetchMessages(thread.id)
.then((messages) => setMessages(thread.id, messages))
)
}) })
}, [serviceHub, setThreads, setMessages]) }, [serviceHub, setThreads])
// Check for app updates // Check for app updates
useEffect(() => { useEffect(() => {

View File

@ -1,60 +1,21 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useLocation } from '@tanstack/react-router' import { useLocation } from '@tanstack/react-router'
import { pageview } from '@/lib/analytics'
export function GoogleAnalyticsProvider() { export function GoogleAnalyticsProvider() {
const location = useLocation() 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 // Track page views on route change
useEffect(() => { useEffect(() => {
if (!window.gtag) { // Skip if GA is not configured
if (!GA_MEASUREMENT_ID) {
return return
} }
window.gtag('event', 'page_view', { // Track page view with current path
page_path: location.pathname + location.search, const path = location.pathname + (window.location.search || '')
page_location: window.location.href, pageview(path)
page_title: document.title,
})
}, [location]) }, [location])
return null return null

View File

@ -71,7 +71,9 @@ function Index() {
return ( return (
<div className="flex h-full flex-col justify-center pb-[calc(env(safe-area-inset-bottom)+env(safe-area-inset-top))]"> <div className="flex h-full flex-col justify-center pb-[calc(env(safe-area-inset-bottom)+env(safe-area-inset-top))]">
<HeaderPage> <HeaderPage>
<div className="flex items-center justify-between w-full pr-2">
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />} {PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
</div>
</HeaderPage> </HeaderPage>
<div <div
className={cn( className={cn(

View File

@ -105,7 +105,7 @@ function ProjectPage() {
{/* Thread List or Empty State */} {/* Thread List or Empty State */}
<div className="mb-0"> <div className="mb-0">
{projectThreads.length > 0 ? ( {projectThreads.length > 0 ? (
<ThreadList threads={projectThreads} variant="project" /> <ThreadList threads={projectThreads} variant="project" currentProjectId={projectId} />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<IconMessage <IconMessage

View File

@ -32,7 +32,7 @@ function Project() {
function ProjectContent() { function ProjectContent() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } = const { folders, addFolder, updateFolder, getFolderById } =
useThreadManagement() useThreadManagement()
const threads = useThreads((state) => state.threads) const threads = useThreads((state) => state.threads)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -48,19 +48,16 @@ function ProjectContent() {
setDeleteConfirmOpen(true) setDeleteConfirmOpen(true)
} }
const confirmDelete = () => { const handleDeleteClose = () => {
if (deletingId) {
deleteFolder(deletingId)
setDeleteConfirmOpen(false) setDeleteConfirmOpen(false)
setDeletingId(null) setDeletingId(null)
} }
}
const handleSave = (name: string) => { const handleSave = async (name: string) => {
if (editingKey) { if (editingKey) {
updateFolder(editingKey, name) await updateFolder(editingKey, name)
} else { } else {
const newProject = addFolder(name) const newProject = await addFolder(name)
// Navigate to the newly created project // Navigate to the newly created project
navigate({ navigate({
to: '/project/$projectId', to: '/project/$projectId',
@ -244,8 +241,8 @@ function ProjectContent() {
/> />
<DeleteProjectDialog <DeleteProjectDialog
open={deleteConfirmOpen} open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen} onOpenChange={handleDeleteClose}
onConfirm={confirmDelete} projectId={deletingId ?? undefined}
projectName={deletingId ? getFolderById(deletingId)?.name : undefined} projectName={deletingId ? getFolderById(deletingId)?.name : undefined}
/> />
</div> </div>

View File

@ -26,6 +26,7 @@ import { DefaultUpdaterService } from './updater/default'
import { DefaultPathService } from './path/default' import { DefaultPathService } from './path/default'
import { DefaultCoreService } from './core/default' import { DefaultCoreService } from './core/default'
import { DefaultDeepLinkService } from './deeplink/default' import { DefaultDeepLinkService } from './deeplink/default'
import { DefaultProjectsService } from './projects/default'
// Import service types // Import service types
import type { ThemeService } from './theme/types' import type { ThemeService } from './theme/types'
@ -46,6 +47,7 @@ import type { UpdaterService } from './updater/types'
import type { PathService } from './path/types' import type { PathService } from './path/types'
import type { CoreService } from './core/types' import type { CoreService } from './core/types'
import type { DeepLinkService } from './deeplink/types' import type { DeepLinkService } from './deeplink/types'
import type { ProjectsService } from './projects/types'
export interface ServiceHub { export interface ServiceHub {
// Service getters - all synchronous after initialization // Service getters - all synchronous after initialization
@ -67,6 +69,7 @@ export interface ServiceHub {
path(): PathService path(): PathService
core(): CoreService core(): CoreService
deeplink(): DeepLinkService deeplink(): DeepLinkService
projects(): ProjectsService
} }
class PlatformServiceHub implements ServiceHub { class PlatformServiceHub implements ServiceHub {
@ -88,6 +91,7 @@ class PlatformServiceHub implements ServiceHub {
private pathService: PathService = new DefaultPathService() private pathService: PathService = new DefaultPathService()
private coreService: CoreService = new DefaultCoreService() private coreService: CoreService = new DefaultCoreService()
private deepLinkService: DeepLinkService = new DefaultDeepLinkService() private deepLinkService: DeepLinkService = new DefaultDeepLinkService()
private projectsService: ProjectsService = new DefaultProjectsService()
private initialized = false private initialized = false
/** /**
@ -158,6 +162,7 @@ class PlatformServiceHub implements ServiceHub {
deepLinkModule, deepLinkModule,
providersModule, providersModule,
mcpModule, mcpModule,
projectsModule,
] = await Promise.all([ ] = await Promise.all([
import('./theme/web'), import('./theme/web'),
import('./app/web'), import('./app/web'),
@ -169,6 +174,7 @@ class PlatformServiceHub implements ServiceHub {
import('./deeplink/web'), import('./deeplink/web'),
import('./providers/web'), import('./providers/web'),
import('./mcp/web'), import('./mcp/web'),
import('./projects/web'),
]) ])
this.themeService = new themeModule.WebThemeService() this.themeService = new themeModule.WebThemeService()
@ -181,6 +187,7 @@ class PlatformServiceHub implements ServiceHub {
this.deepLinkService = new deepLinkModule.WebDeepLinkService() this.deepLinkService = new deepLinkModule.WebDeepLinkService()
this.providersService = new providersModule.WebProvidersService() this.providersService = new providersModule.WebProvidersService()
this.mcpService = new mcpModule.WebMCPService() this.mcpService = new mcpModule.WebMCPService()
this.projectsService = new projectsModule.WebProjectsService()
} }
this.initialized = true this.initialized = true
@ -290,6 +297,11 @@ class PlatformServiceHub implements ServiceHub {
this.ensureInitialized() this.ensureInitialized()
return this.deepLinkService return this.deepLinkService
} }
projects(): ProjectsService {
this.ensureInitialized()
return this.projectsService
}
} }
export async function initializeServiceHub(): Promise<ServiceHub> { export async function initializeServiceHub(): Promise<ServiceHub> {

View File

@ -0,0 +1,78 @@
/**
* Default Projects Service - localStorage implementation
*/
import { ulid } from 'ulidx'
import type { ProjectsService, ThreadFolder } from './types'
import { localStorageKey } from '@/constants/localStorage'
export class DefaultProjectsService implements ProjectsService {
private storageKey = localStorageKey.threadManagement
private loadFromStorage(): ThreadFolder[] {
try {
const stored = localStorage.getItem(this.storageKey)
if (!stored) return []
const data = JSON.parse(stored)
return data.state?.folders || []
} catch (error) {
console.error('Error loading projects from localStorage:', error)
return []
}
}
private saveToStorage(projects: ThreadFolder[]): void {
try {
const data = {
state: { folders: projects },
version: 0,
}
localStorage.setItem(this.storageKey, JSON.stringify(data))
} catch (error) {
console.error('Error saving projects to localStorage:', error)
}
}
async getProjects(): Promise<ThreadFolder[]> {
return this.loadFromStorage()
}
async addProject(name: string): Promise<ThreadFolder> {
const newProject: ThreadFolder = {
id: ulid(),
name,
updated_at: Date.now(),
}
const projects = this.loadFromStorage()
const updatedProjects = [...projects, newProject]
this.saveToStorage(updatedProjects)
return newProject
}
async updateProject(id: string, name: string): Promise<void> {
const projects = this.loadFromStorage()
const updatedProjects = projects.map((project) =>
project.id === id
? { ...project, name, updated_at: Date.now() }
: project
)
this.saveToStorage(updatedProjects)
}
async deleteProject(id: string): Promise<void> {
const projects = this.loadFromStorage()
const updatedProjects = projects.filter((project) => project.id !== id)
this.saveToStorage(updatedProjects)
}
async getProjectById(id: string): Promise<ThreadFolder | undefined> {
const projects = this.loadFromStorage()
return projects.find((project) => project.id === id)
}
async setProjects(projects: ThreadFolder[]): Promise<void> {
this.saveToStorage(projects)
}
}

View File

@ -0,0 +1,42 @@
/**
* Projects Service Types
* Types for project/folder management operations
*/
export interface ThreadFolder {
id: string
name: string
updated_at: number
}
export interface ProjectsService {
/**
* Get all projects/folders
*/
getProjects(): Promise<ThreadFolder[]>
/**
* Add a new project/folder
*/
addProject(name: string): Promise<ThreadFolder>
/**
* Update a project/folder name
*/
updateProject(id: string, name: string): Promise<void>
/**
* Delete a project/folder
*/
deleteProject(id: string): Promise<void>
/**
* Get a project/folder by ID
*/
getProjectById(id: string): Promise<ThreadFolder | undefined>
/**
* Set all projects/folders (for bulk updates)
*/
setProjects(projects: ThreadFolder[]): Promise<void>
}

View File

@ -0,0 +1,11 @@
/**
* Web Projects Service - Web implementation
* Currently extends default, will be customized by extension-web team later
*/
import { DefaultProjectsService } from './default'
export class WebProjectsService extends DefaultProjectsService {
// Currently uses the same localStorage implementation as default
// Extension-web team can override methods here later
}

View File

@ -71,6 +71,7 @@ export class DefaultThreadsService implements ThreadsService {
}, },
], ],
metadata: { metadata: {
...thread.metadata,
order: thread.order, order: thread.order,
}, },
}) })

View File

@ -1,4 +1,4 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv, Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
@ -7,6 +7,41 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'
import packageJson from './package.json' import packageJson from './package.json'
const host = process.env.TAURI_DEV_HOST const host = process.env.TAURI_DEV_HOST
// Plugin to inject GA scripts in HTML
function injectGoogleAnalytics(gaMeasurementId?: string): Plugin {
return {
name: 'inject-google-analytics',
transformIndexHtml(html) {
// Only inject GA scripts if GA_MEASUREMENT_ID is set
if (!gaMeasurementId) {
// Remove placeholder if no GA ID
return html.replace(/\s*<!-- INJECT_GOOGLE_ANALYTICS -->\n?/g, '')
}
const gaScripts = `<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${gaMeasurementId}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('consent','default',{
ad_storage:'denied',
analytics_storage:'denied',
ad_user_data:'denied',
ad_personalization:'denied',
wait_for_update:500
});
gtag('js', new Date());
gtag('config', '${gaMeasurementId}', {
debug_mode: (location.hostname === 'localhost'),
send_page_view: false
});
</script>`
return html.replace('<!-- INJECT_GOOGLE_ANALYTICS -->', gaScripts)
},
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory. // Load env file based on `mode` in the current working directory.
@ -24,6 +59,7 @@ export default defineConfig(({ mode }) => {
nodePolyfills({ nodePolyfills({
include: ['path'], include: ['path'],
}), }),
injectGoogleAnalytics(env.GA_MEASUREMENT_ID),
], ],
resolve: { resolve: {
alias: { alias: {

View File

@ -1,9 +1,46 @@
import { defineConfig } from 'vite' import { defineConfig, Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite' import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
// Plugin to inject GA scripts in HTML
function injectGoogleAnalytics(): Plugin {
return {
name: 'inject-google-analytics',
transformIndexHtml(html) {
const gaMeasurementId = process.env.GA_MEASUREMENT_ID
// Only inject GA scripts if GA_MEASUREMENT_ID is set
if (!gaMeasurementId) {
// Remove placeholder if no GA ID
return html.replace(/\s*<!-- INJECT_GOOGLE_ANALYTICS -->\n?/g, '')
}
const gaScripts = `<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${gaMeasurementId}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('consent','default',{
ad_storage:'denied',
analytics_storage:'denied',
ad_user_data:'denied',
ad_personalization:'denied',
wait_for_update:500
});
gtag('js', new Date());
gtag('config', '${gaMeasurementId}', {
debug_mode: (location.hostname === 'localhost'),
send_page_view: false
});
</script>`
return html.replace('<!-- INJECT_GOOGLE_ANALYTICS -->', gaScripts)
},
}
}
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
TanStackRouterVite({ TanStackRouterVite({
@ -13,6 +50,7 @@ export default defineConfig({
}), }),
react(), react(),
tailwindcss(), tailwindcss(),
injectGoogleAnalytics(),
], ],
build: { build: {
outDir: './dist-web', outDir: './dist-web',