Markdown

Data Display

스트리밍 마크다운 본문 렌더러.

Usage

assistant-ui 텍스트 파트의 마크다운을 코드 하이라이팅·수식과 함께 렌더링할 때 사용합니다. 스트리밍 도중에는 부드러운 글자 등장 효과가 자동 적용됩니다.

import

import

import { Markdown } from '@mildang/design-system/unofficial/Chat';

예제를 복사해 쓸 때 필요한 준비

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

외부 패키지를 따로 설치한다. DS 의 전이 의존성에 기대지 않는다.

API Reference

Markdown Props

Prop

Type

Default

className

string

지정 안 함

smooth

boolean | SmoothOptions

true

같은 패밀리

@mildang/design-system/unofficial/Chat 에서 같이 내보내는 컴포넌트다.

예제

기본 마크다운 렌더링

코드

// MarkdownDemo.example.tsx
import { css } from "@mildang/styled-system/css";
import { MessagePrimitive, ThreadPrimitive } from "@assistant-ui/react";
import { Markdown } from "@mildang/design-system/unofficial/Chat";

// ../../examples/mockRuntime.tsx
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
  useRemoteThreadListRuntime
} from "@assistant-ui/react";
import { useState } from "react";
var mockAdapter = {
  async run() {
    return {
      content: [{ type: "text", text: "안녕하세요! 무엇을 도와드릴까요? (mock 응답)" }]
    };
  }
};
function MockRuntimeProvider({
  children,
  initialMessages
}) {
  const runtime = useLocalRuntime(mockAdapter, { initialMessages });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}

// MarkdownDemo.example.tsx
var markdownContent = `# 마크다운 제목 (h1)

일반 문단입니다. **굵게**, *기울임*, \`인라인 코드\`, 그리고 [링크](https://mildang.kr)를 포함합니다.

## 소제목 (h2)

- 순서 없는 목록 1
- 순서 없는 목록 2

1. 순서 있는 목록 1
2. 순서 있는 목록 2

> 인용문 블록입니다.

\`\`\`ts
const greet = (name: string) => \`안녕, \${name}\`;
\`\`\`

| 이름 | 값 |
| --- | --- |
| 알파 | 1 |
| 베타 | 2 |
`;
var messages = [{ role: "assistant", content: markdownContent }];
var MarkdownText = () => <Markdown />;
var AssistantMessage = () => <MessagePrimitive.Root>
    <MessagePrimitive.Parts components={{ Text: MarkdownText }} />
  </MessagePrimitive.Root>;
var UserMessage = () => null;
var wrapper = css({ maxWidth: "chat.thread.maxWidth" });
var MarkdownDemoExample = () => <MockRuntimeProvider initialMessages={messages}>
    <div className={wrapper}>
      <ThreadPrimitive.Root>
        <ThreadPrimitive.Messages components={{ AssistantMessage, UserMessage }} />
      </ThreadPrimitive.Root>
    </div>
  </MockRuntimeProvider>;
var MarkdownDemo_example_default = MarkdownDemoExample;
export {
  MarkdownDemo_example_default as default
};

코드 블록 하이라이팅

코드

// MarkdownCodeBlock.example.tsx
import { css } from "@mildang/styled-system/css";
import { MessagePrimitive, ThreadPrimitive } from "@assistant-ui/react";
import { Markdown } from "@mildang/design-system/unofficial/Chat";

// ../../examples/mockRuntime.tsx
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
  useRemoteThreadListRuntime
} from "@assistant-ui/react";
import { useState } from "react";
var mockAdapter = {
  async run() {
    return {
      content: [{ type: "text", text: "안녕하세요! 무엇을 도와드릴까요? (mock 응답)" }]
    };
  }
};
function MockRuntimeProvider({
  children,
  initialMessages
}) {
  const runtime = useLocalRuntime(mockAdapter, { initialMessages });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}

// MarkdownCodeBlock.example.tsx
var codeContent = `언어가 지정된 코드블록은 shiki로 하이라이팅되고, 헤더에 언어 라벨과 복사 버튼이 붙습니다.

\`\`\`tsx
export const add = (a: number, b: number) => a + b;
console.log(add(1, 2));
\`\`\`

언어가 없는 코드블록도 복사 버튼은 제공됩니다.

\`\`\`
$ pnpm --filter @mildang/design-system storybook
\`\`\`
`;
var codeMessages = [{ role: "assistant", content: codeContent }];
var MarkdownText = () => <Markdown />;
var AssistantMessage = () => <MessagePrimitive.Root>
    <MessagePrimitive.Parts components={{ Text: MarkdownText }} />
  </MessagePrimitive.Root>;
