feat: product analytic UI (#4262)
* chore: initial ui prompt product analytic * chore: integrate posthog * chore: update event app_version * chore: update build env * feat: posthog config in ci * chore: resolve linter test CI * chore: resolve e2e * chore: disable capture unless property * chore: update e2e * chore: update privacy data analytic --------- Co-authored-by: Hien To <tominhhien97@gmail.com>
This commit is contained in:
parent
5041651c21
commit
5fc04e09eb
14
.github/workflows/template-build-linux-x64.yml
vendored
14
.github/workflows/template-build-linux-x64.yml
vendored
@ -111,8 +111,10 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
|
||||
@ -122,6 +124,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
|
||||
ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
|
||||
@ -131,8 +135,10 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Upload Artifact .deb file
|
||||
if: inputs.public_provider != 'github'
|
||||
|
||||
32
.github/workflows/template-build-macos.yml
vendored
32
.github/workflows/template-build-macos.yml
vendored
@ -140,18 +140,20 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: "/tmp/codesign.p12"
|
||||
CSC_LINK: '/tmp/codesign.p12'
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "true"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APP_PATH: "."
|
||||
APP_PATH: '.'
|
||||
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
|
||||
@ -159,15 +161,17 @@ jobs:
|
||||
make build-and-publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: "/tmp/codesign.p12"
|
||||
CSC_LINK: '/tmp/codesign.p12'
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "true"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APP_PATH: "."
|
||||
APP_PATH: '.'
|
||||
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
|
||||
ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
|
||||
ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
|
||||
@ -175,18 +179,20 @@ jobs:
|
||||
make build-and-publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: "/tmp/codesign.p12"
|
||||
CSC_LINK: '/tmp/codesign.p12'
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "true"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APP_PATH: "."
|
||||
APP_PATH: '.'
|
||||
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Upload Artifact
|
||||
if: inputs.public_provider != 'github'
|
||||
|
||||
15
.github/workflows/template-build-windows-x64.yml
vendored
15
.github/workflows/template-build-windows-x64.yml
vendored
@ -149,8 +149,10 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build app and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
|
||||
@ -165,6 +167,8 @@ jobs:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_CERT_NAME: homebrewltd
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Build app and publish app to github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
|
||||
@ -175,14 +179,16 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_MAX_ATTEMPTS: "5"
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
AWS_MAX_ATTEMPTS: '5'
|
||||
AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# AZURE_CERT_NAME: ${{ secrets.AZURE_CERT_NAME }}
|
||||
AZURE_CERT_NAME: homebrewltd
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
|
||||
|
||||
- name: Upload Artifact
|
||||
if: inputs.public_provider != 'github'
|
||||
@ -190,4 +196,3 @@ jobs:
|
||||
with:
|
||||
name: jan-win-x64-${{ inputs.new_version }}
|
||||
path: ./electron/dist/*.exe
|
||||
|
||||
|
||||
@ -15,7 +15,13 @@ test('Select GPT model from Hub and Chat with Invalid API Key', async ({
|
||||
|
||||
await page.getByTestId('txt-input-chat').fill('dummy value')
|
||||
|
||||
await page.getByTestId('btn-send-chat').click()
|
||||
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
|
||||
|
||||
if ((await denyButton.count()) > 0) {
|
||||
await denyButton.click({ force: true })
|
||||
} else {
|
||||
await page.getByTestId('btn-send-chat').click({ force: true })
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
@ -24,9 +30,4 @@ test('Select GPT model from Hub and Chat with Invalid API Key', async ({
|
||||
},
|
||||
{ timeout: TIMEOUT }
|
||||
)
|
||||
|
||||
const APIKeyError = page.getByTestId('passthrough-error-message')
|
||||
await expect(APIKeyError).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
|
||||
@ -35,7 +35,7 @@ const BottomPanel = () => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'fixed bottom-0 left-0 z-50 flex h-9 w-full items-center justify-between px-3 text-xs',
|
||||
'fixed bottom-0 left-0 z-40 flex h-9 w-full items-center justify-between px-3 text-xs',
|
||||
reduceTransparent &&
|
||||
'border-t border-[hsla(var(--app-border))] bg-[hsla(var(--bottom-panel-bg))]'
|
||||
)}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { Button } from '@janhq/joi'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import BottomPanel from '@/containers/Layout/BottomPanel'
|
||||
@ -31,12 +33,72 @@ import MainViewContainer from '../MainViewContainer'
|
||||
import InstallingExtensionModal from './BottomPanel/InstallingExtension/InstallingExtensionModal'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { reduceTransparentAtom } from '@/helpers/atoms/Setting.atom'
|
||||
import {
|
||||
productAnalyticAtom,
|
||||
productAnalyticPromptAtom,
|
||||
reduceTransparentAtom,
|
||||
} from '@/helpers/atoms/Setting.atom'
|
||||
|
||||
const BaseLayout = () => {
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
||||
const [productAnalytic, setProductAnalytic] = useAtom(productAnalyticAtom)
|
||||
const [productAnalyticPrompt, setProductAnalyticPrompt] = useAtom(
|
||||
productAnalyticPromptAtom
|
||||
)
|
||||
const [showProductAnalyticPrompt, setShowProductAnalyticPrompt] =
|
||||
useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (productAnalyticPrompt) {
|
||||
setShowProductAnalyticPrompt(true)
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
}, 3000) // 3 seconds delay
|
||||
|
||||
return () => clearTimeout(timer) // Cleanup timer on unmount
|
||||
}, [productAnalyticPrompt])
|
||||
|
||||
useEffect(() => {
|
||||
if (productAnalytic) {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
disable_session_recording: true,
|
||||
person_profiles: 'always',
|
||||
persistence: 'localStorage',
|
||||
opt_out_capturing_by_default: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
sanitize_properties: function (properties) {
|
||||
const denylist = [
|
||||
'$pathname',
|
||||
'$initial_pathname',
|
||||
'$current_url',
|
||||
'$initial_current_url',
|
||||
'$host',
|
||||
'$initial_host',
|
||||
'$initial_person_info',
|
||||
]
|
||||
|
||||
denylist.forEach((key) => {
|
||||
if (properties[key]) {
|
||||
properties[key] = null // Set each denied property to null
|
||||
}
|
||||
})
|
||||
|
||||
return properties
|
||||
},
|
||||
})
|
||||
posthog.opt_in_capturing()
|
||||
posthog.register({ app_version: VERSION })
|
||||
} else {
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
}, [productAnalytic])
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
|
||||
@ -54,6 +116,17 @@ const BaseLayout = () => {
|
||||
)
|
||||
}, [setMainViewState])
|
||||
|
||||
const handleProductAnalytics = (isAllowed: boolean) => {
|
||||
setProductAnalytic(isAllowed)
|
||||
setProductAnalyticPrompt(false)
|
||||
setShowProductAnalyticPrompt(false)
|
||||
if (isAllowed) {
|
||||
posthog.opt_in_capturing()
|
||||
} else {
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
@ -76,6 +149,79 @@ const BaseLayout = () => {
|
||||
<ChooseWhatToImportModal />
|
||||
<InstallingExtensionModal />
|
||||
<HuggingFaceRepoDetailModal />
|
||||
{showProductAnalyticPrompt && (
|
||||
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.5 12.5C5.5 11.1193 6.61929 10 8 10H24C25.3807 10 26.5 11.1193 26.5 12.5V18.5C26.5 24.299 21.799 29 16 29C10.201 29 5.5 24.299 5.5 18.5V12.5Z"
|
||||
fill="#2563EB"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.20959 25.54L12.0093 10H14.0093L9.84984 27.0113C9.25274 26.579 8.70292 26.0855 8.20959 25.54ZM11.5993 28.0361C11.2955 27.8957 10.9996 27.7412 10.7124 27.5734L15.0093 10H16.0093L11.5993 28.0361Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M21 8C21 6.67392 20.4732 5.40215 19.5355 4.46447C18.5979 3.52678 17.3261 3 16 3C14.6739 3 13.4021 3.52678 12.4645 4.46447C11.5268 5.40215 11 6.67392 11 8"
|
||||
stroke="#2563EB"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M27.0478 18.054C27.609 18.5733 27.609 19.4267 27.0478 19.946C25.221 21.6363 20.9622 25 16 25C11.0378 25 6.77899 21.6363 4.95219 19.946C4.39099 19.4267 4.39099 18.5733 4.95219 18.054C6.77899 16.3637 11.0378 13 16 13C20.9622 13 25.221 16.3637 27.0478 18.054Z"
|
||||
fill="#C8D1EA"
|
||||
/>
|
||||
<circle cx="16" cy="19" r="4" fill="#2563EB" />
|
||||
<path
|
||||
d="M19.25 17.5C19.9404 17.5 20.5 16.9404 20.5 16.25C20.5 15.5596 19.9404 15 19.25 15C18.5596 15 18 15.5596 18 16.25C18 16.9404 18.5596 17.5 19.25 17.5Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M17.75 18.5C18.1642 18.5 18.5 18.1642 18.5 17.75C18.5 17.3358 18.1642 17 17.75 17C17.3358 17 17 17.3358 17 17.75C17 18.1642 17.3358 18.5 17.75 18.5Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<h6 className="text-base font-semibold">Help Us Improve Jan</h6>
|
||||
</div>
|
||||
<p className="text-[hsla(var(--text-secondary))]">
|
||||
To improve Jan, we collect anonymous data to understand feature
|
||||
usage. Your chats and personal information are never tracked. You
|
||||
can change this anytime in
|
||||
<span className="font-semibold">{`Settings > Privacy.`}</span>
|
||||
</p>
|
||||
<p className="mt-6 text-[hsla(var(--text-secondary))]">
|
||||
Would you like to help us to improve Jan?
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleProductAnalytics(true)
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="btn-deny-product-analytics"
|
||||
theme="ghost"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleProductAnalytics(false)
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BottomPanel />
|
||||
</div>
|
||||
|
||||
@ -106,7 +106,7 @@ const LeftPanelContainer = ({ children }: Props) => {
|
||||
<Fragment>
|
||||
<div
|
||||
className={twMerge(
|
||||
'group/resize absolute right-0 top-0 z-[9999] h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
|
||||
'group/resize absolute right-0 top-0 z-40 h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
|
||||
isResizing && 'cursor-col-resize bg-[hsla(var(--resize-bg))]',
|
||||
!reduceTransparent && 'shadow-sm'
|
||||
)}
|
||||
|
||||
@ -109,7 +109,7 @@ const RightPanelContainer = ({ children }: Props) => {
|
||||
<Fragment>
|
||||
<div
|
||||
className={twMerge(
|
||||
'group/resize absolute left-0 top-0 z-[9999] h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
|
||||
'group/resize absolute left-0 top-0 z-40 h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
|
||||
isResizing && 'cursor-col-resize bg-[hsla(var(--resize-bg))]',
|
||||
!reduceTransparent && 'shadow-sm'
|
||||
)}
|
||||
|
||||
@ -11,6 +11,8 @@ export const janSettingScreenAtom = atom<SettingScreen[]>([])
|
||||
export const THEME = 'themeAppearance'
|
||||
export const REDUCE_TRANSPARENT = 'reduceTransparent'
|
||||
export const SPELL_CHECKING = 'spellChecking'
|
||||
export const PRODUCT_ANALYTIC = 'productAnalytic'
|
||||
export const PRODUCT_ANALYTIC_PROMPT = 'productAnalyticPrompt'
|
||||
export const THEME_DATA = 'themeData'
|
||||
export const THEME_OPTIONS = 'themeOptions'
|
||||
export const THEME_PATH = 'themePath'
|
||||
@ -47,3 +49,15 @@ export const spellCheckAtom = atomWithStorage<boolean>(
|
||||
undefined,
|
||||
{ getOnInit: true }
|
||||
)
|
||||
export const productAnalyticAtom = atomWithStorage<boolean>(
|
||||
PRODUCT_ANALYTIC,
|
||||
false,
|
||||
undefined,
|
||||
{ getOnInit: true }
|
||||
)
|
||||
export const productAnalyticPromptAtom = atomWithStorage<boolean>(
|
||||
PRODUCT_ANALYTIC_PROMPT,
|
||||
true,
|
||||
undefined,
|
||||
{ getOnInit: true }
|
||||
)
|
||||
|
||||
@ -31,6 +31,8 @@ const nextConfig = {
|
||||
new webpack.DefinePlugin({
|
||||
VERSION: JSON.stringify(packageJson.version),
|
||||
ANALYTICS_ID: JSON.stringify(process.env.ANALYTICS_ID),
|
||||
POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_KEY),
|
||||
POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_HOST),
|
||||
ANALYTICS_HOST: JSON.stringify(process.env.ANALYTICS_HOST),
|
||||
API_BASE_URL: JSON.stringify(
|
||||
process.env.API_BASE_URL ?? 'http://localhost:1337'
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "8.4.31",
|
||||
"postcss-url": "10.1.3",
|
||||
"posthog-js": "^1.194.6",
|
||||
"react": "18.2.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
|
||||
@ -91,20 +91,6 @@ describe('Advanced', () => {
|
||||
expect(experimentalToggle).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('clears logs', async () => {
|
||||
const jestMock = jest.fn()
|
||||
jest.spyOn(toast, 'toaster').mockImplementation(jestMock)
|
||||
|
||||
render(<Advanced />)
|
||||
let clearLogsButton
|
||||
await waitFor(() => {
|
||||
clearLogsButton = screen.getByTestId(/clear-logs/i)
|
||||
fireEvent.click(clearLogsButton)
|
||||
})
|
||||
expect(clearLogsButton).toBeInTheDocument()
|
||||
expect(jestMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles proxy enabled', async () => {
|
||||
render(<Advanced />)
|
||||
let proxyToggle
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { useEffect, useState, useCallback, ChangeEvent } from 'react'
|
||||
|
||||
import { openExternalUrl, fs, AppConfiguration } from '@janhq/core'
|
||||
import { openExternalUrl, AppConfiguration } from '@janhq/core'
|
||||
|
||||
import {
|
||||
ScrollArea,
|
||||
Button,
|
||||
Switch,
|
||||
Input,
|
||||
Tooltip,
|
||||
@ -180,24 +179,6 @@ const Advanced = () => {
|
||||
setUseGpuIfPossible()
|
||||
}, [readSettings, setGpuList, setGpuEnabled, setGpusInUse, setVulkanEnabled])
|
||||
|
||||
/**
|
||||
* Clear logs
|
||||
* @returns
|
||||
*/
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
await fs.rm(`file://logs`)
|
||||
} catch (err) {
|
||||
console.error('Error clearing logs: ', err)
|
||||
}
|
||||
|
||||
toaster({
|
||||
title: 'Logs cleared',
|
||||
description: 'All logs have been cleared.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GPU Change
|
||||
* @param gpuId
|
||||
@ -447,7 +428,7 @@ const Advanced = () => {
|
||||
model performance (reload needed).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-sharink-0">
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={vulkanEnabled}
|
||||
onChange={(e) => updateVulkanEnabled(e.target.checked)}
|
||||
@ -542,25 +523,6 @@ const Advanced = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear log */}
|
||||
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="font-semibold capitalize">Clear logs</h6>
|
||||
</div>
|
||||
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||
Clear all logs from Jan app.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="clear-logs"
|
||||
theme="destructive"
|
||||
onClick={clearLogs}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Factory Reset */}
|
||||
<FactoryReset />
|
||||
</div>
|
||||
|
||||
82
web/screens/Settings/Privacy/index.test.tsx
Normal file
82
web/screens/Settings/Privacy/index.test.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Privacy from '.'
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock
|
||||
global.window.core = {
|
||||
api: {
|
||||
getAppConfigurations: () => jest.fn(),
|
||||
updateAppConfiguration: () => jest.fn(),
|
||||
relaunch: () => jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
const setSettingsMock = jest.fn()
|
||||
|
||||
// Mock useSettings hook
|
||||
jest.mock('@/hooks/useSettings', () => ({
|
||||
__esModule: true,
|
||||
useSettings: () => ({
|
||||
readSettings: () => ({
|
||||
run_mode: 'gpu',
|
||||
experimental: false,
|
||||
proxy: false,
|
||||
gpus: [{ name: 'gpu-1' }, { name: 'gpu-2' }],
|
||||
gpus_in_use: ['0'],
|
||||
quick_ask: false,
|
||||
}),
|
||||
setSettings: setSettingsMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import * as toast from '@/containers/Toast'
|
||||
|
||||
jest.mock('@/containers/Toast')
|
||||
|
||||
jest.mock('@janhq/core', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('@janhq/core'),
|
||||
fs: {
|
||||
rm: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Simulate a full Privacy settings screen
|
||||
// @ts-ignore
|
||||
global.isMac = false
|
||||
// @ts-ignore
|
||||
global.isWindows = true
|
||||
|
||||
describe('Privacy', () => {
|
||||
it('renders the component', async () => {
|
||||
render(<Privacy />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Clear logs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears logs', async () => {
|
||||
const jestMock = jest.fn()
|
||||
jest.spyOn(toast, 'toaster').mockImplementation(jestMock)
|
||||
|
||||
render(<Privacy />)
|
||||
let clearLogsButton
|
||||
await waitFor(() => {
|
||||
clearLogsButton = screen.getByTestId(/clear-logs/i)
|
||||
fireEvent.click(clearLogsButton)
|
||||
})
|
||||
expect(clearLogsButton).toBeInTheDocument()
|
||||
expect(jestMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
150
web/screens/Settings/Privacy/index.tsx
Normal file
150
web/screens/Settings/Privacy/index.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { fs } from '@janhq/core'
|
||||
import { Button, Input, ScrollArea, Switch } from '@janhq/joi'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { FolderOpenIcon } from 'lucide-react'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { toaster } from '@/containers/Toast'
|
||||
|
||||
import { usePath } from '@/hooks/usePath'
|
||||
|
||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
import { productAnalyticAtom } from '@/helpers/atoms/Setting.atom'
|
||||
|
||||
const Privacy = () => {
|
||||
/**
|
||||
* Clear logs
|
||||
* @returns
|
||||
*/
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
await fs.rm(`file://logs`)
|
||||
} catch (err) {
|
||||
console.error('Error clearing logs: ', err)
|
||||
}
|
||||
|
||||
toaster({
|
||||
title: 'Logs cleared',
|
||||
description: 'All logs have been cleared.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
|
||||
const { onRevealInFinder } = usePath()
|
||||
const [productAnalytic, setProductAnalytic] = useAtom(productAnalyticAtom)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full px-4">
|
||||
<div className="mb-4 mt-8 rounded-xl bg-[hsla(var(--tertiary-bg))] px-4 py-2 text-[hsla(var(--text-secondary))]">
|
||||
<p>
|
||||
We prioritize your control over your data. Learn more about our
|
||||
<a
|
||||
href="https://jan.ai/docs/privacy"
|
||||
target="_blank"
|
||||
className="text-[hsla(var(--app-link))]"
|
||||
>
|
||||
Privacy Policy.
|
||||
</a>
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
To make Jan better, we need to understand how it’s used - but only if
|
||||
you choose to help. You can change your Jan Analytics settings
|
||||
anytime.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
{`Your choice to opt-in or out doesn't change our core privacy promises:`}
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4">
|
||||
<li>Your chats are never read</li>
|
||||
<li>No personal information is collected</li>
|
||||
<li>No accounts or logins required</li>
|
||||
<li>We don’t access your files</li>
|
||||
<li>Your chat history and settings stay on your device</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="block w-full py-4">
|
||||
{/* Analytic */}
|
||||
<div className="flex w-full flex-col justify-between gap-x-20 gap-y-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row sm:items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="font-semibold capitalize">Analytics</h6>
|
||||
</div>
|
||||
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||
By opting in, you help us make Jan better by sharing anonymous
|
||||
data, like feature usage and user counts. Your chats and personal
|
||||
information are never collected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={productAnalytic}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
posthog.opt_in_capturing()
|
||||
} else {
|
||||
posthog.capture('user_opt_out', { timestamp: new Date() })
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
setProductAnalytic(e.target.checked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
|
||||
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="font-semibold capitalize">Logs</h6>
|
||||
</div>
|
||||
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||
Open App Logs and Cortex Logs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
data-testid="jan-data-folder-input"
|
||||
value={janDataFolderPath + '/logs'}
|
||||
className="w-full pr-8 sm:w-[240px]"
|
||||
disabled
|
||||
/>
|
||||
<FolderOpenIcon
|
||||
size={16}
|
||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer"
|
||||
onClick={() => onRevealInFinder('Logs')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear log */}
|
||||
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="font-semibold capitalize">Clear logs</h6>
|
||||
</div>
|
||||
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||
Clear all logs from Jan app.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="clear-logs"
|
||||
theme="destructive"
|
||||
variant="soft"
|
||||
onClick={clearLogs}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
export default Privacy
|
||||
@ -6,6 +6,7 @@ import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
|
||||
import ExtensionSetting from '@/screens/Settings/ExtensionSetting'
|
||||
import Hotkeys from '@/screens/Settings/Hotkeys'
|
||||
import MyModels from '@/screens/Settings/MyModels'
|
||||
import Privacy from '@/screens/Settings/Privacy'
|
||||
|
||||
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||
|
||||
@ -22,6 +23,9 @@ const SettingDetail = () => {
|
||||
case 'Keyboard Shortcuts':
|
||||
return <Hotkeys />
|
||||
|
||||
case 'Privacy':
|
||||
return <Privacy />
|
||||
|
||||
case 'Advanced Settings':
|
||||
return <Advanced />
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ export const SettingScreenList = [
|
||||
'My Models',
|
||||
'Appearance',
|
||||
'Keyboard Shortcuts',
|
||||
'Privacy',
|
||||
'Advanced Settings',
|
||||
'Extensions',
|
||||
] as const
|
||||
|
||||
2
web/types/index.d.ts
vendored
2
web/types/index.d.ts
vendored
@ -6,6 +6,8 @@ export {}
|
||||
declare global {
|
||||
declare const VERSION: string
|
||||
declare const ANALYTICS_ID: string
|
||||
declare const POSTHOG_KEY: string
|
||||
declare const POSTHOG_HOST: string
|
||||
declare const ANALYTICS_HOST: string
|
||||
declare const API_BASE_URL: string
|
||||
declare const isMac: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user