From 9568ff12e86401eb0c8d5321953f8bcee12d37ee Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 24 Sep 2025 15:47:46 +0700 Subject: [PATCH 01/22] feat: add cleanup logic for windows installer --- src-tauri/windows/hooks.nsh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/windows/hooks.nsh b/src-tauri/windows/hooks.nsh index 5e1a32141..d1beed199 100644 --- a/src-tauri/windows/hooks.nsh +++ b/src-tauri/windows/hooks.nsh @@ -42,6 +42,11 @@ ${If} ${FileExists} "$INSTDIR\resources\LICENSE" CopyFiles /SILENT "$INSTDIR\resources\LICENSE" "$INSTDIR\LICENSE" DetailPrint "Copied LICENSE to install root" + + ; Optional cleanup - remove from resources folder + Delete "$INSTDIR\resources\LICENSE" + ${Else} + DetailPrint "LICENSE not found at expected location: $INSTDIR\resources\LICENSE" ${EndIf} ; ---- Copy vulkan-1.dll to install root ---- @@ -51,6 +56,7 @@ ; Optional cleanup - remove from resources folder Delete "$INSTDIR\resources\lib\vulkan-1.dll" + ; Only remove the lib directory if it's empty after removing both files RMDir "$INSTDIR\resources\lib" ${Else} From 57110d2bd7fe10c6658e388dfcacb6e10d981a7d Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 24 Sep 2025 17:57:10 +0700 Subject: [PATCH 02/22] fix: allow users to download the same model from different authors (#6577) * fix: allow users to download the same model from different authors * fix: importing models should have author name in the ID * fix: incorrect model id show * fix: tests * fix: default to mmproj f16 instead of bf16 * fix: type * fix: build error --- .../browser/extensions/engines/AIEngine.ts | 6 + .../src/jan-provider-web/provider.ts | 102 +++++-- extensions/llamacpp-extension/src/index.ts | 32 +- web-app/src/containers/DownloadButton.tsx | 142 +++++++++ web-app/src/containers/RenderMarkdown.tsx | 1 + web-app/src/routes/hub/$modelId.tsx | 40 ++- web-app/src/routes/hub/index.tsx | 282 ++++-------------- web-app/src/services/__tests__/models.test.ts | 106 +++++-- web-app/src/services/models/default.ts | 6 +- web-app/src/services/models/types.ts | 1 + 10 files changed, 407 insertions(+), 311 deletions(-) create mode 100644 web-app/src/containers/DownloadButton.tsx diff --git a/core/src/browser/extensions/engines/AIEngine.ts b/core/src/browser/extensions/engines/AIEngine.ts index 0e8a75fca..855f6e4dc 100644 --- a/core/src/browser/extensions/engines/AIEngine.ts +++ b/core/src/browser/extensions/engines/AIEngine.ts @@ -240,6 +240,12 @@ export abstract class AIEngine extends BaseExtension { EngineManager.instance().register(this) } + /** + * Gets model info + * @param modelId + */ + abstract get(modelId: string): Promise + /** * Lists available models */ diff --git a/extensions-web/src/jan-provider-web/provider.ts b/extensions-web/src/jan-provider-web/provider.ts index 216da66c9..dfdfe01b4 100644 --- a/extensions-web/src/jan-provider-web/provider.ts +++ b/extensions-web/src/jan-provider-web/provider.ts @@ -22,7 +22,7 @@ export default class JanProviderWeb extends AIEngine { override async onLoad() { console.log('Loading Jan Provider Extension...') - + try { // Initialize authentication and fetch models await janApiClient.initialize() @@ -37,20 +37,43 @@ export default class JanProviderWeb extends AIEngine { override async onUnload() { console.log('Unloading Jan Provider Extension...') - + // Clear all sessions for (const sessionId of this.activeSessions.keys()) { await this.unload(sessionId) } - + janProviderStore.reset() console.log('Jan Provider Extension unloaded') } + async get(modelId: string): Promise { + return janApiClient + .getModels() + .then((list) => list.find((e) => e.id === modelId)) + .then((model) => + model + ? { + id: model.id, + name: model.id, // Use ID as name for now + quant_type: undefined, + providerId: this.provider, + port: 443, // HTTPS port for API + sizeBytes: 0, // Size not provided by Jan API + tags: [], + path: undefined, // Remote model, no local path + owned_by: model.owned_by, + object: model.object, + capabilities: ['tools'], // Jan models support both tools via MCP + } + : undefined + ) + } + async list(): Promise { try { const janModels = await janApiClient.getModels() - + return janModels.map((model) => ({ id: model.id, name: model.id, // Use ID as name for now @@ -75,7 +98,7 @@ export default class JanProviderWeb extends AIEngine { // For Jan API, we don't actually "load" models in the traditional sense // We just create a session reference for tracking const sessionId = `jan-${modelId}-${Date.now()}` - + const sessionInfo: SessionInfo = { pid: Date.now(), // Use timestamp as pseudo-PID port: 443, // HTTPS port @@ -85,8 +108,10 @@ export default class JanProviderWeb extends AIEngine { } this.activeSessions.set(sessionId, sessionInfo) - - console.log(`Jan model session created: ${sessionId} for model ${modelId}`) + + console.log( + `Jan model session created: ${sessionId} for model ${modelId}` + ) return sessionInfo } catch (error) { console.error(`Failed to load Jan model ${modelId}:`, error) @@ -97,23 +122,23 @@ export default class JanProviderWeb extends AIEngine { async unload(sessionId: string): Promise { try { const session = this.activeSessions.get(sessionId) - + if (!session) { return { success: false, - error: `Session ${sessionId} not found` + error: `Session ${sessionId} not found`, } } this.activeSessions.delete(sessionId) console.log(`Jan model session unloaded: ${sessionId}`) - + return { success: true } } catch (error) { console.error(`Failed to unload Jan session ${sessionId}:`, error) return { success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', } } } @@ -136,9 +161,12 @@ export default class JanProviderWeb extends AIEngine { } // Convert core chat completion request to Jan API format - const janMessages: JanChatMessage[] = opts.messages.map(msg => ({ + const janMessages: JanChatMessage[] = opts.messages.map((msg) => ({ role: msg.role as 'system' | 'user' | 'assistant', - content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + content: + typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content), })) const janRequest = { @@ -162,18 +190,18 @@ export default class JanProviderWeb extends AIEngine { } else { // Return single response const response = await janApiClient.createChatCompletion(janRequest) - + // Check if aborted after completion if (abortController?.signal?.aborted) { throw new Error('Request was aborted') } - + return { id: response.id, object: 'chat.completion' as const, created: response.created, model: response.model, - choices: response.choices.map(choice => ({ + choices: response.choices.map((choice) => ({ index: choice.index, message: { role: choice.message.role, @@ -182,7 +210,12 @@ export default class JanProviderWeb extends AIEngine { reasoning_content: choice.message.reasoning_content, tool_calls: choice.message.tool_calls, }, - finish_reason: (choice.finish_reason || 'stop') as 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call', + finish_reason: (choice.finish_reason || 'stop') as + | 'stop' + | 'length' + | 'tool_calls' + | 'content_filter' + | 'function_call', })), usage: response.usage, } @@ -193,7 +226,10 @@ export default class JanProviderWeb extends AIEngine { } } - private async *createStreamingGenerator(janRequest: any, abortController?: AbortController) { + private async *createStreamingGenerator( + janRequest: any, + abortController?: AbortController + ) { let resolve: () => void let reject: (error: Error) => void const chunks: any[] = [] @@ -231,7 +267,7 @@ export default class JanProviderWeb extends AIEngine { object: chunk.object, created: chunk.created, model: chunk.model, - choices: chunk.choices.map(choice => ({ + choices: chunk.choices.map((choice) => ({ index: choice.index, delta: { role: choice.delta.role, @@ -261,14 +297,14 @@ export default class JanProviderWeb extends AIEngine { if (abortController?.signal?.aborted) { throw new Error('Request was aborted') } - + while (yieldedIndex < chunks.length) { yield chunks[yieldedIndex] yieldedIndex++ } - + // Wait a bit before checking again - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) } // Yield any remaining chunks @@ -291,24 +327,32 @@ export default class JanProviderWeb extends AIEngine { } async delete(modelId: string): Promise { - throw new Error(`Delete operation not supported for remote Jan API model: ${modelId}`) + throw new Error( + `Delete operation not supported for remote Jan API model: ${modelId}` + ) } async import(modelId: string, _opts: ImportOptions): Promise { - throw new Error(`Import operation not supported for remote Jan API model: ${modelId}`) + throw new Error( + `Import operation not supported for remote Jan API model: ${modelId}` + ) } async abortImport(modelId: string): Promise { - throw new Error(`Abort import operation not supported for remote Jan API model: ${modelId}`) + throw new Error( + `Abort import operation not supported for remote Jan API model: ${modelId}` + ) } async getLoadedModels(): Promise { - return Array.from(this.activeSessions.values()).map(session => session.model_id) + return Array.from(this.activeSessions.values()).map( + (session) => session.model_id + ) } async isToolSupported(modelId: string): Promise { // Jan models support tool calls via MCP - console.log(`Checking tool support for Jan model ${modelId}: supported`); - return true; + console.log(`Checking tool support for Jan model ${modelId}: supported`) + return true } -} \ No newline at end of file +} diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index 77b0aafcd..8fad4fd87 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -922,6 +922,30 @@ export default class llamacpp_extension extends AIEngine { return hash } + override async get(modelId: string): Promise { + const modelPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + const path = await joinPath([modelPath, 'model.yml']) + + if (!(await fs.existsSync(path))) return undefined + + const modelConfig = await invoke('read_yaml', { + path, + }) + + return { + id: modelId, + name: modelConfig.name ?? modelId, + quant_type: undefined, // TODO: parse quantization type from model.yml or model.gguf + providerId: this.provider, + port: 0, // port is not known until the model is loaded + sizeBytes: modelConfig.size_bytes ?? 0, + } as modelInfo + } + // Implement the required LocalProvider interface methods override async list(): Promise { const modelsDir = await joinPath([await this.getProviderPath(), 'models']) @@ -1085,7 +1109,10 @@ export default class llamacpp_extension extends AIEngine { const archiveName = await basename(path) logger.info(`Installing backend from path: ${path}`) - if (!(await fs.existsSync(path)) || (!path.endsWith('tar.gz') && !path.endsWith('zip'))) { + if ( + !(await fs.existsSync(path)) || + (!path.endsWith('tar.gz') && !path.endsWith('zip')) + ) { logger.error(`Invalid path or file ${path}`) throw new Error(`Invalid path or file ${path}`) } @@ -2601,7 +2628,8 @@ export default class llamacpp_extension extends AIEngine { metadata: Record ): Promise { // Extract vision parameters from metadata - const projectionDim = Math.floor(Number(metadata['clip.vision.projection_dim']) / 10) || 256 + const projectionDim = + Math.floor(Number(metadata['clip.vision.projection_dim']) / 10) || 256 // Count images in messages let imageCount = 0 diff --git a/web-app/src/containers/DownloadButton.tsx b/web-app/src/containers/DownloadButton.tsx new file mode 100644 index 000000000..7d4db703b --- /dev/null +++ b/web-app/src/containers/DownloadButton.tsx @@ -0,0 +1,142 @@ +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { useDownloadStore } from '@/hooks/useDownloadStore' +import { useGeneralSetting } from '@/hooks/useGeneralSetting' +import { useModelProvider } from '@/hooks/useModelProvider' +import { useServiceHub } from '@/hooks/useServiceHub' +import { useTranslation } from '@/i18n' +import { extractModelName } from '@/lib/models' +import { cn, sanitizeModelId } from '@/lib/utils' +import { CatalogModel } from '@/services/models/types' +import { useCallback, useMemo } from 'react' +import { useShallow } from 'zustand/shallow' + +type ModelProps = { + model: CatalogModel + handleUseModel: (modelId: string) => void +} +const defaultModelQuantizations = ['iq4_xs', 'q4_k_m'] + +export function DownloadButtonPlaceholder({ + model, + handleUseModel, +}: ModelProps) { + const { downloads, localDownloadingModels, addLocalDownloadingModel } = + useDownloadStore( + useShallow((state) => ({ + downloads: state.downloads, + localDownloadingModels: state.localDownloadingModels, + addLocalDownloadingModel: state.addLocalDownloadingModel, + })) + ) + const { t } = useTranslation() + const getProviderByName = useModelProvider((state) => state.getProviderByName) + const llamaProvider = getProviderByName('llamacpp') + + const serviceHub = useServiceHub() + const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken) + + const quant = + model.quants.find((e) => + defaultModelQuantizations.some((m) => + e.model_id.toLowerCase().includes(m) + ) + ) ?? model.quants[0] + + const modelId = quant?.model_id || model.model_name + + const downloadProcesses = useMemo( + () => + Object.values(downloads).map((download) => ({ + id: download.name, + name: download.name, + progress: download.progress, + current: download.current, + total: download.total, + })), + [downloads] + ) + + const isRecommendedModel = useCallback((modelId: string) => { + return (extractModelName(modelId)?.toLowerCase() === + 'jan-nano-gguf') as boolean + }, []) + + if (model.quants.length === 0) { + return ( +
+ +
+ ) + } + + const modelUrl = quant?.path || modelId + const isDownloading = + localDownloadingModels.has(modelId) || + downloadProcesses.some((e) => e.id === modelId) + + const downloadProgress = + downloadProcesses.find((e) => e.id === modelId)?.progress || 0 + const isDownloaded = llamaProvider?.models.some( + (m: { id: string }) => + m.id === modelId || + m.id === `${model.developer}/${sanitizeModelId(modelId)}` + ) + const isRecommended = isRecommendedModel(model.model_name) + + const handleDownload = () => { + // Immediately set local downloading state + addLocalDownloadingModel(modelId) + const mmprojPath = ( + model.mmproj_models?.find( + (e) => e.model_id.toLowerCase() === 'mmproj-f16' + ) || model.mmproj_models?.[0] + )?.path + serviceHub + .models() + .pullModelWithMetadata(modelId, modelUrl, mmprojPath, huggingfaceToken) + } + + return ( +
+ {isDownloading && !isDownloaded && ( +
+ + + {Math.round(downloadProgress * 100)}% + +
+ )} + {isDownloaded ? ( + + ) : ( + + )} +
+ ) +} diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index da702eff6..31d08cf10 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -89,6 +89,7 @@ const CodeComponent = memo( onCopy, copiedId, ...props + // eslint-disable-next-line @typescript-eslint/no-explicit-any }: any) => { const { t } = useTranslation() const match = /language-(\w+)/.exec(className || '') diff --git a/web-app/src/routes/hub/$modelId.tsx b/web-app/src/routes/hub/$modelId.tsx index 75ccc58bf..102b5cece 100644 --- a/web-app/src/routes/hub/$modelId.tsx +++ b/web-app/src/routes/hub/$modelId.tsx @@ -21,10 +21,7 @@ import { useEffect, useMemo, useCallback, useState } from 'react' import { useModelProvider } from '@/hooks/useModelProvider' import { useDownloadStore } from '@/hooks/useDownloadStore' import { useServiceHub } from '@/hooks/useServiceHub' -import type { - CatalogModel, - ModelQuant, -} from '@/services/models/types' +import type { CatalogModel, ModelQuant } from '@/services/models/types' import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -80,12 +77,13 @@ function HubModelDetailContent() { }, [fetchSources]) const fetchRepo = useCallback(async () => { - const repoInfo = await serviceHub.models().fetchHuggingFaceRepo( - search.repo || modelId, - huggingfaceToken - ) + const repoInfo = await serviceHub + .models() + .fetchHuggingFaceRepo(search.repo || modelId, huggingfaceToken) if (repoInfo) { - const repoDetail = serviceHub.models().convertHfRepoToCatalogModel(repoInfo) + const repoDetail = serviceHub + .models() + .convertHfRepoToCatalogModel(repoInfo) setRepoData(repoDetail || undefined) } }, [serviceHub, modelId, search, huggingfaceToken]) @@ -168,7 +166,9 @@ function HubModelDetailContent() { try { // Use the HuggingFace path for the model const modelPath = variant.path - const supported = await serviceHub.models().isModelSupported(modelPath, 8192) + const supported = await serviceHub + .models() + .isModelSupported(modelPath, 8192) setModelSupportStatus((prev) => ({ ...prev, [modelKey]: supported, @@ -473,12 +473,20 @@ function HubModelDetailContent() { addLocalDownloadingModel( variant.model_id ) - serviceHub.models().pullModelWithMetadata( - variant.model_id, - variant.path, - modelData.mmproj_models?.[0]?.path, - huggingfaceToken - ) + serviceHub + .models() + .pullModelWithMetadata( + variant.model_id, + variant.path, + ( + modelData.mmproj_models?.find( + (e) => + e.model_id.toLowerCase() === + 'mmproj-f16' + ) || modelData.mmproj_models?.[0] + )?.path, + huggingfaceToken + ) }} className={cn(isDownloading && 'hidden')} > diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index 2a53a848f..be63c49b6 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useVirtualizer } from '@tanstack/react-virtual' -import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useModelSources } from '@/hooks/useModelSources' import { cn } from '@/lib/utils' @@ -34,8 +34,6 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { ModelInfoHoverCard } from '@/containers/ModelInfoHoverCard' -import Joyride, { CallBackProps, STATUS } from 'react-joyride' -import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide' import { DropdownMenu, DropdownMenuContent, @@ -51,10 +49,9 @@ import { Loader } from 'lucide-react' import { useTranslation } from '@/i18n/react-i18next-compat' import Fuse from 'fuse.js' import { useGeneralSetting } from '@/hooks/useGeneralSetting' +import { DownloadButtonPlaceholder } from '@/containers/DownloadButton' +import { useShallow } from 'zustand/shallow' -type ModelProps = { - model: CatalogModel -} type SearchParams = { repo: string } @@ -77,7 +74,7 @@ function Hub() { function HubContent() { const parentRef = useRef(null) - const { huggingfaceToken } = useGeneralSetting() + const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken) const serviceHub = useServiceHub() const { t } = useTranslation() @@ -93,7 +90,13 @@ function HubContent() { } }, []) - const { sources, fetchSources, loading } = useModelSources() + const { sources, fetchSources, loading } = useModelSources( + useShallow((state) => ({ + sources: state.sources, + fetchSources: state.fetchSources, + loading: state.loading, + })) + ) const [searchValue, setSearchValue] = useState('') const [sortSelected, setSortSelected] = useState('newest') @@ -108,16 +111,9 @@ function HubContent() { const [modelSupportStatus, setModelSupportStatus] = useState< Record >({}) - const [joyrideReady, setJoyrideReady] = useState(false) - const [currentStepIndex, setCurrentStepIndex] = useState(0) const addModelSourceTimeoutRef = useRef | null>( null ) - const downloadButtonRef = useRef(null) - const hasTriggeredDownload = useRef(false) - - const { getProviderByName } = useModelProvider() - const llamaProvider = getProviderByName('llamacpp') const toggleModelExpansion = (modelId: string) => { setExpandedModels((prev) => ({ @@ -168,9 +164,10 @@ function HubContent() { ?.map((model) => ({ ...model, quants: model.quants.filter((variant) => - llamaProvider?.models.some( - (m: { id: string }) => m.id === variant.model_id - ) + useModelProvider + .getState() + .getProviderByName('llamacpp') + ?.models.some((m: { id: string }) => m.id === variant.model_id) ), })) .filter((model) => model.quants.length > 0) @@ -186,7 +183,6 @@ function HubContent() { showOnlyDownloaded, huggingFaceRepo, searchOptions, - llamaProvider?.models, ]) // The virtualizer @@ -215,9 +211,13 @@ function HubContent() { addModelSourceTimeoutRef.current = setTimeout(async () => { try { - const repoInfo = await serviceHub.models().fetchHuggingFaceRepo(searchValue, huggingfaceToken) + const repoInfo = await serviceHub + .models() + .fetchHuggingFaceRepo(searchValue, huggingfaceToken) if (repoInfo) { - const catalogModel = serviceHub.models().convertHfRepoToCatalogModel(repoInfo) + const catalogModel = serviceHub + .models() + .convertHfRepoToCatalogModel(repoInfo) if ( !sources.some( (s) => @@ -303,7 +303,9 @@ function HubContent() { try { // Use the HuggingFace path for the model const modelPath = variant.path - const supportStatus = await serviceHub.models().isModelSupported(modelPath, 8192) + const supportStatus = await serviceHub + .models() + .isModelSupported(modelPath, 8192) setModelSupportStatus((prev) => ({ ...prev, @@ -320,178 +322,7 @@ function HubContent() { [modelSupportStatus, serviceHub] ) - const DownloadButtonPlaceholder = useMemo(() => { - return ({ model }: ModelProps) => { - // Check if this is a HuggingFace repository (no quants) - if (model.quants.length === 0) { - return ( -
- -
- ) - } - - const quant = - model.quants.find((e) => - defaultModelQuantizations.some((m) => - e.model_id.toLowerCase().includes(m) - ) - ) ?? model.quants[0] - const modelId = quant?.model_id || model.model_name - const modelUrl = quant?.path || modelId - const isDownloading = - localDownloadingModels.has(modelId) || - downloadProcesses.some((e) => e.id === modelId) - const downloadProgress = - downloadProcesses.find((e) => e.id === modelId)?.progress || 0 - const isDownloaded = llamaProvider?.models.some( - (m: { id: string }) => m.id === modelId - ) - const isRecommended = isRecommendedModel(model.model_name) - - const handleDownload = () => { - // Immediately set local downloading state - addLocalDownloadingModel(modelId) - const mmprojPath = model.mmproj_models?.[0]?.path - serviceHub.models().pullModelWithMetadata( - modelId, - modelUrl, - mmprojPath, - huggingfaceToken - ) - } - - return ( -
- {isDownloading && !isDownloaded && ( -
- - - {Math.round(downloadProgress * 100)}% - -
- )} - {isDownloaded ? ( - - ) : ( - - )} -
- ) - } - }, [ - localDownloadingModels, - downloadProcesses, - llamaProvider?.models, - isRecommendedModel, - t, - addLocalDownloadingModel, - huggingfaceToken, - handleUseModel, - serviceHub, - ]) - - const { step } = useSearch({ from: Route.id }) - const isSetup = step === 'setup_local_provider' - - // Wait for DOM to be ready before starting Joyride - useEffect(() => { - if (!loading && filteredModels.length > 0 && isSetup) { - const timer = setTimeout(() => { - setJoyrideReady(true) - }, 100) - return () => clearTimeout(timer) - } else { - setJoyrideReady(false) - } - }, [loading, filteredModels.length, isSetup]) - - const handleJoyrideCallback = (data: CallBackProps) => { - const { status, index } = data - - if ( - status === STATUS.FINISHED && - !isDownloading && - isLastStep && - !hasTriggeredDownload.current - ) { - const recommendedModel = filteredModels.find((model) => - isRecommendedModel(model.model_name) - ) - if (recommendedModel && recommendedModel.quants[0]?.model_id) { - if (downloadButtonRef.current) { - hasTriggeredDownload.current = true - downloadButtonRef.current.click() - } - return - } - } - - if (status === STATUS.FINISHED) { - navigate({ - to: route.hub.index, - }) - } - - // Track current step index - setCurrentStepIndex(index) - } - - // Check if any model is currently downloading - const isDownloading = - localDownloadingModels.size > 0 || downloadProcesses.length > 0 - - const steps = [ - { - target: '.hub-model-card-step', - title: t('hub:joyride.recommendedModelTitle'), - disableBeacon: true, - content: t('hub:joyride.recommendedModelContent'), - }, - { - target: '.hub-download-button-step', - title: isDownloading - ? t('hub:joyride.downloadInProgressTitle') - : t('hub:joyride.downloadModelTitle'), - disableBeacon: true, - content: isDownloading - ? t('hub:joyride.downloadInProgressContent') - : t('hub:joyride.downloadModelContent'), - }, - ] - // Check if we're on the last step - const isLastStep = currentStepIndex === steps.length - 1 - const renderFilter = () => { return ( <> @@ -544,31 +375,6 @@ function HubContent() { return ( <> -
@@ -698,6 +504,7 @@ function HubContent() { />
@@ -908,10 +715,13 @@ function HubContent() { (e) => e.id === variant.model_id )?.progress || 0 const isDownloaded = - llamaProvider?.models.some( - (m: { id: string }) => - m.id === variant.model_id - ) + useModelProvider + .getState() + .getProviderByName('llamacpp') + ?.models.some( + (m: { id: string }) => + m.id === variant.model_id + ) if (isDownloading) { return ( @@ -962,14 +772,26 @@ function HubContent() { addLocalDownloadingModel( variant.model_id ) - serviceHub.models().pullModelWithMetadata( - variant.model_id, - variant.path, - filteredModels[ - virtualItem.index - ].mmproj_models?.[0]?.path, - huggingfaceToken - ) + serviceHub + .models() + .pullModelWithMetadata( + variant.model_id, + variant.path, + + ( + filteredModels[ + virtualItem.index + ].mmproj_models?.find( + (e) => + e.model_id.toLowerCase() === + 'mmproj-f16' + ) || + filteredModels[ + virtualItem.index + ].mmproj_models?.[0] + )?.path, + huggingfaceToken + ) }} > { let modelsService: DefaultModelsService - + const mockEngine = { list: vi.fn(), updateSettings: vi.fn(), @@ -246,7 +246,9 @@ describe('DefaultModelsService', () => { }) mockEngine.load.mockRejectedValue(error) - await expect(modelsService.startModel(provider, model)).rejects.toThrow(error) + await expect(modelsService.startModel(provider, model)).rejects.toThrow( + error + ) }) it('should not load model again', async () => { const mockSettings = { @@ -263,7 +265,9 @@ describe('DefaultModelsService', () => { includes: () => true, }) expect(mockEngine.load).toBeCalledTimes(0) - await expect(modelsService.startModel(provider, model)).resolves.toBe(undefined) + await expect(modelsService.startModel(provider, model)).resolves.toBe( + undefined + ) }) }) @@ -312,7 +316,9 @@ describe('DefaultModelsService', () => { json: vi.fn().mockResolvedValue(mockRepoData), }) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toEqual(mockRepoData) expect(fetch).toHaveBeenCalledWith( @@ -342,7 +348,9 @@ describe('DefaultModelsService', () => { ) // Test with domain prefix - await modelsService.fetchHuggingFaceRepo('huggingface.co/microsoft/DialoGPT-medium') + await modelsService.fetchHuggingFaceRepo( + 'huggingface.co/microsoft/DialoGPT-medium' + ) expect(fetch).toHaveBeenCalledWith( 'https://huggingface.co/api/models/microsoft/DialoGPT-medium?blobs=true&files_metadata=true', { @@ -365,7 +373,9 @@ describe('DefaultModelsService', () => { expect(await modelsService.fetchHuggingFaceRepo('')).toBeNull() // Test string without slash - expect(await modelsService.fetchHuggingFaceRepo('invalid-repo')).toBeNull() + expect( + await modelsService.fetchHuggingFaceRepo('invalid-repo') + ).toBeNull() // Test whitespace only expect(await modelsService.fetchHuggingFaceRepo(' ')).toBeNull() @@ -378,7 +388,8 @@ describe('DefaultModelsService', () => { statusText: 'Not Found', }) - const result = await modelsService.fetchHuggingFaceRepo('nonexistent/model') + const result = + await modelsService.fetchHuggingFaceRepo('nonexistent/model') expect(result).toBeNull() expect(fetch).toHaveBeenCalledWith( @@ -398,7 +409,9 @@ describe('DefaultModelsService', () => { statusText: 'Internal Server Error', }) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toBeNull() expect(consoleSpy).toHaveBeenCalledWith( @@ -414,7 +427,9 @@ describe('DefaultModelsService', () => { ;(fetch as any).mockRejectedValue(new Error('Network error')) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toBeNull() expect(consoleSpy).toHaveBeenCalledWith( @@ -448,7 +463,9 @@ describe('DefaultModelsService', () => { json: vi.fn().mockResolvedValue(mockRepoData), }) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toEqual(mockRepoData) }) @@ -487,7 +504,9 @@ describe('DefaultModelsService', () => { json: vi.fn().mockResolvedValue(mockRepoData), }) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toEqual(mockRepoData) }) @@ -531,7 +550,9 @@ describe('DefaultModelsService', () => { json: vi.fn().mockResolvedValue(mockRepoData), }) - const result = await modelsService.fetchHuggingFaceRepo('microsoft/DialoGPT-medium') + const result = await modelsService.fetchHuggingFaceRepo( + 'microsoft/DialoGPT-medium' + ) expect(result).toEqual(mockRepoData) // Verify the GGUF file is present in siblings @@ -576,7 +597,8 @@ describe('DefaultModelsService', () => { } it('should convert HuggingFace repo to catalog model format', () => { - const result = modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) + const result = + modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) const expected: CatalogModel = { model_name: 'microsoft/DialoGPT-medium', @@ -586,12 +608,12 @@ describe('DefaultModelsService', () => { num_quants: 2, quants: [ { - model_id: 'model-q4_0', + model_id: 'microsoft/model-q4_0', path: 'https://huggingface.co/microsoft/DialoGPT-medium/resolve/main/model-q4_0.gguf', file_size: '2.0 GB', }, { - model_id: 'model-q8_0', + model_id: 'microsoft/model-q8_0', path: 'https://huggingface.co/microsoft/DialoGPT-medium/resolve/main/model-q8_0.GGUF', file_size: '4.0 GB', }, @@ -635,7 +657,8 @@ describe('DefaultModelsService', () => { siblings: undefined, } - const result = modelsService.convertHfRepoToCatalogModel(repoWithoutSiblings) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithoutSiblings) expect(result.num_quants).toBe(0) expect(result.quants).toEqual([]) @@ -663,7 +686,9 @@ describe('DefaultModelsService', () => { ], } - const result = modelsService.convertHfRepoToCatalogModel(repoWithVariousFileSizes) + const result = modelsService.convertHfRepoToCatalogModel( + repoWithVariousFileSizes + ) expect(result.quants[0].file_size).toBe('500.0 MB') expect(result.quants[1].file_size).toBe('3.5 GB') @@ -676,7 +701,8 @@ describe('DefaultModelsService', () => { tags: [], } - const result = modelsService.convertHfRepoToCatalogModel(repoWithEmptyTags) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithEmptyTags) expect(result.description).toBe('**Tags**: ') }) @@ -687,7 +713,8 @@ describe('DefaultModelsService', () => { downloads: undefined as any, } - const result = modelsService.convertHfRepoToCatalogModel(repoWithoutDownloads) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithoutDownloads) expect(result.downloads).toBe(0) }) @@ -714,15 +741,17 @@ describe('DefaultModelsService', () => { ], } - const result = modelsService.convertHfRepoToCatalogModel(repoWithVariousGGUF) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithVariousGGUF) - expect(result.quants[0].model_id).toBe('model') - expect(result.quants[1].model_id).toBe('MODEL') - expect(result.quants[2].model_id).toBe('complex-model-name') + expect(result.quants[0].model_id).toBe('microsoft/model') + expect(result.quants[1].model_id).toBe('microsoft/MODEL') + expect(result.quants[2].model_id).toBe('microsoft/complex-model-name') }) it('should generate correct download paths', () => { - const result = modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) + const result = + modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) expect(result.quants[0].path).toBe( 'https://huggingface.co/microsoft/DialoGPT-medium/resolve/main/model-q4_0.gguf' @@ -733,7 +762,8 @@ describe('DefaultModelsService', () => { }) it('should generate correct readme URL', () => { - const result = modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) + const result = + modelsService.convertHfRepoToCatalogModel(mockHuggingFaceRepo) expect(result.readme).toBe( 'https://huggingface.co/microsoft/DialoGPT-medium/resolve/main/README.md' @@ -767,13 +797,14 @@ describe('DefaultModelsService', () => { ], } - const result = modelsService.convertHfRepoToCatalogModel(repoWithMixedCase) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithMixedCase) expect(result.num_quants).toBe(3) expect(result.quants).toHaveLength(3) - expect(result.quants[0].model_id).toBe('model-1') - expect(result.quants[1].model_id).toBe('model-2') - expect(result.quants[2].model_id).toBe('model-3') + expect(result.quants[0].model_id).toBe('microsoft/model-1') + expect(result.quants[1].model_id).toBe('microsoft/model-2') + expect(result.quants[2].model_id).toBe('microsoft/model-3') }) it('should handle edge cases with file size formatting', () => { @@ -798,7 +829,8 @@ describe('DefaultModelsService', () => { ], } - const result = modelsService.convertHfRepoToCatalogModel(repoWithEdgeCases) + const result = + modelsService.convertHfRepoToCatalogModel(repoWithEdgeCases) expect(result.quants[0].file_size).toBe('0.0 MB') expect(result.quants[1].file_size).toBe('1.0 GB') @@ -850,7 +882,10 @@ describe('DefaultModelsService', () => { mockEngineManager.get.mockReturnValue(mockEngineWithSupport) - const result = await modelsService.isModelSupported('/path/to/model.gguf', 4096) + const result = await modelsService.isModelSupported( + '/path/to/model.gguf', + 4096 + ) expect(result).toBe('GREEN') expect(mockEngineWithSupport.isModelSupported).toHaveBeenCalledWith( @@ -867,7 +902,10 @@ describe('DefaultModelsService', () => { mockEngineManager.get.mockReturnValue(mockEngineWithSupport) - const result = await modelsService.isModelSupported('/path/to/model.gguf', 8192) + const result = await modelsService.isModelSupported( + '/path/to/model.gguf', + 8192 + ) expect(result).toBe('YELLOW') expect(mockEngineWithSupport.isModelSupported).toHaveBeenCalledWith( @@ -884,7 +922,9 @@ describe('DefaultModelsService', () => { mockEngineManager.get.mockReturnValue(mockEngineWithSupport) - const result = await modelsService.isModelSupported('/path/to/large-model.gguf') + const result = await modelsService.isModelSupported( + '/path/to/large-model.gguf' + ) expect(result).toBe('RED') expect(mockEngineWithSupport.isModelSupported).toHaveBeenCalledWith( diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index 5a31f3993..186706334 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -30,6 +30,10 @@ export class DefaultModelsService implements ModelsService { return EngineManager.instance().get(provider) as AIEngine | undefined } + async getModel(modelId: string): Promise { + return this.getEngine()?.get(modelId) + } + async fetchModels(): Promise { return this.getEngine()?.list() ?? [] } @@ -127,7 +131,7 @@ export class DefaultModelsService implements ModelsService { const modelId = file.rfilename.replace(/\.gguf$/i, '') return { - model_id: sanitizeModelId(modelId), + model_id: `${repo.author}/${sanitizeModelId(modelId)}`, path: `https://huggingface.co/${repo.modelId}/resolve/main/${file.rfilename}`, file_size: formatFileSize(file.size), } diff --git a/web-app/src/services/models/types.ts b/web-app/src/services/models/types.ts index 5bf66b8bf..d92dae38a 100644 --- a/web-app/src/services/models/types.ts +++ b/web-app/src/services/models/types.ts @@ -90,6 +90,7 @@ export interface ModelPlan { } export interface ModelsService { + getModel(modelId: string): Promise fetchModels(): Promise fetchModelCatalog(): Promise fetchHuggingFaceRepo( From e322e46e4b2f146ecf42c0fd1c0ad276deb21106 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 24 Sep 2025 18:29:03 +0700 Subject: [PATCH 03/22] chore: separate windows install script --- package.json | 3 +- scripts/download-lib.mjs | 19 ------ scripts/download-win-installer-deps.mjs | 83 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 scripts/download-win-installer-deps.mjs diff --git a/package.json b/package.json index 2ec212088..50eb8ecaf 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "copy:assets:tauri": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"LICENSE\" \"src-tauri/resources/\"", "download:lib": "node ./scripts/download-lib.mjs", "download:bin": "node ./scripts/download-bin.mjs", - "build:tauri:win32": "yarn download:bin && yarn download:lib && yarn tauri build", + "download:windows-installer": "node ./scripts/download-win-installer-deps.mjs", + "build:tauri:win32": "yarn download:bin && yarn download:lib && yarn download:windows-installer && yarn tauri build", "build:tauri:linux": "yarn download:bin && yarn download:lib && NO_STRIP=1 ./src-tauri/build-utils/shim-linuxdeploy.sh yarn tauri build && ./src-tauri/build-utils/buildAppImage.sh", "build:tauri:darwin": "yarn download:bin && yarn tauri build --target universal-apple-darwin", "build:tauri": "yarn build:icon && yarn copy:assets:tauri && run-script-os", diff --git a/scripts/download-lib.mjs b/scripts/download-lib.mjs index 6075a18d1..d2086b36e 100644 --- a/scripts/download-lib.mjs +++ b/scripts/download-lib.mjs @@ -77,25 +77,6 @@ async function main() { // Expect EEXIST error } - // Download VC++ Redistributable 17 - if (platform == 'win32') { - const vcFilename = 'vc_redist.x64.exe' - const vcUrl = 'https://aka.ms/vs/17/release/vc_redist.x64.exe' - - console.log(`Downloading VC++ Redistributable...`) - const vcSavePath = path.join(tempDir, vcFilename) - if (!fs.existsSync(vcSavePath)) { - await download(vcUrl, vcSavePath) - } - - // copy to tauri resources - try { - copySync(vcSavePath, libDir) - } catch (err) { - // Expect EEXIST error - } - } - console.log('Downloads completed.') } diff --git a/scripts/download-win-installer-deps.mjs b/scripts/download-win-installer-deps.mjs new file mode 100644 index 000000000..33bbbe04b --- /dev/null +++ b/scripts/download-win-installer-deps.mjs @@ -0,0 +1,83 @@ +console.log('Downloading Windows installer dependencies...') +// scripts/download-win-installer-deps.mjs +import https from 'https' +import fs, { mkdirSync } from 'fs' +import os from 'os' +import path from 'path' +import { copySync } from 'cpx' + +function download(url, dest) { + return new Promise((resolve, reject) => { + console.log(`Downloading ${url} to ${dest}`) + const file = fs.createWriteStream(dest) + https + .get(url, (response) => { + console.log(`Response status code: ${response.statusCode}`) + if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + // Handle redirect + const redirectURL = response.headers.location + console.log(`Redirecting to ${redirectURL}`) + download(redirectURL, dest).then(resolve, reject) // Recursive call + return + } else if (response.statusCode !== 200) { + reject(`Failed to get '${url}' (${response.statusCode})`) + return + } + response.pipe(file) + file.on('finish', () => { + file.close(resolve) + }) + }) + .on('error', (err) => { + fs.unlink(dest, () => reject(err.message)) + }) + }) +} + +async function main() { + console.log('Starting Windows installer dependencies download') + const platform = os.platform() // 'darwin', 'linux', 'win32' + const arch = os.arch() // 'x64', 'arm64', etc. + + if (arch != 'x64') return + + + const libDir = 'src-tauri/resources/lib' + const tempDir = 'scripts/dist' + + try { + mkdirSync('scripts/dist') + } catch (err) { + // Expect EEXIST error if the directory already exists + } + + // Download VC++ Redistributable 17 + if (platform == 'win32') { + const vcFilename = 'vc_redist.x64.exe' + const vcUrl = 'https://aka.ms/vs/17/release/vc_redist.x64.exe' + + console.log(`Downloading VC++ Redistributable...`) + const vcSavePath = path.join(tempDir, vcFilename) + if (!fs.existsSync(vcSavePath)) { + await download(vcUrl, vcSavePath) + } + + // copy to tauri resources + try { + copySync(vcSavePath, libDir) + } catch (err) { + // Expect EEXIST error + } + } + + console.log('Windows installer dependencies downloads completed.') +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) From 23f14ebbb71fb14fe00f4524a3af05c0f2f48732 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 24 Sep 2025 19:02:18 +0700 Subject: [PATCH 04/22] fix: window dependencies not downloaded during tests --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 085e42e74..9a03ddaad 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,9 @@ lint: install-and-build test: lint yarn download:bin yarn download:lib +ifeq ($(OS),Windows_NT) + yarn download:windows-installer +endif yarn test yarn copy:assets:tauri yarn build:icon From e7a1a06395c5ba0d224d11efe2f6678bf91facb5 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 25 Sep 2025 10:12:08 +0700 Subject: [PATCH 05/22] feat: thread organization folder --- web-app/src/constants/localStorage.ts | 1 + web-app/src/constants/routes.ts | 2 + web-app/src/containers/ChatInput.tsx | 33 +- web-app/src/containers/LeftPanel.tsx | 318 ++++++++++++---- web-app/src/containers/ThreadList.tsx | 355 ++++++++++++------ .../containers/dialogs/AddProjectDialog.tsx | 125 ++++++ .../dialogs/DeleteProjectDialog.tsx | 85 +++++ web-app/src/containers/dialogs/index.ts | 3 +- web-app/src/hooks/useThreadManagement.ts | 82 ++++ web-app/src/hooks/useThreads.ts | 59 ++- web-app/src/locales/de-DE/common.json | 162 +++++++- web-app/src/locales/en/common.json | 75 ++++ web-app/src/locales/id/common.json | 83 ++++ web-app/src/locales/pl/common.json | 47 +++ web-app/src/locales/vn/common.json | 1 + web-app/src/locales/zh-CN/common.json | 1 + web-app/src/locales/zh-TW/common.json | 1 + web-app/src/routeTree.gen.ts | 54 ++- web-app/src/routes/assistant.tsx | 106 +++--- web-app/src/routes/project/$projectId.tsx | 143 +++++++ web-app/src/routes/project/index.tsx | 244 ++++++++++++ web-app/src/services/threads/default.ts | 9 +- web-app/src/types/threads.d.ts | 10 +- 23 files changed, 1735 insertions(+), 264 deletions(-) create mode 100644 web-app/src/containers/dialogs/AddProjectDialog.tsx create mode 100644 web-app/src/containers/dialogs/DeleteProjectDialog.tsx create mode 100644 web-app/src/hooks/useThreadManagement.ts create mode 100644 web-app/src/routes/project/$projectId.tsx create mode 100644 web-app/src/routes/project/index.tsx diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts index ae744837b..f13f5fcab 100644 --- a/web-app/src/constants/localStorage.ts +++ b/web-app/src/constants/localStorage.ts @@ -21,4 +21,5 @@ export const localStorageKey = { lastUsedAssistant: 'last-used-assistant', favoriteModels: 'favorite-models', setupCompleted: 'setup-completed', + threadManagement: 'thread-management', } diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index 97f95631d..f1f870dd5 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -3,6 +3,8 @@ export const route = { home: '/', appLogs: '/logs', assistant: '/assistant', + project: '/project', + projectDetail: '/project/$projectId', settings: { index: '/settings', model_providers: '/settings/providers', diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index c5743647b..cba580ebd 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -4,6 +4,7 @@ import TextareaAutosize from 'react-textarea-autosize' import { cn } from '@/lib/utils' import { usePrompt } from '@/hooks/usePrompt' import { useThreads } from '@/hooks/useThreads' +import { useThreadManagement } from '@/hooks/useThreadManagement' import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { @@ -43,9 +44,15 @@ type ChatInputProps = { showSpeedToken?: boolean model?: ThreadModel initialMessage?: boolean + projectId?: string } -const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { +const ChatInput = ({ + model, + className, + initialMessage, + projectId, +}: ChatInputProps) => { const textareaRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const [rows, setRows] = useState(1) @@ -58,6 +65,8 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const prompt = usePrompt((state) => state.prompt) const setPrompt = usePrompt((state) => state.setPrompt) const currentThreadId = useThreads((state) => state.currentThreadId) + const updateThread = useThreads((state) => state.updateThread) + const { getFolderById } = useThreadManagement() const { t } = useTranslation() const spellCheckChatInput = useGeneralSetting( (state) => state.spellCheckChatInput @@ -177,6 +186,28 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { uploadedFiles.length > 0 ? uploadedFiles : undefined ) setUploadedFiles([]) + + // Handle project assignment for new threads + if (projectId && !currentThreadId) { + const project = getFolderById(projectId) + if (project) { + // Use setTimeout to ensure the thread is created first + setTimeout(() => { + const newCurrentThreadId = useThreads.getState().currentThreadId + if (newCurrentThreadId) { + updateThread(newCurrentThreadId, { + metadata: { + project: { + id: project.id, + name: project.name, + updated_at: project.updated_at, + }, + }, + }) + } + }, 100) + } + } } useEffect(() => { diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 67e35fab2..cef872119 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -4,14 +4,18 @@ import { cn } from '@/lib/utils' import { IconLayoutSidebar, IconDots, - IconCirclePlusFilled, - IconSettingsFilled, + IconCirclePlus, + IconSettings, IconStar, - IconMessageFilled, - IconAppsFilled, + IconFolderPlus, + IconMessage, + IconApps, IconX, IconSearch, - IconClipboardSmileFilled, + IconClipboardSmile, + IconFolder, + IconPencil, + IconTrash, } from '@tabler/icons-react' import { route } from '@/constants/routes' import ThreadList from './ThreadList' @@ -28,6 +32,7 @@ import { UserProfileMenu } from '@/containers/auth/UserProfileMenu' import { useAuth } from '@/hooks/useAuth' import { useThreads } from '@/hooks/useThreads' +import { useThreadManagement } from '@/hooks/useThreadManagement' import { useTranslation } from '@/i18n/react-i18next-compat' import { useMemo, useState, useEffect, useRef } from 'react' @@ -37,38 +42,40 @@ import { useSmallScreen } from '@/hooks/useMediaQuery' import { useClickOutside } from '@/hooks/useClickOutside' import { DeleteAllThreadsDialog } from '@/containers/dialogs' +import AddProjectDialog from '@/containers/dialogs/AddProjectDialog' +import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog' const mainMenus = [ { title: 'common:newChat', - icon: IconCirclePlusFilled, + icon: IconCirclePlus, route: route.home, isEnabled: true, }, + { + title: 'Projects', + icon: IconFolderPlus, + route: route.project, + isEnabled: true, + }, { title: 'common:assistants', - icon: IconClipboardSmileFilled, + icon: IconClipboardSmile, route: route.assistant, isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS], }, { title: 'common:hub', - icon: IconAppsFilled, + icon: IconApps, route: route.hub.index, isEnabled: PlatformFeatures[PlatformFeature.MODEL_HUB], }, { title: 'common:settings', - icon: IconSettingsFilled, + icon: IconSettings, route: route.settings.general, isEnabled: true, }, - { - title: 'common:authentication', - icon: null, - route: null, - isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION], - }, ] const LeftPanel = () => { @@ -152,20 +159,65 @@ const LeftPanel = () => { const getFilteredThreads = useThreads((state) => state.getFilteredThreads) const threads = useThreads((state) => state.threads) + const { folders, addFolder, updateFolder, deleteFolder, getFolderById } = + useThreadManagement() + + // Project dialog states + const [projectDialogOpen, setProjectDialogOpen] = useState(false) + const [editingProjectKey, setEditingProjectKey] = useState( + null + ) + const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] = + useState(false) + const [deletingProjectId, setDeletingProjectId] = useState( + null + ) + const filteredThreads = useMemo(() => { return getFilteredThreads(searchTerm) // eslint-disable-next-line react-hooks/exhaustive-deps }, [getFilteredThreads, searchTerm, threads]) + const filteredProjects = useMemo(() => { + if (!searchTerm) return folders + return folders.filter((folder) => + folder.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [folders, searchTerm]) + // Memoize categorized threads based on filteredThreads const favoritedThreads = useMemo(() => { return filteredThreads.filter((t) => t.isFavorite) }, [filteredThreads]) const unFavoritedThreads = useMemo(() => { - return filteredThreads.filter((t) => !t.isFavorite) + return filteredThreads.filter((t) => !t.isFavorite && !t.metadata?.project) }, [filteredThreads]) + // Project handlers + const handleProjectDelete = (id: string) => { + setDeletingProjectId(id) + setDeleteProjectConfirmOpen(true) + } + + const confirmProjectDelete = () => { + if (deletingProjectId) { + deleteFolder(deletingProjectId) + setDeleteProjectConfirmOpen(false) + setDeletingProjectId(null) + } + } + + const handleProjectSave = (name: string) => { + if (editingProjectKey) { + updateFolder(editingProjectKey, name) + } else { + addFolder(name) + } + setProjectDialogOpen(false) + setEditingProjectKey(null) + } + // Disable body scroll when panel is open on small screens useEffect(() => { if (isSmallScreen && open) { @@ -260,15 +312,12 @@ const LeftPanel = () => { )} -
-
+
+
{IS_MACOS && (
@@ -294,7 +343,151 @@ const LeftPanel = () => { )}
)} -
+ + {mainMenus.map((menu) => { + if (!menu.isEnabled) { + return null + } + + // Handle authentication menu specially + if (menu.title === 'common:authentication') { + return ( +
+
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ ) + } + + // Regular menu items must have route and icon + if (!menu.route || !menu.icon) return null + + const isActive = (() => { + // Settings routes + if (menu.route.includes(route.settings.index)) { + return currentPath.includes(route.settings.index) + } + + // Default exact match for other routes + return currentPath === menu.route + })() + return ( + isSmallScreen && setLeftPanel(false)} + data-test-id={`menu-${menu.title}`} + activeOptions={{ exact: true }} + className={cn( + 'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded', + isActive && 'bg-left-panel-fg/10' + )} + > + + + {t(menu.title)} + + + ) + })} +
+ + {filteredProjects.length > 0 && ( +
+
+ + Projects + +
+
+ {filteredProjects + .slice() + .sort((a, b) => b.updated_at - a.updated_at) + .map((folder) => { + const ProjectItem = () => { + const [openDropdown, setOpenDropdown] = useState(false) + const isProjectActive = + currentPath === `/project/${folder.id}` + + return ( +
+
+ + isSmallScreen && setLeftPanel(false) + } + className="py-1 pr-2 truncate flex items-center gap-2 flex-1" + > + + + {folder.name} + + +
+ setOpenDropdown(open)} + > + + { + e.preventDefault() + e.stopPropagation() + }} + /> + + + { + e.stopPropagation() + setEditingProjectKey(folder.id) + setProjectDialogOpen(true) + }} + > + + Edit + + { + e.stopPropagation() + handleProjectDelete(folder.id) + }} + > + + Delete + + + +
+
+
+ ) + } + + return + })} +
+
+ )} + +
+
{favoritedThreads.length > 0 && ( <> @@ -397,7 +590,7 @@ const LeftPanel = () => { <>
- +
{t('common:noThreadsYet')}
@@ -414,59 +607,36 @@ const LeftPanel = () => {
+ {PlatformFeatures[PlatformFeature.AUTHENTICATION] && ( +
+
+
+ {isAuthenticated ? : } +
+
+ )} + +
- -
- {mainMenus.map((menu) => { - if (!menu.isEnabled) { - return null - } - - // Handle authentication menu specially - if (menu.title === 'common:authentication') { - return ( -
-
- {isAuthenticated ? ( - - ) : ( - - )} -
- ) - } - - // Regular menu items must have route and icon - if (!menu.route || !menu.icon) return null - - const isActive = - currentPath.includes(route.settings.index) && - menu.route.includes(route.settings.index) - return ( - isSmallScreen && setLeftPanel(false)} - data-test-id={`menu-${menu.title}`} - className={cn( - 'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded', - isActive - ? 'bg-left-panel-fg/10' - : '[&.active]:bg-left-panel-fg/10' - )} - > - - - {t(menu.title)} - - - ) - })} -
- -
+ + {/* Project Dialogs */} + + ) } diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index 672fc3ebc..40f0e0216 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -16,9 +16,13 @@ import { IconDots, IconStarFilled, IconStar, + IconFolder, + IconX, } from '@tabler/icons-react' import { useThreads } from '@/hooks/useThreads' +import { useThreadManagement } from '@/hooks/useThreadManagement' import { useLeftPanel } from '@/hooks/useLeftPanel' +import { useMessages } from '@/hooks/useMessages' import { cn } from '@/lib/utils' import { useSmallScreen } from '@/hooks/useMediaQuery' @@ -28,147 +32,268 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, } from '@/components/ui/dropdown-menu' import { useTranslation } from '@/i18n/react-i18next-compat' import { memo, useMemo, useState } from 'react' import { useNavigate, useMatches } from '@tanstack/react-router' import { RenameThreadDialog, DeleteThreadDialog } from '@/containers/dialogs' import { route } from '@/constants/routes' +import { toast } from 'sonner' -const SortableItem = memo(({ thread }: { thread: Thread }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: thread.id, disabled: true }) +const SortableItem = memo( + ({ + thread, + variant, + }: { + thread: Thread + variant?: 'default' | 'project' + }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: thread.id, disabled: true }) - const isSmallScreen = useSmallScreen() - const setLeftPanel = useLeftPanel(state => state.setLeftPanel) + const isSmallScreen = useSmallScreen() + const setLeftPanel = useLeftPanel((state) => state.setLeftPanel) - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } - const toggleFavorite = useThreads((state) => state.toggleFavorite) - const deleteThread = useThreads((state) => state.deleteThread) - const renameThread = useThreads((state) => state.renameThread) - const { t } = useTranslation() - const [openDropdown, setOpenDropdown] = useState(false) - const navigate = useNavigate() - // Check if current route matches this thread's detail page - const matches = useMatches() - const isActive = matches.some( - (match) => - match.routeId === '/threads/$threadId' && - 'threadId' in match.params && - match.params.threadId === thread.id - ) + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + const toggleFavorite = useThreads((state) => state.toggleFavorite) + const deleteThread = useThreads((state) => state.deleteThread) + const renameThread = useThreads((state) => state.renameThread) + const updateThread = useThreads((state) => state.updateThread) + const getFolderById = useThreadManagement().getFolderById + const { folders } = useThreadManagement() + const getMessages = useMessages((state) => state.getMessages) + const { t } = useTranslation() + const [openDropdown, setOpenDropdown] = useState(false) + const navigate = useNavigate() + // Check if current route matches this thread's detail page + const matches = useMatches() + const isActive = matches.some( + (match) => + match.routeId === '/threads/$threadId' && + 'threadId' in match.params && + match.params.threadId === thread.id + ) - const handleClick = () => { - if (!isDragging) { - // Only close panel and navigate if the thread is not already active - if (!isActive) { - if (isSmallScreen) setLeftPanel(false) - navigate({ to: route.threadsDetail, params: { threadId: thread.id } }) + const handleClick = () => { + if (!isDragging) { + // Only close panel and navigate if the thread is not already active + if (!isActive) { + if (isSmallScreen) setLeftPanel(false) + navigate({ to: route.threadsDetail, params: { threadId: thread.id } }) + } } } - } - const plainTitleForRename = useMemo(() => { - // Basic HTML stripping for simple span tags. - // If thread.title is undefined or null, treat as empty string before replace. - return (thread.title || '').replace(/]*>|<\/span>/g, '') - }, [thread.title]) + const plainTitleForRename = useMemo(() => { + // Basic HTML stripping for simple span tags. + // If thread.title is undefined or null, treat as empty string before replace. + return (thread.title || '').replace(/]*>|<\/span>/g, '') + }, [thread.title]) + const assignThreadToProject = (threadId: string, projectId: string) => { + const project = getFolderById(projectId) + if (project && updateThread) { + const projectMetadata = { + id: project.id, + name: project.name, + updated_at: project.updated_at, + } - return ( -
{ - e.preventDefault() - e.stopPropagation() - setOpenDropdown(true) - }} - className={cn( - 'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all', - isDragging ? 'cursor-move' : 'cursor-pointer', - isActive && 'bg-left-panel-fg/10' - )} - > -
- {thread.title || t('common:newThread')} -
-
- setOpenDropdown(open)} + updateThread(threadId, { + metadata: { + ...thread.metadata, + project: projectMetadata, + }, + }) + + toast.success(`Thread assigned to "${project.name}" successfully`) + } + } + + const getLastMessageInfo = useMemo(() => { + const messages = getMessages(thread.id) + if (messages.length === 0) return null + + const lastMessage = messages[messages.length - 1] + return { + date: new Date(lastMessage.created_at || 0), + content: lastMessage.content?.[0]?.text?.value || '', + } + }, [getMessages, thread.id]) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + setOpenDropdown(true) + }} + className={cn( + 'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all', + variant === 'project' + ? 'mb-2 rounded-lg px-4 border border-main-view-fg/10 bg-main-view-fg/5' + : 'mb-1', + isDragging ? 'cursor-move' : 'cursor-pointer', + isActive && 'bg-left-panel-fg/10' + )} + > +
- - { - e.preventDefault() - e.stopPropagation() - }} - /> - - - {thread.isFavorite ? ( - {thread.title || t('common:newThread')} + {variant === 'project' && ( + <> + {variant === 'project' && getLastMessageInfo?.content && ( +
+ {getLastMessageInfo.content} +
+ )} + + )} +
+
+ setOpenDropdown(open)} + > + + { + e.preventDefault() e.stopPropagation() - toggleFavorite(thread.id) }} - > - - {t('common:unstar')} - - ) : ( - { - e.stopPropagation() - toggleFavorite(thread.id) - }} - > - - {t('common:star')} - - )} - setOpenDropdown(false)} - /> + /> + + + {thread.isFavorite ? ( + { + e.stopPropagation() + toggleFavorite(thread.id) + }} + > + + {t('common:unstar')} + + ) : ( + { + e.stopPropagation() + toggleFavorite(thread.id) + }} + > + + {t('common:star')} + + )} + setOpenDropdown(false)} + /> - - setOpenDropdown(false)} - /> - - + + + + Add to project + + + {folders.length === 0 ? ( + + + No projects available + + + ) : ( + folders + .sort((a, b) => b.updated_at - a.updated_at) + .map((folder) => ( + { + e.stopPropagation() + assignThreadToProject(thread.id, folder.id) + }} + > + + + {folder.name} + + + )) + )} + {thread.metadata?.project && ( + <> + + { + e.stopPropagation() + // Remove project from metadata + const projectName = thread.metadata?.project?.name + updateThread(thread.id, { + metadata: { + ...thread.metadata, + project: undefined, + }, + }) + toast.success( + `Thread removed from "${projectName}" successfully` + ) + }} + > + + Remove from project + + + )} + + + + setOpenDropdown(false)} + /> + + +
-
- ) -}) + ) + } +) type ThreadListProps = { threads: Thread[] isFavoriteSection?: boolean + variant?: 'default' | 'project' + showDate?: boolean } -function ThreadList({ threads }: ThreadListProps) { +function ThreadList({ threads, variant = 'default' }: ThreadListProps) { const sortedThreads = useMemo(() => { return threads.sort((a, b) => { return (b.updated || 0) - (a.updated || 0) @@ -192,7 +317,7 @@ function ThreadList({ threads }: ThreadListProps) { strategy={verticalListSortingStrategy} > {sortedThreads.map((thread, index) => ( - + ))} diff --git a/web-app/src/containers/dialogs/AddProjectDialog.tsx b/web-app/src/containers/dialogs/AddProjectDialog.tsx new file mode 100644 index 000000000..f0fda648c --- /dev/null +++ b/web-app/src/containers/dialogs/AddProjectDialog.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useThreadManagement } from '@/hooks/useThreadManagement' +import { toast } from 'sonner' +import { useTranslation } from '@/i18n/react-i18next-compat' + +interface AddProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + editingKey: string | null + initialData?: { + id: string + name: string + updated_at: number + } + onSave: (name: string) => void +} + +export default function AddProjectDialog({ + open, + onOpenChange, + editingKey, + initialData, + onSave, +}: AddProjectDialogProps) { + const { t } = useTranslation() + const [name, setName] = useState(initialData?.name || '') + const { folders } = useThreadManagement() + + useEffect(() => { + if (open) { + setName(initialData?.name || '') + } + }, [open, initialData]) + + const handleSave = () => { + if (!name.trim()) return + + const trimmedName = name.trim() + + // Check for duplicate names (excluding current project when editing) + const isDuplicate = folders.some( + (folder) => + folder.name.toLowerCase() === trimmedName.toLowerCase() && + folder.id !== editingKey + ) + + if (isDuplicate) { + toast.warning(t('projects.addProjectDialog.alreadyExists', { projectName: trimmedName })) + return + } + + onSave(trimmedName) + + // Show detailed success message + if (editingKey && initialData) { + toast.success( + t('projects.addProjectDialog.renameSuccess', { + oldName: initialData.name, + newName: trimmedName + }) + ) + } else { + toast.success(t('projects.addProjectDialog.createSuccess', { projectName: trimmedName })) + } + + setName('') + } + + const handleCancel = () => { + onOpenChange(false) + setName('') + } + + // Check if the button should be disabled + const isButtonDisabled = + !name.trim() || (editingKey && name.trim() === initialData?.name) + + return ( + + + + + {editingKey ? t('projects.addProjectDialog.editTitle') : t('projects.addProjectDialog.createTitle')} + + +
+
+ + setName(e.target.value)} + placeholder={t('projects.addProjectDialog.namePlaceholder')} + className="mt-1" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !isButtonDisabled) { + handleSave() + } + }} + /> +
+
+ + + + +
+
+ ) +} diff --git a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx new file mode 100644 index 000000000..f8c86a3b4 --- /dev/null +++ b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx @@ -0,0 +1,85 @@ +import { useRef } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { toast } from 'sonner' +import { useTranslation } from '@/i18n/react-i18next-compat' + +interface DeleteProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + projectName?: string +} + +export function DeleteProjectDialog({ + open, + onOpenChange, + onConfirm, + projectName, +}: DeleteProjectDialogProps) { + const { t } = useTranslation() + const deleteButtonRef = useRef(null) + + const handleConfirm = () => { + try { + onConfirm() + toast.success( + projectName + ? t('projects.deleteProjectDialog.successWithName', { projectName }) + : t('projects.deleteProjectDialog.successWithoutName') + ) + onOpenChange(false) + } catch (error) { + toast.error(t('projects.deleteProjectDialog.error')) + console.error('Delete project error:', error) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleConfirm() + } + } + + return ( + + { + e.preventDefault() + deleteButtonRef.current?.focus() + }} + > + + {t('projects.deleteProjectDialog.title')} + + {t('projects.deleteProjectDialog.description')} + + + + + + + + + ) +} diff --git a/web-app/src/containers/dialogs/index.ts b/web-app/src/containers/dialogs/index.ts index b3c640200..3f96e5d17 100644 --- a/web-app/src/containers/dialogs/index.ts +++ b/web-app/src/containers/dialogs/index.ts @@ -6,4 +6,5 @@ export { MessageMetadataDialog } from './MessageMetadataDialog' export { DeleteMessageDialog } from './DeleteMessageDialog' export { FactoryResetDialog } from './FactoryResetDialog' export { DeleteAssistantDialog } from './DeleteAssistantDialog' -export { AddProviderDialog } from './AddProviderDialog' \ No newline at end of file +export { DeleteProjectDialog } from './DeleteProjectDialog' +export { AddProviderDialog } from './AddProviderDialog' diff --git a/web-app/src/hooks/useThreadManagement.ts b/web-app/src/hooks/useThreadManagement.ts new file mode 100644 index 000000000..84e5b0e34 --- /dev/null +++ b/web-app/src/hooks/useThreadManagement.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' +import { ulid } from 'ulidx' +import { localStorageKey } from '@/constants/localStorage' +import { useThreads } from '@/hooks/useThreads' + +type ThreadFolder = { + id: string + name: string + updated_at: number +} + +type ThreadManagementState = { + folders: ThreadFolder[] + setFolders: (folders: ThreadFolder[]) => void + addFolder: (name: string) => void + updateFolder: (id: string, name: string) => void + deleteFolder: (id: string) => void + getFolderById: (id: string) => ThreadFolder | undefined +} + +export const useThreadManagement = create()( + persist( + (set, get) => ({ + folders: [], + + setFolders: (folders) => { + set({ folders }) + }, + + addFolder: (name) => { + const newFolder: ThreadFolder = { + id: ulid(), + name, + updated_at: Date.now(), + } + set((state) => ({ + folders: [...state.folders, newFolder], + })) + }, + + updateFolder: (id, name) => { + set((state) => ({ + folders: state.folders.map((folder) => + folder.id === id + ? { ...folder, name, updated_at: Date.now() } + : folder + ), + })) + }, + + deleteFolder: (id) => { + // Remove project metadata from all threads that belong to this project + const threadsState = useThreads.getState() + const threadsToUpdate = Object.values(threadsState.threads).filter( + (thread) => thread.metadata?.project?.id === id + ) + + threadsToUpdate.forEach((thread) => { + threadsState.updateThread(thread.id, { + metadata: { + ...thread.metadata, + project: undefined, + }, + }) + }) + + set((state) => ({ + folders: state.folders.filter((folder) => folder.id !== id), + })) + }, + + getFolderById: (id) => { + return get().folders.find((folder) => folder.id === id) + }, + }), + { + name: localStorageKey.threadManagement, + storage: createJSONStorage(() => localStorage), + } + ) +) diff --git a/web-app/src/hooks/useThreads.ts b/web-app/src/hooks/useThreads.ts index cce11c027..b450874cd 100644 --- a/web-app/src/hooks/useThreads.ts +++ b/web-app/src/hooks/useThreads.ts @@ -20,12 +20,14 @@ type ThreadState = { createThread: ( model: ThreadModel, title?: string, - assistant?: Assistant + assistant?: Assistant, + projectMetadata?: { id: string; name: string; updated_at: number } ) => Promise updateCurrentThreadModel: (model: ThreadModel) => void getFilteredThreads: (searchTerm: string) => Thread[] updateCurrentThreadAssistant: (assistant: Assistant) => void updateThreadTimestamp: (threadId: string) => void + updateThread: (threadId: string, updates: Partial) => void searchIndex: Fzf | null } @@ -132,20 +134,28 @@ export const useThreads = create()((set, get) => ({ deleteAllThreads: () => { set((state) => { const allThreadIds = Object.keys(state.threads) - const favoriteThreadIds = allThreadIds.filter( - (threadId) => state.threads[threadId].isFavorite - ) - const nonFavoriteThreadIds = allThreadIds.filter( - (threadId) => !state.threads[threadId].isFavorite + + // Identify threads to keep (favorites OR have project metadata) + const threadsToKeepIds = allThreadIds.filter( + (threadId) => + state.threads[threadId].isFavorite || + state.threads[threadId].metadata?.project ) - // Only delete non-favorite threads - nonFavoriteThreadIds.forEach((threadId) => { + // Identify threads to delete (non-favorites AND no project metadata) + const threadsToDeleteIds = allThreadIds.filter( + (threadId) => + !state.threads[threadId].isFavorite && + !state.threads[threadId].metadata?.project + ) + + // Delete threads that are not favorites and not in projects + threadsToDeleteIds.forEach((threadId) => { getServiceHub().threads().deleteThread(threadId) }) - // Keep only favorite threads - const remainingThreads = favoriteThreadIds.reduce( + // Keep favorite threads and threads with project metadata + const remainingThreads = threadsToKeepIds.reduce( (acc, threadId) => { acc[threadId] = state.threads[threadId] return acc @@ -208,13 +218,18 @@ export const useThreads = create()((set, get) => ({ setCurrentThreadId: (threadId) => { if (threadId !== get().currentThreadId) set({ currentThreadId: threadId }) }, - createThread: async (model, title, assistant) => { + createThread: async (model, title, assistant, projectMetadata) => { const newThread: Thread = { id: ulid(), title: title ?? 'New Thread', model, updated: Date.now() / 1000, assistants: assistant ? [assistant] : [], + ...(projectMetadata && { + metadata: { + project: projectMetadata, + }, + }), } return await getServiceHub() .threads() @@ -328,4 +343,26 @@ export const useThreads = create()((set, get) => ({ } }) }, + updateThread: (threadId, updates) => { + set((state) => { + const thread = state.threads[threadId] + if (!thread) return state + + const updatedThread = { + ...thread, + ...updates, + updated: Date.now() / 1000, + } + + getServiceHub().threads().updateThread(updatedThread) + + const newThreads = { ...state.threads, [threadId]: updatedThread } + return { + threads: newThreads, + searchIndex: new Fzf(Object.values(newThreads), { + selector: (item: Thread) => item.title, + }), + } + }) + }, })) diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index c0a55e1d9..4ce743b46 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -117,6 +117,7 @@ "chatInput": "Frage mich etwas..." }, "confirm": "Bestätige", + "continue": "Weiter", "loading": "Lade...", "error": "Fehler", "success": "Erfolg", @@ -127,6 +128,7 @@ "createAssistant": "Assistenten anlegen", "enterApiKey": "API Key eingeben", "scrollToBottom": "Zum Ende scrollen", + "generateAiResponse": "KI-Antwort generieren", "addModel": { "title": "Modell hinzufügen", "modelId": "Modell ID", @@ -154,12 +156,12 @@ "delete": "Löschen" }, "editJson": { - "errorParse": "Failed to parse JSON", - "errorPaste": "Failed to paste JSON", - "errorFormat": "Invalid JSON format", - "titleAll": "Edit All Servers Configuration", - "placeholder": "Enter JSON configuration...", - "save": "Save" + "errorParse": "JSON-Parsing fehlgeschlagen", + "errorPaste": "JSON-Einfügen fehlgeschlagen", + "errorFormat": "Ungültiges JSON-Format", + "titleAll": "Alle Serverkonfigurationen bearbeiten", + "placeholder": "JSON-Konfiguration eingeben...", + "save": "Speichern" }, "editModel": { "title": "Modell bearbeiten: {{modelId}}", @@ -228,11 +230,85 @@ "title": "Nachricht Metadaten" } }, + "projects": { + "title": "Projekte", + "addProject": "Projekt hinzufügen", + "addToProject": "Zum Projekt hinzufügen", + "removeFromProject": "Vom Projekt entfernen", + "createNewProject": "Neues Projekt erstellen", + "editProject": "Projekt bearbeiten", + "deleteProject": "Projekt löschen", + "projectName": "Projektname", + "enterProjectName": "Projektname eingeben...", + "noProjectsAvailable": "Keine Projekte verfügbar", + "noProjectsYet": "Noch keine Projekte", + "noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.", + "projectNotFound": "Projekt nicht gefunden", + "projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.", + "deleteProjectDialog": { + "title": "Projekt löschen", + "description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteButton": "Löschen", + "successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht", + "successWithoutName": "Projekt erfolgreich gelöscht", + "error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", + "ariaLabel": "{{projectName}} löschen" + }, + "addProjectDialog": { + "createTitle": "Neues Projekt erstellen", + "editTitle": "Projekt bearbeiten", + "nameLabel": "Projektname", + "namePlaceholder": "Projektname eingeben...", + "createButton": "Erstellen", + "updateButton": "Aktualisieren", + "alreadyExists": "Projekt \"{{projectName}}\" existiert bereits", + "createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt", + "renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" + }, + "noConversationsIn": "Keine Gespräche in {{projectName}}", + "startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten", + "conversationsIn": "Gespräche in {{projectName}}", + "conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.", + "thread": "Thread", + "threads": "Threads", + "updated": "Aktualisiert:", + "collapseThreads": "Threads einklappen", + "expandThreads": "Threads ausklappen", + "update": "Aktualisieren" + }, "toast": { "allThreadsUnfavorited": { "title": "Alle Threads De-Favorisieren ", "description": "Alle deine Threads wurden defavorisiert." }, + "projectCreated": { + "title": "Projekt erstellt", + "description": "Projekt \"{{projectName}}\" erfolgreich erstellt" + }, + "projectRenamed": { + "title": "Projekt umbenannt", + "description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" + }, + "projectDeleted": { + "title": "Projekt gelöscht", + "description": "Projekt \"{{projectName}}\" erfolgreich gelöscht" + }, + "projectAlreadyExists": { + "title": "Projekt existiert bereits", + "description": "Projekt \"{{projectName}}\" existiert bereits" + }, + "projectDeleteFailed": { + "title": "Löschen fehlgeschlagen", + "description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut." + }, + "threadAssignedToProject": { + "title": "Thread zugewiesen", + "description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt" + }, + "threadRemovedFromProject": { + "title": "Thread entfernt", + "description": "Thread erfolgreich von \"{{projectName}}\" entfernt" + }, "deleteAllThreads": { "title": "Alle Threads löschen", "description": "Alle deine Threads wurden permanent gelöscht." @@ -280,6 +356,80 @@ "downloadAndVerificationComplete": { "title": "Download abgeschlossen", "description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert" + }, + "projectCreated": { + "title": "Projekt erstellt", + "description": "Projekt \"{{projectName}}\" erfolgreich erstellt" + }, + "projectRenamed": { + "title": "Projekt umbenannt", + "description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" + }, + "projectDeleted": { + "title": "Projekt gelöscht", + "description": "Projekt \"{{projectName}}\" erfolgreich gelöscht" + }, + "projectAlreadyExists": { + "title": "Projekt existiert bereits", + "description": "Projekt \"{{projectName}}\" existiert bereits" + }, + "projectDeleteFailed": { + "title": "Löschen fehlgeschlagen", + "description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut." + }, + "threadAssignedToProject": { + "title": "Thread zugewiesen", + "description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt" + }, + "threadRemovedFromProject": { + "title": "Thread entfernt", + "description": "Thread erfolgreich von \"{{projectName}}\" entfernt" } + }, + "projects": { + "title": "Projekte", + "addProject": "Projekt hinzufügen", + "addToProject": "Zu Projekt hinzufügen", + "removeFromProject": "Von Projekt entfernen", + "createNewProject": "Neues Projekt erstellen", + "editProject": "Projekt bearbeiten", + "deleteProject": "Projekt löschen", + "projectName": "Projektname", + "enterProjectName": "Projektname eingeben...", + "noProjectsAvailable": "Keine Projekte verfügbar", + "noProjectsYet": "Noch keine Projekte", + "noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.", + "projectNotFound": "Projekt nicht gefunden", + "projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.", + "deleteProjectDialog": { + "title": "Projekt löschen", + "description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteButton": "Löschen", + "successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht", + "successWithoutName": "Projekt erfolgreich gelöscht", + "error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", + "ariaLabel": "{{projectName}} löschen" + }, + "addProjectDialog": { + "createTitle": "Neues Projekt erstellen", + "editTitle": "Projekt bearbeiten", + "nameLabel": "Projektname", + "namePlaceholder": "Projektname eingeben...", + "createButton": "Erstellen", + "updateButton": "Aktualisieren", + "alreadyExists": "Projekt \"{{projectName}}\" existiert bereits", + "createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt", + "renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" + }, + "noConversationsIn": "Keine Gespräche in {{projectName}}", + "startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten", + "conversationsIn": "Gespräche in {{projectName}}", + "conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.", + "thread": "Thread", + "threads": "Threads", + "updated": "Aktualisiert:", + "collapseThreads": "Threads einklappen", + "expandThreads": "Threads ausklappen", + "update": "Aktualisieren" } } diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index ce6628000..c829dbdf8 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -119,6 +119,7 @@ "chatInput": "Ask me anything..." }, "confirm": "Confirm", + "continue": "Continue", "loading": "Loading...", "error": "Error", "success": "Success", @@ -231,6 +232,52 @@ "title": "Message Metadata" } }, + "projects": { + "title": "Projects", + "addProject": "Add Project", + "addToProject": "Add to project", + "removeFromProject": "Remove from project", + "createNewProject": "Create New Project", + "editProject": "Edit Project", + "deleteProject": "Delete Project", + "projectName": "Project Name", + "enterProjectName": "Enter project name...", + "noProjectsAvailable": "No projects available", + "noProjectsYet": "No Projects Yet", + "noProjectsYetDesc": "Start a new project by clicking the Add Project button.", + "projectNotFound": "Project Not Found", + "projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.", + "deleteProjectDialog": { + "title": "Delete Project", + "description": "Are you sure you want to delete this project? This action cannot be undone.", + "deleteButton": "Delete", + "successWithName": "Project \"{{projectName}}\" deleted successfully", + "successWithoutName": "Project deleted successfully", + "error": "Failed to delete project. Please try again.", + "ariaLabel": "Delete {{projectName}}" + }, + "addProjectDialog": { + "createTitle": "Create New Project", + "editTitle": "Edit Project", + "nameLabel": "Project Name", + "namePlaceholder": "Enter project name...", + "createButton": "Create", + "updateButton": "Update", + "alreadyExists": "Project \"{{projectName}}\" already exists", + "createSuccess": "Project \"{{projectName}}\" created successfully", + "renameSuccess": "Project renamed from \"{{oldName}}\" to \"{{newName}}\"" + }, + "noConversationsIn": "No Conversations in {{projectName}}", + "startNewConversation": "Start a new conversation with {{projectName}} below", + "conversationsIn": "Conversations in {{projectName}}", + "conversationsDescription": "Click on any conversation to continue chatting, or start a new one below.", + "thread": "thread", + "threads": "threads", + "updated": "Updated:", + "collapseThreads": "Collapse threads", + "expandThreads": "Expand threads", + "update": "Update" + }, "toast": { "allThreadsUnfavorited": { "title": "All Threads Unfavorited", @@ -283,6 +330,34 @@ "downloadAndVerificationComplete": { "title": "Download Complete", "description": "Model \"{{item}}\" downloaded and verified successfully" + }, + "projectCreated": { + "title": "Project Created", + "description": "Project \"{{projectName}}\" created successfully" + }, + "projectRenamed": { + "title": "Project Renamed", + "description": "Project renamed from \"{{oldName}}\" to \"{{newName}}\"" + }, + "projectDeleted": { + "title": "Project Deleted", + "description": "Project \"{{projectName}}\" deleted successfully" + }, + "projectAlreadyExists": { + "title": "Project Already Exists", + "description": "Project \"{{projectName}}\" already exists" + }, + "projectDeleteFailed": { + "title": "Delete Failed", + "description": "Failed to delete project. Please try again." + }, + "threadAssignedToProject": { + "title": "Thread Assigned", + "description": "Thread assigned to \"{{projectName}}\" successfully" + }, + "threadRemovedFromProject": { + "title": "Thread Removed", + "description": "Thread removed from \"{{projectName}}\" successfully" } } } \ No newline at end of file diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index c1f9838c6..aa0c83fd9 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -117,6 +117,7 @@ "chatInput": "Tanyakan apa saja padaku..." }, "confirm": "Konfirmasi", + "continue": "Lanjutkan", "loading": "Memuat...", "error": "Kesalahan", "success": "Sukses", @@ -127,6 +128,7 @@ "createAssistant": "Buat Asisten", "enterApiKey": "Masukkan Kunci API", "scrollToBottom": "Gulir ke bawah", + "generateAiResponse": "Hasilkan Respons AI", "addModel": { "title": "Tambah Model", "modelId": "ID Model", @@ -170,6 +172,13 @@ "embeddings": "Embedding", "notAvailable": "Belum tersedia" }, + "outOfContextError": { + "truncateInput": "Potong Input", + "title": "Kesalahan konteks habis", + "description": "Obrolan ini mencapai batas memori AI, seperti papan tulis yang penuh. Kami dapat memperluas jendela memori (disebut ukuran konteks) agar lebih mengingat, tetapi mungkin akan menggunakan lebih banyak memori komputer Anda. Kami juga dapat memotong input, yang berarti akan melupakan sebagian riwayat obrolan untuk memberi ruang bagi pesan baru.", + "increaseContextSizeDescription": "Apakah Anda ingin meningkatkan ukuran konteks?", + "increaseContextSize": "Tingkatkan Ukuran Konteks" + }, "toolApproval": { "title": "Permintaan Izin Alat", "description": "Asisten ingin menggunakan {{toolName}}", @@ -273,6 +282,80 @@ "downloadAndVerificationComplete": { "title": "Unduhan Selesai", "description": "Model \"{{item}}\" berhasil diunduh dan diverifikasi" + }, + "projectCreated": { + "title": "Proyek Dibuat", + "description": "Proyek \"{{projectName}}\" berhasil dibuat" + }, + "projectRenamed": { + "title": "Proyek Diganti Nama", + "description": "Proyek diganti nama dari \"{{oldName}}\" ke \"{{newName}}\"" + }, + "projectDeleted": { + "title": "Proyek Dihapus", + "description": "Proyek \"{{projectName}}\" berhasil dihapus" + }, + "projectAlreadyExists": { + "title": "Proyek Sudah Ada", + "description": "Proyek \"{{projectName}}\" sudah ada" + }, + "projectDeleteFailed": { + "title": "Penghapusan Gagal", + "description": "Gagal menghapus proyek. Silakan coba lagi." + }, + "threadAssignedToProject": { + "title": "Thread Ditugaskan", + "description": "Thread berhasil ditugaskan ke \"{{projectName}}\"" + }, + "threadRemovedFromProject": { + "title": "Thread Dihapus", + "description": "Thread berhasil dihapus dari \"{{projectName}}\"" } + }, + "projects": { + "title": "Proyek", + "addProject": "Tambah Proyek", + "addToProject": "Tambahkan ke proyek", + "removeFromProject": "Hapus dari proyek", + "createNewProject": "Buat Proyek Baru", + "editProject": "Edit Proyek", + "deleteProject": "Hapus Proyek", + "projectName": "Nama Proyek", + "enterProjectName": "Masukkan nama proyek...", + "noProjectsAvailable": "Tidak ada proyek tersedia", + "noProjectsYet": "Belum Ada Proyek", + "noProjectsYetDesc": "Mulai proyek baru dengan mengklik tombol Tambah Proyek.", + "projectNotFound": "Proyek Tidak Ditemukan", + "projectNotFoundDesc": "Proyek yang Anda cari tidak ada atau telah dihapus.", + "deleteProjectDialog": { + "title": "Hapus Proyek", + "description": "Apakah Anda yakin ingin menghapus proyek ini? Tindakan ini tidak dapat dibatalkan.", + "deleteButton": "Hapus", + "successWithName": "Proyek \"{{projectName}}\" berhasil dihapus", + "successWithoutName": "Proyek berhasil dihapus", + "error": "Gagal menghapus proyek. Silakan coba lagi.", + "ariaLabel": "Hapus {{projectName}}" + }, + "addProjectDialog": { + "createTitle": "Buat Proyek Baru", + "editTitle": "Edit Proyek", + "nameLabel": "Nama Proyek", + "namePlaceholder": "Masukkan nama proyek...", + "createButton": "Buat", + "updateButton": "Perbarui", + "alreadyExists": "Proyek \"{{projectName}}\" sudah ada", + "createSuccess": "Proyek \"{{projectName}}\" berhasil dibuat", + "renameSuccess": "Proyek diubah dari \"{{oldName}}\" menjadi \"{{newName}}\"" + }, + "noConversationsIn": "Tidak Ada Percakapan di {{projectName}}", + "startNewConversation": "Mulai percakapan baru dengan {{projectName}} di bawah", + "conversationsIn": "Percakapan di {{projectName}}", + "conversationsDescription": "Klik percakapan mana pun untuk melanjutkan chatting, atau mulai yang baru di bawah.", + "thread": "utas", + "threads": "utas", + "updated": "Diperbarui:", + "collapseThreads": "Tutup utas", + "expandThreads": "Buka utas", + "update": "Perbarui" } } diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index 14fd6519e..ca6f6b6b7 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -117,6 +117,7 @@ "chatInput": "Zapytaj mnie o cokolwiek…" }, "confirm": "Potwierdź", + "continue": "Kontynuuj", "loading": "Wczytywanie…", "error": "Błąd", "success": "Sukces", @@ -229,6 +230,52 @@ "title": "Metadane Wiadomości" } }, + "projects": { + "title": "Projekty", + "addProject": "Dodaj Projekt", + "addToProject": "Dodaj do projektu", + "removeFromProject": "Usuń z projektu", + "createNewProject": "Utwórz Nowy Projekt", + "editProject": "Edytuj Projekt", + "deleteProject": "Usuń Projekt", + "projectName": "Nazwa Projektu", + "enterProjectName": "Wprowadź nazwę projektu...", + "noProjectsAvailable": "Brak dostępnych projektów", + "noProjectsYet": "Brak Projektów", + "noProjectsYetDesc": "Rozpocznij nowy projekt klikając przycisk Dodaj Projekt.", + "projectNotFound": "Projekt Nie Znaleziony", + "projectNotFoundDesc": "Projekt, którego szukasz nie istnieje lub został usunięty.", + "deleteProjectDialog": { + "title": "Usuń Projekt", + "description": "Na pewno chcesz usunąć ten projekt? Tej operacji nie można cofnąć.", + "deleteButton": "Usuń", + "successWithName": "Projekt \"{{projectName}}\" został pomyślnie usunięty", + "successWithoutName": "Projekt został pomyślnie usunięty", + "error": "Nie udało się usunąć projektu. Spróbuj ponownie.", + "ariaLabel": "Usuń {{projectName}}" + }, + "addProjectDialog": { + "createTitle": "Utwórz Nowy Projekt", + "editTitle": "Edytuj Projekt", + "nameLabel": "Nazwa Projektu", + "namePlaceholder": "Wprowadź nazwę projektu...", + "createButton": "Utwórz", + "updateButton": "Aktualizuj", + "alreadyExists": "Projekt \"{{projectName}}\" już istnieje", + "createSuccess": "Projekt \"{{projectName}}\" został pomyślnie utworzony", + "renameSuccess": "Projekt zmieniono z \"{{oldName}}\" na \"{{newName}}\"" + }, + "noConversationsIn": "Brak Rozmów w {{projectName}}", + "startNewConversation": "Rozpocznij nową rozmowę z {{projectName}} poniżej", + "conversationsIn": "Rozmowy w {{projectName}}", + "conversationsDescription": "Kliknij na dowolną rozmowę aby kontynuować czat, lub rozpocznij nową poniżej.", + "thread": "wątek", + "threads": "wątki", + "updated": "Zaktualizowano:", + "collapseThreads": "Zwiń wątki", + "expandThreads": "Rozwiń wątki", + "update": "Aktualizuj" + }, "toast": { "allThreadsUnfavorited": { "title": "Wszystkie Wątki Usunięte z Ulubionych", diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 8a107a9a2..4c2d95101 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -117,6 +117,7 @@ "chatInput": "Hỏi tôi bất cứ điều gì..." }, "confirm": "Xác nhận", + "continue": "Tiếp tục", "loading": "Đang tải...", "error": "Lỗi", "success": "Thành công", diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index ccabb6071..6da4a83fa 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -117,6 +117,7 @@ "chatInput": "随便问我什么..." }, "confirm": "确认", + "continue": "继续", "loading": "加载中...", "error": "错误", "success": "成功", diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index cb0a60510..4b9d1e7f6 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -117,6 +117,7 @@ "chatInput": "問我任何事..." }, "confirm": "確認", + "continue": "繼續", "loading": "載入中...", "error": "錯誤", "success": "成功", diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 4322b0fd1..0eb2bbf13 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as SystemMonitorImport } from './routes/system-monitor' import { Route as LogsImport } from './routes/logs' import { Route as AssistantImport } from './routes/assistant' import { Route as IndexImport } from './routes/index' +import { Route as ProjectIndexImport } from './routes/project/index' import { Route as HubIndexImport } from './routes/hub/index' import { Route as ThreadsThreadIdImport } from './routes/threads/$threadId' import { Route as SettingsShortcutsImport } from './routes/settings/shortcuts' @@ -26,6 +27,7 @@ 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 SettingsAppearanceImport } from './routes/settings/appearance' +import { Route as ProjectProjectIdImport } from './routes/project/$projectId' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' import { Route as HubModelIdImport } from './routes/hub/$modelId' import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index' @@ -58,6 +60,12 @@ const IndexRoute = IndexImport.update({ getParentRoute: () => rootRoute, } as any) +const ProjectIndexRoute = ProjectIndexImport.update({ + id: '/project/', + path: '/project/', + getParentRoute: () => rootRoute, +} as any) + const HubIndexRoute = HubIndexImport.update({ id: '/hub/', path: '/hub/', @@ -124,6 +132,12 @@ const SettingsAppearanceRoute = SettingsAppearanceImport.update({ getParentRoute: () => rootRoute, } as any) +const ProjectProjectIdRoute = ProjectProjectIdImport.update({ + id: '/project/$projectId', + path: '/project/$projectId', + getParentRoute: () => rootRoute, +} as any) + const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({ id: '/local-api-server/logs', path: '/local-api-server/logs', @@ -201,6 +215,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LocalApiServerLogsImport parentRoute: typeof rootRoute } + '/project/$projectId': { + id: '/project/$projectId' + path: '/project/$projectId' + fullPath: '/project/$projectId' + preLoaderRoute: typeof ProjectProjectIdImport + parentRoute: typeof rootRoute + } '/settings/appearance': { id: '/settings/appearance' path: '/settings/appearance' @@ -278,6 +299,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HubIndexImport parentRoute: typeof rootRoute } + '/project/': { + id: '/project/' + path: '/project' + fullPath: '/project' + preLoaderRoute: typeof ProjectIndexImport + parentRoute: typeof rootRoute + } '/auth/google/callback': { id: '/auth/google/callback' path: '/auth/google/callback' @@ -311,6 +339,7 @@ export interface FileRoutesByFullPath { '/system-monitor': typeof SystemMonitorRoute '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute + '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -322,6 +351,7 @@ export interface FileRoutesByFullPath { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub': typeof HubIndexRoute + '/project': typeof ProjectIndexRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers': typeof SettingsProvidersIndexRoute @@ -334,6 +364,7 @@ export interface FileRoutesByTo { '/system-monitor': typeof SystemMonitorRoute '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute + '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -345,19 +376,21 @@ export interface FileRoutesByTo { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub': typeof HubIndexRoute + '/project': typeof ProjectIndexRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers': typeof SettingsProvidersIndexRoute } export interface FileRoutesById { - __root__: typeof rootRoute + '__root__': typeof rootRoute '/': typeof IndexRoute '/assistant': typeof AssistantRoute '/logs': typeof LogsRoute '/system-monitor': typeof SystemMonitorRoute '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute + '/project/$projectId': typeof ProjectProjectIdRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -369,6 +402,7 @@ export interface FileRoutesById { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub/': typeof HubIndexRoute + '/project/': typeof ProjectIndexRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers/': typeof SettingsProvidersIndexRoute @@ -383,6 +417,7 @@ export interface FileRouteTypes { | '/system-monitor' | '/hub/$modelId' | '/local-api-server/logs' + | '/project/$projectId' | '/settings/appearance' | '/settings/extensions' | '/settings/general' @@ -394,6 +429,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub' + | '/project' | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers' @@ -405,6 +441,7 @@ export interface FileRouteTypes { | '/system-monitor' | '/hub/$modelId' | '/local-api-server/logs' + | '/project/$projectId' | '/settings/appearance' | '/settings/extensions' | '/settings/general' @@ -416,6 +453,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub' + | '/project' | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers' @@ -427,6 +465,7 @@ export interface FileRouteTypes { | '/system-monitor' | '/hub/$modelId' | '/local-api-server/logs' + | '/project/$projectId' | '/settings/appearance' | '/settings/extensions' | '/settings/general' @@ -438,6 +477,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub/' + | '/project/' | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers/' @@ -451,6 +491,7 @@ export interface RootRouteChildren { SystemMonitorRoute: typeof SystemMonitorRoute HubModelIdRoute: typeof HubModelIdRoute LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute + ProjectProjectIdRoute: typeof ProjectProjectIdRoute SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsExtensionsRoute: typeof SettingsExtensionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -462,6 +503,7 @@ export interface RootRouteChildren { SettingsShortcutsRoute: typeof SettingsShortcutsRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute HubIndexRoute: typeof HubIndexRoute + ProjectIndexRoute: typeof ProjectIndexRoute AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute @@ -474,6 +516,7 @@ const rootRouteChildren: RootRouteChildren = { SystemMonitorRoute: SystemMonitorRoute, HubModelIdRoute: HubModelIdRoute, LocalApiServerLogsRoute: LocalApiServerLogsRoute, + ProjectProjectIdRoute: ProjectProjectIdRoute, SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsExtensionsRoute: SettingsExtensionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, @@ -485,6 +528,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsShortcutsRoute: SettingsShortcutsRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, HubIndexRoute: HubIndexRoute, + ProjectIndexRoute: ProjectIndexRoute, AuthGoogleCallbackRoute: AuthGoogleCallbackRoute, SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute, SettingsProvidersIndexRoute: SettingsProvidersIndexRoute, @@ -506,6 +550,7 @@ export const routeTree = rootRoute "/system-monitor", "/hub/$modelId", "/local-api-server/logs", + "/project/$projectId", "/settings/appearance", "/settings/extensions", "/settings/general", @@ -517,6 +562,7 @@ export const routeTree = rootRoute "/settings/shortcuts", "/threads/$threadId", "/hub/", + "/project/", "/auth/google/callback", "/settings/providers/$providerName", "/settings/providers/" @@ -540,6 +586,9 @@ export const routeTree = rootRoute "/local-api-server/logs": { "filePath": "local-api-server/logs.tsx" }, + "/project/$projectId": { + "filePath": "project/$projectId.tsx" + }, "/settings/appearance": { "filePath": "settings/appearance.tsx" }, @@ -573,6 +622,9 @@ export const routeTree = rootRoute "/hub/": { "filePath": "hub/index.tsx" }, + "/project/": { + "filePath": "project/index.tsx" + }, "/auth/google/callback": { "filePath": "auth.google.callback.tsx" }, diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index bf4fd928c..d96080f7d 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.tsx @@ -12,6 +12,7 @@ import { AvatarEmoji } from '@/containers/AvatarEmoji' import { useTranslation } from '@/i18n/react-i18next-compat' import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { PlatformFeature } from '@/lib/platform/types' +import { Button } from '@/components/ui/button' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.assistant as any)({ @@ -61,72 +62,71 @@ function AssistantContent() { return (
- {t('assistants:title')} +
+ {t('assistants:title')} + +
-
-
+
+
{assistants .slice() .sort((a, b) => a.created_at - b.created_at) .map((assistant) => (
-
-

-
- {assistant?.avatar && ( - - - - )} - {assistant.name} -
-

-
-
{ - setEditingKey(assistant.id) - setOpen(true) - }} - > - -
-
handleDelete(assistant.id)} - > - +
+ {assistant?.avatar && ( +
+
+ )} +
+

+ {assistant.name} +

+

+ {assistant.description} +

-

- {assistant.description} -

+
+ + +
))} - -
{ - setEditingKey(null) - setOpen(true) - }} - > - -
state.threads) + + const chatWidth = useAppearance((state) => state.chatWidth) + const isSmallScreen = useSmallScreen() + + // Find the project + const project = getFolderById(projectId) + + // Get threads for this project + const projectThreads = useMemo(() => { + return Object.values(threads) + .filter((thread) => thread.metadata?.project?.id === projectId) + .sort((a, b) => (b.updated || 0) - (a.updated || 0)) + }, [threads, projectId]) + + // Conditional to check if there are any valid providers + const hasValidProviders = providers.some( + (provider) => + provider.api_key?.length || + (provider.provider === 'llamacpp' && provider.models.length) || + (provider.provider === 'jan' && provider.models.length) + ) + + if (!hasValidProviders) { + return + } + + if (!project) { + return ( +
+
+

+ {t('projects.projectNotFound')} +

+

+ {t('projects.projectNotFoundDesc')} +

+
+
+ ) + } + + return ( +
+ +
+ {PlatformFeatures[PlatformFeature.ASSISTANTS] && ( + + )} +
+
+ +
+
+
+
+ {projectThreads.length > 0 && ( + <> +

+ {t('projects.conversationsIn', { projectName: project.name })} +

+

+ {t('projects.conversationsDescription')} +

+ + )} +
+ + {/* Thread List or Empty State */} +
+ {projectThreads.length > 0 ? ( + + ) : ( +
+ +

+ {t('projects.noConversationsIn', { projectName: project.name })} +

+

+ {t('projects.startNewConversation', { projectName: project.name })} +

+
+ )} +
+
+
+
+ {/* New Chat Input */} +
+ +
+
+ ) +} diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx new file mode 100644 index 000000000..b4cbb6618 --- /dev/null +++ b/web-app/src/routes/project/index.tsx @@ -0,0 +1,244 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState, useMemo } from 'react' + +import { useThreadManagement } from '@/hooks/useThreadManagement' +import { useThreads } from '@/hooks/useThreads' +import { useTranslation } from '@/i18n/react-i18next-compat' + +import HeaderPage from '@/containers/HeaderPage' +import ThreadList from '@/containers/ThreadList' +import { + IconCirclePlus, + IconPencil, + IconTrash, + IconFolder, + IconChevronDown, + IconChevronRight, +} from '@tabler/icons-react' +import AddProjectDialog from '@/containers/dialogs/AddProjectDialog' +import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog' +import { Button } from '@/components/ui/button' + +import { formatDate } from '@/utils/formatDate' + +export const Route = createFileRoute('/project/')({ + component: Project, +}) + +function Project() { + return +} + +function ProjectContent() { + const { t } = useTranslation() + const { folders, addFolder, updateFolder, deleteFolder, getFolderById } = + useThreadManagement() + const threads = useThreads((state) => state.threads) + const [open, setOpen] = useState(false) + const [editingKey, setEditingKey] = useState(null) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [expandedProjects, setExpandedProjects] = useState>( + new Set() + ) + + const handleDelete = (id: string) => { + setDeletingId(id) + setDeleteConfirmOpen(true) + } + + const confirmDelete = () => { + if (deletingId) { + deleteFolder(deletingId) + setDeleteConfirmOpen(false) + setDeletingId(null) + } + } + + const handleSave = (name: string) => { + if (editingKey) { + updateFolder(editingKey, name) + } else { + addFolder(name) + } + setOpen(false) + setEditingKey(null) + } + + const formatProjectDate = (timestamp: number) => { + return formatDate(new Date(timestamp), { includeTime: false }) + } + + // Get threads for a specific project + const getThreadsForProject = useMemo(() => { + return (projectId: string) => { + return Object.values(threads) + .filter((thread) => thread.metadata?.project?.id === projectId) + .sort((a, b) => (b.updated || 0) - (a.updated || 0)) + } + }, [threads]) + + const toggleProjectExpansion = (projectId: string) => { + setExpandedProjects((prev) => { + const newSet = new Set(prev) + if (newSet.has(projectId)) { + newSet.delete(projectId) + } else { + newSet.add(projectId) + } + return newSet + }) + } + + return ( +
+ +
+ {t('projects.title')} + +
+
+
+
+ {folders.length === 0 ? ( +
+ +

+ {t('projects.noProjectsYet')} +

+

+ {t('projects.noProjectsYetDesc')} +

+
+ ) : ( +
+ {folders + .slice() + .sort((a, b) => a.updated_at - b.updated_at) + .map((folder) => { + const projectThreads = getThreadsForProject(folder.id) + const isExpanded = expandedProjects.has(folder.id) + + return ( +
+
+
+
+ +
+
+
+

+ {folder.name} +

+ + {projectThreads.length}{' '} + {projectThreads.length === 1 + ? t('projects.thread') + : t('projects.threads')} + +
+

+ {t('projects.updated')}{' '} + {formatProjectDate(folder.updated_at)} +

+
+
+
+ {projectThreads.length > 0 && ( + + )} + + +
+
+ + {/* Thread List */} + {isExpanded && projectThreads.length > 0 && ( +
+ +
+ )} +
+ ) + })} +
+ )} +
+
+ + +
+ ) +} diff --git a/web-app/src/services/threads/default.ts b/web-app/src/services/threads/default.ts index af5f213d5..72c66841a 100644 --- a/web-app/src/services/threads/default.ts +++ b/web-app/src/services/threads/default.ts @@ -30,6 +30,12 @@ export class DefaultThreadsService implements ThreadsService { provider: e.assistants?.[0]?.model?.engine, }, assistants: e.assistants ?? [defaultAssistant], + metadata: { + ...e.metadata, + // Override extracted fields to avoid duplication + order: e.metadata?.order, + is_favorite: e.metadata?.is_favorite, + }, } as Thread }) }) @@ -101,6 +107,7 @@ export class DefaultThreadsService implements ThreadsService { }, ], metadata: { + ...thread.metadata, is_favorite: thread.isFavorite, order: thread.order, }, @@ -115,4 +122,4 @@ export class DefaultThreadsService implements ThreadsService { .get(ExtensionTypeEnum.Conversational) ?.deleteThread(threadId) } -} \ No newline at end of file +} diff --git a/web-app/src/types/threads.d.ts b/web-app/src/types/threads.d.ts index 657b7e651..35238687a 100644 --- a/web-app/src/types/threads.d.ts +++ b/web-app/src/types/threads.d.ts @@ -44,6 +44,14 @@ type Thread = { model?: ThreadModel updated: number order?: number + metadata?: { + project?: { + id: string + name: string + updated_at: number + } + [key: string]: unknown + } } type Assistant = { @@ -62,4 +70,4 @@ type TokenSpeed = { tokenSpeed: number tokenCount: number lastTimestamp: number -} \ No newline at end of file +} From d0f62fa634eb1deef68c888203610f5ae1c90ae9 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 25 Sep 2025 10:18:21 +0700 Subject: [PATCH 06/22] chore: fix missing classname --- web-app/src/containers/SetupScreen.tsx | 2 +- web-app/src/routes/assistant.tsx | 2 +- web-app/src/routes/index.tsx | 2 +- web-app/src/routes/project/index.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web-app/src/containers/SetupScreen.tsx b/web-app/src/containers/SetupScreen.tsx index 812ed6493..bce474836 100644 --- a/web-app/src/containers/SetupScreen.tsx +++ b/web-app/src/containers/SetupScreen.tsx @@ -18,7 +18,7 @@ function SetupScreen() { localStorage.getItem(localStorageKey.setupCompleted) === 'true' return ( -
+
diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index d96080f7d..dca4c93ef 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.tsx @@ -60,7 +60,7 @@ function AssistantContent() { } return ( -
+
{t('assistants:title')} diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index 80bf065f2..b4b208b9d 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -52,7 +52,7 @@ function Index() { } return ( -
+
{PlatformFeatures[PlatformFeature.ASSISTANTS] && } diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx index b4cbb6618..f609f7943 100644 --- a/web-app/src/routes/project/index.tsx +++ b/web-app/src/routes/project/index.tsx @@ -91,7 +91,7 @@ function ProjectContent() { } return ( -
+
{t('projects.title')} From b0bca2ac1f5e33cd8463e6b3c885d4be3a522796 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 25 Sep 2025 10:19:18 +0700 Subject: [PATCH 07/22] Update web-app/src/routes/project/index.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- web-app/src/routes/project/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx index f609f7943..fa8e1cf3a 100644 --- a/web-app/src/routes/project/index.tsx +++ b/web-app/src/routes/project/index.tsx @@ -124,7 +124,7 @@ function ProjectContent() {
{folders .slice() - .sort((a, b) => a.updated_at - b.updated_at) + .sort((a, b) => b.updated_at - a.updated_at) .map((folder) => { const projectThreads = getThreadsForProject(folder.id) const isExpanded = expandedProjects.has(folder.id) From 8205c3317646cb339726790b4b0dd1d7d074d7b3 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Thu, 25 Sep 2025 10:55:10 +0700 Subject: [PATCH 08/22] ci: update package version for tauri plugin --- ...emplate-tauri-build-linux-x64-external.yml | 27 +++++++++- ...template-tauri-build-linux-x64-flatpak.yml | 29 +++++++++-- .../template-tauri-build-linux-x64.yml | 29 +++++++++-- .../template-tauri-build-macos-external.yml | 52 ++++++++++--------- .../workflows/template-tauri-build-macos.yml | 23 ++++++++ ...plate-tauri-build-windows-x64-external.yml | 25 ++++++++- .../template-tauri-build-windows-x64.yml | 24 ++++++++- 7 files changed, 175 insertions(+), 34 deletions(-) diff --git a/.github/workflows/template-tauri-build-linux-x64-external.yml b/.github/workflows/template-tauri-build-linux-x64-external.yml index 59c14a3d6..83c19879f 100644 --- a/.github/workflows/template-tauri-build-linux-x64-external.yml +++ b/.github/workflows/template-tauri-build-linux-x64-external.yml @@ -79,8 +79,33 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json - ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml + ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/Cargo.toml---------" + cat ./src-tauri/Cargo.toml + + ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" if [ "${{ inputs.channel }}" != "stable" ]; then jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json diff --git a/.github/workflows/template-tauri-build-linux-x64-flatpak.yml b/.github/workflows/template-tauri-build-linux-x64-flatpak.yml index 2807a74ae..d8b374cdf 100644 --- a/.github/workflows/template-tauri-build-linux-x64-flatpak.yml +++ b/.github/workflows/template-tauri-build-linux-x64-flatpak.yml @@ -100,13 +100,36 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json - # Temporarily enable devtool on prod build - ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" - cat ./src-tauri/Cargo.toml + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/Cargo.toml---------" cat ./src-tauri/Cargo.toml + # Temporarily enable devtool on prod build + ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" + cat ./src-tauri/Cargo.toml + # Change app name for beta and nightly builds if [ "${{ inputs.channel }}" != "stable" ]; then jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json diff --git a/.github/workflows/template-tauri-build-linux-x64.yml b/.github/workflows/template-tauri-build-linux-x64.yml index 3b9daebb5..7cebf389a 100644 --- a/.github/workflows/template-tauri-build-linux-x64.yml +++ b/.github/workflows/template-tauri-build-linux-x64.yml @@ -117,11 +117,34 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json - # Temporarily enable devtool on prod build - ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" - cat ./src-tauri/Cargo.toml + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/Cargo.toml---------" + cat ./src-tauri/Cargo.toml + + # Temporarily enable devtool on prod build + ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" cat ./src-tauri/Cargo.toml # Change app name for beta and nightly builds diff --git a/.github/workflows/template-tauri-build-macos-external.yml b/.github/workflows/template-tauri-build-macos-external.yml index 8f61b86fa..3ba92f263 100644 --- a/.github/workflows/template-tauri-build-macos-external.yml +++ b/.github/workflows/template-tauri-build-macos-external.yml @@ -42,31 +42,6 @@ jobs: run: | cargo install ctoml - - name: Create bun and uv universal - run: | - mkdir -p ./src-tauri/resources/bin/ - cd ./src-tauri/resources/bin/ - curl -L -o bun-darwin-x64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.10/bun-darwin-x64.zip - curl -L -o bun-darwin-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.10/bun-darwin-aarch64.zip - unzip bun-darwin-x64.zip - unzip bun-darwin-aarch64.zip - lipo -create -output bun-universal-apple-darwin bun-darwin-x64/bun bun-darwin-aarch64/bun - cp -f bun-darwin-aarch64/bun bun-aarch64-apple-darwin - cp -f bun-darwin-x64/bun bun-x86_64-apple-darwin - cp -f bun-universal-apple-darwin bun - - curl -L -o uv-x86_64.tar.gz https://github.com/astral-sh/uv/releases/download/0.6.17/uv-x86_64-apple-darwin.tar.gz - curl -L -o uv-arm64.tar.gz https://github.com/astral-sh/uv/releases/download/0.6.17/uv-aarch64-apple-darwin.tar.gz - tar -xzf uv-x86_64.tar.gz - tar -xzf uv-arm64.tar.gz - mv uv-x86_64-apple-darwin uv-x86_64 - mv uv-aarch64-apple-darwin uv-aarch64 - lipo -create -output uv-universal-apple-darwin uv-x86_64/uv uv-aarch64/uv - cp -f uv-x86_64/uv uv-x86_64-apple-darwin - cp -f uv-aarch64/uv uv-aarch64-apple-darwin - cp -f uv-universal-apple-darwin uv - ls -la - - name: Update app version run: | echo "Version: ${{ inputs.new_version }}" @@ -74,8 +49,35 @@ jobs: mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json + + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml + ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/Cargo.toml---------" + cat ./src-tauri/Cargo.toml + ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools" + if [ "${{ inputs.channel }}" != "stable" ]; then jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json diff --git a/.github/workflows/template-tauri-build-macos.yml b/.github/workflows/template-tauri-build-macos.yml index 4646041cf..82370b10b 100644 --- a/.github/workflows/template-tauri-build-macos.yml +++ b/.github/workflows/template-tauri-build-macos.yml @@ -101,7 +101,30 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml + ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/Cargo.toml---------" cat ./src-tauri/Cargo.toml # Temporarily enable devtool on prod build diff --git a/.github/workflows/template-tauri-build-windows-x64-external.yml b/.github/workflows/template-tauri-build-windows-x64-external.yml index ed1d601a3..5559fe146 100644 --- a/.github/workflows/template-tauri-build-windows-x64-external.yml +++ b/.github/workflows/template-tauri-build-windows-x64-external.yml @@ -54,9 +54,32 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml + ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" - echo "---------Cargo.toml---------" + echo "---------./src-tauri/Cargo.toml---------" cat ./src-tauri/Cargo.toml + if [ "${{ inputs.channel }}" != "stable" ]; then jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json diff --git a/.github/workflows/template-tauri-build-windows-x64.yml b/.github/workflows/template-tauri-build-windows-x64.yml index 1f25e5295..246b3705b 100644 --- a/.github/workflows/template-tauri-build-windows-x64.yml +++ b/.github/workflows/template-tauri-build-windows-x64.yml @@ -97,9 +97,31 @@ jobs: mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json mv /tmp/package.json web-app/package.json + + # Update tauri plugin versions + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-hardware/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-hardware/package.json + echo "---------./src-tauri/tauri-plugin-hardware/package.json---------" + cat ./src-tauri/tauri-plugin-hardware/package.json + + jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/tauri-plugin-llamacpp/package.json > /tmp/package.json + mv /tmp/package.json ./src-tauri/tauri-plugin-llamacpp/package.json + + echo "---------./src-tauri/tauri-plugin-llamacpp/package.json---------" + cat ./src-tauri/tauri-plugin-llamacpp/package.json + + ctoml ./src-tauri/tauri-plugin-hardware/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-hardware/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-hardware/Cargo.toml + + ctoml ./src-tauri/tauri-plugin-llamacpp/Cargo.toml package.version "${{ inputs.new_version }}" + echo "---------./src-tauri/tauri-plugin-llamacpp/Cargo.toml---------" + cat ./src-tauri/tauri-plugin-llamacpp/Cargo.toml + ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" - echo "---------Cargo.toml---------" + echo "---------./src-tauri/Cargo.toml---------" cat ./src-tauri/Cargo.toml # Add sign commands to tauri.windows.conf.json From 1b120712d49830e6d23fe5ae967a2ae8a467ecc7 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 25 Sep 2025 11:26:15 +0700 Subject: [PATCH 09/22] enhancement: update responsive footer and copy hero section --- docs/src/components/FooterMenu/index.tsx | 6 +++--- docs/src/components/Home/index.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/components/FooterMenu/index.tsx b/docs/src/components/FooterMenu/index.tsx index 1609430bf..68e1e6e78 100644 --- a/docs/src/components/FooterMenu/index.tsx +++ b/docs/src/components/FooterMenu/index.tsx @@ -77,9 +77,9 @@ export default function Footer() { return (