mirror of
https://github.com/zadam/trilium.git
synced 2026-06-27 22:07:49 +02:00
Improved tools & MC (#9256)
This commit is contained in:
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
@@ -186,6 +186,14 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
|
||||
|
||||
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
|
||||
|
||||
### Adding New LLM Tools
|
||||
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
|
||||
|
||||
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
|
||||
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
|
||||
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
|
||||
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
|
||||
|
||||
### Database Migrations
|
||||
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
@@ -213,6 +221,12 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
|
||||
|
||||
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
|
||||
|
||||
## MCP Server
|
||||
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
|
||||
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
|
||||
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
|
||||
- Use it to interact with actual note data when developing or debugging note-related features
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
|
||||
@@ -299,6 +313,7 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Use translation system via `t()` function
|
||||
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
|
||||
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"trilium": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -120,6 +120,7 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
|
||||
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
|
||||
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
@@ -151,6 +152,15 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
### Adding New LLM Tools
|
||||
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
|
||||
|
||||
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `attachment_tools.ts`, `hierarchy_tools.ts`) or create a new module
|
||||
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations
|
||||
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
|
||||
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
|
||||
5. Use ETAPI (`apps/server/src/etapi/`) as inspiration for what fields to expose, but **do not import ETAPI mappers** — inline the field mappings directly in the tool so the LLM layer stays decoupled from the API layer
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
@@ -161,6 +171,12 @@ Trilium provides powerful user scripting capabilities:
|
||||
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
|
||||
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
|
||||
|
||||
## MCP Server
|
||||
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
|
||||
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
|
||||
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
|
||||
- Use it to interact with actual note data when developing or debugging note-related features
|
||||
|
||||
## Build System Notes
|
||||
- Uses pnpm for monorepo management
|
||||
- Vite for fast development builds
|
||||
|
||||
@@ -77,9 +77,13 @@ export async function streamChatCompletion(
|
||||
break;
|
||||
case "tool_use":
|
||||
callbacks.onToolUse?.(data.toolName, data.toolInput);
|
||||
// Yield to force Preact to commit the pending tool call
|
||||
// state before we process the result.
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
break;
|
||||
case "tool_result":
|
||||
callbacks.onToolResult?.(data.toolName, data.result, data.isError);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
break;
|
||||
case "citation":
|
||||
if (data.citation) {
|
||||
|
||||
@@ -1639,6 +1639,7 @@
|
||||
"web_search": "Web search",
|
||||
"note_tools": "Note access",
|
||||
"sources": "Sources",
|
||||
"sources_summary": "{{count}} sources from {{sites}} sites",
|
||||
"extended_thinking": "Extended thinking",
|
||||
"legacy_models": "Legacy models",
|
||||
"thinking": "Thinking...",
|
||||
@@ -1659,9 +1660,7 @@
|
||||
"note_context_enabled": "Click to disable note context: {{title}}",
|
||||
"note_context_disabled": "Click to include current note in context",
|
||||
"no_provider_message": "No AI provider configured. Add one to start chatting.",
|
||||
"add_provider": "Add AI Provider",
|
||||
"role_user": "You",
|
||||
"role_assistant": "Assistant"
|
||||
"add_provider": "Add AI Provider"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI Chat",
|
||||
@@ -2324,6 +2323,7 @@
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "Configure AI and Large Language Model integrations.",
|
||||
"feature_not_enabled": "Enable the LLM experimental feature in Settings → Advanced → Experimental features to use AI integrations.",
|
||||
"add_provider": "Add Provider",
|
||||
"add_provider_title": "Add AI Provider",
|
||||
"configured_providers": "Configured Providers",
|
||||
@@ -2335,6 +2335,30 @@
|
||||
"delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "Enter your API key",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"mcp_title": "MCP (Model Context Protocol)",
|
||||
"mcp_enabled": "MCP server",
|
||||
"mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.",
|
||||
"mcp_endpoint_title": "Endpoint URL",
|
||||
"mcp_endpoint_description": "Add this URL to your AI assistant's MCP configuration",
|
||||
"tools": {
|
||||
"search_notes": "Search notes",
|
||||
"get_note": "Get note",
|
||||
"get_note_content": "Get note content",
|
||||
"update_note_content": "Update note content",
|
||||
"append_to_note": "Append to note",
|
||||
"create_note": "Create note",
|
||||
"get_attributes": "Get attributes",
|
||||
"get_attribute": "Get attribute",
|
||||
"set_attribute": "Set attribute",
|
||||
"delete_attribute": "Delete attribute",
|
||||
"get_child_notes": "Get child notes",
|
||||
"get_subtree": "Get subtree",
|
||||
"load_skill": "Load skill",
|
||||
"web_search": "Web search",
|
||||
"note_in_parent": "<Note/> in <Parent/>",
|
||||
"get_attachment": "Get attachment",
|
||||
"get_attachment_content": "Read attachment content"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,12 +289,6 @@ export default function SidebarChat() {
|
||||
{chat.messages.map(msg => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{chat.toolActivity && !chat.streamingThinking && (
|
||||
<div className="llm-chat-tool-activity">
|
||||
<span className="llm-chat-tool-spinner" />
|
||||
{chat.toolActivity}
|
||||
</div>
|
||||
)}
|
||||
{chat.isStreaming && chat.streamingThinking && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
@@ -307,12 +301,12 @@ export default function SidebarChat() {
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
{chat.isStreaming && chat.streamingContent && (
|
||||
{chat.isStreaming && chat.streamingBlocks.length > 0 && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
role: "assistant",
|
||||
content: chat.streamingContent,
|
||||
content: chat.streamingBlocks,
|
||||
createdAt: new Date().toISOString(),
|
||||
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
|
||||
}}
|
||||
|
||||
169
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css
Normal file
169
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* Input form */
|
||||
.llm-chat-input-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-input {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-selection-color);
|
||||
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
|
||||
}
|
||||
|
||||
.llm-chat-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options row */
|
||||
.llm-chat-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn {
|
||||
margin-left: auto;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Model selector */
|
||||
.llm-chat-model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-model-selector .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-model-selector .dropdown {
|
||||
display: flex;
|
||||
|
||||
small {
|
||||
margin-left: 0.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Position legacy models submenu to open upward */
|
||||
.dropdown-submenu .dropdown-menu {
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-selection-color);
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Note context toggle */
|
||||
.llm-chat-note-context.tn-low-profile {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.5;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.llm-chat-note-context.tn-low-profile:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.llm-chat-note-context.tn-low-profile.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Context window indicator */
|
||||
.llm-chat-context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-left: 0.5rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.llm-chat-context-pie {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.llm-chat-context-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* No provider state */
|
||||
.llm-chat-no-provider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-content p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./ChatInputBar.css";
|
||||
|
||||
import type { RefObject } from "preact";
|
||||
import { useState, useCallback } from "preact/hooks";
|
||||
|
||||
|
||||
320
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css
Normal file
320
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css
Normal file
@@ -0,0 +1,320 @@
|
||||
/* Message wrapper and bubble */
|
||||
.llm-chat-message-wrapper {
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 1.25rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper-user {
|
||||
margin-left: auto;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper-assistant {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Show footer only on hover */
|
||||
.llm-chat-message-wrapper:hover .llm-chat-footer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.llm-chat-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.llm-chat-message-user {
|
||||
background: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-assistant {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-role {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-content {
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Preserve whitespace only for user messages (plain text) */
|
||||
.llm-chat-message-user .llm-chat-message-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.llm-chat-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.1em;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: llm-chat-blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes llm-chat-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.expandable-card.llm-chat-citations-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Citations table (inside an expandable card) */
|
||||
.llm-chat-citations-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list td {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list tr + tr td {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-citation-title {
|
||||
max-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.llm-chat-citation-title a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.llm-chat-citation-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.llm-chat-citation-site {
|
||||
white-space: nowrap;
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.llm-chat-error {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--danger-background-color, #fee);
|
||||
border: 1px solid var(--danger-border-color, #fcc);
|
||||
color: var(--danger-text-color, #c00);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Error message (persisted in conversation) */
|
||||
.llm-chat-message-error {
|
||||
background: var(--danger-background-color, #fee);
|
||||
border: 1px solid var(--danger-border-color, #fcc);
|
||||
color: var(--danger-text-color, #c00);
|
||||
}
|
||||
|
||||
.llm-chat-message-error .llm-chat-message-role {
|
||||
color: var(--danger-text-color, #c00);
|
||||
}
|
||||
|
||||
.llm-chat-thinking-card.expandable-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-style: dashed;
|
||||
margin-right: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-card .expandable-section-summary {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-thinking-content {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Markdown styles */
|
||||
.llm-chat-markdown {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1,
|
||||
.llm-chat-markdown h2,
|
||||
.llm-chat-markdown h3,
|
||||
.llm-chat-markdown h4,
|
||||
.llm-chat-markdown h5,
|
||||
.llm-chat-markdown h6 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1:first-child,
|
||||
.llm-chat-markdown h2:first-child,
|
||||
.llm-chat-markdown h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1 { font-size: 1.4em; }
|
||||
.llm-chat-markdown h2 { font-size: 1.25em; }
|
||||
.llm-chat-markdown h3 { font-size: 1.1em; }
|
||||
|
||||
.llm-chat-markdown ul,
|
||||
.llm-chat-markdown ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown code {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
background: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.llm-chat-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th,
|
||||
.llm-chat-markdown td {
|
||||
border: 1px solid var(--main-border-color);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th {
|
||||
background: var(--accented-background-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Message footer (timestamp + token usage, sits below the bubble) */
|
||||
.llm-chat-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted-text-color);
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-chat-footer-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.llm-chat-footer .bx {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.llm-chat-footer-time {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.llm-chat-usage-model {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.llm-chat-usage-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.llm-chat-usage-tokens {
|
||||
cursor: help;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.llm-chat-usage-cost {
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import "./LlmChat.css";
|
||||
import "./ChatMessage.css";
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { Marked } from "marked";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { useEffect, useMemo, useRef } from "preact/hooks";
|
||||
|
||||
import { type LlmCitation, createWikiLinkExtension } from "@triliumnext/commons";
|
||||
|
||||
import link from "../../../services/link.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import utils from "../../../services/utils.js";
|
||||
import { SanitizedHtml } from "../../react/RawHtml.js";
|
||||
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
|
||||
import { ExpandableCard, ExpandableSection } from "./ExpandableCard.js";
|
||||
import { type ContentBlock, getMessageText, type StoredMessage, type TextBlock, type ToolCallBlock } from "./llm_chat_types.js";
|
||||
import ToolCallCard from "./ToolCallCard.js";
|
||||
|
||||
function shortenNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
@@ -14,80 +19,115 @@ function shortenNumber(n: number): string {
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
// Configure marked for safe rendering
|
||||
// Configure marked for safe rendering with client-side URL format
|
||||
const markedInstance = new Marked({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true // GitHub Flavored Markdown
|
||||
});
|
||||
markedInstance.use({
|
||||
extensions: [createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })]
|
||||
});
|
||||
|
||||
/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */
|
||||
/** Parse markdown to HTML. */
|
||||
function renderMarkdown(markdown: string): string {
|
||||
return markedInstance.parse(markdown) as string;
|
||||
}
|
||||
|
||||
/** Renders markdown content with reference link title loading. */
|
||||
function MarkdownContent({ html, isStreaming }: { html: string; isStreaming?: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const referenceLinks = containerRef.current.querySelectorAll<HTMLAnchorElement>("a.reference-link");
|
||||
for (const el of referenceLinks) {
|
||||
link.loadReferenceLinkTitle($(el), el.href);
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="llm-chat-markdown"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
|
||||
/>
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: StoredMessage;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
|
||||
const classes = [
|
||||
"llm-chat-tool-call-inline",
|
||||
toolCall.isError && "llm-chat-tool-call-error"
|
||||
].filter(Boolean).join(" ");
|
||||
type ContentGroup =
|
||||
| { type: "text"; block: TextBlock; index: number }
|
||||
| { type: "tool_calls"; blocks: ToolCallBlock[]; index: number };
|
||||
|
||||
/** Extract domain + TLD from a hostname (e.g. "www.example.co.uk" → "example.co.uk"). */
|
||||
function extractDomain(hostname: string): string {
|
||||
return hostname.replace(/^www\./, "");
|
||||
}
|
||||
|
||||
function getUniqueSiteCount(citations: LlmCitation[]): number {
|
||||
const domains = new Set<string>();
|
||||
for (const c of citations) {
|
||||
if (c.url) {
|
||||
try {
|
||||
domains.add(extractDomain(new URL(c.url).hostname));
|
||||
} catch { /* ignore invalid URLs */ }
|
||||
}
|
||||
}
|
||||
return domains.size;
|
||||
}
|
||||
|
||||
function CitationsSection({ citations }: { citations: LlmCitation[] }) {
|
||||
const siteCount = getUniqueSiteCount(citations);
|
||||
const summary = t("llm_chat.sources_summary", { count: citations.length, sites: siteCount });
|
||||
|
||||
return (
|
||||
<details className={classes}>
|
||||
<summary className="llm-chat-tool-call-inline-summary">
|
||||
<span className={toolCall.isError ? "bx bx-error-circle" : "bx bx-wrench"} />
|
||||
{toolCall.toolName}
|
||||
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
|
||||
</summary>
|
||||
<div className="llm-chat-tool-call-inline-body">
|
||||
<div className="llm-chat-tool-call-input">
|
||||
<strong>{t("llm_chat.input")}:</strong>
|
||||
<pre>{JSON.stringify(toolCall.input, null, 2)}</pre>
|
||||
</div>
|
||||
{toolCall.result && (
|
||||
<div className={`llm-chat-tool-call-result ${toolCall.isError ? "llm-chat-tool-call-result-error" : ""}`}>
|
||||
<strong>{toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}:</strong>
|
||||
<pre>{(() => {
|
||||
if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
|
||||
<ExpandableCard className="llm-chat-citations-card">
|
||||
<ExpandableSection icon="bx bx-link" label={summary}>
|
||||
<table className="llm-chat-citations-list">
|
||||
<tbody>
|
||||
{citations.map((citation, idx) => {
|
||||
const title = citation.title || citation.citedText?.slice(0, 80) || `Source ${idx + 1}`;
|
||||
let domain: string | null = null;
|
||||
if (citation.url) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(toolCall.result), null, 2);
|
||||
} catch {
|
||||
return toolCall.result;
|
||||
}
|
||||
domain = extractDomain(new URL(citation.url).hostname);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return toolCall.result;
|
||||
})()}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="llm-chat-citation-title">
|
||||
{citation.url ? (
|
||||
<a href={citation.url} target="_blank" rel="noopener noreferrer" title={title}>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<span>{title}</span>
|
||||
)}
|
||||
</td>
|
||||
{domain && (
|
||||
<td className="llm-chat-citation-site">{domain}</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ExpandableSection>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
|
||||
return blocks.map((block, idx) => {
|
||||
if (block.type === "text") {
|
||||
const html = renderMarkdown(block.content);
|
||||
return (
|
||||
<div key={idx}>
|
||||
<SanitizedHtml className="llm-chat-markdown" html={html} />
|
||||
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (block.type === "tool_call") {
|
||||
return <ToolCallCard key={idx} toolCall={block.toolCall} />;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant");
|
||||
const isError = message.type === "error";
|
||||
const isThinking = message.type === "thinking";
|
||||
const textContent = typeof message.content === "string" ? message.content : getMessageText(message.content);
|
||||
@@ -107,101 +147,41 @@ export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
isThinking && "llm-chat-message-thinking"
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
// Render thinking messages in a collapsible details element
|
||||
// Render thinking messages in a collapsible card
|
||||
if (isThinking) {
|
||||
return (
|
||||
<details className={messageClasses}>
|
||||
<summary className="llm-chat-thinking-summary">
|
||||
<span className="bx bx-brain" />
|
||||
{t("llm_chat.thought_process")}
|
||||
</summary>
|
||||
<div className="llm-chat-message-content llm-chat-thinking-content">
|
||||
{textContent}
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</div>
|
||||
</details>
|
||||
<div className="llm-chat-message-wrapper llm-chat-message-wrapper-assistant">
|
||||
<ExpandableCard className="llm-chat-thinking-card">
|
||||
<ExpandableSection icon="bx bx-brain" label={t("llm_chat.thought_process")}>
|
||||
<div className="llm-chat-thinking-content">
|
||||
{textContent}
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</div>
|
||||
</ExpandableSection>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy tool calls (from old format stored as separate field)
|
||||
const legacyToolCalls = message.toolCalls;
|
||||
const hasBlockContent = Array.isArray(message.content);
|
||||
|
||||
return (
|
||||
<div className={`llm-chat-message-wrapper llm-chat-message-wrapper-${message.role}`}>
|
||||
<div className={messageClasses}>
|
||||
<div className="llm-chat-message-role">
|
||||
{isError ? "Error" : roleLabel}
|
||||
</div>
|
||||
{isError && <div className="llm-chat-message-role">Error</div>}
|
||||
<div className="llm-chat-message-content">
|
||||
{message.role === "assistant" && !isError ? (
|
||||
hasBlockContent ? (
|
||||
renderContentBlocks(message.content as ContentBlock[], isStreaming)
|
||||
) : (
|
||||
<>
|
||||
<SanitizedHtml className="llm-chat-markdown" html={renderedContent || ""} />
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</>
|
||||
<MarkdownContent html={renderedContent || ""} isStreaming={isStreaming} />
|
||||
)
|
||||
) : (
|
||||
textContent
|
||||
)}
|
||||
</div>
|
||||
{legacyToolCalls && legacyToolCalls.length > 0 && (
|
||||
<details className="llm-chat-tool-calls">
|
||||
<summary className="llm-chat-tool-calls-summary">
|
||||
<span className="bx bx-wrench" />
|
||||
{t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
|
||||
</summary>
|
||||
<div className="llm-chat-tool-calls-list">
|
||||
{legacyToolCalls.map((tool) => (
|
||||
<ToolCallCard key={tool.id} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className="llm-chat-citations">
|
||||
<div className="llm-chat-citations-label">
|
||||
<span className="bx bx-link" />
|
||||
{t("llm_chat.sources")}
|
||||
</div>
|
||||
<ul className="llm-chat-citations-list">
|
||||
{message.citations.map((citation, idx) => {
|
||||
// Determine display text: title, URL hostname, or cited text
|
||||
let displayText = citation.title;
|
||||
if (!displayText && citation.url) {
|
||||
try {
|
||||
displayText = new URL(citation.url).hostname;
|
||||
} catch {
|
||||
displayText = citation.url;
|
||||
}
|
||||
}
|
||||
if (!displayText) {
|
||||
displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={idx}>
|
||||
{citation.url ? (
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={citation.citedText || citation.url}
|
||||
>
|
||||
{displayText}
|
||||
</a>
|
||||
) : (
|
||||
<span title={citation.citedText}>
|
||||
{displayText}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<CitationsSection citations={message.citations} />
|
||||
)}
|
||||
</div>
|
||||
<div className={`llm-chat-footer llm-chat-footer-${message.role}`}>
|
||||
@@ -242,3 +222,40 @@ export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Group content blocks so that consecutive tool_calls are merged into one entry. */
|
||||
function groupContentBlocks(blocks: ContentBlock[]): ContentGroup[] {
|
||||
const groups: ContentGroup[] = [];
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
if (block.type === "tool_call") {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.type === "tool_calls") {
|
||||
last.blocks.push(block);
|
||||
} else {
|
||||
groups.push({ type: "tool_calls", blocks: [block], index: i });
|
||||
}
|
||||
} else {
|
||||
groups.push({ type: "text", block, index: i });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
|
||||
return groupContentBlocks(blocks).map((group) => {
|
||||
if (group.type === "text") {
|
||||
const html = renderMarkdown(group.block.content);
|
||||
const isLastBlock = group.index === blocks.length - 1;
|
||||
return (
|
||||
<div key={group.index}>
|
||||
<MarkdownContent html={html} isStreaming={isStreaming && isLastBlock} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolCallCard key={group.index} toolCalls={group.blocks.map((b) => b.toolCall)} />;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Expandable card — bordered container for collapsible sections */
|
||||
.expandable-card {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 80%;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Expandable section — collapsible details within a card */
|
||||
.expandable-section + .expandable-section {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.expandable-section-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expandable-section-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expandable-section-summary > .bx {
|
||||
font-size: 1rem;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.expandable-section-chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expandable-section[open] .expandable-section-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.expandable-section-body {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import "./ExpandableCard.css";
|
||||
|
||||
import type { ComponentChildren } from "preact";
|
||||
|
||||
interface ExpandableSectionProps {
|
||||
icon: string;
|
||||
label: ComponentChildren;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
/** A collapsible section within an ExpandableCard. */
|
||||
export function ExpandableSection({ icon, label, className, children }: ExpandableSectionProps) {
|
||||
return (
|
||||
<details className={`expandable-section ${className ?? ""}`}>
|
||||
<summary className="expandable-section-summary">
|
||||
<span className={icon} />
|
||||
<span className="expandable-section-label">{label}</span>
|
||||
<span className="bx bx-chevron-down expandable-section-chevron" />
|
||||
</summary>
|
||||
<div className="expandable-section-body">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpandableCardProps {
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
/** A bordered card that groups one or more ExpandableSections. */
|
||||
export function ExpandableCard({ className, children }: ExpandableCardProps) {
|
||||
return (
|
||||
<div className={`expandable-card ${className ?? ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,715 +11,3 @@
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper {
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 1.25rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper-user {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper-assistant {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Show footer only on hover */
|
||||
.llm-chat-message-wrapper:hover .llm-chat-footer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.llm-chat-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.llm-chat-message-user {
|
||||
background: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-assistant {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-role {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-content {
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Preserve whitespace only for user messages (plain text) */
|
||||
.llm-chat-message-user .llm-chat-message-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.llm-chat-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.1em;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: llm-chat-blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes llm-chat-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Tool activity indicator */
|
||||
.llm-chat-tool-activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--accented-background-color);
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.9rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.llm-chat-tool-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--muted-text-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: llm-chat-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes llm-chat-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Citations */
|
||||
.llm-chat-citations {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-citations-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list li {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.llm-chat-error {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--danger-background-color, #fee);
|
||||
border: 1px solid var(--danger-border-color, #fcc);
|
||||
color: var(--danger-text-color, #c00);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Error message (persisted in conversation) */
|
||||
.llm-chat-message-error {
|
||||
background: var(--danger-background-color, #fee);
|
||||
border: 1px solid var(--danger-border-color, #fcc);
|
||||
color: var(--danger-text-color, #c00);
|
||||
}
|
||||
|
||||
.llm-chat-message-error .llm-chat-message-role {
|
||||
color: var(--danger-text-color, #c00);
|
||||
}
|
||||
|
||||
/* Thinking message (collapsible) */
|
||||
.llm-chat-message-thinking {
|
||||
background: var(--accented-background-color);
|
||||
border: 1px dashed var(--main-border-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text-color);
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-chat-message-thinking[open] .llm-chat-thinking-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.llm-chat-thinking-summary .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-content {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Input form */
|
||||
.llm-chat-input-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-input {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-selection-color);
|
||||
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
|
||||
}
|
||||
|
||||
.llm-chat-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options row */
|
||||
.llm-chat-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn {
|
||||
margin-left: auto;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Model selector */
|
||||
.llm-chat-model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-model-selector .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-model-selector .dropdown {
|
||||
display: flex;
|
||||
|
||||
small {
|
||||
margin-left: 0.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Position legacy models submenu to open upward */
|
||||
.dropdown-submenu .dropdown-menu {
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-selection-color);
|
||||
}
|
||||
|
||||
.llm-chat-model-select.select-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Note context toggle */
|
||||
.llm-chat-note-context.tn-low-profile {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.5;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.llm-chat-note-context.tn-low-profile:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.llm-chat-note-context.tn-low-profile.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Markdown styles */
|
||||
.llm-chat-markdown {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1,
|
||||
.llm-chat-markdown h2,
|
||||
.llm-chat-markdown h3,
|
||||
.llm-chat-markdown h4,
|
||||
.llm-chat-markdown h5,
|
||||
.llm-chat-markdown h6 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1:first-child,
|
||||
.llm-chat-markdown h2:first-child,
|
||||
.llm-chat-markdown h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1 { font-size: 1.4em; }
|
||||
.llm-chat-markdown h2 { font-size: 1.25em; }
|
||||
.llm-chat-markdown h3 { font-size: 1.1em; }
|
||||
|
||||
.llm-chat-markdown ul,
|
||||
.llm-chat-markdown ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown code {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
background: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.llm-chat-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th,
|
||||
.llm-chat-markdown td {
|
||||
border: 1px solid var(--main-border-color);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th {
|
||||
background: var(--accented-background-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tool calls display */
|
||||
.llm-chat-tool-calls {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text-color);
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls[open] .llm-chat-tool-calls-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls-summary .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-calls-list {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call {
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--main-text-color);
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-input,
|
||||
.llm-chat-tool-call-result {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-input strong,
|
||||
.llm-chat-tool-call-result strong {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Inline tool call cards (timeline style) */
|
||||
.llm-chat-tool-call-inline {
|
||||
margin: 0.5rem 0;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--muted-text-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text-color);
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-summary .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-body {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-body pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-body strong {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-inline-body .llm-chat-tool-call-result {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tool call error styling */
|
||||
.llm-chat-tool-call-error {
|
||||
border-left-color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-error-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
font-family: var(--main-font-family);
|
||||
color: var(--danger-color, #dc3545);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-result-error pre {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
/* Message footer (timestamp + token usage, sits below the bubble) */
|
||||
.llm-chat-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted-text-color);
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-chat-footer-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.llm-chat-footer .bx {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.llm-chat-footer-time {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.llm-chat-usage-model {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.llm-chat-usage-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.llm-chat-usage-tokens {
|
||||
cursor: help;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.llm-chat-usage-cost {
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
/* Context window indicator */
|
||||
.llm-chat-context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-left: 0.5rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.llm-chat-context-pie {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.llm-chat-context-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* No provider state */
|
||||
.llm-chat-no-provider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.llm-chat-no-provider-content p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -65,12 +65,6 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
{chat.messages.map(msg => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{chat.toolActivity && !chat.streamingThinking && (
|
||||
<div className="llm-chat-tool-activity">
|
||||
<span className="llm-chat-tool-spinner" />
|
||||
{chat.toolActivity}
|
||||
</div>
|
||||
)}
|
||||
{chat.isStreaming && chat.streamingThinking && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
@@ -83,12 +77,12 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
{chat.isStreaming && chat.streamingContent && (
|
||||
{chat.isStreaming && chat.streamingBlocks.length > 0 && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
role: "assistant",
|
||||
content: chat.streamingContent,
|
||||
content: chat.streamingBlocks,
|
||||
createdAt: new Date().toISOString(),
|
||||
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
|
||||
}}
|
||||
|
||||
113
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css
Normal file
113
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Tool call specific styles (card/section structure is in ExpandableCard.css) */
|
||||
|
||||
.llm-chat-tool-call-detail {
|
||||
font-weight: 400;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-note-ref {
|
||||
font-weight: 400;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Section body (input + result) */
|
||||
.llm-chat-tool-call-input,
|
||||
.llm-chat-tool-call-result {
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-result {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.expandable-section-body pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-input strong,
|
||||
.llm-chat-tool-call-result strong {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tool call key-value table */
|
||||
.llm-chat-tool-call-table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table td {
|
||||
padding: 0.25rem 0;
|
||||
padding-right: 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-key {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
width: 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-value pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Nested tables */
|
||||
.llm-chat-tool-call-table-value .llm-chat-tool-call-table {
|
||||
background: none;
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array > .llm-chat-tool-call-table {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array > .llm-chat-tool-call-table + .llm-chat-tool-call-table {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
/* Tool call error styling */
|
||||
.llm-chat-tool-call-error .expandable-section-summary {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-error-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--danger-color, #dc3545);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-result-error pre {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
213
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx
Normal file
213
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import "./ToolCallCard.css";
|
||||
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { NewNoteLink } from "../../react/NoteLink.js";
|
||||
import { ExpandableCard, ExpandableSection } from "./ExpandableCard.js";
|
||||
import type { ToolCall } from "./llm_chat_types.js";
|
||||
|
||||
interface ToolCallContext {
|
||||
/** The primary note the tool operates on or created. */
|
||||
noteId: string | null;
|
||||
/** The parent note, shown as "in <parent>" for creation tools. */
|
||||
parentNoteId: string | null;
|
||||
/** Plain-text detail (e.g. skill name, search query) when no note ref is available. */
|
||||
detailText: string | null;
|
||||
}
|
||||
|
||||
/** Try to extract a noteId from the tool call's result JSON. */
|
||||
function parseResultNoteId(toolCall: ToolCall): string | null {
|
||||
if (!toolCall.result) return null;
|
||||
try {
|
||||
const result = typeof toolCall.result === "string"
|
||||
? JSON.parse(toolCall.result)
|
||||
: toolCall.result;
|
||||
return result?.noteId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract contextual info from a tool call for display in the summary. */
|
||||
function getToolCallContext(toolCall: ToolCall): ToolCallContext {
|
||||
const input = toolCall.input;
|
||||
const parentNoteId = (input?.parentNoteId as string) || null;
|
||||
|
||||
// For creation tools, the created note ID is in the result.
|
||||
if (parentNoteId) {
|
||||
const createdNoteId = parseResultNoteId(toolCall);
|
||||
if (createdNoteId) {
|
||||
return { noteId: createdNoteId, parentNoteId, detailText: null };
|
||||
}
|
||||
}
|
||||
|
||||
const noteId = (input?.noteId as string) || parentNoteId || parseResultNoteId(toolCall);
|
||||
if (noteId) {
|
||||
return { noteId, parentNoteId: null, detailText: null };
|
||||
}
|
||||
|
||||
const detailText = (input?.name ?? input?.query) as string | undefined;
|
||||
return { noteId: null, parentNoteId: null, detailText: detailText || null };
|
||||
}
|
||||
|
||||
function toolCallIcon(toolCall: ToolCall): string {
|
||||
if (toolCall.isError) return "bx bx-error-circle";
|
||||
if (!toolCall.result) return "bx bx-loader-alt bx-spin";
|
||||
|
||||
const name = toolCall.toolName;
|
||||
if (name.includes("search")) return "bx bx-search";
|
||||
if (name.includes("note")) return "bx bx-note";
|
||||
if (name.includes("attribute")) return "bx bx-purchase-tag";
|
||||
if (name.includes("attachment")) return "bx bx-paperclip";
|
||||
if (name.includes("skill")) return "bx bx-book-open";
|
||||
if (name.includes("web")) return "bx bx-globe";
|
||||
return "bx bx-wrench";
|
||||
}
|
||||
|
||||
/** Try to parse a JSON string into a structured value. */
|
||||
function tryParseJson(data: unknown): unknown {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Check if a value is a plain object (not null, not array). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const MAX_TABLE_DEPTH = 2;
|
||||
|
||||
/** Render a single value — recurse for objects/arrays up to max depth. */
|
||||
function ValueCell({ value, depth }: { value: unknown; depth: number }) {
|
||||
if (value === null || value === undefined) return <pre />;
|
||||
|
||||
// Beyond max depth, fall back to JSON.
|
||||
if (depth >= MAX_TABLE_DEPTH) {
|
||||
if (isPlainObject(value) || Array.isArray(value)) {
|
||||
return <pre>{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
return <pre>{String(value)}</pre>;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return <KeyValueTable data={value} depth={depth} />;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <pre>{"[]"}</pre>;
|
||||
|
||||
// Array of objects: render each as a nested table.
|
||||
if (value.every(isPlainObject)) {
|
||||
return (
|
||||
<div className="llm-chat-tool-call-table-array">
|
||||
{value.map((item, idx) => (
|
||||
<KeyValueTable key={idx} data={item} depth={depth} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of primitives: comma-separated.
|
||||
return <pre>{value.map(String).join(", ")}</pre>;
|
||||
}
|
||||
|
||||
return <pre>{String(value)}</pre>;
|
||||
}
|
||||
|
||||
/** Renders a data object as a recursive two-column key-value table. */
|
||||
function KeyValueTable({ data, className, depth = 0 }: { data: unknown; className?: string; depth?: number }) {
|
||||
const obj = tryParseJson(data);
|
||||
|
||||
if (!isPlainObject(obj)) {
|
||||
const raw = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
||||
return <pre className={className}>{raw}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={`llm-chat-tool-call-table ${className ?? ""}`}>
|
||||
<tbody>
|
||||
{Object.entries(obj).map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="llm-chat-tool-call-table-key">{key}</td>
|
||||
<td className="llm-chat-tool-call-table-value">
|
||||
<ValueCell value={value} depth={depth + 1} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the label content for a tool call section. */
|
||||
function ToolCallLabel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall);
|
||||
const hasError = toolCall.isError;
|
||||
|
||||
return (
|
||||
<>
|
||||
{t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })}
|
||||
{detailText && (
|
||||
<span className="llm-chat-tool-call-detail">{detailText}</span>
|
||||
)}
|
||||
{refNoteId && (
|
||||
<span className="llm-chat-tool-call-note-ref">
|
||||
{refParentId ? (
|
||||
<Trans
|
||||
i18nKey="llm.tools.note_in_parent"
|
||||
components={{
|
||||
Note: <NewNoteLink notePath={refNoteId} showNoteIcon noPreview />,
|
||||
Parent: <NewNoteLink notePath={refParentId} showNoteIcon noPreview />
|
||||
} as any}
|
||||
/>
|
||||
) : (
|
||||
<NewNoteLink notePath={refNoteId} showNoteIcon noPreview />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{hasError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single tool call section within a ToolCallCard. */
|
||||
function ToolCallSection({ toolCall }: { toolCall: ToolCall }) {
|
||||
const hasError = toolCall.isError;
|
||||
|
||||
return (
|
||||
<ExpandableSection
|
||||
icon={toolCallIcon(toolCall)}
|
||||
label={<ToolCallLabel toolCall={toolCall} />}
|
||||
className={hasError ? "llm-chat-tool-call-error" : ""}
|
||||
>
|
||||
<div className="llm-chat-tool-call-input">
|
||||
<strong>{t("llm_chat.input")}</strong>
|
||||
<KeyValueTable data={toolCall.input} />
|
||||
</div>
|
||||
{toolCall.result && (
|
||||
<div className={`llm-chat-tool-call-result ${hasError ? "llm-chat-tool-call-result-error" : ""}`}>
|
||||
<strong>{hasError ? t("llm_chat.error") : t("llm_chat.result")}</strong>
|
||||
<KeyValueTable data={toolCall.result} />
|
||||
</div>
|
||||
)}
|
||||
</ExpandableSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card that groups one or more sequential tool calls together. */
|
||||
export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) {
|
||||
return (
|
||||
<ExpandableCard>
|
||||
{toolCalls.map((tc, idx) => (
|
||||
<ToolCallSection key={tc.id ?? idx} toolCall={tc} />
|
||||
))}
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -42,11 +42,6 @@ export function getMessageText(content: string | ContentBlock[]): string {
|
||||
* Extract tool calls from message content blocks.
|
||||
*/
|
||||
export function getMessageToolCalls(message: StoredMessage): ToolCall[] {
|
||||
// Legacy format: tool calls stored in separate field
|
||||
if (message.toolCalls) {
|
||||
return message.toolCalls;
|
||||
}
|
||||
// Block format: extract from content blocks
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter((b): b is ToolCallBlock => b.type === "tool_call")
|
||||
@@ -64,8 +59,6 @@ export interface StoredMessage {
|
||||
citations?: LlmCitation[];
|
||||
/** Message type for special rendering. Defaults to "message" if omitted. */
|
||||
type?: MessageType;
|
||||
/** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */
|
||||
toolCalls?: ToolCall[];
|
||||
/** Token usage for this response */
|
||||
usage?: LlmUsage;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumne
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js";
|
||||
import { randomString } from "../../../services/utils.js";
|
||||
import type { ContentBlock, LlmChatContent, StoredMessage } from "./llm_chat_types.js";
|
||||
@@ -28,8 +27,8 @@ export interface UseLlmChatReturn {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingBlocks: ContentBlock[];
|
||||
streamingThinking: string;
|
||||
toolActivity: string | null;
|
||||
pendingCitations: LlmCitation[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string;
|
||||
@@ -75,8 +74,8 @@ export function useLlmChat(
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [streamingBlocks, setStreamingBlocks] = useState<ContentBlock[]>([]);
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [toolActivity, setToolActivity] = useState<string | null>(null);
|
||||
const [pendingCitations, setPendingCitations] = useState<LlmCitation[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
@@ -152,7 +151,7 @@ export function useLlmChat(
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
|
||||
}, [messages, streamingContent, streamingThinking, scrollToBottom]);
|
||||
|
||||
// Load state from content object
|
||||
const loadFromContent = useCallback((content: LlmChatContent) => {
|
||||
@@ -198,7 +197,6 @@ export function useLlmChat(
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isStreaming) return;
|
||||
|
||||
setToolActivity(null);
|
||||
setPendingCitations([]);
|
||||
|
||||
const userMessage: StoredMessage = {
|
||||
@@ -213,6 +211,7 @@ export function useLlmChat(
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
setStreamingBlocks([]);
|
||||
setStreamingThinking("");
|
||||
|
||||
let thinkingContent = "";
|
||||
@@ -262,18 +261,13 @@ export function useLlmChat(
|
||||
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
|
||||
.map(b => b.content)
|
||||
.join(""));
|
||||
setToolActivity(null);
|
||||
setStreamingBlocks([...contentBlocks]);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
thinkingContent += text;
|
||||
setStreamingThinking(thinkingContent);
|
||||
setToolActivity(t("llm_chat.thinking"));
|
||||
},
|
||||
onToolUse: (toolName, toolInput) => {
|
||||
const toolLabel = toolName === "web_search"
|
||||
? t("llm_chat.searching_web")
|
||||
: `Using ${toolName}...`;
|
||||
setToolActivity(toolLabel);
|
||||
contentBlocks.push({
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
@@ -282,21 +276,28 @@ export function useLlmChat(
|
||||
input: toolInput
|
||||
}
|
||||
});
|
||||
setStreamingBlocks([...contentBlocks]);
|
||||
},
|
||||
onToolResult: (toolName, result, isError) => {
|
||||
// Find the most recent tool_call block for this tool without a result
|
||||
// Replace the matching block with a new object so Preact sees the change.
|
||||
for (let i = contentBlocks.length - 1; i >= 0; i--) {
|
||||
const block = contentBlocks[i];
|
||||
if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) {
|
||||
block.toolCall.result = result;
|
||||
block.toolCall.isError = isError;
|
||||
contentBlocks[i] = {
|
||||
type: "tool_call",
|
||||
toolCall: { ...block.toolCall, result, isError }
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
setStreamingBlocks([...contentBlocks]);
|
||||
},
|
||||
onCitation: (citation) => {
|
||||
citations.push(citation);
|
||||
setPendingCitations([...citations]);
|
||||
// Deduplicate by URL
|
||||
if (!citation.url || !citations.some(c => c.url === citation.url)) {
|
||||
citations.push(citation);
|
||||
setPendingCitations([...citations]);
|
||||
}
|
||||
},
|
||||
onUsage: (u) => {
|
||||
usage = u;
|
||||
@@ -314,9 +315,9 @@ export function useLlmChat(
|
||||
const finalMessages = [...newMessages, errorMessage];
|
||||
setMessages(finalMessages);
|
||||
setStreamingContent("");
|
||||
setStreamingBlocks([]);
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
setToolActivity(null);
|
||||
},
|
||||
onDone: () => {
|
||||
const finalNewMessages: StoredMessage[] = [];
|
||||
@@ -348,10 +349,10 @@ export function useLlmChat(
|
||||
}
|
||||
|
||||
setStreamingContent("");
|
||||
setStreamingBlocks([]);
|
||||
setStreamingThinking("");
|
||||
setPendingCitations([]);
|
||||
setIsStreaming(false);
|
||||
setToolActivity(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -370,8 +371,8 @@ export function useLlmChat(
|
||||
input,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingBlocks,
|
||||
streamingThinking,
|
||||
toolActivity,
|
||||
pendingCitations,
|
||||
availableModels,
|
||||
selectedModel,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import { experimentalFeatures } from "../../../services/experimental_features";
|
||||
import { experimentalFeatures, type ExperimentalFeatureId } from "../../../services/experimental_features";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import Button from "../../react/Button";
|
||||
import Column from "../../react/Column";
|
||||
import FormText from "../../react/FormText";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOptionJson } from "../../react/hooks";
|
||||
import CheckboxList from "./components/CheckboxList";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
|
||||
export default function AdvancedSettings() {
|
||||
@@ -180,19 +181,39 @@ function VacuumDatabaseOptions() {
|
||||
}
|
||||
|
||||
function ExperimentalOptions() {
|
||||
const [ enabledExperimentalFeatures, setEnabledExperimentalFeatures ] = useTriliumOptionJson<string[]>("experimentalFeatures", true);
|
||||
const filteredExperimentalFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []);
|
||||
const [enabledFeatures, setEnabledFeatures] = useTriliumOptionJson<ExperimentalFeatureId[]>("experimentalFeatures", true);
|
||||
const filteredFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []);
|
||||
|
||||
return (filteredExperimentalFeatures.length > 0 &&
|
||||
const toggleFeature = useCallback((featureId: ExperimentalFeatureId, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setEnabledFeatures([...enabledFeatures, featureId]);
|
||||
} else {
|
||||
setEnabledFeatures(enabledFeatures.filter(id => id !== featureId));
|
||||
}
|
||||
}, [enabledFeatures, setEnabledFeatures]);
|
||||
|
||||
if (filteredFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("experimental_features.title")}>
|
||||
<FormText>{t("experimental_features.disclaimer")}</FormText>
|
||||
|
||||
<CheckboxList
|
||||
values={filteredExperimentalFeatures}
|
||||
keyProperty="id"
|
||||
titleProperty="name"
|
||||
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
|
||||
/>
|
||||
{filteredFeatures.map((feature) => (
|
||||
<OptionsRow
|
||||
key={feature.id}
|
||||
name={`experimental-${feature.id}`}
|
||||
label={feature.name}
|
||||
description={feature.description}
|
||||
>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={enabledFeatures.includes(feature.id)}
|
||||
onChange={(enabled) => toggleFeature(feature.id, enabled)}
|
||||
/>
|
||||
</OptionsRow>
|
||||
))}
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
.option-row {
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.option-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75em 0;
|
||||
}
|
||||
|
||||
.option-row-main > label {
|
||||
width: 45%;
|
||||
.option-row-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.option-row-label > label {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.option-row-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-row-main > select,
|
||||
.option-row-main > .dropdown {
|
||||
width: 60%;
|
||||
.option-row-input > select,
|
||||
.option-row-input > .dropdown {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.option-row-main > .dropdown button {
|
||||
.option-row-input > .dropdown button {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
@@ -36,6 +42,6 @@
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
.option-row.centered .option-row-main {
|
||||
.option-row.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ export default function OptionsRow({ name, label, description, children, centere
|
||||
|
||||
return (
|
||||
<div className={`option-row ${centered ? "centered" : ""}`}>
|
||||
<div className="option-row-main">
|
||||
<div className="option-row-label">
|
||||
{label && <label for={id}>{label}</label>}
|
||||
{description && <small className="option-row-description">{description}</small>}
|
||||
</div>
|
||||
<div className="option-row-input">
|
||||
{childWithId}
|
||||
</div>
|
||||
{description && <small className="option-row-description">{description}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useCallback, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import dialog from "../../../services/dialog";
|
||||
import { isExperimentalFeatureEnabled } from "../../../services/experimental_features";
|
||||
import { t } from "../../../services/i18n";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Button from "../../react/Button";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { useTriliumOption } from "../../react/hooks";
|
||||
|
||||
export default function LlmSettings() {
|
||||
if (!isExperimentalFeatureEnabled("llm")) {
|
||||
return (
|
||||
<OptionsSection title={t("llm.settings_title")}>
|
||||
<p className="form-text">{t("llm.feature_not_enabled")}</p>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderSettings />
|
||||
<McpSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSettings() {
|
||||
const [providersJson, setProvidersJson] = useTriliumOption("llmProviders");
|
||||
const providers = useMemo<LlmProviderConfig[]>(() => {
|
||||
try {
|
||||
@@ -34,7 +55,7 @@ export default function LlmSettings() {
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("llm.settings_title")}>
|
||||
<p>{t("llm.settings_description")}</p>
|
||||
<p className="form-text">{t("llm.settings_description")}</p>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
@@ -60,6 +81,39 @@ export default function LlmSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
function getMcpEndpointUrl() {
|
||||
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
||||
return `${window.location.protocol}//localhost:${port}/mcp`;
|
||||
}
|
||||
|
||||
function McpSettings() {
|
||||
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
|
||||
const endpointUrl = useMemo(() => getMcpEndpointUrl(), []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("llm.mcp_title")}>
|
||||
<OptionsRow name="mcp-enabled" label={t("llm.mcp_enabled")} description={t("llm.mcp_enabled_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={mcpEnabled}
|
||||
onChange={setMcpEnabled}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
{mcpEnabled && (
|
||||
<OptionsRow name="mcp-endpoint" label={t("llm.mcp_endpoint_title")} description={t("llm.mcp_endpoint_description")}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={endpointUrl}
|
||||
readOnly
|
||||
/>
|
||||
</OptionsRow>
|
||||
)}
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: LlmProviderConfig[];
|
||||
onDelete: (providerId: string, providerName: string) => Promise<void>;
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/google": "3.0.55",
|
||||
"@ai-sdk/openai": "3.0.49",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"ai": "6.0.142",
|
||||
"better-sqlite3": "12.8.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"js-yaml": "4.1.1",
|
||||
"node-html-parser": "7.1.0",
|
||||
"sucrase": "3.35.1",
|
||||
"unpdf": "1.4.0"
|
||||
@@ -60,6 +62,7 @@
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
|
||||
177
apps/server/spec/etapi/mcp.spec.ts
Normal file
177
apps/server/spec/etapi/mcp.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Application } from "express";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import supertest from "supertest";
|
||||
import { createNote, login } from "./utils.js";
|
||||
import config from "../../src/services/config.js";
|
||||
import becca from "../../src/becca/becca.js";
|
||||
import optionService from "../../src/services/options.js";
|
||||
import cls from "../../src/services/cls.js";
|
||||
|
||||
let app: Application;
|
||||
let token: string;
|
||||
|
||||
const USER = "etapi";
|
||||
const MCP_ACCEPT = "application/json, text/event-stream";
|
||||
|
||||
/** Builds a JSON-RPC 2.0 request body for MCP. */
|
||||
function jsonRpc(method: string, params?: Record<string, unknown>, id: number = 1) {
|
||||
return { jsonrpc: "2.0", id, method, params };
|
||||
}
|
||||
|
||||
/** Parses the JSON-RPC response from an SSE response text. */
|
||||
function parseSseResponse(text: string) {
|
||||
const dataLine = text.split("\n").find(line => line.startsWith("data: "));
|
||||
if (!dataLine) {
|
||||
throw new Error(`No SSE data line found in response: ${text}`);
|
||||
}
|
||||
return JSON.parse(dataLine.slice("data: ".length));
|
||||
}
|
||||
|
||||
function mcpPost(app: Application) {
|
||||
return supertest(app)
|
||||
.post("/mcp")
|
||||
.set("Accept", MCP_ACCEPT)
|
||||
.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
function setOption(name: Parameters<typeof optionService.setOption>[0], value: string) {
|
||||
cls.init(() => optionService.setOption(name, value));
|
||||
}
|
||||
|
||||
describe("mcp", () => {
|
||||
beforeAll(async () => {
|
||||
config.General.noAuthentication = false;
|
||||
const buildApp = (await (import("../../src/app.js"))).default;
|
||||
app = await buildApp();
|
||||
token = await login(app);
|
||||
});
|
||||
|
||||
describe("option gate", () => {
|
||||
it("rejects requests when mcpEnabled is false", async () => {
|
||||
setOption("mcpEnabled", "false");
|
||||
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("initialize"))
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error).toContain("disabled");
|
||||
});
|
||||
|
||||
it("rejects requests when mcpEnabled option does not exist", async () => {
|
||||
const saved = becca.options["mcpEnabled"];
|
||||
delete becca.options["mcpEnabled"];
|
||||
|
||||
try {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("initialize"))
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error).toContain("disabled");
|
||||
} finally {
|
||||
becca.options["mcpEnabled"] = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts requests when mcpEnabled is true", async () => {
|
||||
setOption("mcpEnabled", "true");
|
||||
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0.0" }
|
||||
}));
|
||||
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol", () => {
|
||||
beforeAll(() => {
|
||||
setOption("mcpEnabled", "true");
|
||||
});
|
||||
|
||||
it("initializes and returns server capabilities", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0.0" }
|
||||
}))
|
||||
.expect(200);
|
||||
|
||||
const body = parseSseResponse(response.text);
|
||||
expect(body.result.serverInfo.name).toBe("trilium-notes");
|
||||
expect(body.result.capabilities.tools).toBeDefined();
|
||||
});
|
||||
|
||||
it("lists available tools", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/list"))
|
||||
.expect(200);
|
||||
|
||||
const body = parseSseResponse(response.text);
|
||||
const toolNames: string[] = body.result.tools.map((t: { name: string }) => t.name);
|
||||
expect(toolNames).toContain("search_notes");
|
||||
expect(toolNames).toContain("get_note");
|
||||
expect(toolNames).toContain("get_note_content");
|
||||
expect(toolNames).toContain("create_note");
|
||||
expect(toolNames).not.toContain("get_current_note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tools", () => {
|
||||
let noteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
setOption("mcpEnabled", "true");
|
||||
noteId = await createNote(app, token, "MCP test note content");
|
||||
});
|
||||
|
||||
it("searches for notes", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/call", {
|
||||
name: "search_notes",
|
||||
arguments: { query: "MCP test note content" }
|
||||
}))
|
||||
.expect(200);
|
||||
|
||||
const body = parseSseResponse(response.text);
|
||||
expect(body.result).toBeDefined();
|
||||
const content = body.result.content;
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
expect(content[0].text).toContain(noteId);
|
||||
});
|
||||
|
||||
it("gets note metadata by ID", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/call", {
|
||||
name: "get_note",
|
||||
arguments: { noteId }
|
||||
}))
|
||||
.expect(200);
|
||||
|
||||
const body = parseSseResponse(response.text);
|
||||
expect(body.result).toBeDefined();
|
||||
const parsed = JSON.parse(body.result.content[0].text);
|
||||
expect(parsed.noteId).toBe(noteId);
|
||||
expect(parsed.type).toBeDefined();
|
||||
expect(parsed.attributes).toBeDefined();
|
||||
});
|
||||
|
||||
it("reads note content by ID", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/call", {
|
||||
name: "get_note_content",
|
||||
arguments: { noteId }
|
||||
}))
|
||||
.expect(200);
|
||||
|
||||
const body = parseSseResponse(response.text);
|
||||
expect(body.result).toBeDefined();
|
||||
const parsed = JSON.parse(body.result.content[0].text);
|
||||
expect(parsed.noteId).toBe(noteId);
|
||||
expect(parsed.content).toContain("MCP test note content");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import favicon from "serve-favicon";
|
||||
import assets from "./routes/assets.js";
|
||||
import custom from "./routes/custom.js";
|
||||
import error_handlers from "./routes/error_handlers.js";
|
||||
import mcpRoutes from "./routes/mcp.js";
|
||||
import routes from "./routes/routes.js";
|
||||
import config from "./services/config.js";
|
||||
import { startScheduledCleanup } from "./services/erase.js";
|
||||
@@ -58,8 +59,8 @@ export default async function buildApp() {
|
||||
app.use(compression({
|
||||
// Skip compression for SSE endpoints to enable real-time streaming
|
||||
filter: (req, res) => {
|
||||
// Skip compression for LLM chat streaming endpoint
|
||||
if (req.path === "/api/llm-chat/stream") {
|
||||
// Skip compression for SSE-capable endpoints
|
||||
if (req.path === "/api/llm-chat/stream" || req.path === "/mcp") {
|
||||
return false;
|
||||
}
|
||||
return compression.filter(req, res);
|
||||
@@ -90,6 +91,10 @@ export default async function buildApp() {
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// MCP is registered before session/auth middleware — it uses its own
|
||||
// localhost-only guard and does not require Trilium authentication.
|
||||
mcpRoutes.register(app);
|
||||
|
||||
app.use(express.static(path.join(publicDir, "root")));
|
||||
app.use(`/manifest.webmanifest`, express.static(path.join(publicAssetsDir, "manifest.webmanifest")));
|
||||
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));
|
||||
|
||||
@@ -105,8 +105,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"newLayout",
|
||||
"mfaEnabled",
|
||||
"mfaMethod",
|
||||
// LLM options
|
||||
"llmProviders",
|
||||
|
||||
"mcpEnabled",
|
||||
// OCR options
|
||||
"ocrAutoProcessImages",
|
||||
"ocrMinConfidence"
|
||||
|
||||
73
apps/server/src/routes/mcp.ts
Normal file
73
apps/server/src/routes/mcp.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) HTTP route handler.
|
||||
*
|
||||
* Mounts the Streamable HTTP transport at `/mcp` with a localhost-only guard.
|
||||
* No authentication is required — access is restricted to loopback addresses.
|
||||
*/
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
|
||||
import { createMcpServer } from "../services/mcp/mcp_server.js";
|
||||
import log from "../services/log.js";
|
||||
import optionService from "../services/options.js";
|
||||
|
||||
function isLoopback(addr: string | undefined): boolean {
|
||||
if (!addr) return false;
|
||||
// IPv6 loopback
|
||||
if (addr === "::1") return true;
|
||||
// IPv4 loopback (127.0.0.0/8)
|
||||
if (addr.startsWith("127.")) return true;
|
||||
// IPv4-mapped IPv6 loopback
|
||||
if (addr.startsWith("::ffff:127.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function mcpGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
if (optionService.getOptionOrNull("mcpEnabled") !== "true") {
|
||||
res.status(403).json({ error: "MCP server is disabled. Enable it in Options > AI / LLM." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use req.ip which respects trust proxy settings, falling back to socket address
|
||||
const clientIp = req.ip || req.socket.remoteAddress;
|
||||
if (!isLoopback(clientIp)) {
|
||||
res.status(403).json({ error: "MCP is only available from localhost" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
async function handleMcpRequest(req: express.Request, res: express.Response) {
|
||||
try {
|
||||
const server = createMcpServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined // stateless
|
||||
});
|
||||
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
log.error(`MCP request error: ${err}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal MCP error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app: express.Application) {
|
||||
app.post("/mcp", mcpGuard, handleMcpRequest);
|
||||
app.get("/mcp", mcpGuard, handleMcpRequest);
|
||||
app.delete("/mcp", mcpGuard, handleMcpRequest);
|
||||
|
||||
log.info("MCP server registered at /mcp (localhost only)");
|
||||
}
|
||||
|
||||
export default { register };
|
||||
@@ -1,14 +1,11 @@
|
||||
|
||||
|
||||
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons";
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import { parse, Renderer, type Tokens,use } from "marked";
|
||||
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor, transclusionExtension, wikiLinkExtension } from "@triliumnext/commons";
|
||||
import { parse, Renderer, type Tokens, use } from "marked";
|
||||
|
||||
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import utils from "../utils.js";
|
||||
import wikiLinkInternalLink from "./markdown/wikilink_internal_link.js";
|
||||
import wikiLinkTransclusion from "./markdown/wikilink_transclusion.js";
|
||||
import importUtils from "./utils.js";
|
||||
|
||||
const escape = utils.escapeHtml;
|
||||
@@ -136,8 +133,8 @@ function renderToHtml(content: string, title: string) {
|
||||
use({
|
||||
// Order is important, especially for wikilinks.
|
||||
extensions: [
|
||||
wikiLinkTransclusion,
|
||||
wikiLinkInternalLink
|
||||
transclusionExtension,
|
||||
wikiLinkExtension
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { TokenizerAndRendererExtension } from "marked";
|
||||
|
||||
const wikiLinkInternalLink: TokenizerAndRendererExtension = {
|
||||
name: "wikilinkInternalLink",
|
||||
level: "inline",
|
||||
|
||||
start(src: string) {
|
||||
return src.indexOf('[[');
|
||||
},
|
||||
|
||||
tokenizer(src) {
|
||||
const match = /^\[\[([^\]]+?)\]\]/.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: 'wikilinkInternalLink',
|
||||
raw: match[0],
|
||||
text: match[1].trim(), // what shows as link text
|
||||
href: match[1].trim()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderer(token) {
|
||||
return `<a class="reference-link" href="/${token.href}">${token.text}</a>`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default wikiLinkInternalLink;
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { TokenizerAndRendererExtension } from "marked";
|
||||
|
||||
/**
|
||||
* The terminology is inspired by https://silverbullet.md/Transclusions.
|
||||
*/
|
||||
const wikiLinkTransclusion: TokenizerAndRendererExtension = {
|
||||
name: "wikiLinkTransclusion",
|
||||
level: "inline",
|
||||
|
||||
start(src: string) {
|
||||
return src.match(/!\[\[/)?.index;
|
||||
},
|
||||
|
||||
tokenizer(src) {
|
||||
const match = /^!\[\[([^\]]+?)\]\]/.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: "wikiLinkTransclusion",
|
||||
raw: match[0],
|
||||
href: match[1].trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderer(token) {
|
||||
return `<img src="/${token.href}">`;
|
||||
}
|
||||
};
|
||||
|
||||
export default wikiLinkTransclusion;
|
||||
@@ -3,13 +3,15 @@
|
||||
* tool assembly, model pricing, and title generation.
|
||||
*/
|
||||
|
||||
import { generateText, streamText, stepCountIs, type ModelMessage, type ToolSet } from "ai";
|
||||
import type { LanguageModel } from "ai";
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
import type { LanguageModel } from "ai";
|
||||
import { generateText, type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import { getSkillsSummary } from "../skills/index.js";
|
||||
import { noteTools, attributeTools, hierarchyTools, skillTools, currentNoteTools } from "../tools/index.js";
|
||||
import { getNoteMeta,SYSTEM_PROMPT_LIMITS } from "../tools/helpers.js";
|
||||
import { allToolRegistries } from "../tools/index.js";
|
||||
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 8096;
|
||||
@@ -24,7 +26,7 @@ function effectiveCost(pricing: ModelPricing): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lightweight context hint about the current note (title + type only, no content).
|
||||
* Build a context hint about the current note with full metadata (same as get_note / ETAPI).
|
||||
*/
|
||||
function buildNoteHint(noteId: string): string | null {
|
||||
const note = becca.getNote(noteId);
|
||||
@@ -32,7 +34,14 @@ function buildNoteHint(noteId: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`;
|
||||
const metadata = yaml.dump(getNoteMeta(note, SYSTEM_PROMPT_LIMITS), { lineWidth: -1 });
|
||||
return [
|
||||
"The user is currently viewing the following note.",
|
||||
"Use this metadata (including contentPreview) to answer questions about the note without calling tools when possible.",
|
||||
"Use get_note_content only if the preview is insufficient.",
|
||||
"",
|
||||
metadata
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,25 +81,48 @@ export abstract class BaseProvider implements LlmProvider {
|
||||
* Build the system prompt with note hints and skills summary.
|
||||
*/
|
||||
protected buildSystemPrompt(messages: LlmMessage[], config: LlmProviderConfig): string | undefined {
|
||||
let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
|
||||
const parts: string[] = [];
|
||||
|
||||
// Base system prompt from config or messages
|
||||
const basePrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
|
||||
if (basePrompt) {
|
||||
parts.push(basePrompt);
|
||||
}
|
||||
|
||||
// Context note hint
|
||||
if (config.contextNoteId) {
|
||||
const noteHint = buildNoteHint(config.contextNoteId);
|
||||
if (noteHint) {
|
||||
systemPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n${noteHint}`
|
||||
: noteHint;
|
||||
parts.push(noteHint);
|
||||
}
|
||||
}
|
||||
|
||||
// Note tools hint
|
||||
if (config.enableNoteTools) {
|
||||
const skillsHint = `You have access to skills that provide specialized instructions. Load a skill with the load_skill tool before performing complex operations.\n\nAvailable skills:\n${getSkillsSummary()}`;
|
||||
systemPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n${skillsHint}`
|
||||
: skillsHint;
|
||||
parts.push(
|
||||
`You have access to skills that provide specialized instructions. Load a skill with the load_skill tool before performing complex operations.\n\nAvailable skills:\n${getSkillsSummary()}`
|
||||
);
|
||||
parts.push(
|
||||
`When referring to notes in your responses, use the wiki-link format [[noteId]] to create clickable internal links. Use the note ID (not the title) from tool results. The link will automatically display the note's title and icon, so don't repeat the title in your text. For example: "You can find more details in [[ZjSfLhzlqNY6]]" instead of "You can find more details in the Meeting Notes note ([[ZjSfLhzlqNY6]])".`
|
||||
);
|
||||
} else if (config.contextNoteId) {
|
||||
parts.push(
|
||||
`You can see the current note's metadata above, but you cannot search or access other notes. If the user asks about other notes, inform them that "Note access" is disabled and they need to enable it in the chat settings (click on the model name dropdown and toggle "Note access").`
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
`You do not have access to the user's notes. If the user asks about their notes, inform them that "Note access" is disabled and they need to enable it in the chat settings (click on the model name dropdown and toggle "Note access").`
|
||||
);
|
||||
}
|
||||
|
||||
return systemPrompt;
|
||||
// Web search hint
|
||||
if (!config.enableWebSearch) {
|
||||
parts.push(
|
||||
`You do not have access to web search. If the user asks for current/real-time information, news, or anything that requires searching the web, inform them that "Web search" is disabled and they need to enable it in the chat settings (click on the model name dropdown and toggle "Web search").`
|
||||
);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,15 +160,10 @@ export abstract class BaseProvider implements LlmProvider {
|
||||
this.addWebSearchTool(tools);
|
||||
}
|
||||
|
||||
if (config.contextNoteId) {
|
||||
Object.assign(tools, currentNoteTools(config.contextNoteId));
|
||||
}
|
||||
|
||||
if (config.enableNoteTools) {
|
||||
Object.assign(tools, noteTools);
|
||||
Object.assign(tools, attributeTools);
|
||||
Object.assign(tools, hierarchyTools);
|
||||
Object.assign(tools, skillTools);
|
||||
for (const registry of allToolRegistries) {
|
||||
Object.assign(tools, registry.toToolSet());
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
@@ -156,7 +183,7 @@ export abstract class BaseProvider implements LlmProvider {
|
||||
const tools = this.buildTools(config);
|
||||
if (Object.keys(tools).length > 0) {
|
||||
streamOptions.tools = tools;
|
||||
streamOptions.stopWhen = stepCountIs(5);
|
||||
streamOptions.stopWhen = stepCountIs(15);
|
||||
streamOptions.toolChoice = "auto";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* included in the system prompt; full content is fetched via the load_skill tool.
|
||||
*/
|
||||
|
||||
import { readFile } from "fs/promises";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import resourceDir from "../../resource_dir.js";
|
||||
import { defineTools } from "../tools/tool_registry.js";
|
||||
|
||||
const SKILLS_DIR = join(resourceDir.RESOURCE_DIR, "llm", "skills");
|
||||
|
||||
@@ -38,12 +38,12 @@ const SKILLS: SkillDefinition[] = [
|
||||
}
|
||||
];
|
||||
|
||||
async function loadSkillContent(name: string): Promise<string | null> {
|
||||
function loadSkillContent(name: string): string | null {
|
||||
const skill = SKILLS.find((s) => s.name === name);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
return readFile(join(SKILLS_DIR, skill.file), "utf-8");
|
||||
return readFileSync(join(SKILLS_DIR, skill.file), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,24 +55,19 @@ export function getSkillsSummary(): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The load_skill tool — lets the LLM fetch full instructions on demand.
|
||||
*/
|
||||
export const loadSkill = tool({
|
||||
description: "Load a skill to get specialized instructions. Available skills:\n"
|
||||
+ SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("The skill name to load")
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
const content = await loadSkillContent(name);
|
||||
if (!content) {
|
||||
return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` };
|
||||
export const skillTools = defineTools({
|
||||
load_skill: {
|
||||
description: "Load a skill to get specialized instructions. Available skills:\n"
|
||||
+ SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("The skill name to load")
|
||||
}),
|
||||
execute: ({ name }) => {
|
||||
const content = loadSkillContent(name);
|
||||
if (!content) {
|
||||
return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` };
|
||||
}
|
||||
return { skill: name, instructions: content };
|
||||
}
|
||||
return { skill: name, instructions: content };
|
||||
}
|
||||
});
|
||||
|
||||
export const skillTools = {
|
||||
load_skill: loadSkill
|
||||
};
|
||||
|
||||
67
apps/server/src/services/llm/tools/attachment_tools.ts
Normal file
67
apps/server/src/services/llm/tools/attachment_tools.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* LLM tools for attachment operations.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import { defineTools } from "./tool_registry.js";
|
||||
|
||||
export const attachmentTools = defineTools({
|
||||
get_attachment: {
|
||||
description: "Get metadata for a single attachment by its ID.",
|
||||
inputSchema: z.object({
|
||||
attachmentId: z.string().describe("The ID of the attachment to retrieve")
|
||||
}),
|
||||
execute: ({ attachmentId }) => {
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
return { error: "Attachment not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
attachmentId: attachment.attachmentId,
|
||||
ownerId: attachment.ownerId,
|
||||
role: attachment.role,
|
||||
mime: attachment.mime,
|
||||
title: attachment.title,
|
||||
dateModified: attachment.dateModified,
|
||||
contentLength: attachment.contentLength
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
get_attachment_content: {
|
||||
description: "Read the text content of an attachment. Works for text-based attachments (code, SVG, plain text) and binary attachments that have OCR/extracted text (PDF, images). Attachments with a null contentPreview in get_note_attachments have no readable content.",
|
||||
inputSchema: z.object({
|
||||
attachmentId: z.string().describe("The ID of the attachment to read")
|
||||
}),
|
||||
execute: ({ attachmentId }) => {
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
return { error: "Attachment not found" };
|
||||
}
|
||||
|
||||
if (attachment.hasStringContent()) {
|
||||
const content = attachment.getContent();
|
||||
return {
|
||||
attachmentId: attachment.attachmentId,
|
||||
source: "text" as const,
|
||||
content: typeof content === "string" ? content : content.toString("utf-8")
|
||||
};
|
||||
}
|
||||
|
||||
// For binary attachments, try OCR/extracted text from the blob.
|
||||
const blob = attachment.blobId ? becca.getBlob({ blobId: attachment.blobId }) : null;
|
||||
if (blob?.textRepresentation) {
|
||||
return {
|
||||
attachmentId: attachment.attachmentId,
|
||||
source: "ocr" as const,
|
||||
content: blob.textRepresentation
|
||||
};
|
||||
}
|
||||
|
||||
return { error: "Attachment has no readable text content" };
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,136 +2,122 @@
|
||||
* LLM tools for attribute operations (get, set, delete labels/relations).
|
||||
*/
|
||||
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import attributeService from "../../attributes.js";
|
||||
import { flag } from "./helpers.js";
|
||||
import { defineTools } from "./tool_registry.js";
|
||||
|
||||
/**
|
||||
* Get all owned attributes (labels/relations) of a note.
|
||||
*/
|
||||
export const getAttributes = tool({
|
||||
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note")
|
||||
}),
|
||||
execute: async ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
export const attributeTools = defineTools({
|
||||
get_attributes: {
|
||||
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note")
|
||||
}),
|
||||
execute: ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return note.getOwnedAttributes()
|
||||
.filter((attr) => !attr.isAutoLink())
|
||||
.map((attr) => ({
|
||||
attributeId: attr.attributeId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: flag(attr.isInheritable)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
return note.getOwnedAttributes()
|
||||
.filter((attr) => !attr.isAutoLink())
|
||||
.map((attr) => ({
|
||||
attributeId: attr.attributeId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: attr.isInheritable
|
||||
}));
|
||||
get_attribute: {
|
||||
description: "Get a single attribute by its ID.",
|
||||
inputSchema: z.object({
|
||||
attributeId: z.string().describe("The ID of the attribute")
|
||||
}),
|
||||
execute: ({ attributeId }) => {
|
||||
const attribute = becca.getAttribute(attributeId);
|
||||
if (!attribute) {
|
||||
return { error: "Attribute not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
attributeId: attribute.attributeId,
|
||||
noteId: attribute.noteId,
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: flag(attribute.isInheritable)
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
set_attribute: {
|
||||
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note"),
|
||||
type: z.enum(["label", "relation"]).describe("The attribute type"),
|
||||
name: z.string().describe("The attribute name"),
|
||||
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
|
||||
}),
|
||||
mutates: true,
|
||||
execute: ({ noteId, type, name, value = "" }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (note.isProtected) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (attributeService.isAttributeDangerous(type, name)) {
|
||||
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
|
||||
}
|
||||
if (type === "relation" && value && !becca.getNote(value)) {
|
||||
return { error: "Target note not found for relation" };
|
||||
}
|
||||
|
||||
note.setAttribute(type, name, value);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
type,
|
||||
name,
|
||||
value
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
delete_attribute: {
|
||||
description: "Remove an attribute from a note by its attribute ID.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note that owns the attribute"),
|
||||
attributeId: z.string().describe("The ID of the attribute to delete")
|
||||
}),
|
||||
mutates: true,
|
||||
execute: ({ noteId, attributeId }) => {
|
||||
const attribute = becca.getAttribute(attributeId);
|
||||
if (!attribute) {
|
||||
return { error: "Attribute not found" };
|
||||
}
|
||||
if (attribute.noteId !== noteId) {
|
||||
return { error: "Attribute does not belong to the specified note" };
|
||||
}
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (note?.isProtected) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
|
||||
attribute.markAsDeleted();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
attributeId
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single attribute by its ID.
|
||||
*/
|
||||
export const getAttribute = tool({
|
||||
description: "Get a single attribute by its ID.",
|
||||
inputSchema: z.object({
|
||||
attributeId: z.string().describe("The ID of the attribute")
|
||||
}),
|
||||
execute: async ({ attributeId }) => {
|
||||
const attribute = becca.getAttribute(attributeId);
|
||||
if (!attribute) {
|
||||
return { error: "Attribute not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
attributeId: attribute.attributeId,
|
||||
noteId: attribute.noteId,
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: attribute.isInheritable
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Add or update an attribute on a note.
|
||||
*/
|
||||
export const setAttribute = tool({
|
||||
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note"),
|
||||
type: z.enum(["label", "relation"]).describe("The attribute type"),
|
||||
name: z.string().describe("The attribute name"),
|
||||
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
|
||||
}),
|
||||
execute: async ({ noteId, type, name, value = "" }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (note.isProtected) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (attributeService.isAttributeDangerous(type, name)) {
|
||||
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
|
||||
}
|
||||
if (type === "relation" && value && !becca.getNote(value)) {
|
||||
return { error: "Target note not found for relation" };
|
||||
}
|
||||
|
||||
note.setAttribute(type, name, value);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
type,
|
||||
name,
|
||||
value
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove an attribute from a note.
|
||||
*/
|
||||
export const deleteAttribute = tool({
|
||||
description: "Remove an attribute from a note by its attribute ID.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note that owns the attribute"),
|
||||
attributeId: z.string().describe("The ID of the attribute to delete")
|
||||
}),
|
||||
execute: async ({ noteId, attributeId }) => {
|
||||
const attribute = becca.getAttribute(attributeId);
|
||||
if (!attribute) {
|
||||
return { error: "Attribute not found" };
|
||||
}
|
||||
if (attribute.noteId !== noteId) {
|
||||
return { error: "Attribute does not belong to the specified note" };
|
||||
}
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (note?.isProtected) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
|
||||
attribute.markAsDeleted();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
attributeId
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const attributeTools = {
|
||||
get_attributes: getAttributes,
|
||||
get_attribute: getAttribute,
|
||||
set_attribute: setAttribute,
|
||||
delete_attribute: deleteAttribute
|
||||
};
|
||||
|
||||
193
apps/server/src/services/llm/tools/helpers.ts
Normal file
193
apps/server/src/services/llm/tools/helpers.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Shared helpers for LLM tools — content conversion, metadata building, and previews.
|
||||
*/
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import type BAttachment from "../../../becca/entities/battachment.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import markdownExport from "../../export/markdown.js";
|
||||
import markdownImport from "../../import/markdown.js";
|
||||
|
||||
const CONTENT_PREVIEW_MAX_LENGTH = 500;
|
||||
const ATTACHMENT_PREVIEW_MAX_LENGTH = 200;
|
||||
/** Skip expensive content loading/conversion for notes larger than this. */
|
||||
const CONTENT_PREVIEW_SIZE_THRESHOLD = 10_000;
|
||||
|
||||
/**
|
||||
* Return `true` if the value is truthy, otherwise `undefined`.
|
||||
* Since `undefined` values are omitted from JSON serialization,
|
||||
* this effectively includes the field only when true.
|
||||
* Usage: `{ isInheritable: flag(attr.isInheritable) }`
|
||||
*/
|
||||
export function flag(value: boolean | undefined): true | undefined {
|
||||
return value ? true : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert note content to a format suitable for LLM consumption.
|
||||
* Text notes are converted from HTML to Markdown to reduce token usage.
|
||||
*/
|
||||
export function getNoteContentForLlm(note: BNote) {
|
||||
const content = note.getContent();
|
||||
if (typeof content !== "string") {
|
||||
// For binary content (images, files), use extracted text if available.
|
||||
const blob = note.blobId ? becca.getBlob({ blobId: note.blobId }) : null;
|
||||
if (blob?.textRepresentation) {
|
||||
return `[extracted text from ${note.type}]\n${blob.textRepresentation}`;
|
||||
}
|
||||
return "[binary content]";
|
||||
}
|
||||
if (note.type === "text") {
|
||||
return markdownExport.toMarkdown(content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LLM-provided content to a format suitable for storage.
|
||||
* For text notes, converts Markdown to HTML.
|
||||
*/
|
||||
export function setNoteContentFromLlm(note: BNote, content: string) {
|
||||
if (note.type === "text") {
|
||||
note.setContent(markdownImport.renderToHtml(content, note.title));
|
||||
} else {
|
||||
note.setContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a short plain-text content preview for a note, truncated to
|
||||
* {@link CONTENT_PREVIEW_MAX_LENGTH} characters. Useful for giving an LLM a
|
||||
* glimpse of the content without sending the full body.
|
||||
*
|
||||
* For large notes (>{@link CONTENT_PREVIEW_SIZE_THRESHOLD} bytes), returns a
|
||||
* size hint instead of loading and converting the full content.
|
||||
*/
|
||||
export function getContentPreview(note: BNote): string | null {
|
||||
if (!note.isContentAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check content size before loading to avoid expensive conversion for large notes
|
||||
const blob = note.blobId ? becca.getBlob({ blobId: note.blobId }) : null;
|
||||
if (blob && blob.contentLength > CONTENT_PREVIEW_SIZE_THRESHOLD) {
|
||||
const sizeKb = Math.round(blob.contentLength / 1024);
|
||||
return `[${sizeKb}KB - use get_note_content for full text]`;
|
||||
}
|
||||
|
||||
const full = getNoteContentForLlm(note);
|
||||
if (!full || full === "[binary content]") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (full.length <= CONTENT_PREVIEW_MAX_LENGTH) {
|
||||
return full;
|
||||
}
|
||||
|
||||
return `${full.slice(0, CONTENT_PREVIEW_MAX_LENGTH)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a short content preview for an attachment, or null if no readable
|
||||
* content is available. For text attachments the raw content is used; for
|
||||
* binary attachments (PDF, images) the OCR/extracted text is used when present.
|
||||
*/
|
||||
export function getAttachmentContentPreview(att: BAttachment): string | null {
|
||||
let text: string | null = null;
|
||||
|
||||
if (att.hasStringContent()) {
|
||||
const content = att.getContent();
|
||||
text = typeof content === "string" ? content : content.toString("utf-8");
|
||||
} else {
|
||||
const blob = att.blobId ? becca.getBlob({ blobId: att.blobId }) : null;
|
||||
text = blob?.textRepresentation ?? null;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.length <= ATTACHMENT_PREVIEW_MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, ATTACHMENT_PREVIEW_MAX_LENGTH)}…`;
|
||||
}
|
||||
|
||||
/** Limits for collections returned in system prompt context. */
|
||||
export const SYSTEM_PROMPT_LIMITS = {
|
||||
childNotes: 20,
|
||||
attributes: 20,
|
||||
attachments: 20
|
||||
} as const;
|
||||
|
||||
/** Limits for collections returned by the get_note tool. */
|
||||
export const TOOL_LIMITS = {
|
||||
childNotes: 50,
|
||||
attributes: 50,
|
||||
attachments: 50
|
||||
} as const;
|
||||
|
||||
interface NoteMetaLimits {
|
||||
childNotes: number;
|
||||
attributes: number;
|
||||
attachments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate an array and return it with total count metadata.
|
||||
* If the array exceeds `limit`, only the first `limit` items are returned.
|
||||
*/
|
||||
function truncated<T>(items: T[], limit: number) {
|
||||
return {
|
||||
totalCount: items.length,
|
||||
results: items.slice(0, limit)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full metadata object for a note. Used by both the `get_note` tool
|
||||
* and the system prompt.
|
||||
*
|
||||
* @param limits — controls how many child notes, attributes, and attachments
|
||||
* are included. Use {@link SYSTEM_PROMPT_LIMITS} for the system prompt and
|
||||
* {@link TOOL_LIMITS} for the `get_note` tool.
|
||||
*/
|
||||
export function getNoteMeta(note: BNote, limits: NoteMetaLimits) {
|
||||
const allChildNotes = note.getChildNotes().map((ch) => ({
|
||||
noteId: ch.noteId,
|
||||
title: ch.getTitleOrProtected()
|
||||
}));
|
||||
|
||||
const allAttributes = note.getAttributes().map((attr) => ({
|
||||
attributeId: attr.attributeId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: flag(attr.isInheritable)
|
||||
}));
|
||||
|
||||
const allAttachments = note.getAttachments().map((att) => ({
|
||||
attachmentId: att.attachmentId,
|
||||
role: att.role,
|
||||
mime: att.mime,
|
||||
title: att.title,
|
||||
contentLength: att.contentLength,
|
||||
contentPreview: getAttachmentContentPreview(att)
|
||||
}));
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
isProtected: flag(note.isProtected),
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type,
|
||||
mime: note.mime,
|
||||
dateCreated: note.dateCreated,
|
||||
dateModified: note.dateModified,
|
||||
parentNoteIds: note.getParentNotes().map((p) => p.noteId),
|
||||
childNotes: truncated(allChildNotes, limits.childNotes),
|
||||
attributes: truncated(allAttributes, limits.attributes),
|
||||
contentPreview: getContentPreview(note),
|
||||
attachments: truncated(allAttachments, limits.attachments)
|
||||
};
|
||||
}
|
||||
@@ -2,34 +2,11 @@
|
||||
* LLM tools for navigating the note hierarchy (tree structure, branches).
|
||||
*/
|
||||
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
|
||||
/**
|
||||
* Get the child notes of a given note.
|
||||
*/
|
||||
export const getChildNotes = tool({
|
||||
description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)")
|
||||
}),
|
||||
execute: async ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return note.getChildNotes().map((child) => ({
|
||||
noteId: child.noteId,
|
||||
title: child.getTitleOrProtected(),
|
||||
type: child.type,
|
||||
childCount: child.getChildNotes().length
|
||||
}));
|
||||
}
|
||||
});
|
||||
import { defineTools } from "./tool_registry.js";
|
||||
|
||||
//#region Subtree tool implementation
|
||||
const MAX_DEPTH = 5;
|
||||
@@ -75,28 +52,42 @@ function buildSubtree(note: BNote, depth: number, maxDepth: number): SubtreeNode
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a subtree of notes up to a specified depth.
|
||||
*/
|
||||
export const getSubtree = tool({
|
||||
description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"),
|
||||
depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`)
|
||||
}),
|
||||
execute: async ({ noteId, depth = 2 }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return buildSubtree(note, 0, depth);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
export const hierarchyTools = {
|
||||
get_child_notes: getChildNotes,
|
||||
get_subtree: getSubtree
|
||||
};
|
||||
export const hierarchyTools = defineTools({
|
||||
get_child_notes: {
|
||||
description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)")
|
||||
}),
|
||||
execute: ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return note.getChildNotes().map((child) => ({
|
||||
noteId: child.noteId,
|
||||
title: child.getTitleOrProtected(),
|
||||
type: child.type,
|
||||
childCount: child.getChildNotes().length
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
get_subtree: {
|
||||
description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"),
|
||||
depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`)
|
||||
}),
|
||||
execute: ({ noteId, depth = 2 }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return buildSubtree(note, 0, depth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,26 @@
|
||||
* These reuse the same logic as ETAPI without any HTTP overhead.
|
||||
*/
|
||||
|
||||
export { noteTools, currentNoteTools } from "./note_tools.js";
|
||||
export { noteTools } from "./note_tools.js";
|
||||
export { attributeTools } from "./attribute_tools.js";
|
||||
export { attachmentTools } from "./attachment_tools.js";
|
||||
export { hierarchyTools } from "./hierarchy_tools.js";
|
||||
export { skillTools } from "../skills/index.js";
|
||||
export type { ToolDefinition } from "./tool_registry.js";
|
||||
export { ToolRegistry } from "./tool_registry.js";
|
||||
|
||||
import { noteTools } from "./note_tools.js";
|
||||
import { attributeTools } from "./attribute_tools.js";
|
||||
import { attachmentTools } from "./attachment_tools.js";
|
||||
import { hierarchyTools } from "./hierarchy_tools.js";
|
||||
import { skillTools } from "../skills/index.js";
|
||||
import type { ToolRegistry } from "./tool_registry.js";
|
||||
|
||||
/** All tool registries, for consumers that need to iterate every tool (e.g. MCP). */
|
||||
export const allToolRegistries: ToolRegistry[] = [
|
||||
noteTools,
|
||||
attributeTools,
|
||||
attachmentTools,
|
||||
hierarchyTools,
|
||||
skillTools
|
||||
];
|
||||
|
||||
@@ -2,282 +2,234 @@
|
||||
* LLM tools for note operations (search, read, create, update, append).
|
||||
*/
|
||||
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import markdownExport from "../../export/markdown.js";
|
||||
import markdownImport from "../../import/markdown.js";
|
||||
import noteService from "../../notes.js";
|
||||
import SearchContext from "../../search/search_context.js";
|
||||
import searchService from "../../search/services/search.js";
|
||||
import { TOOL_LIMITS, getContentPreview, getNoteContentForLlm, getNoteMeta, setNoteContentFromLlm } from "./helpers.js";
|
||||
import { defineTools } from "./tool_registry.js";
|
||||
|
||||
/**
|
||||
* Convert note content to a format suitable for LLM consumption.
|
||||
* Text notes are converted from HTML to Markdown to reduce token usage.
|
||||
*/
|
||||
export function getNoteContentForLlm(note: { type: string; blobId?: string; getContent: () => string | Buffer }) {
|
||||
const content = note.getContent();
|
||||
if (typeof content !== "string") {
|
||||
// For binary content (images, files), use extracted text if available.
|
||||
const blob = note.blobId ? becca.getBlob({ blobId: note.blobId }) : null;
|
||||
if (blob?.textRepresentation) {
|
||||
return `[extracted text from ${note.type}]\n${blob.textRepresentation}`;
|
||||
}
|
||||
return "[binary content]";
|
||||
}
|
||||
if (note.type === "text") {
|
||||
return markdownExport.toMarkdown(content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LLM-provided content to a format suitable for storage.
|
||||
* For text notes, converts Markdown to HTML.
|
||||
*/
|
||||
function setNoteContentFromLlm(note: { type: string; title: string; setContent: (content: string) => void }, content: string) {
|
||||
if (note.type === "text") {
|
||||
note.setContent(markdownImport.renderToHtml(content, note.title));
|
||||
} else {
|
||||
note.setContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for notes in the knowledge base.
|
||||
*/
|
||||
export const searchNotes = tool({
|
||||
description: [
|
||||
"Search for notes in the user's knowledge base using Trilium search syntax.",
|
||||
"For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.",
|
||||
"Common patterns:",
|
||||
"- Full-text: 'rings tolkien' (notes containing both words)",
|
||||
"- By label: '#book', '#status = done', '#year >= 2000'",
|
||||
"- By type: 'note.type = code'",
|
||||
"- By relation: '~author', '~author.title *= Tolkien'",
|
||||
"- Combined: 'tolkien #book' (full-text + label filter)",
|
||||
"- Negation: '#!archived' (notes WITHOUT label)"
|
||||
].join(" "),
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Search query in Trilium search syntax"),
|
||||
fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."),
|
||||
includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."),
|
||||
ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."),
|
||||
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.")
|
||||
}),
|
||||
execute: async ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch,
|
||||
includeArchivedNotes,
|
||||
ancestorNoteId
|
||||
});
|
||||
const results = searchService.findResultsWithQuery(query, searchContext);
|
||||
|
||||
return results.slice(0, limit).map(sr => {
|
||||
const note = becca.notes[sr.noteId];
|
||||
if (!note) return null;
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the content of a specific note.
|
||||
*/
|
||||
export const readNote = tool({
|
||||
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to read")
|
||||
}),
|
||||
execute: async ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected" };
|
||||
}
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type,
|
||||
content: getNoteContentForLlm(note)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the content of a note.
|
||||
*/
|
||||
export const updateNoteContent = tool({
|
||||
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to update"),
|
||||
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
|
||||
}),
|
||||
execute: async ({ noteId, content }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (!note.hasStringContent()) {
|
||||
return { error: `Cannot update content for note type: ${note.type}` };
|
||||
}
|
||||
|
||||
note.saveRevision();
|
||||
setNoteContentFromLlm(note, content);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Append content to a note.
|
||||
*/
|
||||
export const appendToNote = tool({
|
||||
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to append to"),
|
||||
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
|
||||
}),
|
||||
execute: async ({ noteId, content }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (!note.hasStringContent()) {
|
||||
return { error: `Cannot update content for note type: ${note.type}` };
|
||||
}
|
||||
|
||||
const existingContent = note.getContent();
|
||||
if (typeof existingContent !== "string") {
|
||||
return { error: "Note has binary content" };
|
||||
}
|
||||
|
||||
let newContent: string;
|
||||
if (note.type === "text") {
|
||||
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
|
||||
newContent = existingContent + htmlToAppend;
|
||||
} else {
|
||||
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
|
||||
}
|
||||
|
||||
note.saveRevision();
|
||||
note.setContent(newContent);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new note.
|
||||
*/
|
||||
export const createNote = tool({
|
||||
description: [
|
||||
"Create a new note in the user's knowledge base. Returns the created note's ID and title.",
|
||||
"Set type to 'text' for rich text notes (content in Markdown) or 'code' for code notes (must also set mime).",
|
||||
"Common mime values for code notes:",
|
||||
"'application/javascript;env=frontend' (JS frontend),",
|
||||
"'application/javascript;env=backend' (JS backend),",
|
||||
"'text/jsx' (Preact JSX, preferred for frontend widgets),",
|
||||
"'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'."
|
||||
].join(" "),
|
||||
inputSchema: z.object({
|
||||
parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."),
|
||||
title: z.string().describe("The title of the new note"),
|
||||
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"),
|
||||
type: z.enum(["text", "code"]).describe("The type of note to create."),
|
||||
mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for text notes.")
|
||||
}),
|
||||
execute: async ({ parentNoteId, title, content, type, mime }) => {
|
||||
if (type === "code" && !mime) {
|
||||
return { error: "mime is required when creating code notes" };
|
||||
}
|
||||
|
||||
const parentNote = becca.getNote(parentNoteId);
|
||||
if (!parentNote) {
|
||||
return { error: "Parent note not found" };
|
||||
}
|
||||
if (!parentNote.isContentAvailable()) {
|
||||
return { error: "Cannot create note under a protected parent" };
|
||||
}
|
||||
|
||||
const htmlContent = type === "text"
|
||||
? markdownImport.renderToHtml(content, title)
|
||||
: content;
|
||||
|
||||
try {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId,
|
||||
title,
|
||||
content: htmlContent,
|
||||
type,
|
||||
...(mime ? { mime } : {})
|
||||
export const noteTools = defineTools({
|
||||
search_notes: {
|
||||
description: [
|
||||
"Search for notes in the user's knowledge base using Trilium search syntax.",
|
||||
"For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.",
|
||||
"Common patterns:",
|
||||
"- Full-text: 'rings tolkien' (notes containing both words)",
|
||||
"- By label: '#book', '#status = done', '#year >= 2000'",
|
||||
"- By type: 'note.type = code'",
|
||||
"- By relation: '~author', '~author.title *= Tolkien'",
|
||||
"- Combined: 'tolkien #book' (full-text + label filter)",
|
||||
"- Negation: '#!archived' (notes WITHOUT label)"
|
||||
].join(" "),
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Search query in Trilium search syntax"),
|
||||
fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."),
|
||||
includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."),
|
||||
ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."),
|
||||
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.")
|
||||
}),
|
||||
execute: ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch,
|
||||
includeArchivedNotes,
|
||||
ancestorNoteId
|
||||
});
|
||||
const results = searchService.findResultsWithQuery(query, searchContext);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : "Failed to create note" };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the content of the note the user is currently viewing.
|
||||
* Created dynamically so it captures the contextNoteId.
|
||||
*/
|
||||
export function currentNoteTools(contextNoteId: string) {
|
||||
return {
|
||||
get_current_note: tool({
|
||||
description: "Read the content of the note the user is currently viewing. Call this when the user asks about or refers to their current note.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const note = becca.getNote(contextNoteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected" };
|
||||
}
|
||||
|
||||
const notes = results.slice(0, limit).map(sr => {
|
||||
const note = becca.notes[sr.noteId];
|
||||
if (!note) return null;
|
||||
const parentNote = note.getParentNotes()[0];
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type,
|
||||
content: getNoteContentForLlm(note)
|
||||
parentTitle: parentNote?.getTitleOrProtected() ?? null,
|
||||
contentPreview: getContentPreview(note)
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
export const noteTools = {
|
||||
search_notes: searchNotes,
|
||||
read_note: readNote,
|
||||
update_note_content: updateNoteContent,
|
||||
append_to_note: appendToNote,
|
||||
create_note: createNote
|
||||
};
|
||||
return {
|
||||
totalResults: results.length,
|
||||
results: notes
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
get_note: {
|
||||
description: "Get a note's metadata by its ID. Returns title, type, mime, dates, parent/child relationships, attributes, and a short content preview. Use get_note_content for the full content.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to retrieve")
|
||||
}),
|
||||
execute: ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
|
||||
return getNoteMeta(note, TOOL_LIMITS);
|
||||
}
|
||||
},
|
||||
|
||||
get_note_content: {
|
||||
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to read")
|
||||
}),
|
||||
execute: ({ noteId }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected" };
|
||||
}
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
content: getNoteContentForLlm(note)
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
update_note_content: {
|
||||
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to update"),
|
||||
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
|
||||
}),
|
||||
mutates: true,
|
||||
execute: ({ noteId, content }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (!note.hasStringContent()) {
|
||||
return { error: `Cannot update content for note type: ${note.type}` };
|
||||
}
|
||||
|
||||
note.saveRevision();
|
||||
setNoteContentFromLlm(note, content);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
append_to_note: {
|
||||
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe("The ID of the note to append to"),
|
||||
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
|
||||
}),
|
||||
mutates: true,
|
||||
execute: ({ noteId, content }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return { error: "Note not found" };
|
||||
}
|
||||
if (!note.isContentAvailable()) {
|
||||
return { error: "Note is protected and cannot be modified" };
|
||||
}
|
||||
if (!note.hasStringContent()) {
|
||||
return { error: `Cannot update content for note type: ${note.type}` };
|
||||
}
|
||||
|
||||
const existingContent = note.getContent();
|
||||
if (typeof existingContent !== "string") {
|
||||
return { error: "Note has binary content" };
|
||||
}
|
||||
|
||||
let newContent: string;
|
||||
if (note.type === "text") {
|
||||
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
|
||||
newContent = existingContent + htmlToAppend;
|
||||
} else {
|
||||
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
|
||||
}
|
||||
|
||||
note.saveRevision();
|
||||
note.setContent(newContent);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
create_note: {
|
||||
description: [
|
||||
"Create a new note in the user's knowledge base. Returns the created note's ID and title.",
|
||||
"Note types:",
|
||||
"- 'text': rich text (provide content in Markdown)",
|
||||
"- 'code': source code (must also set mime)",
|
||||
"- 'render': displays output of a child code note (content is empty, add a code note as child and set ~renderNote relation)",
|
||||
"- 'book': container that displays children as a book/list",
|
||||
"- 'mermaid': Mermaid diagram source",
|
||||
"- 'canvas': Excalidraw drawing (JSON content)",
|
||||
"- 'webView': embedded web page (set content to URL or HTML)",
|
||||
"- 'relationMap': visual map of note relations (JSON content)",
|
||||
"- 'search': saved search (content is the search query)",
|
||||
"- 'mindMap': mind map (JSON content)",
|
||||
"Common mime values for code notes:",
|
||||
"'application/javascript;env=frontend' (JS frontend),",
|
||||
"'application/javascript;env=backend' (JS backend),",
|
||||
"'text/jsx' (Preact JSX, preferred for frontend widgets),",
|
||||
"'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'."
|
||||
].join(" "),
|
||||
inputSchema: z.object({
|
||||
parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."),
|
||||
title: z.string().describe("The title of the new note"),
|
||||
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes, empty string for render notes)"),
|
||||
type: z.enum(["text", "code", "render", "book", "mermaid", "canvas", "webView", "relationMap", "search", "mindMap"]).describe("The type of note to create."),
|
||||
mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for other types.")
|
||||
}),
|
||||
mutates: true,
|
||||
execute: ({ parentNoteId, title, content, type, mime }) => {
|
||||
if (type === "code" && !mime) {
|
||||
return { error: "mime is required when creating code notes" };
|
||||
}
|
||||
|
||||
const parentNote = becca.getNote(parentNoteId);
|
||||
if (!parentNote) {
|
||||
return { error: "Parent note not found" };
|
||||
}
|
||||
if (!parentNote.isContentAvailable()) {
|
||||
return { error: "Cannot create note under a protected parent" };
|
||||
}
|
||||
|
||||
const htmlContent = type === "text"
|
||||
? markdownImport.renderToHtml(content, title)
|
||||
: content;
|
||||
|
||||
try {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId,
|
||||
title,
|
||||
content: htmlContent,
|
||||
type,
|
||||
...(mime ? { mime } : {})
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : "Failed to create note" };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
94
apps/server/src/services/llm/tools/tool_registry.ts
Normal file
94
apps/server/src/services/llm/tools/tool_registry.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Lightweight wrapper around AI tool definitions that carries extra metadata
|
||||
* (e.g. `mutates`) while remaining compatible with the Vercel AI SDK ToolSet.
|
||||
*
|
||||
* Each tool module calls `defineTools({ ... })` to declare its tools.
|
||||
* Consumers can then:
|
||||
* - iterate over entries with `for (const [name, def] of registry)` (MCP)
|
||||
* - convert to an AI SDK ToolSet with `registry.toToolSet()` (LLM chat)
|
||||
*/
|
||||
|
||||
import { tool } from "ai";
|
||||
import type { z } from "zod";
|
||||
import type { ToolSet } from "ai";
|
||||
|
||||
import sql from "../../sql.js";
|
||||
|
||||
/**
|
||||
* Type constraint that rejects Promises at compile time.
|
||||
* Works by requiring `then` to be void if present - Promises have `then: Function`.
|
||||
*/
|
||||
type NotAPromise<T> = T & { then?: void };
|
||||
|
||||
interface MutatingToolDefinition {
|
||||
description: string;
|
||||
inputSchema: z.ZodType;
|
||||
/** Marks this tool as modifying data (needs CLS + transaction wrapping). */
|
||||
mutates: true;
|
||||
/**
|
||||
* Execute the tool synchronously. Must NOT be async because better-sqlite3
|
||||
* transactions are synchronous and would commit before awaits complete.
|
||||
*/
|
||||
execute: (args: any) => NotAPromise<object>;
|
||||
}
|
||||
|
||||
interface ReadOnlyToolDefinition {
|
||||
description: string;
|
||||
inputSchema: z.ZodType;
|
||||
mutates?: false;
|
||||
/** Execute the tool synchronously. Kept sync for consistency with MCP. */
|
||||
execute: (args: any) => NotAPromise<object>;
|
||||
}
|
||||
|
||||
export type ToolDefinition = MutatingToolDefinition | ReadOnlyToolDefinition;
|
||||
|
||||
/**
|
||||
* A named collection of tool definitions that can be iterated or converted
|
||||
* to an AI SDK ToolSet.
|
||||
*/
|
||||
export class ToolRegistry implements Iterable<[string, ToolDefinition]> {
|
||||
constructor(private readonly tools: Record<string, ToolDefinition>) {}
|
||||
|
||||
/** Iterate over `[name, definition]` pairs. */
|
||||
[Symbol.iterator](): Iterator<[string, ToolDefinition]> {
|
||||
return Object.entries(this.tools)[Symbol.iterator]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to an AI SDK ToolSet for use with the LLM chat providers.
|
||||
* Mutating tools are wrapped in a transaction for consistency with MCP.
|
||||
* (CLS context is provided by the route handler.)
|
||||
*/
|
||||
toToolSet(): ToolSet {
|
||||
const set: ToolSet = {};
|
||||
for (const [name, def] of this) {
|
||||
const execute = def.mutates
|
||||
? (args: unknown) => sql.transactional(() => def.execute(args))
|
||||
: def.execute;
|
||||
|
||||
set[name] = tool({
|
||||
description: def.description,
|
||||
inputSchema: def.inputSchema,
|
||||
execute
|
||||
});
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a group of tools with metadata.
|
||||
*
|
||||
* ```ts
|
||||
* export const noteTools = defineTools({
|
||||
* search_notes: { description: "...", inputSchema: z.object({...}), execute: (args) => {...} },
|
||||
* create_note: { description: "...", inputSchema: z.object({...}), mutates: true, execute: (args) => {...} },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Note: All tools MUST have synchronous execute functions (no async/await)
|
||||
* because better-sqlite3 transactions are synchronous and MCP expects sync results.
|
||||
*/
|
||||
export function defineTools(tools: Record<string, ToolDefinition>): ToolRegistry {
|
||||
return new ToolRegistry(tools);
|
||||
}
|
||||
53
apps/server/src/services/mcp/mcp_server.ts
Normal file
53
apps/server/src/services/mcp/mcp_server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) server for Trilium Notes.
|
||||
*
|
||||
* Exposes existing LLM tools via the MCP protocol so external AI agents
|
||||
* (e.g. Claude Desktop) can interact with Trilium.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import appInfo from "../app_info.js";
|
||||
import cls from "../cls.js";
|
||||
import { allToolRegistries } from "../llm/tools/index.js";
|
||||
import type { ToolDefinition } from "../llm/tools/tool_registry.js";
|
||||
import sql from "../sql.js";
|
||||
|
||||
/**
|
||||
* Register a tool definition on the MCP server.
|
||||
*
|
||||
* Write operations are wrapped in CLS + transaction context so that
|
||||
* Becca entity tracking works correctly.
|
||||
*/
|
||||
function registerTool(server: McpServer, name: string, def: ToolDefinition) {
|
||||
server.registerTool(name, {
|
||||
description: def.description,
|
||||
inputSchema: def.inputSchema
|
||||
}, (args: any): CallToolResult => {
|
||||
const result = cls.init(() => {
|
||||
cls.set("componentId", "mcp");
|
||||
|
||||
return def.mutates
|
||||
? sql.transactional(() => def.execute(args))
|
||||
: def.execute(args);
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
});
|
||||
}
|
||||
|
||||
export function createMcpServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "trilium-notes",
|
||||
version: appInfo.appVersion
|
||||
});
|
||||
|
||||
for (const registry of allToolRegistries) {
|
||||
for (const [name, def] of registry) {
|
||||
registerTool(server, name, def);
|
||||
}
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -212,7 +212,8 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "experimentalFeatures", value: "[]", isSynced: true },
|
||||
|
||||
// AI / LLM
|
||||
{ name: "llmProviders", value: "[]", isSynced: false },
|
||||
{ name: "llmProviders", value: "[]", isSynced: true },
|
||||
{ name: "mcpEnabled", value: "false", isSynced: false },
|
||||
|
||||
// OCR options
|
||||
{ name: "ocrAutoProcessImages", value: "false", isSynced: true },
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "1.11.20",
|
||||
"dayjs-plugin-utc": "0.1.2"
|
||||
"dayjs-plugin-utc": "0.1.2",
|
||||
"marked": "17.0.5"
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from "./lib/week_utils.js";
|
||||
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
|
||||
export * from "./lib/spreadsheet/render_to_html.js";
|
||||
export * from "./lib/llm_api.js";
|
||||
export * from "./lib/marked_extensions.js";
|
||||
|
||||
96
packages/commons/src/lib/marked_extensions.spec.ts
Normal file
96
packages/commons/src/lib/marked_extensions.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Marked } from "marked";
|
||||
import { createWikiLinkExtension, createTransclusionExtension } from "./marked_extensions.js";
|
||||
|
||||
describe("marked_extensions", () => {
|
||||
describe("createWikiLinkExtension", () => {
|
||||
it("should render basic wiki links", () => {
|
||||
const marked = new Marked({ extensions: [createWikiLinkExtension()] });
|
||||
const result = marked.parse("[[abc123]]");
|
||||
expect(result).toContain('<a class="reference-link" href="/abc123">abc123</a>');
|
||||
});
|
||||
|
||||
it("should escape HTML in link text to prevent XSS", () => {
|
||||
const marked = new Marked({ extensions: [createWikiLinkExtension()] });
|
||||
// Malicious input attempting to inject HTML/script via link text
|
||||
const result = marked.parse("[[<script>alert('xss')</script>]]");
|
||||
|
||||
// The output should NOT contain unescaped script tags
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("</script>");
|
||||
// Should be properly escaped
|
||||
expect(result).toContain("<script>");
|
||||
});
|
||||
|
||||
it("should escape attribute-breaking characters in href to prevent XSS", () => {
|
||||
const marked = new Marked({ extensions: [createWikiLinkExtension()] });
|
||||
// Malicious input attempting to break out of href attribute
|
||||
const result = marked.parse('[[x" onclick="alert(1)"]]');
|
||||
|
||||
// The output should NOT allow breaking out of the href attribute
|
||||
// The key is that quotes are escaped, so onclick can't become an actual attribute
|
||||
expect(result).not.toContain('href="/x"'); // Would indicate unescaped quote breaking out
|
||||
expect(result).not.toContain('" onclick="'); // Unescaped pattern that would create event handler
|
||||
// Double quotes should be escaped
|
||||
expect(result).toContain('"');
|
||||
// The href should contain the escaped malicious input, not be broken by it
|
||||
expect(result).toContain('href="/x"');
|
||||
});
|
||||
|
||||
it("should handle custom formatHref safely", () => {
|
||||
const marked = new Marked({
|
||||
extensions: [createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })]
|
||||
});
|
||||
const result = marked.parse('[[x"><img src=x onerror=alert(1)>]]');
|
||||
|
||||
// The < and > should be escaped so no img tag is injected
|
||||
expect(result).not.toContain('<img src'); // Actual img tag
|
||||
expect(result).toContain('<img'); // Escaped version
|
||||
expect(result).toContain('>'); // Escaped >
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTransclusionExtension", () => {
|
||||
it("should render basic transclusions", () => {
|
||||
const marked = new Marked({ extensions: [createTransclusionExtension()] });
|
||||
const result = marked.parse("![[abc123]]");
|
||||
expect(result).toContain('<img src="/abc123">');
|
||||
});
|
||||
|
||||
it("should escape attribute-breaking characters in src to prevent XSS", () => {
|
||||
const marked = new Marked({ extensions: [createTransclusionExtension()] });
|
||||
// Malicious input attempting to break out of src attribute
|
||||
const result = marked.parse('![[x" onerror="alert(1)"]]');
|
||||
|
||||
// The output should NOT allow breaking out of the src attribute
|
||||
// The key is that quotes are escaped, so onerror can't become an actual attribute
|
||||
expect(result).not.toContain('src="/x"'); // Would indicate unescaped quote
|
||||
expect(result).not.toContain('" onerror="'); // Unescaped pattern
|
||||
// Double quotes should be escaped
|
||||
expect(result).toContain('"');
|
||||
// The src should contain the escaped malicious input
|
||||
expect(result).toContain('src="/x"');
|
||||
});
|
||||
|
||||
it("should escape HTML injection attempts in transclusion", () => {
|
||||
const marked = new Marked({ extensions: [createTransclusionExtension()] });
|
||||
// Attempt to close img tag and inject script
|
||||
const result = marked.parse('![[x"><script>alert(1)</script>]]');
|
||||
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).not.toContain('</script>');
|
||||
});
|
||||
|
||||
it("should handle custom formatSrc safely", () => {
|
||||
const marked = new Marked({
|
||||
extensions: [createTransclusionExtension({ formatSrc: (id) => `/api/images/${id}` })]
|
||||
});
|
||||
const result = marked.parse('![[x" onload="alert(1)]]');
|
||||
|
||||
// The quote should be escaped so onload can't become an actual attribute
|
||||
expect(result).not.toContain('src="/api/images/x"'); // Would indicate unescaped quote
|
||||
expect(result).toContain('"'); // Quote should be escaped
|
||||
expect(result).toContain('src="/api/images/x"'); // Escaped version
|
||||
});
|
||||
});
|
||||
});
|
||||
107
packages/commons/src/lib/marked_extensions.ts
Normal file
107
packages/commons/src/lib/marked_extensions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { TokenizerAndRendererExtension } from "marked";
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS attacks.
|
||||
* Used for both attribute values and text content.
|
||||
*/
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface WikiLinkOptions {
|
||||
/** Format the href for the link. Defaults to `/${noteId}` */
|
||||
formatHref?: (noteId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wiki-link extension for internal note links: [[noteId]]
|
||||
*
|
||||
* @example
|
||||
* // Server-side (for import)
|
||||
* createWikiLinkExtension() // uses default /${noteId}
|
||||
*
|
||||
* // Client-side (for navigation)
|
||||
* createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })
|
||||
*/
|
||||
export function createWikiLinkExtension(options: WikiLinkOptions = {}): TokenizerAndRendererExtension {
|
||||
const formatHref = options.formatHref ?? ((id) => `/${id}`);
|
||||
|
||||
return {
|
||||
name: "wikiLink",
|
||||
level: "inline",
|
||||
|
||||
start(src: string) {
|
||||
return src.indexOf("[[");
|
||||
},
|
||||
|
||||
tokenizer(src) {
|
||||
const match = /^\[\[([^\]]+?)\]\]/.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: "wikiLink",
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
href: match[1].trim()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderer(token) {
|
||||
const noteId = token.href as string;
|
||||
return `<a class="reference-link" href="${escapeHtml(formatHref(noteId))}">${escapeHtml(token.text as string)}</a>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransclusionOptions {
|
||||
/** Format the src for the image/embed. Defaults to `/${noteId}` */
|
||||
formatSrc?: (noteId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a transclusion extension for embedding note content: ![[noteId]]
|
||||
* Terminology inspired by https://silverbullet.md/Transclusions
|
||||
*
|
||||
* @example
|
||||
* createTransclusionExtension() // uses default /${noteId}
|
||||
* createTransclusionExtension({ formatSrc: (id) => `/api/images/${id}` })
|
||||
*/
|
||||
export function createTransclusionExtension(options: TransclusionOptions = {}): TokenizerAndRendererExtension {
|
||||
const formatSrc = options.formatSrc ?? ((id) => `/${id}`);
|
||||
|
||||
return {
|
||||
name: "transclusion",
|
||||
level: "inline",
|
||||
|
||||
start(src: string) {
|
||||
return src.match(/!\[\[/)?.index;
|
||||
},
|
||||
|
||||
tokenizer(src) {
|
||||
const match = /^!\[\[([^\]]+?)\]\]/.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: "transclusion",
|
||||
raw: match[0],
|
||||
href: match[1].trim()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderer(token) {
|
||||
const noteId = token.href as string;
|
||||
return `<img src="${escapeHtml(formatSrc(noteId))}">`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Pre-configured wiki-link extension for server-side (uses /noteId format) */
|
||||
export const wikiLinkExtension = createWikiLinkExtension();
|
||||
|
||||
/** Pre-configured transclusion extension for server-side (uses /noteId format) */
|
||||
export const transclusionExtension = createTransclusionExtension();
|
||||
@@ -144,6 +144,8 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
|
||||
// AI / LLM
|
||||
/** JSON array of configured LLM providers with their API keys */
|
||||
llmProviders: string;
|
||||
/** Whether the MCP (Model Context Protocol) server endpoint is enabled. */
|
||||
mcpEnabled: boolean;
|
||||
|
||||
// OCR options
|
||||
ocrEnabled: boolean;
|
||||
|
||||
120
pnpm-lock.yaml
generated
120
pnpm-lock.yaml
generated
@@ -565,6 +565,9 @@ importers:
|
||||
'@ai-sdk/openai':
|
||||
specifier: 3.0.49
|
||||
version: 3.0.49(zod@4.3.6)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.12.1
|
||||
version: 1.29.0(zod@4.3.6)
|
||||
ai:
|
||||
specifier: 6.0.142
|
||||
version: 6.0.142(zod@4.3.6)
|
||||
@@ -574,6 +577,9 @@ importers:
|
||||
html-to-text:
|
||||
specifier: 9.0.5
|
||||
version: 9.0.5
|
||||
js-yaml:
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
node-html-parser:
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
@@ -641,6 +647,9 @@ importers:
|
||||
'@types/ini':
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
'@types/js-yaml':
|
||||
specifier: 4.0.9
|
||||
version: 4.0.9
|
||||
'@types/mime-types':
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
@@ -1438,6 +1447,9 @@ importers:
|
||||
dayjs-plugin-utc:
|
||||
specifier: 0.1.2
|
||||
version: 0.1.2
|
||||
marked:
|
||||
specifier: 17.0.5
|
||||
version: 17.0.5
|
||||
|
||||
packages/express-partial-content:
|
||||
dependencies:
|
||||
@@ -3420,6 +3432,12 @@ packages:
|
||||
'@hapi/topo@5.1.0':
|
||||
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
|
||||
|
||||
'@hono/node-server@1.19.12':
|
||||
resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -4024,6 +4042,16 @@ packages:
|
||||
'@mixmark-io/domino@2.2.0':
|
||||
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0':
|
||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: '>=4.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@mswjs/interceptors@0.37.6':
|
||||
resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -9596,6 +9624,10 @@ packages:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource@3.0.7:
|
||||
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
execa@1.0.0:
|
||||
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -10330,6 +10362,10 @@ packages:
|
||||
hoist-non-react-statics@2.5.5:
|
||||
resolution: {integrity: sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==}
|
||||
|
||||
hono@4.12.9:
|
||||
resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hookable@6.0.1:
|
||||
resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==}
|
||||
|
||||
@@ -10974,10 +11010,6 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@3.1.1:
|
||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isexe@3.1.5:
|
||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -11031,6 +11063,9 @@ packages:
|
||||
resolution: {integrity: sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==}
|
||||
engines: {node: '>=10.13.0 < 13 || >=13.7.0'}
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
jotai-scope@0.7.2:
|
||||
resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==}
|
||||
peerDependencies:
|
||||
@@ -11138,6 +11173,9 @@ packages:
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
@@ -12860,6 +12898,10 @@ packages:
|
||||
resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@@ -16136,6 +16178,11 @@ packages:
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
zod-to-json-schema@3.25.2:
|
||||
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||
peerDependencies:
|
||||
zod: '>=4.0.0'
|
||||
|
||||
zod@4.1.12:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
@@ -17150,6 +17197,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.6.1
|
||||
'@ckeditor/ckeditor5-widget': 47.6.1
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-cloud-services@47.6.1':
|
||||
dependencies:
|
||||
@@ -17447,8 +17496,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.6.1
|
||||
'@ckeditor/ckeditor5-utils': 47.6.1
|
||||
ckeditor5: 47.6.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-export-word@47.6.1':
|
||||
dependencies:
|
||||
@@ -17548,6 +17595,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.6.1
|
||||
'@ckeditor/ckeditor5-widget': 47.6.1
|
||||
ckeditor5: 47.6.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-html-embed@47.6.1':
|
||||
dependencies:
|
||||
@@ -17607,8 +17656,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.6.1
|
||||
'@ckeditor/ckeditor5-utils': 47.6.1
|
||||
ckeditor5: 47.6.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-indent@47.6.1':
|
||||
dependencies:
|
||||
@@ -17734,8 +17781,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.6.1
|
||||
ckeditor5: 47.6.1
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-merge-fields@47.6.1':
|
||||
dependencies:
|
||||
@@ -17748,8 +17793,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-widget': 47.6.1
|
||||
ckeditor5: 47.6.1
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-minimap@47.6.1':
|
||||
dependencies:
|
||||
@@ -19421,6 +19464,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
|
||||
'@hono/node-server@1.19.12(hono@4.12.9)':
|
||||
dependencies:
|
||||
hono: 4.12.9
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
@@ -20134,6 +20181,28 @@ snapshots:
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.12(hono@4.12.9)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.3.2(express@5.2.1)
|
||||
hono: 4.12.9
|
||||
jose: 6.2.2
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.2(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mswjs/interceptors@0.37.6':
|
||||
dependencies:
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
@@ -24796,6 +24865,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
ajv: 8.13.0
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.14.0):
|
||||
dependencies:
|
||||
ajv: 6.14.0
|
||||
@@ -27522,6 +27595,10 @@ snapshots:
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.6
|
||||
|
||||
execa@1.0.0:
|
||||
dependencies:
|
||||
cross-spawn: 6.0.6
|
||||
@@ -28502,6 +28579,8 @@ snapshots:
|
||||
|
||||
hoist-non-react-statics@2.5.5: {}
|
||||
|
||||
hono@4.12.9: {}
|
||||
|
||||
hookable@6.0.1: {}
|
||||
|
||||
hookified@1.15.0: {}
|
||||
@@ -28767,7 +28846,6 @@ snapshots:
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
icss-utils@5.1.0(postcss@8.5.8):
|
||||
dependencies:
|
||||
@@ -29130,8 +29208,6 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.1: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
|
||||
isexe@4.0.0: {}
|
||||
@@ -29214,6 +29290,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@panva/asn1.js': 1.0.0
|
||||
|
||||
jose@6.2.2: {}
|
||||
|
||||
jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.1.7)(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
jotai: 2.11.0(@types/react@19.1.7)(react@19.2.4)
|
||||
@@ -29336,6 +29414,8 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
@@ -31473,6 +31553,8 @@ snapshots:
|
||||
dependencies:
|
||||
pngjs: 6.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@@ -32014,7 +32096,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.0
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-loader@0.5.1: {}
|
||||
@@ -35062,7 +35144,7 @@ snapshots:
|
||||
|
||||
which@5.0.0:
|
||||
dependencies:
|
||||
isexe: 3.1.1
|
||||
isexe: 3.1.5
|
||||
|
||||
which@6.0.1:
|
||||
dependencies:
|
||||
@@ -35343,6 +35425,10 @@ snapshots:
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@4.1.12: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
Reference in New Issue
Block a user