Merge pull request #111 from QRun-IO/feature/assistant-answer-presentation

feat(assistant): readable answers: GFM markdown and presentation blocks
This commit was merged in pull request #111.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-13 20:29:51 -05:00
commit 438eda36e3
15 files changed
+771 -14

No files matched your search

+2
View File
@@ -63,6 +63,8 @@
"react-markdown": "10.1.0",
"recharts": "^2.15.4",
"rehype-sanitize": "6.0.0",
"remark-breaks": "4.0.0",
"remark-gfm": "4.0.1",
"sonner": "^2.0.7",
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
+221
View File
@@ -115,6 +115,12 @@ importers:
rehype-sanitize:
specifier: 6.0.0
version: 6.0.0
remark-breaks:
specifier: 4.0.0
version: 4.0.0
remark-gfm:
specifier: 4.0.1
version: 4.0.1
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -3143,6 +3149,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@16.3.6:
resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==}
peerDependencies:
@@ -3997,6 +4007,9 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@4.3.0:
resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==}
engines: {node: '>= 12'}
@@ -4006,9 +4019,30 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-find-and-replace@3.0.3:
resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==}
mdast-util-from-markdown@2.1.0:
resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.1:
resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -4018,6 +4052,9 @@ packages:
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-newline-to-break@2.0.0:
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -4037,6 +4074,27 @@ packages:
micromark-core-commonmark@2.0.4:
resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.2:
resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -4580,12 +4638,21 @@ packages:
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
repeat-string@1.6.1:
resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==}
engines: {node: '>=0.10'}
@@ -8517,6 +8584,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-next@16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.3(jiti@2.7.0))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.3.6(eslint@9.39.3(jiti@2.7.0))
@@ -9485,10 +9554,19 @@ snapshots:
dependencies:
semver: 7.8.5
markdown-table@3.0.4: {}
marked@4.3.0: {}
math-intrinsics@1.1.0: {}
mdast-util-find-and-replace@3.0.3:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -9506,6 +9584,63 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.3
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.1.0
mdast-util-to-markdown: 2.2.0
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.1.0
mdast-util-to-markdown: 2.2.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.1.0
mdast-util-to-markdown: 2.2.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.1.0
mdast-util-to-markdown: 2.2.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.1.0
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.1
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.2.0
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -9545,6 +9680,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-newline-to-break@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-find-and-replace: 3.0.3
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -9602,6 +9742,64 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.4
micromark-factory-space: 2.1.0
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm-table@2.1.2:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.1.0
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.3
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.1.0
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.3
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.2
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.3
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -10299,6 +10497,23 @@ snapshots:
'@types/hast': 3.0.5
hast-util-sanitize: 5.0.2
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -10316,6 +10531,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.2.0
unified: 11.0.5
repeat-string@1.6.1: {}
require-directory@2.1.1: {}
@@ -0,0 +1,73 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Markdown and streaming regressions for the owner's media breakdown.
import React from 'react'
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AssistantMarkdown } from './AssistantMarkdown'
import { messageText, optimisticTurnMessages, reduceStreamEvent } from './message-reducer'
import { SseParser } from '@/lib/api/sse-parser'
import { toAssistantEvent } from '@/lib/api/assistant'
const ANSWER = 'Here is the breakdown:\n\n| Type | Count |\n|---|---|\n| JPEG (image/jpeg) | 81 |\n| PNG (image/png) | 20 |\n| WebP, GIF, SVG | 0 |\n\nYou can open the lists filtered by JPEG or PNG.'
describe('AssistantMarkdown', () => {
it.each([false, true])('renders the exact answer as a table (streamed: %s)', (streamed) => {
let text = ANSWER
if (streamed) {
const ids = { userId: -1, assistantId: -2 }
let messages = optimisticTurnMessages('Count media', ids, '')
for (let offset = 0; offset < ANSWER.length; offset += 7) {
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: offset + 1, messageId: -2, text: ANSWER.slice(offset, offset + 7) }, ids)
}
text = messageText(messages[1])
expect(text).toBe(ANSWER)
}
render(<AssistantMarkdown text={text} />)
const table = screen.getByRole('table')
const rows = within(table).getAllByRole('row')
expect(rows).toHaveLength(4)
expect(within(rows[0]).getAllByRole('columnheader')).toHaveLength(2)
rows.slice(1).forEach((row) => expect(within(row).getAllByRole('cell')).toHaveLength(2))
expect(within(table).getAllByRole('cell').map((cell) => cell.textContent)).toEqual(['JPEG (image/jpeg)', '81', 'PNG (image/png)', '20', 'WebP, GIF, SVG', '0'])
})
it('renders headings, lists, soft breaks, strikethrough and disabled task inputs', () => {
const { container } = render(<AssistantMarkdown text={'# Heading\n\n- First\n- Second\n\nLine one\nLine two\n\n~~Removed~~\n\n- [x] Done'} />)
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument()
expect(screen.getAllByRole('list')[0].tagName).toBe('UL')
expect(container.querySelector('p br')).toBeInTheDocument()
expect(container.querySelector('del')).toHaveTextContent('Removed')
expect(screen.getByRole('checkbox')).toBeDisabled()
expect(screen.getByRole('checkbox')).toBeChecked()
})
it('preserves GFM numeric alignment while still blocking HTML and image loads', () => {
const { container } = render(<AssistantMarkdown text={'| Type | Count |\n|:---|---:|\n| JPEG | 81 |\n\n<script>alert(1)</script>\n\n![secret](https://evil.test/pixel)'} />)
expect(screen.getByRole('columnheader', { name: 'Count' })).toHaveStyle({ textAlign: 'right' })
expect(screen.getByRole('cell', { name: '81' })).toHaveStyle({ textAlign: 'right' })
expect(container.querySelector('script, img')).toBeNull()
})
it('preserves multiline SSE data and escaped text newlines through API decoding', () => {
const parser = new SseParser()
const [frame] = parser.push('id: 1\nevent: text.delta\ndata: {"messageId":2,\ndata: "text":"first\\nsecond"}\n\n')
expect(frame.data).toBe('{"messageId":2,\n"text":"first\\nsecond"}')
expect(toAssistantEvent(frame).event).toEqual({ type: 'text.delta', seq: 1, messageId: 2, text: 'first\nsecond' })
})
})
@@ -26,11 +26,13 @@ import React, { useState } from 'react'
import Link from 'next/link'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Check, Copy } from 'lucide-react'
import { internalAppHref } from '@/lib/utils/assistant-links'
/** The default (GitHub) sanitize schema without inline styles, classes or ids from the model. */
/** Keep GitHub's table/align, del and disabled checkbox rules; strip model styles and ids. */
const SCHEMA = {
...defaultSchema,
clobberPrefix: 'assistant-md-',
@@ -93,6 +95,9 @@ const COMPONENTS: Components = {
<table className="w-full text-xs">{children}</table>
</div>
),
// react-markdown converts sanitized GFM align attributes to textAlign before these run.
th: ({ style, children }) => <th scope="col" style={{ textAlign: style?.textAlign ?? 'left' }}>{children}</th>,
td: ({ style, children }) => <td style={{ textAlign: style?.textAlign ?? 'left' }}>{children}</td>,
}
/** Typography for the rendered markdown (the app has no typography plugin). */
@@ -103,7 +108,7 @@ const PROSE = [
'[&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0',
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
'[&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
'[&_thead]:bg-muted [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:tabular-nums',
].join(' ')
/**
@@ -116,7 +121,7 @@ const PROSE = [
export function AssistantMarkdown({ text }: { text: string }) {
return (
<div className={PROSE} data-qqq-id="assistant-markdown">
<ReactMarkdown skipHtml rehypePlugins={[[rehypeSanitize, SCHEMA]]} components={COMPONENTS}>
<ReactMarkdown skipHtml remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[[rehypeSanitize, SCHEMA]]} components={COMPONENTS}>
{text}
</ReactMarkdown>
</div>
@@ -35,7 +35,7 @@ const meta = {
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<div className="@container w-[400px] space-y-4 bg-card p-3">
<div className="@container w-full max-w-[390px] space-y-4 bg-card p-3">
<Story />
</div>
</QueryClientProvider>
@@ -109,3 +109,34 @@ export const DeniedAndError: Story = {
} as AssistantMessage,
},
}
const mediaHref = (type: string) => `/app/media?filter=${encodeURIComponent(JSON.stringify({ criteria: [{ fieldName: 'mimeType', operator: 'EQUALS', values: [type] }], booleanOperator: 'AND' }))}`
const jpegHref = mediaHref('image/jpeg')
const pngHref = mediaHref('image/png')
/** The owner's media answer, in a phone-width panel. Switch Storybook's theme to verify dark mode. */
export const MediaPresentation: Story = {
args: {
onSend: (prompt) => window.alert(`Follow-up: ${prompt}`),
message: {
...BASE, id: 14, role: 'ASSISTANT', status: 'COMPLETE',
parts: [{
type: 'presentation',
summary: '101 media files are images: 81 JPEG and 20 PNG.',
markdown: '101 media files are images: 81 JPEG and 20 PNG.',
blocks: [
{ kind: 'table', title: 'Media by type',
columns: [{ key: 'type', label: 'Type' }, { key: 'count', label: 'Count', align: 'right' }, { key: 'share', label: 'Share', align: 'right' }],
rows: [
{ cells: { type: 'JPEG (image/jpeg)', count: 81, share: 0.802 }, display: { share: '80.2%' }, href: jpegHref },
{ cells: { type: 'PNG (image/png)', count: 20, share: 0.198 }, display: { share: '19.8%' }, href: pngHref },
],
},
{ kind: 'chart', chartType: 'pie', title: 'Share by type', points: [{ label: 'JPEG', value: 81, href: jpegHref }, { label: 'PNG', value: 20, href: pngHref }] },
{ kind: 'filterLinks', items: [{ label: 'Open JPEG media (81)', tableName: 'media', href: jpegHref }, { label: 'Open PNG media (20)', tableName: 'media', href: pngHref }] },
{ kind: 'actions', items: [{ label: 'Show the PNG files', prompt: 'Show me the PNG media files' }] },
],
}],
},
},
}
@@ -39,6 +39,7 @@ import { sendAssistantFeedback } from '@/lib/api/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { cn } from '@/lib/utils/cn'
import { AssistantMarkdown } from './AssistantMarkdown'
import { AssistantPresentation, type AssistantPresentationActions } from './AssistantPresentation'
import { AssistantRenderView, RecordChips } from './AssistantRenders'
import Link from 'next/link'
@@ -140,12 +141,13 @@ function NavigateView({ part }: { part: AssistantNavigatePart }) {
}
/** Renders one part type. */
type PartRenderer<T extends AssistantPart> = (props: { part: T }) => React.ReactNode
type PartRenderer<T extends AssistantPart> = (props: { part: T } & AssistantPresentationActions) => React.ReactNode
/**
* Renderers by part type. Phase 2 adds `approval` here; anything without a renderer is skipped.
*/
export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extract<AssistantPart, { type: K }>> } = {
presentation: ({ part, onSend, sending }) => <AssistantPresentation part={part} onSend={onSend} sending={sending} />,
text: ({ part }: { part: AssistantTextPart }) => (part.text ? <AssistantMarkdown text={part.text} /> : null),
tool: ({ part }: { part: AssistantToolPart }) => <ToolStep part={part} />,
record_refs: ({ part }: { part: AssistantRecordRefsPart }) => <RecordChips refs={part.refs} />,
@@ -160,9 +162,9 @@ export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extrac
* @param props.part - Any part, including types this version does not know.
* @returns The rendered part, or null.
*/
function PartView({ part }: { part: AssistantPart }) {
function PartView({ part, onSend, sending }: { part: AssistantPart } & AssistantPresentationActions) {
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
return render ? <>{render({ part })}</> : null
return render ? <>{render({ part, onSend, sending })}</> : null
}
/**
@@ -235,7 +237,7 @@ function FeedbackButtons({ messageId }: { messageId: number }) {
* @param props.message - The message.
* @returns The message article.
*/
export function AssistantMessageView({ message }: { message: AssistantMessage }) {
export function AssistantMessageView({ message, onSend, sending }: { message: AssistantMessage } & AssistantPresentationActions) {
if (message.role === 'USER') {
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
return (
@@ -254,7 +256,7 @@ export function AssistantMessageView({ message }: { message: AssistantMessage })
const streaming = message.status === 'STREAMING'
return (
<article className="min-w-0 space-y-2" aria-label="Assistant" aria-busy={streaming} data-qqq-id="assistant-message" data-role="assistant" data-status={message.status}>
{message.parts.map((part, index) => <PartView key={part.type === 'tool' ? part.toolCallId : index} part={part} />)}
{message.parts.map((part, index) => <PartView key={part.type === 'tool' ? part.toolCallId : index} part={part} onSend={onSend} sending={sending || streaming} />)}
{streaming && message.parts.length === 0 && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-thinking">
<Loader2 className="h-3.5 w-3.5 motion-safe:animate-spin" aria-hidden="true" />
@@ -184,6 +184,27 @@ describe('assistant gating', () => {
})
describe('asking a question', () => {
it('sends a presentation prompt through the same engine and context as the composer', async () => {
mockAssistant()
const reply = { type: 'presentation', summary: 'Images found.', markdown: 'Images found.', blocks: [{ kind: 'actions', items: [{ label: 'Show PNG', prompt: 'Show me PNG files' }] }] }
const requests: unknown[] = []
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
const body = await request.json() as { text: string }
requests.push(body)
persisted.messages = storedTurn(body.text, [reply])
return new HttpResponse(sse(START, ['presentation', { seq: 2, messageId: 11, part: reply }], ['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }]), { headers: { 'Content-Type': 'text/event-stream' } })
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
await user.type(composer, 'Count images{Enter}')
const action = await screen.findByRole('button', { name: 'Show PNG' })
await waitFor(() => expect(action).toBeEnabled())
await user.click(action)
await waitFor(() => expect(requests[1]).toEqual({ text: 'Show me PNG files', pageContext: { route: '/app/person', tableName: 'person' } }))
})
it('streams the reply with a tool step and record chip, follows navigation, and passes axe', async () => {
mockAssistant()
const stream = controllableStream()
+1 -1
View File
@@ -437,7 +437,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
{activeConversationId !== null && detailQuery.isError && (
<p className="text-xs text-destructive" role="alert">{getErrorMessage(detailQuery.error, 'Could not load the conversation.')}</p>
)}
{messages.map((message) => <AssistantMessageView key={message.id} message={message} />)}
{messages.map((message) => <AssistantMessageView key={message.id} message={message} onSend={send} sending={Boolean(run)} />)}
</div>
<AssistantComposer
ref={composerRef}
@@ -0,0 +1,131 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Presentation contract, navigation safety and action behavior.
import React from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import type { AssistantPresentationPart } from '@/types/assistant'
import { AssistantPresentation } from './AssistantPresentation'
import { AssistantMessageView } from './AssistantMessageView'
import { optimisticTurnMessages, reduceStreamEvent } from './message-reducer'
import { toAssistantEvent } from '@/lib/api/assistant'
const router = vi.hoisted(() => ({ push: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => router }))
// Keep real Recharts, supplying the size that jsdom cannot measure.
vi.mock('recharts', async (importOriginal) => {
const actual = await importOriginal<typeof import('recharts')>()
return { ...actual, ResponsiveContainer: ({ children }: { children: React.ReactElement }) => React.cloneElement(children as React.ReactElement<{ width: number; height: number }>, { width: 340, height: 200 }) }
})
const table = {
kind: 'table' as const, title: 'Media by type',
columns: [{ key: 'type', label: 'Type' }, { key: 'count', label: 'Count' }, { key: 'share', label: 'Share', align: 'right' as const }],
rows: [{ cells: { type: 'JPEG', count: 81, share: 0.802 }, display: { share: '80.2%' }, href: '/app/media?filter=jpeg' }],
}
const part: AssistantPresentationPart = {
type: 'presentation', summary: '101 images.', markdown: '**Fallback answer**',
blocks: [
{ kind: 'metrics', items: [{ label: 'Images', value: 101, display: '101 files', href: '/app/media' }] },
table,
{ kind: 'filterLinks', items: [{ label: 'Open JPEG', tableName: 'media', href: '/app/media?filter=jpeg' }] },
{ kind: 'recordLinks', items: [{ label: 'Sunset.jpg', tableName: 'media', recordId: '12', href: '/app/media/12' }] },
{ kind: 'actions', items: [{ label: 'Show PNG', prompt: 'Show me PNG files' }, { label: 'Open media', href: '/app/media' }] },
{ kind: 'future', payload: 'Ignore this' },
],
}
describe('AssistantPresentation', () => {
it('renders summary, metrics, table display values, links and prompt actions accessibly', async () => {
const send = vi.fn()
const { container } = render(<AssistantPresentation part={part} onSend={send} />)
expect(screen.getByText('101 images.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '101 files' })).toHaveAttribute('href', '/app/media')
expect(screen.getByRole('table', { name: 'Media by type' })).toBeInTheDocument()
expect(screen.getByRole('cell', { name: '81' })).toHaveStyle({ textAlign: 'right' })
expect(screen.getByRole('cell', { name: '80.2%' })).toHaveStyle({ textAlign: 'right' })
expect(screen.getByRole('link', { name: 'JPEG' })).toHaveAttribute('href', '/app/media?filter=jpeg')
expect(screen.getByRole('link', { name: 'Open JPEG' })).toHaveAttribute('href', '/app/media?filter=jpeg')
expect(screen.getByRole('link', { name: 'Sunset.jpg' })).toHaveAttribute('href', '/app/media/12')
expect(screen.getByRole('link', { name: 'Open media' })).toHaveAttribute('href', '/app/media')
fireEvent.click(screen.getByRole('button', { name: 'Show PNG' }))
expect(send).toHaveBeenCalledWith('Show me PNG files')
expect(screen.queryByText('Ignore this')).not.toBeInTheDocument()
expect(screen.queryByText('Fallback answer')).not.toBeInTheDocument()
expect(screen.queryByRole('region')).not.toBeInTheDocument()
expect((await axe(container)).violations).toEqual([])
})
it.each(['https://evil.test/app/media', 'javascript:alert(1)', '//evil.test/app/media'])('rejects unsafe hrefs everywhere: %s', async (href) => {
const unsafe: AssistantPresentationPart = { ...part, blocks: [
{ kind: 'metrics', items: [{ label: 'Total', value: 1, href }] },
{ ...table, rows: [{ ...table.rows[0], href }] },
{ kind: 'filterLinks', items: [{ label: 'Filtered', tableName: 'media', href }] },
{ kind: 'recordLinks', items: [{ label: 'Record', tableName: 'media', recordId: 1, href }] },
{ kind: 'actions', items: [{ label: 'Action', href }] },
{ kind: 'chart', chartType: 'pie', points: [{ label: 'Point', value: 1, href }] },
] }
render(<AssistantPresentation part={unsafe} />)
await screen.findByRole('img', { name: /Point: 1/ }, { timeout: 10_000 })
expect(screen.queryAllByRole('link')).toHaveLength(0)
expect(screen.getByText('Action')).toBeInTheDocument()
router.push.mockClear()
fireEvent.click(document.querySelector('.recharts-pie-sector')!)
expect(router.push).not.toHaveBeenCalled()
})
it.each(['bar', 'pie', 'line'] as const)('renders a real %s chart with accessible data and clickable points', async (chartType) => {
const { container } = render(<AssistantPresentation part={{ ...part, blocks: [{ kind: 'chart', chartType, title: 'Media share', points: [{ label: 'JPEG', value: 81, display: '81 files', href: '/app/media' }, { label: 'PNG', value: 20 }] }] }} />)
const chart = await screen.findByRole('img', { name: 'Media share: JPEG: 81 files; PNG: 20' }, { timeout: 10_000 })
expect(chart.querySelector('svg')).toBeInTheDocument()
expect(within(screen.getByRole('list', { name: 'Media share data' })).getAllByRole('listitem')).toHaveLength(2)
expect(screen.getByRole('link', { name: 'JPEG: 81 files' })).toHaveAttribute('href', '/app/media')
const selector = chartType === 'bar' ? '.recharts-bar-rectangle' : chartType === 'pie' ? '.recharts-pie-sector' : '.recharts-line-dots circle'
router.push.mockClear()
fireEvent.click(container.querySelector(selector)!)
expect(router.push).toHaveBeenCalledWith('/app/media')
})
it('falls back to markdown when every block is unknown or empty', () => {
render(<AssistantPresentation part={{ ...part, blocks: [{ kind: 'future' }, { kind: 'metrics', items: [] }] }} />)
expect(screen.getByText('Fallback answer').tagName).toBe('STRONG')
})
it('gives wide tables an accessible scroll region and disables busy actions', () => {
render(<AssistantPresentation part={{ ...part, blocks: [{ ...table, columns: [...table.columns, { key: 'extra', label: 'Extra' }] }, part.blocks[4]] }} onSend={vi.fn()} sending />)
expect(screen.getByRole('region', { name: 'Media by type' })).toHaveAttribute('tabindex', '0')
expect(screen.getByRole('button', { name: 'Show PNG' })).toBeDisabled()
})
it('decodes and appends streamed presentations between text parts, preserving reload order', () => {
const ids = { userId: -1, assistantId: -2 }
let messages = optimisticTurnMessages('Media', ids, '')
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: 1, messageId: -2, text: 'Before' }, ids)
const event = toAssistantEvent({ event: 'presentation', id: '2', data: JSON.stringify({ messageId: -2, part }) }).event!
expect(event.type).toBe('presentation')
messages = reduceStreamEvent(messages, event, ids)
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: 3, messageId: -2, text: 'After' }, ids)
expect(messages[1].parts).toEqual([{ type: 'text', text: 'Before' }, part, { type: 'text', text: 'After' }])
const send = vi.fn()
const { container } = render(<AssistantMessageView message={{ ...messages[1], status: 'COMPLETE' }} onSend={send} />)
expect(container.textContent).toMatch(/Before.*101 images\..*After/)
fireEvent.click(screen.getByRole('button', { name: 'Show PNG' }))
expect(send).toHaveBeenCalledWith('Show me PNG files')
})
})
@@ -0,0 +1,136 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Structured assistant answers, with safe navigation and markdown fallback. */
'use client'
import React, { lazy, Suspense } from 'react'
import Link from 'next/link'
import type { AssistantPresentationBlock, AssistantPresentationPart, AssistantPresentationTable } from '@/types/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { AssistantMarkdown } from './AssistantMarkdown'
const Chart = lazy(() => import('./AssistantPresentationChart').then((module) => ({ default: module.AssistantPresentationChart })))
const LINK = 'rounded text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
const CHIP = 'inline-flex max-w-full items-center rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50'
/** Send behavior shared with the composer; unavailable or busy actions are disabled. */
export interface AssistantPresentationActions {
onSend?: (prompt: string) => void
sending?: boolean
}
/**
* Checks known kinds before narrowing the forward-compatible wire union.
* @param block - A server block.
* @returns Whether this version can render nonempty content.
*/
function isRenderable(block: AssistantPresentationPart['blocks'][number]): block is AssistantPresentationBlock {
switch (block.kind) {
case 'metrics': case 'filterLinks': case 'recordLinks': case 'actions':
return Array.isArray(block.items) && block.items.length > 0
case 'table':
return Array.isArray(block.columns) && block.columns.length > 0 && Array.isArray(block.rows)
case 'chart':
return ['bar', 'pie', 'line'].includes(String(block.chartType)) && Array.isArray(block.points) && block.points.length > 0
default:
return false
}
}
/**
* Applies the same href policy as assistant markdown to every presentation link.
* @param props - Destination, content and styling.
* @returns An internal Next link or plain text.
*/
export function PresentationLink({ href, children, className = LINK }: { href?: string; children: React.ReactNode; className?: string }) {
const safe = internalAppHref(href)
return safe
? <Link href={safe} className={className} data-qqq-id="assistant-presentation-link">{children}</Link>
: <span>{children}</span>
}
/**
* Keeps up to three columns within the panel, wrapping even unbroken cell values.
* @param props - Table payload.
* @returns A semantic table; only wider tables get a scroll region.
*/
function PresentationTable({ block }: { block: AssistantPresentationTable }) {
const wide = block.columns.length > 3
const alignments = block.columns.map((column) => column.align ?? (
block.rows.some((row) => typeof row.cells[column.key] === 'number') ? 'right' : 'left'
))
return (
<div className={wide ? 'overflow-x-auto focus-visible:ring-2 focus-visible:ring-ring' : 'min-w-0'} role={wide ? 'region' : undefined} tabIndex={wide ? 0 : undefined} aria-label={wide ? block.title ?? 'Results table' : undefined}>
<table className={`w-full border-collapse text-sm ${wide ? '' : 'table-fixed'}`}>
<caption className="mb-2 text-left font-medium">{block.title ?? 'Results'}</caption>
<thead className="bg-muted">
<tr>{block.columns.map((column, index) => <th key={column.key} scope="col" style={{ textAlign: alignments[index] }} className="border border-border px-2 py-1.5 [overflow-wrap:anywhere]">{column.label}</th>)}</tr>
</thead>
<tbody>{block.rows.map((row, index) => (
<tr key={index}>{block.columns.map((column, cellIndex) => {
const value = row.display?.[column.key] ?? String(row.cells[column.key] ?? '—')
return <td key={column.key} style={{ textAlign: alignments[cellIndex] }} className="border border-border px-2 py-1.5 align-top tabular-nums [overflow-wrap:anywhere]">{cellIndex === 0 ? <PresentationLink href={row.href}>{value}</PresentationLink> : value}</td>
})}</tr>
))}</tbody>
</table>
</div>
)
}
/**
* Renders one known block, using the existing panel send path for follow-up prompts.
* @param props - Block and send behavior.
* @returns Block content.
*/
function BlockView({ block, onSend, sending }: { block: AssistantPresentationBlock } & AssistantPresentationActions) {
switch (block.kind) {
case 'metrics':
return <dl className="flex flex-wrap gap-2">{block.items.map((item, index) => (
<div key={index} className="min-w-0 max-w-full flex-auto rounded-lg border border-border bg-muted/40 p-3 [overflow-wrap:anywhere]">
<dt className="text-xs text-muted-foreground">{item.label}</dt>
<dd className="text-2xl font-semibold tabular-nums"><PresentationLink href={item.href}>{item.display ?? item.value}</PresentationLink></dd>
</div>
))}</dl>
case 'table':
return <PresentationTable block={block} />
case 'chart':
return <Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading chart…</p>}><Chart block={block} /></Suspense>
case 'filterLinks': case 'recordLinks':
return <ul aria-label={block.kind === 'filterLinks' ? 'Filtered lists' : 'Records'} className="flex flex-wrap gap-2">{block.items.map((item, index) => <li key={index} className="min-w-0 max-w-full [overflow-wrap:anywhere]"><PresentationLink href={item.href} className={CHIP}>{item.label}</PresentationLink></li>)}</ul>
case 'actions':
return <div className="flex flex-wrap gap-2">{block.items.map((item, index) => item.prompt !== undefined ? (
<button key={index} type="button" disabled={!onSend || sending} onClick={() => onSend?.(item.prompt)} className={`${CHIP} [overflow-wrap:anywhere]`} data-qqq-id="assistant-presentation-action">{item.label}</button>
) : <PresentationLink key={index} href={item.href} className={CHIP}>{item.label}</PresentationLink>)}</div>
}
}
/**
* Shows the direct answer first and preserves server block order.
* @param props - Presentation and composer callback.
* @returns The answer or its whole-part markdown fallback.
*/
export function AssistantPresentation({ part, onSend, sending }: { part: AssistantPresentationPart } & AssistantPresentationActions) {
const blocks = part.blocks.filter(isRenderable)
if (!blocks.length) return <AssistantMarkdown text={part.markdown || part.summary} />
return (
<div className="min-w-0 space-y-3 text-foreground [overflow-wrap:anywhere]" data-qqq-id="assistant-presentation">
<p className="text-base font-semibold leading-snug" data-qqq-id="assistant-presentation-summary">{part.summary}</p>
{blocks.map((block, index) => <div key={index} className="min-w-0" data-qqq-id={`assistant-presentation-${block.kind}`}><BlockView block={block} onSend={onSend} sending={sending} /></div>)}
</div>
)
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Lazy Recharts rendering for small assistant presentation charts. */
'use client'
import React from 'react'
import { useRouter } from 'next/navigation'
import { Bar, BarChart, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import type { AssistantPresentationChart as ChartBlock } from '@/types/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { PresentationLink } from './AssistantPresentation'
/**
* The dashboard chart widgets' palette (PieChartWidget): concrete colors, because Tailwind only
* emits the `--color-chart-*` theme variables that some class uses, and an unemitted one renders black.
*/
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#f97316']
const TICK = { fontSize: 11, fill: 'var(--color-muted-foreground)' }
/**
* Plots values and provides equivalent keyboard-accessible links and data for every point.
* @param props - The chart payload.
* @returns A themed chart no taller than 200px, with a screen-reader data list.
*/
export function AssistantPresentationChart({ block }: { block: ChartBlock }) {
const router = useRouter()
const label = `${block.title ?? `${block.chartType} chart`}: ${block.points.map((point) => `${point.label}: ${point.display ?? point.value}`).join('; ')}`
const navigate = (index: number) => {
const href = internalAppHref(block.points[index]?.href)
if (href) router.push(href)
}
const tooltip = <Tooltip contentStyle={{ background: 'var(--color-popover)', color: 'var(--color-popover-foreground)', border: '1px solid var(--color-border)' }} itemStyle={{ color: 'var(--color-popover-foreground)' }} formatter={(value: number, _name, item) => [item.payload.display ?? value, item.payload.label]} />
const axes = <><XAxis dataKey="label" tick={TICK} tickLine={false} /><YAxis width={40} tick={TICK} tickLine={false} /></>
return (
<figure className="min-w-0">
{block.title && <figcaption className="mb-1 text-sm font-medium">{block.title}</figcaption>}
<div role="img" aria-label={label} className="h-[200px] min-w-0">
<ResponsiveContainer width="100%" height={200}>
{block.chartType === 'pie' ? (
<PieChart>{tooltip}<Pie data={block.points} dataKey="value" nameKey="label" outerRadius="80%" isAnimationActive={false} onClick={(_point, index) => navigate(index)}>
{block.points.map((point, index) => <Cell key={index} fill={COLORS[index % COLORS.length]} cursor={internalAppHref(point.href) ? 'pointer' : undefined} />)}
</Pie></PieChart>
) : block.chartType === 'bar' ? (
<BarChart data={block.points}>{axes}{tooltip}<Bar dataKey="value" isAnimationActive={false} onClick={(_point, index) => navigate(index)}>
{block.points.map((point, index) => <Cell key={index} fill={COLORS[index % COLORS.length]} cursor={internalAppHref(point.href) ? 'pointer' : undefined} />)}
</Bar></BarChart>
) : (
<LineChart data={block.points}>{axes}{tooltip}<Line dataKey="value" stroke={COLORS[0]} isAnimationActive={false} activeDot={false} dot={(props) => (
<circle key={props.index} cx={props.cx} cy={props.cy} r={5} fill={COLORS[0]} onClick={() => navigate(props.index ?? -1)} style={{ cursor: internalAppHref(props.payload?.href) ? 'pointer' : undefined }} />
)} /></LineChart>
)}
</ResponsiveContainer>
</div>
<ul aria-label={`${block.title ?? 'Chart'} data`} className="sr-only focus-within:not-sr-only focus-within:mt-2 focus-within:space-y-1">
{block.points.map((point, index) => <li key={index}><PresentationLink href={point.href}>{point.label}: {point.display ?? point.value}</PresentationLink></li>)}
</ul>
</figure>
)
}
@@ -40,6 +40,13 @@ function apply(...events: AssistantStreamEvent[]): { messages: AssistantMessage[
const START: AssistantStreamEvent = { type: 'message.start', seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }
describe('reduceStreamEvent', () => {
it('appends a presentation to its message and makes its summary available to announcements', () => {
const part = { type: 'presentation' as const, summary: '101 images.', markdown: '101 images.', blocks: [] }
const { messages } = apply(START, { type: 'presentation', seq: 2, messageId: 11, part })
expect(messages[1].parts).toEqual([part])
expect(messageText(messages[1])).toBe('\n101 images.\n')
})
it('swaps temporary ids for the server ids on message.start', () => {
const { messages, ids } = apply(START)
expect(messages.map((message) => [message.id, message.turnId])).toEqual([[10, 9], [11, 9]])
+4 -2
View File
@@ -94,6 +94,8 @@ export function reduceStreamEvent(messages: AssistantMessage[], event: Assistant
}
return { ...message, parts }
})
case 'presentation':
return appendPart(messages, event.messageId, event.part)
case 'ui.navigate':
return appendPart(messages, ids.assistantId, { type: 'navigate', href: event.href })
case 'notice':
@@ -125,13 +127,13 @@ export function endReply(messages: AssistantMessage[], assistantId: number, stat
}
/**
* The plain text of a message's text parts.
* Text and presentation summaries for the streaming screen-reader announcer.
*
* @param message - A message.
* @returns The concatenated text.
*/
export function messageText(message: AssistantMessage): string {
return message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
return message.parts.map((part) => part.type === 'text' ? part.text : part.type === 'presentation' ? `\n${part.summary}\n` : '').join('')
}
/**
+1 -1
View File
@@ -35,7 +35,7 @@ import { SseParser, type SseEvent } from './sse-parser'
/** Stream event types this client understands; others (later phases) are skipped. */
const KNOWN_EVENTS = new Set<AssistantStreamEvent['type']>([
'message.start', 'text.delta', 'tool.start', 'tool.result', 'ui.navigate', 'notice', 'usage', 'message.end', 'error', 'ping',
'message.start', 'text.delta', 'presentation', 'tool.start', 'tool.result', 'ui.navigate', 'notice', 'usage', 'message.end', 'error', 'ping',
])
/** An HTTP error from a streamed request, with the backend's `{ error }` message. */
+53 -1
View File
@@ -202,8 +202,59 @@ export interface AssistantNavigatePart { type: 'navigate'; href: string }
/** An informational, warning or error notice. */
export interface AssistantNoticePart { type: 'notice'; level: 'info' | 'warning' | 'error'; text: string }
/** One part of a message. Unknown part types (later phases) are ignored by the UI. */
/** A server-formatted value with an optional destination. */
export interface AssistantPresentationMetric {
label: string
value: string | number
display?: string
href?: string
}
/** A compact results table. */
export interface AssistantPresentationTable {
kind: 'table'
title?: string
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' }>
rows: Array<{
cells: Record<string, string | number | boolean | null>
display?: Record<string, string>
href?: string
}>
}
/** A small chart with server-built point destinations. */
export interface AssistantPresentationChart {
kind: 'chart'
chartType: 'bar' | 'pie' | 'line'
title?: string
points: Array<{ label: string; value: number; display?: string; href?: string }>
}
/** Either sends the next user message or opens a server-built path. */
export type AssistantPresentationAction =
| { label: string; prompt: string; href?: never }
| { label: string; href: string; prompt?: never }
/** Known v1 blocks, discriminated by kind. */
export type AssistantPresentationBlock =
| { kind: 'metrics'; items: AssistantPresentationMetric[] }
| AssistantPresentationTable
| AssistantPresentationChart
| { kind: 'filterLinks'; items: Array<{ label: string; tableName: string; href: string }> }
| { kind: 'recordLinks'; items: Array<{ label: string; tableName: string; recordId: string | number; href: string }> }
| { kind: 'actions'; items: AssistantPresentationAction[] }
/** Forward-compatible answer from present_results; unknown blocks are skipped. */
export interface AssistantPresentationPart {
type: 'presentation'
summary: string
markdown: string
blocks: Array<AssistantPresentationBlock | { kind: string; [key: string]: unknown }>
}
/** One part of a message. Unknown part types are ignored by the UI. */
export type AssistantPart =
| AssistantPresentationPart
| AssistantTextPart
| AssistantToolPart
| AssistantRecordRefsPart
@@ -252,6 +303,7 @@ export type AssistantStreamErrorCode = 'KILL_SWITCH' | 'BUDGET' | 'RATE_LIMIT' |
/** Typed events of a turn stream; every event carries its `seq`. */
export type AssistantStreamEvent =
| { type: 'presentation'; seq: number; messageId: number; part: AssistantPresentationPart }
| { type: 'message.start'; seq: number; turnId: number; conversationId: number; userMessageId: number; messageId: number }
| { type: 'text.delta'; seq: number; messageId: number; text: string }
| { type: 'tool.start'; seq: number; messageId: number; toolCallId: string; name: string; summary: string }