feat: improve assistant interactions

This commit is contained in:
ajnart committed 2026-08-04 13:21:39 +02:00
1 parent 1642a862d9
commit 8e557fb9fd
25 files changed
+1345 -244

No files matched your search

+14 -6
View File
@@ -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,
@@ -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
+2
View File
@@ -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:",
@@ -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" }]);
});
});
@@ -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<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 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<string, OpenRouterWebSearchSource>();
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<string, OpenRouterWebSearchSource>();
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<string, unknown>) => {
const tools = Array.isArray(body.tools) ? body.tools : [];
if (
@@ -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.",
);
});
});
@@ -0,0 +1,41 @@
const asErrorRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
const getErrorChain = (error: unknown) => {
const chain: Record<string, unknown>[] = [];
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.";
};
+24 -20
View File
@@ -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<string, string | number> = {};
let metadata: Record<string, string | number | AssistantWebSearchSource[]> = {};
const webSearchSources = new Map<string, AssistantWebSearchSource>();
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,
},
},
@@ -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");
});
});
@@ -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;
};
@@ -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" }],
}),
],
});
@@ -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<AssistantMessageMetadata>;
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<string, AssistantWebSearchSource>();
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const candidate = item as Record<string, unknown>;
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 } : {}),
};
@@ -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;
}
@@ -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 (
<span
className={classes.directiveChip}
data-directive-type={directiveType}
data-directive-id={directiveId}
title={`${typeLabel}: ${label}`}
>
<Icon size={12} aria-hidden />
<span className={classes.directiveChipLabel}>{label}</span>
</span>
);
};
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 (
<Group component="output" className={classes.mermaidStatus} gap="xs">
<Loader type="bars" size="xs" />
<Text size="xs" c="dimmed">
{t("markdown.renderingDiagram")}
</Text>
</Group>
);
}
if (rendered.failed || !rendered.svg) {
const Pre = components.Pre;
const Code = components.Code;
return (
<Box className={classes.mermaidFallback}>
<Text size="xs" c="dimmed" mb="xs">
{t("markdown.diagramUnavailable")}
</Text>
<Pre>
<Code>{code}</Code>
</Pre>
</Box>
);
}
return (
<Box
component="figure"
className={classes.mermaidDiagram}
aria-label={t("markdown.diagram")}
dangerouslySetInnerHTML={{ __html: rendered.svg }}
/>
);
};
const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={{ a: MarkdownLink, img: MarkdownImage, table: MarkdownTable }}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
);
const UserTextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const UserTextPart = () => {
const { text } = useMessagePartText();
const segments = parseAssistantDirectives(text);
return (
<span className={classes.messageText}>
{segments.map((segment, index) =>
segment.kind === "text" ? (
<span key={`${index}:${segment.text}`}>{segment.text}</span>
) : (
<ContextDirectiveChip
key={`${index}:${segment.type}:${segment.id}`}
directiveId={segment.id}
directiveType={segment.type}
label={segment.label}
/>
),
)}
</span>
);
};
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
@@ -203,21 +325,30 @@ const Attachment = ({ removable = false }: { removable?: boolean }) => {
};
const SentAttachment = () => <Attachment />;
const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => {
const ReasoningPart = ({ text }: ReasoningMessagePartProps) => (
<Text component="div" size="sm" c="dimmed" className={classes.reasoningText}>
{text}
</Text>
);
const ReasoningGroup = ({ children, running }: { children: ReactNode; running: boolean }) => {
const t = useScopedI18n("common.assistant");
return (
<details className={classes.reasoning} open={status.type === "running"}>
<details className={classes.reasoning} open={running}>
<summary>
<Group gap="xs" wrap="nowrap">
<Loader type="bars" size="xs" color="gray" style={{ opacity: status.type === "running" ? 1 : 0 }} />
<Text size="xs" fw={600} c="dimmed">
{t("reasoning")}
</Text>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
{running ? <Loader type="bars" size="xs" color="gray" /> : <IconBrain size={14} />}
<Text size="xs" fw={600} c="dimmed">
{t("reasoning")}
</Text>
</Group>
<IconChevronDown size={14} className={classes.reasoningChevron} />
</Group>
</summary>
<Text size="sm" c="dimmed" className={classes.reasoningText}>
{text}
</Text>
<Box className={classes.reasoningContent} aria-busy={running}>
{children}
</Box>
</details>
);
};
@@ -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 (
<Box className={`${classes.tool} ${classes.webSearchActivity}`}>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon size="sm" radius="xl" variant="light" color="blue">
<IconSearch size={13} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{t("webSearch.title")}
</Text>
<Text size="xs" c="dimmed">
{t("webSearch.completed")}
</Text>
</div>
</Group>
<Group gap={4} wrap="wrap" justify="flex-end">
{telemetry.webSearchRequests !== undefined && (
<Badge size="xs" variant="light" color="blue">
{t("webSearch.searches", { count: telemetry.webSearchRequests })}
</Badge>
)}
{sources.length > 0 && (
<Badge size="xs" variant="light" color="gray">
{t("webSearch.sources", { count: sources.length })}
</Badge>
)}
</Group>
</Group>
{sources.length > 0 && (
<Group className={classes.webSearchSources} gap="xs" wrap="wrap">
{sources.map((source) => (
<Anchor
key={source.url}
className={classes.webSearchSource}
href={source.url}
target="_blank"
rel="noreferrer"
size="xs"
title={source.title ?? source.url}
>
<IconLink size={13} />
<Text component="span" inherit lineClamp={1}>
{source.title ?? new URL(source.url).hostname}
</Text>
</Anchor>
))}
</Group>
)}
</Box>
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
components={{
Text: AssistantTextPart,
Reasoning: ReasoningPart,
Source: SourcePart,
File: FilePart,
Image: ImagePart,
tools: { Fallback: ToolPart },
<MessagePrimitive.GroupedParts groupBy={groupPartByType({ reasoning: ["group-reasoning"] })}>
{({ part, children }) => {
switch (part.type) {
case "group-reasoning":
return <ReasoningGroup running={part.status.type === "running"}>{children}</ReasoningGroup>;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "indicator":
return null;
default:
return null;
}
}}
/>
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<RequestTelemetry />
<AssistantMessageActions />
</MessagePrimitive.Root>
@@ -1543,14 +1750,6 @@ const EmptyThread = () => {
);
};
const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tools: IconTool,
};
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
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<string, AssistantRuntimeModelOption[]>());
const modelCombobox = useCombobox({
onDropdownOpen: () => {
modelCombobox.selectActiveOption();
@@ -1765,40 +1981,45 @@ const RuntimeControls = ({
};
return (
<Group className={classes.runtimeControls} gap={0} wrap="nowrap">
<Button.Group
className={classes.runtimeButtonGroup}
aria-label={`${t("runtime.model")}, ${t("runtime.thinking")}`}
>
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={320}>
<Combobox.Target>
<Button
className={classes.modelButton}
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
variant="default"
size="compact-sm"
disabled={modelOptionsLoading || models.length === 0}
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
rightSection={<Combobox.Chevron size="xs" />}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}`}
title={selectedModel?.name}
>
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={360}>
<Combobox.Target>
<UnstyledButton
className={classes.runtimeSelectorTrigger}
type="button"
disabled={modelOptionsLoading || models.length === 0}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
aria-expanded={modelCombobox.dropdownOpened}
>
<Group gap="xs" wrap="nowrap">
<ThemeIcon className={classes.runtimeSelectorIcon} size="sm" variant="light" color="gray">
{modelOptionsLoading ? <Loader size={12} /> : <IconSparkles size={13} />}
</ThemeIcon>
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
{selectedModel?.name ?? t("runtime.model")}
</Button>
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.model")}
aria-label={t("runtime.model")}
/>
<Combobox.Options className={classes.modelOptions}>
{visibleModels.map((model) => (
</Text>
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
<IconBrain size={11} />
{t(`runtime.reasoning.${reasoning}`)}
</Badge>
<Combobox.Chevron size="xs" />
</Group>
</UnstyledButton>
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
/>
<Combobox.Options className={classes.modelOptions}>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
<Combobox.Group key={provider} label={provider}>
{providerModels.map((model) => (
<Combobox.Option
className={classes.modelOption}
key={model.id}
@@ -1806,54 +2027,63 @@ const RuntimeControls = ({
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<Stack gap={0} className={classes.modelOptionText}>
<ThemeIcon size="md" radius="md" variant="light" color="gray">
<IconSparkles size={14} />
</ThemeIcon>
<Stack gap={1} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{model.id}
{model.description?.trim() || model.id}
</Text>
</Stack>
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
<Group className={classes.modelOptionMeta} gap={4} wrap="nowrap">
{model.inputModalities.includes("image") && (
<Tooltip label={t("runtime.imageInput")}>
<IconPhoto size={14} aria-label={t("runtime.imageInput")} />
</Tooltip>
)}
{model.contextLength && (
<Badge size="xs" variant="light" color="gray">
{formatCompactModelNumber(model.contextLength)}
</Badge>
)}
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
</Group>
</Group>
</Combobox.Option>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Menu position="top-end" width={210} withinPortal>
<Menu.Target>
<Button
className={classes.reasoningButton}
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
variant="default"
size="compact-sm"
leftSection={<IconBrain size={14} />}
rightSection={<IconChevronUp size={14} />}
aria-label={`${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
title={t(`runtime.reasoning.${reasoning}`)}
>
{t(`runtime.reasoning.${reasoning}`)}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("runtime.thinking")}</Menu.Label>
{assistantReasoningModes.map((mode) => (
<Menu.Item
key={mode}
className={classes.runtimeMenuItem}
data-selected={mode === reasoning || undefined}
rightSection={mode === reasoning ? <IconCheck size={15} /> : null}
onClick={() => onReasoningChange(mode)}
>
{t(`runtime.reasoning.${mode}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Button.Group>
</Group>
</Combobox.Group>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
<Divider />
<Box className={classes.reasoningSelector}>
<Group gap="xs" mb={6} wrap="nowrap">
<IconBrain size={14} />
<Text size="xs" fw={650}>
{t("runtime.thinking")}
</Text>
</Group>
<SegmentedControl
className={classes.reasoningSegmentedControl}
value={reasoning}
onChange={(value) => {
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
size="xs"
fullWidth
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
/>
</Box>
</Combobox.Dropdown>
</Combobox>
);
};
@@ -1893,10 +2123,12 @@ const Composer = (props: ComposerProps) => {
</ComposerPrimitive.AddAttachment>
</Tooltip>
</Group>
<ComposerPrimitive.Input
<LexicalComposerInput
className={classes.composerInput}
placeholder={t("composerPlaceholder")}
rows={1}
directiveChip={ContextDirectiveChip}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
autoFocus
data-autofocus
/>
{running ? (
@@ -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", () => {
@@ -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[];
};
@@ -186,7 +186,16 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
reasoning: "auto",
});
const models = useMemo<AssistantRuntimeModelOption[]>(
() => 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],
);
@@ -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.",
},
]);
});
});
+20 -5
View File
@@ -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.",
},
]
: [];
});
}),
});
@@ -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);
});
});
+10 -7
View File
@@ -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);
},
});
+15
View File
@@ -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.",
+1 -1
View File
@@ -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,
+303 -2
View File
@@ -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: {}
+2
View File
@@ -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