feat(AI Assistant): custom name option for AI Assistant

This commit is contained in:
Jake Turner 2026-03-04 06:29:08 +00:00 committed by Jake Turner
parent b806cefe3a
commit 96beab7e69
18 changed files with 171 additions and 263 deletions

View File

@ -54,12 +54,14 @@ export default class SettingsController {
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null, limit: 15 });
const installedModels = await this.ollamaService.getModels();
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
installedModels: installedModels || [],
settings: {
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
aiAssistantCustomName: aiAssistantCustomName ?? '',
}
}
});

View File

@ -50,4 +50,15 @@ export default class KVStore extends BaseModel {
}
return setting
}
/**
* Clear a setting value by key, storing null so getValue returns null.
*/
static async clearValue<K extends KVStoreKey>(key: K): Promise<void> {
const setting = await this.findBy('key', key)
if (setting && setting.value !== null) {
setting.value = null
await setting.save()
}
}
}

View File

@ -12,7 +12,7 @@ import { getAllFilesystems, getFile } from '../utils/fs.js'
import axios from 'axios'
import env from '#start/env'
import KVStore from '#models/kv_store'
import { KVStoreKey } from '../../types/kv_store.js'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
@inject()
@ -388,7 +388,11 @@ export class SystemService {
}
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
await KVStore.setValue(key, value);
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
await KVStore.clearValue(key)
} else {
await KVStore.setValue(key, value)
}
}
/**

View File

@ -4,5 +4,5 @@ import { SETTINGS_KEYS } from "../../constants/kv_store.js";
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
value: vine.any(),
value: vine.any().optional(),
}))

View File

@ -1,3 +1,4 @@
import KVStore from '#models/kv_store'
import { SystemService } from '#services/system_service'
import { defineConfig } from '@adonisjs/inertia'
import type { InferSharedProps } from '@adonisjs/inertia/types'
@ -14,6 +15,10 @@ const inertiaConfig = defineConfig({
sharedData: {
appVersion: () => SystemService.getAppVersion(),
environment: process.env.NODE_ENV || 'production',
aiAssistantName: async () => {
const customName = await KVStore.getValue('ai.assistantCustomName')
return (customName && customName.trim()) ? customName : 'AI Assistant'
},
},
/**

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup', 'system.earlyAccess'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName'];

View File

@ -9,6 +9,7 @@ import StyledModal from '../StyledModal'
import api from '~/lib/api'
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
import { useNotifications } from '~/context/NotificationContext'
import { usePage } from '@inertiajs/react'
interface ChatInterfaceProps {
messages: ChatMessage[]
@ -29,6 +30,7 @@ export default function ChatInterface({
chatSuggestionsLoading = false,
rewriteModelAvailable = false
}: ChatInterfaceProps) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { addNotification } = useNotifications()
const [input, setInput] = useState('')
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
@ -160,7 +162,7 @@ export default function ChatInterface({
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
rows={1}
disabled={isLoading}

View File

@ -1,6 +1,6 @@
import classNames from '~/lib/classNames'
import StyledButton from '../StyledButton'
import { router } from '@inertiajs/react'
import { router, usePage } from '@inertiajs/react'
import { ChatSession } from '../../../types/chat'
import { IconMessage } from '@tabler/icons-react'
import { useState } from 'react'
@ -23,6 +23,7 @@ export default function ChatSidebar({
onClearHistory,
isInModal = false,
}: ChatSidebarProps) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const [isKnowledgeBaseModalOpen, setIsKnowledgeBaseModalOpen] = useState(
() => new URLSearchParams(window.location.search).get('knowledge_base') === 'true'
)
@ -139,7 +140,7 @@ export default function ChatSidebar({
)}
</div>
{isKnowledgeBaseModalOpen && (
<KnowledgeBaseModal onClose={handleCloseKnowledgeBase} />
<KnowledgeBaseModal aiAssistantName={aiAssistantName} onClose={handleCloseKnowledgeBase} />
)}
</div>
)

View File

@ -11,10 +11,11 @@ import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
interface KnowledgeBaseModalProps {
aiAssistantName?: string
onClose: () => void
}
export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps) {
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
@ -140,12 +141,12 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
</div>
<div>
<p className="font-medium text-desert-stone-dark">
AI Assistant Knowledge Base Integration
{aiAssistantName} Knowledge Base Integration
</p>
<p className="text-sm text-desert-stone">
When you upload documents to your Knowledge Base, NOMAD processes and embeds
the content, making it directly accessible to the AI Assistant. This allows
the AI Assistant to reference your specific documents during conversations,
the content, making it directly accessible to {aiAssistantName}. This allows{' '}
{aiAssistantName} to reference your specific documents during conversations,
providing more accurate and personalized responses based on your uploaded
data.
</p>
@ -177,8 +178,7 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
</p>
<p className="text-sm text-desert-stone">
NOMAD will automatically discover and extract any content you save to your
Information Library (if installed), making it instantly available to the AI
Assistant without any extra steps.
Information Library (if installed), making it instantly available to {aiAssistantName} without any extra steps.
</p>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
name: string;
label: string;
helpText?: string;
className?: string;
labelClassName?: string;
inputClassName?: string;
@ -17,6 +18,7 @@ const Input: React.FC<InputProps> = ({
className,
label,
name,
helpText,
labelClassName,
inputClassName,
containerClassName,
@ -33,6 +35,7 @@ const Input: React.FC<InputProps> = ({
>
{label}{required ? "*" : ""}
</label>
{helpText && <p className="mt-1 text-sm text-gray-500">{helpText}</p>}
<div className={classNames("mt-1.5", containerClassName)}>
<div className="relative">
{leftIcon && (

View File

@ -10,11 +10,15 @@ import {
IconWand,
IconZoom
} from '@tabler/icons-react'
import { usePage } from '@inertiajs/react'
import StyledSidebar from '~/components/StyledSidebar'
import { getServiceLink } from '~/lib/navigation'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const navigation = [
{ name: 'AI Assistant', href: '/settings/models', icon: IconWand, current: false },
{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false },
{ name: 'Apps', href: '/settings/apps', icon: IconTerminal2, current: false },
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
@ -37,7 +41,6 @@ const navigation = [
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
]
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-row bg-stone-50/90">
<StyledSidebar title="Settings" items={navigation} />

View File

@ -1,10 +1,11 @@
import { Head } from '@inertiajs/react'
import { Head, usePage } from '@inertiajs/react'
import ChatComponent from '~/components/chat'
export default function Chat(props: { settings: { chatSuggestionsEnabled: boolean } }) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
return (
<div className="w-full h-full">
<Head title="AI Assistant" />
<Head title={aiAssistantName} />
<ChatComponent enabled={true} suggestionsEnabled={props.settings.chatSuggestionsEnabled} />
</div>
)

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'
import { Head, router, usePage } from '@inertiajs/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState, useMemo } from 'react'
import AppLayout from '~/layouts/AppLayout'
@ -32,7 +32,8 @@ interface Capability {
icon: string
}
const CORE_CAPABILITIES: Capability[] = [
function buildCoreCapabilities(aiAssistantName: string): Capability[] {
return [
{
id: 'information',
name: 'Information Library',
@ -64,7 +65,7 @@ const CORE_CAPABILITIES: Capability[] = [
},
{
id: 'ai',
name: 'AI Assistant',
name: aiAssistantName,
technicalName: 'Ollama',
description: 'Local AI chat that runs entirely on your hardware - no internet required',
features: [
@ -77,6 +78,7 @@ const CORE_CAPABILITIES: Capability[] = [
icon: 'IconRobot',
},
]
}
const ADDITIONAL_TOOLS: Capability[] = [
{
@ -110,6 +112,9 @@ const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName)
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])

View File

@ -6,7 +6,7 @@ import {
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head } from '@inertiajs/react'
import { Head, usePage } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
@ -14,6 +14,7 @@ import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
import { useUpdateAvailable } from '~/hooks/useUpdateAvailable'
import { useSystemSetting } from '~/hooks/useSystemSetting'
import Alert from '~/components/Alert'
import { SERVICE_NAMES } from '../../constants/service_names'
// Maps is a Core Capability (display_order: 4)
const MAPS_ITEM = {
@ -90,6 +91,7 @@ export default function Home(props: {
}) {
const items: DashboardItem[] = []
const updateInfo = useUpdateAvailable();
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
// Check if user has visited Easy Setup
const { data: easySetupVisited } = useSystemSetting({
@ -102,7 +104,8 @@ export default function Home(props: {
.filter((service) => service.installed && service.ui_location)
.forEach((service) => {
items.push({
label: service.friendly_name || service.service_name,
// Inject custom AI Assistant name if this is the chat service
label: service.service_name === SERVICE_NAMES.OLLAMA && aiAssistantName ? aiAssistantName : (service.friendly_name || service.service_name),
to: service.ui_location ? getServiceLink(service.ui_location) : '#',
target: '_blank',
description:

View File

@ -1,153 +0,0 @@
import { Head } from '@inertiajs/react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import StyledTable from '~/components/StyledTable'
import { useNotifications } from '~/context/NotificationContext'
import AppLayout from '~/layouts/AppLayout'
import api from '~/lib/api'
export default function KnowledgeBase() {
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
queryKey: ['storedFiles'],
queryFn: () => api.getStoredRAGFiles(),
select: (data) => data || [],
})
const uploadMutation = useMutation({
mutationFn: (file: File) => api.uploadDocument(file),
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'Document uploaded and queued for processing',
})
setFiles([])
if (fileUploaderRef.current) {
fileUploaderRef.current.clear()
}
},
onError: (error: any) => {
addNotification({
type: 'error',
message: error?.message || 'Failed to upload document',
})
},
})
const handleUpload = () => {
if (files.length > 0) {
uploadMutation.mutate(files[0])
}
}
return (
<AppLayout>
<Head title="Knowledge Base" />
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="p-6">
<FileUploader
ref={fileUploaderRef}
minFiles={1}
maxFiles={1}
onUpload={(uploadedFiles) => {
setFiles(Array.from(uploadedFiles))
}}
/>
<div className="flex justify-center gap-4 my-6">
<StyledButton
variant="primary"
size="lg"
icon="IconUpload"
onClick={handleUpload}
disabled={files.length === 0 || uploadMutation.isPending}
loading={uploadMutation.isPending}
>
Upload
</StyledButton>
</div>
</div>
<div className="border-t bg-white p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
Why upload documents to your Knowledge Base?
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
1
</div>
<div>
<p className="font-medium text-desert-stone-dark">
AI Assistant Knowledge Base Integration
</p>
<p className="text-sm text-desert-stone">
When you upload documents to your Knowledge Base, NOMAD processes and embeds the
content, making it directly accessible to the AI Assistant. This allows the AI
Assistant to reference your specific documents during conversations, providing
more accurate and personalized responses based on your uploaded data.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
2
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Enhanced Document Processing with OCR
</p>
<p className="text-sm text-desert-stone">
NOMAD includes built-in Optical Character Recognition (OCR) capabilities,
allowing it to extract text from image-based documents such as scanned PDFs or
photos. This means that even if your documents are not in a standard text
format, NOMAD can still process and embed their content for AI access.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
3
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Information Library Integration
</p>
<p className="text-sm text-desert-stone">
NOMAD will automatically discover and extract any content you save to your
Information Library (if installed), making it instantly available to the AI
Assistant without any extra steps.
</p>
</div>
</div>
</div>
</div>
</div>
<div className="my-12">
<StyledSectionHeader title="Stored Knowledge Base Files" />
<StyledTable<{ source: string }>
className="font-semibold"
rowLines={true}
columns={[
{
accessor: 'source',
title: 'File Name',
render(record) {
return <span className="text-gray-700">{record.source}</span>
},
},
]}
data={storedFiles.map((source) => ({ source }))}
loading={isLoadingFiles}
/>
</div>
</main>
</AppLayout>
)
}

View File

@ -1,4 +1,4 @@
import { Head, Link } from '@inertiajs/react'
import { Head, Link, usePage } from '@inertiajs/react'
import { useState, useEffect } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
@ -34,6 +34,7 @@ export default function BenchmarkPage(props: {
currentBenchmarkId: string | null
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { subscribe } = useTransmit()
const queryClient = useQueryClient()
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
@ -403,8 +404,8 @@ export default function BenchmarkPage(props: {
{showAIRequiredAlert && (
<Alert
type="warning"
title="AI Assistant Required"
message="Full benchmark requires AI Assistant to be installed. Install it to measure your complete NOMAD capability and share results with the community."
title={`${aiAssistantName} Required`}
message={`Full benchmark requires ${aiAssistantName} to be installed. Install it to measure your complete NOMAD capability and share results with the community.`}
variant="bordered"
dismissible
onDismiss={() => setShowAIRequiredAlert(false)}
@ -413,7 +414,7 @@ export default function BenchmarkPage(props: {
href="/settings/apps"
className="text-sm text-desert-green hover:underline mt-2 inline-block font-medium"
>
Go to Apps to install AI Assistant
Go to Apps to install {aiAssistantName}
</Link>
</Alert>
)}
@ -444,7 +445,7 @@ export default function BenchmarkPage(props: {
icon="IconWand"
title={
!aiInstalled
? 'AI Assistant must be installed to run AI benchmark'
? `${aiAssistantName} must be installed to run AI benchmark`
: undefined
}
>
@ -453,7 +454,8 @@ export default function BenchmarkPage(props: {
</div>
{!aiInstalled && (
<p className="text-sm text-desert-stone-dark">
<span className="text-amber-600">Note:</span> AI Assistant is not installed.
<span className="text-amber-600">Note:</span> {aiAssistantName} is not
installed.
<Link
href="/settings/apps"
className="text-desert-green hover:underline ml-1"
@ -566,7 +568,7 @@ export default function BenchmarkPage(props: {
<Alert
type="info"
title="Partial Benchmark"
message={`This ${latestResult.benchmark_type} benchmark cannot be shared with the community. Run a Full Benchmark with AI Assistant installed to share your results.`}
message={`This ${latestResult.benchmark_type} benchmark cannot be shared with the community. Run a Full Benchmark with ${aiAssistantName} installed to share your results.`}
variant="bordered"
/>
)}

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'
import { Head, router, usePage } from '@inertiajs/react'
import { useState } from 'react'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
@ -24,9 +24,10 @@ export default function ModelsPage(props: {
models: {
availableModels: NomadOllamaModel[]
installedModels: ModelResponse[]
settings: { chatSuggestionsEnabled: boolean }
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string }
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const { addNotification } = useNotifications()
const { openModal, closeAllModals } = useModals()
@ -34,6 +35,9 @@ export default function ModelsPage(props: {
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
props.models.settings.chatSuggestionsEnabled
)
const [aiAssistantCustomName, setAiAssistantCustomName] = useState(
props.models.settings.aiAssistantCustomName
)
const [query, setQuery] = useState('')
const [queryUI, setQueryUI] = useState('')
@ -123,7 +127,7 @@ export default function ModelsPage(props: {
}
const updateSettingMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
mutationFn: async ({ key, value }: { key: string; value: boolean | string }) => {
return await api.updateSetting(key, value)
},
onSuccess: () => {
@ -143,18 +147,18 @@ export default function ModelsPage(props: {
return (
<SettingsLayout>
<Head title="AI Assistant Settings | Project N.O.M.A.D." />
<Head title={`${aiAssistantName} Settings | Project N.O.M.A.D.`} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">AI Assistant</h1>
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
<p className="text-gray-500 mb-4">
Easily manage the AI Assistant's settings and installed models. We recommend starting
with smaller models first to see how they perform on your system before moving on to
larger ones.
Easily manage the {aiAssistantName}'s settings and installed models. We recommend
starting with smaller models first to see how they perform on your system before moving
on to larger ones.
</p>
{!isInstalled && (
<Alert
title="AI Assistant's dependencies are not installed. Please install them to manage AI models."
title={`${aiAssistantName}'s dependencies are not installed. Please install them to manage AI models.`}
type="warning"
variant="solid"
className="!mt-6"
@ -173,6 +177,20 @@ export default function ModelsPage(props: {
label="Chat Suggestions"
description="Display AI-generated conversation starters in the chat interface"
/>
<Input
name="aiAssistantCustomName"
label="Assistant Name"
helpText='Give your AI assistant a custom name that will be used in the chat interface and other areas of the application.'
placeholder="AI Assistant"
value={aiAssistantCustomName}
onChange={(e) => setAiAssistantCustomName(e.target.value)}
onBlur={() =>
updateSettingMutation.mutate({
key: 'ai.assistantCustomName',
value: aiAssistantCustomName,
})
}
/>
</div>
</div>
<ActiveModelDownloads withHeader />

View File

@ -6,6 +6,7 @@ export const KV_STORE_SCHEMA = {
'system.latestVersion': 'string',
'system.earlyAccess': 'boolean',
'ui.hasVisitedEasySetup': 'boolean',
'ai.assistantCustomName': 'string',
} as const
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string