Compare commits

...

4 Commits

Author SHA1 Message Date
07f8a60b69 fix: Ollama base URL not read from per-purpose config keys in Docker + i18n for all locales
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
The admin form saves Ollama URLs as OLLAMA_BASE_URL_TAGS/EMBEDDING/CHAT,
but the factory only read OLLAMA_BASE_URL — causing 500 errors in Docker
where no localhost fallback exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:26:45 +02:00
a482aaaad6 fix: AI assistant in note-input still searches other notes when scoped to "this note"
When chatScope is "this note", the AI had access to note_search and note_read
tools which let it pull content from any note. Now these tools are excluded via
a webOnly flag in buildToolsForChat — only web_search/web_scrape remain if
the user toggled web search.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:10:20 +02:00
cd6819b905 fix: chat "this note" context searches all notes + Ollama model selector missing search
- When chat scope is "this note" (noteContext present), skip RAG/semantic
  search entirely. Previously the AI received all user notes as context
  even when scoped to a single note, causing irrelevant responses.
- Replace 3 native <select> elements for Ollama models with searchable
  Combobox component (tags, embeddings, chat providers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:04:34 +02:00
43f73a2ec7 fix: auto-generated title not visible in tabs view after note creation
The sync effect in notes-tabs-view was always preferring local.title over
fresh.title from the DB. When a note was created with an AI-generated title,
triggerRefresh() fetched the note with its title from DB, but the merge
overwrote it with the empty local title. Now uses local.title || fresh.title
so fresh titles show through when local is empty.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:47:41 +02:00
19 changed files with 15995 additions and 15566 deletions

View File

@@ -446,27 +446,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label> <Label>{t('admin.ai.model')}</Label>
<select <input type="hidden" name="AI_MODEL_TAGS_OLLAMA" value={selectedTagsModel} />
id="AI_MODEL_TAGS_OLLAMA" <Combobox
name="AI_MODEL_TAGS_OLLAMA" options={ollamaTagsModels.length > 0
? ollamaTagsModels.map((m) => ({ value: m, label: m }))
: selectedTagsModel
? [{ value: selectedTagsModel, label: `${selectedTagsModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedTagsModel} value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)} onChange={setSelectedTagsModel}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')}
> searchPlaceholder={t('admin.ai.searchModel')}
{ollamaTagsModels.length > 0 ? ( emptyMessage={t('admin.ai.noModels')}
<> />
{!ollamaTagsModels.includes(selectedTagsModel) && selectedTagsModel && (
<option value={selectedTagsModel}>{selectedTagsModel} ({t('admin.ai.configured')})</option>
)}
{ollamaTagsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</>
) : (
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')} {isLoadingTagsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
</p> </p>
@@ -620,27 +614,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label> <Label>{t('admin.ai.model')}</Label>
<select <input type="hidden" name="AI_MODEL_EMBEDDING_OLLAMA" value={selectedEmbeddingModel} />
id="AI_MODEL_EMBEDDING_OLLAMA" <Combobox
name="AI_MODEL_EMBEDDING_OLLAMA" options={ollamaEmbeddingsModels.length > 0
? ollamaEmbeddingsModels.map((m) => ({ value: m, label: m }))
: selectedEmbeddingModel
? [{ value: selectedEmbeddingModel, label: `${selectedEmbeddingModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedEmbeddingModel} value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)} onChange={setSelectedEmbeddingModel}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')}
> searchPlaceholder={t('admin.ai.searchModel')}
{ollamaEmbeddingsModels.length > 0 ? ( emptyMessage={t('admin.ai.noModels')}
<> />
{!ollamaEmbeddingsModels.includes(selectedEmbeddingModel) && selectedEmbeddingModel && (
<option value={selectedEmbeddingModel}>{selectedEmbeddingModel} ({t('admin.ai.configured')})</option>
)}
{ollamaEmbeddingsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</>
) : (
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectEmbeddingModel')} {isLoadingEmbeddingsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectEmbeddingModel')}
</p> </p>
@@ -790,27 +778,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label> <Label>{t('admin.ai.model')}</Label>
<select <input type="hidden" name="AI_MODEL_CHAT_OLLAMA" value={selectedChatModel} />
id="AI_MODEL_CHAT_OLLAMA" <Combobox
name="AI_MODEL_CHAT_OLLAMA" options={ollamaChatModels.length > 0
? ollamaChatModels.map((m) => ({ value: m, label: m }))
: selectedChatModel
? [{ value: selectedChatModel, label: `${selectedChatModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedChatModel} value={selectedChatModel}
onChange={(e) => setSelectedChatModel(e.target.value)} onChange={setSelectedChatModel}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')}
> searchPlaceholder={t('admin.ai.searchModel')}
{ollamaChatModels.length > 0 ? ( emptyMessage={t('admin.ai.noModels')}
<> />
{!ollamaChatModels.includes(selectedChatModel) && selectedChatModel && (
<option value={selectedChatModel}>{selectedChatModel} ({t('admin.ai.configured')})</option>
)}
{ollamaChatModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</>
) : (
<option value={selectedChatModel || 'granite4:latest'}>{selectedChatModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{isLoadingChatModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')} {isLoadingChatModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
</p> </p>

View File

@@ -99,40 +99,46 @@ export async function POST(req: Request) {
// This ensures the AI always has access to the notebook content, // This ensures the AI always has access to the notebook content,
// even for vague queries like "what's in this notebook?" // even for vague queries like "what's in this notebook?"
let notebookContext = '' let notebookContext = ''
if (notebookId) { let searchNotes = ''
const notebookNotes = await prisma.note.findMany({
where: { // When scope is "this note" (noteContext present), skip RAG retrieval entirely
notebookId, // The note content is already injected as copilotContext below
userId, if (!noteContext) {
trashedAt: null, if (notebookId) {
}, const notebookNotes = await prisma.note.findMany({
orderBy: { updatedAt: 'desc' }, where: {
take: 20, notebookId,
select: { id: true, title: true, content: true, updatedAt: true }, userId,
}) trashedAt: null,
if (notebookNotes.length > 0) { },
notebookContext = notebookNotes orderBy: { updatedAt: 'desc' },
.map(n => `NOTE [${n.title || untitledText}] (updated ${n.updatedAt.toLocaleDateString()}):\n${(n.content || '').substring(0, 1500)}`) take: 20,
.join('\n\n---\n\n') select: { id: true, title: true, content: true, updatedAt: true },
})
if (notebookNotes.length > 0) {
notebookContext = notebookNotes
.map(n => `NOTE [${n.title || untitledText}] (updated ${n.updatedAt.toLocaleDateString()}):\n${(n.content || '').substring(0, 1500)}`)
.join('\n\n---\n\n')
}
} }
}
// Also run semantic search for the specific query // Also run semantic search for the specific query
let searchResults: any[] = [] let searchResults: any[] = []
try { try {
searchResults = await semanticSearchService.search(currentMessage, { searchResults = await semanticSearchService.search(currentMessage, {
notebookId, notebookId,
limit: notebookId ? 10 : 5, limit: notebookId ? 10 : 5,
threshold: notebookId ? 0.3 : 0.5, threshold: notebookId ? 0.3 : 0.5,
defaultTitle: untitledText, defaultTitle: untitledText,
}) })
} catch { } catch {
// Search failure should not block chat // Search failure should not block chat
} }
const searchNotes = searchResults searchNotes = searchResults
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`) .map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
.join('\n\n---\n\n') .join('\n\n---\n\n')
}
// Combine: full notebook context + semantic search results (deduplicated) // Combine: full notebook context + semantic search results (deduplicated)
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n') const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
@@ -354,7 +360,7 @@ ${noteContext.content || '(empty)'}
${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''} ${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''}
The user wants you to write in a **${noteContext.tone || 'professional'}** tone. The user wants you to write in a **${noteContext.tone || 'professional'}** tone.
Keep your suggestions tailored to this note and tone. You can suggest rewrites, answer questions about the note, or draft new sections.` IMPORTANT: Focus ONLY on this note. Do NOT reference other notes or external information unless the user explicitly asks. Your job is to help with this specific note — suggest rewrites, answer questions about it, or draft new sections.`
} }
const systemPrompt = `${prompts.system} const systemPrompt = `${prompts.system}
@@ -413,7 +419,11 @@ Never switch to another language. Even if the user writes in a different languag
webSearch: !!webSearch, webSearch: !!webSearch,
config, config,
} }
const chatTools = toolRegistry.buildToolsForChat(chatToolContext) // When scoped to "this note", only provide web tools — no note_search/note_read
// to prevent the AI from pulling information from other notes
const chatTools = noteContext
? toolRegistry.buildToolsForChat({ ...chatToolContext, webOnly: true })
: toolRegistry.buildToolsForChat(chatToolContext)
// 8. Save user message to DB before streaming // 8. Save user message to DB before streaming
if (isNewMessage && lastIncoming) { if (isNewMessage && lastIncoming) {

View File

@@ -613,31 +613,27 @@ export function NotesTabsView({
setItems((prev) => { setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',') const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',') const incomingIds = notes.map((n) => n.id).join(',')
const merge = (fresh: Note, local: Note) => {
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
return {
...fresh,
title: local.title || fresh.title,
content: local.content,
checkItems: local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels
}
}
if (prevIds === incomingIds) { if (prevIds === incomingIds) {
return prev.map((p) => { return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id) const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p if (!fresh) return p
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort()) return merge(fresh, p)
return {
...fresh,
title: p.title,
content: p.content,
checkItems: p.checkItems,
labels: labelsChanged ? fresh.labels : p.labels
}
}) })
} }
return notes.map((fresh) => { return notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id) const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh if (!local) return fresh
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort()) return merge(fresh, local)
return {
...fresh,
title: local.title,
content: local.content,
checkItems: local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels
}
}) })
}) })
}, [notes]) }, [notes])

