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:
commit
34036d895a
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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) {
|
setDeleteProjectConfirmOpen(false)
|
||||||
deleteFolder(deletingProjectId)
|
setDeletingProjectId(null)
|
||||||
setDeleteProjectConfirmOpen(false)
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,29 +240,27 @@ 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)
|
<DropdownMenuItem
|
||||||
.map((folder) => (
|
key={folder.id}
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
key={folder.id}
|
e.stopPropagation()
|
||||||
onClick={(e) => {
|
assignThreadToProject(thread.id, folder.id)
|
||||||
e.stopPropagation()
|
}}
|
||||||
assignThreadToProject(thread.id, folder.id)
|
>
|
||||||
}}
|
<IconFolder size={16} />
|
||||||
>
|
<span className="truncate max-w-[200px]">
|
||||||
<IconFolder size={16} />
|
{folder.name}
|
||||||
<span className="truncate max-w-[200px]">
|
</span>
|
||||||
{folder.name}
|
</DropdownMenuItem>
|
||||||
</span>
|
))
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
{thread.metadata?.project && (
|
{thread.metadata?.project && (
|
||||||
<>
|
<>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,53 +112,38 @@ 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
|
|
||||||
updatedModels = updatedModels.map((m: Model) => {
|
|
||||||
if (m.id === selectedModelId) {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
capabilities: updatedCapabilities,
|
|
||||||
// Mark that user has manually configured capabilities
|
|
||||||
_userConfiguredCapabilities: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
})
|
|
||||||
|
|
||||||
setOriginalCapabilities(capabilities)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the model in the provider models array
|
||||||
|
const updatedModels = provider.models.map((m: Model) =>
|
||||||
|
m.id === selectedModelId ? { ...m, ...modelUpdate } : m
|
||||||
|
)
|
||||||
|
|
||||||
// Update the provider with the updated models
|
// Update the provider with the updated models
|
||||||
updateProvider(provider.provider, {
|
updateProvider(provider.provider, {
|
||||||
...provider,
|
...provider,
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -1,83 +1,116 @@
|
|||||||
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(
|
folders: [],
|
||||||
(set, get) => ({
|
|
||||||
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 })
|
||||||
}
|
return newFolder
|
||||||
set((state) => ({
|
},
|
||||||
folders: [...state.folders, 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(
|
||||||
(thread) => thread.metadata?.project?.id === id
|
(thread) => thread.metadata?.project?.id === id
|
||||||
)
|
)
|
||||||
|
|
||||||
threadsToUpdate.forEach((thread) => {
|
threadsToUpdate.forEach((thread) => {
|
||||||
threadsState.updateThread(thread.id, {
|
threadsState.updateThread(thread.id, {
|
||||||
metadata: {
|
metadata: {
|
||||||
...thread.metadata,
|
...thread.metadata,
|
||||||
project: undefined,
|
project: undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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 })
|
||||||
|
},
|
||||||
|
|
||||||
getFolderById: (id) => {
|
deleteFolderWithThreads: async (id) => {
|
||||||
return get().folders.find((folder) => folder.id === 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
|
||||||
name: localStorageKey.threadManagement,
|
)
|
||||||
storage: createJSONStorage(() => localStorage),
|
|
||||||
|
// 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) => {
|
||||||
|
return get().folders.find((folder) => folder.id === id)
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectById: async (id) => {
|
||||||
|
const projectsService = getServiceHub().projects()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
@ -365,4 +369,5 @@
|
|||||||
"description": "Thread removed from \"{{projectName}}\" successfully"
|
"description": "Thread removed from \"{{projectName}}\" successfully"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
|
<div className="flex items-center justify-between w-full pr-2">
|
||||||
|
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
|
||||||
|
</div>
|
||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
setDeleteConfirmOpen(false)
|
||||||
deleteFolder(deletingId)
|
setDeletingId(null)
|
||||||
setDeleteConfirmOpen(false)
|
|
||||||
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>
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
78
web-app/src/services/projects/default.ts
Normal file
78
web-app/src/services/projects/default.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
web-app/src/services/projects/types.ts
Normal file
42
web-app/src/services/projects/types.ts
Normal 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>
|
||||||
|
}
|
||||||
11
web-app/src/services/projects/web.ts
Normal file
11
web-app/src/services/projects/web.ts
Normal 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
|
||||||
|
}
|
||||||
@ -71,6 +71,7 @@ export class DefaultThreadsService implements ThreadsService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...thread.metadata,
|
||||||
order: thread.order,
|
order: thread.order,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user