AI Chat inside Trilium - A No-Code Approach #9602
Replies: 4 comments 2 replies
-
|
I've updated the code so the save button is now functional. Chats are now saved as child notes under the selected context note. |
Beta Was this translation helpful? Give feedback.
-
|
@ricolandia , pretty cool and I'm glad to see this kind of work be done. |
Beta Was this translation helpful? Give feedback.
-
|
@ricolandia — really nice work on the in-app chat. I built a complementary piece for the share-page side: a floating chat widget that drops into any TriliumNext share via https://github.com/mrbeandev/trilium-ai-agent Different problem space yours adds AI to the editing experience, mine adds it to the public docs experience but anyone enjoying yours will probably want to try mine too. |
Beta Was this translation helpful? Give feedback.
-
|
@eliandoran and @mrbeandev I've updated the code, and now we have a command bar for : summary, mermaid, insights and slides( only text ) as child notes from the context note. @eliandoran I am using this solution until the "experimental chat interface built-in to Trilium" pops in the next release.
The code:const $c = $container;
$c.html(`
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.chat-wrap {
display: flex; flex-direction: column; height: 100%;
padding: 14px; gap: 10px;
font-family: var(--font-family, sans-serif);
color: var(--main-text-color);
}
.chat-ctx {
display: flex; align-items: center; gap: 6px;
padding: 10px 14px;
background: var(--accented-background-color);
border: 1px solid var(--main-border-color);
border-radius: 6px; font-size: 15px;
color: var(--muted-text-color);
}
.chat-ctx input {
flex: 1; border: none; background: transparent;
color: var(--main-text-color); font-size: 15px; outline: none;
}
.chat-ctx button {
padding: 2px 8px; font-size: 15px; cursor: pointer;
background: var(--button-background-color);
border: 1px solid var(--main-border-color);
border-radius: 4px; color: var(--main-text-color);
}
.chat-ctx button:hover { filter: brightness(1.1); }
.chat-label { font-size: 15px; white-space: nowrap; opacity: 0.7; }
.chat-ctx-title {
font-size: 15px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 200px;
}
/* ── Barra de comandos rápidos ── */
.chat-cmds {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 8px 12px;
background: var(--accented-background-color);
border: 1px solid var(--main-border-color);
border-radius: 6px;
}
.btn-cmd {
padding: 4px 11px; cursor: pointer; font-size: 13.5px;
background: var(--button-background-color);
border: 1px solid var(--main-border-color);
border-radius: 4px; color: var(--main-text-color);
white-space: nowrap; transition: filter 0.1s;
}
.btn-cmd:hover:not(:disabled) { filter: brightness(1.15); }
.btn-cmd:disabled { opacity: 0.45; cursor: not-allowed; }
.chat-messages {
flex: 1; overflow-y: auto; min-height: 180px;
border: 1px solid var(--main-border-color);
border-radius: 6px; padding: 10px;
display: flex; flex-direction: column; gap: 10px;
}
.msg { display: flex; flex-direction: column; gap: 3px; }
.msg-label { font-size: 15px; font-weight: 700; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.04em; }
.msg-body { font-size: 15px; line-height: 1.55; white-space: pre-wrap; }
.msg-user .msg-label { color: var(--main-color, #448); }
.msg-ai .msg-label { color: var(--muted-text-color); }
.msg-ai .msg-body {
padding: 8px 10px;
background: var(--accented-background-color);
border-left: 3px solid var(--main-color, #448);
border-radius: 0 4px 4px 0;
}
.msg-error .msg-body { color: #c0392b; font-style: italic; }
.msg-system .msg-body { font-size: 15px; text-align: center; opacity: 0.45; font-style: italic; }
.chat-footer { display: flex; gap: 8px; align-items: flex-end; }
.chat-footer textarea {
flex: 1; padding: 8px 10px; font-size: 15px; resize: none;
border: 1px solid var(--main-border-color); border-radius: 6px;
background: var(--accented-background-color);
color: var(--main-text-color);
font-family: inherit; line-height: 1.4;
}
.chat-footer textarea:focus { outline: none; border-color: var(--main-color, #448); }
.chat-actions { display: flex; flex-direction: column; gap: 5px; }
.btn-send {
padding: 7px 14px; cursor: pointer; border: none;
border-radius: 5px; font-size: 15px; font-weight: 600; white-space: nowrap;
background: var(--main-color, #4477aa); color: #fff;
}
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-send:not(:disabled):hover { filter: brightness(1.1); }
.btn-save {
padding: 7px 14px; cursor: pointer;
border-radius: 5px; font-size: 15px; font-weight: 600; white-space: nowrap;
background: var(--button-background-color);
border: 1px solid var(--main-border-color);
color: var(--main-text-color);
}
.btn-save:hover { filter: brightness(1.1); }
.btn-clear {
background: none; border: none; cursor: pointer;
font-size: 15px; color: var(--muted-text-color);
padding: 2px 4px; align-self: flex-end;
}
.typing { display: none; font-size: 15px; color: var(--muted-text-color); font-style: italic; }
.typing.visible { display: block; }
</style>
<div class="chat-wrap">
<div class="chat-ctx">
<span class="chat-label">Contexto:</span>
<span class="chat-ctx-title" id="ctx-title">nenhum</span>
<input id="ctx-id-input" placeholder="ID da nota..." />
<button id="btn-load">Carregar</button>
<button id="btn-active">Nota ativa</button>
</div>
<div class="chat-cmds">
<span class="chat-label">Gerar:</span>
<button class="btn-cmd" id="btn-cmd-resumo">📄 Resumo</button>
<button class="btn-cmd" id="btn-cmd-mermaid">🔀 Mermaid</button>
<button class="btn-cmd" id="btn-cmd-insights">💡 Insights</button>
<button class="btn-cmd" id="btn-cmd-slides">🖥️ Slides</button>
</div>
<div class="chat-messages" id="messages">
<div class="msg msg-system"><div class="msg-body">Carregue uma nota como contexto e faça sua pergunta — ou use os botões acima para gerar notas filhas.</div></div>
</div>
<span class="typing" id="typing">IA digitando...</span>
<div class="chat-footer">
<textarea id="user-input" rows="2" placeholder="Digite sua pergunta... (Ctrl+Enter para enviar)"></textarea>
<div class="chat-actions">
<button class="btn-send" id="btn-send">Enviar</button>
<button class="btn-save" id="btn-save">Salvar nota</button>
<button class="btn-clear" id="btn-clear">Limpar</button>
</div>
</div>
</div>
`);
// ── Estado ──────────────────────────────────────────────────────────────────
let history = [];
let ctxNoteId = null;
// ── Definição dos comandos rápidos ──────────────────────────────────────────
const COMMANDS = [
{
id: 'resumo',
label: '📄 Resumo',
childTitle: (t) => 'Resumo — ' + t,
prompt: `Crie um resumo completo desta nota preservando:
- O tema central e a linha argumentativa
- Todos os links e URLs mencionados (mantenha-os clicáveis como <a href="...">)
- A bibliografia e referências completas
Formate a resposta em HTML limpo usando <h2>, <p> e <ul> onde adequado.
Não inclua comentários introdutórios — comece direto pelo conteúdo.`,
noteType: 'text',
mime: null,
// pós-processamento: nenhum
process: (s) => s
},
{
id: 'mermaid',
label: '🔀 Mermaid',
childTitle: (t) => 'Fluxo — ' + t,
prompt: `Crie um diagrama Mermaid (flowchart LR, mindmap ou sequenceDiagram conforme o mais adequado) representando os conceitos e relações principais desta nota.
Retorne APENAS o código Mermaid puro, sem blocos de markdown (sem \`\`\`), sem explicações, sem texto adicional.`,
noteType: 'code',
mime: 'text/x-mermaid',
// Remove fences caso o modelo adicione mesmo com instrução contrária
process: (s) => s.replace(/^```(?:mermaid)?\r?\n?/i, '').replace(/\r?\n?```$/i, '').trim()
},
{
id: 'insights',
label: '💡 Insights',
childTitle: (t) => 'Insights — ' + t,
prompt: `A partir desta nota, gere:
1. Insights-chave e padrões não óbvios
2. Conexões com outros campos do conhecimento
3. Perguntas abertas que o conteúdo levanta
4. Possíveis pontos cegos ou limitações do argumento
Formate em HTML com <h3> para cada seção e <ul>/<li> para os itens.
Seja analítico e crítico, não apenas descritivo.`,
noteType: 'text',
mime: null,
process: (s) => s
},
{
id: 'slides',
label: '🖥️ Slides',
childTitle: (t) => 'Slides — ' + t,
prompt: `Crie o conteúdo textual para uma apresentação de slides a partir desta nota.
Para cada slide use exatamente este formato HTML:
<section>
<h2>Título do Slide</h2>
<ul>
<li>Ponto principal 1</li>
<li>Ponto principal 2</li>
</ul>
<p><em>Nota do apresentador (opcional)</em></p>
</section>
Gere entre 6 e 10 slides, incluindo: slide de título, desenvolvimento e slide de conclusão.
Apenas texto — sem imagens, sem código, sem comentários fora do HTML.`,
noteType: 'text',
mime: null,
process: (s) => s
}
];
// ── Helpers de UI ────────────────────────────────────────────────────────────
function addMsg(role, text) {
const $msgs = $c.find('#messages');
const labels = { user: 'Você', ai: 'IA', error: 'Erro', system: '' };
const cls = { user: 'msg-user', ai: 'msg-ai', error: 'msg-error', system: 'msg-system' };
const div = $('<div>').addClass('msg ' + (cls[role] || ''));
if (labels[role]) div.append($('<div>').addClass('msg-label').text(labels[role]));
div.append($('<div>').addClass('msg-body').text(text));
$msgs.append(div);
$msgs.scrollTop($msgs[0].scrollHeight);
}
function setCtx(id, title) {
ctxNoteId = id;
$c.find('#ctx-title').text(title || id);
$c.find('#ctx-id-input').val(id);
history = [];
$c.find('#messages').empty();
addMsg('system', 'Contexto carregado: "' + (title || id) + '"');
}
function setLoading(on) {
$c.find('#btn-send').prop('disabled', on).text(on ? '...' : 'Enviar');
$c.find('#typing').toggleClass('visible', on);
}
// ── Config & nota ────────────────────────────────────────────────────────────
async function loadConfig() {
const notes = await api.searchForNotes('note.title = "AI Chat - Config"');
if (!notes.length) throw new Error('Nota "AI Chat - Config" não encontrada.');
const content = await notes[0].getContent();
const keyMatch = content.match(/openrouter_key:\s*(\S+)/);
const modelMatch = content.match(/model:\s*(\S+)/);
if (!keyMatch) throw new Error('Campo openrouter_key não encontrado na nota de config.');
return {
key: keyMatch[1],
model: modelMatch ? modelMatch[1] : 'anthropic/claude-3.5-sonnet'
};
}
async function loadNote(noteId) {
if (!noteId) { alert('Informe um ID de nota.'); return; }
const note = await api.getNote(noteId);
if (note) setCtx(note.noteId, note.title);
else alert('Nota não encontrada: ' + noteId);
}
// ── Chat principal ───────────────────────────────────────────────────────────
async function send() {
const input = $c.find('#user-input');
const text = input.val().trim();
if (!text) return;
input.val('');
addMsg('user', text);
history.push({ role: 'user', content: text });
setLoading(true);
try {
const cfg = await loadConfig();
let system = 'Você é um assistente de conhecimento pessoal integrado ao Trilium Notes. Seja claro e conciso.';
if (ctxNoteId) {
const note = await api.getNote(ctxNoteId);
if (note) {
const raw = await note.getContent();
const plain = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5000);
system += '\n\nContexto — nota "' + note.title + '":\n' + plain;
}
}
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + cfg.key,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://trilium.local',
'X-Title': 'Trilium AI Chat'
},
body: JSON.stringify({
model: cfg.model,
messages: [{ role: 'system', content: system }].concat(history)
})
});
const data = await res.json();
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
const reply = data.choices[0].message.content;
addMsg('ai', reply);
history.push({ role: 'assistant', content: reply });
} catch (e) {
addMsg('error', e.message);
history.pop();
}
setLoading(false);
}
// ── Salvar conversa ──────────────────────────────────────────────────────────
async function saveNote() {
if (!history.length) { alert('Nenhuma conversa para salvar.'); return; }
if (!ctxNoteId) { alert('Carregue uma nota de contexto antes de salvar.'); return; }
const html = history.map(function(m) {
const who = m.role === 'user' ? '<strong>Você</strong>' : '<strong>IA</strong>';
return '<p>' + who + ': ' + m.content.replace(/\n/g, '<br>') + '</p>';
}).join('<hr>');
const now = new Date().toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
await api.runOnBackend((parentNoteId, title, content) => {
api.createNewNote({ parentNoteId, title, content, type: 'text' });
}, [ctxNoteId, 'Chat IA — ' + now, html]);
addMsg('system', 'Conversa salva como nota filha.');
}
// ── Comandos rápidos ─────────────────────────────────────────────────────────
async function runCommand(cmd) {
if (!ctxNoteId) {
alert('Carregue uma nota como contexto primeiro.');
return;
}
const $btn = $c.find('#btn-cmd-' + cmd.id);
const originalLabel = cmd.label;
$btn.prop('disabled', true).text('⏳ gerando...');
try {
const cfg = await loadConfig();
const note = await api.getNote(ctxNoteId);
if (!note) throw new Error('Nota de contexto não encontrada.');
const raw = await note.getContent();
const plain = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 6000);
const system = 'Você é um assistente especializado em processamento de notas de conhecimento. Responda apenas com o conteúdo solicitado, sem comentários adicionais antes ou depois.';
const userMsg = cmd.prompt + '\n\n--- CONTEÚDO DA NOTA "' + note.title + '" ---\n' + plain;
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + cfg.key,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://trilium.local',
'X-Title': 'Trilium AI Chat'
},
body: JSON.stringify({
model: cfg.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMsg }
]
})
});
const data = await res.json();
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
const rawContent = data.choices[0].message.content.trim();
const content = cmd.process(rawContent);
const childTitle = cmd.childTitle(note.title);
await api.runOnBackend((parentNoteId, title, noteContent, type, mime) => {
api.createNewNote({
parentNoteId,
title,
content: noteContent,
type,
mime: mime || undefined
});
}, [ctxNoteId, childTitle, content, cmd.noteType, cmd.mime]);
addMsg('system', '✓ Nota criada: "' + childTitle + '"');
} catch (e) {
addMsg('error', e.message);
}
$btn.prop('disabled', false).text(originalLabel);
}
// ── Event listeners ──────────────────────────────────────────────────────────
$c.find('#btn-send').on('click', send);
$c.find('#btn-save').on('click', saveNote);
$c.find('#btn-clear').on('click', function() {
history = [];
$c.find('#messages').empty();
addMsg('system', 'Conversa limpa.');
});
$c.find('#btn-load').on('click', function() {
loadNote($c.find('#ctx-id-input').val().trim());
});
$c.find('#btn-active').on('click', async function() {
const note = api.getActiveContextNote();
if (note) await loadNote(note.noteId);
else alert('Nenhuma nota ativa encontrada.');
});
$c.find('#user-input').on('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') send();
});
// Registra handlers dos comandos rápidos
COMMANDS.forEach(function(cmd) {
$c.find('#btn-cmd-' + cmd.id).on('click', function() { runCommand(cmd); });
}); |
Beta Was this translation helpful? Give feedback.


Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hey everyone! I wanted to share a small integration I put together that brings AI chat directly into Trilium Notes, without any external app or browser extension.
Note 1: I'm not a developer; just a regular user who enjoys tinkering and sharing what works. Take the code with that in mind, and feel free to improve on it.
Note 2: I used OpenRouter as the API provider since it gives access to many models under a single key, but the integration should work with any OpenAI-compatible API — just swap the endpoint URL and key in the config note.
What it does
The whole thing runs inside Trilium itself — no server, no extra container, no external UI.
How it works
It uses two native Trilium features:
$containerand calls the OpenRouter API directly viafetch(since the script runs in the browser context, no backend bridge is needed)~renderNoterelationConfiguration (API key + model) lives in a separate Code / Plain Text note named
AI Chat - Config— regular users never need to touch it.Setup (quick version)
1. Config note — type: Code / Plain Text, name:
AI Chat - Config2. Render note — type: Render
3. Script note — type: JS Frontend — paste the full script (see below)
4. Link them — on the Render note, add relation
~renderNotepointing to the script noteThe script
Click to expand — full JS Frontend script
A few things I learned along the way
In case it saves someone some debugging time:
api.$widgetdoes not exist in JS Frontend scripts loaded via~renderNote. Use$containerinstead.api.runOnBackendis also not available in this context. Since the script runs in the browser,fetchworks natively — no backend bridge needed at all.api.runOnBackenddoes not acceptasyncfunctions in recent TriliumNext versions anyway. For anything involving HTTP calls you needapi.runAsyncOnBackendWithManualTransactionHandling, but since we don't need the backend here, it's a moot point.api.getActiveContextNote()may not return the expected note when the script runs inside a Render Note. Since the active context is the Render Note itself, not the note you were reading before opening it. If anyone knows a clean way to get the previously focused note from inside a~renderNotescript, I'd love to know.Possible next steps
A few things I haven't done yet that could be interesting:
Happy to hear if anyone extends this or runs into issues on a different setup.
Screenshots
Tutorial - AI Chat in Trilium Next
Integrate AI models via OpenRouter directly inside Trilium Notes, using a Render Note + JS Frontend script.
Requirements
Notes to create
Step 1 — Config note
AI Chat - ConfigStep 2 — Render note
Render - AI(or anything you prefer)Step 3 — JS script
AI Code(or anything you prefer)Step 4 — Link the script to the Render note
#/@icon at the top), click Add relationrenderNoteAI CodenoteThe chat interface will appear automatically inside the Render note.
How to use
Switching models
Edit the
AI Chat - Confignote and change themodelfield. Any model available on OpenRouter works. Examples:The full list is at openrouter.ai/models.
Organization tip
Keep all three notes (
Config,Render - AI, andAI Code) inside a dedicated folder, such as⚙️ Tools. The only note regular users need to open day-to-day is Render - AI — the other two are infrastructure and can stay out of the main tree.Beta Was this translation helpful? Give feedback.
All reactions