var UserMessage = () => null;
var wrapper = css({ maxWidth: "chat.thread.maxWidth" });
var MarkdownCodeBlockExample = () => <MockRuntimeProvider initialMessages={codeMessages}>
    <div className={wrapper}>
      <ThreadPrimitive.Root>
        <ThreadPrimitive.Messages components={{ AssistantMessage, UserMessage }} />
      </ThreadPrimitive.Root>
    </div>
  </MockRuntimeProvider>;
var MarkdownCodeBlock_example_default = MarkdownCodeBlockExample;
export {
  MarkdownCodeBlock_example_default as default
};

스트리밍 중 렌더링

코드

import { TextMessagePartProvider } from '@assistant-ui/react';
import { Markdown } from '@mildang/design-system/unofficial/Chat';
import { css } from '@mildang/styled-system/css';
import { useEffect, useState } from 'react';

const markdownContent = `# 마크다운 제목 (h1)

일반 문단입니다. **굵게**, *기울임*, \`인라인 코드\`, 그리고 [링크](https://mildang.kr)를 포함합니다.

## 소제목 (h2)

- 순서 없는 목록 1
- 순서 없는 목록 2

1. 순서 있는 목록 1
2. 순서 있는 목록 2

> 인용문 블록입니다.

\`\`\`ts
const greet = (name: string) => \`안녕, \${name}\`;
\`\`\`

| 이름 | 값 |
| --- | --- |
| 알파 | 1 |
| 베타 | 2 |
`;

const wrapper = css({ maxWidth: 'chat.thread.maxWidth' });

// ── 스트리밍 데모 ─────────────────────────────────────────────────────────────
// 실제 LLM처럼 청크(여러 글자) 단위로 도착하는 텍스트를 Markdown이 어떻게 렌더하는지 시연.
// TextMessagePartProvider(text + isRunning)로 part 컨텍스트를 직접 공급하므로 런타임이 필요 없다.
// Markdown의 smooth(기본 true, useSmooth)가 청크를 글자 단위 타이프라이터로 풀어낸다.
const CHUNK_SIZE = 16;

const CHUNK_INTERVAL_MS = 220;

const useStreamingLength = () => {
  const [length, setLength] = useState(0);
  const done = length >= markdownContent.length;

  useEffect(() => {
    if (done) return;
    const id = setInterval(() => setLength((prev) => prev + CHUNK_SIZE), CHUNK_INTERVAL_MS);
    return () => clearInterval(id);
  }, [done]);

  return { done, length };
};

const StreamingDemo = ({ smooth }: { smooth: boolean }) => {
  const { done, length } = useStreamingLength();

  return (
    <div className={wrapper}>
      <TextMessagePartProvider text={markdownContent.slice(0, length)} isRunning={!done}>
        <Markdown smooth={smooth} />
      </TextMessagePartProvider>
    </div>
  );
};

const MarkdownStreamingExample = () => <StreamingDemo smooth />;

export default MarkdownStreamingExample;

부드러운 효과 끈 스트리밍

코드

import { TextMessagePartProvider } from '@assistant-ui/react';
import { Markdown } from '@mildang/design-system/unofficial/Chat';
import { css } from '@mildang/styled-system/css';
import { useEffect, useState } from 'react';

const markdownContent = `# 마크다운 제목 (h1)

일반 문단입니다. **굵게**, *기울임*, \`인라인 코드\`, 그리고 [링크](https://mildang.kr)를 포함합니다.

## 소제목 (h2)

- 순서 없는 목록 1
- 순서 없는 목록 2

1. 순서 있는 목록 1
2. 순서 있는 목록 2

> 인용문 블록입니다.

\`\`\`ts
const greet = (name: string) => \`안녕, \${name}\`;
\`\`\`

| 이름 | 값 |
| --- | --- |
| 알파 | 1 |
| 베타 | 2 |
`;

const wrapper = css({ maxWidth: 'chat.thread.maxWidth' });

// ── 스트리밍 데모 ─────────────────────────────────────────────────────────────
// 실제 LLM처럼 청크(여러 글자) 단위로 도착하는 텍스트를 Markdown이 어떻게 렌더하는지 시연.
// TextMessagePartProvider(text + isRunning)로 part 컨텍스트를 직접 공급하므로 런타임이 필요 없다.
// Markdown의 smooth(기본 true, useSmooth)가 청크를 글자 단위 타이프라이터로 풀어낸다.
const CHUNK_SIZE = 16;

