diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b4dfb29f7f..7184420945 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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.` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous + ### Database Migrations - Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql` - Update schema in `apps/server/src/assets/db/schema.sql` @@ -213,6 +221,12 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main 10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note. +## MCP Server +- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json` +- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`) +- It provides tools for reading, searching, and modifying notes directly from the AI assistant +- Use it to interact with actual note data when developing or debugging note-related features + ## TypeScript Configuration - **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`) @@ -299,6 +313,7 @@ Trilium provides powerful user scripting capabilities: - Translation files in `apps/client/src/translations/` - Use translation system via `t()` function - Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural) +- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `" in "` vs `"in , "`) ## Testing Conventions diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..9fd17b8d67 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "trilium": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index a818b18929..2ca8811dbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ Trilium provides powerful user scripting capabilities: - Supported languages: English, German, Spanish, French, Romanian, Chinese - **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community - Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`) +- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `" in "` vs `"in , "`) ### Security Considerations - Per-note encryption with granular protected sessions @@ -151,6 +152,15 @@ Trilium provides powerful user scripting capabilities: - Create new package in `packages/` following existing plugin structure - Register in `packages/ckeditor5/src/plugins.ts` +### Adding New LLM Tools +Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server. + +1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `attachment_tools.ts`, `hierarchy_tools.ts`) or create a new module +2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations +3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts` +4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous +5. Use ETAPI (`apps/server/src/etapi/`) as inspiration for what fields to expose, but **do not import ETAPI mappers** — inline the field mappings directly in the tool so the LLM layer stays decoupled from the API layer + ### Database Migrations - Add migration scripts in `apps/server/src/migrations/` - Update schema in `apps/server/src/assets/db/schema.sql` @@ -161,6 +171,12 @@ Trilium provides powerful user scripting capabilities: - **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory - **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree +## MCP Server +- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json` +- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`) +- It provides tools for reading, searching, and modifying notes directly from the AI assistant +- Use it to interact with actual note data when developing or debugging note-related features + ## Build System Notes - Uses pnpm for monorepo management - Vite for fast development builds diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index e4263aa896..fa0a0279d3 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -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) { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index b0cd25bbab..ce49038fb5 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1639,6 +1639,7 @@ "web_search": "Web search", "note_tools": "Note access", "sources": "Sources", + "sources_summary": "{{count}} sources from {{sites}} sites", "extended_thinking": "Extended thinking", "legacy_models": "Legacy models", "thinking": "Thinking...", @@ -1659,9 +1660,7 @@ "note_context_enabled": "Click to disable note context: {{title}}", "note_context_disabled": "Click to include current note in context", "no_provider_message": "No AI provider configured. Add one to start chatting.", - "add_provider": "Add AI Provider", - "role_user": "You", - "role_assistant": "Assistant" + "add_provider": "Add AI Provider" }, "sidebar_chat": { "title": "AI Chat", @@ -2324,6 +2323,7 @@ "llm": { "settings_title": "AI / LLM", "settings_description": "Configure AI and Large Language Model integrations.", + "feature_not_enabled": "Enable the LLM experimental feature in Settings → Advanced → Experimental features to use AI integrations.", "add_provider": "Add Provider", "add_provider_title": "Add AI Provider", "configured_providers": "Configured Providers", @@ -2335,6 +2335,30 @@ "delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?", "api_key": "API Key", "api_key_placeholder": "Enter your API key", - "cancel": "Cancel" + "cancel": "Cancel", + "mcp_title": "MCP (Model Context Protocol)", + "mcp_enabled": "MCP server", + "mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.", + "mcp_endpoint_title": "Endpoint URL", + "mcp_endpoint_description": "Add this URL to your AI assistant's MCP configuration", + "tools": { + "search_notes": "Search notes", + "get_note": "Get note", + "get_note_content": "Get note content", + "update_note_content": "Update note content", + "append_to_note": "Append to note", + "create_note": "Create note", + "get_attributes": "Get attributes", + "get_attribute": "Get attribute", + "set_attribute": "Set attribute", + "delete_attribute": "Delete attribute", + "get_child_notes": "Get child notes", + "get_subtree": "Get subtree", + "load_skill": "Load skill", + "web_search": "Web search", + "note_in_parent": " in ", + "get_attachment": "Get attachment", + "get_attachment_content": "Read attachment content" + } } } diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 5a972fe4fa..5155126274 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -289,12 +289,6 @@ export default function SidebarChat() { {chat.messages.map(msg => ( ))} - {chat.toolActivity && !chat.streamingThinking && ( -
- - {chat.toolActivity} -
- )} {chat.isStreaming && chat.streamingThinking && ( )} - {chat.isStreaming && chat.streamingContent && ( + {chat.isStreaming && chat.streamingBlocks.length > 0 && ( 0 ? chat.pendingCitations : undefined }} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css new file mode 100644 index 0000000000..4599e6a511 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css @@ -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; +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx index cbf95fe1ea..6491a595b0 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx @@ -1,3 +1,5 @@ +import "./ChatInputBar.css"; + import type { RefObject } from "preact"; import { useState, useCallback } from "preact/hooks"; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css new file mode 100644 index 0000000000..f37f9abec9 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css @@ -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); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index e450521a1c..26a7f36c46 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -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
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(null); + + useEffect(() => { + if (!containerRef.current) return; + + const referenceLinks = containerRef.current.querySelectorAll("a.reference-link"); + for (const el of referenceLinks) { + link.loadReferenceLinkTitle($(el), el.href); + } + }, [html]); + + return ( + <> +
+ {isStreaming && } + + ); +} + 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(); + 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 ( -
- - - {toolCall.toolName} - {toolCall.isError && {t("llm_chat.tool_error")}} - -
-
- {t("llm_chat.input")}: -
{JSON.stringify(toolCall.input, null, 2)}
-
- {toolCall.result && ( -
- {toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}: -
{(() => {
-                            if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
+        
+            
+                
+                    
+                        {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;
-                        })()}
-                    
-                )}
-            
-        
+
+                            return (
+                                
+                                    
+                                    {domain && (
+                                        
+                                    )}
+                                
+                            );
+                        })}
+                    
+                
+ {citation.url ? ( + + {title} + + ) : ( + {title} + )} + {domain}
+
+
); } -function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) { - return blocks.map((block, idx) => { - if (block.type === "text") { - const html = renderMarkdown(block.content); - return ( -
- - {isStreaming && idx === blocks.length - 1 && } -
- ); - } - if (block.type === "tool_call") { - return ; - } - 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 ( -
- - - {t("llm_chat.thought_process")} - -
- {textContent} - {isStreaming && } -
-
+
+ + +
+ {textContent} + {isStreaming && } +
+
+
+
); } - // Legacy tool calls (from old format stored as separate field) - const legacyToolCalls = message.toolCalls; const hasBlockContent = Array.isArray(message.content); return (
-
- {isError ? "Error" : roleLabel} -
+ {isError &&
Error
}
{message.role === "assistant" && !isError ? ( hasBlockContent ? ( renderContentBlocks(message.content as ContentBlock[], isStreaming) ) : ( - <> - - {isStreaming && } - + ) ) : ( textContent )}
- {legacyToolCalls && legacyToolCalls.length > 0 && ( -
- - - {t("llm_chat.tool_calls", { count: legacyToolCalls.length })} - -
- {legacyToolCalls.map((tool) => ( - - ))} -
-
- )} {message.citations && message.citations.length > 0 && ( -
-
- - {t("llm_chat.sources")} -
-
    - {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 ( -
  • - {citation.url ? ( - - {displayText} - - ) : ( - - {displayText} - - )} -
  • - ); - })} -
-
+ )}
@@ -242,3 +222,40 @@ export default function ChatMessage({ message, isStreaming }: Props) {
); } + +/** 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 ( +
+ +
+ ); + } + + return b.toolCall)} />; + }); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.css b/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.css new file mode 100644 index 0000000000..e237bd2244 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.css @@ -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; +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.tsx new file mode 100644 index 0000000000..2e12c08e64 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ExpandableCard.tsx @@ -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 ( +
+ + + {label} + + +
+ {children} +
+
+ ); +} + +interface ExpandableCardProps { + className?: string; + children: ComponentChildren; +} + +/** A bordered card that groups one or more ExpandableSections. */ +export function ExpandableCard({ className, children }: ExpandableCardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css index 567949e556..06af4bac6c 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -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; -} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx index f22e8016cc..301141f0d8 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx @@ -65,12 +65,6 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { {chat.messages.map(msg => ( ))} - {chat.toolActivity && !chat.streamingThinking && ( -
- - {chat.toolActivity} -
- )} {chat.isStreaming && chat.streamingThinking && ( )} - {chat.isStreaming && chat.streamingContent && ( + {chat.isStreaming && chat.streamingBlocks.length > 0 && ( 0 ? chat.pendingCitations : undefined }} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css new file mode 100644 index 0000000000..1de4319b8d --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css @@ -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); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx new file mode 100644 index 0000000000..aa766fd1b4 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx @@ -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 " 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 { + 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
;
+
+    // Beyond max depth, fall back to JSON.
+    if (depth >= MAX_TABLE_DEPTH) {
+        if (isPlainObject(value) || Array.isArray(value)) {
+            return 
{JSON.stringify(value, null, 2)}
; + } + return
{String(value)}
; + } + + if (isPlainObject(value)) { + return ; + } + + if (Array.isArray(value)) { + if (value.length === 0) return
{"[]"}
; + + // Array of objects: render each as a nested table. + if (value.every(isPlainObject)) { + return ( +
+ {value.map((item, idx) => ( + + ))} +
+ ); + } + + // Array of primitives: comma-separated. + return
{value.map(String).join(", ")}
; + } + + return
{String(value)}
; +} + +/** 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
{raw}
; + } + + return ( + + + {Object.entries(obj).map(([key, value]) => ( + + + + + ))} + +
{key} + +
+ ); +} + +/** 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 && ( + {detailText} + )} + {refNoteId && ( + + {refParentId ? ( + , + Parent: + } as any} + /> + ) : ( + + )} + + )} + {hasError && {t("llm_chat.tool_error")}} + + ); +} + +/** A single tool call section within a ToolCallCard. */ +function ToolCallSection({ toolCall }: { toolCall: ToolCall }) { + const hasError = toolCall.isError; + + return ( + } + className={hasError ? "llm-chat-tool-call-error" : ""} + > +
+ {t("llm_chat.input")} + +
+ {toolCall.result && ( +
+ {hasError ? t("llm_chat.error") : t("llm_chat.result")} + +
+ )} +
+ ); +} + +/** A card that groups one or more sequential tool calls together. */ +export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) { + return ( + + {toolCalls.map((tc, idx) => ( + + ))} + + ); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts index 767a886dae..d05bf291b5 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts @@ -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; } diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index ddca7deb98..63cbf4bbf4 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -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([]); const [streamingThinking, setStreamingThinking] = useState(""); - const [toolActivity, setToolActivity] = useState(null); const [pendingCitations, setPendingCitations] = useState([]); const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); @@ -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, diff --git a/apps/client/src/widgets/type_widgets/options/advanced.tsx b/apps/client/src/widgets/type_widgets/options/advanced.tsx index 2959c5a1cc..619a1fb514 100644 --- a/apps/client/src/widgets/type_widgets/options/advanced.tsx +++ b/apps/client/src/widgets/type_widgets/options/advanced.tsx @@ -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("experimentalFeatures", true); - const filteredExperimentalFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []); + const [enabledFeatures, setEnabledFeatures] = useTriliumOptionJson("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 ( {t("experimental_features.disclaimer")} - + {filteredFeatures.map((feature) => ( + + toggleFeature(feature.id, enabled)} + /> + + ))} ); } diff --git a/apps/client/src/widgets/type_widgets/options/components/OptionsRow.css b/apps/client/src/widgets/type_widgets/options/components/OptionsRow.css index 3e6fc2dbf8..a811aaa720 100644 --- a/apps/client/src/widgets/type_widgets/options/components/OptionsRow.css +++ b/apps/client/src/widgets/type_widgets/options/components/OptionsRow.css @@ -1,27 +1,33 @@ .option-row { border-bottom: 1px solid var(--main-border-color); - display: flex; - flex-direction: column; - padding: 0.5em 0; -} - -.option-row-main { display: flex; align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75em 0; } -.option-row-main > label { - width: 45%; +.option-row-label { + flex: 1; + display: flex; + flex-direction: column; +} + +.option-row-label > label { margin-bottom: 0 !important; +} + +.option-row-input { flex-shrink: 0; } -.option-row-main > select, -.option-row-main > .dropdown { - width: 60%; +.option-row-input > select, +.option-row-input > .dropdown { + width: auto; + min-width: 150px; } -.option-row-main > .dropdown button { +.option-row-input > .dropdown button { width: 100%; text-align: start; } @@ -36,6 +42,6 @@ border-bottom: unset; } -.option-row.centered .option-row-main { +.option-row.centered { justify-content: center; } diff --git a/apps/client/src/widgets/type_widgets/options/components/OptionsRow.tsx b/apps/client/src/widgets/type_widgets/options/components/OptionsRow.tsx index 851be70a41..92f0c04315 100644 --- a/apps/client/src/widgets/type_widgets/options/components/OptionsRow.tsx +++ b/apps/client/src/widgets/type_widgets/options/components/OptionsRow.tsx @@ -16,11 +16,13 @@ export default function OptionsRow({ name, label, description, children, centere return (
-
+
{label && } + {description && {description}} +
+
{childWithId}
- {description && {description}}
); } \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/options/llm.tsx b/apps/client/src/widgets/type_widgets/options/llm.tsx index caa867f5fd..9da9f50ca6 100644 --- a/apps/client/src/widgets/type_widgets/options/llm.tsx +++ b/apps/client/src/widgets/type_widgets/options/llm.tsx @@ -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 ( + +

{t("llm.feature_not_enabled")}

+
+ ); + } + + return ( + <> + + + + ); +} + +function ProviderSettings() { const [providersJson, setProvidersJson] = useTriliumOption("llmProviders"); const providers = useMemo(() => { try { @@ -34,7 +55,7 @@ export default function LlmSettings() { return ( -

{t("llm.settings_description")}

+

{t("llm.settings_description")}