feat: improve assistant interactions
This commit is contained in:
1 parent
1642a862d9
commit
8e557fb9fd
25 files changed
+1345
-244
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
@@ -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.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+303
-2
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user