const CHUNK_INTERVAL_MS = 220;

const useStreamingLength = () => {
  const [length, setLength] = useState(0);
  const done = length >= markdownContent.length;

  useEffect(() => {
    if (done) return;
    const id = setInterval(() => setLength((prev) => prev + CHUNK_SIZE), CHUNK_INTERVAL_MS);
    return () => clearInterval(id);
  }, [done]);

  return { done, length };
};

const StreamingDemo = ({ smooth }: { smooth: boolean }) => {
  const { done, length } = useStreamingLength();

  return (
    <div className={wrapper}>
      <TextMessagePartProvider text={markdownContent.slice(0, length)} isRunning={!done}>
        <Markdown smooth={smooth} />
      </TextMessagePartProvider>
    </div>
  );
};

const MarkdownStreamingWithoutSmoothExample = () => <StreamingDemo smooth={false} />;

export default MarkdownStreamingWithoutSmoothExample;

스트리밍 효과 비교

코드

import { useEffect, useState } from 'react';
import { HStack, VStack } from '@mildang/styled-system/jsx';
import { Button } from '@mildang/design-system/Button';
import { TextMessagePartProvider } from '@assistant-ui/react';
import { Markdown } from '@mildang/design-system/unofficial/Chat';
import { css } from '@mildang/styled-system/css';

const markdownContent = `# 마크다운 제목 (h1)

일반 문단입니다. **굵게**, *기울임*, \`인라인 코드\`, 그리고 [링크](https://mildang.kr)를 포함합니다.

## 소제목 (h2)

- 순서 없는 목록 1
- 순서 없는 목록 2

1. 순서 있는 목록 1
2. 순서 있는 목록 2

> 인용문 블록입니다.

\`\`\`ts
const greet = (name: string) => \`안녕, \${name}\`;
\`\`\`

| 이름 | 값 |
| --- | --- |
| 알파 | 1 |
| 베타 | 2 |
`;

const comparisonColumn = css({
  flex: '1',
  minWidth: '0',
  padding: '12',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.low',
  borderRadius: 'md',
});

const comparisonTitle = css({
  display: 'block',
  marginBlockEnd: '8',
  color: 'neutral.text.low',
  textStyle: 'chat-small-text-M',
});

// ── 스트리밍 데모 ─────────────────────────────────────────────────────────────
// 실제 LLM처럼 청크(여러 글자) 단위로 도착하는 텍스트를 Markdown이 어떻게 렌더하는지 시연.
// TextMessagePartProvider(text + isRunning)로 part 컨텍스트를 직접 공급하므로 런타임이 필요 없다.
// Markdown의 smooth(기본 true, useSmooth)가 청크를 글자 단위 타이프라이터로 풀어낸다.
const CHUNK_SIZE = 16;

const CHUNK_INTERVAL_MS = 220;

const useStreamingLength = () => {
  const [length, setLength] = useState(0);
  const done = length >= markdownContent.length;

  useEffect(() => {
    if (done) return;
    const id = setInterval(() => setLength((prev) => prev + CHUNK_SIZE), CHUNK_INTERVAL_MS);
    return () => clearInterval(id);
  }, [done]);

  return { done, length };
};

const StreamingComparisonRun = () => {
  const { done, length } = useStreamingLength();
  const text = markdownContent.slice(0, length);

  return (
    <HStack width="100%" gap="16" alignItems="flex-start">
      <div className={comparisonColumn}>
        <span className={comparisonTitle}>GPT형 smooth</span>
        <TextMessagePartProvider text={text} isRunning={!done}>
          <Markdown />
        </TextMessagePartProvider>
      </div>
      <div className={comparisonColumn}>
        <span className={comparisonTitle}>원본 청크 (smooth=false)</span>
        <TextMessagePartProvider text={text} isRunning={!done}>
          <Markdown smooth={false} />
        </TextMessagePartProvider>
      </div>
    </HStack>
  );
};

const StreamingComparisonDemo = () => {
  const [runId, setRunId] = useState(0);

  const handleReplay = () => {
    setRunId((previousRunId) => previousRunId + 1);
  };

  return (
    <VStack width="100%" maxWidth="chat.thread.maxWidth" gap="12" alignItems="flex-start">
      <Button variant="tertiary" size="sm" onClick={handleReplay}>
        다시 재생
      </Button>
      <StreamingComparisonRun key={runId} />
    </VStack>
  );
};

const MarkdownStreamingComparisonExample = () => <StreamingComparisonDemo />;

export default MarkdownStreamingComparisonExample;