View File

@@ -5,8 +5,8 @@ import { AIProvider } from './types';
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter'; type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter';
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): OllamaProvider { function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string, baseUrlOverride?: string): OllamaProvider {
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL let baseUrl = baseUrlOverride || config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
// Only use localhost as fallback for local development (not in Docker) // Only use localhost as fallback for local development (not in Docker)
if (!baseUrl && process.env.NODE_ENV !== 'production') { if (!baseUrl && process.env.NODE_ENV !== 'production') {
@@ -62,10 +62,10 @@ function createOpenRouterProvider(config: Record<string, string>, modelName: str
return new CustomOpenAIProvider(apiKey, 'https://openrouter.ai/api/v1', modelName, embeddingModelName); return new CustomOpenAIProvider(apiKey, 'https://openrouter.ai/api/v1', modelName, embeddingModelName);
} }
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string): AIProvider { function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
switch (providerType) { switch (providerType) {
case 'ollama': case 'ollama':
return createOllamaProvider(config, modelName, embeddingModelName); return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
case 'openai': case 'openai':
return createOpenAIProvider(config, modelName, embeddingModelName); return createOpenAIProvider(config, modelName, embeddingModelName);
case 'custom': case 'custom':
@@ -75,7 +75,7 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
case 'openrouter': case 'openrouter':
return createOpenRouterProvider(config, modelName, embeddingModelName); return createOpenRouterProvider(config, modelName, embeddingModelName);
default: default:
return createOllamaProvider(config, modelName, embeddingModelName); return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
} }
} }
@@ -102,8 +102,9 @@ export function getTagsProvider(config?: Record<string, string>): AIProvider {
const provider = providerType.toLowerCase() as ProviderType; const provider = providerType.toLowerCase() as ProviderType;
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest'; const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'; const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName); return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
} }
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider { export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
@@ -129,8 +130,9 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
const provider = providerType.toLowerCase() as ProviderType; const provider = providerType.toLowerCase() as ProviderType;
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest'; const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'; const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName); return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
} }
export function getAIProvider(config?: Record<string, string>): AIProvider { export function getAIProvider(config?: Record<string, string>): AIProvider {
@@ -167,6 +169,7 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
'granite4:latest' 'granite4:latest'
); );
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'; const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_CHAT || config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName); return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
} }

View File

@@ -49,9 +49,10 @@ class ToolRegistry {
/** /**
* Build tools for the chat endpoint. * Build tools for the chat endpoint.
* Includes internal tools (note_search, note_read) and web tools when configured. * Includes internal tools (note_search, note_read) and web tools when configured.
* When webOnly is true, only web tools are included (no note access).
*/ */
buildToolsForChat(ctx: ToolContext): Record<string, any> { buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
const toolNames: string[] = ['note_search', 'note_read'] const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read']
// Add web tools only when user toggled web search AND config is present // Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) { if (ctx.webSearch) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff