From 8e557fb9fd1cca6d7e209fc2530f4697997f4ce7 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 4 Aug 2026 13:21:39 +0200 Subject: [PATCH] feat: improve assistant interactions --- apps/docs/docs/management/assistant.mdx | 20 +- apps/docs/docs/widgets/calendar/index.mdx | 2 + apps/nextjs/package.json | 2 + .../chat/assistant-openrouter.spec.ts | 65 ++- .../assistant/chat/assistant-openrouter.ts | 62 +++ .../chat/assistant-tool-error.spec.ts | 30 ++ .../assistant/chat/assistant-tool-error.ts | 41 ++ .../src/app/api/assistant/chat/route.ts | 44 +- .../assistant/assistant-directives.spec.ts | 25 + .../assistant/assistant-directives.ts | 9 + .../assistant-message-metadata.spec.ts | 7 + .../assistant/assistant-message-metadata.ts | 29 ++ .../assistant/assistant-panel.module.css | 275 +++++++---- .../components/assistant/assistant-panel.tsx | 444 +++++++++++++----- .../assistant/assistant-preferences.spec.ts | 20 +- .../assistant/assistant-preferences.ts | 4 + .../assistant/assistant-provider.tsx | 11 +- .../src/router/test/widgets/calendar.spec.ts | 115 +++++ packages/api/src/router/widgets/calendar.ts | 25 +- packages/request-handler/src/calendar.spec.ts | 18 + packages/request-handler/src/calendar.ts | 17 +- packages/translation/src/lang/en.json | 15 + packages/widgets/src/calendar/component.tsx | 2 +- pnpm-lock.yaml | 305 +++++++++++- pnpm-workspace.yaml | 2 + 25 files changed, 1345 insertions(+), 244 deletions(-) create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.spec.ts create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-directives.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-directives.ts create mode 100644 packages/api/src/router/test/widgets/calendar.spec.ts create mode 100644 packages/request-handler/src/calendar.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index ca1918232..d57b2ca6a 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -91,6 +91,10 @@ alongside Homarr's tools, so OpenRouter performs each search without exposing the stored credential to the browser. The model decides when live research is needed and can use the grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to three searches with five results per search. OpenRouter charges web-search usage separately from model tokens. +Completed responses show a compact web-search activity card when OpenRouter reports search usage. OpenRouter +does not expose these server-side searches as ordinary client-executed tool calls, so Homarr shows the reported +search count and the safe title and URL from each returned `url_citation` annotation instead. Citation excerpts +stay in the provider response and are not persisted in the activity card. The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary @@ -114,6 +118,8 @@ Files returned by a compatible model can be downloaded directly from the message Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables, quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed responses where a provider returns escaped line breaks or places a bold-label list on one line. +Fenced `mermaid` blocks render as diagrams in the current color scheme using Mermaid's strict security mode; +invalid diagrams fall back to their source instead of breaking the response. When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative, HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer. @@ -124,12 +130,14 @@ as part of the prompt. Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on -the server before adding it to the model context. Type `/` for common prompt shortcuts. Both menus support -keyboard navigation and keep the active result visible while moving through long lists. The joined -controls below the composer open a searchable model picker and the thinking-level menu. Model selection -is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, -or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning -setting and still depend on support from the chosen model and endpoint. +the server before adding it to the model context. Selected references appear as compact, typed pills in the +composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. Type `/` +for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while +moving through long lists. The selector below the composer combines searchable, provider-grouped models with +their description, context size and image-input capability, plus the thinking-level control. Model selection is +stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set +from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and +still depend on support from the chosen model and endpoint. Each completed response includes a circular context-window meter. Select it to open request details when the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context, diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index 5db4e9b30..fe9df0379 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -28,6 +28,8 @@ import { icalIntegration } from '@site/docs/integrations/ical'; The calendar widget shows a month view of events from different integrations. It allows you to view calendar events or upcoming releases from your media managers. +Calendar requests use normal human month numbers (`1` for January through `12` for December), including when +the same data is requested through Homarr's MCP or Assistant tools. ### Screenshots diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 76e12c886..40a24adfc 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -17,6 +17,7 @@ "@ai-sdk/openai-compatible": "catalog:", "@assistant-ui/react": "catalog:", "@assistant-ui/react-ai-sdk": "catalog:", + "@assistant-ui/react-lexical": "catalog:", "@assistant-ui/react-markdown": "catalog:", "@dnd-kit/core": "catalog:", "@dnd-kit/modifiers": "catalog:", @@ -94,6 +95,7 @@ "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", "mcp-handler": "catalog:", + "mermaid": "catalog:", "next": "catalog:", "postcss-preset-mantine": "catalog:", "posthog-js": "catalog:", diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts index 0b9c63038..aa3eec0de 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; -import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter"; +import { + getOpenRouterWebSearchRequests, + getOpenRouterWebSearchSources, + normalizeOpenRouterWebSearchSources, + withOpenRouterWebSearch, +} from "./assistant-openrouter"; describe("withOpenRouterWebSearch", () => { test("adds the current OpenRouter server tool alongside Homarr function tools", () => { @@ -28,4 +33,62 @@ describe("withOpenRouterWebSearch", () => { expect(getOpenRouterWebSearchRequests({ usage: {} })).toBeUndefined(); expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: -1 } } })).toBeUndefined(); }); + + test("extracts and deduplicates Chat Completions web-search citations", () => { + expect( + getOpenRouterWebSearchSources({ + choices: [ + { + delta: { + annotations: [ + { + type: "url_citation", + url_citation: { + url: "https://example.com/plex", + title: "Self-host Plex", + content: "A long excerpt that must not be persisted.", + }, + }, + ], + }, + }, + { + message: { + annotations: [ + { + type: "url_citation", + url_citation: { url: "https://example.com/plex", title: "Duplicate" }, + }, + { + type: "url_citation", + url_citation: { url: "https://docs.plex.tv/install", title: "Plex documentation" }, + }, + ], + }, + }, + ], + }), + ).toEqual([ + { url: "https://example.com/plex", title: "Self-host Plex" }, + { url: "https://docs.plex.tv/install", title: "Plex documentation" }, + ]); + }); + + test("rejects unsafe citations and normalizes persisted source metadata", () => { + expect( + getOpenRouterWebSearchSources({ + annotations: [ + { type: "url_citation", url_citation: { url: "javascript:alert(1)" } }, + { type: "url_citation", url_citation: { url: "https://user:password@example.com/private" } }, + ], + }), + ).toEqual([]); + expect( + normalizeOpenRouterWebSearchSources([ + { url: "https://example.com/result", title: " Result " }, + { url: "https://example.com/result" }, + { url: "file:///etc/passwd", title: "Unsafe" }, + ]), + ).toEqual([{ url: "https://example.com/result", title: "Result" }]); + }); }); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts index b8fadc68b..4990b8361 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-openrouter.ts @@ -3,6 +3,68 @@ const openRouterWebSearchTool = { parameters: { max_results: 5, max_uses: 3 }, } as const; +export interface OpenRouterWebSearchSource { + url: string; + title?: string; +} + +const maximumWebSearchSources = 12; + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + +const getSafeWebSourceUrl = (value: unknown) => { + if (typeof value !== "string" || !URL.canParse(value)) return undefined; + const url = new URL(value); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined; + return url.href; +}; + +const getWebSearchSource = (value: unknown, requireCitationType: boolean): OpenRouterWebSearchSource | undefined => { + const annotation = asRecord(value); + if (!annotation || (requireCitationType && annotation.type !== "url_citation")) return undefined; + const citation = asRecord(annotation.url_citation) ?? annotation; + const url = getSafeWebSourceUrl(citation.url); + if (!url) return undefined; + const title = typeof citation.title === "string" ? citation.title.trim().slice(0, 200) : ""; + return { url, ...(title ? { title } : {}) }; +}; + +export const normalizeOpenRouterWebSearchSources = (value: unknown): OpenRouterWebSearchSource[] => { + if (!Array.isArray(value)) return []; + const sources = new Map(); + for (const item of value) { + const source = getWebSearchSource(item, false); + if (!source) continue; + const existing = sources.get(source.url); + sources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source); + if (sources.size >= maximumWebSearchSources) break; + } + return [...sources.values()]; +}; + +export const getOpenRouterWebSearchSources = (value: unknown): OpenRouterWebSearchSource[] => { + const sources = new Map(); + const visit = (candidate: unknown, depth: number) => { + if (depth > 8 || sources.size >= maximumWebSearchSources) return; + if (Array.isArray(candidate)) { + for (const item of candidate) visit(item, depth + 1); + return; + } + const record = asRecord(candidate); + if (!record) return; + const source = getWebSearchSource(record, true); + if (source) { + const existing = sources.get(source.url); + sources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source); + } + for (const nested of Object.values(record)) visit(nested, depth + 1); + }; + + visit(value, 0); + return [...sources.values()]; +}; + export const withOpenRouterWebSearch = (body: Record) => { const tools = Array.isArray(body.tools) ? body.tools : []; if ( diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.spec.ts new file mode 100644 index 000000000..a0b8ff043 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.spec.ts @@ -0,0 +1,30 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, test } from "vitest"; + +import { getSafeAssistantToolError } from "./assistant-tool-error"; + +describe("getSafeAssistantToolError", () => { + test("keeps authorization and compatibility failures actionable", () => { + expect(getSafeAssistantToolError(new TRPCError({ code: "FORBIDDEN" }))).toBe( + "You do not have permission to perform this action.", + ); + expect(getSafeAssistantToolError(new TRPCError({ code: "NOT_FOUND" }))).toBe( + "The requested resource was not found or is not compatible with this tool.", + ); + }); + + test("recognizes wrapped integration credential decryption failures", () => { + const cause = Object.assign(new Error("error:1C800064:Provider routines::bad decrypt"), { + code: "ERR_OSSL_BAD_DECRYPT", + }); + const wrapped = new Error("Calendar tool failed", { cause }); + + expect(getSafeAssistantToolError(wrapped)).toContain("Re-save the integration credentials"); + }); + + test("does not expose unexpected internal errors", () => { + expect(getSafeAssistantToolError(new Error("database password was secret"))).toBe( + "The Homarr tool could not complete this request.", + ); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.ts new file mode 100644 index 000000000..73f0b2b7c --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-error.ts @@ -0,0 +1,41 @@ +const asErrorRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + +const getErrorChain = (error: unknown) => { + const chain: Record[] = []; + let current = asErrorRecord(error); + while (current && chain.length < 8 && !chain.includes(current)) { + chain.push(current); + current = asErrorRecord(current.cause); + } + return chain; +}; + +export const getSafeAssistantToolError = (error: unknown) => { + const chain = getErrorChain(error); + if ( + chain.some( + (candidate) => + candidate.code === "ERR_OSSL_BAD_DECRYPT" || + (typeof candidate.message === "string" && /bad decrypt|unable to decrypt/iu.test(candidate.message)), + ) + ) { + return "Homarr could not read this integration's saved credentials. Re-save the integration credentials in Management, then try again."; + } + + const code = chain.find((candidate) => typeof candidate.code === "string")?.code; + switch (code) { + case "UNAUTHORIZED": + case "FORBIDDEN": + return "You do not have permission to perform this action."; + case "NOT_FOUND": + return "The requested resource was not found or is not compatible with this tool."; + case "BAD_REQUEST": + return "The tool input was not valid."; + case "PRECONDITION_FAILED": + return "The requested resource is not ready for this operation. Check its configuration and try again."; + case "TOO_MANY_REQUESTS": + return "The operation is rate limited. Try again later."; + } + return "The Homarr tool could not complete this request."; +}; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index fb150a3fd..4bf21b0ae 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -31,14 +31,21 @@ import type { AssistantMessageMetadata, AssistantRequestStep, AssistantUsage, + AssistantWebSearchSource, } from "~/components/assistant/assistant-message-metadata"; import { extractMcpTools } from "../../mcp/_extract-tools"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantModelLookupStatus } from "./assistant-model-lookup"; -import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter"; +import { + getOpenRouterWebSearchRequests, + getOpenRouterWebSearchSources, + normalizeOpenRouterWebSearchSources, + withOpenRouterWebSearch, +} from "./assistant-openrouter"; import { assistantExecutionPolicy } from "./assistant-execution-policy"; import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; +import { getSafeAssistantToolError } from "./assistant-tool-error"; import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy"; export const maxDuration = 300; @@ -132,13 +139,18 @@ const asFiniteNumber = (value: unknown) => { const createProviderTelemetryExtractor = (): MetadataExtractor => { const createAccumulator = () => { - let metadata: Record = {}; + let metadata: Record = {}; + const webSearchSources = new Map(); const process = (value: unknown) => { const body = asRecord(value); if (!body) return; const usage = asRecord(body.usage); const costDetails = asRecord(usage?.cost_details); const webSearchRequests = getOpenRouterWebSearchRequests(body); + for (const source of getOpenRouterWebSearchSources(body)) { + const existing = webSearchSources.get(source.url); + webSearchSources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source); + } const routerMetadata = asRecord(body.openrouter_metadata); const endpoints = asRecord(routerMetadata?.endpoints); const selectedEndpoint = Array.isArray(endpoints?.available) @@ -163,6 +175,7 @@ const createProviderTelemetryExtractor = (): MetadataExtractor => { ...(cost !== undefined ? { cost } : {}), ...(upstreamCost !== undefined ? { upstreamCost } : {}), ...(webSearchRequests !== undefined ? { webSearchRequests } : {}), + ...(webSearchSources.size > 0 ? { webSearchSources: [...webSearchSources.values()] } : {}), ...(routerAttempt !== undefined ? { fallbackCount: Math.max(routerAttempt - 1, 0) } : {}), ...(typeof routerMetadata?.strategy === "string" ? { routerStrategy: routerMetadata.strategy } : {}), ...(typeof routerMetadata?.region === "string" ? { routerRegion: routerMetadata.region } : {}), @@ -295,23 +308,6 @@ const sumCompleteStepMetric = ( return (values as number[]).reduce((sum, value) => sum + value, 0); }; -const safeToolError = (error: unknown) => { - if (error instanceof Error && "code" in error) { - switch ((error as Error & { code: string }).code) { - case "UNAUTHORIZED": - case "FORBIDDEN": - return "You do not have permission to perform this action."; - case "NOT_FOUND": - return "The requested resource was not found."; - case "BAD_REQUEST": - return "The tool input was not valid."; - case "TOO_MANY_REQUESTS": - return "The operation is rate limited. Try again later."; - } - } - return "The Homarr tool could not complete this request."; -}; - const getProcedureTypeMap = () => { const procedures = ( mcpRouter as unknown as { @@ -456,7 +452,7 @@ export async function POST(request: Request) { toolName: mcpTool.name, error: error instanceof Error ? error.message : String(error), }); - return { error: safeToolError(error) }; + return { error: getSafeAssistantToolError(error) }; } }, }), @@ -569,6 +565,7 @@ export async function POST(request: Request) { }, onStepFinish: ({ performance, providerMetadata, response, usage }) => { const telemetry = asRecord(providerMetadata?.homarrTelemetry); + const webSearchSources = normalizeOpenRouterWebSearchSources(telemetry?.webSearchSources); const generationId = typeof telemetry?.generationId === "string" ? telemetry.generationId @@ -595,6 +592,7 @@ export async function POST(request: Request) { ...(asFiniteNumber(telemetry?.webSearchRequests) !== undefined ? { webSearchRequests: asFiniteNumber(telemetry?.webSearchRequests) } : {}), + ...(webSearchSources.length > 0 ? { webSearchSources } : {}), ...(generationId ? { generationId } : {}), ...(generationId ? { @@ -706,6 +704,11 @@ export async function POST(request: Request) { const cacheDiscount = sumCompleteStepMetric(requestSteps, (step) => step.cacheDiscount); const fallbackCount = sumCompleteStepMetric(requestSteps, (step) => step.fallbackCount); const webSearchRequests = requestSteps.reduce((sum, step) => sum + (step.webSearchRequests ?? 0), 0); + const webSearchSources = [ + ...new Map( + requestSteps.flatMap((step) => step.webSearchSources ?? []).map((source) => [source.url, source] as const), + ).values(), + ]; return { usage, @@ -727,6 +730,7 @@ export async function POST(request: Request) { ...(cacheDiscount !== undefined ? { cacheDiscount } : {}), ...(fallbackCount !== undefined ? { fallbackCount } : {}), ...(webSearchRequests > 0 ? { webSearchRequests } : {}), + ...(webSearchSources.length > 0 ? { webSearchSources } : {}), finishReason: latestStep?.finishReason ?? part.finishReason, }, }, diff --git a/apps/nextjs/src/components/assistant/assistant-directives.spec.ts b/apps/nextjs/src/components/assistant/assistant-directives.spec.ts new file mode 100644 index 000000000..659e75787 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-directives.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives"; + +describe("assistant directives", () => { + test("preserves text while parsing Homarr context references into visual segments", () => { + expect( + parseAssistantDirectives( + "Open :app[Facebook]{name=p6ik48io64xh4p1kyz2kwxiw} with :integration[Jellyfin]{name=pui7uj2196m5a96tl5hues5r} on :board[defaultv2]{name=a7b4sb1qkwftzt5xppxuvceq}", + ), + ).toEqual([ + { kind: "text", text: "Open " }, + { kind: "mention", type: "app", label: "Facebook", id: "p6ik48io64xh4p1kyz2kwxiw" }, + { kind: "text", text: " with " }, + { kind: "mention", type: "integration", label: "Jellyfin", id: "pui7uj2196m5a96tl5hues5r" }, + { kind: "text", text: " on " }, + { kind: "mention", type: "board", label: "defaultv2", id: "a7b4sb1qkwftzt5xppxuvceq" }, + ]); + }); + + test("uses the tools label for model-context tools and a safe fallback for unknown directives", () => { + expect(getAssistantDirectiveTranslationKey("tool")).toBe("tools"); + expect(getAssistantDirectiveTranslationKey("unknown")).toBe("context"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-directives.ts b/apps/nextjs/src/components/assistant/assistant-directives.ts new file mode 100644 index 000000000..134238fcc --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-directives.ts @@ -0,0 +1,9 @@ +import { unstable_defaultDirectiveFormatter } from "@assistant-ui/react"; + +export const parseAssistantDirectives = (text: string) => unstable_defaultDirectiveFormatter.parse(text); + +export const getAssistantDirectiveTranslationKey = (type: string) => { + if (type === "tool" || type === "tools") return "tools" as const; + if (type === "app" || type === "integration" || type === "board" || type === "widget") return type; + return "context" as const; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts index 90bfad714..38aeb89c5 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts @@ -24,6 +24,10 @@ describe("getAssistantTelemetry", () => { cacheDiscount: 0.00003, fallbackCount: 1, webSearchRequests: 2, + webSearchSources: [ + { url: "https://example.com/research", title: "Research source" }, + { url: "javascript:alert(1)", title: "Unsafe source" }, + ], steps: [ { index: 1, @@ -42,6 +46,7 @@ describe("getAssistantTelemetry", () => { cacheDiscount: 0.00003, fallbackCount: 1, webSearchRequests: 2, + webSearchSources: [{ url: "https://example.com/research", title: "Research source" }], routedProvider: "Alibaba", serviceTier: "default", dataRegion: "US", @@ -64,6 +69,7 @@ describe("getAssistantTelemetry", () => { cacheDiscount: 0.00003, fallbackCount: 1, webSearchRequests: 2, + webSearchSources: [{ url: "https://example.com/research", title: "Research source" }], steps: [ expect.objectContaining({ providerLatencyMs: 964, @@ -72,6 +78,7 @@ describe("getAssistantTelemetry", () => { routedProvider: "Alibaba", serviceTier: "default", webSearchRequests: 2, + webSearchSources: [{ url: "https://example.com/research", title: "Research source" }], }), ], }); diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts index 1a863f856..7ab3dbb9a 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts @@ -9,6 +9,11 @@ export type AssistantUsage = { cacheWriteTokens?: number; }; +export type AssistantWebSearchSource = { + url: string; + title?: string; +}; + export type AssistantRequestStep = { index: number; durationMs: number; @@ -41,6 +46,7 @@ export type AssistantRequestStep = { fallbackCount?: number; fallbackLatencyMs?: number; webSearchRequests?: number; + webSearchSources?: AssistantWebSearchSource[]; isByok?: boolean; streamed?: boolean; cancelled?: boolean; @@ -66,6 +72,7 @@ export type AssistantRequestTelemetry = { fallbackCount?: number; fallbackLatencyMs?: number; webSearchRequests?: number; + webSearchSources?: AssistantWebSearchSource[]; costType?: "reported" | "estimated"; finishReason?: string; steps: AssistantRequestStep[]; @@ -83,6 +90,22 @@ export type AssistantUIMessage = UIMessage; const getFiniteNonNegativeNumber = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +const getWebSearchSources = (value: unknown): AssistantWebSearchSource[] => { + if (!Array.isArray(value)) return []; + const sources = new Map(); + for (const item of value) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const candidate = item as Record; + if (typeof candidate.url !== "string" || !URL.canParse(candidate.url)) continue; + const url = new URL(candidate.url); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) continue; + const title = typeof candidate.title === "string" ? candidate.title.trim().slice(0, 200) : ""; + if (!sources.has(url.href)) sources.set(url.href, { url: url.href, ...(title ? { title } : {}) }); + if (sources.size >= 12) break; + } + return [...sources.values()]; +}; + export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || !("usage" in metadata)) return null; const usage = metadata.usage; @@ -213,6 +236,9 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme ...(getFiniteNonNegativeNumber(candidate.webSearchRequests) !== undefined ? { webSearchRequests: getFiniteNonNegativeNumber(candidate.webSearchRequests) } : {}), + ...(getWebSearchSources(candidate.webSearchSources).length > 0 + ? { webSearchSources: getWebSearchSources(candidate.webSearchSources) } + : {}), ...(typeof candidate.isByok === "boolean" ? { isByok: candidate.isByok } : {}), ...(typeof candidate.streamed === "boolean" ? { streamed: candidate.streamed } : {}), ...(typeof candidate.cancelled === "boolean" ? { cancelled: candidate.cancelled } : {}), @@ -267,6 +293,9 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme ...(getFiniteNonNegativeNumber(value.webSearchRequests) !== undefined ? { webSearchRequests: getFiniteNonNegativeNumber(value.webSearchRequests) } : {}), + ...(getWebSearchSources(value.webSearchSources).length > 0 + ? { webSearchSources: getWebSearchSources(value.webSearchSources) } + : {}), ...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}), ...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}), }; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 03fc906d3..be25be8f7 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -217,19 +217,8 @@ padding: 0.6rem 0.5rem; } - .runtimeButtonGroup, - .runtimeControls { - width: 100%; - } - - .modelButton { - min-width: 0; - max-width: none; - } - - .reasoningButton { - min-width: 2.5rem; - max-width: 7.75rem; + .runtimeSelectorTrigger { + max-width: 100%; } .suggestions { @@ -258,24 +247,9 @@ overflow: hidden; } - .runtimeButton { - padding-inline: 0.5rem; - } - - .reasoningButton { - width: 2.5rem; - min-width: 2.5rem; - max-width: 2.5rem; - } - - .reasoningButton :global(.mantine-Button-label), - .reasoningButton :global(.mantine-Button-section[data-position="right"]) { + .runtimeSelectorEffort { display: none; } - - .reasoningButton :global(.mantine-Button-section[data-position="left"]) { - margin-inline: 0; - } } @container assistant-widget (max-width: 18rem) { @@ -365,6 +339,57 @@ white-space: pre-wrap; } +.directiveChip { + --mention-color: var(--mantine-color-gray-light-color); + --mention-background: var(--mantine-color-gray-light); + + display: inline-flex; + max-width: min(12rem, 80%); + align-items: center; + gap: 0.28rem; + margin-inline: 0.12rem; + padding: 0.08rem 0.42rem 0.08rem 0.3rem; + border-radius: 999px; + color: var(--mention-color); + background: var(--mention-background); + font-size: var(--mantine-font-size-xs); + font-weight: 650; + line-height: 1.55; + vertical-align: 0.02em; + white-space: nowrap; +} + +.directiveChip[data-directive-type="app"] { + --mention-color: var(--mantine-color-red-light-color); + --mention-background: var(--mantine-color-red-light); +} + +.directiveChip[data-directive-type="integration"] { + --mention-color: var(--mantine-color-blue-light-color); + --mention-background: var(--mantine-color-blue-light); +} + +.directiveChip[data-directive-type="board"] { + --mention-color: var(--mantine-color-grape-light-color); + --mention-background: var(--mantine-color-grape-light); +} + +.directiveChip[data-directive-type="widget"] { + --mention-color: var(--mantine-color-teal-light-color); + --mention-background: var(--mantine-color-teal-light); +} + +.directiveChip[data-directive-type="tool"], +.directiveChip[data-directive-type="tools"] { + --mention-color: var(--mantine-color-orange-light-color); + --mention-background: var(--mantine-color-orange-light); +} + +.directiveChipLabel { + overflow: hidden; + text-overflow: ellipsis; +} + .messageMarkdown { line-height: 1.65; overflow-wrap: anywhere; @@ -476,6 +501,30 @@ border-radius: var(--mantine-radius-md); } +.mermaidStatus, +.mermaidFallback, +.mermaidDiagram { + margin-block: 0.75rem; + padding: 0.75rem; + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-default); +} + +.mermaidDiagram { + overflow-x: auto; +} + +.mermaidDiagram :global(svg) { + display: block; + max-width: 100%; + height: auto; + margin-inline: auto; +} + +.mermaidFallback pre { + margin: 0; +} + .messageMarkdown input[type="checkbox"] { margin-inline: -1.1rem 0.45rem; accent-color: var(--mantine-primary-color-filled); @@ -744,7 +793,8 @@ .reasoning { margin-block: 0.45rem; padding: 0.45rem 0.6rem; - border-inline-start: 2px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-default); } .reasoning summary { @@ -756,8 +806,19 @@ display: none; } +.reasoningChevron { + transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.reasoning[open] .reasoningChevron { + transform: rotate(180deg); +} + +.reasoningContent { + padding-top: 0.45rem; +} + .reasoningText { - margin-top: 0.45rem; white-space: pre-wrap; } @@ -768,6 +829,34 @@ margin-inline-end: 0.4rem; } +.webSearchActivity { + background: color-mix(in srgb, var(--mantine-color-blue-light) 45%, var(--mantine-color-default)); +} + +.webSearchSources { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--mantine-color-default-border); +} + +.webSearchSource { + display: inline-flex; + max-width: min(100%, 18rem); + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.5rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-xl); + color: var(--mantine-color-text); + background: var(--mantine-color-body); + text-decoration: none; +} + +.webSearchSource:hover { + border-color: var(--mantine-primary-color-filled); + color: var(--mantine-primary-color-filled); +} + .messageFile, .attachment { width: fit-content; @@ -1001,16 +1090,29 @@ } .composerInput { + position: relative; min-height: 2.5rem; max-height: 9rem; flex: 1; - resize: none; - border: 0; - outline: 0; padding: 0.55rem 0.65rem; color: var(--mantine-color-text); background: transparent; font: inherit; + cursor: text; +} + +.composerInput :global(.aui-lexical-input) { + min-height: 1.5rem; + outline: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.composerInput :global(.aui-lexical-placeholder) { + position: absolute; + inset: 0.55rem 0.65rem auto; + color: var(--mantine-color-dimmed); + pointer-events: none; } .composerHints { @@ -1022,49 +1124,49 @@ padding-inline: 0.2rem; } -.runtimeControls { +.runtimeSelectorTrigger { + width: fit-content; min-width: 0; - flex: 1 1 auto; -} - -.runtimeButtonGroup { - min-width: 0; - max-width: 100%; -} - -.runtimeButton { - min-width: 0; - border-color: var(--mantine-color-default-border); + max-width: min(24rem, 100%); + padding: 0.3rem 0.45rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); color: var(--mantine-color-text); background: var(--mantine-color-body); - font-weight: 600; } -.runtimeButton:hover { +.runtimeSelectorTrigger:hover { background: var(--mantine-color-default-hover); } -.runtimeButtonLabel { +.runtimeSelectorTrigger:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.runtimeSelectorTrigger:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.runtimeSelectorIcon { + flex: 0 0 auto; +} + +.runtimeSelectorName { min-width: 0; + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.modelButton { - min-width: 8rem; - max-width: 15rem; - flex: 1 1 auto; -} - -.modelButton :global(.mantine-Button-label) { - font-size: var(--mantine-font-size-xs); -} - -.reasoningButton { - min-width: 7.5rem; - max-width: 10.5rem; - flex: 0 1 auto; +.runtimeSelectorEffort { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.2rem; + text-transform: none; } .modelDropdown { @@ -1086,15 +1188,23 @@ flex: 1; } +.modelOptionMeta { + flex: 0 0 auto; + color: var(--mantine-color-dimmed); +} + .runtimeOptionCheck { flex: 0 0 auto; color: var(--mantine-primary-color-filled); } -.runtimeMenuItem[data-selected] { - color: var(--mantine-primary-color-filled); - background: var(--mantine-primary-color-light); - font-weight: 600; +.reasoningSelector { + padding: 0.55rem; +} + +.reasoningSegmentedControl :global(.mantine-SegmentedControl-label) { + padding-inline: 0.35rem; + font-size: 0.68rem; } .triggerPopover { @@ -1140,11 +1250,6 @@ flex: 1; } -.composerInput::placeholder { - color: var(--mantine-color-dimmed); - opacity: 1; -} - .empty { display: flex; min-height: 23rem; @@ -1249,21 +1354,8 @@ display: none; } - .runtimeControls { - width: 100%; - } - - .runtimeButtonGroup { - width: 100%; - } - - .modelButton { - min-width: 0; - max-width: none; - } - - .reasoningButton { - max-width: 9rem; + .runtimeSelectorTrigger { + max-width: 100%; } .suggestions { @@ -1316,11 +1408,6 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .reasoningButton { - min-width: 2.5rem; - max-width: 7.75rem; - } - .approvalActions { flex-direction: column; } @@ -1345,7 +1432,9 @@ @media (prefers-reduced-motion: reduce) { .activityBar, .floatingPanel, - .contextMeter { + .contextMeter, + .disclosureIcon, + .reasoningChevron { animation: none; transition: none; } diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index f70830736..79d5cc718 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -1,7 +1,7 @@ "use client"; -import type { ComponentPropsWithoutRef, RefObject } from "react"; -import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react"; +import { createContext, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { FileMessagePartProps, ImageMessagePartProps, @@ -17,19 +17,23 @@ import { BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, - MessagePartPrimitive, MessagePrimitive, SelectionToolbarPrimitive, ThreadListItemPrimitive, ThreadListPrimitive, ThreadPrimitive, + groupPartByType, useAui, useAuiState, + useMessagePartText, unstable_useMentionAdapter, unstable_useSlashCommandAdapter, unstable_useTriggerPopoverScopeContext, } from "@assistant-ui/react"; +import { LexicalComposerInput } from "@assistant-ui/react-lexical"; +import type { DirectiveChipProps } from "@assistant-ui/react-lexical"; import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; +import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown"; import { ActionIcon, Anchor, @@ -43,16 +47,17 @@ import { Group, Image, Loader, - Menu, Popover, RingProgress, ScrollArea, + SegmentedControl, Stack, Text, ThemeIcon, Tooltip, UnstyledButton, useCombobox, + useComputedColorScheme, } from "@mantine/core"; import { useReducedMotion, useWindowEvent } from "@mantine/hooks"; import { @@ -78,6 +83,7 @@ import { IconPaperclip, IconPalette, IconPencil, + IconPhoto, IconHistory, IconPlayerStop, IconPlus, @@ -103,6 +109,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval"; +import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; @@ -139,6 +146,34 @@ interface AssistantPanelProps extends AssistantConversationControls { const markdownRemarkPlugins = [remarkGfm, remarkBreaks]; +const contextIcons = { + app: IconApps, + integration: IconLink, + board: IconMessage, + widget: IconTool, + tool: IconTool, + tools: IconTool, +}; + +const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveChipProps) => { + const t = useScopedI18n("common.assistant"); + const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; + const translationKey = getAssistantDirectiveTranslationKey(directiveType); + const typeLabel = t(`mentions.${translationKey}`); + + return ( + + + {label} + + ); +}; + const MarkdownLink = ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => { const external = href !== undefined && /^https?:\/\//iu.test(href); return ( @@ -171,17 +206,104 @@ const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"im ); }; +const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => { + const t = useScopedI18n("common.assistant"); + const colorScheme = useComputedColorScheme("light"); + const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`; + const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code }); + + useEffect(() => { + let active = true; + setRendered({ code }); + void import("mermaid") + .then(async ({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: colorScheme === "dark" ? "dark" : "neutral", + fontFamily: "Inter, system-ui, sans-serif", + }); + return await mermaid.render(diagramId, code); + }) + .then(({ svg }) => { + if (active) setRendered({ code, svg }); + }) + .catch(() => { + document.getElementById(`d${diagramId}`)?.remove(); + if (active) setRendered({ code, failed: true }); + }); + + return () => { + active = false; + }; + }, [code, colorScheme, diagramId]); + + if (rendered.code !== code || (!rendered.svg && !rendered.failed)) { + return ( + + + + {t("markdown.renderingDiagram")} + + + ); + } + if (rendered.failed || !rendered.svg) { + const Pre = components.Pre; + const Code = components.Code; + return ( + + + {t("markdown.diagramUnavailable")} + +
+          {code}
+        
+
+ ); + } + return ( + + ); +}; + const AssistantTextPart = () => ( ); -const UserTextPart = () => ; +const UserTextPart = () => { + const { text } = useMessagePartText(); + const segments = parseAssistantDirectives(text); + return ( + + {segments.map((segment, index) => + segment.kind === "text" ? ( + {segment.text} + ) : ( + + ), + )} + + ); +}; const Attachment = ({ removable = false }: { removable?: boolean }) => { const t = useScopedI18n("common.assistant"); @@ -203,21 +325,30 @@ const Attachment = ({ removable = false }: { removable?: boolean }) => { }; const SentAttachment = () => ; -const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => { +const ReasoningPart = ({ text }: ReasoningMessagePartProps) => ( + + {text} + +); + +const ReasoningGroup = ({ children, running }: { children: ReactNode; running: boolean }) => { const t = useScopedI18n("common.assistant"); return ( -
+
- - - - {t("reasoning")} - + + + {running ? : } + + {t("reasoning")} + + + - - {text} - + + {children} +
); }; @@ -1289,19 +1420,95 @@ const RuntimeError = () => { ); }; +const WebSearchActivity = () => { + const t = useScopedI18n("common.assistant"); + const metadata = useAuiState((state) => state.message.metadata); + const telemetry = getAssistantTelemetry(metadata); + if (!telemetry) return null; + const sources = telemetry.webSearchSources ?? []; + if (telemetry.webSearchRequests === undefined && sources.length === 0) return null; + + return ( + + + + + + +
+ + {t("webSearch.title")} + + + {t("webSearch.completed")} + +
+
+ + {telemetry.webSearchRequests !== undefined && ( + + {t("webSearch.searches", { count: telemetry.webSearchRequests })} + + )} + {sources.length > 0 && ( + + {t("webSearch.sources", { count: sources.length })} + + )} + +
+ {sources.length > 0 && ( + + {sources.map((source) => ( + + + + {source.title ?? new URL(source.url).hostname} + + + ))} + + )} +
+ ); +}; + const AssistantMessage = () => ( - + {({ part, children }) => { + switch (part.type) { + case "group-reasoning": + return {children}; + case "text": + return ; + case "reasoning": + return ; + case "source": + return ; + case "file": + return ; + case "image": + return ; + case "tool-call": + return part.toolUI ?? ; + case "indicator": + return null; + default: + return null; + } }} - /> + + @@ -1543,14 +1750,6 @@ const EmptyThread = () => { ); }; -const contextIcons = { - app: IconApps, - integration: IconLink, - board: IconMessage, - widget: IconTool, - tools: IconTool, -}; - const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = unstable_useTriggerPopoverScopeContext(); @@ -1727,6 +1926,16 @@ const ComposerTriggers = () => { type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; +const getModelProviderLabel = (modelId: string, fallback: string) => { + const separator = modelId.indexOf("/"); + if (separator <= 0) return fallback; + const provider = modelId.slice(0, separator).replaceAll(/[-_]/gu, " "); + return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`; +}; + +const formatCompactModelNumber = (value: number) => + new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value); + const RuntimeControls = ({ modelId, models, @@ -1747,6 +1956,13 @@ const RuntimeControls = ({ model.name.toLocaleLowerCase().includes(normalizedModelSearch) || model.id.toLocaleLowerCase().includes(normalizedModelSearch), ); + const groupedModels = visibleModels.reduce((groups, model) => { + const provider = getModelProviderLabel(model.id, t("runtime.otherModels")); + const providerModels = groups.get(provider); + if (providerModels) providerModels.push(model); + else groups.set(provider, [model]); + return groups; + }, new Map()); const modelCombobox = useCombobox({ onDropdownOpen: () => { modelCombobox.selectActiveOption(); @@ -1765,40 +1981,45 @@ const RuntimeControls = ({ }; return ( - - - - - - - - { - setModelSearch(event.currentTarget.value); - modelCombobox.updateSelectedOptionIndex(); - }} - placeholder={t("runtime.model")} - aria-label={t("runtime.model")} - /> - - {visibleModels.map((model) => ( + + + + {t(`runtime.reasoning.${reasoning}`)} + + + + + + + { + setModelSearch(event.currentTarget.value); + modelCombobox.updateSelectedOptionIndex(); + }} + placeholder={t("runtime.searchModels")} + aria-label={t("runtime.searchModels")} + /> + + {[...groupedModels.entries()].map(([provider, providerModels]) => ( + + {providerModels.map((model) => ( - + + + + {model.name} - {model.id} + {model.description?.trim() || model.id} - {model.id === modelId && } + + {model.inputModalities.includes("image") && ( + + + + )} + {model.contextLength && ( + + {formatCompactModelNumber(model.contextLength)} + + )} + {model.id === modelId && } + ))} - {visibleModels.length === 0 && {t("runtime.noModels")}} - - - - - - - - - {t("runtime.thinking")} - {assistantReasoningModes.map((mode) => ( - : null} - onClick={() => onReasoningChange(mode)} - > - {t(`runtime.reasoning.${mode}`)} - - ))} - - - - + + ))} + {visibleModels.length === 0 && {t("runtime.noModels")}} + + + + + + + {t("runtime.thinking")} + + + { + if (assistantReasoningModes.includes(value as AssistantReasoningMode)) { + onReasoningChange(value as AssistantReasoningMode); + } + }} + size="xs" + fullWidth + data={assistantReasoningModes.map((mode) => ({ + value: mode, + label: t(`runtime.reasoning.${mode}`), + }))} + /> + + + ); }; @@ -1893,10 +2123,12 @@ const Composer = (props: ComposerProps) => { - {running ? ( diff --git a/apps/nextjs/src/components/assistant/assistant-preferences.spec.ts b/apps/nextjs/src/components/assistant/assistant-preferences.spec.ts index c0dd0352d..9b7517c00 100644 --- a/apps/nextjs/src/components/assistant/assistant-preferences.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-preferences.spec.ts @@ -3,8 +3,24 @@ import { describe, expect, test } from "vitest"; import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; const models = [ - { id: "provider/default", name: "Default", inputModalities: ["text"] }, - { id: "provider/alternate", name: "Alternate", inputModalities: ["text"] }, + { + id: "provider/default", + name: "Default", + description: null, + contextLength: null, + promptPrice: null, + completionPrice: null, + inputModalities: ["text"], + }, + { + id: "provider/alternate", + name: "Alternate", + description: null, + contextLength: null, + promptPrice: null, + completionPrice: null, + inputModalities: ["text"], + }, ]; describe("resolveAssistantPreferenceModelId", () => { diff --git a/apps/nextjs/src/components/assistant/assistant-preferences.ts b/apps/nextjs/src/components/assistant/assistant-preferences.ts index 54f6b6468..97fa0edca 100644 --- a/apps/nextjs/src/components/assistant/assistant-preferences.ts +++ b/apps/nextjs/src/components/assistant/assistant-preferences.ts @@ -3,6 +3,10 @@ export type { AssistantReasoningMode } from "@homarr/definitions"; export type AssistantRuntimeModelOption = { id: string; name: string; + description: string | null; + contextLength: number | null; + promptPrice: string | null; + completionPrice: string | null; inputModalities: string[]; }; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index aa67cde0c..db9825f8f 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -186,7 +186,16 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => { reasoning: "auto", }); const models = useMemo( - () => data?.models.map(({ id, name, inputModalities }) => ({ id, name, inputModalities })) ?? [], + () => + data?.models.map(({ id, name, description, contextLength, promptPrice, completionPrice, inputModalities }) => ({ + id, + name, + description, + contextLength, + promptPrice, + completionPrice, + inputModalities, + })) ?? [], [data?.models], ); diff --git a/packages/api/src/router/test/widgets/calendar.spec.ts b/packages/api/src/router/test/widgets/calendar.spec.ts new file mode 100644 index 000000000..7f0b04771 --- /dev/null +++ b/packages/api/src/router/test/widgets/calendar.spec.ts @@ -0,0 +1,115 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import { integrations, integrationUserPermissions, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { calendarRouter } from "../../widgets/calendar"; + +const { getDataAsync, handler } = vi.hoisted(() => { + const getDataAsync = vi.fn(); + return { getDataAsync, handler: vi.fn(() => ({ getDataAsync })) }; +}); + +vi.mock("@homarr/request-handler/calendar", () => ({ + calendarMonthRequestHandler: { handler }, +})); + +const createCallerAsync = async () => { + const db = createDb(); + const userId = createId(); + const integrationId = createId(); + await db.insert(users).values({ id: userId }); + await db.insert(integrations).values({ + id: integrationId, + kind: "radarr", + name: "Radarr", + url: "https://radarr.example.com", + }); + await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" }); + const session = { + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), + } satisfies Session; + return { caller: calendarRouter.createCaller({ db, deviceType: undefined, session }), integrationId }; +}; + +const createEvent = (title: string, releaseType?: "digitalRelease" | "physicalRelease") => ({ + title, + subTitle: null, + description: null, + startDate: new Date("2025-05-10T12:00:00.000Z"), + endDate: null, + image: null, + location: null, + indicatorColor: "blue", + links: [], + ...(releaseType ? { metadata: { type: "radarr" as const, releaseType } } : {}), +}); + +describe("calendarRouter.findAllEvents", () => { + test("uses human month numbers and filters Radarr release types", async () => { + const { caller, integrationId } = await createCallerAsync(); + getDataAsync.mockResolvedValueOnce({ + data: [ + createEvent("Digital", "digitalRelease"), + createEvent("Physical", "physicalRelease"), + createEvent("Sonarr episode"), + ], + timestamp: new Date(), + }); + + const result = await caller.findAllEvents({ + integrationIds: [integrationId], + year: 2025, + month: 5, + releaseType: ["digitalRelease"], + showUnmonitored: true, + }); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: integrationId }), { + year: 2025, + month: 5, + releaseType: ["digitalRelease"], + showUnmonitored: true, + }); + expect(result[0]?.events.map((event) => event.title)).toEqual(["Digital", "Sonarr episode"]); + }); + + test("rejects zero-based and out-of-range months", async () => { + const { caller } = await createCallerAsync(); + await expect( + caller.findAllEvents({ + integrationIds: [], + year: 2025, + month: 0, + releaseType: [], + showUnmonitored: true, + }), + ).rejects.toThrow(); + }); + + test("returns an integration-scoped error instead of silently dropping a failed calendar", async () => { + const { caller, integrationId } = await createCallerAsync(); + getDataAsync.mockRejectedValueOnce(new Error("remote calendar unavailable")); + + await expect( + caller.findAllEvents({ + integrationIds: [integrationId], + year: 2025, + month: 5, + releaseType: ["inCinemas", "digitalRelease", "physicalRelease"], + showUnmonitored: true, + }), + ).resolves.toEqual([ + { + events: [], + integration: { id: integrationId, name: "Radarr", kind: "radarr" }, + error: "Calendar events could not be loaded from this integration.", + }, + ]); + }); +}); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 729e82634..3aabb47ed 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -17,13 +17,13 @@ export const calendarRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'", + "Get calendar events from calendar-capable integrations. REQUIRED: integrationIds (IDs from integration_all whose kinds include Sonarr, Radarr, Lidarr, Readarr, Home Assistant, Nextcloud, or iCal), year (four-digit number), month (human month number from 1=January through 12=December), releaseType (Radarr filters: 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'", }, }) .input( z.object({ - year: z.number(), - month: z.number(), + year: z.number().int().min(1970).max(9999), + month: z.number().int().min(1).max(12), releaseType: z.array(z.enum(radarrReleaseTypes)), showUnmonitored: z.boolean(), }), @@ -35,9 +35,12 @@ export const calendarRouter = createTRPCRouter({ const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const { data } = await innerHandler.getDataAsync(); + const events = data.filter( + (event) => event.metadata?.type !== "radarr" || input.releaseType.includes(event.metadata.releaseType), + ); return { - events: data, + events, integration: { id: integration.id, name: integration.name, @@ -62,7 +65,19 @@ export const calendarRouter = createTRPCRouter({ { cause: result.reason }, ), ); - return []; + return integration + ? [ + { + events: [], + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + error: "Calendar events could not be loaded from this integration.", + }, + ] + : []; }); }), }); diff --git a/packages/request-handler/src/calendar.spec.ts b/packages/request-handler/src/calendar.spec.ts new file mode 100644 index 000000000..807e6e443 --- /dev/null +++ b/packages/request-handler/src/calendar.spec.ts @@ -0,0 +1,18 @@ +// @vitest-environment node + +import { describe, expect, test } from "vitest"; + +import { getCalendarMonthRange } from "./calendar"; + +describe("getCalendarMonthRange", () => { + test("treats month 5 as May and includes the calendar overflow days", () => { + const { startDate, endDate } = getCalendarMonthRange(2025, 5); + + expect(startDate.getFullYear()).toBe(2025); + expect(startDate.getMonth()).toBe(3); + expect(startDate.getDate()).toBe(25); + expect(endDate.getFullYear()).toBe(2025); + expect(endDate.getMonth()).toBe(5); + expect(endDate.getDate()).toBe(6); + }); +}); diff --git a/packages/request-handler/src/calendar.ts b/packages/request-handler/src/calendar.ts index a358d13e8..312121bd6 100644 --- a/packages/request-handler/src/calendar.ts +++ b/packages/request-handler/src/calendar.ts @@ -6,6 +6,14 @@ import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/type import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; +export const getCalendarMonthRange = (year: number, month: number) => { + const zeroBasedMonth = month - 1; + return { + startDate: dayjs().year(year).month(zeroBasedMonth).startOf("month").subtract(6, "days").toDate(), + endDate: dayjs().year(year).month(zeroBasedMonth).endOf("month").add(6, "days").toDate(), + }; +}; + export const calendarMonthRequestHandler = createIntegrationRequestHandler< CalendarEvent[], IntegrationKindByCategory<"calendar">, @@ -14,13 +22,8 @@ export const calendarMonthRequestHandler = createIntegrationRequestHandler< async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); // Calendar component shows up to 6 days before and after the month, for example if 1. of january is sunday, it shows the last 6 days of december. - const startDate = dayjs().year(input.year).month(input.month).startOf("month").subtract(6, "days"); - const endDate = dayjs().year(input.year).month(input.month).endOf("month").add(6, "days"); + const { startDate, endDate } = getCalendarMonthRange(input.year, input.month); - return await integrationInstance.getCalendarEventsAsync( - startDate.toDate(), - endDate.toDate(), - input.showUnmonitored, - ); + return await integrationInstance.getCalendarEventsAsync(startDate, endDate, input.showUnmonitored); }, }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 3fd8b8859..711964486 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1559,7 +1559,10 @@ "composerPlaceholder": "Ask Homarr Assistant…", "runtime": { "model": "Model", + "searchModels": "Search models", "noModels": "No models available", + "otherModels": "Other models", + "imageInput": "Accepts image input", "thinking": "Thinking mode", "reasoning": { "auto": "Auto", @@ -1598,12 +1601,18 @@ "mentions": { "menu": "Add Homarr context", "hint": "Type @ to add context", + "context": "Context", "app": "Apps", "integration": "Integrations", "board": "Boards", "widget": "Widgets", "tools": "Available tools" }, + "markdown": { + "diagram": "Diagram", + "renderingDiagram": "Rendering diagram…", + "diagramUnavailable": "This diagram could not be rendered." + }, "commands": { "menu": "Assistant commands", "hint": "Type / for shortcuts", @@ -1657,6 +1666,12 @@ }, "toolInput": "Input", "toolOutput": "Output", + "webSearch": { + "title": "Web search", + "completed": "OpenRouter searched the live web for this response.", + "searches": "{count, plural, one {# search} other {# searches}}", + "sources": "{count, plural, one {# source} other {# sources}}" + }, "responseError": { "title": "The assistant could not finish", "description": "Try again. If the problem continues, ask an administrator to verify the configured model endpoint.", diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 6d309ce42..2888ba694 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -34,7 +34,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> { const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => { const input = { integrationIds, - month: month.getMonth(), + month: month.getMonth() + 1, year: month.getFullYear(), releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eea585cf..6a7b42b5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@assistant-ui/react-ai-sdk': specifier: ^1.3.41 version: 1.3.41 + '@assistant-ui/react-lexical': + specifier: 0.2.0 + version: 0.2.0 '@assistant-ui/react-markdown': specifier: ^0.14.7 version: 0.14.7 @@ -429,6 +432,9 @@ catalogs: mcp-handler: specifier: ^1.1.0 version: 1.1.0 + mermaid: + specifier: ^11.15.0 + version: 11.15.0 mysql2: specifier: 3.22.6 version: 3.22.6 @@ -815,6 +821,9 @@ importers: '@assistant-ui/react-ai-sdk': specifier: 'catalog:' version: 1.3.41(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(ioredis@5.11.1(supports-color@10.2.2))(react@19.2.7)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) + '@assistant-ui/react-lexical': + specifier: 'catalog:' + version: 0.2.0(@assistant-ui/core@0.2.21(@assistant-ui/store@0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.35(ioredis@5.11.1(supports-color@10.2.2)))(ioredis@5.11.1(supports-color@10.2.2))(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))))(@assistant-ui/react@0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))(@assistant-ui/store@0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(yjs@13.6.31) '@assistant-ui/react-markdown': specifier: 'catalog:' version: 0.14.7(@assistant-ui/react@0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2) @@ -1046,6 +1055,9 @@ importers: mcp-handler: specifier: 'catalog:' version: 1.1.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)) + mermaid: + specifier: 'catalog:' + version: 11.15.0 next: specifier: 'catalog:' version: 16.2.10(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) @@ -2965,6 +2977,19 @@ packages: '@types/react': optional: true + '@assistant-ui/react-lexical@0.2.0': + resolution: {integrity: sha512-jngZcKAiAe4T7EPtdJxrXBWvfWLtJ2lAP4EuzlLaM2nmlep2tZcsJyjyXbXiBVxkA6ROdGHH7shWxGm13gwMIA==} + peerDependencies: + '@assistant-ui/core': ^0.2.0 + '@assistant-ui/react': ^0.14.0 + '@assistant-ui/store': '*' + '@types/react': '*' + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@assistant-ui/react-markdown@0.14.7': resolution: {integrity: sha512-dbPvJIWtRuJM+r/SUjqFJi5Bz5srZt6T64kz66AvOAHovN7J7exzwOK40QApvpLyQYKa1rZMNhYi25m+aUzlSA==} peerDependencies: @@ -5691,6 +5716,81 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@lexical/clipboard@0.44.0': + resolution: {integrity: sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==} + + '@lexical/code-core@0.44.0': + resolution: {integrity: sha512-m57JyXTIvW1tsqw/Vuogk8jqWCZZIeFQbWybRc46ytR8ReDgzPRODpN8+dacIIeRH5yC5UC3lAa743mtdNkxqg==} + + '@lexical/devtools-core@0.44.0': + resolution: {integrity: sha512-X3uNG3P1vOsdzmEcy+7m9DxAcIVtVUZnvskmLqqLs6VluVVwH9xy7h1bPsvlDKvj1Nj73tWJ3TW0qXQWDTo5tw==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/dragon@0.44.0': + resolution: {integrity: sha512-RhlsjVDket9k1+YFEkDE0/7Qyrh2BI0vxBMzrWwPJTXX/4YFanYN9su8RSabkIukBBJ3QiNOOoC8FKK4Lkr4qg==} + + '@lexical/extension@0.44.0': + resolution: {integrity: sha512-BsYtoc+0EU0pqcOpf/lIUDU6LQVO6zX2AawZoUWJzT3Wzfov23qsqZWvl2WGM9dnRTN5iISJL3Fl53bQVxiXxw==} + + '@lexical/hashtag@0.44.0': + resolution: {integrity: sha512-0WATahDSqYKVTudQv3KpFbLeCpmrCpRptPFbjxOMckAX2MRpYlrExlqKfgfpri5BSQPtG49EPSGeNfSx/Faavw==} + + '@lexical/history@0.44.0': + resolution: {integrity: sha512-RGXcbFTgYL1GIWaReBI26mNSsJTfiA9EAtDY4LBeZ14NrIQhYNokKgNiOxq5Bn8xXrl2+mawQEqoMfgpWp/5YA==} + + '@lexical/html@0.44.0': + resolution: {integrity: sha512-5X6eGsgwtqPxABsuShUxF7ZfyB/U4GwSEyeonvwH1Vc/5Q2uQVjlB+FAYd+MNwWMHMh4d4+yZ3l70AtIuhr5eg==} + + '@lexical/link@0.44.0': + resolution: {integrity: sha512-uvEqEol/mLEzGVQd8Rok9I48RgYPKokM/nsclI9nYcEdccVOM2Nri4ntoRwodhbccFLtjMPl8OBldwXbfc77tQ==} + + '@lexical/list@0.44.0': + resolution: {integrity: sha512-ZTCWxDz1okPrC9FBXi1yV3W5fbQQeMUlFIcSVF9HibcVPmCsPa900IxthuiQbGiTycUyXDTOB3IUYRtlJNtpjw==} + + '@lexical/mark@0.44.0': + resolution: {integrity: sha512-bWMowllwe6BcgYMAkrsZx6Z+CX/72qCQpFKhlkR4ael92yOWSBkz68xp1wxxkSnQX9zoI1gYTeWBofVsSDKcsQ==} + + '@lexical/markdown@0.44.0': + resolution: {integrity: sha512-DwlXdp85pYMo3exDF6W3iz8plpuP+RQ4Me4Iljm7O5aPDp0SSrIoZxyX4zS668mVAoz5HHj1Ka0kQkft8mq26Q==} + + '@lexical/overflow@0.44.0': + resolution: {integrity: sha512-5GYaYjSxn27pqHRfU+tQ2STF10wgJvI+MUnwTnUFSzy3dko1b+oV94K/Yx0TuEewPbwDibfoFA8CwqUvOLHAyw==} + + '@lexical/plain-text@0.44.0': + resolution: {integrity: sha512-bIV4Lljk0x70zFhkZIwzSPK5q3m9FpDisjGm2/3Q/chb+5BW3Tv8QJmqnpCiSO6S2KXO7gfSy81ZfkQ1dcd4EQ==} + + '@lexical/react@0.44.0': + resolution: {integrity: sha512-p/NQd/fMh3pXb1XqegE2ruvWDcUmfB12OidQ9nwtMtj5VfcUjQu2I+trUhgGRIADxSYxMWmw+8PPj5YSf4m5oA==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + yjs: '>=13.5.22' + peerDependenciesMeta: + yjs: + optional: true + + '@lexical/rich-text@0.44.0': + resolution: {integrity: sha512-IIdrutK5GY47ITjPlZB7KzUi9dBDwygsyFOwolnrYSL7m6TtGhAqrYiFg/YNOTT/nBzK3KQeCJRbnxpjJAVZtQ==} + + '@lexical/selection@0.44.0': + resolution: {integrity: sha512-AEyeZJFFr5YRLeqVR+X0QAW19c4Fk4MFAQu52z2gxAyDGTj9xwVJxjfepVpfUp4P9K+sPtJ/yaqfMXH506ksSQ==} + + '@lexical/table@0.44.0': + resolution: {integrity: sha512-5Uq0O/fBCxcZp9y17fXUONY7dU9lVo/mB5JHy23laIiKzBKP5IzzTLMU9ikZTppIXbMNxYXd+R2pmy7PYTLyvw==} + + '@lexical/text@0.44.0': + resolution: {integrity: sha512-1XJD8ZbwaXljTl8k4+jjiopdhnYZm26IJw9Gv8+cIThVC0b6B3JZ/WxH97BMDcSloKvWHFkGiPztxRwNwA29Rw==} + + '@lexical/utils@0.44.0': + resolution: {integrity: sha512-/D2ptztNevfBJgtkj4uaiYBeRcvSy+1mQj6pNYaCFZIoPJIwl6H5fXwWAvpvr11vcQKP9DEEoXR+V4qkMOA+EA==} + + '@lexical/yjs@0.44.0': + resolution: {integrity: sha512-b3QTub9J/3LuwSSdooynb6GbMHBRyBT4xUbXzXqNPbDHgYe6CDrqf/uJIHRihIjAhOnPaHYqo9XUzitl++N1DQ==} + peerDependencies: + yjs: '>=13.5.22' + '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -6556,6 +6656,9 @@ packages: '@posthog/types@1.393.0': resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -11997,6 +12100,9 @@ packages: peerDependencies: ws: '*' + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + issue-parser@7.0.1: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} @@ -12210,6 +12316,14 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + lexical@0.44.0: + resolution: {integrity: sha512-ReDUjRlFgkGoPWzvdjr7s16PUVpHATN+2NH2NiZs+PLlISTaIFFgKil2P467oP3Vg+XgmpDsUgmWZsFJTztYjg==} + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -16169,6 +16283,10 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -16523,6 +16641,21 @@ snapshots: - zod - zustand + '@assistant-ui/react-lexical@0.2.0(@assistant-ui/core@0.2.21(@assistant-ui/store@0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.35(ioredis@5.11.1(supports-color@10.2.2)))(ioredis@5.11.1(supports-color@10.2.2))(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))))(@assistant-ui/react@0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))(@assistant-ui/store@0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(yjs@13.6.31)': + dependencies: + '@assistant-ui/core': 0.2.21(@assistant-ui/store@0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.35(ioredis@5.11.1(supports-color@10.2.2)))(ioredis@5.11.1(supports-color@10.2.2))(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) + '@assistant-ui/react': 0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + '@assistant-ui/store': 0.2.20(@assistant-ui/tap@0.9.4(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7) + '@lexical/react': 0.44.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(yjs@13.6.31) + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - yjs + '@assistant-ui/react-markdown@0.14.7(@assistant-ui/react@0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)': dependencies: '@assistant-ui/react': 0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -19989,6 +20122,161 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@lexical/clipboard@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + '@lexical/html': 0.44.0 + '@lexical/list': 0.44.0 + '@lexical/selection': 0.44.0 + '@lexical/utils': 0.44.0 + '@types/trusted-types': 2.0.7 + lexical: 0.44.0 + + '@lexical/code-core@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + lexical: 0.44.0 + + '@lexical/devtools-core@0.44.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@lexical/html': 0.44.0 + '@lexical/link': 0.44.0 + '@lexical/mark': 0.44.0 + '@lexical/table': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@lexical/dragon@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + lexical: 0.44.0 + + '@lexical/extension@0.44.0': + dependencies: + '@lexical/utils': 0.44.0 + '@preact/signals-core': 1.14.4 + lexical: 0.44.0 + + '@lexical/hashtag@0.44.0': + dependencies: + '@lexical/text': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/history@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/html@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + '@lexical/selection': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/link@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/list@0.44.0': + dependencies: + '@lexical/extension': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/mark@0.44.0': + dependencies: + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/markdown@0.44.0': + dependencies: + '@lexical/code-core': 0.44.0 + '@lexical/link': 0.44.0 + '@lexical/list': 0.44.0 + '@lexical/rich-text': 0.44.0 + '@lexical/text': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/overflow@0.44.0': + dependencies: + lexical: 0.44.0 + + '@lexical/plain-text@0.44.0': + dependencies: + '@lexical/clipboard': 0.44.0 + '@lexical/dragon': 0.44.0 + '@lexical/selection': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/react@0.44.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(yjs@13.6.31)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@lexical/devtools-core': 0.44.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@lexical/dragon': 0.44.0 + '@lexical/extension': 0.44.0 + '@lexical/hashtag': 0.44.0 + '@lexical/history': 0.44.0 + '@lexical/link': 0.44.0 + '@lexical/list': 0.44.0 + '@lexical/mark': 0.44.0 + '@lexical/markdown': 0.44.0 + '@lexical/overflow': 0.44.0 + '@lexical/plain-text': 0.44.0 + '@lexical/rich-text': 0.44.0 + '@lexical/table': 0.44.0 + '@lexical/text': 0.44.0 + '@lexical/utils': 0.44.0 + '@lexical/yjs': 0.44.0(yjs@13.6.31) + lexical: 0.44.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-error-boundary: 6.1.2(react@19.2.7) + optionalDependencies: + yjs: 13.6.31 + + '@lexical/rich-text@0.44.0': + dependencies: + '@lexical/clipboard': 0.44.0 + '@lexical/dragon': 0.44.0 + '@lexical/selection': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/selection@0.44.0': + dependencies: + lexical: 0.44.0 + + '@lexical/table@0.44.0': + dependencies: + '@lexical/clipboard': 0.44.0 + '@lexical/extension': 0.44.0 + '@lexical/utils': 0.44.0 + lexical: 0.44.0 + + '@lexical/text@0.44.0': + dependencies: + lexical: 0.44.0 + + '@lexical/utils@0.44.0': + dependencies: + '@lexical/selection': 0.44.0 + lexical: 0.44.0 + + '@lexical/yjs@0.44.0(yjs@13.6.31)': + dependencies: + '@lexical/selection': 0.44.0 + lexical: 0.44.0 + yjs: 13.6.31 + '@lezer/common@1.5.2': {} '@lezer/css@1.3.3': @@ -20934,6 +21222,8 @@ snapshots: '@posthog/types@1.393.0': {} + '@preact/signals-core@1.14.4': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -23456,8 +23746,7 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/unist@2.0.11': {} @@ -27036,6 +27325,8 @@ snapshots: dependencies: ws: 8.21.0 + isomorphic.js@0.2.5: {} + issue-parser@7.0.1: dependencies: lodash.capitalize: 4.2.1 @@ -27255,6 +27546,12 @@ snapshots: leven@3.1.0: {} + lexical@0.44.0: {} + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -31950,6 +32247,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yocto-queue@1.2.2: {} yoctocolors-cjs@2.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c86b568fa..632883a44 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,6 +143,7 @@ catalog: "mantine-contextmenu": ^9.3.1 mantine-datatable: ^9.3.1 mantine-react-table: npm:mantine-react-table-open@9.0.3 + mermaid: ^11.15.0 mcp-handler: ^1.1.0 maria2: ^0.4.1 mysql2: 3.22.6 @@ -193,6 +194,7 @@ catalog: zod-validation-error: ^5.0.0 "@assistant-ui/react": ^0.14.27 "@assistant-ui/react-ai-sdk": ^1.3.41 + "@assistant-ui/react-lexical": 0.2.0 "@assistant-ui/react-markdown": ^0.14.7 ai: ^7.0.37 assistant-stream: ^0.3.26