fix tests

This commit is contained in:
Dinh Long Nguyen 2025-10-09 04:28:08 +07:00
parent 340042682a
commit fc784620e0
10 changed files with 141 additions and 64 deletions

View File

@ -12,6 +12,8 @@ export type SettingComponentProps = {
extensionName?: string
requireModelReload?: boolean
configType?: ConfigType
titleKey?: string
descriptionKey?: string
}
export type ConfigType = 'runtime' | 'setting'

View File

@ -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: {

View File

@ -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

View File

@ -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])

View File

@ -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<void>
setEnabled: (v: boolean) => void
setMaxFileSizeMB: (v: number) => void
@ -27,7 +27,7 @@ type AttachmentsStore = AttachmentsSettings & {
const getRagExtension = (): RAGExtension | undefined => {
try {
return ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG) as any
return ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
} catch {
return undefined
}
@ -43,94 +43,124 @@ export const useAttachments = create<AttachmentsStore>()((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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<SettingComponentProps>,
])
}
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<AttachmentsStore>()((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<string, any>()
const map = new Map<string, unknown>()
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)
}
})()

View File

@ -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,

View File

@ -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)

View File

@ -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<ThreadMessage['content']>[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<ChatCompletionMessageParam>((msg) => this.toCompletionParamFromThread(msg))
.map<ChatCompletionMessageParam>((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 ?? '.'

View File

@ -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<any[]>([])
const [defs, setDefs] = useState<SettingComponentProps[]>([])
// 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<Record<string, any>>({})
const [localValues, setLocalValues] = useState<Record<string, string | number | boolean | string[]>>({})
// Debounce timers
const timersRef = useRef<Record<string, NodeJS.Timeout>>({})
const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 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={
<DynamicControllerSetting
controllerType={d.controllerType}
controllerProps={props as any}
controllerProps={props}
onChange={(val) => debouncedSet(d.key, val, d)}
/>
}

View File

@ -16,14 +16,15 @@ export class DefaultRAGService implements RAGService {
return []
}
async callTool(args: { toolName: string; arguments: object; threadId?: string }): Promise<MCPToolCallResult> {
async callTool(args: { toolName: string; arguments: Record<string, unknown>; threadId?: string }): Promise<MCPToolCallResult> {
const ext = ExtensionManager.getInstance().get<RAGExtension>(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<string, unknown> & { scope?: string; thread_id?: string }
const a: ToolCallArgs = { ...(args.arguments as Record<string, unknown>) }
if (!a.scope) a.scope = 'thread'
if (a.scope === 'thread' && !a.thread_id) {
a.thread_id = args.threadId