mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 10:46:57 +02:00
Merge remote-tracking branch 'origin/main' into standalone
; Conflicts: ; CLAUDE.md ; apps/client/src/widgets/collections/board/data.spec.ts ; apps/server/package.json ; apps/server/src/routes/routes.ts ; apps/server/src/services/app_info.ts ; apps/server/src/services/blob-interface.ts ; apps/server/src/services/entity_changes.ts ; apps/server/src/services/handlers.ts ; apps/server/src/services/hidden_subtree.ts ; apps/server/src/services/image.ts ; apps/server/src/services/options_init.ts ; apps/server/src/services/search/services/search.ts ; packages/trilium-core/src/services/blob.ts ; packages/trilium-core/src/services/import/markdown.ts ; packages/trilium-core/src/services/import/markdown/wikilink_internal_link.ts ; packages/trilium-core/src/services/import/markdown/wikilink_transclusion.ts ; packages/trilium-core/src/services/search/expressions/ocr_content.ts ; packages/trilium-core/src/services/search/search_result.ts ; packages/trilium-core/src/services/search/services/parse.ts ; pnpm-lock.yaml
This commit is contained in:
21
.github/copilot-instructions.md
vendored
21
.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`)
|
||||
@@ -275,6 +289,12 @@ View types are configured via `#viewType` label (e.g., `#viewType=table`). Each
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
|
||||
|
||||
### Updating PDF.js
|
||||
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||
3. Run `pnpm build` to verify success
|
||||
4. Commit all changes including updated viewer files
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
@@ -299,6 +319,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -157,6 +157,7 @@ SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/servi
|
||||
- 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/>"`)
|
||||
|
||||
Three inheritance mechanisms:
|
||||
1. **Standard**: `note.getInheritableAttributes()` walks parent tree
|
||||
@@ -213,12 +214,37 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
|
||||
- `apps/server/src/routes/routes.ts` — API route registration
|
||||
- `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction
|
||||
|
||||
### 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
|
||||
|
||||
### Updating PDF.js
|
||||
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||
3. Run `pnpm build` to verify success
|
||||
4. Commit all changes including updated viewer files
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
### Server-Side Static Assets
|
||||
- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/`
|
||||
- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`)
|
||||
- **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
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.2",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"i18next": "26.0.3",
|
||||
"i18next-http-backend": "3.0.4",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
@@ -69,7 +69,7 @@
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.4",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "17.0.1",
|
||||
"react-i18next": "17.0.2",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"rrule": "2.8.1",
|
||||
@@ -90,6 +90,6 @@
|
||||
"happy-dom": "20.8.9",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.4.0"
|
||||
"vite-plugin-static-copy": "4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export type CommandMappings = {
|
||||
ninthTab: CommandData;
|
||||
lastTab: CommandData;
|
||||
showNoteSource: CommandData;
|
||||
showNoteOCRText: CommandData;
|
||||
showSQLConsole: CommandData;
|
||||
showBackendLog: CommandData;
|
||||
showCheatsheet: CommandData;
|
||||
|
||||
@@ -148,6 +148,19 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async showNoteOCRTextCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "ocr"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentsCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./content_renderer.css";
|
||||
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import { normalizeMimeTypeForCKEditor, type TextRepresentationResponse } from "@triliumnext/commons";
|
||||
import { h, render } from "preact";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
|
||||
@@ -15,6 +15,7 @@ import openService from "./open.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import renderService from "./render.js";
|
||||
import server from "./server.js";
|
||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||
import utils, { getErrorMessage } from "./utils.js";
|
||||
|
||||
@@ -32,6 +33,7 @@ export interface RenderOptions {
|
||||
includeArchivedNotes?: boolean;
|
||||
/** Set of note IDs that have already been seen during rendering to prevent infinite recursion. */
|
||||
seenNoteIds?: Set<string>;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
@@ -55,9 +57,9 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
await renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
await renderFile(entity, type, $renderedContent, options);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
@@ -138,7 +140,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
const encodedTitle = encodeURIComponent(entity.title);
|
||||
|
||||
let url;
|
||||
@@ -146,13 +148,14 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
if (entity instanceof FNote) {
|
||||
url = `api/images/${entity.noteId}/${encodedTitle}?${Math.random()}`;
|
||||
} else if (entity instanceof FAttachment) {
|
||||
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}">`;
|
||||
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}`;
|
||||
}
|
||||
|
||||
$renderedContent // styles needed for the zoom to work well
|
||||
.css("display", "flex")
|
||||
.css("align-items", "center")
|
||||
.css("justify-content", "center");
|
||||
.css("justify-content", "center")
|
||||
.css("flex-direction", "column"); // OCR text is displayed below the image.
|
||||
|
||||
const $img = $("<img>")
|
||||
.attr("src", url || "")
|
||||
@@ -178,9 +181,35 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
}
|
||||
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
|
||||
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
|
||||
try {
|
||||
const data = await server.get<TextRepresentationResponse>(`ocr/notes/${note.noteId}/text`);
|
||||
if (data.success && data.hasOcr && data.text) {
|
||||
const $ocrSection = $(`
|
||||
<div class="ocr-text-section">
|
||||
<div class="ocr-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
|
||||
</div>
|
||||
<div class="ocr-content"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$ocrSection.find('.ocr-content').text(data.text);
|
||||
$content.append($ocrSection);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR API is not available
|
||||
console.debug('Failed to fetch OCR text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -220,6 +249,10 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
||||
$content.append($videoPreview);
|
||||
}
|
||||
|
||||
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||
await addOCRTextIfAvailable(entity, $content);
|
||||
}
|
||||
|
||||
if (entityType === "notes" && "noteId" in entity) {
|
||||
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
||||
// in attachment list
|
||||
|
||||
@@ -25,8 +25,7 @@ export async function initLocale() {
|
||||
backend: {
|
||||
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
showSupportNotice: false
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
await setDayjsLocale(locale);
|
||||
|
||||
@@ -28,7 +28,7 @@ async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
export type ViewMode = "default" | "source" | "attachments" | "contextual-help" | "note-map";
|
||||
export type ViewMode = "default" | "source" | "attachments" | "contextual-help" | "note-map" | "ocr";
|
||||
|
||||
export interface ViewScope {
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -270,7 +270,11 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
|
||||
// report nothing
|
||||
} else {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
try {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
} catch {
|
||||
// reportError may throw (e.g. ValidationError); ensure rej() is still called below.
|
||||
}
|
||||
}
|
||||
|
||||
rej(jqXhr.responseText);
|
||||
|
||||
@@ -2641,3 +2641,26 @@ iframe.print-iframe {
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ocr-text-section {
|
||||
padding: 10px;
|
||||
background: var(--accented-background-color);
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.ocr-content {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -709,7 +709,8 @@
|
||||
"advanced": "高级",
|
||||
"export_as_image": "导出为图像",
|
||||
"export_as_image_png": "PNG(栅格)",
|
||||
"export_as_image_svg": "SVG(矢量图)"
|
||||
"export_as_image_svg": "SVG(矢量图)",
|
||||
"view_ocr_text": "查看 OCR 文本"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "按钮组件'{{componentId}}'没有定义点击处理程序"
|
||||
@@ -1197,12 +1198,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "图片",
|
||||
"download_images_automatically": "自动下载图片以供离线使用。",
|
||||
"download_images_description": "粘贴的 HTML 可能包含在线图片的引用,Trilium 会找到这些引用并下载图片,以便它们可以离线使用。",
|
||||
"enable_image_compression": "启用图片压缩",
|
||||
"max_image_dimensions": "图片的最大宽度/高度(超过此限制的图像将会被缩放)。",
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)",
|
||||
"max_image_dimensions_unit": "像素"
|
||||
"download_images_automatically": "自动下载图片",
|
||||
"download_images_description": "从粘贴的 HTML 代码中下载引用的在线图片,以便离线使用。",
|
||||
"enable_image_compression": "图片压缩",
|
||||
"max_image_dimensions": "最大图像尺寸",
|
||||
"jpeg_quality_description": "建议范围为 50–85。较低的值可以减小文件大小,较高的值可以保留细节。",
|
||||
"max_image_dimensions_unit": "像素",
|
||||
"enable_image_compression_description": "上传或粘贴图片时,对其进行压缩和调整大小。",
|
||||
"max_image_dimensions_description": "超过此尺寸的图片将自动调整大小。",
|
||||
"jpeg_quality": "JPEG质量",
|
||||
"ocr_section_title": "文本提取(OCR)",
|
||||
"ocr_related_content_languages": "内容语言(用于文本提取)",
|
||||
"ocr_auto_process": "自动处理新文件",
|
||||
"ocr_auto_process_description": "自动从新上传或粘贴的文件中提取文本。",
|
||||
"ocr_min_confidence": "最低置信度",
|
||||
"ocr_confidence_description": "仅提取置信度高于此阈值的文本。较低的置信度阈值会包含更多文本,但可能准确性较低。",
|
||||
"batch_ocr_title": "处理现有文件",
|
||||
"batch_ocr_description": "从笔记中的所有现有图像、PDF 和 Office 文档中提取文本。这可能需要一些时间,具体取决于文件数量。",
|
||||
"batch_ocr_start": "开始批量处理",
|
||||
"batch_ocr_starting": "开始批量处理...",
|
||||
"batch_ocr_progress": "正在处理 {{processed}} 个文件,共 {{total}} 个文件...",
|
||||
"batch_ocr_completed": "批量处理完成!已处理 {{processed}} 个文件。",
|
||||
"batch_ocr_error": "批量处理过程中出错:{{error}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "附件清理超时",
|
||||
@@ -1535,8 +1552,9 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
"ai-chat": "AI对话",
|
||||
"spreadsheet": "电子表格",
|
||||
"llm-chat": "AI对话"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
@@ -2046,7 +2064,9 @@
|
||||
"title": "实验选项",
|
||||
"disclaimer": "这些选项处于实验阶段,可能导致系统不稳定。请谨慎使用。",
|
||||
"new_layout_name": "新布局",
|
||||
"new_layout_description": "尝试全新布局,呈现更现代的外观并提升易用性。后续版本将进行重大调整。"
|
||||
"new_layout_description": "尝试全新布局,呈现更现代的外观并提升易用性。后续版本将进行重大调整。",
|
||||
"llm_name": "AI/大语言模型对话",
|
||||
"llm_description": "启用由大语言模型驱动的 AI对话侧边栏和大语言模型对话笔记。"
|
||||
},
|
||||
"tab_history_navigation_buttons": {
|
||||
"go-back": "返回前一笔记",
|
||||
@@ -2215,5 +2235,77 @@
|
||||
"sample_venn": "韦恩图",
|
||||
"sample_ishikawa": "鱼骨图",
|
||||
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "输入消息…",
|
||||
"send": "发送",
|
||||
"sending": "正在发送...",
|
||||
"empty_state": "在下方输入消息,即可开始对话。",
|
||||
"searching_web": "在网上搜索…",
|
||||
"web_search": "联网搜索",
|
||||
"sources": "来源",
|
||||
"extended_thinking": "延伸思考",
|
||||
"legacy_models": "传统模型",
|
||||
"thinking": "正在思考...",
|
||||
"thought_process": "思考过程",
|
||||
"tool_calls": "{{count}} 次工具调用",
|
||||
"input": "输入",
|
||||
"result": "结果",
|
||||
"error": "错误",
|
||||
"tool_error": "失败",
|
||||
"total_tokens": "{{total}} 个词元",
|
||||
"tokens_detail": "{{prompt}} 提示词 + {{completion}} 补全",
|
||||
"tokens_used": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_cost": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens": "词元",
|
||||
"context_used": "{{percentage}}% 使用率",
|
||||
"note_context_enabled": "点击即可禁用笔记上下文:{{title}}",
|
||||
"note_context_disabled": "点击即可将当前注释添加到上下文中",
|
||||
"no_provider_message": "未配置人工智能提供商。添加一个即可开始对话。",
|
||||
"add_provider": "添加人工智能提供商",
|
||||
"note_tools": "笔记访问"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI对话",
|
||||
"launcher_title": "打开AI对话",
|
||||
"new_chat": "开始新对话",
|
||||
"save_chat": "将对话保存到笔记",
|
||||
"empty_state": "开始对话",
|
||||
"history": "对话历史",
|
||||
"recent_chats": "最近对话",
|
||||
"no_chats": "无历史对话"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "提取文本(OCR)",
|
||||
"extracted_text_title": "提取文本(OCR)",
|
||||
"loading_text": "正在加载OCR文本...",
|
||||
"no_text_available": "暂无OCR文本",
|
||||
"no_text_explanation": "该笔记未进行 OCR 文本提取处理,或未找到文本。",
|
||||
"failed_to_load": "OCR文本加载失败",
|
||||
"process_now": "处理 OCR",
|
||||
"processing": "正在处理...",
|
||||
"processing_started": "OCR识别已开始。请稍候片刻并刷新页面。",
|
||||
"processing_failed": "OCR处理启动失败",
|
||||
"view_extracted_text": "查看提取的文本(OCR)"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "添加子节点",
|
||||
"addParent": "添加父节点",
|
||||
"addSibling": "添加同级节点",
|
||||
"removeNode": "删除节点",
|
||||
"focus": "专注模式",
|
||||
"cancelFocus": "退出专注模式",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"link": "链接",
|
||||
"linkBidirectional": "双向链接",
|
||||
"clickTips": "请点击目标节点",
|
||||
"summary": "总结"
|
||||
},
|
||||
"llm": {
|
||||
"settings_description": "配置人工智能和大语言模型集成。",
|
||||
"add_provider": "添加提供商"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
"calendar_root": "marks note which should be used as root for day notes. Only one should be marked as such.",
|
||||
"archived": "notes with this label won't be visible by default in search results (also in Jump To, Add Link dialogs etc).",
|
||||
"exclude_from_export": "notes (with their sub-tree) won't be included in any note export",
|
||||
"run": "defines on which events script should run. Possible values are:\n<ul>\n<li>frontendStartup - when Trilium frontend starts up (or is refreshed), but not on mobile.</li>\n<li>mobileStartup - when Trilium frontend starts up (or is refreshed), on mobile.</li>\n<li>backendStartup - when Trilium backend starts up</li>\n<li>hourly - run once an hour. You can use additional label <code>runAtHour</code> to specify at which hour.</li>\n<li>daily - run once a day</li>\n</ul>",
|
||||
"run": "defines on which events script should run. Possible values are:\n<ul>\n<li>frontendStartup - when Trilium frontend starts up (or is refreshed), but not on mobile.</li>\n<li>mobileStartup - when Trilium frontend starts up (or is refreshed), on mobile.</li>\n<li>backendStartup - when Trilium backend starts up.</li>\n<li>hourly - run once an hour. You can use additional label <code>runAtHour</code> to specify at which hour.</li>\n<li>daily - run once a day.</li>\n</ul>",
|
||||
"run_on_instance": "Define which trilium instance should run this on. Default to all instances.",
|
||||
"run_at_hour": "On which hour should this run. Should be used together with <code>#run=hourly</code>. Can be defined multiple times for more runs during the day.",
|
||||
"disable_inclusion": "scripts with this label won't be included into parent script execution.",
|
||||
@@ -691,6 +691,7 @@
|
||||
"search_in_note": "Search in note",
|
||||
"note_source": "Note source",
|
||||
"note_attachments": "Note attachments",
|
||||
"view_ocr_text": "View OCR text",
|
||||
"open_note_externally": "Open note externally",
|
||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||
"open_note_custom": "Open note custom",
|
||||
@@ -1254,12 +1255,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "Images",
|
||||
"download_images_automatically": "Download images automatically for offline use.",
|
||||
"download_images_description": "Pasted HTML can contain references to online images, Trilium will find those references and download the images so that they are available offline.",
|
||||
"enable_image_compression": "Enable image compression",
|
||||
"max_image_dimensions": "Max width / height of an image (image will be resized if it exceeds this setting).",
|
||||
"download_images_automatically": "Download images automatically",
|
||||
"download_images_description": "Download referenced online images from pasted HTML so they are available offline.",
|
||||
"enable_image_compression": "Image compression",
|
||||
"enable_image_compression_description": "Compress and resize images when they are uploaded or pasted.",
|
||||
"max_image_dimensions": "Max image dimensions",
|
||||
"max_image_dimensions_description": "Images exceeding this size will be resized automatically.",
|
||||
"max_image_dimensions_unit": "pixels",
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)"
|
||||
"jpeg_quality": "JPEG quality",
|
||||
"jpeg_quality_description": "Recommended range is 50–85. Lower values reduce file size, higher values preserve detail.",
|
||||
"ocr_section_title": "Text Extraction (OCR)",
|
||||
"ocr_related_content_languages": "Content languages (used for text extraction)",
|
||||
"ocr_auto_process": "Auto-process new files",
|
||||
"ocr_auto_process_description": "Automatically extract text from newly uploaded or pasted files.",
|
||||
"ocr_min_confidence": "Minimum confidence",
|
||||
"ocr_confidence_description": "Only extract text above this confidence threshold. Lower values include more text but may be less accurate.",
|
||||
"batch_ocr_title": "Process Existing Files",
|
||||
"batch_ocr_description": "Extract text from all existing images, PDFs, and Office documents in your notes. This may take some time depending on the number of files.",
|
||||
"batch_ocr_start": "Start Batch Processing",
|
||||
"batch_ocr_starting": "Starting batch processing...",
|
||||
"batch_ocr_progress": "Processing {{processed}} of {{total}} files...",
|
||||
"batch_ocr_completed": "Batch processing completed! Processed {{processed}} files.",
|
||||
"batch_ocr_error": "Error during batch processing: {{error}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Attachment Erasure Timeout",
|
||||
@@ -1305,7 +1322,7 @@
|
||||
"custom_name_label": "Custom search engine name",
|
||||
"custom_name_placeholder": "Customize search engine name",
|
||||
"custom_url_label": "Custom search engine URL should include {keyword} as a placeholder for the search term.",
|
||||
"custom_url_placeholder": "Customize search engine url",
|
||||
"custom_url_placeholder": "Customize search engine URL",
|
||||
"save_button": "Save"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1622,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...",
|
||||
@@ -1642,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",
|
||||
@@ -1967,7 +1983,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Content languages",
|
||||
"description": "Select one or more languages that should appear in the language selection in the Basic Properties section of a read-only or editable text note. This will allow features such as spell-checking or right-to-left support."
|
||||
"description": "Select one or more languages that should appear in the language selection in the Basic Properties section of a read-only or editable text note. This will allow features such as spell-checking, right-to-left support and text extraction (OCR)."
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "Move editing pane to the bottom",
|
||||
@@ -2067,6 +2083,19 @@
|
||||
"calendar_view": {
|
||||
"delete_note": "Delete note..."
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "Extracted Text (OCR)",
|
||||
"extracted_text_title": "Extracted Text (OCR)",
|
||||
"loading_text": "Loading OCR text...",
|
||||
"no_text_available": "No OCR text available",
|
||||
"no_text_explanation": "This note has not been processed for OCR text extraction or no text was found.",
|
||||
"failed_to_load": "Failed to load OCR text",
|
||||
"process_now": "Process OCR",
|
||||
"processing": "Processing...",
|
||||
"processing_started": "OCR processing has been started. Please wait a moment and refresh.",
|
||||
"processing_failed": "Failed to start OCR processing",
|
||||
"view_extracted_text": "View extracted text (OCR)"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
@@ -2345,6 +2374,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",
|
||||
@@ -2356,6 +2386,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"widget-render-error": {
|
||||
"title": "Rendu impossible d'un widget React custom"
|
||||
},
|
||||
"widget-missing-parent": "Le widget personnalisé ne possède pas la propriété obligatoire '{{property}}'.\n\nSi ce script est destiné à être exécuté sans élément d’interface utilisateur, utilisez plutôt '#run=frontendStartup'.",
|
||||
"open-script-note": "Ouvrir la note du script",
|
||||
"scripting-error": "Erreur de script personnalisée: {{title}}"
|
||||
"widget-missing-parent": "Le widget personnalisé ne comprend pas de propriété '{{property}}' définie\n\nSi ce script est prévu pour être exécuté sans fonctionnalité UI, utilisez '#run=frontendStartup' plutôt.",
|
||||
"open-script-note": "Ouvrir une note script",
|
||||
"scripting-error": "Échec du script personnalisé : {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Ajouter un lien",
|
||||
@@ -49,7 +49,7 @@
|
||||
"prefix": "Préfixe : ",
|
||||
"save": "Sauvegarder",
|
||||
"branch_prefix_saved": "Le préfixe de la branche a été enregistré.",
|
||||
"edit_branch_prefix_multiple": "Modifier le préfixe de branche pour {{count}} branches",
|
||||
"edit_branch_prefix_multiple": "Modifier le préfixe pour {{count}} branches",
|
||||
"branch_prefix_saved_multiple": "Le préfixe de la branche a été sauvegardé pour {{count}} branches.",
|
||||
"affected_branches": "Branches impactées ({{count}}):"
|
||||
},
|
||||
@@ -117,7 +117,7 @@
|
||||
"export_in_progress": "Exportation en cours : {{progressCount}}",
|
||||
"export_finished_successfully": "L'exportation s'est terminée avec succès.",
|
||||
"format_pdf": "PDF - pour l'impression ou le partage de documents.",
|
||||
"share-format": "HTML pour la publication Web - utilise le même thème que celui utilisé pour les notes partagées, mais peut être publié sous forme de site Web statique."
|
||||
"share-format": "HTML pour la publication Web : utilise le même thème que celui utilisé pour les notes partagées, mais peut être publié sous forme de site Web statique."
|
||||
},
|
||||
"help": {
|
||||
"noteNavigation": "Navigation dans les notes",
|
||||
@@ -754,9 +754,9 @@
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relation",
|
||||
"backlink_one": "{{count}} Lien inverse",
|
||||
"backlink_many": "",
|
||||
"backlink_other": "{{count}} Liens inverses"
|
||||
"backlink_one": "{{count}} Rétrolien",
|
||||
"backlink_many": "{{count}} Rétroliens",
|
||||
"backlink_other": "{{count}} Rétrolien"
|
||||
},
|
||||
"mobile_detail_menu": {
|
||||
"insert_child_note": "Insérer une note enfant",
|
||||
@@ -776,9 +776,9 @@
|
||||
"filter-default": "Icônes par défaut",
|
||||
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}",
|
||||
"no_results": "Aucune icône trouvée.",
|
||||
"search_placeholder_one": "Rechercher {{number}} icônes dans {{count}} packs",
|
||||
"search_placeholder_many": "Rechercher {{number}} icônes dans {{count}} packs",
|
||||
"search_placeholder_other": "Rechercher les icônes {{number}} dans les paquets {{count}}",
|
||||
"search_placeholder_one": "{{number}} icône recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_many": "{{number}} icônes recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_other": "{{number}} icônes recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}"
|
||||
},
|
||||
"basic_properties": {
|
||||
@@ -795,7 +795,7 @@
|
||||
"collapse_all_notes": "Réduire toutes les notes",
|
||||
"collapse": "Réduire",
|
||||
"expand": "Développer",
|
||||
"invalid_view_type": "Type de vue non valide '{{type}}'",
|
||||
"invalid_view_type": "Type de vue '{{type}}' non valide",
|
||||
"calendar": "Calendrier",
|
||||
"book_properties": "Propriétés de la collection",
|
||||
"table": "Tableau",
|
||||
@@ -1187,8 +1187,8 @@
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "Types MIME disponibles dans la liste déroulante",
|
||||
"tooltip_syntax_highlighting": "Souligner la syntaxe",
|
||||
"tooltip_code_block_syntax": "Blocs de code dans les notes de texte",
|
||||
"tooltip_syntax_highlighting": "Mise en évidence de la syntaxe",
|
||||
"tooltip_code_block_syntax": "Blocs de code dans les notes textuelles",
|
||||
"tooltip_code_note_syntax": "Notes de code"
|
||||
},
|
||||
"vim_key_bindings": {
|
||||
@@ -1539,7 +1539,13 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Accentuations",
|
||||
"options": "Options"
|
||||
"options": "Options",
|
||||
"title_with_count_one": "{{count}} mise en évidence",
|
||||
"title_with_count_many": "{{count}} mises en évidence",
|
||||
"title_with_count_other": "{{count}} mises en évidence",
|
||||
"modal_title": "Configurer les mises en évidence",
|
||||
"menu_configure": "Configuration des mises en évidence...",
|
||||
"no_highlights": "Aucune mise en évidence."
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Recherche rapide",
|
||||
@@ -1563,7 +1569,17 @@
|
||||
"create-child-note": "Créer une note enfant",
|
||||
"unhoist": "Désactiver le focus",
|
||||
"toggle-sidebar": "Basculer la barre latérale",
|
||||
"dropping-not-allowed": "Lâcher des notes à cet endroit n'est pas autorisé"
|
||||
"dropping-not-allowed": "Déplacer des notes à cet emplacement n'est pas autorisé.",
|
||||
"clone-indicator-tooltip": "Cette note a {{- count}} parents: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Cette note est clonée (1 parent supplémentaire: {{- parent}})",
|
||||
"shared-indicator-tooltip": "Cette note est partagée publiquement",
|
||||
"shared-indicator-tooltip-with-url": "Cette note est partagée publiquement sur: {{- url}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} note enfant cachée de l'arbre",
|
||||
"subtree-hidden-tooltip_many": "{{count}} notes enfants cachées de l'arbre",
|
||||
"subtree-hidden-tooltip_other": "{{count}} notes enfants cachées de l'arbre",
|
||||
"subtree-hidden-moved-title": "Ajouté à {{title}}",
|
||||
"subtree-hidden-moved-description-collection": "Cette collection cache ses notes enfants dans l'arbre.",
|
||||
"subtree-hidden-moved-description-other": "Les notes enfants sont cachées dans l'arbre pour cette note."
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Épingler cette fenêtre au premier plan"
|
||||
@@ -1574,7 +1590,12 @@
|
||||
"printing_pdf": "Export au format PDF en cours...",
|
||||
"print_report_title": "Imprimer le rapport",
|
||||
"print_report_collection_details_button": "Consulter les détails",
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées"
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées",
|
||||
"print_report_error_title": "Échec de l'impression",
|
||||
"print_report_stack_trace": "Trace de la pile",
|
||||
"print_report_collection_content_one": "La {{count}} note de la collection n'a pas pu être imprimée car elle n'est pas prises en charge ou est protégée.",
|
||||
"print_report_collection_content_many": "Les {{count}} notes de la collection n'ont pas pu être imprimées car elles ne sont pas prises en charge ou sont protégées.",
|
||||
"print_report_collection_content_other": "Les {{count}} notes de la collection n'ont pas pu être imprimées car elles ne sont pas prises en charge ou sont protégées."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "saisir le titre de la note ici...",
|
||||
@@ -1583,17 +1604,24 @@
|
||||
"note_type_switcher_label": "Basculer de {{type}} à :",
|
||||
"note_type_switcher_others": "Autre type de note",
|
||||
"note_type_switcher_templates": "Modèle",
|
||||
"note_type_switcher_collection": "Collection"
|
||||
"note_type_switcher_collection": "Collection",
|
||||
"edited_notes": "Notes éditées ce jour",
|
||||
"promoted_attributes": "Attributs promus"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Aucune note n'a été trouvée pour les paramètres de recherche donnés.",
|
||||
"search_not_executed": "La recherche n'a pas encore été exécutée. Cliquez sur le bouton \"Rechercher\" ci-dessus pour voir les résultats."
|
||||
"search_not_executed": "La recherche n'a pas encore été exécutée.",
|
||||
"search_now": "Recherche maintenant"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurer la Barre de raccourcis"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "Aucune ligne n'a été renvoyée pour cette requête"
|
||||
"no_rows": "Aucune ligne n'a été renvoyée pour cette requête",
|
||||
"not_executed": "La requête n'a pas encore été exécutée.",
|
||||
"failed": "L'exécution de requêtes SQL a échoué",
|
||||
"statement_result": "Résultat de la déclaration",
|
||||
"execute_now": "Exécuter maintenant"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tableaux"
|
||||
@@ -1716,7 +1744,7 @@
|
||||
"paste": "Coller",
|
||||
"paste-as-plain-text": "Coller comme texte brut",
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}",
|
||||
"search_in_trilium": "Rechercher \"{{term}}\" dans Trilium"
|
||||
"search_in_trilium": "Rechercher « {{term}} » dans Trilium"
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "Copier la référence dans le presse-papiers",
|
||||
@@ -1726,14 +1754,15 @@
|
||||
"open_note_in_new_tab": "Ouvrir la note dans un nouvel onglet",
|
||||
"open_note_in_new_split": "Ouvrir la note dans une nouvelle division",
|
||||
"open_note_in_new_window": "Ouvrir la note dans une nouvelle fenêtre",
|
||||
"open_note_in_popup": "Édition rapide"
|
||||
"open_note_in_popup": "Édition rapide",
|
||||
"open_note_in_other_split": "Ouvrir la note dans l'autre volet"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "Application de bureau",
|
||||
"native-title-bar": "Barre de titre native",
|
||||
"native-title-bar-description": "Sous Windows et macOS, désactiver la barre de titre native rend l'application plus compacte. Sous Linux, le maintien de la barre de titre native permet une meilleure intégration avec le reste du système.",
|
||||
"background-effects": "Activer les effets d'arrière-plan (Windows 11 uniquement)",
|
||||
"background-effects-description": "L'effet Mica ajoute un fond flou et élégant aux fenêtres de l'application, créant une profondeur et un style moderne.",
|
||||
"background-effects": "Activer les effets d'arrière-plan",
|
||||
"background-effects-description": "Ajoute un arrière-plan flou et élégant aux fenêtres d'application, créant de la profondeur et un style moderne. La « barre de titre native » doit être désactivée.",
|
||||
"restart-app-button": "Redémarrez l'application pour afficher les modifications",
|
||||
"zoom-factor": "Facteur de zoom"
|
||||
},
|
||||
@@ -1752,7 +1781,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Créer une nouvelle note enfant et l'ajouter à la carte",
|
||||
"create-child-note-instruction": "Cliquez sur la carte pour créer une nouvelle note à cet endroit ou appuyez sur Échap pour la supprimer.",
|
||||
"unable-to-load-map": "Impossible de charger la carte."
|
||||
"unable-to-load-map": "Impossible de charger la carte.",
|
||||
"create-child-note-text": "Ajouter le marqueur"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Ouvrir la position",
|
||||
@@ -1862,7 +1892,8 @@
|
||||
"raster": "Trame",
|
||||
"vector_light": "Vecteur (clair)",
|
||||
"vector_dark": "Vecteur (foncé)",
|
||||
"show-scale": "Afficher l'échelle"
|
||||
"show-scale": "Afficher l'échelle",
|
||||
"show-labels": "Afficher les noms des marqueurs"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Supprimer la ligne"
|
||||
@@ -1883,7 +1914,7 @@
|
||||
"add-column-placeholder": "Entrez le nom de la colonne...",
|
||||
"edit-note-title": "Cliquez pour modifier le titre de la note",
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne",
|
||||
"column-already-exists": "Cette colonne existe déjà dans le tableau."
|
||||
"column-already-exists": "Cette colonne existe déjà sur le tableau."
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Modifier cette diapositive",
|
||||
@@ -1913,22 +1944,30 @@
|
||||
"next_theme_message": "Vous utilisez actuellement le thème hérité de l'ancienne version, souhaitez-vous essayer le nouveau thème ?",
|
||||
"next_theme_button": "Essayez le nouveau thème",
|
||||
"background_effects_title": "Les effets d'arrière-plan sont désormais stables",
|
||||
"background_effects_message": "Sur les appareils Windows, les effets d'arrière-plan sont désormais parfaitement stables. Ils ajoutent une touche de couleur à l'interface utilisateur en floutant l'arrière-plan. Cette technique est également utilisée dans d'autres applications comme l'Explorateur Windows.",
|
||||
"background_effects_message": "Sur les appareils Windows et macOS les effets d'arrière-plan sont désormais stables. Ils ajoutent une touche de couleur à l'interface utilisateur en floutant l'arrière-plan.",
|
||||
"background_effects_button": "Activer les effets d'arrière-plan",
|
||||
"dismiss": "Rejeter"
|
||||
"dismiss": "Rejeter",
|
||||
"new_layout_title": "Nouvelle mise en page",
|
||||
"new_layout_message": "Nous avons introduit une mise en page modernisée pour Trilium. Le ruban a été supprimé et intégré de manière transparente dans l'interface principale, avec une nouvelle barre d'état et des sections extensibles (telles que les attributs promus) reprenant les fonctions clés.\n\nLa nouvelle mise en page est activée par défaut et peut être temporairement désactivée via Options → Apparence.",
|
||||
"new_layout_button": "Plus d'infos"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "Paramètres associés"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Schéma de coloration syntaxique pour les blocs de code dans les notes de texte",
|
||||
"related_code_notes": "Schéma de couleurs pour les notes de code"
|
||||
"related_code_notes": "Schéma de couleurs pour les notes de code",
|
||||
"ui": "Interface utilisateur",
|
||||
"ui_old_layout": "Ancienne mise en page",
|
||||
"ui_new_layout": "Nouvelle mise en page"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} notes"
|
||||
"total_notes": "{{count}} notes",
|
||||
"prev_page": "Page précédente",
|
||||
"next_page": "Page suivante"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Impossible d'afficher le contenu en raison d'une erreur."
|
||||
@@ -1947,8 +1986,9 @@
|
||||
"unknown_widget": "Widget inconnu pour « {{id}} »."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "Non défini",
|
||||
"configure-languages": "Configurer les langues..."
|
||||
"not_set": "Langage non défini",
|
||||
"configure-languages": "Configurer les langues...",
|
||||
"help-on-languages": "Aide sur les langues de contenu..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Contenu des langues",
|
||||
@@ -2003,10 +2043,10 @@
|
||||
"read-only-info": {
|
||||
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide.",
|
||||
"edit-note": "Editer la note"
|
||||
"edit-note": "Modifier la note"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Effacer la note..."
|
||||
"delete_note": "Supprimer la note..."
|
||||
},
|
||||
"media": {
|
||||
"play": "Lire (Espace)",
|
||||
@@ -2058,6 +2098,226 @@
|
||||
"thinking": "Réflexion...",
|
||||
"thought_process": "Processus de réflexion",
|
||||
"tool_calls": "{{count}} appel(s) d'outil",
|
||||
"input": "Entrée"
|
||||
"input": "Entrée",
|
||||
"result": "Résultat",
|
||||
"error": "Erreur",
|
||||
"tool_error": "échoué",
|
||||
"total_tokens": "{{total}} jetons",
|
||||
"tokens_detail": "{{prompt}} prompt + {{completion}} achèvement",
|
||||
"tokens_used": "{{prompt}} prompt + {{completion}} achèvement = {{total}} jetons",
|
||||
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} achèvement = {{total}} jetons (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} achèvement = {{total}} jetons",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} achèvement = {{total}} jetons (~${{cost}})",
|
||||
"tokens": "jetons",
|
||||
"context_used": "{{percentage}}% utilisé",
|
||||
"note_context_enabled": "Cliquez pour désactiver le contexte de la note : {{title}}",
|
||||
"note_context_disabled": "Cliquez pour inclure la note actuelle dans le contexte",
|
||||
"no_provider_message": "Aucun fournisseur d'IA configuré. Ajoutez en un pour commencer à discuter.",
|
||||
"add_provider": "Ajouter un fournisseur d'IA"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "discussion IA",
|
||||
"launcher_title": "Ouvrir la discussion IA",
|
||||
"new_chat": "Démarrer une nouvelle discussion",
|
||||
"save_chat": "Enregistrer la discussion dans les notes",
|
||||
"empty_state": "Démarrer une conversation",
|
||||
"history": "Historique des discussions",
|
||||
"recent_chats": "Discussions récentes",
|
||||
"no_chats": "Pas de discussions précédentes"
|
||||
},
|
||||
"note-color": {
|
||||
"clear-color": "Retirer la couleur de la note",
|
||||
"set-color": "Définir la couleur de la note",
|
||||
"set-custom-color": "Définir la couleur personnalisée de la note"
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Basculer sur l'éditeur complet"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "Erreur de communication avec le serveur",
|
||||
"unknown_http_error_content": "Code de statut: {{statusCode}}\nURL: {{method}} {{url}}\nMessage: {{message}}",
|
||||
"traefik_blocks_requests": "Si vous utilisez le reverse proxy Traefik, celui-ci a introduit un changement de rupture qui affecte la communication avec le serveur."
|
||||
},
|
||||
"tab_history_navigation_buttons": {
|
||||
"go-back": "Revenir à la note précédente",
|
||||
"go-forward": "Aller vers la note suivante"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"hoisted_badge": "Remonté",
|
||||
"hoisted_badge_title": "Redescendu",
|
||||
"workspace_badge": "Espace de travail",
|
||||
"scroll_to_top_title": "Aller au début de la note",
|
||||
"create_new_note": "Créer une nouvelle note enfant",
|
||||
"empty_hide_archived_notes": "Cacher les notes archivées"
|
||||
},
|
||||
"breadcrumb_badges": {
|
||||
"read_only_explicit": "Lecture seule",
|
||||
"read_only_explicit_description": "Cette note a été paramétrée manuellement en lecture seule.\nCliquer pour temporairement l'éditer.",
|
||||
"read_only_auto": "Lecture seule automatique",
|
||||
"read_only_auto_description": "Cette note a été réglée automatiquement en mode lecture seule pour des raisons de performances. Cette limite automatique est réglable à partir des paramètres.\n\nCliquez pour la modifier temporairement.",
|
||||
"read_only_temporarily_disabled": "Temporairement modifiable",
|
||||
"read_only_temporarily_disabled_description": "Cette note est actuellement modifiable, mais elle est normalement en lecture seule. La note redeviendra en lecture seule dès que vous accéderez à une autre note.\n\nCliquez pour réactiver le mode lecture seule.",
|
||||
"shared_publicly": "Partagés publiquement",
|
||||
"shared_locally": "Partagé localement",
|
||||
"shared_copy_to_clipboard": "Copier le lien vers le presse-papier",
|
||||
"shared_open_in_browser": "Ouvrir le lien dans le navigateur",
|
||||
"shared_unshare": "Supprimer le partage",
|
||||
"clipped_note": "Clip Web",
|
||||
"clipped_note_description": "Cette note a été initialement construite depuis l'url {{url}}.\n\nCliquez pour accéder à la page Web source.",
|
||||
"execute_script": "Exécuter le script",
|
||||
"execute_script_description": "Cette note est une note de script. Cliquez pour exécuter le script.",
|
||||
"execute_sql": "Exécuter la commande SQL",
|
||||
"execute_sql_description": "Cette note est une note SQL. Cliquer pour exécuter la requête SQL.",
|
||||
"save_status_saved": "Enregister",
|
||||
"save_status_saving": "Enregistrement...",
|
||||
"save_status_unsaved": "Non sauvée",
|
||||
"save_status_error": "La sauvegarde a échoué",
|
||||
"save_status_saving_tooltip": "Les modifications sont enregistrées.",
|
||||
"save_status_unsaved_tooltip": "Il y a des changements non enregistrés. Ils seront enregistrés automatiquement dans un instant.",
|
||||
"save_status_error_tooltip": "Une erreur s'est produite lors de l'enregistrement de la note. Si possible, essayez de copier le contenu de la note ailleurs et de recharger l'application."
|
||||
},
|
||||
"right_pane": {
|
||||
"toggle": "Basculer le panneau de droite",
|
||||
"custom_widget_go_to_source": "Aller sur le code source",
|
||||
"empty_message": "Rien à afficher pour cette note",
|
||||
"empty_button": "Cacher le panneau"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} pièce jointe",
|
||||
"attachments_many": "{{count}} pièces jointes",
|
||||
"attachments_other": "{{count}} pièces jointes",
|
||||
"layers_one": "{{count}} couche",
|
||||
"layers_many": "{{count}} couches",
|
||||
"layers_other": "{{count}} couches",
|
||||
"pages_one": "{{count}} page",
|
||||
"pages_many": "{{count}} pages",
|
||||
"pages_other": "{{count}} pages",
|
||||
"pages_alt": "Page {{pageNumber}}",
|
||||
"pages_loading": "Chargement..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Disponible sur {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} onglet",
|
||||
"title_many": "{{count}} onglets",
|
||||
"title_other": "{{count}} onglets",
|
||||
"more_options": "Autres options"
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "Signets"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "pack d'icônes",
|
||||
"type_backend_script": "Script backend",
|
||||
"type_frontend_script": "Script frontend",
|
||||
"type_widget": "Widget",
|
||||
"type_app_css": "CSS personnalisé",
|
||||
"type_render_note": "Note de rendu",
|
||||
"type_web_view": "Vue Web",
|
||||
"type_app_theme": "Thème personnalisé",
|
||||
"toggle_tooltip_enable_tooltip": "Cliquer pour activer {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Cliquer pour désactiver ce {{type}}.",
|
||||
"menu_docs": "Ouvrir la documentation",
|
||||
"menu_execute_now": "Exécuter le script maintenant",
|
||||
"menu_run": "Démarrer automatiquement",
|
||||
"menu_run_disabled": "Manuellement",
|
||||
"menu_run_backend_startup": "Lorsque le backend commence",
|
||||
"menu_run_hourly": "Horaire",
|
||||
"menu_run_daily": "Quotidien",
|
||||
"menu_run_frontend_startup": "Lorsque le frontend du bureau démarre",
|
||||
"menu_run_mobile_startup": "Lorsque le frontend mobile démarre",
|
||||
"menu_change_to_widget": "Passer au widget",
|
||||
"menu_change_to_frontend_script": "Passer au script frontend",
|
||||
"menu_theme_base": "Thème de base"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "En savoir plus"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Tapez le contenu de votre diagramme Mermaid ou utilisez l'un des diagrammes de l'échantillon ci-dessous.",
|
||||
"sample_diagrams": "Diagrammes d 'exemple:",
|
||||
"sample_flowchart": "Organigramme",
|
||||
"sample_class": "Classe",
|
||||
"sample_sequence": "Séquence",
|
||||
"sample_entity_relationship": "Entité relationnelle",
|
||||
"sample_state": "État",
|
||||
"sample_mindmap": "Carte mentale",
|
||||
"sample_architecture": "Architecture",
|
||||
"sample_block": "Bloc",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Paquet",
|
||||
"sample_pie": "Camembert",
|
||||
"sample_quadrant": "Quadrant",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Exigence",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Chronologie",
|
||||
"sample_treemap": "Arborescence",
|
||||
"sample_user_journey": "Utilisateur Journey",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Ajouter un enfant",
|
||||
"addParent": "Ajouter parent",
|
||||
"addSibling": "Ajouter un frère",
|
||||
"removeNode": "Supprimer le nœud",
|
||||
"focus": "Mode Focus",
|
||||
"cancelFocus": "Annuler le mode Focus",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"link": "Lien",
|
||||
"linkBidirectional": "Lien bidirectionnel",
|
||||
"clickTips": "Cliquer sur le nœud cible",
|
||||
"summary": "Résumé"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "Configurer les intégrations AI et les LLM (Large Language Model).",
|
||||
"add_provider": "Ajouter le fournisseur",
|
||||
"add_provider_title": "Ajouter le fournisseur d'IA",
|
||||
"configured_providers": "Fournisseurs configurés",
|
||||
"no_providers_configured": "Aucun fournisseur n'est encore configuré.",
|
||||
"provider_name": "Nom",
|
||||
"provider_type": "Fournisseur",
|
||||
"actions": "Actions",
|
||||
"delete_provider": "Supprimer",
|
||||
"delete_provider_confirmation": "Êtes-vous sûr de vouloir supprimer le fournisseur \"{{name}}\" ?",
|
||||
"api_key": "Clé API",
|
||||
"api_key_placeholder": "Entrer votre clé API",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "Changer de langue",
|
||||
"note_info_title": "Afficher les informations sur les notes (par exemple, dates, taille des notes)",
|
||||
"backlinks_one": "{{count}} rétrolien",
|
||||
"backlinks_many": "{{count}} rétroliens",
|
||||
"backlinks_other": "{{count}} rétroliens",
|
||||
"backlinks_title_one": "voir le rétrolien",
|
||||
"backlinks_title_many": "voir les rétroliens",
|
||||
"backlinks_title_other": "voir les rétroliens",
|
||||
"attachments_one": "{{count}} pièce-jointe",
|
||||
"attachments_many": "{{count}} pièces-jointes",
|
||||
"attachments_other": "{{count}} pièces-jointes",
|
||||
"attachments_title_one": "Voir la pièce-jointe dans un nouvel onglet",
|
||||
"attachments_title_many": "Voir les pièces-jointes dans un nouvel onglet",
|
||||
"attachments_title_other": "Voir les pièces-jointes dans un nouvel onglet",
|
||||
"attributes_one": "{{count}} attribut",
|
||||
"attributes_many": "{{count}} attributs",
|
||||
"attributes_other": "{{count}} attributs",
|
||||
"attributes_title": "Attributs propres et attributs hérités",
|
||||
"note_paths_one": "{{count}} chemin",
|
||||
"note_paths_many": "{{count}} chemins",
|
||||
"note_paths_other": "{{count}} chemins",
|
||||
"note_paths_title": "Chemins de la note",
|
||||
"code_note_switcher": "Changer de langue"
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "Attributs de la note"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,7 +1127,9 @@
|
||||
"title": "Roghanna Turgnamhacha",
|
||||
"disclaimer": "Is roghanna turgnamhacha iad seo agus d’fhéadfadh éagobhsaíocht a bheith mar thoradh orthu. Bain úsáid astu go cúramach.",
|
||||
"new_layout_name": "Leagan Amach Nua",
|
||||
"new_layout_description": "Bain triail as an leagan amach nua le haghaidh cuma níos nua-aimseartha agus inúsáidteachta feabhsaithe. Tá sé faoi réir athruithe móra sna heisiúintí atá le teacht."
|
||||
"new_layout_description": "Bain triail as an leagan amach nua le haghaidh cuma níos nua-aimseartha agus inúsáidteachta feabhsaithe. Tá sé faoi réir athruithe móra sna heisiúintí atá le teacht.",
|
||||
"llm_name": "Comhrá AI / LLM",
|
||||
"llm_description": "Cumasaigh an taobhbharra comhrá AI agus nótaí comhrá LLM faoi thiomáint ag samhlacha teanga móra."
|
||||
},
|
||||
"fonts": {
|
||||
"theme_defined": "Téama sainmhínithe",
|
||||
@@ -1572,7 +1574,8 @@
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
"spreadsheet": "Scarbhileog",
|
||||
"llm-chat": "Comhrá AI"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
@@ -2275,5 +2278,76 @@
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Clóscríobh teachtaireacht...",
|
||||
"send": "Seol",
|
||||
"sending": "Ag seoladh...",
|
||||
"empty_state": "Tosaigh comhrá trí theachtaireacht a chlóscríobh thíos.",
|
||||
"searching_web": "Ag cuardach an ghréasáin...",
|
||||
"web_search": "Cuardach gréasáin",
|
||||
"note_tools": "Rochtain nótaí",
|
||||
"sources": "Foinsí",
|
||||
"extended_thinking": "Smaointeoireacht leathnaithe",
|
||||
"legacy_models": "Samhlacha oidhreachta",
|
||||
"thinking": "Ag smaoineamh...",
|
||||
"thought_process": "Próiseas smaointeoireachta",
|
||||
"tool_calls": "{{count}} glao(í) uirlisí",
|
||||
"input": "Ionchur",
|
||||
"result": "Toradh",
|
||||
"error": "Earráid",
|
||||
"tool_error": "theip",
|
||||
"total_tokens": "{{total}} comharthaí",
|
||||
"tokens_detail": "leid {{prompt}} + críochnú {{completion}}",
|
||||
"tokens_used": "{{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí",
|
||||
"tokens_used_with_cost": "{{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: leid {{prompt}} + críochnú {{completion}} = {{total}} comharthaí (~${{cost}})",
|
||||
"tokens": "comharthaí",
|
||||
"context_used": "Úsáideadh {{percentage}}%",
|
||||
"note_context_enabled": "Cliceáil chun comhthéacs nótaí a dhíchumasú: {{title}}",
|
||||
"note_context_disabled": "Cliceáil chun an nóta reatha a chur san áireamh i gcomhthéacs",
|
||||
"no_provider_message": "Níl aon soláthraí AI cumraithe. Cuir ceann leis chun comhrá a thosú.",
|
||||
"add_provider": "Cuir Soláthraí AI leis"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "Comhrá AI",
|
||||
"launcher_title": "Oscail Comhrá AI",
|
||||
"new_chat": "Tosaigh comhrá nua",
|
||||
"save_chat": "Sábháil comhrá sna nótaí",
|
||||
"empty_state": "Tosaigh comhrá",
|
||||
"history": "Stair chomhrá",
|
||||
"recent_chats": "Comhráite le déanaí",
|
||||
"no_chats": "Gan aon chomhráite roimhe seo"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Cuir páiste leis",
|
||||
"addParent": "Cuir tuismitheoir leis",
|
||||
"addSibling": "Cuir deartháir nó deirfiúr leis",
|
||||
"removeNode": "Bain nód",
|
||||
"focus": "Mód Fócais",
|
||||
"cancelFocus": "Cealaigh Mód Fócais",
|
||||
"moveUp": "Bog suas",
|
||||
"moveDown": "Bog síos",
|
||||
"link": "Nasc",
|
||||
"linkBidirectional": "Nasc Déthreoch",
|
||||
"clickTips": "Cliceáil ar an nód sprice le do thoil",
|
||||
"summary": "Achoimre"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "Cumraigh comhtháthú idir Intleacht Shaorga agus Múnla Teanga Mór.",
|
||||
"add_provider": "Cuir Soláthraí leis",
|
||||
"add_provider_title": "Cuir Soláthraí AI leis",
|
||||
"configured_providers": "Soláthraithe Cumraithe",
|
||||
"no_providers_configured": "Níl aon soláthraithe cumraithe fós.",
|
||||
"provider_name": "Ainm",
|
||||
"provider_type": "Soláthraí",
|
||||
"actions": "Gníomhartha",
|
||||
"delete_provider": "Scrios",
|
||||
"delete_provider_confirmation": "An bhfuil tú cinnte gur mian leat an soláthraí \"{{name}}\" a scriosadh?",
|
||||
"api_key": "Eochair API",
|
||||
"api_key_placeholder": "Cuir isteach d'eochair API",
|
||||
"cancel": "Cealaigh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza indirizzo url del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza indirizzo URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -2278,9 +2278,7 @@
|
||||
"note_context_enabled": "Clicca qui per disattivare il contesto della nota: {{title}}",
|
||||
"note_context_disabled": "Clicca per includere la nota corrente nel contesto",
|
||||
"no_provider_message": "Non è stato configurato alcun fornitore di IA. Aggiungine uno per iniziare a chattare.",
|
||||
"add_provider": "Aggiungi un fornitore di IA",
|
||||
"role_user": "Tu",
|
||||
"role_assistant": "Assistente"
|
||||
"add_provider": "Aggiungi un fornitore di IA"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "Chat AI",
|
||||
|
||||
@@ -486,7 +486,8 @@
|
||||
"advanced": "高度",
|
||||
"export_as_image": "画像としてエクスポート",
|
||||
"export_as_image_png": "PNG (raster)",
|
||||
"export_as_image_svg": "SVG (vector)"
|
||||
"export_as_image_svg": "SVG (vector)",
|
||||
"view_ocr_text": "OCR テキストを表示"
|
||||
},
|
||||
"command_palette": {
|
||||
"export_note_title": "ノートをエクスポート",
|
||||
@@ -601,7 +602,8 @@
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
"spreadsheet": "スプレッドシート",
|
||||
"llm-chat": "AI チャット"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
@@ -897,12 +899,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "画像",
|
||||
"download_images_automatically": "画像を自動的にダウンロードしてオフラインで使用可能にする。",
|
||||
"download_images_description": "貼り付けられたHTMLにはオンライン画像への参照が含まれていることがありますが、Triliumはそれらの参照を見つけて画像をダウンロードし、オフラインで利用できるようにします。",
|
||||
"enable_image_compression": "画像の圧縮を有効にする",
|
||||
"max_image_dimensions": "画像の最大幅/高さ(この設定を超えると画像はリサイズされます)。",
|
||||
"download_images_automatically": "画像を自動的にダウンロードする。",
|
||||
"download_images_description": "貼り付けた HTML 内の参照画像をダウンロードし、オフラインでも利用できるようにする。",
|
||||
"enable_image_compression": "画像の圧縮",
|
||||
"max_image_dimensions": "画像の最大サイズ",
|
||||
"max_image_dimensions_unit": "ピクセル",
|
||||
"jpeg_quality_description": "JPEGの品質(10 - 最低品質、100 - 最高品質、50 - 80を推奨)"
|
||||
"jpeg_quality_description": "推奨範囲は50~85です。値が低いほどファイルサイズが小さくなり、値が高いほどディテールが保持されます。",
|
||||
"enable_image_compression_description": "画像をアップロードまたは貼り付ける際に、画像を圧縮およびサイズ変更します。",
|
||||
"max_image_dimensions_description": "このサイズを超える画像は自動的にサイズ変更されます",
|
||||
"jpeg_quality": "JPEG 画質",
|
||||
"ocr_section_title": "テキスト抽出(OCR)",
|
||||
"ocr_related_content_languages": "コンテンツ言語(テキスト抽出に使用)",
|
||||
"ocr_auto_process": "新しいファイルを自動処理",
|
||||
"ocr_auto_process_description": "新しくアップロードまたは貼り付けられたファイルからテキストを自動的に抽出します。",
|
||||
"ocr_min_confidence": "最低限の信頼度",
|
||||
"ocr_confidence_description": "この信頼度閾値以上のテキストのみを抽出します。信頼度が低いほど抽出されるテキストの量は増えますが、精度が低下する可能性があります。",
|
||||
"batch_ocr_title": "既存ファイルの処理",
|
||||
"batch_ocr_description": "ノート内の既存の画像、PDF、Office 文書からテキストを抽出します。ファイル数によっては時間がかかる場合があります。",
|
||||
"batch_ocr_start": "バッチ処理を開始します",
|
||||
"batch_ocr_starting": "バッチ処理を開始しています...",
|
||||
"batch_ocr_progress": "{{total}} ファイルのうち {{processed}} ファイルを処理中...",
|
||||
"batch_ocr_completed": "バッチ処理が完了しました!{{processed}} ファイルを処理しました。",
|
||||
"batch_ocr_error": "バッチ処理中にエラーが発生しました: {{error}}"
|
||||
},
|
||||
"search_engine": {
|
||||
"title": "検索エンジン",
|
||||
@@ -915,7 +933,7 @@
|
||||
"custom_name_label": "カスタム検索エンジンの名前",
|
||||
"custom_name_placeholder": "カスタム検索エンジンの名前",
|
||||
"custom_url_label": "カスタム検索エンジンのURLには、検索語句のプレースホルダーとして {keyword} を含める必要があります。",
|
||||
"custom_url_placeholder": "カスタム検索エンジンのurl",
|
||||
"custom_url_placeholder": "検索エンジンの URL をカスタマイズ",
|
||||
"save_button": "保存"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1102,7 +1120,7 @@
|
||||
"calendar_root": "dayノートのルートとして使用するノートをマークします。このようにマークできるのは 1 つだけです。",
|
||||
"archived": "このラベルの付いたノートは、デフォルトでは検索結果に表示されません (ジャンプ先、リンクの追加ダイアログなどにも表示されません)。",
|
||||
"exclude_from_export": "ノート(サブツリーを含む)はノートのエクスポートには含まれません",
|
||||
"run": "どのイベントでスクリプトを実行するかを定義します。可能な値は次の通り:\n<ul>\n<li>frontendStartup - Trilium フロントエンドが起動(または更新)されたとき。モバイルは除く</li>\n<li>mobileStartup - モバイルで Trilium フロントエンドが起動(または更新)されたとき。</li>\n<li>backendStartup - Trilium バックエンドが起動したとき</li>\n<li>hourly - 1時間に1回実行します。 <code>runAtHour</code> というラベルを追加して、実行時刻を指定できます。</li>\n<li>daily - 1日に1回実行</li>\n</ul>",
|
||||
"run": "スクリプトを実行するイベントを定義します。指定可能な値は以下の通りです:\n<ul>\n<li>frontendStartup - Trilium フロントエンドの起動時(または更新時)に実行されます。モバイルでは実行されません。</li>\n<li>mobileStartup - モバイルでの Trilium フロントエンドの起動時(または更新時)に実行されます。</li>\n<li>backendStartup - Trilium バックエンドの起動時。</li>\n<li>hourly - 1時間ごとに実行。 <code>runAtHour</code> というラベルを追加することで、実行時刻を指定できます。</li>\n<li>daily - 1日に1回実行。</li>\n</ul>",
|
||||
"run_on_instance": "どの Trilium インスタンスでこれを実行するかを定義します。デフォルトはすべてのインスタンスです。",
|
||||
"run_at_hour": "何時に実行するかを指定します。 <code>#run=hourly</code> と併用してください。1日に複数回実行したい場合は、複数回定義できます。",
|
||||
"disable_inclusion": "このラベルが付いたスクリプトは親スクリプトの実行には含まれません。",
|
||||
@@ -1390,7 +1408,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "コンテンツの言語",
|
||||
"description": "読み取り専用または編集可能なテキストノートの基本プロパティセクションの言語選択に表示する言語を 1 つ以上選択します。これにより、スペルチェックや右から左へのサポートなどの機能が利用できるようになります。"
|
||||
"description": "読み取り専用または編集可能なテキストノートの基本プロパティセクションの言語選択に表示する言語を 1 つ以上選択してください。これにより、スペルチェック、右から左へのサポート、テキスト抽出(OCR)などの機能が利用できるようになります。"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "図をPNG形式でエクスポート"
|
||||
@@ -2050,7 +2068,9 @@
|
||||
"title": "実験オプション",
|
||||
"disclaimer": "これらのオプションは試験的なもので、動作が不安定になる可能性があります。注意してご使用ください。",
|
||||
"new_layout_name": "新しいレイアウト",
|
||||
"new_layout_description": "よりモダンな外観と使いやすさが向上した新しいレイアウトをお試しください。今後のリリースで大幅な変更が加えられる可能性があります。"
|
||||
"new_layout_description": "よりモダンな外観と使いやすさが向上した新しいレイアウトをお試しください。今後のリリースで大幅な変更が加えられる可能性があります。",
|
||||
"llm_name": "AI / LLM チャット",
|
||||
"llm_description": "大規模言語モデルを活用した AI チャットサイドバーと LLM チャットノートを有効にします。"
|
||||
},
|
||||
"breadcrumb_badges": {
|
||||
"read_only_explicit": "読み取り専用",
|
||||
@@ -2215,5 +2235,89 @@
|
||||
"sample_xy": "XY チャート",
|
||||
"sample_venn": "ベン図",
|
||||
"sample_ishikawa": "石川図"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "メッセージを入力してください…",
|
||||
"send": "送信",
|
||||
"sending": "送信中...",
|
||||
"empty_state": "下記にメッセージを入力して会話を始めましょう。",
|
||||
"searching_web": "ウェブ検索中…",
|
||||
"web_search": "ウェブ検索",
|
||||
"note_tools": "ノートへのアクセス",
|
||||
"sources": "ソース",
|
||||
"extended_thinking": "思考を拡張",
|
||||
"legacy_models": "レガシーモデル",
|
||||
"thinking": "思考中...",
|
||||
"thought_process": "思考プロセス",
|
||||
"tool_calls": "{{count}} 回のツール呼び出し",
|
||||
"input": "入力",
|
||||
"result": "結果",
|
||||
"error": "エラー",
|
||||
"tool_error": "失敗",
|
||||
"total_tokens": "{{total}} トークン",
|
||||
"tokens_detail": "{{prompt}} プロンプト + {{completion}} コンプリーション",
|
||||
"tokens_used": "{{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン",
|
||||
"tokens_used_with_cost": "{{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン (~${{cost}})",
|
||||
"tokens": "トークン",
|
||||
"context_used": "{{percentage}} % 使用済み",
|
||||
"note_context_enabled": "クリックしてノートのコンテキストを無効にする: {{title}}",
|
||||
"note_context_disabled": "クリックして現在のノートをコンテキストに含める",
|
||||
"no_provider_message": "AI プロバイダーが設定されていません。チャットを開始するには、プロバイダーを追加してください。",
|
||||
"add_provider": "AI プロバイダーを追加"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI チャット",
|
||||
"launcher_title": "AI チャットを開く",
|
||||
"new_chat": "新しいチャットを開始",
|
||||
"save_chat": "チャットをノートに保存",
|
||||
"empty_state": "会話を開始",
|
||||
"history": "チャット履歴",
|
||||
"recent_chats": "最近のチャット",
|
||||
"no_chats": "過去のチャットはありません"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "子ノードを追加",
|
||||
"addParent": "親ノードを追加",
|
||||
"addSibling": "兄弟ノードを追加",
|
||||
"removeNode": "ノードを削除",
|
||||
"focus": "フォーカスモード",
|
||||
"cancelFocus": "フォーカスモードを解除",
|
||||
"moveUp": "上に移動",
|
||||
"moveDown": "下に移動",
|
||||
"link": "リンク",
|
||||
"linkBidirectional": "双方向リンク",
|
||||
"clickTips": "対象ノードをクリックしてください",
|
||||
"summary": "概要"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "AI と大規模言語モデルの連携設定をします。",
|
||||
"add_provider": "プロバイダーを追加",
|
||||
"add_provider_title": "AI プロバイダーを追加",
|
||||
"configured_providers": "設定済みプロバイダー",
|
||||
"no_providers_configured": "まだプロバイダーが設定されていません。",
|
||||
"provider_name": "名前",
|
||||
"provider_type": "プロバイダー",
|
||||
"actions": "アクション",
|
||||
"delete_provider": "削除",
|
||||
"delete_provider_confirmation": "プロバイダー \"{{name}}\" を削除してもよろしいですか?",
|
||||
"api_key": "API キー",
|
||||
"api_key_placeholder": "API キーを入力してください",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "抽出されたテキスト(OCR)",
|
||||
"extracted_text_title": "抽出されたテキスト(OCR)",
|
||||
"loading_text": "OCR テキストを読み込んでいます…",
|
||||
"no_text_available": "OCR テキストが見つかりません",
|
||||
"no_text_explanation": "このノートは OCR テキスト抽出処理が行われなかったか、テキストが見つかりませんでした。",
|
||||
"failed_to_load": "OCR テキストの読み込みに失敗しました",
|
||||
"process_now": "OCR 処理",
|
||||
"processing": "処理中…",
|
||||
"processing_started": "OCR 処理が開始されました。しばらくお待ちいただき、ページを更新してください。",
|
||||
"processing_failed": "OCR 処理の開始に失敗しました",
|
||||
"view_extracted_text": "抽出されたテキスト(OCR)を表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,7 +875,7 @@
|
||||
"print_note": "Imprimare notiță",
|
||||
"re_render_note": "Reinterpretare notiță",
|
||||
"save_revision": "Salvează o nouă revizie",
|
||||
"advanced": "Advansat",
|
||||
"advanced": "Avansat",
|
||||
"search_in_note": "Caută în notiță",
|
||||
"convert_into_attachment_failed": "Nu s-a putut converti notița „{{title}}”.",
|
||||
"convert_into_attachment_successful": "Notița „{{title}}” a fost convertită în atașament.",
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
"row-insert-child": "Создать дочернюю заметку",
|
||||
"row-insert-below": "Добавить строку ниже",
|
||||
"row-insert-above": "Добавить строку выше",
|
||||
"new-column-relation": "Связь"
|
||||
"new-column-relation": "Отношение"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Добавить метку",
|
||||
@@ -465,13 +465,13 @@
|
||||
"related_notes_title": "Другие заметки с этой меткой",
|
||||
"label": "Метка",
|
||||
"label_definition": "Определение метки",
|
||||
"relation": "Отношение",
|
||||
"relation": "Детали отношения",
|
||||
"relation_definition": "Определение отношения",
|
||||
"disable_versioning": "отключает автоматическое версионирование. Полезно, например, для больших, но неважных заметок, например, для больших JS-библиотек, используемых для написания скриптов",
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -495,7 +495,7 @@
|
||||
"is_owned_by_note": "принадлежит заметке",
|
||||
"and_more": "... и ещё {{count}}.",
|
||||
"app_theme": "отмечает заметки CSS, которые являются полноценными темами Trilium и, таким образом, доступны в опциях Trilium.",
|
||||
"title_template": "Заголовок по умолчанию для заметок, создаваемых как дочерние элементы данной заметки. Значение вычисляется как строка JavaScript\n и, таким образом, может быть дополнено динамическим контентом с помощью внедренных переменных <code>now</code> и <code>parentNote</code>. Примеры:\n \n <ul>\n <li><code>Литературные произведения ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Лог для ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">вики</a>, документации API для <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> и <a href=\"https://day.js.org/docs/en/display/format\">now</a>.",
|
||||
"title_template": "заголовок по умолчанию для заметок, создаваемых как дочерние элементы текущей. Значение вычисляется как строка JavaScript \n и может быть дополнено динамическим контентом с помощью внедренных переменных <code>now</code> и <code>parentNote</code>. Например:\n \n <ul>\n <li><code>Литературные произведения ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Лог для ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">вики</a>, документации API для <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> и <a href=\"https://day.js.org/docs/en/display/format\">now</a>.",
|
||||
"icon_class": "значение этой метки добавляется в виде CSS-класса к значку в дереве, что помогает визуально различать заметки в дереве. Примером может служить bx bx-home — значки берутся из boxicons. Может использоваться в шаблонах заметок.",
|
||||
"share_favicon": "Заметка о фавиконе должна быть размещена на странице общего доступа. Обычно её назначают корневой папке общего доступа и делают наследуемой. Заметка о фавиконе также должна находиться в поддереве общего доступа. Рассмотрите возможность использования атрибута 'share_hidden_from_tree'.",
|
||||
"inbox": "расположение папки «Входящие» по умолчанию для новых заметок — при создании заметки с помощью кнопки «Новая заметка» на боковой панели заметки будут созданы как дочерние заметки в заметке, помеченной меткой <code>#inbox</code>.",
|
||||
@@ -548,7 +548,8 @@
|
||||
"render_note": "заметки типа «Рендер HTML» будут отображаться с использованием кодовой заметки (HTML или скрипта), и необходимо указать с помощью этой связи, какую заметку следует отобразить",
|
||||
"widget_relation": "заметка, на которую ссылается отношение будет выполнена и отображена как виджет на боковой панели",
|
||||
"share_js": "JavaScript-заметка, которая будет добавлена на страницу общего доступа. JavaScript-заметка также должна находиться в общем поддереве. Рекомендуется использовать 'share_hidden_from_tree'.",
|
||||
"other_notes_with_name": "Другие заметки с {{attributeType}} названием \"{{attributeName}}\""
|
||||
"other_notes_with_name": "Другие заметки с {{attributeType}} названием \"{{attributeName}}\"",
|
||||
"textarea": "Многострочный текст"
|
||||
},
|
||||
"command_palette": {
|
||||
"configure_launch_bar_description": "Откройте конфигурацию панели запуска, чтобы добавить или удалить элементы.",
|
||||
@@ -835,7 +836,8 @@
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
"spreadsheet": "Электронная таблица",
|
||||
"llm-chat": "Чат с ИИ"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1015,7 +1017,7 @@
|
||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||
"switch_to_desktop_version": "Переключиться на версию для компьютера",
|
||||
"new-version-available": "Доступно обновление",
|
||||
"download-update": "Обновить до {{latestVersion}}",
|
||||
"search_notes": "Поиск заметок"
|
||||
@@ -1637,11 +1639,11 @@
|
||||
"start_dragging_relations": "Начните перетягивать отношения отсюда на другую заметку."
|
||||
},
|
||||
"vacuum_database": {
|
||||
"title": "Сжатие базы данных",
|
||||
"description": "Это приведет к перестройке базы данных, что, как правило, приводит к уменьшению размера файла базы данных. Данные затронуты не будут.",
|
||||
"button_text": "Сжать базу данных",
|
||||
"vacuuming_database": "Сжатие БД...",
|
||||
"database_vacuumed": "База данных была сжата"
|
||||
"title": "Уменьшение размера файла базы данных",
|
||||
"description": "Это приведет к перестройке базы данных, что, скорее всего, уменьшит размер её файла. Данные не будут изменены.",
|
||||
"button_text": "Уменьшить размер файла базы данных",
|
||||
"vacuuming_database": "Уменьшение размера файла базы данных...",
|
||||
"database_vacuumed": "База данных была перестроена"
|
||||
},
|
||||
"vim_key_bindings": {
|
||||
"use_vim_keybindings_in_code_notes": "Сочетания клавиш Vim",
|
||||
@@ -1763,8 +1765,8 @@
|
||||
"database_integrity_check": {
|
||||
"title": "Проверка целостности базы данных",
|
||||
"description": "Это позволит проверить базу данных на предмет повреждений на уровне SQLite. Это может занять некоторое время в зависимости от размера базы данных.",
|
||||
"check_button": "Проверить целостность БД",
|
||||
"checking_integrity": "Проверка целостности БД...",
|
||||
"check_button": "Проверить целостность базы данных",
|
||||
"checking_integrity": "Проверка целостности базы данных...",
|
||||
"integrity_check_succeeded": "Проверка целостности прошла успешно - проблем не обнаружено.",
|
||||
"integrity_check_failed": "Проверка целостности завершена с ошибками: {{results}}"
|
||||
},
|
||||
@@ -2115,7 +2117,9 @@
|
||||
"new_layout_description": "Попробуйте новый современный и удобный дизайн. В будущих обновлениях возможны его существенные изменения.",
|
||||
"new_layout_name": "Новый дизайн",
|
||||
"title": "Экспериментальные параметры",
|
||||
"disclaimer": "Эти параметры экспериментальные и могут повлиять на стабильность. Используйте с осторожностью."
|
||||
"disclaimer": "Эти параметры экспериментальные и могут повлиять на стабильность. Используйте с осторожностью.",
|
||||
"llm_name": "ИИ / LLM чат",
|
||||
"llm_description": "Включить боковую панель чата с ИИ и заметки, созданные на основе больших языковых моделей (LLM)."
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Переключить на полный редактор"
|
||||
@@ -2197,5 +2201,123 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Узнать больше"
|
||||
},
|
||||
"media": {
|
||||
"play": "Воспроизвести (пробел)",
|
||||
"pause": "Пауза (пробел)",
|
||||
"back-10s": "Назад на 10 секунд (стрелка влево)",
|
||||
"forward-30s": "Вперёд на 30 секунд",
|
||||
"mute": "Выключить звук (M)",
|
||||
"unmute": "Включить звук (M)",
|
||||
"playback-speed": "Скорость проигрывания",
|
||||
"loop": "Зациклить",
|
||||
"disable-loop": "Отключить зацикливание",
|
||||
"rotate": "Повернуть",
|
||||
"picture-in-picture": "Картинка в картинке",
|
||||
"exit-picture-in-picture": "Выйти из режима \"картинка в картинке\"",
|
||||
"fullscreen": "Режим полного экрана (F)",
|
||||
"exit-fullscreen": "Выйти из режима полного экрана",
|
||||
"unsupported-format": "Предпросмотр недоступен для данного формата файла:\n{{mime}}",
|
||||
"zoom-to-fit": "Заполнить путём масштабирования",
|
||||
"zoom-reset": "Сбросить заполнение путём масштабирования"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"sending": "Отправка...",
|
||||
"empty_state": "Начните общение, написав сообщение в поле ниже.",
|
||||
"searching_web": "Поиск в сети...",
|
||||
"web_search": "Поиск в сети",
|
||||
"note_tools": "Доступ к заметке",
|
||||
"sources": "Источники",
|
||||
"extended_thinking": "Расширенное мышление",
|
||||
"legacy_models": "Устаревшие модели",
|
||||
"thinking": "Обработка...",
|
||||
"thought_process": "Процесс обработки",
|
||||
"tool_calls": "{{count}} вызов(а/ов) инструмента",
|
||||
"input": "Ввод",
|
||||
"result": "Результат",
|
||||
"error": "Ошибка",
|
||||
"tool_error": "ошибка",
|
||||
"total_tokens": "{{total}} токен(а/ов)",
|
||||
"tokens": "токены",
|
||||
"context_used": "{{percentage}}% использовано",
|
||||
"note_context_enabled": "Нажмите, чтобы отключить контекст заметки: {{title}}",
|
||||
"note_context_disabled": "Нажмите, чтобы включить текущую заметку в контекст",
|
||||
"no_provider_message": "Не выбран провайдер ИИ. Добавьте его для начала общения.",
|
||||
"add_provider": "Добавить провайдера ИИ",
|
||||
"tokens_detail": "{{prompt}} (промт) + {{completion}} (ответ)",
|
||||
"tokens_used": "{{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов)",
|
||||
"tokens_used_with_cost": "{{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов) (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов)",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов) (~${{cost}})"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "Чат с ИИ",
|
||||
"launcher_title": "Чат с Open AI",
|
||||
"new_chat": "Начать новый чат",
|
||||
"save_chat": "Сохранить чат в заметках",
|
||||
"empty_state": "Начать общение",
|
||||
"history": "История чата",
|
||||
"recent_chats": "Недавние чаты",
|
||||
"no_chats": "Нет предыдущих чатов"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Введите содержимое вашей Mermaid диаграммы или используйте один из примеров ниже.",
|
||||
"sample_diagrams": "Примеры диаграм:",
|
||||
"sample_flowchart": "Блок-схема",
|
||||
"sample_class": "Диаграмма классов",
|
||||
"sample_sequence": "Диаграмма последовательностей",
|
||||
"sample_entity_relationship": "Диаграмма \"Сущность — связь\"",
|
||||
"sample_state": "Диаграмма состояний",
|
||||
"sample_mindmap": "Ментальная карта",
|
||||
"sample_architecture": "Архитектурная схема",
|
||||
"sample_block": "Структурная схема",
|
||||
"sample_gantt": "Диаграмма Ганта",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Канбан",
|
||||
"sample_ishikawa": "Диаграмма Исикавы",
|
||||
"sample_c4": "C4",
|
||||
"sample_packet": "Диаграмма сетевых пакетов",
|
||||
"sample_pie": "Круговая диаграмма",
|
||||
"sample_quadrant": "Квадрантная диаграмма",
|
||||
"sample_radar": "Радиолокационная схема",
|
||||
"sample_requirement": "Диаграмма зависимостей",
|
||||
"sample_sankey": "Диаграмма Сэнки",
|
||||
"sample_timeline": "Временная диаграмма",
|
||||
"sample_treemap": "Древовидная диаграмма",
|
||||
"sample_user_journey": "Карта пользовательского пути",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Диаграмма Венна"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Добавить дочерний элемент",
|
||||
"addParent": "Добавить родительский элемент",
|
||||
"addSibling": "Добавить элемент на том же уровне",
|
||||
"removeNode": "Удалить узел",
|
||||
"focus": "Режим фокусировки",
|
||||
"cancelFocus": "Отключить режим фокусировки",
|
||||
"moveUp": "Передвинуть выше",
|
||||
"moveDown": "Передвинуть ниже",
|
||||
"link": "Связь",
|
||||
"linkBidirectional": "Двусторонняя связь",
|
||||
"clickTips": "Пожалуйста, нажмите на целевой узел",
|
||||
"summary": "Сводка"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "ИИ / LLM",
|
||||
"settings_description": "Настроить интеграции ИИ и больших языковых моделей.",
|
||||
"add_provider": "Добавить провайдера",
|
||||
"add_provider_title": "Добавить провайдера ИИ",
|
||||
"configured_providers": "Настроенные провайдеры",
|
||||
"no_providers_configured": "Ещё нет настроенных провайдеров.",
|
||||
"provider_name": "Название",
|
||||
"provider_type": "Провайдер",
|
||||
"actions": "Действия",
|
||||
"delete_provider": "Удалить",
|
||||
"delete_provider_confirmation": "Вы уверены, что желаете удалить провайдера \"{{name}}\"?",
|
||||
"api_key": "Ключ API",
|
||||
"api_key_placeholder": "Введите ваш ключ API",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,8 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note
|
||||
|
||||
if (noteContext?.viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (noteContext.viewScope?.viewMode === "ocr") {
|
||||
resultingType = "readOnlyOCRText";
|
||||
} else if (noteContext.viewScope?.viewMode === "attachments") {
|
||||
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (noteContext.viewScope?.viewMode === "note-map") {
|
||||
|
||||
@@ -25,6 +25,7 @@ interface NoteListProps {
|
||||
viewType: ViewTypeOptions | undefined;
|
||||
onReady?: (data: PrintReport) => void;
|
||||
onProgressChanged?(progress: number): void;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
type LazyLoadedComponent = ((props: ViewModeProps<any>) => VNode<any> | undefined);
|
||||
@@ -67,7 +68,7 @@ export default function NoteList(props: Pick<NoteListProps, "displayOnlyCollecti
|
||||
|
||||
export function SearchNoteList(props: Omit<NoteListProps, "isEnabled" | "viewType">) {
|
||||
const viewType = useNoteViewType(props.note);
|
||||
return <CustomNoteList {...props} isEnabled={true} viewType={viewType} />;
|
||||
return <CustomNoteList {...props} isEnabled={true} viewType={viewType} showTextRepresentation />;
|
||||
}
|
||||
|
||||
export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, onProgressChanged, ...restProps }: NoteListProps) {
|
||||
|
||||
@@ -21,4 +21,5 @@ export interface ViewModeProps<T extends object> {
|
||||
media: ViewModeMedia;
|
||||
onReady(data: PrintReport): void;
|
||||
onProgressChanged?: ProgressChangedFn;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ComponentChildren, TargetedMouseEvent } from "preact";
|
||||
|
||||
const contentSizeObserver = new ResizeObserver(onContentResized);
|
||||
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens, showTextRepresentation }: ViewModeProps<{}>) {
|
||||
const expandDepth = useExpansionDepth(note);
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
@@ -37,13 +37,14 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
key={childNote.noteId}
|
||||
note={childNote} parentNote={note}
|
||||
expandDepth={expandDepth} highlightedTokens={highlightedTokens}
|
||||
currentLevel={1} includeArchived={includeArchived} />
|
||||
currentLevel={1} includeArchived={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
))}
|
||||
</Card>
|
||||
</NoteList>;
|
||||
}
|
||||
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens, showTextRepresentation }: ViewModeProps<{}>) {
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
@@ -56,7 +57,8 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
note={childNote}
|
||||
parentNote={note}
|
||||
highlightedTokens={highlightedTokens}
|
||||
includeArchived={includeArchived} />
|
||||
includeArchived={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
))}
|
||||
</div>
|
||||
</NoteList>
|
||||
@@ -91,13 +93,14 @@ function NoteList(props: NoteListProps) {
|
||||
</div>
|
||||
}
|
||||
|
||||
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived }: {
|
||||
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived, showTextRepresentation }: {
|
||||
note: FNote,
|
||||
parentNote: FNote,
|
||||
currentLevel: number,
|
||||
expandDepth: number,
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchived: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}) {
|
||||
|
||||
const [ isExpanded, setExpanded ] = useState(currentLevel <= expandDepth);
|
||||
@@ -113,7 +116,8 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
|
||||
<NoteContent note={note}
|
||||
highlightedTokens={highlightedTokens}
|
||||
noChildrenList
|
||||
includeArchivedNotes={includeArchived} />
|
||||
includeArchivedNotes={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
</CardSection>
|
||||
|
||||
<NoteChildren note={note}
|
||||
@@ -157,6 +161,7 @@ interface GridNoteCardProps {
|
||||
parentNote: FNote;
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchived: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
function GridNoteCard(props: GridNoteCardProps) {
|
||||
@@ -185,6 +190,7 @@ function GridNoteCard(props: GridNoteCardProps) {
|
||||
trim
|
||||
highlightedTokens={props.highlightedTokens}
|
||||
includeArchivedNotes={props.includeArchived}
|
||||
showTextRepresentation={props.showTextRepresentation}
|
||||
/>
|
||||
</CardFrame>
|
||||
);
|
||||
@@ -201,12 +207,13 @@ function NoteAttributes({ note }: { note: FNote }) {
|
||||
return <span className="note-list-attributes" ref={ref} />;
|
||||
}
|
||||
|
||||
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
|
||||
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes, showTextRepresentation }: {
|
||||
note: FNote;
|
||||
trim?: boolean;
|
||||
noChildrenList?: boolean;
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchivedNotes: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
@@ -230,7 +237,8 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
|
||||
trim,
|
||||
noChildrenList,
|
||||
noIncludedNotes: true,
|
||||
includeArchivedNotes
|
||||
includeArchivedNotes,
|
||||
showTextRepresentation
|
||||
})
|
||||
.then(({ $renderedContent, type }) => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
@@ -27,6 +27,7 @@ const VIEW_MODE_ICON_MAPPINGS: Record<Exclude<ViewMode, "default">, string> = {
|
||||
"contextual-help": "bx bx-help-circle",
|
||||
"note-map": "bx bxs-network-chart",
|
||||
attachments: "bx bx-paperclip",
|
||||
ocr: "bx bx-text"
|
||||
};
|
||||
|
||||
export default function TabSwitcher() {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||
* for protected session or attachment information.
|
||||
*/
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
|
||||
|
||||
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
|
||||
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
|
||||
@@ -78,6 +78,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
className: "note-detail-readonly-code",
|
||||
printable: true
|
||||
},
|
||||
readOnlyOCRText: {
|
||||
view: () => import("./type_widgets/ReadOnlyTextRepresentation"),
|
||||
className: "note-detail-ocr-text",
|
||||
printable: true
|
||||
},
|
||||
editableCode: {
|
||||
view: async () => (await import("./type_widgets/code/Code")).EditableCode,
|
||||
className: "note-detail-code",
|
||||
|
||||
@@ -3,6 +3,7 @@ interface SliderProps {
|
||||
onChange(newValue: number);
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
|
||||
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
|
||||
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
||||
<CommandItem command="showNoteOCRText" icon="bx bx-text" disabled={!["image", "file"].includes(noteType)} text={t("note_actions.view_ocr_text")} />
|
||||
{(syncServerHost && isElectron) &&
|
||||
<CommandItem command="openNoteOnServer" icon="bx bx-world" disabled={!syncServerHost} text={t("note_actions.open_note_on_server")} />
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatDateTime } from "../../utils/formatters";
|
||||
import ActionButton from "../react/ActionButton.js";
|
||||
import Dropdown from "../react/Dropdown.js";
|
||||
import { FormListItem } from "../react/FormList.js";
|
||||
import { useActiveNoteContext, useEditorSpacedUpdate, useNote, useNoteProperty } from "../react/hooks.js";
|
||||
import { useActiveNoteContext, useNote, useNoteProperty, useSpacedUpdate } from "../react/hooks.js";
|
||||
import NoItems from "../react/NoItems.js";
|
||||
import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js";
|
||||
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
|
||||
@@ -22,11 +22,15 @@ import RightPanelWidget from "./RightPanelWidget.js";
|
||||
* Sidebar chat widget that appears in the right panel.
|
||||
* Uses a hidden LLM chat note for persistence across all notes.
|
||||
* The same chat persists when switching between notes.
|
||||
*
|
||||
* Unlike the LlmChat type widget which receives a valid FNote from the
|
||||
* framework, the sidebar creates notes lazily. We use useSpacedUpdate with
|
||||
* a direct server.put (using the string noteId) instead of useEditorSpacedUpdate
|
||||
* (which requires an FNote and silently no-ops when it's null).
|
||||
*/
|
||||
export default function SidebarChat() {
|
||||
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
|
||||
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
|
||||
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
|
||||
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
|
||||
|
||||
// Get the current active note context
|
||||
@@ -36,42 +40,35 @@ export default function SidebarChat() {
|
||||
const chatNote = useNote(chatNoteId);
|
||||
const chatTitle = useNoteProperty(chatNote, "title") || t("sidebar_chat.title");
|
||||
|
||||
// Refs for stable access in the spaced update callback
|
||||
const chatNoteIdRef = useRef(chatNoteId);
|
||||
chatNoteIdRef.current = chatNoteId;
|
||||
|
||||
// Use shared chat hook with sidebar-specific options
|
||||
const chat = useLlmChat(
|
||||
// onMessagesChange - trigger save
|
||||
() => spacedUpdateRef.current?.scheduleUpdate(),
|
||||
() => spacedUpdate.scheduleUpdate(),
|
||||
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
|
||||
);
|
||||
|
||||
// Ref to access chat methods in callbacks without triggering re-runs
|
||||
const chatRef = useRef(chat);
|
||||
chatRef.current = chat;
|
||||
|
||||
// Persistence via useEditorSpacedUpdate (same mechanism as the LlmChat type widget).
|
||||
// When chatNote is null (before lazy creation), saves are no-ops.
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note: chatNote,
|
||||
noteType: "llmChat",
|
||||
noteContext: null,
|
||||
getData: () => {
|
||||
const content = chatRef.current.getContent();
|
||||
return { content: JSON.stringify(content) };
|
||||
},
|
||||
onContentChange: (content) => {
|
||||
if (!content) {
|
||||
chatRef.current.clearMessages();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: LlmChatContent = JSON.parse(content);
|
||||
chatRef.current.loadFromContent(parsed);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse LLM chat content:", e);
|
||||
chatRef.current.clearMessages();
|
||||
}
|
||||
// Save directly via server.put using the string noteId.
|
||||
// This avoids the FNote dependency that useEditorSpacedUpdate requires.
|
||||
const spacedUpdate = useSpacedUpdate(async () => {
|
||||
const noteId = chatNoteIdRef.current;
|
||||
if (!noteId) return;
|
||||
|
||||
const content = chatRef.current.getContent();
|
||||
try {
|
||||
await server.put(`notes/${noteId}/data`, {
|
||||
content: JSON.stringify(content)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to save chat:", err);
|
||||
}
|
||||
});
|
||||
spacedUpdateRef.current = spacedUpdate;
|
||||
|
||||
// Update chat context when active note changes
|
||||
useEffect(() => {
|
||||
@@ -95,8 +92,17 @@ export default function SidebarChat() {
|
||||
|
||||
if (existingChat) {
|
||||
setChatNoteId(existingChat.noteId);
|
||||
// Load content
|
||||
try {
|
||||
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
|
||||
if (!cancelled && blob?.content) {
|
||||
const parsed: LlmChatContent = JSON.parse(blob.content);
|
||||
chatRef.current.loadFromContent(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load chat content:", err);
|
||||
}
|
||||
} else {
|
||||
// No existing chat - will create on first message
|
||||
setChatNoteId(null);
|
||||
chatRef.current.clearMessages();
|
||||
}
|
||||
@@ -203,7 +209,17 @@ export default function SidebarChat() {
|
||||
// Save any pending changes before switching
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
setChatNoteId(noteId);
|
||||
// Load the selected chat's content
|
||||
try {
|
||||
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
|
||||
if (blob?.content) {
|
||||
const parsed: LlmChatContent = JSON.parse(blob.content);
|
||||
setChatNoteId(noteId);
|
||||
chatRef.current.loadFromContent(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load selected chat:", err);
|
||||
}
|
||||
}, [chatNoteId, spacedUpdate]);
|
||||
|
||||
return (
|
||||
@@ -273,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={{
|
||||
@@ -291,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
|
||||
}}
|
||||
|
||||
@@ -27,8 +27,10 @@ import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import HelpButton from "../react/HelpButton";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
|
||||
import { TextRepresentation } from "./ReadOnlyTextRepresentation";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
/**
|
||||
@@ -141,6 +143,8 @@ export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
|
||||
|
||||
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
|
||||
const contentWrapper = useRef<HTMLDivElement>(null);
|
||||
const [ ocrModalShown, setOcrModalShown ] = useState(false);
|
||||
const supportsOcr = attachment.role === "image" || attachment.role === "file";
|
||||
|
||||
function refresh() {
|
||||
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
|
||||
@@ -181,7 +185,11 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
|
||||
<div className="attachment-detail-widget">
|
||||
<div className={`attachment-detail-wrapper ${isFullDetail ? "full-detail" : "list-view"} ${attachment.utcDateScheduledForErasureSince ? "scheduled-for-deletion" : ""}`}>
|
||||
<div className="attachment-title-line">
|
||||
<AttachmentActions attachment={attachment} copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard} />
|
||||
<AttachmentActions
|
||||
attachment={attachment}
|
||||
copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard}
|
||||
onShowOcr={supportsOcr ? () => setOcrModalShown(true) : undefined}
|
||||
/>
|
||||
<h4 className="attachment-title">
|
||||
{!isFullDetail ? (
|
||||
<NoteLink
|
||||
@@ -207,6 +215,22 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
|
||||
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
|
||||
<div ref={contentWrapper} className="attachment-content-wrapper" />
|
||||
</div>
|
||||
|
||||
{supportsOcr && (
|
||||
<Modal
|
||||
className="ocr-text-modal"
|
||||
title={t("ocr.extracted_text_title")}
|
||||
show={ocrModalShown}
|
||||
onHidden={() => setOcrModalShown(false)}
|
||||
size="lg"
|
||||
scrollable
|
||||
>
|
||||
<TextRepresentation
|
||||
textUrl={`ocr/attachments/${attachment.attachmentId}/text`}
|
||||
processUrl={`ocr/process-attachment/${attachment.attachmentId}`}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +252,7 @@ function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledFo
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) {
|
||||
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard, onShowOcr }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void, onShowOcr?: () => void }) {
|
||||
const isElectron = utils.isElectron();
|
||||
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -262,6 +286,12 @@ function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { atta
|
||||
icon="bx bx-link"
|
||||
onClick={copyAttachmentLinkToClipboard}
|
||||
>{t("attachments_actions.copy_link_to_clipboard")}</FormListItem>
|
||||
{onShowOcr && (
|
||||
<FormListItem
|
||||
icon="bx bx-text"
|
||||
onClick={onShowOcr}
|
||||
>{t("ocr.view_extracted_text")}</FormListItem>
|
||||
)}
|
||||
<FormDropdownDivider />
|
||||
|
||||
<FormListItem
|
||||
|
||||
@@ -4,7 +4,7 @@ import AppearanceSettings from "./options/appearance";
|
||||
import ShortcutSettings from "./options/shortcuts";
|
||||
import TextNoteSettings from "./options/text_notes";
|
||||
import CodeNoteSettings from "./options/code_notes";
|
||||
import ImageSettings from "./options/images";
|
||||
import MediaSettings from "./options/media";
|
||||
import SpellcheckSettings from "./options/spellcheck";
|
||||
import PasswordSettings from "./options/password";
|
||||
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication";
|
||||
@@ -19,14 +19,14 @@ import "./ContentWidget.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import BackendLog from "./code/BackendLog";
|
||||
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsMedia" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
|
||||
_optionsAppearance: AppearanceSettings,
|
||||
_optionsShortcuts: ShortcutSettings,
|
||||
_optionsTextNotes: TextNoteSettings,
|
||||
_optionsCodeNotes: CodeNoteSettings,
|
||||
_optionsImages: ImageSettings,
|
||||
_optionsMedia: MediaSettings,
|
||||
_optionsSpellcheck: SpellcheckSettings,
|
||||
_optionsPassword: PasswordSettings,
|
||||
_optionsMFA: MultiFactorAuthenticationSettings,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.text-representation {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.text-representation-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-representation-loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.text-representation-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: var(--accented-background-color);
|
||||
min-height: 100px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.text-representation-meta {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-representation-empty {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.text-representation-process-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.text-representation-error {
|
||||
color: var(--error-color);
|
||||
background-color: var(--error-background-color);
|
||||
border: 1px solid var(--error-border-color);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import "./ReadOnlyTextRepresentation.css";
|
||||
|
||||
import type { TextRepresentationResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "loaded"; text: string }
|
||||
| { kind: "empty" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
interface TextRepresentationProps {
|
||||
/** The API path to fetch OCR text from (e.g. `ocr/notes/{id}/text`). */
|
||||
textUrl: string;
|
||||
/** The API path to trigger OCR processing (e.g. `ocr/process-note/{id}`). */
|
||||
processUrl: string;
|
||||
}
|
||||
|
||||
export default function ReadOnlyTextRepresentation({ note }: TypeWidgetProps) {
|
||||
return (
|
||||
<TextRepresentation
|
||||
textUrl={`ocr/notes/${note.noteId}/text`}
|
||||
processUrl={`ocr/process-note/${note.noteId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextRepresentation({ textUrl, processUrl }: TextRepresentationProps) {
|
||||
const [ state, setState ] = useState<State>({ kind: "loading" });
|
||||
const [ processing, setProcessing ] = useState(false);
|
||||
|
||||
async function fetchText() {
|
||||
setState({ kind: "loading" });
|
||||
|
||||
try {
|
||||
const response = await server.get<TextRepresentationResponse>(textUrl);
|
||||
|
||||
if (!response.success) {
|
||||
setState({ kind: "error", message: response.message || t("ocr.failed_to_load") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.hasOcr || !response.text) {
|
||||
setState({ kind: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ kind: "loaded", text: response.text });
|
||||
} catch (error: any) {
|
||||
console.error("Error loading text representation:", error);
|
||||
setState({ kind: "error", message: error.message || t("ocr.failed_to_load") });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchText(); }, [ textUrl ]);
|
||||
|
||||
async function processOCR() {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await server.post<{ success: boolean; message?: string }>(processUrl, { forceReprocess: true });
|
||||
if (response.success) {
|
||||
toast.showMessage(t("ocr.processing_started"));
|
||||
setTimeout(fetchText, 2000);
|
||||
} else {
|
||||
toast.showError(response.message || t("ocr.processing_failed"));
|
||||
}
|
||||
} catch {
|
||||
// Server errors (4xx/5xx) are already shown as toasts by server.ts.
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-representation note-detail-printable">
|
||||
<div className="text-representation-header">
|
||||
<span className="bx bx-text" />{" "}{t("ocr.extracted_text_title")}
|
||||
</div>
|
||||
|
||||
{state.kind === "loading" && (
|
||||
<div className="text-representation-loading">
|
||||
<span className="bx bx-loader-alt bx-spin" />{" "}{t("ocr.loading_text")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === "loaded" && (
|
||||
<>
|
||||
<div className="text-representation-content">
|
||||
{state.text}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "empty" && (
|
||||
<>
|
||||
<div className="text-representation-empty">
|
||||
<span className="bx bx-info-circle" />{" "}{t("ocr.no_text_available")}
|
||||
</div>
|
||||
<div className="text-representation-meta">
|
||||
{t("ocr.no_text_explanation")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "error" && (
|
||||
<div className="text-representation-error">
|
||||
<span className="bx bx-error" />{" "}{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind !== "loading" && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary text-representation-process-btn"
|
||||
disabled={processing}
|
||||
onClick={processOCR}
|
||||
>
|
||||
{processing
|
||||
? <><span className="bx bx-loader-alt bx-spin" />{" "}{t("ocr.processing")}</>
|
||||
: <><span className="bx bx-play" />{" "}{t("ocr.process_now")}</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,42 @@
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75em 0;
|
||||
}
|
||||
|
||||
.option-row > label {
|
||||
width: 40%;
|
||||
.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 > select,
|
||||
.option-row > .dropdown {
|
||||
width: 60%;
|
||||
.option-row-input > select,
|
||||
.option-row-input > .dropdown {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.option-row > .dropdown button {
|
||||
.option-row-input > .dropdown button {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.option-row-description {
|
||||
line-height: 1.3;
|
||||
margin-top: 0.25em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.option-row:last-of-type {
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,24 @@ import { useUniqueName } from "../../../react/hooks";
|
||||
interface OptionsRowProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
children: VNode;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export default function OptionsRow({ name, label, children, centered }: OptionsRowProps) {
|
||||
export default function OptionsRow({ name, label, description, children, centered }: OptionsRowProps) {
|
||||
const id = useUniqueName(name);
|
||||
const childWithId = cloneElement(children, { id });
|
||||
|
||||
return (
|
||||
<div className={`option-row ${centered ? "centered" : ""}`}>
|
||||
{label && <label for={id}>{label}</label>}
|
||||
{childWithId}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { t } from "../../../services/i18n";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
|
||||
export default function ImageSettings() {
|
||||
const [ downloadImagesAutomatically, setDownloadImagesAutomatically ] = useTriliumOptionBool("downloadImagesAutomatically");
|
||||
const [ compressImages, setCompressImages ] = useTriliumOptionBool("compressImages");
|
||||
const [ imageMaxWidthHeight, setImageMaxWidthHeight ] = useTriliumOption("imageMaxWidthHeight");
|
||||
const [ imageJpegQuality, setImageJpegQuality ] = useTriliumOption("imageJpegQuality");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("images.images_section_title")}>
|
||||
<FormGroup name="download-images-automatically" description={t("images.download_images_description")}>
|
||||
<FormCheckbox
|
||||
label={t("images.download_images_automatically")}
|
||||
currentValue={downloadImagesAutomatically} onChange={setDownloadImagesAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<hr/>
|
||||
|
||||
<FormCheckbox
|
||||
name="image-compression-enabled"
|
||||
label={t("images.enable_image_compression")}
|
||||
currentValue={compressImages} onChange={setCompressImages}
|
||||
/>
|
||||
|
||||
<FormGroup name="image-max-width-height" label={t("images.max_image_dimensions")} disabled={!compressImages}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min="1"
|
||||
unit={t("images.max_image_dimensions_unit")}
|
||||
currentValue={imageMaxWidthHeight} onChange={setImageMaxWidthHeight}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="image-jpeg-quality" label={t("images.jpeg_quality_description")} disabled={!compressImages}>
|
||||
<FormTextBoxWithUnit
|
||||
min="10" max="100" type="number"
|
||||
unit={t("units.percentage")}
|
||||
currentValue={imageJpegQuality} onChange={setImageJpegQuality}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
176
apps/client/src/widgets/type_widgets/options/media.tsx
Normal file
176
apps/client/src/widgets/type_widgets/options/media.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import Slider from "../../react/Slider";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import RelatedSettings from "./components/RelatedSettings";
|
||||
|
||||
export default function MediaSettings() {
|
||||
return (
|
||||
<>
|
||||
<ImageSettings />
|
||||
<OcrSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSettings() {
|
||||
const [ downloadImagesAutomatically, setDownloadImagesAutomatically ] = useTriliumOptionBool("downloadImagesAutomatically");
|
||||
const [ compressImages, setCompressImages ] = useTriliumOptionBool("compressImages");
|
||||
const [ imageMaxWidthHeight, setImageMaxWidthHeight ] = useTriliumOption("imageMaxWidthHeight");
|
||||
const [ imageJpegQuality, setImageJpegQuality ] = useTriliumOption("imageJpegQuality");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("images.images_section_title")}>
|
||||
<OptionsRow name="download-images-automatically" label={t("images.download_images_automatically")} description={t("images.download_images_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={downloadImagesAutomatically}
|
||||
onChange={setDownloadImagesAutomatically}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-compression-enabled" label={t("images.enable_image_compression")} description={t("images.enable_image_compression_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={compressImages}
|
||||
onChange={setCompressImages}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-max-width-height" label={t("images.max_image_dimensions")} description={t("images.max_image_dimensions_description")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min="1"
|
||||
disabled={!compressImages}
|
||||
unit={t("images.max_image_dimensions_unit")}
|
||||
currentValue={imageMaxWidthHeight} onChange={setImageMaxWidthHeight}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-jpeg-quality" label={`${t("images.jpeg_quality")} (${imageJpegQuality ?? 75}%)`} description={t("images.jpeg_quality_description")}>
|
||||
<Slider
|
||||
min={10} max={100} step={5}
|
||||
value={parseInt(imageJpegQuality ?? "75", 10)}
|
||||
onChange={(v) => setImageJpegQuality(String(v))}
|
||||
/>
|
||||
</OptionsRow>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function OcrSettings() {
|
||||
const [ ocrAutoProcess, setOcrAutoProcess ] = useTriliumOptionBool("ocrAutoProcessImages");
|
||||
const [ ocrMinConfidence, setOcrMinConfidence ] = useTriliumOption("ocrMinConfidence");
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsSection title={t("images.ocr_section_title")}>
|
||||
<OptionsRow name="ocr-auto-process" label={t("images.ocr_auto_process")} description={t("images.ocr_auto_process_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={ocrAutoProcess}
|
||||
onChange={setOcrAutoProcess}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="ocr-min-confidence" label={`${t("images.ocr_min_confidence")} (${Math.round(parseFloat(ocrMinConfidence ?? "0.75") * 100)}%)`} description={t("images.ocr_confidence_description")}>
|
||||
<Slider
|
||||
min={0} max={100} step={5}
|
||||
value={Math.round(parseFloat(ocrMinConfidence ?? "0.75") * 100)}
|
||||
onChange={(v) => setOcrMinConfidence(String(v / 100))}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<BatchProcessing />
|
||||
</OptionsSection>
|
||||
|
||||
<RelatedSettings items={[
|
||||
{
|
||||
title: t("images.ocr_related_content_languages"),
|
||||
targetPage: "_optionsLocalization"
|
||||
}
|
||||
]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BatchProgress {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
function BatchProcessing() {
|
||||
const [ progress, setProgress ] = useState<BatchProgress | null>(null);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval>>(null);
|
||||
|
||||
const pollProgress = useCallback(() => {
|
||||
server.get<BatchProgress>("ocr/batch-progress").then((data) => {
|
||||
setProgress(data);
|
||||
if (!data.inProgress && pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
toast.showMessage(t("images.batch_ocr_completed", { processed: data.processed }));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clean up polling on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startBatch() {
|
||||
try {
|
||||
const result = await server.post<{ success: boolean; message?: string }>("ocr/batch-process");
|
||||
if (result.success) {
|
||||
toast.showMessage(t("images.batch_ocr_starting"));
|
||||
pollingRef.current = setInterval(pollProgress, 2000);
|
||||
pollProgress();
|
||||
} else {
|
||||
toast.showError(result.message || t("images.batch_ocr_error", { error: "Unknown" }));
|
||||
}
|
||||
} catch {
|
||||
// Server errors are already shown as toasts by server.ts.
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = progress?.inProgress ?? false;
|
||||
|
||||
return (
|
||||
<OptionsRow name="batch-ocr" label={t("images.batch_ocr_title")} description={t("images.batch_ocr_description")}>
|
||||
{isRunning ? (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="progress" style={{ height: "24px" }}>
|
||||
<div
|
||||
className="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style={{ width: `${progress?.percentage ?? 0}%` }}
|
||||
>
|
||||
{t("images.batch_ocr_progress", { processed: progress?.processed ?? 0, total: progress?.total ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={startBatch}
|
||||
>
|
||||
<span className="bx bx-play" />{" "}{t("images.batch_ocr_start")}
|
||||
</button>
|
||||
)}
|
||||
</OptionsRow>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
|
||||
watchdog.on("stateChange", () => onWatchdogStateChange(watchdog));
|
||||
}
|
||||
|
||||
await watchdog.create(container);
|
||||
await watchdog.create(container, {});
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import utils from "../../../services/utils.js";
|
||||
import options from "../../../services/options.js";
|
||||
import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw";
|
||||
import { IconAlignCenter } from "@ckeditor/ckeditor5-icons";
|
||||
|
||||
const TEXT_FORMATTING_GROUP = {
|
||||
label: "Text formatting",
|
||||
|
||||
@@ -19,15 +19,15 @@ if (isDev) {
|
||||
plugins = [
|
||||
viteStaticCopy({
|
||||
targets: assets.map((asset) => ({
|
||||
src: `src/${asset}/*`,
|
||||
dest: asset
|
||||
src: `src/${asset}/**/*`,
|
||||
dest: asset,
|
||||
rename: { stripBase: 2 }
|
||||
}))
|
||||
}),
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,11 +31,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/google": "3.0.54",
|
||||
"@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"
|
||||
"html-to-text": "9.0.5",
|
||||
"js-yaml": "4.1.1",
|
||||
"unpdf": "1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
@@ -56,6 +59,7 @@
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
@@ -94,8 +98,8 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "8.0.0",
|
||||
"https-proxy-agent": "8.0.0",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"i18next": "26.0.3",
|
||||
"i18next-fs-backend": "2.6.3",
|
||||
"image-type": "6.1.0",
|
||||
"ini": "6.0.0",
|
||||
"is-animated": "2.0.2",
|
||||
@@ -105,6 +109,7 @@
|
||||
"marked": "17.0.5",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"officeparser": "6.0.7",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sax": "1.6.0",
|
||||
@@ -113,11 +118,12 @@
|
||||
"striptags": "3.2.0",
|
||||
"supertest": "7.2.2",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"tesseract.js": "7.0.0",
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"vite": "8.0.3",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
"yauzl": "3.3.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
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 log from "./services/log.js";
|
||||
@@ -60,8 +61,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);
|
||||
@@ -92,6 +93,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")));
|
||||
|
||||
@@ -159,7 +159,8 @@
|
||||
},
|
||||
"quarterNumber": "第 {quarterNumber} 季度",
|
||||
"special_notes": {
|
||||
"search_prefix": "搜索:"
|
||||
"search_prefix": "搜索:",
|
||||
"llm_chat_prefix": "对话:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "同步服务器主机未配置。请先配置同步。",
|
||||
@@ -217,7 +218,9 @@
|
||||
"inbox-title": "收件箱",
|
||||
"command-palette": "打开命令面板",
|
||||
"zen-mode": "禅模式",
|
||||
"tab-switcher-title": "标签切换器"
|
||||
"tab-switcher-title": "标签切换器",
|
||||
"llm-chat-history-title": "AI对话历史",
|
||||
"sidebar-chat-title": "AI对话"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "新建笔记",
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"shortcuts-title": "Shortcuts",
|
||||
"text-notes": "Text Notes",
|
||||
"code-notes-title": "Code Notes",
|
||||
"images-title": "Images",
|
||||
"images-title": "Media",
|
||||
"spellcheck-title": "Spellcheck",
|
||||
"password-title": "Password",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
|
||||
@@ -428,7 +428,7 @@
|
||||
"end-time": "Heure de fin",
|
||||
"geolocation": "Géolocalisation",
|
||||
"built-in-templates": "Modèles intégrés",
|
||||
"board": "Tableau Kanban",
|
||||
"board": "Vue Kanban",
|
||||
"status": "État",
|
||||
"board_note_first": "Première note",
|
||||
"board_note_second": "Deuxième note",
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
},
|
||||
"quarterNumber": "Ráithe {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Cuardaigh:"
|
||||
"search_prefix": "Cuardaigh:",
|
||||
"llm_chat_prefix": "Comhrá:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Níl an freastalaí sioncrónaithe cumraithe. Cumraigh an sioncrónú ar dtús.",
|
||||
@@ -314,7 +315,10 @@
|
||||
"user-guide": "Treoir Úsáideora",
|
||||
"localization": "Teanga & Réigiún",
|
||||
"inbox-title": "Bosca isteach",
|
||||
"tab-switcher-title": "Athraitheoir Cluaisíní"
|
||||
"tab-switcher-title": "Athraitheoir Cluaisíní",
|
||||
"llm-chat-history-title": "Stair Comhrá AI",
|
||||
"llm-title": "AI / LLM",
|
||||
"sidebar-chat-title": "Comhrá AI"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nóta nua",
|
||||
|
||||
@@ -244,7 +244,8 @@
|
||||
"december": "12月"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "検索:"
|
||||
"search_prefix": "検索:",
|
||||
"llm_chat_prefix": "チャット:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。",
|
||||
@@ -257,7 +258,7 @@
|
||||
"shortcuts-title": "ショートカット",
|
||||
"text-notes": "テキストノート",
|
||||
"code-notes-title": "コードノート",
|
||||
"images-title": "画像",
|
||||
"images-title": "メディア",
|
||||
"spellcheck-title": "スペルチェック",
|
||||
"password-title": "パスワード",
|
||||
"backup-title": "バックアップ",
|
||||
@@ -302,7 +303,10 @@
|
||||
"base-abstract-launcher-title": "ベース アブストラクトランチャー",
|
||||
"command-palette": "コマンドパレットを開く",
|
||||
"zen-mode": "禅モード",
|
||||
"tab-switcher-title": "タブ切り替え"
|
||||
"tab-switcher-title": "タブ切り替え",
|
||||
"llm-chat-history-title": "AI チャット履歴",
|
||||
"llm-title": "AI / LLM",
|
||||
"sidebar-chat-title": "AI チャット"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "新しいノート",
|
||||
|
||||
@@ -157,7 +157,10 @@
|
||||
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
|
||||
"zen-mode": "Режим \"Дзен\"",
|
||||
"command-palette": "Открыть панель команд",
|
||||
"tab-switcher-title": "Переключатель вкладок"
|
||||
"tab-switcher-title": "Переключатель вкладок",
|
||||
"llm-chat-history-title": "История чата с ИИ",
|
||||
"llm-title": "ИИ / LLM",
|
||||
"sidebar-chat-title": "Чат с ИИ"
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Закладки",
|
||||
@@ -310,7 +313,8 @@
|
||||
"description": "Прежде чем начать использовать Trilium через веб-браузер, вам необходимо установить пароль. Этот пароль будет использоваться для входа."
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Поиск:"
|
||||
"search_prefix": "Поиск:",
|
||||
"llm_chat_prefix": "Чат:"
|
||||
},
|
||||
"notes": {
|
||||
"duplicate-note-suffix": "(дубликат)",
|
||||
@@ -370,7 +374,7 @@
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронизация с настольной версией",
|
||||
"description": "Это настройку нужно выполнить с помощью настольной версии:",
|
||||
"step1": "Откройте приложение Trilium Notes на ПК.",
|
||||
"step1": "Откройте приложение Trilium Notes на компьютере.",
|
||||
"step2": "В меню Trilium выберите «Параметры».",
|
||||
"step3": "Нажмите на категорию «Синхронизация».",
|
||||
"step4": "Измените адрес экземпляра сервера на: {{- host}} и нажмите «Сохранить».",
|
||||
|
||||
56
apps/server/src/routes/api/ocr.spec.ts
Normal file
56
apps/server/src/routes/api/ocr.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import ocrRoutes from "./ocr.js";
|
||||
|
||||
// Mock the OCR service
|
||||
vi.mock("../../services/ocr/ocr_service.js", () => ({
|
||||
default: {
|
||||
startBatchProcessing: vi.fn(() => Promise.resolve({ success: true })),
|
||||
getBatchProgress: vi.fn(() => ({ inProgress: false, total: 0, processed: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock becca
|
||||
vi.mock("../../becca/becca.js", () => ({
|
||||
default: {}
|
||||
}));
|
||||
|
||||
// Mock sql
|
||||
vi.mock("../../services/sql.js", () => ({
|
||||
default: {
|
||||
getRow: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock log
|
||||
vi.mock("../../services/log.js", () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("OCR API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return success for batch processing", async () => {
|
||||
const result = await ocrRoutes.batchProcessOCR();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should return batch progress", async () => {
|
||||
const result = await ocrRoutes.getBatchProgress();
|
||||
expect(result).toEqual({ inProgress: false, total: 0, processed: 0 });
|
||||
});
|
||||
|
||||
it("should return 400 when batch processing fails", async () => {
|
||||
const ocrService = await import("../../services/ocr/ocr_service.js");
|
||||
vi.mocked(ocrService.default.startBatchProcessing).mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "No images found that need OCR processing"
|
||||
});
|
||||
|
||||
const result = await ocrRoutes.batchProcessOCR();
|
||||
expect(result).toEqual([400, { success: false, message: "No images found that need OCR processing" }]);
|
||||
});
|
||||
});
|
||||
241
apps/server/src/routes/api/ocr.ts
Normal file
241
apps/server/src/routes/api/ocr.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { TextRepresentationResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import ocrService from "../../services/ocr/ocr_service.js";
|
||||
import sql from "../../services/sql.js";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/process-note/{noteId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific note
|
||||
* operationId: ocr-process-note
|
||||
* parameters:
|
||||
* - name: noteId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the note to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: >
|
||||
* Tesseract language code to use (e.g. 'eng', 'fra', 'deu', 'eng+fra').
|
||||
* If omitted, the language is resolved automatically from the note's language label,
|
||||
* the enabled content languages, or the UI locale.
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* '400':
|
||||
* description: Bad request - unsupported file type
|
||||
* '404':
|
||||
* description: Note not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processNoteOCR(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const { language, forceReprocess = false } = req.body || {};
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return [404, { success: false, message: 'Note not found' }];
|
||||
}
|
||||
|
||||
const result = await ocrService.processNoteOCR(noteId, { language, forceReprocess });
|
||||
if (!result) {
|
||||
return [400, { success: false, message: 'Note is not an image or has unsupported format' }];
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/process-attachment/{attachmentId}:
|
||||
* post:
|
||||
* summary: Process OCR for a specific attachment
|
||||
* operationId: ocr-process-attachment
|
||||
* parameters:
|
||||
* - name: attachmentId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: ID of the attachment to process
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* language:
|
||||
* type: string
|
||||
* description: >
|
||||
* Tesseract language code to use (e.g. 'eng', 'fra', 'deu', 'eng+fra').
|
||||
* If omitted, the language is resolved automatically from the owner note's language label,
|
||||
* the enabled content languages, or the UI locale.
|
||||
* forceReprocess:
|
||||
* type: boolean
|
||||
* description: Force reprocessing even if OCR already exists
|
||||
* default: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: OCR processing completed successfully
|
||||
* '400':
|
||||
* description: Bad request - unsupported file type
|
||||
* '404':
|
||||
* description: Attachment not found
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function processAttachmentOCR(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
const { language, forceReprocess = false } = req.body || {};
|
||||
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
return [404, { success: false, message: 'Attachment not found' }];
|
||||
}
|
||||
|
||||
const result = await ocrService.processAttachmentOCR(attachmentId, { language, forceReprocess });
|
||||
if (!result) {
|
||||
return [400, { success: false, message: 'Attachment is not an image or has unsupported format' }];
|
||||
}
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-process:
|
||||
* post:
|
||||
* summary: Process OCR for all images without existing OCR results
|
||||
* operationId: ocr-batch-process
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing initiated successfully
|
||||
* '400':
|
||||
* description: Bad request - OCR disabled or already processing
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function batchProcessOCR() {
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
if (!result.success) {
|
||||
return [400, result];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/batch-progress:
|
||||
* get:
|
||||
* summary: Get batch OCR processing progress
|
||||
* operationId: ocr-batch-progress
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Batch processing progress information
|
||||
* '500':
|
||||
* description: Internal server error
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
async function getBatchProgress() {
|
||||
return ocrService.getBatchProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ocr/notes/{noteId}/text:
|
||||
* get:
|
||||
* summary: Get OCR text for a specific note
|
||||
* operationId: ocr-get-note-text
|
||||
* parameters:
|
||||
* - name: noteId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Note ID to get OCR text for
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OCR text retrieved successfully
|
||||
* 404:
|
||||
* description: Note not found
|
||||
* tags: ["ocr"]
|
||||
*/
|
||||
function getTextRepresentation(blobId: string | undefined): TextRepresentationResponse {
|
||||
let ocrText: string | null = null;
|
||||
|
||||
if (blobId) {
|
||||
const result = sql.getRow<{
|
||||
textRepresentation: string | null;
|
||||
}>(`
|
||||
SELECT textRepresentation
|
||||
FROM blobs
|
||||
WHERE blobId = ?
|
||||
`, [blobId]);
|
||||
|
||||
if (result) {
|
||||
ocrText = result.textRepresentation;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: ocrText || '',
|
||||
hasOcr: !!ocrText
|
||||
};
|
||||
}
|
||||
|
||||
async function getNoteOCRText(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNote(req.params.noteId);
|
||||
if (!note) {
|
||||
return [404, { success: false, message: 'Note not found' }];
|
||||
}
|
||||
|
||||
return getTextRepresentation(note.blobId);
|
||||
}
|
||||
|
||||
async function getAttachmentOCRText(req: Request<{ attachmentId: string }>) {
|
||||
const attachment = becca.getAttachment(req.params.attachmentId);
|
||||
if (!attachment) {
|
||||
return [404, { success: false, message: 'Attachment not found' }];
|
||||
}
|
||||
|
||||
return getTextRepresentation(attachment.blobId);
|
||||
}
|
||||
|
||||
export default {
|
||||
processNoteOCR,
|
||||
processAttachmentOCR,
|
||||
batchProcessOCR,
|
||||
getBatchProgress,
|
||||
getNoteOCRText,
|
||||
getAttachmentOCRText
|
||||
};
|
||||
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 };
|
||||
@@ -28,6 +28,7 @@ import llmChatRoute from "./api/llm_chat.js";
|
||||
import llmSpecialNotesRoute from "./api/llm_special_notes.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import ocrRoute from "./api/ocr.js";
|
||||
import passwordApiRoute from "./api/password.js";
|
||||
import recoveryCodes from './api/recovery_codes.js';
|
||||
import senderRoute from "./api/sender.js";
|
||||
@@ -210,6 +211,14 @@ function register(app: express.Application) {
|
||||
etapiBackupRoute.register(router);
|
||||
etapiMetricsRoute.register(router);
|
||||
|
||||
// OCR API
|
||||
asyncApiRoute(PST, "/api/ocr/process-note/:noteId", ocrRoute.processNoteOCR);
|
||||
asyncApiRoute(PST, "/api/ocr/process-attachment/:attachmentId", ocrRoute.processAttachmentOCR);
|
||||
asyncApiRoute(PST, "/api/ocr/batch-process", ocrRoute.batchProcessOCR);
|
||||
asyncApiRoute(GET, "/api/ocr/batch-progress", ocrRoute.getBatchProgress);
|
||||
asyncApiRoute(GET, "/api/ocr/notes/:noteId/text", ocrRoute.getNoteOCRText);
|
||||
asyncApiRoute(GET, "/api/ocr/attachments/:attachmentId/text", ocrRoute.getAttachmentOCRText);
|
||||
|
||||
app.use("", router);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { getTriliumDataDir as getTriliumDataDirType, getDataDirs as getDataDirsType, getPlatformAppDataDir as getPlatformAppDataDirType } from "./data_dir.js";
|
||||
import type { getDataDirs as getDataDirsType, getPlatformAppDataDir as getPlatformAppDataDirType,getTriliumDataDir as getTriliumDataDirType } from "./data_dir.js";
|
||||
|
||||
describe("data_dir.ts unit tests", async () => {
|
||||
let getTriliumDataDir: typeof getTriliumDataDirType;
|
||||
@@ -277,7 +277,7 @@ describe("data_dir.ts unit tests", async () => {
|
||||
});
|
||||
|
||||
describe("#getDataDirs()", () => {
|
||||
const envKeys: Omit<keyof ReturnType<typeof getDataDirs>, "TRILIUM_DATA_DIR">[] = [ "DOCUMENT_PATH", "BACKUP_DIR", "LOG_DIR", "ANONYMIZED_DB_DIR", "CONFIG_INI_PATH", "TMP_DIR" ];
|
||||
const envKeys: Omit<keyof ReturnType<typeof getDataDirs>, "TRILIUM_DATA_DIR">[] = [ "DOCUMENT_PATH", "BACKUP_DIR", "LOG_DIR", "ANONYMIZED_DB_DIR", "CONFIG_INI_PATH", "TMP_DIR", "OCR_CACHE_DIR" ];
|
||||
|
||||
const setMockedEnv = (prefix: string | null) => {
|
||||
envKeys.forEach((key) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* This file resolves trilium data path in this order of priority:
|
||||
* - case A) if TRILIUM_DATA_DIR environment variable exists, then its value is used as the path
|
||||
@@ -8,8 +6,8 @@
|
||||
* - case D) as a fallback if the previous step fails, we'll use home dir
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { join as pathJoin } from "path";
|
||||
|
||||
const DIR_NAME = "trilium-data";
|
||||
@@ -43,13 +41,14 @@ export function getTriliumDataDir(dataDirName: string) {
|
||||
|
||||
export function getDataDirs(TRILIUM_DATA_DIR: string) {
|
||||
const dataDirs = {
|
||||
TRILIUM_DATA_DIR: TRILIUM_DATA_DIR,
|
||||
TRILIUM_DATA_DIR,
|
||||
DOCUMENT_PATH: process.env.TRILIUM_DOCUMENT_PATH || pathJoin(TRILIUM_DATA_DIR, "document.db"),
|
||||
BACKUP_DIR: process.env.TRILIUM_BACKUP_DIR || pathJoin(TRILIUM_DATA_DIR, "backup"),
|
||||
LOG_DIR: process.env.TRILIUM_LOG_DIR || pathJoin(TRILIUM_DATA_DIR, "log"),
|
||||
TMP_DIR: process.env.TRILIUM_TMP_DIR || pathJoin(TRILIUM_DATA_DIR, "tmp"),
|
||||
ANONYMIZED_DB_DIR: process.env.TRILIUM_ANONYMIZED_DB_DIR || pathJoin(TRILIUM_DATA_DIR, "anonymized-db"),
|
||||
CONFIG_INI_PATH: process.env.TRILIUM_CONFIG_INI_PATH || pathJoin(TRILIUM_DATA_DIR, "config.ini")
|
||||
CONFIG_INI_PATH: process.env.TRILIUM_CONFIG_INI_PATH || pathJoin(TRILIUM_DATA_DIR, "config.ini"),
|
||||
OCR_CACHE_DIR: process.env.TRILIUM_OCR_CACHE_DIR || pathJoin(TRILIUM_DATA_DIR, "ocr-cache")
|
||||
} as const;
|
||||
|
||||
createDirIfNotExisting(dataDirs.TMP_DIR);
|
||||
|
||||
@@ -1,2 +1,42 @@
|
||||
import { handlers } from "@triliumnext/core";
|
||||
import { events, getLog, handlers, options as optionService } from "@triliumnext/core";
|
||||
|
||||
import ocrService from "./ocr/ocr_service";
|
||||
export default handlers;
|
||||
|
||||
export function registerOcrHandlers() {
|
||||
events.subscribe(events.ENTITY_CREATED, ({ entityName, entity }) => {
|
||||
switch (entityName) {
|
||||
case "notes": {
|
||||
// Note: OCR processing for images is now handled in image.ts during image processing
|
||||
// OCR processing for files remains here since they don't go through image processing
|
||||
if (entity.type === 'file' && optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
autoProcessOCR(entity.mime, () => ocrService.processNoteOCR(entity.noteId), `file note ${entity.noteId}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "attachments": {
|
||||
// Image attachments are handled in image.ts after async image processing sets the real MIME type.
|
||||
// Only handle non-image (file) attachments here.
|
||||
if (entity.role === "file" && optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
autoProcessOCR(entity.mime, () => ocrService.processAttachmentOCR(entity.attachmentId), `attachment ${entity.attachmentId}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function autoProcessOCR(mime: string, process: () => Promise<unknown>, entityDescription: string) {
|
||||
const supportedMimeTypes = ocrService.getAllSupportedMimeTypes();
|
||||
|
||||
const log = getLog();
|
||||
if (mime && supportedMimeTypes.includes(mime)) {
|
||||
process().then(result => {
|
||||
if (result) {
|
||||
log.info(`Automatically processed OCR for ${entityDescription} with MIME type ${mime}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
log.error(`Failed to automatically process OCR for ${entityDescription}: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ export async function initializeTranslations(i18nextInstance: typeof i18next, lo
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: join(resourceDir, "assets/translations/{{lng}}/{{ns}}.json")
|
||||
},
|
||||
showSupportNotice: false
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize dayjs locale.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import { sanitize } from "@triliumnext/core";
|
||||
import imageType from "image-type";
|
||||
import isAnimated from "is-animated";
|
||||
@@ -10,6 +8,7 @@ import sanitizeFilename from "sanitize-filename";
|
||||
import becca from "../becca/becca.js";
|
||||
import log from "./log.js";
|
||||
import noteService from "./notes.js";
|
||||
import ocrService from "./ocr/ocr_service.js";
|
||||
import optionService from "./options.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import sql from "./sql.js";
|
||||
@@ -47,9 +46,8 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm
|
||||
async function getImageType(buffer: Buffer) {
|
||||
if (isSvg(buffer.toString())) {
|
||||
return { ext: "svg" };
|
||||
}
|
||||
}
|
||||
return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default
|
||||
|
||||
}
|
||||
|
||||
function getImageMimeFromExtension(ext: string) {
|
||||
@@ -80,6 +78,8 @@ function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string)
|
||||
|
||||
note.setContent(buffer);
|
||||
});
|
||||
|
||||
scheduleOcrForNote(noteId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +122,8 @@ function saveImage(parentNoteId: string, uploadBuffer: Buffer, originalName: str
|
||||
|
||||
note.setContent(buffer, { forceSave: true });
|
||||
});
|
||||
|
||||
scheduleOcrForNote(note.noteId);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -160,13 +162,14 @@ function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalNam
|
||||
}, 5000);
|
||||
|
||||
// resizing images asynchronously since JIMP does not support sync operation
|
||||
const attachmentId = attachment.attachmentId;
|
||||
processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => {
|
||||
sql.transactional(() => {
|
||||
// re-read, might be changed in the meantime
|
||||
if (!attachment.attachmentId) {
|
||||
if (!attachmentId) {
|
||||
throw new Error("Missing attachment ID.");
|
||||
}
|
||||
attachment = becca.getAttachmentOrThrow(attachment.attachmentId);
|
||||
attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
|
||||
attachment.mime = getImageMimeFromExtension(imageFormat.ext);
|
||||
|
||||
@@ -177,11 +180,37 @@ function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalNam
|
||||
|
||||
attachment.setContent(buffer, { forceSave: true });
|
||||
});
|
||||
|
||||
scheduleOcrForAttachment(attachmentId);
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function scheduleOcrForNote(noteId: string) {
|
||||
if (optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await ocrService.processNoteOCR(noteId);
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for note ${noteId}: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleOcrForAttachment(attachmentId: string | undefined) {
|
||||
if (attachmentId && optionService.getOptionBool("ocrAutoProcessImages")) {
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await ocrService.processAttachmentOCR(attachmentId);
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for attachment ${attachmentId}: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function shrinkImage(buffer: Buffer, originalName: string) {
|
||||
let jpegQuality = optionService.getOptionInt("imageJpegQuality", 0);
|
||||
|
||||
|
||||
@@ -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,277 +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; getContent: () => string | Buffer }) {
|
||||
const content = note.getContent();
|
||||
if (typeof content !== "string") {
|
||||
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;
|
||||
}
|
||||
450
apps/server/src/services/ocr/ocr_service.spec.ts
Normal file
450
apps/server/src/services/ocr/ocr_service.spec.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock Tesseract.js
|
||||
const mockWorker = {
|
||||
recognize: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
reinitialize: vi.fn()
|
||||
};
|
||||
|
||||
const mockTesseract = {
|
||||
createWorker: vi.fn().mockResolvedValue(mockWorker)
|
||||
};
|
||||
|
||||
vi.mock('tesseract.js', () => ({
|
||||
default: mockTesseract
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const mockOptions = {
|
||||
getOptionBool: vi.fn(),
|
||||
getOption: vi.fn()
|
||||
};
|
||||
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
const mockSql = {
|
||||
execute: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
getRows: vi.fn(),
|
||||
getColumn: vi.fn()
|
||||
};
|
||||
|
||||
const mockBecca = {
|
||||
getNote: vi.fn(),
|
||||
getAttachment: vi.fn(),
|
||||
getBlob: vi.fn()
|
||||
};
|
||||
|
||||
const mockBlobService = {
|
||||
calculateContentHash: vi.fn().mockReturnValue('hash123')
|
||||
};
|
||||
|
||||
const mockEntityChangesService = {
|
||||
putEntityChange: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../options.js', () => ({
|
||||
default: mockOptions
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
default: mockLog
|
||||
}));
|
||||
|
||||
vi.mock('../sql.js', () => ({
|
||||
default: mockSql
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
vi.mock('../blob.js', () => ({
|
||||
default: mockBlobService
|
||||
}));
|
||||
|
||||
vi.mock('../entity_changes.js', () => ({
|
||||
default: mockEntityChangesService
|
||||
}));
|
||||
|
||||
// Import the service after mocking
|
||||
let ocrService: typeof import('./ocr_service.js').default;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockOptions.getOptionBool.mockReturnValue(true);
|
||||
mockOptions.getOption.mockImplementation((name: string) => {
|
||||
if (name === 'ocrMinConfidence') return '0';
|
||||
return 'eng';
|
||||
});
|
||||
mockSql.execute.mockImplementation(() => ({ lastInsertRowid: 1 }));
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
mockSql.getRows.mockReturnValue([]);
|
||||
mockSql.getColumn.mockReturnValue([]);
|
||||
|
||||
// Mock getBlob for putBlobEntityChange
|
||||
mockBecca.getBlob.mockReturnValue({
|
||||
blobId: 'blob123',
|
||||
content: Buffer.from('data'),
|
||||
textRepresentation: null,
|
||||
utcDateModified: '2025-01-01'
|
||||
});
|
||||
|
||||
mockTesseract.createWorker.mockImplementation(async () => {
|
||||
return mockWorker;
|
||||
});
|
||||
|
||||
// Dynamically import the service to ensure mocks are applied
|
||||
const module = await import('./ocr_service.js');
|
||||
ocrService = module.default;
|
||||
|
||||
// Reset the OCR service state
|
||||
(ocrService as any).batchProcessingState = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OCRService', () => {
|
||||
describe('extractTextFromFile', () => {
|
||||
const mockImageBuffer = Buffer.from('fake-image-data');
|
||||
|
||||
it('should extract text successfully with default options', async () => {
|
||||
const mockResult = {
|
||||
data: {
|
||||
text: 'Extracted text from image',
|
||||
confidence: 95,
|
||||
words: [{ text: 'Extracted', confidence: 95 }, { text: 'text', confidence: 95 }, { text: 'from', confidence: 95 }, { text: 'image', confidence: 95 }]
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await ocrService.extractTextFromFile(mockImageBuffer, 'image/jpeg');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.text).toBe('Extracted text from image');
|
||||
expect(result.extractedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('should handle OCR recognition errors', async () => {
|
||||
const error = new Error('OCR recognition failed');
|
||||
mockWorker.recognize.mockRejectedValue(error);
|
||||
|
||||
await expect(ocrService.extractTextFromFile(mockImageBuffer, 'image/jpeg')).rejects.toThrow('OCR recognition failed');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Image OCR text extraction failed: Error: OCR recognition failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeOCRResult', () => {
|
||||
it('should store OCR result in blob successfully', () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
ocrService.storeOCRResult('blob123', ocrResult);
|
||||
|
||||
expect(mockSql.execute).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE blobs SET textRepresentation = ?'),
|
||||
['Sample text', 'blob123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined blobId gracefully', () => {
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
ocrService.storeOCRResult(undefined, ocrResult);
|
||||
|
||||
expect(mockSql.execute).not.toHaveBeenCalled();
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Cannot store OCR result: blobId is undefined');
|
||||
});
|
||||
|
||||
it('should handle database update errors', () => {
|
||||
const error = new Error('Database error');
|
||||
mockSql.execute.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const ocrResult = {
|
||||
text: 'Sample text',
|
||||
confidence: 0.95,
|
||||
extractedAt: '2025-06-10T10:00:00.000Z',
|
||||
language: 'eng'
|
||||
};
|
||||
|
||||
expect(() => ocrService.storeOCRResult('blob123', ocrResult)).toThrow('Database error');
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Failed to store OCR result for blob blob123: Error: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processNoteOCR', () => {
|
||||
const mockNote = {
|
||||
noteId: 'note123',
|
||||
type: 'image',
|
||||
mime: 'image/jpeg',
|
||||
blobId: 'blob123',
|
||||
getContent: vi.fn(),
|
||||
getLabelValue: vi.fn().mockReturnValue(null)
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getNote.mockReturnValue(mockNote);
|
||||
mockNote.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
mockNote.mime = 'image/jpeg';
|
||||
});
|
||||
|
||||
it('should process note OCR successfully', async () => {
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Note image text',
|
||||
confidence: 90,
|
||||
words: [{ text: 'Note', confidence: 90 }, { text: 'image', confidence: 90 }, { text: 'text', confidence: 90 }]
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.text).toBe('Note image text');
|
||||
expect(mockBecca.getNote).toHaveBeenCalledWith('note123');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip processing if OCR already exists and forceReprocess is false', async () => {
|
||||
mockSql.getRow.mockReturnValue({ textRepresentation: 'Existing text' });
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockNote.getContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reprocess if forceReprocess is true', async () => {
|
||||
mockSql.getRow.mockReturnValue({ textRepresentation: 'Existing text' });
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'New processed text',
|
||||
confidence: 95,
|
||||
words: [{ text: 'New', confidence: 95 }, { text: 'processed', confidence: 95 }, { text: 'text', confidence: 95 }]
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123', { forceReprocess: true });
|
||||
|
||||
expect(result?.text).toBe('New processed text');
|
||||
expect(mockNote.getContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null for non-existent note', async () => {
|
||||
mockBecca.getNote.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processNoteOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Note nonexistent not found');
|
||||
});
|
||||
|
||||
it('should return null for unsupported MIME type', async () => {
|
||||
mockNote.mime = 'text/plain';
|
||||
|
||||
const result = await ocrService.processNoteOCR('note123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('note note123 has unsupported MIME type text/plain for text extraction, skipping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAttachmentOCR', () => {
|
||||
const mockAttachment = {
|
||||
attachmentId: 'attach123',
|
||||
ownerId: 'note123',
|
||||
role: 'image',
|
||||
mime: 'image/png',
|
||||
blobId: 'blob456',
|
||||
getContent: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBecca.getAttachment.mockReturnValue(mockAttachment);
|
||||
mockBecca.getNote.mockReturnValue({ getLabelValue: vi.fn().mockReturnValue(null) });
|
||||
mockAttachment.getContent.mockReturnValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
it('should process attachment OCR successfully', async () => {
|
||||
mockSql.getRow.mockReturnValue(null);
|
||||
|
||||
const mockOCRResult = {
|
||||
data: {
|
||||
text: 'Attachment image text',
|
||||
confidence: 92,
|
||||
words: [{ text: 'Attachment', confidence: 92 }, { text: 'image', confidence: 92 }, { text: 'text', confidence: 92 }]
|
||||
}
|
||||
};
|
||||
mockWorker.recognize.mockResolvedValue(mockOCRResult);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('attach123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.text).toBe('Attachment image text');
|
||||
expect(mockBecca.getAttachment).toHaveBeenCalledWith('attach123');
|
||||
});
|
||||
|
||||
it('should return null for non-existent attachment', async () => {
|
||||
mockBecca.getAttachment.mockReturnValue(null);
|
||||
|
||||
const result = await ocrService.processAttachmentOCR('nonexistent');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(mockLog.error).toHaveBeenCalledWith('Attachment nonexistent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
// Helper to mock getBlobsNeedingOCR to return entities
|
||||
function mockBlobsNeedingOCR(notes: Array<{ entityId: string; mimeType: string }>, attachments: Array<{ entityId: string; mimeType: string }> = []) {
|
||||
const noteRows = notes.map(n => ({ blobId: `blob_${n.entityId}`, mimeType: n.mimeType, entityId: n.entityId }));
|
||||
const attachmentRows = attachments.map(a => ({ blobId: `blob_${a.entityId}`, mimeType: a.mimeType, entityId: a.entityId }));
|
||||
mockSql.getRows.mockReturnValueOnce(noteRows);
|
||||
mockSql.getRows.mockReturnValueOnce(attachmentRows);
|
||||
}
|
||||
|
||||
describe('startBatchProcessing', () => {
|
||||
beforeEach(() => {
|
||||
ocrService.cancelBatchProcessing();
|
||||
});
|
||||
|
||||
it('should start batch processing when items are available', async () => {
|
||||
mockBlobsNeedingOCR(
|
||||
[{ entityId: 'note1', mimeType: 'image/jpeg' }]
|
||||
);
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error if batch processing already in progress', async () => {
|
||||
// First call: items for starting
|
||||
mockBlobsNeedingOCR(
|
||||
[{ entityId: 'note1', mimeType: 'image/jpeg' }]
|
||||
);
|
||||
// Mock note for background processing
|
||||
mockBecca.getNote.mockReturnValue({
|
||||
noteId: 'note1', type: 'image', mime: 'image/jpeg', blobId: 'blob1',
|
||||
getContent: vi.fn().mockReturnValue(Buffer.from('data')),
|
||||
getLabelValue: vi.fn().mockReturnValue(null)
|
||||
});
|
||||
mockWorker.recognize.mockResolvedValue({ data: { text: 'text', confidence: 90, words: [] } });
|
||||
|
||||
ocrService.startBatchProcessing();
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Batch processing already in progress'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if no items need processing', async () => {
|
||||
mockBlobsNeedingOCR([], []);
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'No images found that need OCR processing'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
mockSql.getRows.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const result = await ocrService.startBatchProcessing();
|
||||
|
||||
// getBlobsNeedingOCR catches DB errors and returns [], so startBatchProcessing sees no items
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'No images found that need OCR processing'
|
||||
});
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to get blobs needing OCR')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatchProgress', () => {
|
||||
it('should return initial progress state', () => {
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
expect(progress.inProgress).toBe(false);
|
||||
expect(progress.total).toBe(0);
|
||||
expect(progress.processed).toBe(0);
|
||||
});
|
||||
|
||||
it('should return progress with percentage when total > 0', async () => {
|
||||
mockBlobsNeedingOCR(
|
||||
Array.from({ length: 10 }, (_, i) => ({ entityId: `note${i}`, mimeType: 'image/jpeg' }))
|
||||
);
|
||||
|
||||
ocrService.startBatchProcessing();
|
||||
|
||||
const progress = ocrService.getBatchProgress();
|
||||
|
||||
expect(progress.inProgress).toBe(true);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.processed).toBe(0);
|
||||
expect(progress.percentage).toBe(0);
|
||||
expect(progress.startTime).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelBatchProcessing', () => {
|
||||
it('should cancel ongoing batch processing', async () => {
|
||||
mockBlobsNeedingOCR(
|
||||
[{ entityId: 'note1', mimeType: 'image/jpeg' }]
|
||||
);
|
||||
|
||||
ocrService.startBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(true);
|
||||
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(ocrService.getBatchProgress().inProgress).toBe(false);
|
||||
expect(mockLog.info).toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
|
||||
it('should do nothing if no batch processing is running', () => {
|
||||
ocrService.cancelBatchProcessing();
|
||||
|
||||
expect(mockLog.info).not.toHaveBeenCalledWith('Batch OCR processing cancelled');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
462
apps/server/src/services/ocr/ocr_service.ts
Normal file
462
apps/server/src/services/ocr/ocr_service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { getTesseractCode } from '@triliumnext/commons';
|
||||
|
||||
import becca from '../../becca/becca.js';
|
||||
import blobService from '../blob.js';
|
||||
import entityChangesService from '../entity_changes.js';
|
||||
import log from '../log.js';
|
||||
import options from '../options.js';
|
||||
import sql from '../sql.js';
|
||||
import { FileProcessor } from './processors/file_processor.js';
|
||||
import { ImageProcessor } from './processors/image_processor.js';
|
||||
import { OfficeProcessor } from './processors/office_processor.js';
|
||||
import { PDFProcessor } from './processors/pdf_processor.js';
|
||||
|
||||
export interface OCRResult {
|
||||
text: string;
|
||||
confidence: number;
|
||||
extractedAt: string;
|
||||
language?: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
export interface OCRProcessingOptions {
|
||||
language?: string;
|
||||
forceReprocess?: boolean;
|
||||
confidence?: number;
|
||||
enablePDFTextExtraction?: boolean;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR Service for extracting text from images and other OCR-able objects
|
||||
* Uses Tesseract.js for text recognition
|
||||
*/
|
||||
class OCRService {
|
||||
private processors: Map<string, FileProcessor> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.processors.set('image', new ImageProcessor());
|
||||
this.processors.set('pdf', new PDFProcessor());
|
||||
this.processors.set('office', new OfficeProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Tesseract language code(s) for OCR processing.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicitly passed `language` option (e.g. from API call)
|
||||
* 2. The note's `language` label (mapped via {@link getTesseractCode})
|
||||
* 3. All enabled content languages joined with `+`
|
||||
* 4. The UI locale
|
||||
* 5. Fallback to `eng`
|
||||
*/
|
||||
resolveOcrLanguage(noteId?: string, explicitLanguage?: string): string {
|
||||
// 1. Explicit language from caller
|
||||
if (explicitLanguage) {
|
||||
return explicitLanguage;
|
||||
}
|
||||
|
||||
// 2. Note's language label
|
||||
if (noteId) {
|
||||
const note = becca.getNote(noteId);
|
||||
const noteLanguage = note?.getLabelValue("language");
|
||||
if (noteLanguage) {
|
||||
const code = getTesseractCode(noteLanguage);
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. All enabled content languages
|
||||
try {
|
||||
const languagesJson = options.getOption("languages");
|
||||
const enabledLanguages = JSON.parse(languagesJson || "[]") as string[];
|
||||
if (enabledLanguages.length > 0) {
|
||||
const codes = enabledLanguages
|
||||
.map((id) => getTesseractCode(id))
|
||||
.filter((code): code is string => code !== null);
|
||||
// Deduplicate (e.g. en + en-GB both map to eng)
|
||||
const unique = [...new Set(codes)];
|
||||
if (unique.length > 0) {
|
||||
return unique.join("+");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
||||
// 4. UI locale
|
||||
try {
|
||||
const uiLocale = options.getOption("locale");
|
||||
if (uiLocale) {
|
||||
const code = getTesseractCode(uiLocale);
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
||||
// 5. Fallback
|
||||
return "eng";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract text from file buffer using appropriate processor
|
||||
*/
|
||||
async extractTextFromFile(fileBuffer: Buffer, mimeType: string, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
log.info(`Starting OCR text extraction for MIME type: ${mimeType} with language: ${options.language || "eng"}`);
|
||||
|
||||
const processor = this.getProcessorForMimeType(mimeType);
|
||||
if (!processor) {
|
||||
throw new Error(`No processor found for MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const result = await processor.extractText(fileBuffer, { ...options, mimeType });
|
||||
|
||||
log.info(`OCR extraction completed. Confidence: ${Math.round(result.confidence * 100)}%, Text length: ${result.text.length}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for a note (image type)
|
||||
*/
|
||||
async processNoteOCR(noteId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
log.error(`Note ${noteId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.processEntityOCR({
|
||||
entityId: noteId,
|
||||
entityType: 'note',
|
||||
category: note.type,
|
||||
mime: note.mime,
|
||||
blobId: note.blobId,
|
||||
languageNoteId: noteId,
|
||||
getContent: () => note.getContent()
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for an attachment
|
||||
*/
|
||||
async processAttachmentOCR(attachmentId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
log.error(`Attachment ${attachmentId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.processEntityOCR({
|
||||
entityId: attachmentId,
|
||||
entityType: 'attachment',
|
||||
category: attachment.role,
|
||||
mime: attachment.mime,
|
||||
blobId: attachment.blobId,
|
||||
languageNoteId: attachment.ownerId,
|
||||
getContent: () => attachment.getContent()
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared OCR processing logic for both notes and attachments.
|
||||
*/
|
||||
private async processEntityOCR(entity: {
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
category: string;
|
||||
mime: string;
|
||||
blobId: string | undefined;
|
||||
languageNoteId: string;
|
||||
getContent: () => string | Buffer;
|
||||
}, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
|
||||
const { entityId, entityType, category, mime, blobId, languageNoteId } = entity;
|
||||
|
||||
if (!['image', 'file'].includes(category)) {
|
||||
log.info(`${entityType} ${entityId} is not an image or file, skipping OCR`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.getProcessorForMimeType(mime)) {
|
||||
log.info(`${entityType} ${entityId} has unsupported MIME type ${mime} for text extraction, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!options.forceReprocess && this.hasStoredOCRResult(blobId)) {
|
||||
log.info(`OCR already exists for ${entityType} ${entityId}, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = entity.getContent();
|
||||
if (!content || !(content instanceof Buffer)) {
|
||||
throw new Error(`Cannot get content for ${entityType} ${entityId}`);
|
||||
}
|
||||
|
||||
const language = this.resolveOcrLanguage(languageNoteId, options.language);
|
||||
const ocrResult = await this.extractTextFromFile(content, mime, { ...options, language });
|
||||
|
||||
this.storeOCRResult(blobId, ocrResult);
|
||||
|
||||
return ocrResult;
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for ${entityType} ${entityId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OCR result in blob
|
||||
*/
|
||||
storeOCRResult(blobId: string | undefined, ocrResult: OCRResult): void {
|
||||
if (!blobId) {
|
||||
log.error('Cannot store OCR result: blobId is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sql.execute(`
|
||||
UPDATE blobs SET textRepresentation = ?
|
||||
WHERE blobId = ?
|
||||
`, [ocrResult.text, blobId]);
|
||||
|
||||
this.putBlobEntityChange(blobId);
|
||||
|
||||
log.info(`Stored OCR result for blob ${blobId}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to store OCR result for blob ${blobId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a blob already has a stored text representation.
|
||||
*/
|
||||
private hasStoredOCRResult(blobId: string | undefined): boolean {
|
||||
if (!blobId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = sql.getRow<{ textRepresentation: string | null }>(
|
||||
`SELECT textRepresentation FROM blobs WHERE blobId = ?`,
|
||||
[blobId]
|
||||
);
|
||||
|
||||
return !!row?.textRepresentation;
|
||||
}
|
||||
|
||||
// Batch processing state
|
||||
private batchProcessingState: {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
startTime?: Date;
|
||||
} = {
|
||||
inProgress: false,
|
||||
total: 0,
|
||||
processed: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Start batch OCR processing with progress tracking
|
||||
*/
|
||||
async startBatchProcessing(): Promise<{ success: boolean; message?: string }> {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
return { success: false, message: 'Batch processing already in progress' };
|
||||
}
|
||||
|
||||
try {
|
||||
const blobsNeedingOCR = this.getBlobsNeedingOCR();
|
||||
|
||||
if (blobsNeedingOCR.length === 0) {
|
||||
return { success: false, message: 'No images found that need OCR processing' };
|
||||
}
|
||||
|
||||
this.batchProcessingState = {
|
||||
inProgress: true,
|
||||
total: blobsNeedingOCR.length,
|
||||
processed: 0,
|
||||
startTime: new Date()
|
||||
};
|
||||
|
||||
// Start processing in background
|
||||
this.processBlobs(blobsNeedingOCR).catch(error => {
|
||||
log.error(`Batch processing failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}).finally(() => {
|
||||
this.batchProcessingState.inProgress = false;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error(`Failed to start batch processing: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { success: false, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch processing progress
|
||||
*/
|
||||
getBatchProgress(): { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } {
|
||||
const result: { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } = { ...this.batchProcessingState };
|
||||
if (result.total > 0) {
|
||||
result.percentage = (result.processed / result.total) * 100;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel batch processing
|
||||
*/
|
||||
cancelBatchProcessing(): void {
|
||||
if (this.batchProcessingState.inProgress) {
|
||||
this.batchProcessingState.inProgress = false;
|
||||
log.info('Batch OCR processing cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a list of blobs sequentially, updating batch progress.
|
||||
*/
|
||||
private async processBlobs(blobs: Array<{ entityType: 'note' | 'attachment'; entityId: string }>): Promise<void> {
|
||||
log.info(`Starting batch OCR processing of ${blobs.length} items...`);
|
||||
|
||||
for (const blob of blobs) {
|
||||
if (!this.batchProcessingState.inProgress) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processOcrEntity(blob);
|
||||
} catch (error) {
|
||||
log.error(`Failed to process OCR for ${blob.entityType} ${blob.entityId}: ${error}`);
|
||||
}
|
||||
|
||||
this.batchProcessingState.processed++;
|
||||
|
||||
// Small delay to prevent overwhelming the system
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
log.info(`Batch OCR processing completed. Processed ${this.batchProcessingState.processed} files.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR for a single entity (note or attachment) by type.
|
||||
*/
|
||||
private async processOcrEntity(entity: { entityType: 'note' | 'attachment'; entityId: string }): Promise<void> {
|
||||
if (entity.entityType === 'note') {
|
||||
await this.processNoteOCR(entity.entityId);
|
||||
} else {
|
||||
await this.processAttachmentOCR(entity.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processor for a given MIME type
|
||||
*/
|
||||
/**
|
||||
* Notifies the sync system that a blob has changed, without modifying the blob's identity.
|
||||
*/
|
||||
private putBlobEntityChange(blobId: string): void {
|
||||
const blob = becca.getBlob({ blobId });
|
||||
if (!blob || !blob.blobId) return;
|
||||
|
||||
const hash = blobService.calculateContentHash({
|
||||
blobId: blob.blobId,
|
||||
content: blob.content,
|
||||
textRepresentation: blob.textRepresentation,
|
||||
utcDateModified: blob.utcDateModified!
|
||||
});
|
||||
entityChangesService.putEntityChange({
|
||||
entityName: "blobs",
|
||||
entityId: blobId,
|
||||
hash,
|
||||
isErased: false,
|
||||
utcDateChanged: blob.utcDateModified,
|
||||
isSynced: true
|
||||
});
|
||||
}
|
||||
|
||||
private getProcessorForMimeType(mimeType: string): FileProcessor | null {
|
||||
for (const processor of this.processors.values()) {
|
||||
if (processor.canProcess(mimeType)) {
|
||||
return processor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MIME types supported by all registered processors
|
||||
*/
|
||||
getAllSupportedMimeTypes(): string[] {
|
||||
const supportedTypes = new Set<string>();
|
||||
|
||||
// Gather MIME types from all registered processors
|
||||
for (const processor of this.processors.values()) {
|
||||
const processorTypes = processor.getSupportedMimeTypes();
|
||||
processorTypes.forEach(type => supportedTypes.add(type));
|
||||
}
|
||||
|
||||
return Array.from(supportedTypes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get blobs that need OCR processing (those without text representation)
|
||||
*/
|
||||
getBlobsNeedingOCR(): Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }> {
|
||||
try {
|
||||
const supportedMimes = this.getAllSupportedMimeTypes();
|
||||
const placeholders = supportedMimes.map(() => '?').join(', ');
|
||||
|
||||
const noteBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT n.blobId, n.mime as mimeType, n.noteId as entityId
|
||||
FROM notes n
|
||||
JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE (n.type = 'image' OR (n.type = 'file' AND n.mime IN (${placeholders})))
|
||||
AND n.isDeleted = 0
|
||||
AND n.blobId IS NOT NULL
|
||||
AND b.textRepresentation IS NULL
|
||||
`, supportedMimes);
|
||||
|
||||
const attachmentBlobs = sql.getRows<{
|
||||
blobId: string;
|
||||
mimeType: string;
|
||||
entityId: string;
|
||||
}>(`
|
||||
SELECT a.blobId, a.mime as mimeType, a.attachmentId as entityId
|
||||
FROM attachments a
|
||||
JOIN blobs b ON a.blobId = b.blobId
|
||||
WHERE (a.role = 'image' OR (a.role = 'file' AND a.mime IN (${placeholders})))
|
||||
AND a.isDeleted = 0
|
||||
AND a.blobId IS NOT NULL
|
||||
AND b.textRepresentation IS NULL
|
||||
`, supportedMimes);
|
||||
|
||||
// Combine results
|
||||
const result = [
|
||||
...noteBlobs.map(blob => ({ ...blob, entityType: 'note' as const })),
|
||||
...attachmentBlobs.map(blob => ({ ...blob, entityType: 'attachment' as const }))
|
||||
];
|
||||
|
||||
// Return all results (no need to filter by MIME type as we already did in the query)
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`Failed to get blobs needing OCR: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new OCRService();
|
||||
26
apps/server/src/services/ocr/processors/file_processor.ts
Normal file
26
apps/server/src/services/ocr/processors/file_processor.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { OCRResult, OCRProcessingOptions } from '../ocr_service.js';
|
||||
|
||||
/**
|
||||
* Base class for file processors that extract text from different file types
|
||||
*/
|
||||
export abstract class FileProcessor {
|
||||
/**
|
||||
* Check if this processor can handle the given MIME type
|
||||
*/
|
||||
abstract canProcess(mimeType: string): boolean;
|
||||
|
||||
/**
|
||||
* Extract text from the given file buffer
|
||||
*/
|
||||
abstract extractText(buffer: Buffer, options: OCRProcessingOptions): Promise<OCRResult>;
|
||||
|
||||
/**
|
||||
* Get the processing type identifier
|
||||
*/
|
||||
abstract getProcessingType(): string;
|
||||
|
||||
/**
|
||||
* Get list of MIME types supported by this processor
|
||||
*/
|
||||
abstract getSupportedMimeTypes(): string[];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../data_dir.js', () => ({
|
||||
default: {
|
||||
OCR_CACHE_DIR: '/tmp/trilium-ocr-test-cache'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../options.js', () => ({
|
||||
default: {
|
||||
getOption: vi.fn().mockReturnValue('0')
|
||||
}
|
||||
}));
|
||||
|
||||
import { ImageProcessor } from './image_processor.js';
|
||||
|
||||
describe('ImageProcessor', () => {
|
||||
const processor = new ImageProcessor();
|
||||
const sampleImagePath = path.join(__dirname, 'samples', 'image.png');
|
||||
|
||||
it('should extract text from the sample image', async () => {
|
||||
const imageBuffer = fs.readFileSync(sampleImagePath);
|
||||
|
||||
const result = await processor.extractText(imageBuffer, { language: 'eng' });
|
||||
expect(result.text).toContain('TriliumNext');
|
||||
}, 60000);
|
||||
});
|
||||
160
apps/server/src/services/ocr/processors/image_processor.ts
Normal file
160
apps/server/src/services/ocr/processors/image_processor.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import fs from 'fs';
|
||||
import Tesseract from 'tesseract.js';
|
||||
|
||||
import dataDirs from '../../data_dir.js';
|
||||
import log from '../../log.js';
|
||||
import options from '../../options.js';
|
||||
import { OCRProcessingOptions,OCRResult } from '../ocr_service.js';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
|
||||
/**
|
||||
* Image processor for extracting text from image files using Tesseract
|
||||
*/
|
||||
export class ImageProcessor extends FileProcessor {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private currentLanguage: string | null = null;
|
||||
private readonly supportedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return this.supportedTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return [...this.supportedTypes];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
const language = options.language || "eng";
|
||||
await this.ensureWorker(language);
|
||||
|
||||
try {
|
||||
log.info(`Starting image OCR text extraction (language: ${language})...`);
|
||||
|
||||
const result = await this.worker!.recognize(buffer);
|
||||
|
||||
// Filter text based on minimum confidence threshold
|
||||
const { filteredText, overallConfidence } = this.filterTextByConfidence(result.data);
|
||||
|
||||
const ocrResult: OCRResult = {
|
||||
text: filteredText,
|
||||
confidence: overallConfidence,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language,
|
||||
pageCount: 1
|
||||
};
|
||||
|
||||
return ocrResult;
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Image OCR text extraction failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a Tesseract worker is ready for the given language.
|
||||
* Creates a new worker if none exists or if the language has changed.
|
||||
*/
|
||||
private async ensureWorker(language: string): Promise<void> {
|
||||
if (this.worker && this.currentLanguage === language) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
}
|
||||
|
||||
fs.mkdirSync(dataDirs.OCR_CACHE_DIR, { recursive: true });
|
||||
|
||||
log.info(`Initializing Tesseract worker for language(s): ${language}`);
|
||||
this.worker = await Tesseract.createWorker(language, 1, {
|
||||
cachePath: dataDirs.OCR_CACHE_DIR,
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
log.info(`Image OCR progress (${language}): ${Math.round(m.progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.currentLanguage = language;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filter text based on minimum confidence threshold
|
||||
*/
|
||||
private filterTextByConfidence(data: any): { filteredText: string; overallConfidence: number } {
|
||||
const minConfidence = this.getMinConfidenceThreshold();
|
||||
|
||||
// If no minimum confidence set, return original text
|
||||
if (minConfidence <= 0) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence: data.confidence / 100
|
||||
};
|
||||
}
|
||||
|
||||
const filteredWords: string[] = [];
|
||||
const validConfidences: number[] = [];
|
||||
|
||||
// Tesseract provides word-level data
|
||||
if (data.words && Array.isArray(data.words)) {
|
||||
for (const word of data.words) {
|
||||
const wordConfidence = word.confidence / 100; // Convert to decimal
|
||||
|
||||
if (wordConfidence >= minConfidence) {
|
||||
filteredWords.push(word.text);
|
||||
validConfidences.push(wordConfidence);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: if word-level data not available, use overall confidence
|
||||
const overallConfidence = data.confidence / 100;
|
||||
if (overallConfidence >= minConfidence) {
|
||||
return {
|
||||
filteredText: data.text.trim(),
|
||||
overallConfidence
|
||||
};
|
||||
}
|
||||
log.info(`Entire text filtered out due to low confidence ${overallConfidence} (below threshold ${minConfidence})`);
|
||||
return {
|
||||
filteredText: '',
|
||||
overallConfidence
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate average confidence of accepted words
|
||||
const averageConfidence = validConfidences.length > 0
|
||||
? validConfidences.reduce((sum, conf) => sum + conf, 0) / validConfidences.length
|
||||
: 0;
|
||||
|
||||
const filteredText = filteredWords.join(' ').trim();
|
||||
|
||||
log.info(`Filtered OCR text: ${filteredWords.length} words kept out of ${data.words?.length || 0} total words (min confidence: ${minConfidence})`);
|
||||
|
||||
return {
|
||||
filteredText,
|
||||
overallConfidence: averageConfidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum confidence threshold from options
|
||||
*/
|
||||
private getMinConfidenceThreshold(): number {
|
||||
const minConfidence = options.getOption('ocrMinConfidence') ?? 0;
|
||||
return parseFloat(minConfidence);
|
||||
}
|
||||
|
||||
}
|
||||
70
apps/server/src/services/ocr/processors/office_processor.ts
Normal file
70
apps/server/src/services/ocr/processors/office_processor.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { parseExcel } from 'officeparser/dist/parsers/ExcelParser.js';
|
||||
import { parseOpenOffice } from 'officeparser/dist/parsers/OpenOfficeParser.js';
|
||||
import { parsePowerPoint } from 'officeparser/dist/parsers/PowerPointParser.js';
|
||||
import { parseWord } from 'officeparser/dist/parsers/WordParser.js';
|
||||
import type { OfficeParserConfig } from 'officeparser/dist/types.js';
|
||||
|
||||
import log from '../../log.js';
|
||||
import { OCRProcessingOptions, OCRResult } from '../ocr_service.js';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
|
||||
type Parser = (buffer: Buffer, config: OfficeParserConfig) => Promise<{ toText(): string }>;
|
||||
|
||||
const PARSER_BY_MIME: Record<string, Parser> = {
|
||||
// Office Open XML
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': parseWord,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': parseExcel,
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': parsePowerPoint,
|
||||
// OpenDocument
|
||||
'application/vnd.oasis.opendocument.text': parseOpenOffice,
|
||||
'application/vnd.oasis.opendocument.spreadsheet': parseOpenOffice,
|
||||
'application/vnd.oasis.opendocument.presentation': parseOpenOffice
|
||||
};
|
||||
|
||||
const PARSER_CONFIG: OfficeParserConfig = {
|
||||
outputErrorToConsole: false,
|
||||
newlineDelimiter: '\n',
|
||||
ignoreNotes: false,
|
||||
putNotesAtLast: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Office document processor for extracting text from DOCX/XLSX/PPTX and ODT/ODS/ODP files.
|
||||
* Uses individual parsers from officeparser v6 to avoid pulling in pdfjs-dist.
|
||||
*/
|
||||
export class OfficeProcessor extends FileProcessor {
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType in PARSER_BY_MIME;
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return Object.keys(PARSER_BY_MIME);
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
const mimeType = options.mimeType;
|
||||
if (!mimeType || !(mimeType in PARSER_BY_MIME)) {
|
||||
throw new Error(`Unsupported MIME type for Office processor: ${mimeType}`);
|
||||
}
|
||||
|
||||
log.info(`Starting Office document text extraction for ${mimeType}...`);
|
||||
|
||||
const parse = PARSER_BY_MIME[mimeType];
|
||||
const ast = await parse(buffer, PARSER_CONFIG);
|
||||
const trimmed = ast.toText().trim();
|
||||
|
||||
return {
|
||||
text: trimmed,
|
||||
confidence: trimmed.length > 0 ? 0.99 : 0,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || "eng",
|
||||
pageCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'office';
|
||||
}
|
||||
|
||||
}
|
||||
39
apps/server/src/services/ocr/processors/pdf_processor.ts
Normal file
39
apps/server/src/services/ocr/processors/pdf_processor.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { extractText, getDocumentProxy } from 'unpdf';
|
||||
|
||||
import log from '../../log.js';
|
||||
import { OCRProcessingOptions, OCRResult } from '../ocr_service.js';
|
||||
import { FileProcessor } from './file_processor.js';
|
||||
|
||||
/**
|
||||
* PDF processor for extracting embedded text from PDF files using unpdf.
|
||||
*/
|
||||
export class PDFProcessor extends FileProcessor {
|
||||
|
||||
canProcess(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase() === 'application/pdf';
|
||||
}
|
||||
|
||||
getSupportedMimeTypes(): string[] {
|
||||
return ['application/pdf'];
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer, options: OCRProcessingOptions = {}): Promise<OCRResult> {
|
||||
log.info('Starting PDF text extraction...');
|
||||
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
||||
const { totalPages, text } = await extractText(pdf, { mergePages: true });
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
confidence: 0.99,
|
||||
extractedAt: new Date().toISOString(),
|
||||
language: options.language || "eng",
|
||||
pageCount: totalPages
|
||||
};
|
||||
}
|
||||
|
||||
getProcessingType(): string {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
}
|
||||
BIN
apps/server/src/services/ocr/processors/samples/image.png
Normal file
BIN
apps/server/src/services/ocr/processors/samples/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
149
apps/server/src/services/search/search_result_ocr.spec.ts
Normal file
149
apps/server/src/services/search/search_result_ocr.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockBecca = {
|
||||
notes: {} as Record<string, any>,
|
||||
getNote: vi.fn()
|
||||
};
|
||||
|
||||
const mockBeccaService = {
|
||||
getNoteTitleForPath: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../becca/becca.js', () => ({
|
||||
default: mockBecca
|
||||
}));
|
||||
|
||||
vi.mock('../../becca/becca_service.js', () => ({
|
||||
default: mockBeccaService
|
||||
}));
|
||||
|
||||
let SearchResult: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockBeccaService.getNoteTitleForPath.mockReturnValue('Test Note Title');
|
||||
|
||||
mockBecca.notes['test123'] = {
|
||||
noteId: 'test123',
|
||||
title: 'Test Note',
|
||||
isInHiddenSubtree: vi.fn().mockReturnValue(false)
|
||||
};
|
||||
|
||||
const module = await import('./search_result.js');
|
||||
SearchResult = module.default;
|
||||
});
|
||||
|
||||
describe('SearchResult', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with note path array', () => {
|
||||
const searchResult = new SearchResult(['root', 'folder', 'test123']);
|
||||
|
||||
expect(searchResult.notePathArray).toEqual(['root', 'folder', 'test123']);
|
||||
expect(searchResult.noteId).toBe('test123');
|
||||
expect(searchResult.notePath).toBe('root/folder/test123');
|
||||
expect(searchResult.score).toBe(0);
|
||||
expect(mockBeccaService.getNoteTitleForPath).toHaveBeenCalledWith(['root', 'folder', 'test123']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeScore', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
describe('basic scoring', () => {
|
||||
it('should give highest score for exact note ID match', () => {
|
||||
searchResult.computeScore('test123', ['test123']);
|
||||
expect(searchResult.score).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('should give high score for exact title match', () => {
|
||||
searchResult.computeScore('test note', ['test', 'note']);
|
||||
expect(searchResult.score).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('should give medium score for title prefix match', () => {
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it('should give lower score for title word match', () => {
|
||||
mockBecca.notes['test123'].title = 'This is a test note';
|
||||
searchResult.computeScore('test', ['test']);
|
||||
expect(searchResult.score).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden notes penalty', () => {
|
||||
it('should apply penalty for hidden notes', () => {
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(true);
|
||||
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const hiddenScore = searchResult.score;
|
||||
|
||||
mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(false);
|
||||
searchResult.score = 0;
|
||||
searchResult.computeScore('test', ['test']);
|
||||
const normalScore = searchResult.score;
|
||||
|
||||
expect(normalScore).toBeGreaterThan(hiddenScore);
|
||||
expect(hiddenScore).toBe(normalScore / 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addScoreForStrings', () => {
|
||||
let searchResult: any;
|
||||
|
||||
beforeEach(() => {
|
||||
searchResult = new SearchResult(['root', 'test123']);
|
||||
});
|
||||
|
||||
it('should give highest score for exact token match', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const exactScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sampling text', 1.0);
|
||||
const prefixScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'text sample text', 1.0);
|
||||
const partialScore = searchResult.score;
|
||||
|
||||
expect(exactScore).toBeGreaterThan(prefixScore);
|
||||
expect(exactScore).toBeGreaterThanOrEqual(partialScore);
|
||||
});
|
||||
|
||||
it('should apply factor multiplier correctly', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 2.0);
|
||||
const doubleFactorScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const singleFactorScore = searchResult.score;
|
||||
|
||||
expect(doubleFactorScore).toBe(singleFactorScore * 2);
|
||||
});
|
||||
|
||||
it('should handle multiple tokens', () => {
|
||||
searchResult.addScoreForStrings(['hello', 'world'], 'hello world test', 1.0);
|
||||
expect(searchResult.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
searchResult.addScoreForStrings(['sample'], 'sample text', 1.0);
|
||||
const lowerCaseScore = searchResult.score;
|
||||
|
||||
searchResult.score = 0;
|
||||
searchResult.addScoreForStrings(['sample'], 'SAMPLE text', 1.0);
|
||||
const upperCaseScore = searchResult.score;
|
||||
|
||||
expect(upperCaseScore).toEqual(lowerCaseScore);
|
||||
expect(upperCaseScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import tmp from "tmp";
|
||||
import buildApp from "./app.js";
|
||||
import appInfo from "./services/app_info.js";
|
||||
import config from "./services/config.js";
|
||||
import { registerOcrHandlers } from "./services/handlers.js";
|
||||
import host from "./services/host.js";
|
||||
import log from "./services/log.js";
|
||||
import port from "./services/port.js";
|
||||
@@ -68,6 +69,8 @@ export default async function startTriliumServer() {
|
||||
const electronRouting = await import("./routes/electron.js");
|
||||
electronRouting.default(app);
|
||||
}
|
||||
|
||||
registerOcrHandlers();
|
||||
}
|
||||
|
||||
async function displayStartupMessage() {
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.10.10",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"i18next": "26.0.3",
|
||||
"i18next-http-backend": "3.0.4",
|
||||
"preact": "10.29.0",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.7",
|
||||
"react-i18next": "17.0.1"
|
||||
"react-i18next": "17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.5",
|
||||
|
||||
@@ -27,8 +27,7 @@ export function initTranslations(lng: string) {
|
||||
initAsync: false,
|
||||
react: {
|
||||
useSuspense: false
|
||||
},
|
||||
showSupportNotice: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
9
docs/README-fr.md
vendored
9
docs/README-fr.md
vendored
@@ -107,11 +107,10 @@ Notre documentation est disponible sous plusieurs formats :
|
||||
fort avec granularité par note
|
||||
* Diagrammes d'esquisse, basés sur [Excalidraw](https://excalidraw.com/) (type
|
||||
de note "canvas"))
|
||||
* [Cartes de
|
||||
relations](https://docs.triliumnotes.org/user-guide/note-types/relation-map)
|
||||
et [cartes de
|
||||
notes/liens](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
||||
pour visualiser les notes et leurs liens
|
||||
* [Relation
|
||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) et
|
||||
[note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
||||
pour visualiser les notes et leurs relations
|
||||
* Cartes mentales, basées sur [Mind Elixir] (https://docs.mind-elixir.com/)
|
||||
* [Cartes
|
||||
géographiques](https://docs.triliumnotes.org/user-guide/collections/geomap)
|
||||
|
||||
50
package.json
50
package.json
@@ -50,7 +50,7 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "4.0.3",
|
||||
"@fast-csv/parse": "5.0.5",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@playwright/test": "1.59.0",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
@@ -77,7 +77,7 @@
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.2",
|
||||
"typescript-eslint": "8.57.2",
|
||||
"typescript-eslint": "8.58.0",
|
||||
"upath": "2.0.1",
|
||||
"vite": "8.0.3",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
@@ -111,6 +111,7 @@
|
||||
"preact": "10.29.0",
|
||||
"roughjs": "4.6.6",
|
||||
"@types/express-serve-static-core": "5.1.1",
|
||||
"node-abi": "4.28.0",
|
||||
"flat@<5.0.1": ">=5.0.1",
|
||||
"debug@>=3.2.0 <3.2.7": ">=3.2.7",
|
||||
"nanoid@<3.3.8": ">=3.3.8",
|
||||
@@ -120,26 +121,47 @@
|
||||
"cookie@<0.7.0": ">=0.7.0",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=2.1.3",
|
||||
"on-headers@<1.1.0": ">=1.1.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"form-data@>=3.0.0 <3.0.4": ">=3.0.4",
|
||||
"node-abi": "4.28.0",
|
||||
"validator@<13.15.20": ">=13.15.20",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"tmp@<=0.2.3": ">=0.2.4",
|
||||
"glob@>=10.2.0 <10.5.0": ">=10.5.0",
|
||||
"glob@>=11.0.0 <11.1.0": ">=11.1.0",
|
||||
"node-forge@<1.3.2": ">=1.3.2",
|
||||
"mdast-util-to-hast@>=13.0.0 <13.2.1": ">=13.2.1",
|
||||
"validator@<13.15.22": ">=13.15.22",
|
||||
"qs@<6.14.1": ">=6.14.1",
|
||||
"@smithy/config-resolver@<4.4.0": ">=4.4.0",
|
||||
"tar@<=7.5.2": ">=7.5.3",
|
||||
"tar@<=7.5.3": ">=7.5.4",
|
||||
"lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23",
|
||||
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23",
|
||||
"diff@<4.0.4": ">=4.0.4",
|
||||
"diff@>=6.0.0 <8.0.3": ">=8.0.3",
|
||||
"tar@<7.5.7": ">=7.5.7",
|
||||
"zod@<3.25.76": ">=4.0.0"
|
||||
"zod@<3.25.76": ">=4.0.0",
|
||||
"rollup@>=4.0.0 <4.59.0": ">=4.59.0",
|
||||
"basic-ftp@<5.2.0": ">=5.2.0",
|
||||
"ajv@>=7.0.0-alpha.0 <8.18.0": ">=8.18.0",
|
||||
"@tootallnate/once@<3.0.1": ">=3.0.1",
|
||||
"svgo@>=3.0.0 <3.3.3": ">=3.3.3",
|
||||
"immutable@>=4.0.0-rc.1 <4.3.8": ">=4.3.8",
|
||||
"simple-git@>=3.15.0 <3.32.3": ">=3.32.3",
|
||||
"undici@>=7.0.0 <7.24.0": ">=7.24.0",
|
||||
"socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6",
|
||||
"fast-xml-parser@>=4.0.0-beta.3 <5.5.7": ">=5.5.7",
|
||||
"path-to-regexp@<0.1.13": ">=0.1.13",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
||||
"brace-expansion@<1.1.13": ">=1.1.13",
|
||||
"brace-expansion@>=2.0.0 <2.0.3": ">=2.0.3",
|
||||
"brace-expansion@>=4.0.0 <5.0.5": ">=5.0.5",
|
||||
"picomatch@<2.3.2": ">=2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3",
|
||||
"yaml@>=2.0.0 <2.8.3": ">=2.8.3",
|
||||
"@xmldom/xmldom@<0.8.12": ">=0.8.12",
|
||||
"flatted@<=3.4.1": ">=3.4.2",
|
||||
"defu@<=6.1.4": ">=6.1.5",
|
||||
"tar@<7.5.11": ">=7.5.11",
|
||||
"lodash@<4.18.0": ">=4.18.0",
|
||||
"lodash-es@<4.18.0": ">=4.18.0",
|
||||
"node-forge@<1.4.0": ">=1.4.0",
|
||||
"handlebars@<4.7.9": ">=4.7.9",
|
||||
"qs@<6.14.2": ">=6.14.2",
|
||||
"minimatch@<3.1.4": "^3.1.4",
|
||||
"serialize-javascript@<7.0.5": ">=7.0.5",
|
||||
"webpack@<5.104.1": ">=5.104.1"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"sqlite3"
|
||||
|
||||
@@ -23,26 +23,22 @@
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"@vitest/browser": "4.1.2",
|
||||
"@vitest/coverage-istanbul": "4.1.2",
|
||||
"ckeditor5": "47.6.1",
|
||||
"ckeditor5": "48.0.0",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"stylelint": "17.6.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.2",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.2",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "47.6.1"
|
||||
"ckeditor5": "48.0.0"
|
||||
},
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -51,21 +47,8 @@
|
||||
"ts:build": "tsc -p ./tsconfig.release.json",
|
||||
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
"start": "ckeditor5-package-tools start",
|
||||
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
|
||||
"test": "vitest",
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false",
|
||||
"prepublishOnly": "npm run ts:build && ckeditor5-package-tools export-package-as-javascript",
|
||||
"postpublish": "npm run ts:clear && ckeditor5-package-tools export-package-as-typescript",
|
||||
"translations:synchronize": "ckeditor5-package-tools translations:synchronize",
|
||||
"translations:validate": "ckeditor5-package-tools translations:synchronize --validate-only"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,ts}": [
|
||||
"eslint --quiet"
|
||||
],
|
||||
"**/*.css": [
|
||||
"stylelint --quiet --allow-empty-input"
|
||||
]
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,47 +24,30 @@
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"@vitest/browser": "4.1.2",
|
||||
"@vitest/coverage-istanbul": "4.1.2",
|
||||
"ckeditor5": "47.6.1",
|
||||
"ckeditor5": "48.0.0",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"stylelint": "17.6.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.2",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.2",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "47.6.1"
|
||||
"ckeditor5": "48.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build-dist.mjs",
|
||||
"ts:build": "tsc -p ./tsconfig.release.json",
|
||||
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
"start": "ckeditor5-package-tools start",
|
||||
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
|
||||
"test": "vitest",
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false",
|
||||
"prepublishOnly": "npm run ts:build && ckeditor5-package-tools export-package-as-javascript",
|
||||
"postpublish": "npm run ts:clear && ckeditor5-package-tools export-package-as-typescript",
|
||||
"translations:synchronize": "ckeditor5-package-tools translations:synchronize",
|
||||
"translations:validate": "ckeditor5-package-tools translations:synchronize --validate-only"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,ts}": [
|
||||
"eslint --quiet"
|
||||
],
|
||||
"**/*.css": [
|
||||
"stylelint --quiet --allow-empty-input"
|
||||
]
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,47 +26,30 @@
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"@vitest/browser": "4.1.2",
|
||||
"@vitest/coverage-istanbul": "4.1.2",
|
||||
"ckeditor5": "47.6.1",
|
||||
"ckeditor5": "48.0.0",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"stylelint": "17.6.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.2",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.2",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "47.6.1"
|
||||
"ckeditor5": "48.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build-dist.mjs",
|
||||
"ts:build": "tsc -p ./tsconfig.release.json",
|
||||
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
"start": "ckeditor5-package-tools start",
|
||||
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
|
||||
"test": "vitest",
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false",
|
||||
"prepublishOnly": "npm run ts:build && ckeditor5-package-tools export-package-as-javascript",
|
||||
"postpublish": "npm run ts:clear && ckeditor5-package-tools export-package-as-typescript",
|
||||
"translations:synchronize": "ckeditor5-package-tools translations:synchronize",
|
||||
"translations:validate": "ckeditor5-package-tools translations:synchronize --validate-only"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,ts}": [
|
||||
"eslint --quiet"
|
||||
],
|
||||
"**/*.css": [
|
||||
"stylelint --quiet --allow-empty-input"
|
||||
]
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user