Merge pull request #4338 from janhq/main

Merge bug fixes from 0.5.12 into dev
This commit is contained in:
Louis 2024-12-26 13:31:11 +07:00 committed by GitHub
commit da4336cc49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 355 additions and 192 deletions

View File

@ -40,7 +40,7 @@ export default class CortexConversationalExtension extends ConversationalExtensi
async listThreads(): Promise<Thread[]> { async listThreads(): Promise<Thread[]> {
return this.queue.add(() => return this.queue.add(() =>
ky ky
.get(`${API_URL}/v1/threads`) .get(`${API_URL}/v1/threads?limit=-1`)
.json<ThreadList>() .json<ThreadList>()
.then((e) => e.data) .then((e) => e.data)
) as Promise<Thread[]> ) as Promise<Thread[]>
@ -133,7 +133,7 @@ export default class CortexConversationalExtension extends ConversationalExtensi
async listMessages(threadId: string): Promise<ThreadMessage[]> { async listMessages(threadId: string): Promise<ThreadMessage[]> {
return this.queue.add(() => return this.queue.add(() =>
ky ky
.get(`${API_URL}/v1/threads/${threadId}/messages?order=asc`) .get(`${API_URL}/v1/threads/${threadId}/messages?order=asc&limit=-1`)
.json<MessageList>() .json<MessageList>()
.then((e) => e.data) .then((e) => e.data)
) as Promise<ThreadMessage[]> ) as Promise<ThreadMessage[]>
@ -147,7 +147,9 @@ export default class CortexConversationalExtension extends ConversationalExtensi
*/ */
async getThreadAssistant(threadId: string): Promise<ThreadAssistantInfo> { async getThreadAssistant(threadId: string): Promise<ThreadAssistantInfo> {
return this.queue.add(() => return this.queue.add(() =>
ky.get(`${API_URL}/v1/assistants/${threadId}`).json<ThreadAssistantInfo>() ky
.get(`${API_URL}/v1/assistants/${threadId}?limit=-1`)
.json<ThreadAssistantInfo>()
) as Promise<ThreadAssistantInfo> ) as Promise<ThreadAssistantInfo>
} }
/** /**
@ -188,7 +190,7 @@ export default class CortexConversationalExtension extends ConversationalExtensi
* Do health check on cortex.cpp * Do health check on cortex.cpp
* @returns * @returns
*/ */
healthz(): Promise<void> { async healthz(): Promise<void> {
return ky return ky
.get(`${API_URL}/healthz`, { .get(`${API_URL}/healthz`, {
retry: { limit: 20, delay: () => 500, methods: ['get'] }, retry: { limit: 20, delay: () => 500, methods: ['get'] },

View File

@ -1 +1 @@
1.0.5-rc2 1.0.6

View File

@ -85,6 +85,63 @@
}, },
"engine": "openai" "engine": "openai"
}, },
{
"sources": [
{
"url": "https://openai.com"
}
],
"id": "gpt-4o-mini",
"object": "model",
"name": "OpenAI GPT 4o-mini",
"version": "1.1",
"description": "GPT-4o mini (“o” for “omni”) is a fast, affordable small model for focused tasks.",
"format": "api",
"settings": {
"vision_model": true
},
"parameters": {
"max_tokens": 16384,
"temperature": 0.7,
"top_p": 0.95,
"stream": true,
"stop": [],
"frequency_penalty": 0,
"presence_penalty": 0
},
"metadata": {
"author": "OpenAI",
"tags": ["General"]
},
"engine": "openai"
},
{
"sources": [
{
"url": "https://openai.com"
}
],
"id": "o1",
"object": "model",
"name": "OpenAI o1",
"version": "1.0",
"description": "OpenAI o1 is a new model with complex reasoning",
"format": "api",
"settings": {},
"parameters": {
"max_tokens": 100000,
"temperature": 1,
"top_p": 1,
"stream": true,
"frequency_penalty": 0,
"presence_penalty": 0
},
"metadata": {
"author": "OpenAI",
"tags": ["General"]
},
"engine": "openai"
},
{ {
"sources": [ "sources": [
{ {

View File

@ -53,7 +53,7 @@ export class CortexAPI implements ICortexAPI {
*/ */
getModels(): Promise<Model[]> { getModels(): Promise<Model[]> {
return this.queue return this.queue
.add(() => ky.get(`${API_URL}/v1/models`).json<ModelList>()) .add(() => ky.get(`${API_URL}/v1/models?limit=-1`).json<ModelList>())
.then((e) => .then((e) =>
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : [] typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
) )

View File

@ -34,7 +34,11 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
return ( return (
<div className="textarea__wrapper"> <div className="textarea__wrapper">
<textarea <textarea
className={twMerge('textarea', className)} className={twMerge(
'textarea',
className,
autoResize && 'resize-none'
)}
ref={autoResize ? textareaRef : ref} ref={autoResize ? textareaRef : ref}
{...props} {...props}
/> />

View File

@ -183,10 +183,7 @@ const ModelDropdown = ({
if (!activeThread) return if (!activeThread) return
const modelId = activeAssistant?.model?.id const modelId = activeAssistant?.model?.id
let model = downloadedModels.find((model) => model.id === modelId) const model = downloadedModels.find((model) => model.id === modelId)
if (!model) {
model = undefined
}
setSelectedModel(model) setSelectedModel(model)
}, [ }, [
recommendedModel, recommendedModel,
@ -378,14 +375,14 @@ const ModelDropdown = ({
!selectedModel && 'text-[hsla(var(--text-tertiary))]' !selectedModel && 'text-[hsla(var(--text-tertiary))]'
)} )}
> >
{selectedModel?.name || 'Select Model'} {selectedModel?.name || 'Select a model'}
</span> </span>
</Badge> </Badge>
) : ( ) : (
<Input <Input
value={selectedModel?.name || ''} value={selectedModel?.name || ''}
className="cursor-pointer" className="cursor-pointer"
placeholder="Select Model" placeholder="Select a model"
disabled={disabled} disabled={disabled}
readOnly readOnly
suffixIcon={ suffixIcon={

View File

@ -18,7 +18,7 @@ import {
extractInferenceParams, extractInferenceParams,
ModelExtension, ModelExtension,
} from '@janhq/core' } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ulid } from 'ulidx' import { ulid } from 'ulidx'
import { activeModelAtom, stateModelAtom } from '@/hooks/useActiveModel' import { activeModelAtom, stateModelAtom } from '@/hooks/useActiveModel'
@ -32,6 +32,7 @@ import {
updateMessageAtom, updateMessageAtom,
tokenSpeedAtom, tokenSpeedAtom,
deleteMessageAtom, deleteMessageAtom,
subscribedGeneratingMessageAtom,
} from '@/helpers/atoms/ChatMessage.atom' } from '@/helpers/atoms/ChatMessage.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
import { import {
@ -40,6 +41,7 @@ import {
isGeneratingResponseAtom, isGeneratingResponseAtom,
updateThreadAtom, updateThreadAtom,
getActiveThreadModelParamsAtom, getActiveThreadModelParamsAtom,
activeThreadAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
const maxWordForThreadTitle = 10 const maxWordForThreadTitle = 10
@ -54,6 +56,10 @@ export default function ModelHandler() {
const activeModel = useAtomValue(activeModelAtom) const activeModel = useAtomValue(activeModelAtom)
const setActiveModel = useSetAtom(activeModelAtom) const setActiveModel = useSetAtom(activeModelAtom)
const setStateModel = useSetAtom(stateModelAtom) const setStateModel = useSetAtom(stateModelAtom)
const [subscribedGeneratingMessage, setSubscribedGeneratingMessage] = useAtom(
subscribedGeneratingMessageAtom
)
const activeThread = useAtomValue(activeThreadAtom)
const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom) const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
const threads = useAtomValue(threadsAtom) const threads = useAtomValue(threadsAtom)
@ -62,11 +68,17 @@ export default function ModelHandler() {
const setIsGeneratingResponse = useSetAtom(isGeneratingResponseAtom) const setIsGeneratingResponse = useSetAtom(isGeneratingResponseAtom)
const updateThread = useSetAtom(updateThreadAtom) const updateThread = useSetAtom(updateThreadAtom)
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
const messageGenerationSubscriber = useRef(subscribedGeneratingMessage)
const activeModelRef = useRef(activeModel) const activeModelRef = useRef(activeModel)
const activeThreadRef = useRef(activeThread)
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const activeModelParamsRef = useRef(activeModelParams) const activeModelParamsRef = useRef(activeModelParams)
const setTokenSpeed = useSetAtom(tokenSpeedAtom) const setTokenSpeed = useSetAtom(tokenSpeedAtom)
useEffect(() => {
activeThreadRef.current = activeThread
}, [activeThread])
useEffect(() => { useEffect(() => {
threadsRef.current = threads threadsRef.current = threads
}, [threads]) }, [threads])
@ -87,6 +99,10 @@ export default function ModelHandler() {
activeModelParamsRef.current = activeModelParams activeModelParamsRef.current = activeModelParams
}, [activeModelParams]) }, [activeModelParams])
useEffect(() => {
messageGenerationSubscriber.current = subscribedGeneratingMessage
}, [subscribedGeneratingMessage])
const onNewMessageResponse = useCallback( const onNewMessageResponse = useCallback(
async (message: ThreadMessage) => { async (message: ThreadMessage) => {
if (message.type === MessageRequestType.Thread) { if (message.type === MessageRequestType.Thread) {
@ -179,12 +195,19 @@ export default function ModelHandler() {
const updateThreadMessage = useCallback( const updateThreadMessage = useCallback(
(message: ThreadMessage) => { (message: ThreadMessage) => {
updateMessage( if (
message.id, messageGenerationSubscriber.current &&
message.thread_id, message.thread_id === activeThreadRef.current?.id &&
message.content, !messageGenerationSubscriber.current!.thread_id
message.status ) {
) updateMessage(
message.id,
message.thread_id,
message.content,
message.status
)
}
if (message.status === MessageStatus.Pending) { if (message.status === MessageStatus.Pending) {
if (message.content.length) { if (message.content.length) {
setIsGeneratingResponse(false) setIsGeneratingResponse(false)
@ -244,6 +267,7 @@ export default function ModelHandler() {
const metadata = { const metadata = {
...thread.metadata, ...thread.metadata,
...(messageContent && { lastMessage: messageContent }), ...(messageContent && { lastMessage: messageContent }),
updated_at: Date.now(),
} }
updateThread({ updateThread({
@ -302,15 +326,10 @@ export default function ModelHandler() {
const generateThreadTitle = (message: ThreadMessage, thread: Thread) => { const generateThreadTitle = (message: ThreadMessage, thread: Thread) => {
// If this is the first ever prompt in the thread // If this is the first ever prompt in the thread
if ( if ((thread.title ?? thread.metadata?.title)?.trim() !== defaultThreadTitle)
(thread.title ?? thread.metadata?.title)?.trim() !== defaultThreadTitle
) {
return return
}
if (!activeModelRef.current) { if (!activeModelRef.current) return
return
}
// Check model engine; we don't want to generate a title when it's not a local engine. remote model using first promp // Check model engine; we don't want to generate a title when it's not a local engine. remote model using first promp
if (!isLocalEngine(activeModelRef.current?.engine as InferenceEngine)) { if (!isLocalEngine(activeModelRef.current?.engine as InferenceEngine)) {
@ -332,6 +351,7 @@ export default function ModelHandler() {
...updatedThread, ...updatedThread,
}) })
}) })
.catch(console.error)
} }
// This is the first time message comes in on a new thread // This is the first time message comes in on a new thread

View File

@ -35,6 +35,13 @@ export const chatMessages = atom(
} }
) )
/**
* Store subscribed generating message thread
*/
export const subscribedGeneratingMessageAtom = atom<{
thread_id?: string
}>({})
/** /**
* Stores the status of the messages load for each thread * Stores the status of the messages load for each thread
*/ */
@ -175,6 +182,17 @@ export const updateMessageAtom = atom(
// Update thread last message // Update thread last message
if (text.length) if (text.length)
set(updateThreadStateLastMessageAtom, conversationId, text) set(updateThreadStateLastMessageAtom, conversationId, text)
} else {
set(addNewMessageAtom, {
id,
thread_id: conversationId,
content: text,
status,
role: ChatCompletionRole.Assistant,
created_at: Date.now() / 1000,
completed_at: Date.now() / 1000,
object: 'thread.message',
})
} }
} }
) )

View File

@ -20,12 +20,7 @@ export const CHAT_WIDTH = 'chatWidth'
export const themesOptionsAtom = atomWithStorage< export const themesOptionsAtom = atomWithStorage<
{ name: string; value: string }[] { name: string; value: string }[]
>(THEME_OPTIONS, [], undefined, { getOnInit: true }) >(THEME_OPTIONS, [], undefined, { getOnInit: true })
export const janThemesPathAtom = atomWithStorage<string | undefined>(
THEME_PATH,
undefined,
undefined,
{ getOnInit: true }
)
export const selectedThemeIdAtom = atomWithStorage<string>( export const selectedThemeIdAtom = atomWithStorage<string>(
THEME, THEME,
'', '',

View File

@ -125,6 +125,26 @@ export const waitingToSendMessage = atom<boolean | undefined>(undefined)
*/ */
export const isGeneratingResponseAtom = atom<boolean | undefined>(undefined) export const isGeneratingResponseAtom = atom<boolean | undefined>(undefined)
/**
* Create a new thread and add it to the thread list
*/
export const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => {
// create thread state for this new thread
const currentState = { ...get(threadStatesAtom) }
const threadState: ThreadState = {
hasMore: false,
waitingForResponse: false,
lastMessage: undefined,
}
currentState[newThread.id] = threadState
set(threadStatesAtom, currentState)
// add the new thread on top of the thread list to the state
const threads = get(threadsAtom)
set(threadsAtom, [newThread, ...threads])
})
/** /**
* Remove a thread state from the atom * Remove a thread state from the atom
*/ */
@ -180,12 +200,12 @@ export const updateThreadAtom = atom(
) )
// sort new threads based on updated at // sort new threads based on updated at
threads.sort((thread1, thread2) => { threads.sort((a, b) => {
const aDate = new Date(thread1.updated ?? 0) return ((a.metadata?.updated_at as number) ?? 0) >
const bDate = new Date(thread2.updated ?? 0) ((b.metadata?.updated_at as number) ?? 0)
return bDate.getTime() - aDate.getTime() ? -1
: 1
}) })
set(threadsAtom, threads) set(threadsAtom, threads)
} }
) )

View File

@ -33,29 +33,12 @@ import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom'
import { selectedModelAtom } from '@/helpers/atoms/Model.atom' import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
import { import {
threadsAtom, threadsAtom,
threadStatesAtom,
updateThreadAtom, updateThreadAtom,
setThreadModelParamsAtom, setThreadModelParamsAtom,
isGeneratingResponseAtom, isGeneratingResponseAtom,
createNewThreadAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => {
// create thread state for this new thread
const currentState = { ...get(threadStatesAtom) }
const threadState: ThreadState = {
hasMore: false,
waitingForResponse: false,
lastMessage: undefined,
}
currentState[newThread.id] = threadState
set(threadStatesAtom, currentState)
// add the new thread on top of the thread list to the state
const threads = get(threadsAtom)
set(threadsAtom, [newThread, ...threads])
})
export const useCreateNewThread = () => { export const useCreateNewThread = () => {
const createNewThread = useSetAtom(createNewThreadAtom) const createNewThread = useSetAtom(createNewThreadAtom)
const { setActiveThread } = useSetActiveThread() const { setActiveThread } = useSetActiveThread()
@ -153,6 +136,7 @@ export const useCreateNewThread = () => {
updated: createdAt, updated: createdAt,
metadata: { metadata: {
title: 'New Thread', title: 'New Thread',
updated_at: Date.now(),
}, },
} }
@ -207,9 +191,11 @@ export const useCreateNewThread = () => {
async (thread: Thread) => { async (thread: Thread) => {
updateThread(thread) updateThread(thread)
setActiveAssistant(thread.assistants[0])
updateThreadCallback(thread) updateThreadCallback(thread)
updateAssistantCallback(thread.id, thread.assistants[0]) if (thread.assistants && thread.assistants?.length > 0) {
setActiveAssistant(thread.assistants[0])
updateAssistantCallback(thread.id, thread.assistants[0])
}
}, },
[ [
updateThread, updateThread,

View File

@ -55,17 +55,21 @@ describe('useDeleteThread', () => {
const mockCleanMessages = jest.fn() const mockCleanMessages = jest.fn()
;(useSetAtom as jest.Mock).mockReturnValue(() => mockCleanMessages) ;(useSetAtom as jest.Mock).mockReturnValue(() => mockCleanMessages)
;(useAtomValue as jest.Mock).mockReturnValue(['thread 1']) ;(useAtomValue as jest.Mock).mockReturnValue(['thread 1'])
const mockCreateNewThread = jest.fn()
;(useCreateNewThread as jest.Mock).mockReturnValue({
requestCreateNewThread: mockCreateNewThread,
})
const mockSaveThread = jest.fn() const mockSaveThread = jest.fn()
const mockDeleteThread = jest.fn().mockResolvedValue({}) const mockDeleteMessage = jest.fn().mockResolvedValue({})
const mockModifyThread = jest.fn().mockResolvedValue({})
extensionManager.get = jest.fn().mockReturnValue({ extensionManager.get = jest.fn().mockReturnValue({
saveThread: mockSaveThread, saveThread: mockSaveThread,
getThreadAssistant: jest.fn().mockResolvedValue({}), getThreadAssistant: jest.fn().mockResolvedValue({}),
deleteThread: mockDeleteThread, listMessages: jest.fn().mockResolvedValue([
{
id: 'message1',
text: 'Message 1',
},
]),
deleteMessage: mockDeleteMessage,
modifyThread: mockModifyThread,
}) })
const { result } = renderHook(() => useDeleteThread()) const { result } = renderHook(() => useDeleteThread())
@ -74,8 +78,8 @@ describe('useDeleteThread', () => {
await result.current.cleanThread('thread1') await result.current.cleanThread('thread1')
}) })
expect(mockDeleteThread).toHaveBeenCalled() expect(mockDeleteMessage).toHaveBeenCalled()
expect(mockCreateNewThread).toHaveBeenCalled() expect(mockModifyThread).toHaveBeenCalled()
}) })
it('should handle errors when deleting a thread', async () => { it('should handle errors when deleting a thread', async () => {

View File

@ -2,69 +2,68 @@ import { useCallback } from 'react'
import { ExtensionTypeEnum, ConversationalExtension } from '@janhq/core' import { ExtensionTypeEnum, ConversationalExtension } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { currentPromptAtom } from '@/containers/Providers/Jotai' import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { toaster } from '@/containers/Toast' import { toaster } from '@/containers/Toast'
import { useCreateNewThread } from './useCreateNewThread' import useSetActiveThread from './useSetActiveThread'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import { deleteChatMessageAtom as deleteChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { deleteChatMessageAtom as deleteChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
import { import {
threadsAtom, threadsAtom,
setActiveThreadIdAtom, setActiveThreadIdAtom,
deleteThreadStateAtom, deleteThreadStateAtom,
updateThreadAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
export default function useDeleteThread() { export default function useDeleteThread() {
const [threads, setThreads] = useAtom(threadsAtom) const [threads, setThreads] = useAtom(threadsAtom)
const { requestCreateNewThread } = useCreateNewThread() const updateThread = useSetAtom(updateThreadAtom)
const assistants = useAtomValue(assistantsAtom)
const models = useAtomValue(downloadedModelsAtom)
const setCurrentPrompt = useSetAtom(currentPromptAtom) const setCurrentPrompt = useSetAtom(currentPromptAtom)
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)
const deleteMessages = useSetAtom(deleteChatMessagesAtom) const deleteMessages = useSetAtom(deleteChatMessagesAtom)
const deleteThreadState = useSetAtom(deleteThreadStateAtom) const deleteThreadState = useSetAtom(deleteThreadStateAtom)
const { setActiveThread } = useSetActiveThread()
const cleanThread = useCallback( const cleanThread = useCallback(
async (threadId: string) => { async (threadId: string) => {
const thread = threads.find((c) => c.id === threadId) const messages = await extensionManager
if (!thread) return
const assistantInfo = await extensionManager
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational) .get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
?.getThreadAssistant(thread.id) ?.listMessages(threadId)
if (!assistantInfo) return
const model = models.find((c) => c.id === assistantInfo?.model?.id)
requestCreateNewThread(
{
...assistantInfo,
id: assistants[0].id,
name: assistants[0].name,
},
model
? {
...model,
parameters: assistantInfo?.model?.parameters ?? {},
settings: assistantInfo?.model?.settings ?? {},
}
: undefined
)
// Delete this thread
await extensionManager
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
?.deleteThread(threadId)
.catch(console.error) .catch(console.error)
if (messages) {
messages.forEach((message) => {
extensionManager
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
?.deleteMessage(threadId, message.id)
.catch(console.error)
})
const thread = threads.find((e) => e.id === threadId)
if (thread) {
const updatedThread = {
...thread,
metadata: {
...thread.metadata,
title: 'New Thread',
lastMessage: '',
},
}
extensionManager
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
?.modifyThread(updatedThread)
.catch(console.error)
updateThread(updatedThread)
}
}
deleteMessages(threadId)
}, },
[assistants, models, requestCreateNewThread, threads] [deleteMessages, threads, updateThread]
) )
const deleteThread = async (threadId: string) => { const deleteThread = async (threadId: string) => {
@ -90,7 +89,7 @@ export default function useDeleteThread() {
type: 'success', type: 'success',
}) })
if (availableThreads.length > 0) { if (availableThreads.length > 0) {
setActiveThreadId(availableThreads[0].id) setActiveThread(availableThreads[0])
} else { } else {
setActiveThreadId(undefined) setActiveThreadId(undefined)
} }

View File

@ -10,7 +10,6 @@ import cssVars from '@/utils/jsonToCssVariables'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom' import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
import { import {
janThemesPathAtom,
selectedThemeIdAtom, selectedThemeIdAtom,
themeDataAtom, themeDataAtom,
themesOptionsAtom, themesOptionsAtom,
@ -21,7 +20,6 @@ type NativeThemeProps = 'light' | 'dark'
export const useLoadTheme = () => { export const useLoadTheme = () => {
const janDataFolderPath = useAtomValue(janDataFolderPathAtom) const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
const [themeOptions, setThemeOptions] = useAtom(themesOptionsAtom) const [themeOptions, setThemeOptions] = useAtom(themesOptionsAtom)
const [themePath, setThemePath] = useAtom(janThemesPathAtom)
const [themeData, setThemeData] = useAtom(themeDataAtom) const [themeData, setThemeData] = useAtom(themeDataAtom)
const [selectedIdTheme, setSelectedIdTheme] = useAtom(selectedThemeIdAtom) const [selectedIdTheme, setSelectedIdTheme] = useAtom(selectedThemeIdAtom)
const { setTheme } = useTheme() const { setTheme } = useTheme()
@ -41,6 +39,14 @@ export const useLoadTheme = () => {
[setTheme] [setTheme]
) )
const applyTheme = (theme: Theme) => {
const variables = cssVars(theme.variables)
const headTag = document.getElementsByTagName('head')[0]
const styleTag = document.createElement('style')
styleTag.innerHTML = `:root {${variables}}`
headTag.appendChild(styleTag)
}
const getThemes = useCallback(async () => { const getThemes = useCallback(async () => {
if (!janDataFolderPath.length) return if (!janDataFolderPath.length) return
const folderPath = await joinPath([janDataFolderPath, 'themes']) const folderPath = await joinPath([janDataFolderPath, 'themes'])
@ -59,7 +65,6 @@ export const useLoadTheme = () => {
if (janDataFolderPath.length > 0) { if (janDataFolderPath.length > 0) {
if (!selectedIdTheme.length) return setSelectedIdTheme('joi-light') if (!selectedIdTheme.length) return setSelectedIdTheme('joi-light')
setThemePath(folderPath)
const filePath = await joinPath([ const filePath = await joinPath([
`${folderPath}/${selectedIdTheme}`, `${folderPath}/${selectedIdTheme}`,
`theme.json`, `theme.json`,
@ -68,11 +73,7 @@ export const useLoadTheme = () => {
setThemeData(theme) setThemeData(theme)
setNativeTheme(theme.nativeTheme) setNativeTheme(theme.nativeTheme)
const variables = cssVars(theme.variables) applyTheme(theme)
const headTag = document.getElementsByTagName('head')[0]
const styleTag = document.createElement('style')
styleTag.innerHTML = `:root {${variables}}`
headTag.appendChild(styleTag)
} }
}, [ }, [
janDataFolderPath, janDataFolderPath,
@ -81,26 +82,21 @@ export const useLoadTheme = () => {
setSelectedIdTheme, setSelectedIdTheme,
setThemeData, setThemeData,
setThemeOptions, setThemeOptions,
setThemePath,
]) ])
const applyTheme = useCallback(async () => { const configureTheme = useCallback(async () => {
if (!themeData || !themeOptions || !themePath) { if (!themeData || !themeOptions) {
await getThemes() await getThemes()
} else { } else {
const variables = cssVars(themeData.variables) applyTheme(themeData)
const headTag = document.getElementsByTagName('head')[0]
const styleTag = document.createElement('style')
styleTag.innerHTML = `:root {${variables}}`
headTag.appendChild(styleTag)
} }
setNativeTheme(themeData?.nativeTheme as NativeThemeProps) setNativeTheme(themeData?.nativeTheme as NativeThemeProps)
}, [themeData, themeOptions, themePath, getThemes]) }, [themeData, themeOptions, getThemes, setNativeTheme])
useEffect(() => { useEffect(() => {
applyTheme() configureTheme()
}, [ }, [
applyTheme, configureTheme,
selectedIdTheme, selectedIdTheme,
setNativeTheme, setNativeTheme,
setSelectedIdTheme, setSelectedIdTheme,

View File

@ -1,11 +1,15 @@
import { ExtensionTypeEnum, Thread, ConversationalExtension } from '@janhq/core' import { ExtensionTypeEnum, Thread, ConversationalExtension } from '@janhq/core'
import { useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom' import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom'
import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { import {
setConvoMessagesAtom,
subscribedGeneratingMessageAtom,
} from '@/helpers/atoms/ChatMessage.atom'
import {
getActiveThreadIdAtom,
setActiveThreadIdAtom, setActiveThreadIdAtom,
setThreadModelParamsAtom, setThreadModelParamsAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
@ -13,14 +17,18 @@ import { ModelParams } from '@/types/model'
export default function useSetActiveThread() { export default function useSetActiveThread() {
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)
const setThreadMessage = useSetAtom(setConvoMessagesAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const setThreadMessages = useSetAtom(setConvoMessagesAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom) const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const setActiveAssistant = useSetAtom(activeAssistantAtom) const setActiveAssistant = useSetAtom(activeAssistantAtom)
const [messageSubscriber, setMessageSubscriber] = useAtom(
subscribedGeneratingMessageAtom
)
const setActiveThread = async (thread: Thread) => { const setActiveThread = async (thread: Thread) => {
if (!thread?.id) return if (!thread?.id || activeThreadId === thread.id) return
setActiveThreadId(thread?.id) setActiveThreadId(thread.id)
try { try {
const assistantInfo = await getThreadAssistant(thread.id) const assistantInfo = await getThreadAssistant(thread.id)
@ -32,7 +40,8 @@ export default function useSetActiveThread() {
...assistantInfo?.model?.settings, ...assistantInfo?.model?.settings,
} }
setThreadModelParams(thread?.id, modelParams) setThreadModelParams(thread?.id, modelParams)
setThreadMessage(thread.id, messages) setThreadMessages(thread.id, messages)
if (messageSubscriber.thread_id !== thread.id) setMessageSubscriber({})
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }

View File

@ -26,7 +26,12 @@ const useThreads = () => {
useEffect(() => { useEffect(() => {
const getThreads = async () => { const getThreads = async () => {
const localThreads = await getLocalThreads() const localThreads = (await getLocalThreads()).sort((a, b) => {
return ((a.metadata?.updated_at as number) ?? 0) >
((b.metadata?.updated_at as number) ?? 0)
? -1
: 1
})
const localThreadStates: Record<string, ThreadState> = {} const localThreadStates: Record<string, ThreadState> = {}
const threadModelParams: Record<string, ModelParams> = {} const threadModelParams: Record<string, ModelParams> = {}

View File

@ -1,6 +1,6 @@
{ {
"name": "@janhq/web", "name": "@janhq/web",
"version": "0.5.11", "version": "0.5.12",
"private": true, "private": true,
"homepage": "./", "homepage": "./",
"scripts": { "scripts": {

View File

@ -176,7 +176,7 @@ const LocalServerLeftPanel = () => {
/> />
</div> </div>
<div className="relative z-50 mt-2 block"> <div className="relative mt-2 block">
<Input <Input
className={twMerge( className={twMerge(
errorRangePort && 'border-[hsla(var(--destructive-bg))]' errorRangePort && 'border-[hsla(var(--destructive-bg))]'

View File

@ -8,9 +8,10 @@ import { useAtom, useAtomValue } from 'jotai'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
import { import {
chatWidthAtom, chatWidthAtom,
janThemesPathAtom,
reduceTransparentAtom, reduceTransparentAtom,
selectedThemeIdAtom, selectedThemeIdAtom,
spellCheckAtom, spellCheckAtom,
@ -21,8 +22,8 @@ import {
export default function AppearanceOptions() { export default function AppearanceOptions() {
const [selectedIdTheme, setSelectedIdTheme] = useAtom(selectedThemeIdAtom) const [selectedIdTheme, setSelectedIdTheme] = useAtom(selectedThemeIdAtom)
const themeOptions = useAtomValue(themesOptionsAtom) const themeOptions = useAtomValue(themesOptionsAtom)
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme()
const janThemesPath = useAtomValue(janThemesPathAtom)
const [themeData, setThemeData] = useAtom(themeDataAtom) const [themeData, setThemeData] = useAtom(themeDataAtom)
const [reduceTransparent, setReduceTransparent] = useAtom( const [reduceTransparent, setReduceTransparent] = useAtom(
reduceTransparentAtom reduceTransparentAtom
@ -48,6 +49,7 @@ export default function AppearanceOptions() {
const handleClickTheme = useCallback( const handleClickTheme = useCallback(
async (e: string) => { async (e: string) => {
setSelectedIdTheme(e) setSelectedIdTheme(e)
const janThemesPath = await joinPath([janDataFolderPath, 'themes'])
const filePath = await joinPath([`${janThemesPath}/${e}`, `theme.json`]) const filePath = await joinPath([`${janThemesPath}/${e}`, `theme.json`])
const theme: Theme = JSON.parse(await fs.readFileSync(filePath, 'utf-8')) const theme: Theme = JSON.parse(await fs.readFileSync(filePath, 'utf-8'))
setThemeData(theme) setThemeData(theme)
@ -59,7 +61,7 @@ export default function AppearanceOptions() {
} }
}, },
[ [
janThemesPath, janDataFolderPath,
reduceTransparent, reduceTransparent,
setReduceTransparent, setReduceTransparent,
setSelectedIdTheme, setSelectedIdTheme,

View File

@ -23,9 +23,7 @@ const EmptyThread = () => {
<LogoMark className="mx-auto mb-2 animate-wave" width={32} height={32} /> <LogoMark className="mx-auto mb-2 animate-wave" width={32} height={32} />
{showOnboardingStep ? ( {showOnboardingStep ? (
<> <>
<p className="mt-1 font-medium"> <p className="mt-1 font-medium">{`You don't have any model`}</p>
{`You don't have a local model yet.`}
</p>
<Button <Button
onClick={() => setMainViewState(MainViewState.Hub)} onClick={() => setMainViewState(MainViewState.Hub)}
variant="soft" variant="soft"

View File

@ -14,7 +14,11 @@ import LoadModelError from '../LoadModelError'
import EmptyThread from './EmptyThread' import EmptyThread from './EmptyThread'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import {
activeThreadAtom,
isGeneratingResponseAtom,
threadStatesAtom,
} from '@/helpers/atoms/Thread.atom'
const ChatConfigurator = memo(() => { const ChatConfigurator = memo(() => {
const messages = useAtomValue(getCurrentChatMessagesAtom) const messages = useAtomValue(getCurrentChatMessagesAtom)
@ -61,6 +65,12 @@ const ChatBody = memo(
const prevScrollTop = useRef(0) const prevScrollTop = useRef(0)
const isUserManuallyScrollingUp = useRef(false) const isUserManuallyScrollingUp = useRef(false)
const currentThread = useAtomValue(activeThreadAtom) const currentThread = useAtomValue(activeThreadAtom)
const threadStates = useAtomValue(threadStatesAtom)
const isGeneratingResponse = useAtomValue(isGeneratingResponseAtom)
const isStreamingResponse = Object.values(threadStates).some(
(threadState) => threadState.waitingForResponse
)
const count = useMemo( const count = useMemo(
() => (messages?.length ?? 0) + (loadModelError ? 1 : 0), () => (messages?.length ?? 0) + (loadModelError ? 1 : 0),
@ -76,14 +86,31 @@ const ChatBody = memo(
}) })
useEffect(() => { useEffect(() => {
// Delay the scroll until the DOM is updated
if (parentRef.current) { if (parentRef.current) {
requestAnimationFrame(() => { parentRef.current.scrollTo({ top: parentRef.current.scrollHeight })
if (parentRef.current) { virtualizer.scrollToIndex(count - 1)
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight }) }
virtualizer.scrollToIndex(count - 1) }, [count, virtualizer])
}
}) useEffect(() => {
if (parentRef.current && isGeneratingResponse) {
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight })
virtualizer.scrollToIndex(count - 1)
}
}, [count, virtualizer, isGeneratingResponse])
useEffect(() => {
if (parentRef.current && isGeneratingResponse) {
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight })
virtualizer.scrollToIndex(count - 1)
}
}, [count, virtualizer, isGeneratingResponse, currentThread?.id])
useEffect(() => {
isUserManuallyScrollingUp.current = false
if (parentRef.current) {
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight })
virtualizer.scrollToIndex(count - 1)
} }
}, [count, currentThread?.id, virtualizer]) }, [count, currentThread?.id, virtualizer])
@ -94,34 +121,38 @@ const ChatBody = memo(
_, _,
instance instance
) => { ) => {
if (isUserManuallyScrollingUp.current === true) return false if (isUserManuallyScrollingUp.current === true && isStreamingResponse)
return false
return ( return (
// item.start < (instance.scrollOffset ?? 0) && // item.start < (instance.scrollOffset ?? 0) &&
instance.scrollDirection !== 'backward' instance.scrollDirection !== 'backward'
) )
} }
const handleScroll = useCallback((event: React.UIEvent<HTMLElement>) => { const handleScroll = useCallback(
const currentScrollTop = event.currentTarget.scrollTop (event: React.UIEvent<HTMLElement>) => {
if (prevScrollTop.current > currentScrollTop) {
isUserManuallyScrollingUp.current = true
} else {
const currentScrollTop = event.currentTarget.scrollTop const currentScrollTop = event.currentTarget.scrollTop
const scrollHeight = event.currentTarget.scrollHeight
const clientHeight = event.currentTarget.clientHeight
if (currentScrollTop + clientHeight >= scrollHeight) { if (prevScrollTop.current > currentScrollTop && isStreamingResponse) {
isUserManuallyScrollingUp.current = false isUserManuallyScrollingUp.current = true
} else {
const currentScrollTop = event.currentTarget.scrollTop
const scrollHeight = event.currentTarget.scrollHeight
const clientHeight = event.currentTarget.clientHeight
if (currentScrollTop + clientHeight >= scrollHeight) {
isUserManuallyScrollingUp.current = false
}
} }
}
if (isUserManuallyScrollingUp.current === true) { if (isUserManuallyScrollingUp.current === true) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
prevScrollTop.current = currentScrollTop prevScrollTop.current = currentScrollTop
}, []) },
[isStreamingResponse]
)
return ( return (
<div className="flex h-full w-full flex-col overflow-x-hidden"> <div className="flex h-full w-full flex-col overflow-x-hidden">

View File

@ -231,30 +231,24 @@ const ChatInput = () => {
)} )}
> >
<ul> <ul>
<Tooltip <li
trigger={ className={twMerge(
<li 'text-[hsla(var(--text-secondary)] hover:bg-secondary flex w-full items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
className={twMerge( activeAssistant?.model.settings?.vision_model &&
'text-[hsla(var(--text-secondary)] hover:bg-secondary flex w-full items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]', isModelSupportRagAndTools
activeAssistant?.model.settings?.vision_model || ? 'cursor-pointer'
isModelSupportRagAndTools : 'cursor-not-allowed opacity-50'
? 'cursor-pointer' )}
: 'cursor-not-allowed opacity-50' onClick={() => {
)} if (activeAssistant?.model.settings?.vision_model) {
onClick={() => { imageInputRef.current?.click()
if (activeAssistant?.model.settings?.vision_model) { setShowAttacmentMenus(false)
imageInputRef.current?.click() }
setShowAttacmentMenus(false) }}
} >
}} <ImageIcon size={16} />
> <span className="font-medium">Image</span>
<ImageIcon size={16} /> </li>
<span className="font-medium">Image</span>
</li>
}
content="This feature only supports multimodal models."
disabled={activeAssistant?.model.settings?.vision_model}
/>
<Tooltip <Tooltip
side="bottom" side="bottom"
trigger={ trigger={

View File

@ -1,4 +1,4 @@
import React, { forwardRef, useEffect, useState } from 'react' import React, { forwardRef, useEffect, useRef, useState } from 'react'
import { import {
events, events,
@ -8,10 +8,14 @@ import {
ThreadMessage, ThreadMessage,
} from '@janhq/core' } from '@janhq/core'
import { useAtom } from 'jotai'
import ErrorMessage from '@/containers/ErrorMessage' import ErrorMessage from '@/containers/ErrorMessage'
import MessageContainer from '../TextMessage' import MessageContainer from '../TextMessage'
import { subscribedGeneratingMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
type Ref = HTMLDivElement type Ref = HTMLDivElement
type Props = { type Props = {
@ -22,9 +26,13 @@ type Props = {
const ChatItem = forwardRef<Ref, Props>((message, ref) => { const ChatItem = forwardRef<Ref, Props>((message, ref) => {
const [content, setContent] = useState<ThreadContent[]>(message.content) const [content, setContent] = useState<ThreadContent[]>(message.content)
const [status, setStatus] = useState<MessageStatus>(message.status) const [status, setStatus] = useState<MessageStatus>(message.status)
const [subscribedGeneratingMessage, setSubscribedGeneratingMessage] = useAtom(
subscribedGeneratingMessageAtom
)
const [errorMessage, setErrorMessage] = useState<ThreadMessage | undefined>( const [errorMessage, setErrorMessage] = useState<ThreadMessage | undefined>(
message.isCurrentMessage && !!message?.metadata?.error ? message : undefined message.isCurrentMessage && !!message?.metadata?.error ? message : undefined
) )
const subscribedGeneratingMessageRef = useRef(subscribedGeneratingMessage)
function onMessageUpdate(data: ThreadMessage) { function onMessageUpdate(data: ThreadMessage) {
if (data.id === message.id) { if (data.id === message.id) {
@ -32,9 +40,21 @@ const ChatItem = forwardRef<Ref, Props>((message, ref) => {
if (data.status !== status) setStatus(data.status) if (data.status !== status) setStatus(data.status)
if (data.status === MessageStatus.Error && message.isCurrentMessage) if (data.status === MessageStatus.Error && message.isCurrentMessage)
setErrorMessage(data) setErrorMessage(data)
// Update subscriber if the message is generating
if (
subscribedGeneratingMessageRef.current?.thread_id !== message.thread_id
)
setSubscribedGeneratingMessage({
thread_id: message.thread_id,
})
} }
} }
useEffect(() => {
subscribedGeneratingMessageRef.current = subscribedGeneratingMessage
}, [subscribedGeneratingMessage])
useEffect(() => { useEffect(() => {
if (!message.isCurrentMessage && errorMessage) setErrorMessage(undefined) if (!message.isCurrentMessage && errorMessage) setErrorMessage(undefined)
}, [message, errorMessage]) }, [message, errorMessage])

View File

@ -30,6 +30,7 @@ import RequestDownloadModel from './RequestDownloadModel'
import { showSystemMonitorPanelAtom } from '@/helpers/atoms/App.atom' import { showSystemMonitorPanelAtom } from '@/helpers/atoms/App.atom'
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom' import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom' import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom'
import { chatWidthAtom } from '@/helpers/atoms/Setting.atom'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
import { import {
@ -59,6 +60,7 @@ const ThreadCenterPanel = () => {
const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom) const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const activeAssistant = useAtomValue(activeAssistantAtom) const activeAssistant = useAtomValue(activeAssistantAtom)
const chatWidth = useAtomValue(chatWidthAtom)
const upload = uploader() const upload = uploader()
const acceptedFormat: Accept = activeAssistant?.model.settings?.vision_model const acceptedFormat: Accept = activeAssistant?.model.settings?.vision_model
? { ? {
@ -235,7 +237,14 @@ const ThreadCenterPanel = () => {
{reloadModel && <ModelReload />} {reloadModel && <ModelReload />}
{activeModel && isGeneratingResponse && <GenerateResponse />} {activeModel && isGeneratingResponse && <GenerateResponse />}
<ChatInput /> <div
className={twMerge(
'mx-auto w-full',
chatWidth === 'compact' && 'max-w-[700px]'
)}
>
<ChatInput />
</div>
</div> </div>
</div> </div>
</CenterPanelContainer> </CenterPanelContainer>

View File

@ -32,12 +32,9 @@ const Tools = () => {
useEffect(() => { useEffect(() => {
if (!activeThread) return if (!activeThread) return
let model = downloadedModels.find( const model = downloadedModels.find(
(model) => model.id === activeAssistant?.model.id (model) => model.id === activeAssistant?.model.id
) )
if (!model) {
model = recommendedModel
}
setSelectedModel(model) setSelectedModel(model)
}, [ }, [
recommendedModel, recommendedModel,

View File

@ -257,7 +257,7 @@ const ThreadRightPanel = () => {
id="assistant-instructions" id="assistant-instructions"
placeholder="Eg. You are a helpful assistant." placeholder="Eg. You are a helpful assistant."
value={activeAssistant?.instructions ?? ''} value={activeAssistant?.instructions ?? ''}
autoResize // autoResize
onChange={onAssistantInstructionChanged} onChange={onAssistantInstructionChanged}
/> />
</div> </div>