From df874dd2db8a0ae19cd42a9353125dcc19f2e17d Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 16:56:43 +0200 Subject: [PATCH] feat: render assistant content --- apps/docs/docs/management/assistant.mdx | 7 +- .../src/app/api/assistant/chat/route.ts | 1 + .../assistant/assistant-markdown.spec.ts | 44 ++++ .../assistant/assistant-markdown.ts | 41 ++++ .../assistant/assistant-panel.module.css | 190 ++++++++++++++++++ .../components/assistant/assistant-panel.tsx | 121 ++++++++++- .../assistant/assistant-tool-result.spec.ts | 93 +++++++++ .../assistant/assistant-tool-result.ts | 166 +++++++++++++++ pnpm-workspace.yaml | 3 + 9 files changed, 662 insertions(+), 4 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-markdown.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-markdown.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-tool-result.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-tool-result.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 875d1fb07..29d030b8c 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -73,6 +73,9 @@ Messages support copy, edit-and-resend, response regeneration and branch navigat helpful/not-helpful feedback, text-to-speech, and browser speech dictation when the browser supports it. Selecting text in a message exposes a quote action that carries the selected passage into the next prompt. 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. Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by using the paperclip or dropping files onto the composer. A request can contain up to five attachments. Image understanding depends on the selected model; document text is extracted locally by Homarr and sent @@ -94,7 +97,9 @@ The assistant can use the same internal tool definitions as the [Homarr MCP serv Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only procedures can run during the agent loop. Mutating procedures require an explicit approval in the conversation before they execute. Tool cards expose their status, timing, validated input, result, and -error state without flooding the conversation with raw JSON by default. +error state without flooding the conversation with raw JSON by default. Common collection and widget +results are summarized inline as compact responsive cards, while the full structured result remains +available in the tool card details. Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to safe internal routes and open Homarr interfaces such as the command menu and media request search. diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index dce7a4379..67ac03e61 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -90,6 +90,7 @@ Action rules: - Do not retry a denied action. - Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL. - Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON. +- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists. - If configuration or a service is unavailable, say what the user or administrator can do next.`; const asRecord = (value: unknown): Record | null => diff --git a/apps/nextjs/src/components/assistant/assistant-markdown.spec.ts b/apps/nextjs/src/components/assistant/assistant-markdown.spec.ts new file mode 100644 index 000000000..8f5968a9b --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-markdown.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { normalizeAssistantMarkdown } from "./assistant-markdown"; + +describe("normalizeAssistantMarkdown", () => { + test("formats the assistant capability introduction as readable sections", () => { + const text = + "Hey! 👋 I'm your Homarr Assistant — ready to help you manage your dashboard. Here's what I can do: - **Boards & Apps** — create, edit, organize your dashboard boards and app shortcuts - **Integrations** — work with Sonarr, Radarr, Plex, Jellyfin, Home Assistant, Pi-hole, download clients, and more - **Media** — search and request movies/shows, check streams, browse calendars - **Docker** — view, start, stop, restart containers - **System** — monitor health, DNS stats, downloads, and smart home devices What are you looking to do?"; + + const normalized = normalizeAssistantMarkdown(text); + expect(normalized).toContain("Here's what I can do:\n\n- **Boards & Apps**"); + expect(normalized).toContain("\n- **Integrations**"); + expect(normalized).toContain("\n- **System**"); + expect(normalized.endsWith("devices\n\nWhat are you looking to do?")).toBe(true); + }); + + test("turns an inline bold-label sequence into a Markdown list", () => { + const text = + "Here's what I can do: - **Boards & Apps** — create and organize boards - **Integrations** — work with Sonarr and Radarr - **Docker** — manage containers What would you like to do?"; + + expect(normalizeAssistantMarkdown(text)).toBe( + "Here's what I can do:\n\n- **Boards & Apps** — create and organize boards\n- **Integrations** — work with Sonarr and Radarr\n- **Docker** — manage containers\n\nWhat would you like to do?", + ); + }); + + test("supports real and escaped line breaks", () => { + expect(normalizeAssistantMarkdown("First line\\nSecond line\nThird line")).toBe( + "First line\nSecond line\nThird line", + ); + }); + + test("does not rewrite fenced or inline code", () => { + const text = "Before\\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter"; + + expect(normalizeAssistantMarkdown(text)).toBe( + "Before\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter", + ); + }); + + test("leaves ordinary inline hyphens and single suggestions alone", () => { + const text = "Use the well-known provider - or choose **Custom**. Try - **Boards** — list boards."; + expect(normalizeAssistantMarkdown(text)).toBe(text); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-markdown.ts b/apps/nextjs/src/components/assistant/assistant-markdown.ts new file mode 100644 index 000000000..38c7c63f6 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-markdown.ts @@ -0,0 +1,41 @@ +const protectedMarkdownPattern = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`+[^`\n]*`+/gu; +const inlineStrongListMarker = / - (?=\*\*[^*\n]{1,80}\*\*\s+(?:[—–:-]\s+)?)/gu; +const trailingQuestion = /\s+((?:Are|Can|Could|Do|Does|How|Is|What|When|Where|Which|Why|Would)\b[^?\n]*\?)$/u; + +const normalizeProse = (text: string) => { + const withLineBreaks = text.replaceAll("\\r\\n", "\n").replaceAll("\\n", "\n"); + + return withLineBreaks + .split("\n") + .map((line) => { + const markers = [...line.matchAll(inlineStrongListMarker)]; + if (markers.length < 2) return line; + + let markerIndex = 0; + return line + .replaceAll(inlineStrongListMarker, () => (markerIndex++ === 0 ? "\n\n- " : "\n- ")) + .replace(trailingQuestion, "\n\n$1"); + }) + .join("\n"); +}; + +/** + * Makes common streamed model Markdown readable without changing code samples. + * + * Models occasionally emit escaped line breaks or an entire bold-label list on + * one line. The assistant-ui renderer receives this function through its + * streaming-safe `preprocess` hook, before Markdown is parsed. + */ +export const normalizeAssistantMarkdown = (text: string) => { + let offset = 0; + let normalized = ""; + + for (const match of text.matchAll(protectedMarkdownPattern)) { + const index = match.index; + normalized += normalizeProse(text.slice(offset, index)); + normalized += match[0]; + offset = index + match[0].length; + } + + return normalized + normalizeProse(text.slice(offset)); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 753dc508e..936870e33 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -152,6 +152,153 @@ .messageText { line-height: 1.6; overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.messageMarkdown { + line-height: 1.65; + overflow-wrap: anywhere; +} + +.messageMarkdown > :first-child { + margin-top: 0; +} + +.messageMarkdown > :last-child { + margin-bottom: 0; +} + +.messageMarkdown p { + margin-block: 0.55rem; +} + +.messageMarkdown h1, +.messageMarkdown h2, +.messageMarkdown h3, +.messageMarkdown h4 { + margin: 1rem 0 0.4rem; + line-height: 1.25; +} + +.messageMarkdown h1 { + font-size: var(--mantine-font-size-xl); +} + +.messageMarkdown h2 { + font-size: var(--mantine-font-size-lg); +} + +.messageMarkdown h3, +.messageMarkdown h4 { + font-size: var(--mantine-font-size-md); +} + +.messageMarkdown ul, +.messageMarkdown ol { + margin-block: 0.55rem; + padding-inline-start: 1.35rem; +} + +.messageMarkdown li { + padding-inline-start: 0.18rem; +} + +.messageMarkdown li + li { + margin-top: 0.3rem; +} + +.messageMarkdown li > p { + margin-block: 0.2rem; +} + +.messageMarkdown a { + color: var(--mantine-primary-color-filled); + text-decoration: underline; + text-underline-offset: 0.16em; +} + +.messageMarkdown blockquote { + margin: 0.75rem 0; + padding: 0.45rem 0.75rem; + border-inline-start: 3px solid var(--mantine-color-red-filled); + color: var(--mantine-color-dimmed); + background: color-mix(in srgb, var(--mantine-color-default) 60%, transparent); +} + +.messageMarkdown code { + padding: 0.08rem 0.28rem; + border-radius: var(--mantine-radius-xs); + background: var(--mantine-color-default); + font-family: var(--mantine-font-family-monospace); + font-size: 0.88em; +} + +.messageMarkdown pre { + max-width: 100%; + margin-block: 0.75rem; + overflow: auto; + padding: 0.75rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-default); + line-height: 1.5; +} + +.messageMarkdown pre code { + padding: 0; + background: transparent; + font-size: var(--mantine-font-size-xs); +} + +.messageMarkdown hr { + margin-block: 1rem; + border: 0; + border-top: 1px solid var(--mantine-color-default-border); +} + +.messageMarkdown img { + display: block; + max-width: 100%; + height: auto; + margin-block: 0.65rem; + border-radius: var(--mantine-radius-md); +} + +.messageMarkdown input[type="checkbox"] { + margin-inline: -1.1rem 0.45rem; + accent-color: var(--mantine-primary-color-filled); +} + +.markdownTableWrap { + max-width: 100%; + margin-block: 0.75rem; + overflow-x: auto; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); +} + +.markdownTableWrap table { + width: 100%; + min-width: 24rem; + border-collapse: collapse; + font-size: var(--mantine-font-size-sm); +} + +.markdownTableWrap th, +.markdownTableWrap td { + padding: 0.45rem 0.6rem; + border-bottom: 1px solid var(--mantine-color-default-border); + text-align: start; + vertical-align: top; +} + +.markdownTableWrap th { + background: var(--mantine-color-default); + font-weight: 650; +} + +.markdownTableWrap tr:last-child td { + border-bottom: 0; } .messageQuote { @@ -189,6 +336,49 @@ text-align: start; } +.toolResultText, +.toolResultProperties, +.toolResultCollection { + margin-top: 0.65rem; +} + +.toolResultText { + padding: 0.5rem 0.6rem; + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-body); + white-space: pre-wrap; +} + +.toolResultProperties { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); + gap: 0.4rem; +} + +.toolResultProperty, +.toolResultItem { + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-body); +} + +.toolResultCollection { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); + gap: 0.4rem; +} + +.toolResultIdentity { + min-width: 0; +} + +.toolResultMore { + align-self: center; + justify-self: center; +} + .disclosureIcon { flex: 0 0 auto; transition: transform 150ms ease; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 45a56da74..13e5c53a5 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ComponentPropsWithoutRef } from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import type { FileMessagePartProps, @@ -27,6 +28,7 @@ import { unstable_useMentionAdapter, unstable_useSlashCommandAdapter, } from "@assistant-ui/react"; +import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { ActionIcon, Anchor, @@ -83,12 +85,16 @@ import { IconTrash, IconX, } from "@tabler/icons-react"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; +import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; +import { getToolResultPresentation } from "./assistant-tool-result"; interface AssistantPanelProps { opened: boolean; @@ -102,7 +108,34 @@ interface AssistantPanelProps { latestStatus: MessageStatus | undefined; } -const TextPart = () => ; +const markdownRemarkPlugins = [remarkGfm, remarkBreaks]; + +const MarkdownLink = ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => { + const external = href !== undefined && /^https?:\/\//iu.test(href); + return ( + + {children} + + ); +}; + +const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => ( +
+ {children}
+
+); + +const AssistantTextPart = () => ( + +); + +const UserTextPart = () => ; const Attachment = ({ removable = false }: { removable?: boolean }) => { const t = useScopedI18n("common.assistant"); @@ -205,6 +238,87 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => { return ; }; +const formatToolResultValue = (value: string | number | boolean) => + typeof value === "number" ? value.toLocaleString() : String(value); + +const ToolResultPreview = ({ result }: { result: unknown }) => { + const presentation = getToolResultPresentation(result); + if (!presentation) return null; + + if (presentation.type === "text") { + return ( + + {presentation.text} + + ); + } + + if (presentation.type === "properties") { + return ( + + {presentation.fields.map((field) => ( + + + {field.label} + + + {formatToolResultValue(field.value)} + + + ))} + + ); + } + + const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); + return ( + + {presentation.items.map((item, index) => ( + + + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + {item.badges.length > 0 && ( + + {item.badges.map((badge) => ( + + {badge} + + ))} + + )} + + {item.fields.length > 0 && ( + + {item.fields.map((field) => ( + + {field.label}:{" "} + + {formatToolResultValue(field.value)} + + + ))} + + )} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} + + ); +}; + const ToolPart = ({ toolName, args, @@ -286,6 +400,7 @@ const ToolPart = ({ )} + {successful && result !== undefined && } @@ -452,7 +567,7 @@ const UserMessage = () => { )} - + @@ -683,7 +798,7 @@ const AssistantMessage = () => ( { + test("presents common widget collections as compact cards", () => { + expect( + getToolResultPresentation({ + containers: [ + { id: "one", name: "Plex", state: "running", image: "plex:latest", cpuUsage: 2.5 }, + { id: "two", name: "Sonarr", state: "stopped", image: "sonarr:latest", memoryUsage: 128 }, + ], + timestamp: "2026-07-30T10:00:00.000Z", + }), + ).toEqual({ + type: "collection", + totalCount: 2, + items: [ + { + title: "Plex", + description: "plex:latest", + badges: ["running"], + fields: [{ label: "Cpu Usage", value: 2.5 }], + }, + { + title: "Sonarr", + description: "sonarr:latest", + badges: ["stopped"], + fields: [{ label: "Memory Usage", value: 128 }], + }, + ], + }); + }); + + test("uses integration identity and nested widget summary values", () => { + expect( + getToolResultPresentation([ + { + integration: { id: "abc", name: "Pi-hole", kind: "pihole" }, + summary: { queries: 1000, blocked: 125, token: "never render this" }, + }, + ]), + ).toEqual({ + type: "collection", + totalCount: 1, + items: [ + { + title: "Pi-hole", + badges: ["pihole"], + fields: [ + { label: "Queries", value: 1000 }, + { label: "Blocked", value: 125 }, + ], + }, + ], + }); + }); + + test("never includes secret-looking values in an inline presentation", () => { + expect( + getToolResultPresentation({ + version: "1.2.3", + apiKey: "secret", + access_token: "secret", + enabled: true, + }), + ).toEqual({ + type: "properties", + fields: [ + { label: "Version", value: "1.2.3" }, + { label: "Enabled", value: true }, + ], + }); + }); + + test("limits large collections while preserving their total", () => { + const result = getToolResultPresentation({ + items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })), + totalCount: 42, + }); + + expect(result?.type).toBe("collection"); + if (result?.type !== "collection") return; + expect(result.items).toHaveLength(6); + expect(result.totalCount).toBe(42); + }); + + test("humanizes camel, snake, and kebab case labels", () => { + expect(humanizeToolResultKey("memoryUsage")).toBe("Memory Usage"); + expect(humanizeToolResultKey("cached_input")).toBe("Cached input"); + expect(humanizeToolResultKey("response-time")).toBe("Response time"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-result.ts b/apps/nextjs/src/components/assistant/assistant-tool-result.ts new file mode 100644 index 000000000..82da513e5 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-result.ts @@ -0,0 +1,166 @@ +const collectionKeys = [ + "items", + "results", + "data", + "containers", + "apps", + "boards", + "integrations", + "widgets", + "users", + "requests", + "downloads", + "events", +] as const; +const titleKeys = ["name", "title", "label", "displayName", "hostname", "containerName", "id"] as const; +const descriptionKeys = ["description", "url", "href", "image", "version"] as const; +const badgeKeys = ["status", "state", "kind", "category", "type"] as const; +const excludedKeys = new Set([ + ...titleKeys, + ...descriptionKeys, + ...badgeKeys, + ...collectionKeys, + "icon", + "iconUrl", + "logoImageUrl", + "imageUrl", +]); +const sensitiveKeyPattern = /(?:api.?key|authorization|cookie|credential|password|secret|token)/iu; +const maximumItems = 6; +const maximumFields = 6; + +type ToolResultPrimitive = string | number | boolean; + +export interface ToolResultField { + label: string; + value: ToolResultPrimitive; +} + +export interface ToolResultItem { + title: string; + description?: string; + badges: string[]; + fields: ToolResultField[]; +} + +export type ToolResultPresentation = + | { + type: "collection"; + items: ToolResultItem[]; + totalCount: number; + } + | { + type: "properties"; + fields: ToolResultField[]; + } + | { + type: "text"; + text: string; + }; + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + +const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > 160) return undefined; + return trimmed; + } + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean") return value; + return undefined; +}; + +export const humanizeToolResultKey = (key: string) => + key + .replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replaceAll(/[_-]+/gu, " ") + .replace(/^./u, (character) => character.toUpperCase()); + +const getFirstString = (record: Record, keys: readonly string[]) => { + for (const key of keys) { + const value = toDisplayValue(record[key]); + if (typeof value === "string") return value; + } + return undefined; +}; + +const getFields = (record: Record, excludeIdentity = true) => { + const fields: ToolResultField[] = []; + const addEntries = (source: Record) => { + for (const [key, value] of Object.entries(source)) { + if (fields.length >= maximumFields) return; + if ((excludeIdentity && excludedKeys.has(key)) || sensitiveKeyPattern.test(key)) continue; + const displayValue = toDisplayValue(value); + if (displayValue === undefined) continue; + fields.push({ label: humanizeToolResultKey(key), value: displayValue }); + } + }; + + addEntries(record); + for (const nestedKey of ["data", "summary", "healthInfo", "metrics"] as const) { + const nested = asRecord(record[nestedKey]); + if (nested) addEntries(nested); + } + return fields; +}; + +const toItem = (value: unknown, index: number): ToolResultItem | undefined => { + const primitive = toDisplayValue(value); + if (primitive !== undefined) { + return { title: String(primitive), badges: [], fields: [] }; + } + + const record = asRecord(value); + if (!record) return undefined; + const integration = asRecord(record.integration); + const identity = integration ?? record; + const title = getFirstString(record, titleKeys) ?? getFirstString(identity, titleKeys) ?? `Result ${index + 1}`; + const description = getFirstString(record, descriptionKeys) ?? getFirstString(identity, descriptionKeys); + const badges = badgeKeys + .flatMap((key) => { + const badgeValue = toDisplayValue(record[key] ?? identity[key]); + return badgeValue === undefined ? [] : [String(badgeValue)]; + }) + .filter((badgeValue, badgeIndex, allBadges) => allBadges.indexOf(badgeValue) === badgeIndex) + .slice(0, 3); + + return { title, description, badges, fields: getFields(record) }; +}; + +const findCollection = (record: Record) => { + for (const key of collectionKeys) { + const value = record[key]; + if (Array.isArray(value)) return value; + } + return undefined; +}; + +export const getToolResultPresentation = (result: unknown): ToolResultPresentation | undefined => { + if (typeof result === "string") { + const text = result.trim(); + if (text.length === 0) return undefined; + return { type: "text", text: text.length > 280 ? `${text.slice(0, 277)}…` : text }; + } + + const record = asRecord(result); + const collection = Array.isArray(result) ? result : record ? findCollection(record) : undefined; + if (collection) { + const items = collection + .slice(0, maximumItems) + .map(toItem) + .filter((item): item is ToolResultItem => item !== undefined); + if (items.length === 0) return undefined; + const reportedTotal = record ? toDisplayValue(record.totalCount) : undefined; + return { + type: "collection", + items, + totalCount: typeof reportedTotal === "number" ? reportedTotal : collection.length, + }; + } + + if (!record || "error" in record) return undefined; + const fields = getFields(record, false); + return fields.length > 0 ? { type: "properties", fields } : undefined; +}; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d79c80518..b1463ba7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -217,10 +217,13 @@ catalog: zod-validation-error: ^5.0.0 "@assistant-ui/react": ^0.14.27 "@assistant-ui/react-ai-sdk": ^1.3.41 + "@assistant-ui/react-markdown": ^0.14.7 "@ai-sdk/react": ^4.0.40 ai: ^7.0.37 assistant-stream: ^0.3.26 "@ai-sdk/openai-compatible": 3.0.14 + remark-breaks: ^4.0.0 + remark-gfm: ^4.0.1 catalogMode: strict nodeLinker: hoisted