feat: polish assistant conversations
This commit is contained in:
1 parent
1ad1439828
commit
f991dc6334
14 files changed
+509
-120
No files matched your search
@@ -131,18 +131,24 @@ 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. 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 `/`
|
||||
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the
|
||||
assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the
|
||||
stored app or integration icon when one is available. 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.
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
|
||||
streams it. The **Thinking** disclosure can be opened or closed without interrupting the response.
|
||||
|
||||
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,
|
||||
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
|
||||
is labeled as reported.
|
||||
While the next response is still streaming, the meter keeps the last completed reading for the same model and
|
||||
context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage
|
||||
arrives; incompatible model capacities are never mixed.
|
||||
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
|
||||
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
|
||||
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("assistant chat input", () => {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Check :integration[integration-1]{name=Media server} on :board[board-1]",
|
||||
text: "Check :integration[Media server]{name=integration-1} on :board[Home]{name=board-1}",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ export type AssistantMentionReference = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=[^}\n]{1,1024}\})?/gu;
|
||||
const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/gu;
|
||||
|
||||
const getMessageText = (message: { parts: unknown[] }) =>
|
||||
message.parts
|
||||
@@ -20,7 +20,9 @@ export const getRequestedMentionIds = (messages: { role: "user" | "assistant"; p
|
||||
if (message.role !== "user") continue;
|
||||
for (const match of getMessageText(message).matchAll(mentionPattern)) {
|
||||
const type = match[1] as AssistantMentionReference["type"];
|
||||
const id = match[2];
|
||||
// assistant-ui stores the display label between brackets and the stable entity ID in `name`.
|
||||
// Keep the bracket value as a fallback for older messages written before named directives.
|
||||
const id = match[3] ?? match[2];
|
||||
if (id) mentions.set(`${type}:${id}`, { type, id });
|
||||
if (mentions.size >= 30) return [...mentions.values()];
|
||||
}
|
||||
|
||||
@@ -646,6 +646,7 @@ export async function POST(request: Request) {
|
||||
|
||||
return result.toUIMessageStreamResponse<UIMessage<AssistantMessageMetadata>>({
|
||||
originalMessages: parsed.data.messages as UIMessage<AssistantMessageMetadata>[],
|
||||
sendReasoning: true,
|
||||
messageMetadata: ({ part }) => {
|
||||
const common = {
|
||||
requestId,
|
||||
@@ -682,7 +683,11 @@ export async function POST(request: Request) {
|
||||
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
|
||||
? latestStep.inputTokens + latestStep.outputTokens
|
||||
: undefined;
|
||||
const contextUsed = latestContextUsed ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
const contextUsed =
|
||||
latestContextUsed ??
|
||||
(usage.inputTokens !== undefined || usage.outputTokens !== undefined
|
||||
? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
: undefined);
|
||||
const promptPrice = asFiniteNumber(selectedModel?.promptPrice);
|
||||
const completionPrice = asFiniteNumber(selectedModel?.completionPrice);
|
||||
const estimatedCost =
|
||||
@@ -721,8 +726,8 @@ export async function POST(request: Request) {
|
||||
...(outputTokensPerSecond !== undefined ? { outputTokensPerSecond } : {}),
|
||||
...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}),
|
||||
...(providerGenerationTimeMs !== undefined ? { generationTimeMs: providerGenerationTimeMs } : {}),
|
||||
...(contextUsed > 0 ? { contextUsed } : {}),
|
||||
...(selectedModel?.contextLength && contextUsed > 0
|
||||
...(contextUsed !== undefined && contextUsed > 0 ? { contextUsed } : {}),
|
||||
...(selectedModel?.contextLength && contextUsed !== undefined && contextUsed > 0
|
||||
? { contextUtilization: Math.min(contextUsed / selectedModel.contextLength, 1) }
|
||||
: {}),
|
||||
...(cost !== undefined ? { cost, costType: hasReportedCost ? "reported" : "estimated" } : {}),
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react";
|
||||
import { Alert, Badge, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
@@ -170,36 +182,45 @@ export const AssistantAskUserTool = ({
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
return (
|
||||
<Button
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
variant={kind === "alternative" ? "default" : "light"}
|
||||
color={color}
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
leftSection={icon}
|
||||
rightSection={
|
||||
<Badge variant="light" color={color} size="xs">
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className={classes.humanToolOptionText} ta="start">
|
||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
|
||||
<Text
|
||||
component="span"
|
||||
display="block"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
className={classes.humanToolOptionDescription}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Button>
|
||||
<Badge
|
||||
className={classes.humanToolOptionBadge}
|
||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||
variant="light"
|
||||
color={color}
|
||||
size="xs"
|
||||
>
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveAssistantDirectiveEntity, transformAssistantMarkdownDirectives } from "./assistant-markdown-directives";
|
||||
|
||||
describe("assistant markdown directives", () => {
|
||||
test("turns assistant item citations into typed inline elements", () => {
|
||||
const directive: {
|
||||
type: string;
|
||||
name: string;
|
||||
attributes: { name: string };
|
||||
children: { type: string; value: string }[];
|
||||
data?: Record<string, unknown>;
|
||||
} = {
|
||||
type: "textDirective",
|
||||
name: "app",
|
||||
attributes: { name: "app-1" },
|
||||
children: [{ type: "text", value: "Discord" }],
|
||||
};
|
||||
transformAssistantMarkdownDirectives({ type: "root", children: [directive] });
|
||||
|
||||
expect(directive.data).toEqual({
|
||||
hName: "span",
|
||||
hProperties: {
|
||||
"data-assistant-directive": "true",
|
||||
"data-directive-id": "app-1",
|
||||
"data-directive-label": "Discord",
|
||||
"data-directive-type": "app",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves shorthand citations only when their label is unambiguous", () => {
|
||||
const entities = [
|
||||
{ id: "app-1", type: "app" as const, label: "Discord", description: "Chat", iconUrl: "/discord.svg" },
|
||||
{ id: "board-1", type: "board" as const, label: "Home", description: "Home board" },
|
||||
];
|
||||
|
||||
expect(resolveAssistantDirectiveEntity(entities, { id: "Discord", label: "Discord", type: "app" })?.id).toBe(
|
||||
"app-1",
|
||||
);
|
||||
expect(resolveAssistantDirectiveEntity(entities, { id: "Home", label: "Home", type: "board" })?.id).toBe("board-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
export type AssistantDirectiveType = "app" | "integration" | "board" | "widget";
|
||||
|
||||
export type AssistantDirectiveEntity = {
|
||||
id: string;
|
||||
type: AssistantDirectiveType;
|
||||
label: string;
|
||||
description: string;
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
type MarkdownNode = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
attributes?: Record<string, string | null | undefined> | null;
|
||||
children?: MarkdownNode[];
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const directiveTypes = new Set<AssistantDirectiveType>(["app", "integration", "board", "widget"]);
|
||||
|
||||
const getNodeText = (node: MarkdownNode): string =>
|
||||
typeof node.value === "string" ? node.value : (node.children ?? []).map(getNodeText).join("");
|
||||
|
||||
export const transformAssistantMarkdownDirectives = (tree: MarkdownNode) => {
|
||||
const visit = (node: MarkdownNode) => {
|
||||
if (node.type === "textDirective" && node.name && directiveTypes.has(node.name as AssistantDirectiveType)) {
|
||||
const label = getNodeText(node).trim();
|
||||
const directiveId = node.attributes?.name?.trim() || label;
|
||||
if (label && directiveId) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
hName: "span",
|
||||
hProperties: {
|
||||
"data-assistant-directive": "true",
|
||||
"data-directive-id": directiveId,
|
||||
"data-directive-label": label,
|
||||
"data-directive-type": node.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) visit(child);
|
||||
};
|
||||
|
||||
visit(tree);
|
||||
};
|
||||
|
||||
export const remarkAssistantDirectives = () => transformAssistantMarkdownDirectives;
|
||||
|
||||
export const resolveAssistantDirectiveEntity = (
|
||||
entities: AssistantDirectiveEntity[],
|
||||
directive: { id: string; label: string; type: string },
|
||||
) => {
|
||||
if (!directiveTypes.has(directive.type as AssistantDirectiveType)) return undefined;
|
||||
const matchingType = entities.filter((entity) => entity.type === directive.type);
|
||||
const exactId = matchingType.find((entity) => entity.id === directive.id);
|
||||
if (exactId) return exactId;
|
||||
|
||||
const normalizedLabel = directive.label.trim().toLocaleLowerCase();
|
||||
const labelMatches = matchingType.filter((entity) => entity.label.trim().toLocaleLowerCase() === normalizedLabel);
|
||||
return labelMatches.length === 1 ? labelMatches[0] : undefined;
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
import {
|
||||
getAssistantTelemetry,
|
||||
getAssistantUsage,
|
||||
resolveAssistantContextWindowTelemetry,
|
||||
} from "./assistant-message-metadata";
|
||||
|
||||
describe("getAssistantTelemetry", () => {
|
||||
test("extracts persisted request telemetry", () => {
|
||||
@@ -117,3 +121,53 @@ describe("getAssistantTelemetry", () => {
|
||||
).toEqual({ inputTokens: 120, cachedInputTokens: 24 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssistantContextWindowTelemetry", () => {
|
||||
const current = {
|
||||
requestId: "current",
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "2026-08-04T10:00:00.000Z",
|
||||
contextLength: 128_000,
|
||||
steps: [],
|
||||
};
|
||||
const previousMetadata = {
|
||||
custom: {
|
||||
telemetry: {
|
||||
...current,
|
||||
requestId: "previous",
|
||||
completedAt: "2026-08-04T09:59:00.000Z",
|
||||
contextUsed: 12_500,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("keeps the previous completed reading while a matching request streams", () => {
|
||||
expect(resolveAssistantContextWindowTelemetry(current, [previousMetadata])).toMatchObject({
|
||||
source: "previous",
|
||||
telemetry: { contextUsed: 12_500 },
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers a current completed reading", () => {
|
||||
expect(
|
||||
resolveAssistantContextWindowTelemetry({ ...current, contextUsed: 13_000 }, [previousMetadata]),
|
||||
).toMatchObject({ source: "current", telemetry: { contextUsed: 13_000 } });
|
||||
});
|
||||
|
||||
test("does not mix readings from a model with another context capacity", () => {
|
||||
expect(
|
||||
resolveAssistantContextWindowTelemetry(current, [
|
||||
{
|
||||
custom: {
|
||||
telemetry: {
|
||||
...previousMetadata.custom.telemetry,
|
||||
modelId: "another/model",
|
||||
contextLength: 1_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,11 @@ export type AssistantMessageMetadata = {
|
||||
|
||||
export type AssistantUIMessage = UIMessage<AssistantMessageMetadata>;
|
||||
|
||||
export type AssistantContextWindowTelemetry = {
|
||||
telemetry: AssistantRequestTelemetry;
|
||||
source: "current" | "previous";
|
||||
};
|
||||
|
||||
const getFiniteNonNegativeNumber = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
|
||||
@@ -300,3 +305,26 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
|
||||
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAssistantContextWindowTelemetry = (
|
||||
current: AssistantRequestTelemetry,
|
||||
previousMetadata: unknown[],
|
||||
): AssistantContextWindowTelemetry | null => {
|
||||
if (current.contextLength !== undefined && current.contextUsed !== undefined) {
|
||||
return { telemetry: current, source: "current" };
|
||||
}
|
||||
|
||||
for (const metadata of previousMetadata.toReversed()) {
|
||||
const previous = getAssistantTelemetry(metadata);
|
||||
if (
|
||||
previous?.completedAt &&
|
||||
previous.requestId !== current.requestId &&
|
||||
previous.modelId === current.modelId &&
|
||||
previous.contextLength === current.contextLength &&
|
||||
previous.contextUsed !== undefined
|
||||
) {
|
||||
return { telemetry: previous, source: "previous" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -48,6 +48,7 @@
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-yellow-light-color);
|
||||
background: var(--mantine-color-yellow-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activityDismiss {
|
||||
@@ -213,8 +214,8 @@
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
min-height: 2.625rem;
|
||||
padding: 0.6rem 0.5rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.runtimeSelectorTrigger {
|
||||
@@ -390,6 +391,13 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.directiveChipImage {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.messageMarkdown {
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -670,16 +678,65 @@
|
||||
}
|
||||
|
||||
.humanToolOption {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3.25rem;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.8rem;
|
||||
white-space: normal;
|
||||
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.humanToolOption :global(.mantine-Button-inner),
|
||||
.humanToolOption :global(.mantine-Button-label) {
|
||||
width: 100%;
|
||||
.humanToolOption:hover:not(:disabled) {
|
||||
border-color: var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.humanToolOption:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.humanToolOption:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.humanToolOption[data-kind="affirmative"] {
|
||||
border-color: color-mix(in srgb, var(--mantine-color-green-filled) 35%, var(--mantine-color-default-border));
|
||||
}
|
||||
|
||||
.humanToolOption[data-kind="negative"] {
|
||||
border-color: color-mix(in srgb, var(--mantine-color-red-filled) 30%, var(--mantine-color-default-border));
|
||||
}
|
||||
|
||||
.humanToolOptionIcon,
|
||||
.humanToolOptionBadge {
|
||||
width: max-content;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.humanToolOptionText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.humanToolOptionBadgeLabel {
|
||||
width: max-content;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.humanToolOptionLabel,
|
||||
.humanToolOptionDescription {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.humanToolOtherInput {
|
||||
@@ -797,20 +854,25 @@
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.reasoning summary {
|
||||
.reasoningToggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reasoning summary::-webkit-details-marker {
|
||||
display: none;
|
||||
.reasoningToggle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.reasoningChevron {
|
||||
transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.reasoning[open] .reasoningChevron {
|
||||
.reasoning[data-opened] .reasoningChevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@@ -1091,27 +1153,35 @@
|
||||
|
||||
.composerInput {
|
||||
position: relative;
|
||||
min-height: 2.5rem;
|
||||
min-height: 2.25rem;
|
||||
max-height: 9rem;
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.65rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.35rem 0.45rem;
|
||||
color: var(--mantine-color-text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.composerInput :global(.aui-lexical-input) {
|
||||
min-height: 1.5rem;
|
||||
line-height: 1.5;
|
||||
outline: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.composerInput :global(.aui-lexical-input > p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.composerInput :global(.aui-lexical-placeholder) {
|
||||
position: absolute;
|
||||
inset: 0.55rem 0.65rem auto;
|
||||
inset: 0.35rem 0.45rem auto;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1128,7 +1198,7 @@
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
max-width: min(24rem, 100%);
|
||||
padding: 0.3rem 0.45rem;
|
||||
padding: 0.22rem 0.4rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-text);
|
||||
@@ -1149,10 +1219,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtimeSelectorIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.runtimeSelectorName {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -1165,7 +1231,6 @@
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -1180,7 +1245,16 @@
|
||||
}
|
||||
|
||||
.modelOption {
|
||||
padding-block: 0.45rem;
|
||||
margin-inline: 0.25rem;
|
||||
padding: 0.32rem 0.45rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.modelOption[data-combobox-selected],
|
||||
.modelOption[data-combobox-active] {
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-default-hover);
|
||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.modelOptionText {
|
||||
@@ -1193,13 +1267,17 @@
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.modelOptionDescription {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3));
|
||||
}
|
||||
|
||||
.runtimeOptionCheck {
|
||||
flex: 0 0 auto;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.reasoningSelector {
|
||||
padding: 0.55rem;
|
||||
padding: 0.4rem 0.45rem 0.45rem;
|
||||
}
|
||||
|
||||
.reasoningSegmentedControl :global(.mantine-SegmentedControl-label) {
|
||||
@@ -1342,8 +1420,8 @@
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
min-height: 2.625rem;
|
||||
padding: 0.6rem 0.55rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
@@ -1369,10 +1447,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 30em) {
|
||||
.activityReviewAction > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
IconArrowUp,
|
||||
IconArrowsMaximize,
|
||||
IconAt,
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
@@ -92,7 +91,6 @@ import {
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconSparkles,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
@@ -100,6 +98,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkDirective from "remark-directive";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -110,9 +109,15 @@ 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 { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
import { normalizeAssistantMarkdown } from "./assistant-markdown";
|
||||
import { getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
import {
|
||||
getAssistantTelemetry,
|
||||
getAssistantUsage,
|
||||
resolveAssistantContextWindowTelemetry,
|
||||
} from "./assistant-message-metadata";
|
||||
import type { AssistantPendingAction } from "./assistant-pending-action";
|
||||
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
@@ -144,7 +149,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
}
|
||||
|
||||
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
|
||||
const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks];
|
||||
|
||||
const contextIcons = {
|
||||
app: IconApps,
|
||||
@@ -155,9 +160,15 @@ const contextIcons = {
|
||||
tools: IconTool,
|
||||
};
|
||||
|
||||
const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveChipProps) => {
|
||||
const ContextDirectiveChip = ({
|
||||
directiveId,
|
||||
directiveType,
|
||||
label,
|
||||
iconUrl,
|
||||
}: DirectiveChipProps & { iconUrl?: string }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
|
||||
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
|
||||
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
|
||||
const typeLabel = t(`mentions.${translationKey}`);
|
||||
|
||||
@@ -168,7 +179,11 @@ const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveCh
|
||||
data-directive-id={directiveId}
|
||||
title={`${typeLabel}: ${label}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{safeIconUrl ? (
|
||||
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<Icon size={12} aria-hidden />
|
||||
)}
|
||||
<span className={classes.directiveChipLabel}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -273,34 +288,78 @@ const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantTextPart = () => (
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={{ a: MarkdownLink, img: MarkdownImage, table: MarkdownTable }}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
);
|
||||
type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
|
||||
"data-assistant-directive"?: string;
|
||||
"data-directive-id"?: string;
|
||||
"data-directive-label"?: string;
|
||||
"data-directive-type"?: string;
|
||||
};
|
||||
|
||||
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntity[]>([]);
|
||||
|
||||
const AssistantMarkdownSpan = ({
|
||||
children,
|
||||
"data-assistant-directive": assistantDirective,
|
||||
"data-directive-id": directiveId,
|
||||
"data-directive-label": directiveLabel,
|
||||
"data-directive-type": directiveType,
|
||||
...props
|
||||
}: MarkdownSpanProps) => {
|
||||
const entities = useContext(AssistantDirectiveEntitiesContext);
|
||||
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
|
||||
const entity = resolveAssistantDirectiveEntity(entities, {
|
||||
id: directiveId,
|
||||
label: directiveLabel,
|
||||
type: directiveType,
|
||||
});
|
||||
return (
|
||||
<ContextDirectiveChip
|
||||
directiveId={entity?.id ?? directiveId}
|
||||
directiveType={directiveType}
|
||||
label={entity?.label ?? directiveLabel}
|
||||
iconUrl={entity?.iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span {...props}>{children}</span>;
|
||||
};
|
||||
|
||||
const AssistantTextPart = () => {
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={entities}>
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={{ a: MarkdownLink, img: MarkdownImage, span: AssistantMarkdownSpan, table: MarkdownTable }}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTextPart = () => {
|
||||
const { text } = useMessagePartText();
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
const segments = parseAssistantDirectives(text);
|
||||
return (
|
||||
<span className={classes.messageText}>
|
||||
{segments.map((segment, index) =>
|
||||
segment.kind === "text" ? (
|
||||
<span key={`${index}:${segment.text}`}>{segment.text}</span>
|
||||
) : (
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
|
||||
const entity = resolveAssistantDirectiveEntity(entities, segment);
|
||||
return (
|
||||
<ContextDirectiveChip
|
||||
key={`${index}:${segment.type}:${segment.id}`}
|
||||
directiveId={segment.id}
|
||||
directiveId={entity?.id ?? segment.id}
|
||||
directiveType={segment.type}
|
||||
label={segment.label}
|
||||
label={entity?.label ?? segment.label}
|
||||
iconUrl={entity?.iconUrl}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -333,23 +392,40 @@ const ReasoningPart = ({ text }: ReasoningMessagePartProps) => (
|
||||
|
||||
const ReasoningGroup = ({ children, running }: { children: ReactNode; running: boolean }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const contentId = useId();
|
||||
const [opened, setOpened] = useState(running);
|
||||
const [manuallyToggled, setManuallyToggled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (running && !manuallyToggled) setOpened(true);
|
||||
}, [manuallyToggled, running]);
|
||||
|
||||
return (
|
||||
<details className={classes.reasoning} open={running}>
|
||||
<summary>
|
||||
<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} />
|
||||
<Box className={classes.reasoning} data-opened={opened || undefined}>
|
||||
<UnstyledButton
|
||||
className={classes.reasoningToggle}
|
||||
type="button"
|
||||
aria-expanded={opened}
|
||||
aria-controls={contentId}
|
||||
onClick={() => {
|
||||
setManuallyToggled(true);
|
||||
setOpened((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{running && <Loader type="bars" size="xs" color="gray" />}
|
||||
<Text size="xs" fw={650} c="dimmed">
|
||||
{t("reasoning")}
|
||||
</Text>
|
||||
</Group>
|
||||
</summary>
|
||||
<Box className={classes.reasoningContent} aria-busy={running}>
|
||||
{children}
|
||||
</Box>
|
||||
</details>
|
||||
<IconChevronDown size={14} className={classes.reasoningChevron} />
|
||||
</UnstyledButton>
|
||||
<Collapse expanded={opened}>
|
||||
<Box id={contentId} className={classes.reasoningContent} aria-busy={running}>
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -847,6 +923,7 @@ const RequestTelemetry = () => {
|
||||
const metadata = useAuiState((state) => state.message.metadata);
|
||||
const messageId = useAuiState((state) => state.message.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata));
|
||||
const persistedTelemetry = getAssistantTelemetry(metadata);
|
||||
const persistedUsage = getAssistantUsage(metadata);
|
||||
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
|
||||
@@ -941,13 +1018,18 @@ const RequestTelemetry = () => {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const contextLength = telemetry.contextLength ?? 0;
|
||||
const contextUsed = telemetry.contextUsed ?? 0;
|
||||
const hasContextWindow = telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined;
|
||||
const contextWindow = resolveAssistantContextWindowTelemetry(telemetry, threadMessageMetadata);
|
||||
const contextTelemetry = contextWindow?.telemetry;
|
||||
const contextLength = contextTelemetry?.contextLength ?? 0;
|
||||
const contextUsed = contextTelemetry?.contextUsed ?? 0;
|
||||
const hasContextWindow = contextTelemetry?.contextLength !== undefined && contextTelemetry.contextUsed !== undefined;
|
||||
const contextPercentage = hasContextWindow
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(0, Math.round((telemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100)),
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((contextTelemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const contextRemaining = hasContextWindow ? Math.max(contextLength - contextUsed, 0) : undefined;
|
||||
@@ -1054,7 +1136,7 @@ const RequestTelemetry = () => {
|
||||
{t("usage.used")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")}
|
||||
{contextTelemetry?.contextUsed?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1070,7 +1152,7 @@ const RequestTelemetry = () => {
|
||||
{t("usage.capacity")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
|
||||
{contextTelemetry?.contextLength?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
@@ -1929,7 +2011,11 @@ type ComposerProps = AssistantConversationControls & { pendingAction: AssistantP
|
||||
const getModelProviderLabel = (modelId: string, fallback: string) => {
|
||||
const separator = modelId.indexOf("/");
|
||||
if (separator <= 0) return fallback;
|
||||
const provider = modelId.slice(0, separator).replaceAll(/[-_]/gu, " ");
|
||||
const provider = modelId
|
||||
.slice(0, separator)
|
||||
.replace(/^[^\p{L}\p{N}]+/u, "")
|
||||
.replaceAll(/[-_]/gu, " ");
|
||||
if (!provider) return fallback;
|
||||
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
|
||||
};
|
||||
|
||||
@@ -1981,7 +2067,7 @@ const RuntimeControls = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={360}>
|
||||
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
|
||||
<Combobox.Target>
|
||||
<UnstyledButton
|
||||
className={classes.runtimeSelectorTrigger}
|
||||
@@ -1992,14 +2078,11 @@ const RuntimeControls = ({
|
||||
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>
|
||||
{modelOptionsLoading && <Loader size={12} />}
|
||||
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
|
||||
{selectedModel?.name ?? t("runtime.model")}
|
||||
</Text>
|
||||
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
|
||||
<IconBrain size={11} />
|
||||
{t(`runtime.reasoning.${reasoning}`)}
|
||||
</Badge>
|
||||
<Combobox.Chevron size="xs" />
|
||||
@@ -2015,6 +2098,7 @@ const RuntimeControls = ({
|
||||
}}
|
||||
placeholder={t("runtime.searchModels")}
|
||||
aria-label={t("runtime.searchModels")}
|
||||
size="xs"
|
||||
/>
|
||||
<Combobox.Options className={classes.modelOptions}>
|
||||
{[...groupedModels.entries()].map(([provider, providerModels]) => (
|
||||
@@ -2027,14 +2111,11 @@ const RuntimeControls = ({
|
||||
active={model.id === modelId}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<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}>
|
||||
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
|
||||
{model.description?.trim() || model.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -2060,12 +2141,9 @@ const RuntimeControls = ({
|
||||
</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>
|
||||
<Text size="xs" fw={650} mb={5}>
|
||||
{t("runtime.thinking")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
className={classes.reasoningSegmentedControl}
|
||||
value={reasoning}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
assistantProviderIds,
|
||||
assistantProviderPresets,
|
||||
assistantProviderRequiresApiKey,
|
||||
getIconUrl,
|
||||
resolveAssistantModelId,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
@@ -71,6 +72,7 @@ export type AssistantContextEntity = {
|
||||
type: "app" | "integration" | "board" | "widget";
|
||||
label: string;
|
||||
description: string;
|
||||
iconUrl?: string;
|
||||
boardId?: string;
|
||||
};
|
||||
|
||||
@@ -340,7 +342,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
limit: 250,
|
||||
}),
|
||||
ctx.db.query.apps.findMany({
|
||||
columns: { id: true, name: true, description: true },
|
||||
columns: { id: true, name: true, description: true, iconUrl: true },
|
||||
orderBy: asc(apps.name),
|
||||
limit: 250,
|
||||
}),
|
||||
@@ -366,6 +368,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
type: "app",
|
||||
label: app.name,
|
||||
description: app.description ?? "Homarr app",
|
||||
iconUrl: app.iconUrl,
|
||||
}),
|
||||
),
|
||||
...availableIntegrations
|
||||
@@ -376,6 +379,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
type: "integration",
|
||||
label: integration.name,
|
||||
description: `${integration.kind} integration`,
|
||||
iconUrl: getIconUrl(integration.kind),
|
||||
}),
|
||||
),
|
||||
...availableBoards.map(
|
||||
|
||||
@@ -271,10 +271,20 @@ describe("assistant conversation features", () => {
|
||||
|
||||
expect(entities).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "app-1", type: "app", label: "Plex" }),
|
||||
expect.objectContaining({
|
||||
id: "app-1",
|
||||
type: "app",
|
||||
label: "Plex",
|
||||
iconUrl: "https://example.com/plex.svg",
|
||||
}),
|
||||
expect.objectContaining({ id: "board-1", type: "board", label: "Home" }),
|
||||
expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }),
|
||||
expect.objectContaining({ id: "integration-1", type: "integration", label: "Media server" }),
|
||||
expect.objectContaining({
|
||||
id: "integration-1",
|
||||
type: "integration",
|
||||
label: "Media server",
|
||||
iconUrl: expect.stringContaining("plex"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,6 +224,7 @@ catalog:
|
||||
assistant-stream: ^0.3.26
|
||||
"@ai-sdk/openai-compatible": 3.0.14
|
||||
remark-breaks: ^4.0.0
|
||||
remark-directive: ^3.0.1
|
||||
remark-gfm: ^4.0.1
|
||||
|
||||
catalogMode: strict
|
||||
|
||||
Reference in new issue
Block a user