Compare commits
4 Commits
d91072ed6b
...
07f8a60b69
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f8a60b69 | |||
| a482aaaad6 | |||
| cd6819b905 | |||
| 43f73a2ec7 |
@@ -446,27 +446,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OLLAMA"
|
||||
name="AI_MODEL_TAGS_OLLAMA"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_TAGS_OLLAMA" value={selectedTagsModel} />
|
||||
<Combobox
|
||||
options={ollamaTagsModels.length > 0
|
||||
? ollamaTagsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedTagsModel
|
||||
? [{ value: selectedTagsModel, label: `${selectedTagsModel} (${t('admin.ai.saved')})` }]
|
||||
: []
|
||||
}
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{ollamaTagsModels.length > 0 ? (
|
||||
<>
|
||||
{!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>
|
||||
onChange={setSelectedTagsModel}
|
||||
placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')}
|
||||
searchPlaceholder={t('admin.ai.searchModel')}
|
||||
emptyMessage={t('admin.ai.noModels')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingTagsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
@@ -620,27 +614,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
name="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_EMBEDDING_OLLAMA" value={selectedEmbeddingModel} />
|
||||
<Combobox
|
||||
options={ollamaEmbeddingsModels.length > 0
|
||||
? ollamaEmbeddingsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedEmbeddingModel
|
||||
? [{ value: selectedEmbeddingModel, label: `${selectedEmbeddingModel} (${t('admin.ai.saved')})` }]
|
||||
: []
|
||||
}
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{ollamaEmbeddingsModels.length > 0 ? (
|
||||
<>
|
||||
{!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>
|
||||
onChange={setSelectedEmbeddingModel}
|
||||
placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')}
|
||||
searchPlaceholder={t('admin.ai.searchModel')}
|
||||
emptyMessage={t('admin.ai.noModels')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingEmbeddingsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectEmbeddingModel')}
|
||||
</p>
|
||||
@@ -790,27 +778,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OLLAMA"
|
||||
name="AI_MODEL_CHAT_OLLAMA"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_CHAT_OLLAMA" value={selectedChatModel} />
|
||||
<Combobox
|
||||
options={ollamaChatModels.length > 0
|
||||
? ollamaChatModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedChatModel
|
||||
? [{ value: selectedChatModel, label: `${selectedChatModel} (${t('admin.ai.saved')})` }]
|
||||
: []
|
||||
}
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{ollamaChatModels.length > 0 ? (
|
||||
<>
|
||||
{!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>
|
||||
onChange={setSelectedChatModel}
|
||||
placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')}
|
||||
searchPlaceholder={t('admin.ai.searchModel')}
|
||||
emptyMessage={t('admin.ai.noModels')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingChatModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
|
||||
@@ -99,40 +99,46 @@ export async function POST(req: Request) {
|
||||
// This ensures the AI always has access to the notebook content,
|
||||
// even for vague queries like "what's in this notebook?"
|
||||
let notebookContext = ''
|
||||
if (notebookId) {
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
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')
|
||||
let searchNotes = ''
|
||||
|
||||
// When scope is "this note" (noteContext present), skip RAG retrieval entirely
|
||||
// The note content is already injected as copilotContext below
|
||||
if (!noteContext) {
|
||||
if (notebookId) {
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
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
|
||||
let searchResults: any[] = []
|
||||
try {
|
||||
searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
} catch {
|
||||
// Search failure should not block chat
|
||||
}
|
||||
// Also run semantic search for the specific query
|
||||
let searchResults: any[] = []
|
||||
try {
|
||||
searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
} catch {
|
||||
// Search failure should not block chat
|
||||
}
|
||||
|
||||
const searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
// Combine: full notebook context + semantic search results (deduplicated)
|
||||
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.` : ''}
|
||||
|
||||
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}
|
||||
@@ -413,7 +419,11 @@ Never switch to another language. Even if the user writes in a different languag
|
||||
webSearch: !!webSearch,
|
||||
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
|
||||
if (isNewMessage && lastIncoming) {
|
||||
|
||||
@@ -613,31 +613,27 @@ export function NotesTabsView({
|
||||
setItems((prev) => {
|
||||
const prevIds = prev.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) {
|
||||
return prev.map((p) => {
|
||||
const fresh = notes.find((n) => n.id === p.id)
|
||||
if (!fresh) return p
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
checkItems: p.checkItems,
|
||||
labels: labelsChanged ? fresh.labels : p.labels
|
||||
}
|
||||
return merge(fresh, p)
|
||||
})
|
||||
}
|
||||
return notes.map((fresh) => {
|
||||
const local = prev.find((p) => p.id === fresh.id)
|
||||
if (!local) return fresh
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: local.title,
|
||||
content: local.content,
|
||||
checkItems: local.checkItems,
|
||||
labels: labelsChanged ? fresh.labels : local.labels
|
||||
}
|
||||
return merge(fresh, local)
|
||||
})
|
||||
})
|
||||
}, [notes])
|
||||
|
||||
@@ -5,8 +5,8 @@ import { AIProvider } from './types';
|
||||
|
||||
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter';
|
||||
|
||||
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): OllamaProvider {
|
||||
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
|
||||
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string, baseUrlOverride?: string): OllamaProvider {
|
||||
let baseUrl = baseUrlOverride || config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
|
||||
|
||||
// Only use localhost as fallback for local development (not in Docker)
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
case 'ollama':
|
||||
return createOllamaProvider(config, modelName, embeddingModelName);
|
||||
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
case 'openai':
|
||||
return createOpenAIProvider(config, modelName, embeddingModelName);
|
||||
case 'custom':
|
||||
@@ -75,7 +75,7 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
|
||||
case 'openrouter':
|
||||
return createOpenRouterProvider(config, modelName, embeddingModelName);
|
||||
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 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 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 {
|
||||
@@ -129,8 +130,9 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
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 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 {
|
||||
@@ -167,6 +169,7 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
'granite4: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);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ class ToolRegistry {
|
||||
/**
|
||||
* Build tools for the chat endpoint.
|
||||
* 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> {
|
||||
const toolNames: string[] = ['note_search', 'note_read']
|
||||
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read']
|
||||
|
||||
// Add web tools only when user toggled web search AND config is present
|
||||
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
Reference in New Issue
Block a user