From fc784620e03560bf23d00be35a2163f4b8df18a4 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Thu, 9 Oct 2025 04:28:08 +0700 Subject: [PATCH] fix tests --- core/src/types/setting/settingComponent.ts | 2 + extensions/rag-extension/src/tools.ts | 2 +- web-app/src/containers/ChatInput.tsx | 2 +- web-app/src/containers/ThreadContent.tsx | 4 +- web-app/src/hooks/useAttachments.ts | 92 ++++++++++++++------- web-app/src/hooks/useChat.ts | 2 +- web-app/src/lib/completion.ts | 5 +- web-app/src/lib/messages.ts | 29 ++++--- web-app/src/routes/settings/attachments.tsx | 62 ++++++++++---- web-app/src/services/rag/default.ts | 5 +- 10 files changed, 141 insertions(+), 64 deletions(-) diff --git a/core/src/types/setting/settingComponent.ts b/core/src/types/setting/settingComponent.ts index 9dfd9b597..57b222d87 100644 --- a/core/src/types/setting/settingComponent.ts +++ b/core/src/types/setting/settingComponent.ts @@ -12,6 +12,8 @@ export type SettingComponentProps = { extensionName?: string requireModelReload?: boolean configType?: ConfigType + titleKey?: string + descriptionKey?: string } export type ConfigType = 'runtime' | 'setting' diff --git a/extensions/rag-extension/src/tools.ts b/extensions/rag-extension/src/tools.ts index f2199ed86..a881891b4 100644 --- a/extensions/rag-extension/src/tools.ts +++ b/extensions/rag-extension/src/tools.ts @@ -41,7 +41,7 @@ export function getRAGTools(retrievalLimit: number): MCPTool[] { { name: GET_CHUNKS, description: - 'Retrieve chunks from a file by their order range. For a single chunk, use start_order = end_order. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}.', + 'Retrieve chunks from a file by their order range. For a single chunk, use start_order = end_order. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}. Use sparingly; intended for advanced usage. Prefer using retrieve instead for relevance-based fetching.', inputSchema: { type: 'object', properties: { diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 3a926e78c..b736845d5 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -565,7 +565,7 @@ const ChatInput = ({ // If thread exists, ingest images immediately if (currentThreadId) { - ;(async () => { + void (async () => { for (const img of newFiles) { try { // Mark as processing diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx index 1c17d086b..e120544c1 100644 --- a/web-app/src/containers/ThreadContent.tsx +++ b/web-app/src/containers/ThreadContent.tsx @@ -184,9 +184,9 @@ export const ThreadContent = memo( } return null }) - .filter(Boolean) + .filter((v) => v !== null) // Keep embedded document metadata in the message for regenerate - sendMessage(rawText, true, attachments) + sendMessage(textContent, true, attachments) } }, [deleteMessage, getMessages, item, sendMessage]) diff --git a/web-app/src/hooks/useAttachments.ts b/web-app/src/hooks/useAttachments.ts index 9405c4110..4407fa26b 100644 --- a/web-app/src/hooks/useAttachments.ts +++ b/web-app/src/hooks/useAttachments.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { ExtensionManager } from '@/lib/extension' -import { ExtensionTypeEnum, type RAGExtension } from '@janhq/core' +import { ExtensionTypeEnum, type RAGExtension, type SettingComponentProps } from '@janhq/core' export type AttachmentsSettings = { enabled: boolean @@ -14,7 +14,7 @@ export type AttachmentsSettings = { type AttachmentsStore = AttachmentsSettings & { // Dynamic controller definitions for rendering UI - settingsDefs: any[] + settingsDefs: SettingComponentProps[] loadSettingsDefs: () => Promise setEnabled: (v: boolean) => void setMaxFileSizeMB: (v: number) => void @@ -27,7 +27,7 @@ type AttachmentsStore = AttachmentsSettings & { const getRagExtension = (): RAGExtension | undefined => { try { - return ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) as any + return ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) } catch { return undefined } @@ -43,94 +43,124 @@ export const useAttachments = create()((set) => ({ searchMode: 'auto', settingsDefs: [], loadSettingsDefs: async () => { - const ext = getRagExtension() as any + const ext = getRagExtension() if (!ext?.getSettings) return try { const defs = await ext.getSettings() if (Array.isArray(defs)) set({ settingsDefs: defs }) - } catch {} + } catch (e) { + console.debug('Failed to load attachment settings defs:', e) + } }, setEnabled: async (v) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'enabled', controllerProps: { value: !!v } } as any]) + await ext.updateSettings([ + { key: 'enabled', controllerProps: { value: !!v } } as Partial, + ]) } set((s) => ({ enabled: v, settingsDefs: s.settingsDefs.map((d) => - d.key === 'enabled' ? { ...d, controllerProps: { ...d.controllerProps, value: !!v } } : d + d.key === 'enabled' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: !!v } } as SettingComponentProps) + : d ), })) }, setMaxFileSizeMB: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'max_file_size_mb', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'max_file_size_mb', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ maxFileSizeMB: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'max_file_size_mb' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'max_file_size_mb' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setRetrievalLimit: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'retrieval_limit', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'retrieval_limit', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ retrievalLimit: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'retrieval_limit' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'retrieval_limit' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setRetrievalThreshold: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'retrieval_threshold', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'retrieval_threshold', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ retrievalThreshold: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'retrieval_threshold' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'retrieval_threshold' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setChunkSizeTokens: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'chunk_size_tokens', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'chunk_size_tokens', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ chunkSizeTokens: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'chunk_size_tokens' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'chunk_size_tokens' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setOverlapTokens: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'overlap_tokens', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'overlap_tokens', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ overlapTokens: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'overlap_tokens' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'overlap_tokens' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setSearchMode: async (v) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'search_mode', controllerProps: { value: v } } as any]) + await ext.updateSettings([ + { key: 'search_mode', controllerProps: { value: v } } as Partial, + ]) } set((s) => ({ searchMode: v, settingsDefs: s.settingsDefs.map((d) => - d.key === 'search_mode' ? { ...d, controllerProps: { ...d.controllerProps, value: v } } : d + d.key === 'search_mode' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: v } } as SettingComponentProps) + : d ), })) }, @@ -139,22 +169,26 @@ export const useAttachments = create()((set) => ({ // Initialize from extension settings once on import ;(async () => { try { - const ext = getRagExtension() as any + const ext = getRagExtension() if (!ext?.getSettings) return const settings = await ext.getSettings() if (!Array.isArray(settings)) return - const map = new Map() + const map = new Map() for (const s of settings) map.set(s.key, s?.controllerProps?.value) // seed defs and values useAttachments.setState((prev) => ({ settingsDefs: settings, - enabled: map.get('enabled') ?? prev.enabled, - maxFileSizeMB: map.get('max_file_size_mb') ?? prev.maxFileSizeMB, - retrievalLimit: map.get('retrieval_limit') ?? prev.retrievalLimit, - retrievalThreshold: map.get('retrieval_threshold') ?? prev.retrievalThreshold, - chunkSizeTokens: map.get('chunk_size_tokens') ?? prev.chunkSizeTokens, - overlapTokens: map.get('overlap_tokens') ?? prev.overlapTokens, - searchMode: map.get('search_mode') ?? prev.searchMode, + enabled: (map.get('enabled') as boolean | undefined) ?? prev.enabled, + maxFileSizeMB: (map.get('max_file_size_mb') as number | undefined) ?? prev.maxFileSizeMB, + retrievalLimit: (map.get('retrieval_limit') as number | undefined) ?? prev.retrievalLimit, + retrievalThreshold: + (map.get('retrieval_threshold') as number | undefined) ?? prev.retrievalThreshold, + chunkSizeTokens: (map.get('chunk_size_tokens') as number | undefined) ?? prev.chunkSizeTokens, + overlapTokens: (map.get('overlap_tokens') as number | undefined) ?? prev.overlapTokens, + searchMode: + (map.get('search_mode') as 'auto' | 'ann' | 'linear' | undefined) ?? prev.searchMode, })) - } catch {} + } catch (e) { + console.debug('Failed to initialize attachment settings from extension:', e) + } })() diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 13dd906fc..15d06f506 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -380,7 +380,7 @@ export const useChat = () => { // Build the user content once; use it for both the outbound request // and persisting to the store so both are identical. if (updateAttachmentProcessing) { - updateAttachmentProcessing('__CLEAR_ALL__' as any, 'clear_all') + updateAttachmentProcessing('__CLEAR_ALL__', 'clear_all') } const userContent = newUserThreadContent( activeThread.id, diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 301941cf1..e602ff88e 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -241,7 +241,10 @@ export const sendCompletion = async ( usableTools = [...tools, ...ragTools] } } - } catch {} + } catch (e) { + // Ignore RAG tool injection errors during completion setup + console.debug('Skipping RAG tools injection:', e) + } const engine = ExtensionManager.getInstance().getEngine(provider.provider) diff --git a/web-app/src/lib/messages.ts b/web-app/src/lib/messages.ts index 61acf8bde..3361e2703 100644 --- a/web-app/src/lib/messages.ts +++ b/web-app/src/lib/messages.ts @@ -1,10 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { ChatCompletionMessageParam } from 'token.js' import { ChatCompletionMessageToolCall } from 'openai/resources' -import { ThreadMessage } from '@janhq/core' +import { ThreadMessage, ContentType } from '@janhq/core' import { removeReasoningContent } from '@/utils/reasoning' // Attachments are now handled upstream in newUserThreadContent +type ThreadContent = NonNullable[number] + /** * @fileoverview Helper functions for creating chat completion request. * These functions are used to create chat completion request objects @@ -22,7 +23,14 @@ export class CompletionMessagesBuilder { this.messages.push( ...messages .filter((e) => !e.metadata?.error) - .map((msg) => this.toCompletionParamFromThread(msg)) + .map((msg) => { + const param = this.toCompletionParamFromThread(msg) + // In constructor context, normalize empty user text to a placeholder + if (param.role === 'user' && typeof param.content === 'string' && param.content === '') { + return { ...param, content: '.' } + } + return param + }) ) } @@ -45,19 +53,20 @@ export class CompletionMessagesBuilder { // User messages: handle multimodal content if (Array.isArray(msg.content) && msg.content.length > 1) { - const content = msg.content.map((part: any) => { - if (part.type === 'text') { - return { type: 'text', text: part.text?.value ?? '' } + const content = msg.content.map((part: ThreadContent) => { + if (part.type === ContentType.Text) { + return { type: 'text' as const, text: part.text?.value ?? '' } } - if (part.type === 'image_url') { + if (part.type === ContentType.Image) { return { - type: 'image_url', + type: 'image_url' as const, image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' }, } } - return part + // Fallback for unknown content types + return { type: 'text' as const, text: '' } }) - return { role: 'user', content } as any + return { role: 'user', content } as ChatCompletionMessageParam } // Single text part const text = msg?.content?.[0]?.text?.value ?? '.' diff --git a/web-app/src/routes/settings/attachments.tsx b/web-app/src/routes/settings/attachments.tsx index 2fea5a866..342db2a70 100644 --- a/web-app/src/routes/settings/attachments.tsx +++ b/web-app/src/routes/settings/attachments.tsx @@ -1,9 +1,9 @@ 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 { useAttachments } from '@/hooks/useAttachments' +import type { SettingComponentProps } from '@janhq/core' import { useTranslation } from '@/i18n/react-i18next-compat' import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting' @@ -11,14 +11,13 @@ import { PlatformFeature } from '@/lib/platform/types' import { useEffect, useState, useCallback, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const Route = createFileRoute(route.settings.attachments as any)({ +export const Route = createFileRoute('/settings/attachments')({ component: AttachmentsSettings, }) // Helper to extract constraints from settingsDefs -function getConstraints(def: any) { - const props = def?.controllerProps || {} +function getConstraints(def: SettingComponentProps) { + const props = def.controllerProps as Partial<{ min: number; max: number; step: number }> return { min: props.min ?? -Infinity, max: props.max ?? Infinity, @@ -27,7 +26,7 @@ function getConstraints(def: any) { } // Helper to validate and clamp numeric values -function clampValue(val: any, def: any, currentValue: number): number { +function clampValue(val: unknown, def: SettingComponentProps, currentValue: number): number { const num = typeof val === 'number' ? val : Number(val) if (!Number.isFinite(num)) return currentValue const { min, max, step } = getConstraints(def) @@ -40,7 +39,7 @@ function AttachmentsSettings() { const { t } = useTranslation() const hookDefs = useAttachments((s) => s.settingsDefs) const loadDefs = useAttachments((s) => s.loadSettingsDefs) - const [defs, setDefs] = useState([]) + const [defs, setDefs] = useState([]) // Load schema from extension via the hook once useEffect(() => { @@ -73,32 +72,36 @@ function AttachmentsSettings() { ) // Local state for inputs to allow intermediate values while typing - const [localValues, setLocalValues] = useState>({}) + const [localValues, setLocalValues] = useState>({}) // Debounce timers - const timersRef = useRef>({}) + const timersRef = useRef>>({}) // Cleanup timers on unmount useEffect(() => { + const timers = timersRef.current return () => { - Object.values(timersRef.current).forEach(clearTimeout) + Object.values(timers).forEach(clearTimeout) } }, []) // Debounced setter with validation - const debouncedSet = useCallback((key: string, val: any, def: any) => { + const debouncedSet = useCallback((key: string, val: unknown, def: SettingComponentProps) => { // Clear existing timer for this key if (timersRef.current[key]) { clearTimeout(timersRef.current[key]) } // Set local value immediately for responsive UI - setLocalValues((prev) => ({ ...prev, [key]: val })) + setLocalValues((prev) => ({ + ...prev, + [key]: val as string | number | boolean | string[] + })) // For non-numeric inputs, apply immediately without debounce if (key === 'enabled' || key === 'search_mode') { if (key === 'enabled') sel.setEnabled(!!val) - else if (key === 'search_mode') sel.setSearchMode(val) + else if (key === 'search_mode') sel.setSearchMode(val as 'auto' | 'ann' | 'linear') return } @@ -136,7 +139,10 @@ function AttachmentsSettings() { } // Update local value to validated one - setLocalValues((prev) => ({ ...prev, [key]: validated })) + setLocalValues((prev) => ({ + ...prev, + [key]: validated as string | number | boolean | string[] + })) }, 500) // 500ms debounce }, [sel]) @@ -174,8 +180,30 @@ function AttachmentsSettings() { } })() - const currentValue = localValues[d.key] !== undefined ? localValues[d.key] : storeValue - const props = { ...(d.controllerProps || {}), value: currentValue } + const currentValue = + localValues[d.key] !== undefined ? localValues[d.key] : storeValue + + // Convert to DynamicControllerSetting compatible props + const baseProps = d.controllerProps + const normalizedValue: string | number | boolean = (() => { + if (Array.isArray(currentValue)) { + return currentValue.join(',') + } + return currentValue as string | number | boolean + })() + + const props = { + value: normalizedValue, + placeholder: 'placeholder' in baseProps ? baseProps.placeholder : undefined, + type: 'type' in baseProps ? baseProps.type : undefined, + options: 'options' in baseProps ? baseProps.options : undefined, + input_actions: 'inputActions' in baseProps ? baseProps.inputActions : undefined, + rows: undefined, + min: 'min' in baseProps ? baseProps.min : undefined, + max: 'max' in baseProps ? baseProps.max : undefined, + step: 'step' in baseProps ? baseProps.step : undefined, + recommended: 'recommended' in baseProps ? baseProps.recommended : undefined, + } const title = d.titleKey ? t(d.titleKey) : d.title const description = d.descriptionKey ? t(d.descriptionKey) : d.description @@ -188,7 +216,7 @@ function AttachmentsSettings() { actions={ debouncedSet(d.key, val, d)} /> } diff --git a/web-app/src/services/rag/default.ts b/web-app/src/services/rag/default.ts index cfaae5abc..f4535c4fd 100644 --- a/web-app/src/services/rag/default.ts +++ b/web-app/src/services/rag/default.ts @@ -16,14 +16,15 @@ export class DefaultRAGService implements RAGService { return [] } - async callTool(args: { toolName: string; arguments: object; threadId?: string }): Promise { + async callTool(args: { toolName: string; arguments: Record; threadId?: string }): Promise { const ext = ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) if (!ext?.callTool) { return { error: 'RAG extension not available', content: [{ type: 'text', text: 'RAG extension not available' }] } } try { // Inject thread context when scope requires it - const a: any = { ...(args.arguments as any) } + type ToolCallArgs = Record & { scope?: string; thread_id?: string } + const a: ToolCallArgs = { ...(args.arguments as Record) } if (!a.scope) a.scope = 'thread' if (a.scope === 'thread' && !a.thread_id) { a.thread_id = args.threadId