diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index f1f870dd5..16fea5120 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -10,6 +10,7 @@ export const route = { model_providers: '/settings/providers', providers: '/settings/providers/$providerName', general: '/settings/general', + attachments: '/settings/attachments', appearance: '/settings/appearance', privacy: '/settings/privacy', shortcuts: '/settings/shortcuts', diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index 78389233d..d85c4a03c 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -73,6 +73,12 @@ const SettingsMenu = () => { hasSubMenu: false, isEnabled: true, }, + { + title: 'common:attachments', + route: route.settings.attachments, + hasSubMenu: false, + isEnabled: PlatformFeatures[PlatformFeature.ATTACHMENTS], + }, { title: 'common:appearance', route: route.settings.appearance, diff --git a/web-app/src/hooks/useAttachments.ts b/web-app/src/hooks/useAttachments.ts new file mode 100644 index 000000000..62408547e --- /dev/null +++ b/web-app/src/hooks/useAttachments.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' +import { localStorageKey } from '@/constants/localStorage' + +export type AttachmentsSettings = { + enabled: boolean + maxFileSizeMB: number + retrievalLimit: number + retrievalThreshold: number + chunkSizeTokens: number + overlapTokens: number +} + +type AttachmentsStore = AttachmentsSettings & { + setEnabled: (v: boolean) => void + setMaxFileSizeMB: (v: number) => void + setRetrievalLimit: (v: number) => void + setRetrievalThreshold: (v: number) => void + setChunkSizeTokens: (v: number) => void + setOverlapTokens: (v: number) => void +} + +export const useAttachments = create()( + persist( + (set) => ({ + enabled: true, + maxFileSizeMB: 20, + retrievalLimit: 3, + retrievalThreshold: 0.5, + chunkSizeTokens: 512, + overlapTokens: 64, + setEnabled: (v) => set({ enabled: v }), + setMaxFileSizeMB: (v) => set({ maxFileSizeMB: Math.max(1, Math.min(200, Math.floor(v))) }), + setRetrievalLimit: (v) => set({ retrievalLimit: Math.max(1, Math.min(20, Math.floor(v))) }), + setRetrievalThreshold: (v) => set({ retrievalThreshold: Math.max(0, Math.min(1, v)) }), + setChunkSizeTokens: (v) => set({ chunkSizeTokens: Math.max(64, Math.min(8192, Math.floor(v))) }), + setOverlapTokens: (v) => set({ overlapTokens: Math.max(0, Math.min(1024, Math.floor(v))) }), + }), + { + name: `${localStorageKey.settingGeneral}-attachments`, + storage: createJSONStorage(() => localStorage), + } + ) +) + diff --git a/web-app/src/hooks/useTools.ts b/web-app/src/hooks/useTools.ts index 2ff61eb10..cefa3a5ae 100644 --- a/web-app/src/hooks/useTools.ts +++ b/web-app/src/hooks/useTools.ts @@ -5,10 +5,14 @@ import { useAppState } from './useAppState' import { useToolAvailable } from './useToolAvailable' import { ExtensionManager } from '@/lib/extension' import { ExtensionTypeEnum, MCPExtension } from '@janhq/core' +import { useAttachments } from './useAttachments' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export const useTools = () => { const updateTools = useAppState((state) => state.updateTools) const { isDefaultsInitialized, setDefaultDisabledTools, markDefaultsAsInitialized } = useToolAvailable() + const attachmentsEnabled = useAttachments((s) => s.enabled) useEffect(() => { async function setTools() { @@ -19,11 +23,19 @@ export const useTools = () => { ) // Fetch tools - const data = await getServiceHub().mcp().getTools() - updateTools(data) + const [mcpTools, ragTools] = await Promise.all([ + getServiceHub().mcp().getTools(), + // Only include RAG tools when attachments feature is enabled + useAttachments.getState().enabled && PlatformFeatures[PlatformFeature.ATTACHMENTS] + ? getServiceHub().rag().getTools() + : Promise.resolve([]), + ]) + + const combined = [...mcpTools, ...ragTools] + updateTools(combined) // Initialize default disabled tools for new users (only once) - if (!isDefaultsInitialized() && data.length > 0 && mcpExtension?.getDefaultDisabledTools) { + if (!isDefaultsInitialized() && combined.length > 0 && mcpExtension?.getDefaultDisabledTools) { const defaultDisabled = await mcpExtension.getDefaultDisabledTools() if (defaultDisabled.length > 0) { setDefaultDisabledTools(defaultDisabled) @@ -31,7 +43,7 @@ export const useTools = () => { } } } catch (error) { - console.error('Failed to fetch MCP tools:', error) + console.error('Failed to fetch tools:', error) } } setTools() @@ -41,9 +53,14 @@ export const useTools = () => { // Unsubscribe from the event when the component unmounts unsubscribe = unsub }).catch((error) => { - console.error('Failed to set up MCP update listener:', error) + console.error('Failed to set up tools update listener:', error) }) return unsubscribe // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + + // Refresh tools when attachments feature toggles + useEffect(() => { + getServiceHub().events().emit(SystemEvent.MCP_UPDATE) + }, [attachmentsEnabled]) } diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index d38455a49..5f004235a 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -78,6 +78,10 @@ export const PlatformFeatures: Record = { // First message persisted thread - enabled for web only [PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD]: !isPlatformTauri(), - // Temporary chat mode - enabled for web only + // Temporary chat mode - enabled for web only [PlatformFeature.TEMPORARY_CHAT]: !isPlatformTauri(), + + // Attachments/RAG UI and tooling - desktop only for now + [PlatformFeature.ATTACHMENTS]: + isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(), } diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 08d78eeca..823105e13 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -71,4 +71,7 @@ export enum PlatformFeature { // Temporary chat mode - web-only feature for ephemeral conversations like ChatGPT TEMPORARY_CHAT = 'temporaryChat', + + // Attachments/RAG UI and tooling (desktop-only for now) + ATTACHMENTS = 'attachments', } diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index 2eca7a699..5e0679bd6 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -5,6 +5,7 @@ "local_api_server": "Local API Server", "https_proxy": "HTTPS Proxy", "extensions": "Extensions", + "attachments": "Attachments", "general": "General", "settings": "Settings", "modelProviders": "Model Providers", @@ -369,4 +370,3 @@ } } } - diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index afc6d6a47..dc1e34f76 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -253,6 +253,25 @@ "extensions": { "title": "Extensions" }, + "attachments": { + "subtitle": "Configure document attachments, size limits, and retrieval behavior.", + "featureTitle": "Feature", + "enable": "Enable Attachments", + "enableDesc": "Allow uploading and indexing documents for retrieval.", + "limitsTitle": "Limits", + "maxFile": "Max File Size (MB)", + "maxFileDesc": "Maximum size per file. Enforced at upload and processing time.", + "retrievalTitle": "Retrieval", + "topK": "Top-K", + "topKDesc": "Maximum citations to return.", + "threshold": "Affinity Threshold", + "thresholdDesc": "Minimum similarity score to consider relevant (0-1).", + "chunkingTitle": "Chunking", + "chunkSize": "Chunk Size (tokens)", + "chunkSizeDesc": "Approximate max tokens per chunk for embeddings.", + "chunkOverlap": "Overlap (tokens)", + "chunkOverlapDesc": "Token overlap between consecutive chunks." + }, "dialogs": { "changeDataFolder": { "title": "Change Data Folder Location", diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 13b55798a..6d2a97d0b 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as SettingsHttpsProxyImport } from './routes/settings/https-proxy import { Route as SettingsHardwareImport } from './routes/settings/hardware' import { Route as SettingsGeneralImport } from './routes/settings/general' import { Route as SettingsExtensionsImport } from './routes/settings/extensions' +import { Route as SettingsAttachmentsImport } from './routes/settings/attachments' import { Route as SettingsAppearanceImport } from './routes/settings/appearance' import { Route as ProjectProjectIdImport } from './routes/project/$projectId' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' @@ -126,6 +127,12 @@ const SettingsExtensionsRoute = SettingsExtensionsImport.update({ getParentRoute: () => rootRoute, } as any) +const SettingsAttachmentsRoute = SettingsAttachmentsImport.update({ + id: '/settings/attachments', + path: '/settings/attachments', + getParentRoute: () => rootRoute, +} as any) + const SettingsAppearanceRoute = SettingsAppearanceImport.update({ id: '/settings/appearance', path: '/settings/appearance', @@ -229,6 +236,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAppearanceImport parentRoute: typeof rootRoute } + '/settings/attachments': { + id: '/settings/attachments' + path: '/settings/attachments' + fullPath: '/settings/attachments' + preLoaderRoute: typeof SettingsAttachmentsImport + parentRoute: typeof rootRoute + } '/settings/extensions': { id: '/settings/extensions' path: '/settings/extensions' @@ -341,6 +355,7 @@ export interface FileRoutesByFullPath { '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute @@ -366,6 +381,7 @@ export interface FileRoutesByTo { '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute @@ -392,6 +408,7 @@ export interface FileRoutesById { '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute @@ -419,6 +436,7 @@ export interface FileRouteTypes { | '/local-api-server/logs' | '/project/$projectId' | '/settings/appearance' + | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' @@ -443,6 +461,7 @@ export interface FileRouteTypes { | '/local-api-server/logs' | '/project/$projectId' | '/settings/appearance' + | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' @@ -467,6 +486,7 @@ export interface FileRouteTypes { | '/local-api-server/logs' | '/project/$projectId' | '/settings/appearance' + | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' @@ -493,6 +513,7 @@ export interface RootRouteChildren { LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute SettingsAppearanceRoute: typeof SettingsAppearanceRoute + SettingsAttachmentsRoute: typeof SettingsAttachmentsRoute SettingsExtensionsRoute: typeof SettingsExtensionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsHardwareRoute: typeof SettingsHardwareRoute @@ -518,6 +539,7 @@ const rootRouteChildren: RootRouteChildren = { LocalApiServerLogsRoute: LocalApiServerLogsRoute, ProjectProjectIdRoute: ProjectProjectIdRoute, SettingsAppearanceRoute: SettingsAppearanceRoute, + SettingsAttachmentsRoute: SettingsAttachmentsRoute, SettingsExtensionsRoute: SettingsExtensionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsHardwareRoute: SettingsHardwareRoute, @@ -552,6 +574,7 @@ export const routeTree = rootRoute "/local-api-server/logs", "/project/$projectId", "/settings/appearance", + "/settings/attachments", "/settings/extensions", "/settings/general", "/settings/hardware", @@ -592,6 +615,9 @@ export const routeTree = rootRoute "/settings/appearance": { "filePath": "settings/appearance.tsx" }, + "/settings/attachments": { + "filePath": "settings/attachments.tsx" + }, "/settings/extensions": { "filePath": "settings/extensions.tsx" }, diff --git a/web-app/src/routes/settings/attachments.tsx b/web-app/src/routes/settings/attachments.tsx new file mode 100644 index 000000000..bd4635cd1 --- /dev/null +++ b/web-app/src/routes/settings/attachments.tsx @@ -0,0 +1,185 @@ +import { createFileRoute } from '@tanstack/react-router' +import { route } from '@/constants/routes' +import SettingsMenu from '@/containers/SettingsMenu' +import HeaderPage from '@/containers/HeaderPage' +import { Card, CardItem } from '@/containers/Card' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { useAttachments } from '@/hooks/useAttachments' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { PlatformGuard } from '@/lib/platform/PlatformGuard' +import { PlatformFeature } from '@/lib/platform/types' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Route = createFileRoute(route.settings.attachments as any)({ + component: AttachmentsSettings, +}) + +function NumberInput({ + value, + onChange, + min, + max, + step = 1, +}: { + value: number + onChange: (v: number) => void + min?: number + max?: number + step?: number +}) { + return ( + onChange(Number(e.target.value))} + className="w-28" + /> + ) +} + +function AttachmentsSettings() { + const { t } = useTranslation() + const { + enabled, + maxFileSizeMB, + retrievalLimit, + retrievalThreshold, + chunkSizeTokens, + overlapTokens, + setEnabled, + setMaxFileSizeMB, + setRetrievalLimit, + setRetrievalThreshold, + setChunkSizeTokens, + setOverlapTokens, + } = useAttachments() + + return ( + +
+
+ +
+
+ +
+ + + } + /> + + + + + } + /> + + + + + } + /> + setRetrievalThreshold(Number(e.target.value))} + className="w-28" + /> + } + /> + + + + + } + /> + + } + /> + +
+
+
+
+ ) +} diff --git a/web-app/src/services/index.ts b/web-app/src/services/index.ts index 0bfba90e6..bd9fcc57b 100644 --- a/web-app/src/services/index.ts +++ b/web-app/src/services/index.ts @@ -27,6 +27,8 @@ import { DefaultPathService } from './path/default' import { DefaultCoreService } from './core/default' import { DefaultDeepLinkService } from './deeplink/default' import { DefaultProjectsService } from './projects/default' +import { DefaultRAGService } from './rag/default' +import type { RAGService } from './rag/types' // Import service types import type { ThemeService } from './theme/types' @@ -70,6 +72,7 @@ export interface ServiceHub { core(): CoreService deeplink(): DeepLinkService projects(): ProjectsService + rag(): RAGService } class PlatformServiceHub implements ServiceHub { @@ -92,6 +95,7 @@ class PlatformServiceHub implements ServiceHub { private coreService: CoreService = new DefaultCoreService() private deepLinkService: DeepLinkService = new DefaultDeepLinkService() private projectsService: ProjectsService = new DefaultProjectsService() + private ragService: RAGService = new DefaultRAGService() private initialized = false /** @@ -302,6 +306,11 @@ class PlatformServiceHub implements ServiceHub { this.ensureInitialized() return this.projectsService } + + rag(): RAGService { + this.ensureInitialized() + return this.ragService + } } export async function initializeServiceHub(): Promise { diff --git a/web-app/src/services/rag/default.ts b/web-app/src/services/rag/default.ts new file mode 100644 index 000000000..4af79d2cd --- /dev/null +++ b/web-app/src/services/rag/default.ts @@ -0,0 +1,9 @@ +import type { RAGService } from './types' +import type { MCPTool } from '@janhq/core' + +export class DefaultRAGService implements RAGService { + async getTools(): Promise { + // Temporarily return no tools; real tools will be provided by rag-extension later + return [] + } +} diff --git a/web-app/src/services/rag/types.ts b/web-app/src/services/rag/types.ts new file mode 100644 index 000000000..f3b53da1e --- /dev/null +++ b/web-app/src/services/rag/types.ts @@ -0,0 +1,7 @@ +import { MCPTool } from '@janhq/core' + +export interface RAGService { + // Return tools exposed by RAG-related extensions (e.g., retrieval, list_attachments) + getTools(): Promise +} +