Thread

Layout

메시지 목록과 입력창을 담는 채팅 스레드 컨테이너.

Usage

런타임에 연결된 메시지 목록과 컴포저를 한 화면에 조립할 때 사용한다.

import

import

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

Thread 하나만 가져오면 Thread.Root · Thread.Viewport · Thread.ViewportFooter · Thread.ScrollToBottom · Thread.Suggestion · Thread.Messages · Thread.Suggestions · Thread.Empty · Thread.If 를 그 아래에서 쓸 수 있다.

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

@mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.

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

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

Anatomy

tsx

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

export default function Example() {
  return (
    <Thread>
      <Thread.Viewport>
        <Thread.Empty />
        <Thread.Messages />
        <Thread.ViewportFooter>
          <Thread.ScrollToBottom />
        </Thread.ViewportFooter>
      </Thread.Viewport>
    </Thread>
  );
}

부품

필수 여부

반복

위치

Thread.Root

필수

Thread.Viewport

필수

Thread.Root 안에 둔다.

Thread.Empty

조건부

empty prop을 넘기면 메시지가 없을 때만 렌더한다.

Thread.Viewport 안에 둔다.

Thread.Messages

필수

Thread.Viewport 안에 둔다.

Thread.ViewportFooter

필수

Thread.Viewport 안에 둔다.

Thread.ScrollToBottom

선택

Thread.ViewportFooter 안에 둔다.

  • components는 UserMessage/AssistantMessage 등 역할별 렌더러를 반드시 넘긴다.
  • composer는 ViewportFooter 안에 함께 고정되는 입력창을 넘긴다.
  • Thread.Suggestion은 이 트리 밖에서 독립된 pill로 쓰는 별개 부품이다.

API Reference

Thread Props

Prop

Type

Default

components
필수

NonNullable<MessagesComponentConfig>

지정 안 함

composer
필수

ReactNode

지정 안 함

asChild

boolean

지정 안 함

empty

ReactNode

지정 안 함

render

ReactElement<unknown, string | JSXElementConstructor<any>>

지정 안 함

turnAnchor

"bottom" | "top"

지정 안 함

Thread.Viewport

메시지를 스크롤 영역에 담고 자동 하단 스크롤을 관리한다.

공개 Props 없음

Thread.Empty

메시지가 없을 때 보여줄 콘텐츠를 렌더한다.

공개 Props 없음

Thread.Messages

components prop으로 받은 역할별 메시지 컴포넌트를 순회 렌더한다.

Prop

Type

Default

children

(value: { message: MessageState; }) => ReactNode

지정 안 함

components

MessagesComponentConfig

지정 안 함

Thread.ViewportFooter

컴포저를 뷰포트 바닥에 고정하는 영역이다.

공개 Props 없음

Thread.ScrollToBottom

스크롤이 바닥에서 벗어났을 때 나타나는 하단 이동 버튼이다.

Prop

Type

Default

asChild

boolean

지정 안 함

behavior

ScrollBehavior

지정 안 함

icon

ReactNode

지정 안 함

render

import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>

지정 안 함

같은 패밀리

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

예제

기본 사용

메시지 목록과 컴포저를 함께 조립한 기본 스레드다.

코드

// ThreadDemo.example.tsx
import { css } from "@mildang/styled-system/css";
import { Thread, Message, Composer, TextShimmer } 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 응답)" }]
    };
  }
};
var sampleMessages = [
  { role: "user", content: "밀당 채팅 컴포넌트 잘 붙었어?" },
  {
    role: "assistant",
    content: "네! assistant-ui 프리미티브를 Panda CSS 슬롯 레시피로 스타일링했습니다. Thread / Composer / Message 3종이 렌더됩니다."
  }
];
function MockRuntimeProvider({
  children,
  initialMessages
}) {
  const runtime = useLocalRuntime(mockAdapter, { initialMessages });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}

// ThreadDemo.example.tsx
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts />
    </Message.Content>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <Message.Content>
      <Message.Parts components={{ Empty: ThinkingPlaceholder }} />
    </Message.Content>
  </Message>;
var shell = css({
  height: "100dvh"
});
var ThreadDemoExample = () => <MockRuntimeProvider initialMessages={sampleMessages}>
    <div className={shell}>
      <Thread
  components={{ UserMessage, AssistantMessage }}
  composer={<Composer>
            <Composer.Input placeholder="메시지를 입력하세요…" />
            <Composer.Send>↑</Composer.Send>
          </Composer>}
/>
    </div>
  </MockRuntimeProvider>;
var ThreadDemo_example_default = ThreadDemoExample;
export {
  ThreadDemo_example_default as default
};

빈 스레드

메시지가 없어도 컴포저가 바닥에 고정되는 상태다.

코드

// ThreadEmpty.example.tsx
import { css } from "@mildang/styled-system/css";
import { Thread, Message, Composer, TextShimmer } 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>;
}

// ThreadEmpty.example.tsx
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts />
    </Message.Content>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <Message.Content>
      <Message.Parts components={{ Empty: ThinkingPlaceholder }} />
    </Message.Content>
  </Message>;
var shell = css({
  height: "100dvh"
});
var ThreadEmptyExample = () => <MockRuntimeProvider>
    <div className={shell}>
      <Thread
  components={{ UserMessage, AssistantMessage }}
  composer={<Composer>
            <Composer.Input placeholder="첫 메시지를 보내보세요…" />
            <Composer.Send>↑</Composer.Send>
          </Composer>}
/>
    </div>
  </MockRuntimeProvider>;
var ThreadEmpty_example_default = ThreadEmptyExample;
export {
  ThreadEmpty_example_default as default
};

제안 pill

Thread.Suggestion을 독립된 pill로 쓰는 예시다.

코드

// ThreadSuggestions.example.tsx
import { css } from "@mildang/styled-system/css";
import { Thread } 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>;
}

// ThreadSuggestions.example.tsx
var suggestions = ["오늘 학습 요약해줘", "틀린 문제 다시 풀래", "다음 수업 준비물 알려줘"];
var ThreadSuggestionsExample = () => <MockRuntimeProvider>
    <div className={css({ display: "flex", flexWrap: "wrap", gap: "8" })}>
      {suggestions.map((prompt) => <Thread.Suggestion key={prompt} prompt={prompt} send label={prompt} />)}
    </div>
  </MockRuntimeProvider>;
var ThreadSuggestions_example_default = ThreadSuggestionsExample;
export {
  ThreadSuggestions_example_default as default
};

채팅 표면 카탈로그

완성 채팅 화면을 구성하는 공통 표면 슬롯을 한곳에서 비교한다.

코드

import { css, cx } from '@mildang/styled-system/css';
import CloseIcon from '@mildang/icons/react/close';
import CheckIcon from '@mildang/icons/react/check';
import { chatSurface } from '@mildang/styled-system/recipes';

const surface = chatSurface();

const page = css({ display: 'flex', flexDirection: 'column', gap: '24', padding: '16' });

const row = css({ display: 'flex', alignItems: 'center', gap: '12', flexWrap: 'wrap' });

const label = css({ textStyle: 'chat-small-text-M', color: 'neutral.text.low' });

const cell = css({ display: 'flex', flexDirection: 'column', gap: '8' });

const paperBox = css({ borderRadius: 'lg', padding: '16', minWidth: '220px' });

const fieldBox = css({ borderRadius: 'md', paddingX: '12', paddingY: '8' });

const ghostSize = css({ width: '24', height: '24', '& svg': { width: '16', height: '16' } });

const inkSize = css({
  borderRadius: 'full',
  paddingX: '12',
  paddingY: '6',
  gap: '4',
  textStyle: 'chat-caption-M',
});

const ChatSurfaceCatalogExample = () => (
    <div className={page} data-testid="story-subject">
      <div className={cell}>
        <span className={label}>paper — 카드/패널 바탕 (radius 는 호출자 몫)</span>
        <div className={cx(surface.paper, paperBox)}>
          <span className={css({ textStyle: 'chat-body', color: 'primary.text.base' })}>
            도구 실행 결과 카드
          </span>
        </div>
      </div>

      <div className={cell}>
        <span className={label}>field — paper 안에 한 단 들어간 내부 영역</span>
        <div className={cx(surface.paper, paperBox)}>
          <div className={cx(surface.field, surface.mono, fieldBox)}>
            pnpm --filter @mildang/design-system check-types
          </div>
        </div>
      </div>

      <div className={cell}>
        <span className={label}>ghostButton / inkButton</span>
        <div className={row}>
          <button type="button" aria-label="닫기" className={cx(surface.ghostButton, ghostSize)}>
            <CloseIcon />
          </button>
          <button type="button" className={cx(surface.inkButton, inkSize)}>
            <CheckIcon width={16} height={16} />
            승인
          </button>
        </div>
      </div>

      <div className={cell}>
        <span className={label}>mono — 수치/파일명 등 등폭 라벨</span>
        <div className={row}>
          <span className={surface.mono}>+128 −34</span>
          <span className={cx(surface.mono, css({ color: 'neutral.text.low' }))}>1.8s · 2,412 tokens</span>
        </div>
      </div>
    </div>
  );

export default ChatSurfaceCatalogExample;

완성 채팅 화면

메시지, 사고 과정, 액션, 분기, 컴포저를 한 화면에 조립한다.

코드

// ShowcaseDemo.example.tsx
import { css } from "@mildang/styled-system/css";
import {
  Thread,
  Message,
  Composer,
  ActionBar,
  BranchPicker,
  ChainOfThought,
  Markdown,
  TextShimmer
} from "@mildang/design-system/unofficial/Chat";

// 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>;
}

// ShowcaseDemo.example.tsx
var assistantMarkdown = `이차방정식 \`ax² + bx + c = 0\` 의 근은 **근의 공식**으로 구할 수 있어요.

\`\`\`ts
const solve = (a: number, b: number, c: number) => {
  const d = b * b - 4 * a * c;
  return [(-b + Math.sqrt(d)) / (2 * a), (-b - Math.sqrt(d)) / (2 * a)];
};
\`\`\`

판별식 \`d\` 의 부호로 근의 개수를 알 수 있습니다.`;
var messages = [
  { role: "user", content: "이차방정식 근 구하는 법 알려줘" },
  { role: "assistant", content: assistantMarkdown }
];
var wrapper = css({
  height: "100dvh"
});
var metaRow = css({
  display: "flex",
  alignItems: "center",
  gap: "8",
  marginTop: "4"
});
var MarkdownText = () => <Markdown />;
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts />
    </Message.Content>
    <Message.Meta>오후 3:23</Message.Meta>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <ChainOfThought defaultOpen sx={{ marginBottom: "8" }}>
      <ChainOfThought.Trigger duration={4} />
      <ChainOfThought.Content>
        <ChainOfThought.Steps>
          <ChainOfThought.Step label="문제 파악">
            사용자가 이차방정식의 근을 구하는 방법을 물었어요.
          </ChainOfThought.Step>
          <ChainOfThought.Step label="풀이 전략">
            근의 공식으로 설명하고 코드 예시를 덧붙이기로 했어요.
          </ChainOfThought.Step>
          <ChainOfThought.Step label="답안 구성">판별식으로 근의 개수까지 정리했어요.</ChainOfThought.Step>
        </ChainOfThought.Steps>
      </ChainOfThought.Content>
    </ChainOfThought>

    <Message.Content>
      <Message.Parts components={{ Text: MarkdownText, Empty: ThinkingPlaceholder }} />
    </Message.Content>

    <div className={metaRow}>
      <ActionBar hideWhenRunning autohide="not-last">
        <ActionBar.Copy />
        <ActionBar.Reload />
        <ActionBar.FeedbackPositive />
        <ActionBar.FeedbackNegative />
      </ActionBar>
      <BranchPicker>
        <BranchPicker.Previous />
        <BranchPicker.Number /> / <BranchPicker.Count />
        <BranchPicker.Next />
      </BranchPicker>
      <Message.Meta>오후 3:24</Message.Meta>
    </div>
  </Message>;
var ShowcaseDemoExample = () => <MockRuntimeProvider initialMessages={messages}>
    <div className={wrapper}>
      <Thread
  components={{ UserMessage, AssistantMessage }}
  composer={<Composer>
            <Composer.Input placeholder="메시지를 입력하세요… (Enter 전송, Shift+Enter 줄바꿈)" />
            <Composer.Footer>
              <Composer.LeadingActions>
                <Composer.AddAttachment />
              </Composer.LeadingActions>
              <Composer.TrailingActions>
                <Thread.If running={false}>
                  <Composer.Send />
                </Thread.If>
                <Thread.If running>
                  <Composer.Cancel />
                </Thread.If>
              </Composer.TrailingActions>
            </Composer.Footer>
          </Composer>}
/>
    </div>
  </MockRuntimeProvider>;
var ShowcaseDemo_example_default = ShowcaseDemoExample;
export {
  ShowcaseDemo_example_default as default
};

빈 채팅 화면

Welcome과 추천 프롬프트, 컴포저를 조립한 빈 스레드다.

코드

// ShowcaseWelcome.example.tsx
import { css } from "@mildang/styled-system/css";
import {
  Thread,
  Message,
  Composer,
  ActionBar,
  BranchPicker,
  ThreadWelcome,
  ChainOfThought,
  Markdown,
  TextShimmer
} from "@mildang/design-system/unofficial/Chat";

// 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>;
}

// ShowcaseWelcome.example.tsx
var suggestions = ["오늘 학습 요약해줘", "틀린 문제 다시 풀래", "다음 수업 준비물 알려줘"];
var wrapper = css({
  height: "100dvh"
});
var metaRow = css({
  display: "flex",
  alignItems: "center",
  gap: "8",
  marginTop: "4"
});
var MarkdownText = () => <Markdown />;
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts />
    </Message.Content>
    <Message.Meta>오후 3:23</Message.Meta>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <ChainOfThought defaultOpen sx={{ marginBottom: "8" }}>
      <ChainOfThought.Trigger duration={4} />
      <ChainOfThought.Content>
        <ChainOfThought.Steps>
          <ChainOfThought.Step label="문제 파악">
            사용자가 이차방정식의 근을 구하는 방법을 물었어요.
          </ChainOfThought.Step>
          <ChainOfThought.Step label="풀이 전략">
            근의 공식으로 설명하고 코드 예시를 덧붙이기로 했어요.
          </ChainOfThought.Step>
          <ChainOfThought.Step label="답안 구성">판별식으로 근의 개수까지 정리했어요.</ChainOfThought.Step>
        </ChainOfThought.Steps>
      </ChainOfThought.Content>
    </ChainOfThought>

    <Message.Content>
      <Message.Parts components={{ Text: MarkdownText, Empty: ThinkingPlaceholder }} />
    </Message.Content>

    <div className={metaRow}>
      <ActionBar hideWhenRunning autohide="not-last">
        <ActionBar.Copy />
        <ActionBar.Reload />
        <ActionBar.FeedbackPositive />
        <ActionBar.FeedbackNegative />
      </ActionBar>
      <BranchPicker>
        <BranchPicker.Previous />
        <BranchPicker.Number /> / <BranchPicker.Count />
        <BranchPicker.Next />
      </BranchPicker>
      <Message.Meta>오후 3:24</Message.Meta>
    </div>
  </Message>;
var ShowcaseWelcomeExample = () => <MockRuntimeProvider>
    <div className={wrapper}>
      <Thread
  components={{ UserMessage, AssistantMessage }}
  empty={<ThreadWelcome>
            <ThreadWelcome.Message>무엇이든 물어보세요. 밀당 선생님이 도와드릴게요.</ThreadWelcome.Message>
            <ThreadWelcome.Suggestions>
              {suggestions.map((prompt) => <ThreadWelcome.Suggestion key={prompt} prompt={prompt} send label={prompt} />)}
            </ThreadWelcome.Suggestions>
          </ThreadWelcome>}
  composer={<Composer>
            <Composer.Input placeholder="첫 메시지를 보내보세요…" />
            <Composer.Footer>
              <Composer.TrailingActions>
                <Composer.Send />
              </Composer.TrailingActions>
            </Composer.Footer>
          </Composer>}
/>
    </div>
  </MockRuntimeProvider>;
var ShowcaseWelcome_example_default = ShowcaseWelcomeExample;
export {
  ShowcaseWelcome_example_default as default
};

스트리밍 채팅 — Mock

서버 없이 로컬 SSE로 reasoning, tool call, 응답 스트리밍을 재현한다.

코드

// LiveShowcaseMock.example.tsx
import { css } from "@mildang/styled-system/css";
import {
  Thread,
  Message,
  Composer as Composer2,
  ActionBar,
  BranchPicker,
  ThreadWelcome,
  ThreadList,
  ThreadListItem,
  Markdown,
  MessageError,
  Tool,
  TextShimmer,
  SystemMessage
} from "@mildang/design-system/unofficial/Chat";
import { Sidebar } from "@mildang/design-system/Sidebar";

// aiSdkRuntime.tsx
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
  useRemoteThreadListRuntime
} from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { streamText } from "ai";
import { browserAI, doesBrowserSupportBrowserAI } from "@browser-ai/core";
import { useCallback, useEffect, useMemo, useState } from "react";
var TOOL_NAME = "getStudentProgress";
var TOOL_CALL_ID = "call-get-student-progress-1";
var TOOL_INPUT = { studentId: "stu-1024", period: "last_4_weeks" };
var TOOL_OUTPUT = {
  studentId: "stu-1024",
  studentName: "김민준",
  period: "last_4_weeks",
  weeks: [
    { label: "1주차", studyHours: 4.5, completedAssignments: 12, accuracy: 0.82 },
    { label: "2주차", studyHours: 5.2, completedAssignments: 15, accuracy: 0.86 },
    { label: "3주차", studyHours: 3.8, completedAssignments: 9, accuracy: 0.79 },
    { label: "4주차", studyHours: 6.1, completedAssignments: 18, accuracy: 0.91 }
  ]
};
var STEP1_REASONING = "사용자가 특정 학생의 학습 현황을 물었다. 정확한 수치를 답하려면 추측 대신 getStudentProgress 도구로 최근 4주 데이터를 먼저 조회하는 게 맞겠다.";
var STEP2_REASONING = "도구 결과를 받았다. 주차별 학습 시간·완료 과제·정답률을 표로 정리하고, 주간 목표를 코드로 관리하는 예시를 덧붙여 실천 팁까지 제시하자.";
var STEP2_ANSWER = `민준 학생의 **최근 4주 학습 현황**을 정리했어요.

| 주차 | 학습 시간 | 완료 과제 | 정답률 |
| --- | --- | --- | --- |
| 1주차 | 4.5h | 12개 | 82% |
| 2주차 | 5.2h | 15개 | 86% |
| 3주차 | 3.8h | 9개 | 79% |
| 4주차 | 6.1h | 18개 | 91% |

3주차에 학습량이 잠깐 줄었지만, 4주차에 다시 회복하면서 정답률이 **91%** 까지 올랐어요.

주간 목표를 아래처럼 코드로 관리하면 추적이 훨씬 쉬워요.

\`\`\`ts
const weeklyGoal = {
  studyHours: 6, // 주당 목표 학습 시간
  assignments: 16, // 주당 완료 과제 수
  targetAccuracy: 0.9, // 목표 정답률
};
\`\`\`

다음 주에는 **오답 복습 30분** 을 추가로 배정하는 걸 추천해요.`;
var tokenize = (text) => text.match(/\s*\S+|\s+/g) ?? [text];
var buildMockChunks = () => {
  const inputJson = JSON.stringify(TOOL_INPUT);
  const inputSlices = [inputJson.slice(0, 18), inputJson.slice(18, 34), inputJson.slice(34)];
  return [
    { type: "start", messageId: `mock-${Date.now()}` },
    { type: "start-step" },
    { type: "reasoning-start", id: "reason-step1" },
    ...tokenize(STEP1_REASONING).map((delta) => ({ type: "reasoning-delta", id: "reason-step1", delta })),
    { type: "reasoning-end", id: "reason-step1" },
    { type: "tool-input-start", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, dynamic: false },
    ...inputSlices.map((inputTextDelta) => ({
      type: "tool-input-delta",
      toolCallId: TOOL_CALL_ID,
      inputTextDelta
    })),
    { type: "tool-input-available", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, input: TOOL_INPUT },
    { type: "tool-output-available", toolCallId: TOOL_CALL_ID, output: TOOL_OUTPUT },
    { type: "finish-step" },
    { type: "start-step" },
    { type: "reasoning-start", id: "reason-step2" },
    ...tokenize(STEP2_REASONING).map((delta) => ({ type: "reasoning-delta", id: "reason-step2", delta })),
    { type: "reasoning-end", id: "reason-step2" },
    { type: "text-start", id: "text-final" },
    ...tokenize(STEP2_ANSWER).map((delta) => ({ type: "text-delta", id: "text-final", delta })),
    { type: "text-end", id: "text-final" },
    { type: "finish-step" },
    { type: "finish" }
  ];
};
var encoder = new TextEncoder();
var sleep = (ms, signal) => new Promise((resolve, reject) => {
  if (signal?.aborted) {
    reject(new DOMException("Aborted", "AbortError"));
    return;
  }
  const timer = setTimeout(resolve, ms);
  signal?.addEventListener(
    "abort",
    () => {
      clearTimeout(timer);
      reject(new DOMException("Aborted", "AbortError"));
    },
    { once: true }
  );
});
var delayFor = (chunk) => {
  switch (chunk.type) {
    case "reasoning-delta":
    case "text-delta":
      return 20 + Math.random() * 20;
    case "tool-input-delta":
      return 40;
    case "tool-output-available":
      return 500;
    default:
      return 60;
  }
};
var mockChatFetch = (_input, init) => {
  const signal = init?.signal ?? void 0;
  const chunks = buildMockChunks();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        await sleep(200, signal);
        for (const chunk of chunks) {
          if (signal?.aborted) break;
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}

`));
          await sleep(delayFor(chunk), signal);
        }
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      } catch (error) {
        if (error instanceof DOMException && error.name === "AbortError") {
          controller.close();
          return;
        }
        controller.error(error);
      }
    }
  });
  return Promise.resolve(
    new Response(stream, {
      status: 200,
      headers: {
        "Content-Type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1"
      }
    })
  );
};
var seededThreads = [
  {
    status: "regular",
    remoteId: "thread-1",
    title: "민준이 학습 코칭",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T15:24:00")
  },
  {
    status: "regular",
    remoteId: "thread-2",
    title: "영어 오답 복습 계획",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T11:02:00")
  },
  {
    status: "regular",
    remoteId: "thread-3",
    title: "수학 개념 정리 요약",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-04T20:10:00")
  }
];
var seededThreadListAdapter = {
  async list() {
    return { threads: seededThreads };
  },
  async rename() {
  },
  async archive() {
  },
  async unarchive() {
  },
  async delete() {
  },
  async initialize(threadId) {
    return { remoteId: threadId, externalId: void 0 };
  },
  async fetch(threadId) {
    return seededThreads.find((thread) => thread.remoteId === threadId) ?? {
      status: "regular",
      remoteId: threadId
    };
  },
  // 모든 시드 스레드가 title 을 가지므로 자동 제목 생성은 무한 pending 으로 안전하게 둔다.
  generateTitle: (() => new Promise(() => {
  }))
};
var useMockAssistantRuntime = () => {
  const transport = useMemo(() => new AssistantChatTransport({ fetch: mockChatFetch }), []);
  return useChatRuntime({ transport });
};
function ThreadListWrappedProvider({
  runtimeHook,
  children
}) {
  const [threadId, setThreadId] = useState("thread-1");
  const runtime = useRemoteThreadListRuntime({
    runtimeHook,
    adapter: seededThreadListAdapter,
    threadId,
    onThreadIdChange: setThreadId
  });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}
function MockChatRuntimeProvider({ children }) {
  return <ThreadListWrappedProvider runtimeHook={useMockAssistantRuntime}>{children}</ThreadListWrappedProvider>;
}

// triggerScenario.tsx
import {
  makeAssistantTool,
  unstable_defaultDirectiveFormatter,
  unstable_useMentionAdapter,
  unstable_useSlashCommandAdapter
} from "@assistant-ui/react";
import GraduationCapIcon from "@mildang/icons/react/graduation_cap";
import MagicWandIcon from "@mildang/icons/react/magic-wand";
import ReportPencilIcon from "@mildang/icons/react/report-pencil";
import BookIcon from "@mildang/icons/react/book";
import { Composer, createDirectiveChip, createDirectiveText } from "@mildang/design-system/unofficial/Chat";
var TRIGGER_ICON_MAP = {
  student: GraduationCapIcon,
  tool: MagicWandIcon,
  command: MagicWandIcon,
  report: ReportPencilIcon,
  context: BookIcon
};
var TRIGGER_FALLBACK_ICON = MagicWandIcon;
var resolveTriggerIcon = (item, iconMap = TRIGGER_ICON_MAP, fallbackIcon = TRIGGER_FALLBACK_ICON) => {
  const key = typeof item.metadata?.icon === "string" ? item.metadata.icon : void 0;
  const Icon = (key ? iconMap[key] : void 0) ?? fallbackIcon;
  return Icon ? <Icon /> : void 0;
};
var MILDANG_STUDENTS = [
  { id: "student-minjun", type: "student", label: "민준", description: "중2 · 수학", icon: "student" },
  { id: "student-seoyeon", type: "student", label: "서연", description: "중3 · 영어", icon: "student" },
  { id: "student-jiho", type: "student", label: "지호", description: "고1 · 국어", icon: "student" },
  { id: "student-hayoon", type: "student", label: "하윤", description: "고2 · 과학", icon: "student" }
];
var MILDANG_DIRECTIVE_CHIP_OPTIONS = {
  iconMap: {
    student: GraduationCapIcon,
    tool: MagicWandIcon,
    command: MagicWandIcon,
    context: BookIcon,
    report: ReportPencilIcon
  },
  colorMap: {
    student: "brand",
    tool: "orange",
    command: "blue",
    context: "green",
    report: "red"
  },
  fallbackColor: "neutral"
};
var MildangDirectiveChip = createDirectiveChip(MILDANG_DIRECTIVE_CHIP_OPTIONS);
var MildangDirectiveText = createDirectiveText(
  unstable_defaultDirectiveFormatter,
  MILDANG_DIRECTIVE_CHIP_OPTIONS
);
var emptyParams = { type: "object", properties: {} };
var StudentProgressTool = makeAssistantTool({
  toolName: "getStudentProgress",
  description: "학생의 최근 학습 현황을 조회합니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var WrongAnswerTool = makeAssistantTool({
  toolName: "analyzeWrongAnswers",
  description: "최근 오답을 분석해 취약 유형을 찾습니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var MentionPopover = ({ onInserted }) => {
  const mention = unstable_useMentionAdapter({
    categories: [{ id: "students", label: "학생", items: MILDANG_STUDENTS }],
    includeModelContextTools: { category: { id: "tools", label: "도구" }, icon: "tool" },
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON,
    ...onInserted ? { onInserted } : {}
  });
  return <Composer.TriggerPopover char="@" adapter={mention.adapter}>
      <Composer.TriggerPopover.Directive {...mention.directive} />
      <Composer.TriggerPopover.Categories>
        {(categories) => categories.map((category) => <Composer.TriggerPopover.CategoryItem
    key={category.id}
    categoryId={category.id}
    icon={category.id === "tools" ? <MagicWandIcon /> : <GraduationCapIcon />}
  >
              {category.label}
            </Composer.TriggerPopover.CategoryItem>)}
      </Composer.TriggerPopover.Categories>
      <Composer.TriggerPopover.Back>학생·도구 목록</Composer.TriggerPopover.Back>
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>일치하는 대상이 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};
var buildAuditCommands = (log) => [
  {
    id: "요약",
    label: "/요약",
    description: "오늘 학습을 요약하는 프롬프트 삽입",
    icon: "report",
    execute: () => log("요약")
  },
  {
    id: "추천",
    label: "/추천",
    description: "다음 학습 목표 추천",
    icon: "command",
    execute: () => log("추천")
  },
  {
    id: "리포트",
    label: "/리포트",
    description: "학습 리포트 생성",
    icon: "tool",
    execute: () => log("리포트")
  }
];
var SlashPopover = ({ commands, removeOnExecute }) => {
  const slash = unstable_useSlashCommandAdapter({
    commands,
    ...removeOnExecute !== void 0 ? { removeOnExecute } : {},
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON
  });
  return <Composer.TriggerPopover char="/" adapter={slash.adapter}>
      <Composer.TriggerPopover.Action {...slash.action} />
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>커맨드를 찾을 수 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};

// LiveShowcaseMock.example.tsx
var suggestions = ["민준이 최근 학습 현황 어때?", "이번 주 학습 목표 추천해줘", "오답 복습 계획 짜줘"];
var shell = css({
  height: "100dvh",
  overflow: "hidden"
});
var threadCol = css({
  position: "relative",
  display: "flex",
  flexDirection: "column",
  flex: "1",
  minHeight: "0",
  minWidth: "0"
});
var metaRow = css({ display: "flex", alignItems: "center", gap: "8", marginTop: "4" });
var MarkdownText = () => <Markdown />;
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var SystemMessageSlot = () => <SystemMessage>
    <SystemMessage.Content>
      <SystemMessage.Parts />
    </SystemMessage.Content>
  </SystemMessage>;
var SidebarItem = () => <ThreadListItem>
    <ThreadListItem.Trigger>
      <ThreadListItem.Title />
    </ThreadListItem.Trigger>
    <ThreadListItem.More />
  </ThreadListItem>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts components={{ Text: MildangDirectiveText }} />
    </Message.Content>
    <Message.Meta>방금</Message.Meta>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <Message.Content>
      <Message.Parts
  components={{
    Text: MarkdownText,
    Empty: ThinkingPlaceholder,
    Reasoning: Message.Reasoning,
    tools: { Fallback: Tool.Fallback },
    ToolGroup: Tool.GroupByIndices
  }}
/>
    </Message.Content>

    <Message.Error>
      <MessageError sx={{ marginTop: "8" }} />
    </Message.Error>

    <div className={metaRow}>
      <ActionBar hideWhenRunning autohide="not-last">
        <ActionBar.Copy />
        <ActionBar.Reload />
        <ActionBar.More />
      </ActionBar>
      <BranchPicker>
        <BranchPicker.Previous />
        <BranchPicker.Number /> / <BranchPicker.Count />
        <BranchPicker.Next />
      </BranchPicker>
      <Message.Meta>방금</Message.Meta>
    </div>
  </Message>;
var Welcome = () => <ThreadWelcome>
    <ThreadWelcome.Message>
      무엇이든 물어보세요. 밀당 선생님이 학습 현황을 살펴보고 도와드릴게요.
    </ThreadWelcome.Message>
    <ThreadWelcome.Suggestions>
      {suggestions.map((prompt) => <ThreadWelcome.Suggestion key={prompt} prompt={prompt} send label={prompt} />)}
    </ThreadWelcome.Suggestions>
  </ThreadWelcome>;
var liveSlashCommands = buildAuditCommands(() => {
});
var ChatComposer = () => <Composer2.TriggerPopoverRoot>
    <Composer2>
      <MentionPopover />
      <SlashPopover commands={liveSlashCommands} />
      <Composer2.RichInput
  directiveChip={MildangDirectiveChip}
  placeholder="메시지 입력 — @ 로 학생 멘션, / 로 커맨드 (Enter 전송)"
/>
      <Composer2.Footer>
        <Composer2.LeadingActions>
          <Composer2.AddAttachment />
          <Composer2.Dictate />
        </Composer2.LeadingActions>
        <Composer2.TrailingActions>
          <Thread.If running={false}>
            <Composer2.Send />
          </Thread.If>
          <Thread.If running>
            <Composer2.Cancel />
          </Thread.If>
        </Composer2.TrailingActions>
      </Composer2.Footer>
    </Composer2>
  </Composer2.TriggerPopoverRoot>;
var ChatSurface = () => <div className={shell}>
    <Sidebar.Provider>
      <Sidebar.Root>
        <Sidebar.Content>
          <Sidebar.Group.Root>
            <Sidebar.Group.Label>대화</Sidebar.Group.Label>
            <ThreadList>
              <ThreadList.New />
              <ThreadList.Items components={{ ThreadListItem: SidebarItem }} />
            </ThreadList>
          </Sidebar.Group.Root>
        </Sidebar.Content>
      </Sidebar.Root>

      <Sidebar.Inset>
        <div className={threadCol}>
          <Thread
  components={{ UserMessage, AssistantMessage, SystemMessage: SystemMessageSlot }}
  empty={<Welcome />}
  composer={<ChatComposer />}
/>
        </div>
      </Sidebar.Inset>
    </Sidebar.Provider>
  </div>;
var LiveShowcaseMockExample = () => <MockChatRuntimeProvider>
    <ChatSurface />
  </MockChatRuntimeProvider>;
var LiveShowcaseMock_example_default = LiveShowcaseMockExample;
export {
  LiveShowcaseMock_example_default as default
};

스트리밍 채팅 — Mastra

localhost:4111의 Mastra 데모 서버에 연결하는 수동 실행 예제다.

코드

// LiveShowcaseMastra.example.tsx
import { css } from "@mildang/styled-system/css";
import {
  Thread,
  Message,
  Composer as Composer2,
  ActionBar,
  BranchPicker,
  ThreadWelcome,
  ThreadList,
  ThreadListItem,
  Markdown,
  MessageError,
  Tool,
  TextShimmer,
  SystemMessage
} from "@mildang/design-system/unofficial/Chat";
import { Sidebar } from "@mildang/design-system/Sidebar";

// aiSdkRuntime.tsx
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
  useRemoteThreadListRuntime
} from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { streamText } from "ai";
import { browserAI, doesBrowserSupportBrowserAI } from "@browser-ai/core";
import { useCallback, useEffect, useMemo, useState } from "react";
var encoder = new TextEncoder();
var LIVE_API = "http://localhost:4111/chat/chatDemoAgent";
var guardedLiveFetch = async (input, init) => {
  try {
    return await fetch(input, init);
  } catch {
    throw new Error(
      "라이브 데모 서버(localhost:4111)에 연결할 수 없어요. `pnpm --filter @mildang/agent-chat-demo dev` 로 서버를 먼저 실행한 뒤 다시 시도해 주세요."
    );
  }
};
var seededThreads = [
  {
    status: "regular",
    remoteId: "thread-1",
    title: "민준이 학습 코칭",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T15:24:00")
  },
  {
    status: "regular",
    remoteId: "thread-2",
    title: "영어 오답 복습 계획",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T11:02:00")
  },
  {
    status: "regular",
    remoteId: "thread-3",
    title: "수학 개념 정리 요약",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-04T20:10:00")
  }
];
var seededThreadListAdapter = {
  async list() {
    return { threads: seededThreads };
  },
  async rename() {
  },
  async archive() {
  },
  async unarchive() {
  },
  async delete() {
  },
  async initialize(threadId) {
    return { remoteId: threadId, externalId: void 0 };
  },
  async fetch(threadId) {
    return seededThreads.find((thread) => thread.remoteId === threadId) ?? {
      status: "regular",
      remoteId: threadId
    };
  },
  // 모든 시드 스레드가 title 을 가지므로 자동 제목 생성은 무한 pending 으로 안전하게 둔다.
  generateTitle: (() => new Promise(() => {
  }))
};
var useLiveAssistantRuntime = () => {
  const transport = useMemo(
    () => new AssistantChatTransport({ api: LIVE_API, fetch: guardedLiveFetch }),
    []
  );
  return useChatRuntime({ transport });
};
function ThreadListWrappedProvider({
  runtimeHook,
  children
}) {
  const [threadId, setThreadId] = useState("thread-1");
  const runtime = useRemoteThreadListRuntime({
    runtimeHook,
    adapter: seededThreadListAdapter,
    threadId,
    onThreadIdChange: setThreadId
  });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}
function MastraChatRuntimeProvider({ children }) {
  return <ThreadListWrappedProvider runtimeHook={useLiveAssistantRuntime}>{children}</ThreadListWrappedProvider>;
}

// triggerScenario.tsx
import {
  makeAssistantTool,
  unstable_defaultDirectiveFormatter,
  unstable_useMentionAdapter,
  unstable_useSlashCommandAdapter
} from "@assistant-ui/react";
import GraduationCapIcon from "@mildang/icons/react/graduation_cap";
import MagicWandIcon from "@mildang/icons/react/magic-wand";
import ReportPencilIcon from "@mildang/icons/react/report-pencil";
import BookIcon from "@mildang/icons/react/book";
import { Composer, createDirectiveChip, createDirectiveText } from "@mildang/design-system/unofficial/Chat";
var TRIGGER_ICON_MAP = {
  student: GraduationCapIcon,
  tool: MagicWandIcon,
  command: MagicWandIcon,
  report: ReportPencilIcon,
  context: BookIcon
};
var TRIGGER_FALLBACK_ICON = MagicWandIcon;
var resolveTriggerIcon = (item, iconMap = TRIGGER_ICON_MAP, fallbackIcon = TRIGGER_FALLBACK_ICON) => {
  const key = typeof item.metadata?.icon === "string" ? item.metadata.icon : void 0;
  const Icon = (key ? iconMap[key] : void 0) ?? fallbackIcon;
  return Icon ? <Icon /> : void 0;
};
var MILDANG_STUDENTS = [
  { id: "student-minjun", type: "student", label: "민준", description: "중2 · 수학", icon: "student" },
  { id: "student-seoyeon", type: "student", label: "서연", description: "중3 · 영어", icon: "student" },
  { id: "student-jiho", type: "student", label: "지호", description: "고1 · 국어", icon: "student" },
  { id: "student-hayoon", type: "student", label: "하윤", description: "고2 · 과학", icon: "student" }
];
var MILDANG_DIRECTIVE_CHIP_OPTIONS = {
  iconMap: {
    student: GraduationCapIcon,
    tool: MagicWandIcon,
    command: MagicWandIcon,
    context: BookIcon,
    report: ReportPencilIcon
  },
  colorMap: {
    student: "brand",
    tool: "orange",
    command: "blue",
    context: "green",
    report: "red"
  },
  fallbackColor: "neutral"
};
var MildangDirectiveChip = createDirectiveChip(MILDANG_DIRECTIVE_CHIP_OPTIONS);
var MildangDirectiveText = createDirectiveText(
  unstable_defaultDirectiveFormatter,
  MILDANG_DIRECTIVE_CHIP_OPTIONS
);
var emptyParams = { type: "object", properties: {} };
var StudentProgressTool = makeAssistantTool({
  toolName: "getStudentProgress",
  description: "학생의 최근 학습 현황을 조회합니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var WrongAnswerTool = makeAssistantTool({
  toolName: "analyzeWrongAnswers",
  description: "최근 오답을 분석해 취약 유형을 찾습니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var MentionPopover = ({ onInserted }) => {
  const mention = unstable_useMentionAdapter({
    categories: [{ id: "students", label: "학생", items: MILDANG_STUDENTS }],
    includeModelContextTools: { category: { id: "tools", label: "도구" }, icon: "tool" },
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON,
    ...onInserted ? { onInserted } : {}
  });
  return <Composer.TriggerPopover char="@" adapter={mention.adapter}>
      <Composer.TriggerPopover.Directive {...mention.directive} />
      <Composer.TriggerPopover.Categories>
        {(categories) => categories.map((category) => <Composer.TriggerPopover.CategoryItem
    key={category.id}
    categoryId={category.id}
    icon={category.id === "tools" ? <MagicWandIcon /> : <GraduationCapIcon />}
  >
              {category.label}
            </Composer.TriggerPopover.CategoryItem>)}
      </Composer.TriggerPopover.Categories>
      <Composer.TriggerPopover.Back>학생·도구 목록</Composer.TriggerPopover.Back>
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>일치하는 대상이 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};
var buildAuditCommands = (log) => [
  {
    id: "요약",
    label: "/요약",
    description: "오늘 학습을 요약하는 프롬프트 삽입",
    icon: "report",
    execute: () => log("요약")
  },
  {
    id: "추천",
    label: "/추천",
    description: "다음 학습 목표 추천",
    icon: "command",
    execute: () => log("추천")
  },
  {
    id: "리포트",
    label: "/리포트",
    description: "학습 리포트 생성",
    icon: "tool",
    execute: () => log("리포트")
  }
];
var SlashPopover = ({ commands, removeOnExecute }) => {
  const slash = unstable_useSlashCommandAdapter({
    commands,
    ...removeOnExecute !== void 0 ? { removeOnExecute } : {},
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON
  });
  return <Composer.TriggerPopover char="/" adapter={slash.adapter}>
      <Composer.TriggerPopover.Action {...slash.action} />
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>커맨드를 찾을 수 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};

// LiveShowcaseMastra.example.tsx
var suggestions = ["민준이 최근 학습 현황 어때?", "이번 주 학습 목표 추천해줘", "오답 복습 계획 짜줘"];
var shell = css({
  height: "100dvh",
  overflow: "hidden"
});
var threadCol = css({
  position: "relative",
  display: "flex",
  flexDirection: "column",
  flex: "1",
  minHeight: "0",
  minWidth: "0"
});
var metaRow = css({ display: "flex", alignItems: "center", gap: "8", marginTop: "4" });
var banner = css({
  display: "flex",
  flexDirection: "column",
  gap: "8",
  margin: "12",
  padding: "12",
  borderRadius: "md",
  borderWidth: "1px",
  borderStyle: "solid",
  borderColor: "info.border.base",
  backgroundColor: "info.surface.base",
  color: "info.text.base",
  textStyle: "chat-caption-R"
});
var MarkdownText = () => <Markdown />;
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var SystemMessageSlot = () => <SystemMessage>
    <SystemMessage.Content>
      <SystemMessage.Parts />
    </SystemMessage.Content>
  </SystemMessage>;
var SidebarItem = () => <ThreadListItem>
    <ThreadListItem.Trigger>
      <ThreadListItem.Title />
    </ThreadListItem.Trigger>
    <ThreadListItem.More />
  </ThreadListItem>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts components={{ Text: MildangDirectiveText }} />
    </Message.Content>
    <Message.Meta>방금</Message.Meta>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <Message.Content>
      <Message.Parts
  components={{
    Text: MarkdownText,
    Empty: ThinkingPlaceholder,
    Reasoning: Message.Reasoning,
    tools: { Fallback: Tool.Fallback },
    ToolGroup: Tool.GroupByIndices
  }}
/>
    </Message.Content>

    <Message.Error>
      <MessageError sx={{ marginTop: "8" }} />
    </Message.Error>

    <div className={metaRow}>
      <ActionBar hideWhenRunning autohide="not-last">
        <ActionBar.Copy />
        <ActionBar.Reload />
        <ActionBar.More />
      </ActionBar>
      <BranchPicker>
        <BranchPicker.Previous />
        <BranchPicker.Number /> / <BranchPicker.Count />
        <BranchPicker.Next />
      </BranchPicker>
      <Message.Meta>방금</Message.Meta>
    </div>
  </Message>;
var Welcome = () => <ThreadWelcome>
    <ThreadWelcome.Message>
      무엇이든 물어보세요. 밀당 선생님이 학습 현황을 살펴보고 도와드릴게요.
    </ThreadWelcome.Message>
    <ThreadWelcome.Suggestions>
      {suggestions.map((prompt) => <ThreadWelcome.Suggestion key={prompt} prompt={prompt} send label={prompt} />)}
    </ThreadWelcome.Suggestions>
  </ThreadWelcome>;
var liveSlashCommands = buildAuditCommands(() => {
});
var ChatComposer = () => <Composer2.TriggerPopoverRoot>
    <Composer2>
      <MentionPopover />
      <SlashPopover commands={liveSlashCommands} />
      <Composer2.RichInput
  directiveChip={MildangDirectiveChip}
  placeholder="메시지 입력 — @ 로 학생 멘션, / 로 커맨드 (Enter 전송)"
/>
      <Composer2.Footer>
        <Composer2.LeadingActions>
          <Composer2.AddAttachment />
          <Composer2.Dictate />
        </Composer2.LeadingActions>
        <Composer2.TrailingActions>
          <Thread.If running={false}>
            <Composer2.Send />
          </Thread.If>
          <Thread.If running>
            <Composer2.Cancel />
          </Thread.If>
        </Composer2.TrailingActions>
      </Composer2.Footer>
    </Composer2>
  </Composer2.TriggerPopoverRoot>;
var MastraBanner = () => <div className={banner}>
    <strong>라이브(Mastra) 모드</strong>
    <span>
      이 데모는 로컬 Mastra 서버가 필요합니다: <code>pnpm --filter @mildang/agent-chat-demo dev</code>{" "}
      → http://localhost:4111
    </span>
    <span>서버가 없으면 전송 시 어시스턴트 메시지에 오류 배너로 안내됩니다.</span>
  </div>;
var ChatSurface = () => <div className={shell}>
    <Sidebar.Provider>
      <Sidebar.Root>
        <Sidebar.Content>
          <Sidebar.Group.Root>
            <Sidebar.Group.Label>대화</Sidebar.Group.Label>
            <ThreadList>
              <ThreadList.New />
              <ThreadList.Items components={{ ThreadListItem: SidebarItem }} />
            </ThreadList>
          </Sidebar.Group.Root>
        </Sidebar.Content>
      </Sidebar.Root>

      <Sidebar.Inset>
        <div className={threadCol}>
          <MastraBanner />
          <Thread
  components={{ UserMessage, AssistantMessage, SystemMessage: SystemMessageSlot }}
  empty={<Welcome />}
  composer={<ChatComposer />}
/>
        </div>
      </Sidebar.Inset>
    </Sidebar.Provider>
  </div>;
var LiveShowcaseMastraExample = () => <MastraChatRuntimeProvider>
    <ChatSurface />
  </MastraChatRuntimeProvider>;
var LiveShowcaseMastra_example_default = LiveShowcaseMastraExample;
export {
  LiveShowcaseMastra_example_default as default
};

스트리밍 채팅 — Browser AI

지원 브라우저의 온디바이스 모델로 실행하는 수동 예제다.

코드

// LiveShowcaseBrowserAI.example.tsx
import { css } from "@mildang/styled-system/css";
import {
  Thread,
  Message,
  Composer as Composer2,
  ActionBar,
  BranchPicker,
  ThreadWelcome,
  ThreadList,
  ThreadListItem,
  Markdown,
  MessageError,
  Tool,
  TextShimmer,
  SystemMessage
} from "@mildang/design-system/unofficial/Chat";
import { Sidebar } from "@mildang/design-system/Sidebar";
import { Button } from "@mildang/design-system/Button";

// aiSdkRuntime.tsx
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
  useRemoteThreadListRuntime
} from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { streamText } from "ai";
import { browserAI, doesBrowserSupportBrowserAI } from "@browser-ai/core";
import { useCallback, useEffect, useMemo, useState } from "react";
var encoder = new TextEncoder();
var seededThreads = [
  {
    status: "regular",
    remoteId: "thread-1",
    title: "민준이 학습 코칭",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T15:24:00")
  },
  {
    status: "regular",
    remoteId: "thread-2",
    title: "영어 오답 복습 계획",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-05T11:02:00")
  },
  {
    status: "regular",
    remoteId: "thread-3",
    title: "수학 개념 정리 요약",
    lastMessageAt: /* @__PURE__ */ new Date("2026-08-04T20:10:00")
  }
];
var seededThreadListAdapter = {
  async list() {
    return { threads: seededThreads };
  },
  async rename() {
  },
  async archive() {
  },
  async unarchive() {
  },
  async delete() {
  },
  async initialize(threadId) {
    return { remoteId: threadId, externalId: void 0 };
  },
  async fetch(threadId) {
    return seededThreads.find((thread) => thread.remoteId === threadId) ?? {
      status: "regular",
      remoteId: threadId
    };
  },
  // 모든 시드 스레드가 title 을 가지므로 자동 제목 생성은 무한 pending 으로 안전하게 둔다.
  generateTitle: (() => new Promise(() => {
  }))
};
var browserAIAdapter = {
  async *run({ messages, abortSignal }) {
    const model = browserAI("text");
    const modelMessages = messages.map((message) => ({
      role: message.role === "assistant" ? "assistant" : message.role === "system" ? "system" : "user",
      content: message.content.map((part) => "text" in part && typeof part.text === "string" ? part.text : "").join("")
    }));
    const result = streamText({
      model,
      messages: modelMessages,
      abortSignal
    });
    let text = "";
    for await (const delta of result.textStream) {
      text += delta;
      yield { content: [{ type: "text", text }] };
    }
  }
};
var useBrowserAIAssistantRuntime = () => useLocalRuntime(browserAIAdapter);
function ThreadListWrappedProvider({
  runtimeHook,
  children
}) {
  const [threadId, setThreadId] = useState("thread-1");
  const runtime = useRemoteThreadListRuntime({
    runtimeHook,
    adapter: seededThreadListAdapter,
    threadId,
    onThreadIdChange: setThreadId
  });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}
function BrowserAIChatRuntimeProvider({ children }) {
  return <ThreadListWrappedProvider runtimeHook={useBrowserAIAssistantRuntime}>{children}</ThreadListWrappedProvider>;
}
function useBrowserAIAvailability() {
  const [status, setStatus] = useState("checking");
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState();
  useEffect(() => {
    let cancelled = false;
    void (async () => {
      if (!doesBrowserSupportBrowserAI()) {
        if (!cancelled) setStatus("unsupported");
        return;
      }
      try {
        const availability = await browserAI("text").availability();
        if (cancelled) return;
        if (availability === "available") setStatus("ready");
        else if (availability === "available-after-download") setStatus("downloadable");
        else setStatus("unavailable");
      } catch (e) {
        if (!cancelled) {
          setStatus("error");
          setError(e instanceof Error ? e.message : String(e));
        }
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);
  const download = useCallback(async () => {
    setStatus("downloading");
    setProgress(0);
    try {
      await browserAI("text").createSessionWithProgress((p) => setProgress(p));
      setStatus("ready");
    } catch (e) {
      setStatus("error");
      setError(e instanceof Error ? e.message : String(e));
    }
  }, []);
  return { status, progress, error, download };
}

// triggerScenario.tsx
import {
  makeAssistantTool,
  unstable_defaultDirectiveFormatter,
  unstable_useMentionAdapter,
  unstable_useSlashCommandAdapter
} from "@assistant-ui/react";
import GraduationCapIcon from "@mildang/icons/react/graduation_cap";
import MagicWandIcon from "@mildang/icons/react/magic-wand";
import ReportPencilIcon from "@mildang/icons/react/report-pencil";
import BookIcon from "@mildang/icons/react/book";
import { Composer, createDirectiveChip, createDirectiveText } from "@mildang/design-system/unofficial/Chat";
var TRIGGER_ICON_MAP = {
  student: GraduationCapIcon,
  tool: MagicWandIcon,
  command: MagicWandIcon,
  report: ReportPencilIcon,
  context: BookIcon
};
var TRIGGER_FALLBACK_ICON = MagicWandIcon;
var resolveTriggerIcon = (item, iconMap = TRIGGER_ICON_MAP, fallbackIcon = TRIGGER_FALLBACK_ICON) => {
  const key = typeof item.metadata?.icon === "string" ? item.metadata.icon : void 0;
  const Icon = (key ? iconMap[key] : void 0) ?? fallbackIcon;
  return Icon ? <Icon /> : void 0;
};
var MILDANG_STUDENTS = [
  { id: "student-minjun", type: "student", label: "민준", description: "중2 · 수학", icon: "student" },
  { id: "student-seoyeon", type: "student", label: "서연", description: "중3 · 영어", icon: "student" },
  { id: "student-jiho", type: "student", label: "지호", description: "고1 · 국어", icon: "student" },
  { id: "student-hayoon", type: "student", label: "하윤", description: "고2 · 과학", icon: "student" }
];
var MILDANG_DIRECTIVE_CHIP_OPTIONS = {
  iconMap: {
    student: GraduationCapIcon,
    tool: MagicWandIcon,
    command: MagicWandIcon,
    context: BookIcon,
    report: ReportPencilIcon
  },
  colorMap: {
    student: "brand",
    tool: "orange",
    command: "blue",
    context: "green",
    report: "red"
  },
  fallbackColor: "neutral"
};
var MildangDirectiveChip = createDirectiveChip(MILDANG_DIRECTIVE_CHIP_OPTIONS);
var MildangDirectiveText = createDirectiveText(
  unstable_defaultDirectiveFormatter,
  MILDANG_DIRECTIVE_CHIP_OPTIONS
);
var emptyParams = { type: "object", properties: {} };
var StudentProgressTool = makeAssistantTool({
  toolName: "getStudentProgress",
  description: "학생의 최근 학습 현황을 조회합니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var WrongAnswerTool = makeAssistantTool({
  toolName: "analyzeWrongAnswers",
  description: "최근 오답을 분석해 취약 유형을 찾습니다",
  parameters: emptyParams,
  execute: async () => ({ ok: true })
});
var MentionPopover = ({ onInserted }) => {
  const mention = unstable_useMentionAdapter({
    categories: [{ id: "students", label: "학생", items: MILDANG_STUDENTS }],
    includeModelContextTools: { category: { id: "tools", label: "도구" }, icon: "tool" },
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON,
    ...onInserted ? { onInserted } : {}
  });
  return <Composer.TriggerPopover char="@" adapter={mention.adapter}>
      <Composer.TriggerPopover.Directive {...mention.directive} />
      <Composer.TriggerPopover.Categories>
        {(categories) => categories.map((category) => <Composer.TriggerPopover.CategoryItem
    key={category.id}
    categoryId={category.id}
    icon={category.id === "tools" ? <MagicWandIcon /> : <GraduationCapIcon />}
  >
              {category.label}
            </Composer.TriggerPopover.CategoryItem>)}
      </Composer.TriggerPopover.Categories>
      <Composer.TriggerPopover.Back>학생·도구 목록</Composer.TriggerPopover.Back>
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>일치하는 대상이 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};
var buildAuditCommands = (log) => [
  {
    id: "요약",
    label: "/요약",
    description: "오늘 학습을 요약하는 프롬프트 삽입",
    icon: "report",
    execute: () => log("요약")
  },
  {
    id: "추천",
    label: "/추천",
    description: "다음 학습 목표 추천",
    icon: "command",
    execute: () => log("추천")
  },
  {
    id: "리포트",
    label: "/리포트",
    description: "학습 리포트 생성",
    icon: "tool",
    execute: () => log("리포트")
  }
];
var SlashPopover = ({ commands, removeOnExecute }) => {
  const slash = unstable_useSlashCommandAdapter({
    commands,
    ...removeOnExecute !== void 0 ? { removeOnExecute } : {},
    iconMap: TRIGGER_ICON_MAP,
    fallbackIcon: TRIGGER_FALLBACK_ICON
  });
  return <Composer.TriggerPopover char="/" adapter={slash.adapter}>
      <Composer.TriggerPopover.Action {...slash.action} />
      <Composer.TriggerPopover.Items>
        {(items) => items.length > 0 ? items.map((item, index) => <Composer.TriggerPopover.Item
    key={item.id}
    item={item}
    index={index}
    icon={resolveTriggerIcon(item)}
  />) : <Composer.TriggerPopover.Empty>커맨드를 찾을 수 없어요</Composer.TriggerPopover.Empty>}
      </Composer.TriggerPopover.Items>
    </Composer.TriggerPopover>;
};

// LiveShowcaseBrowserAI.example.tsx
var suggestions = ["민준이 최근 학습 현황 어때?", "이번 주 학습 목표 추천해줘", "오답 복습 계획 짜줘"];
var shell = css({
  height: "100dvh",
  overflow: "hidden"
});
var threadCol = css({
  position: "relative",
  display: "flex",
  flexDirection: "column",
  flex: "1",
  minHeight: "0",
  minWidth: "0"
});
var metaRow = css({ display: "flex", alignItems: "center", gap: "8", marginTop: "4" });
var banner = css({
  display: "flex",
  flexDirection: "column",
  gap: "8",
  margin: "12",
  padding: "12",
  borderRadius: "md",
  borderWidth: "1px",
  borderStyle: "solid",
  borderColor: "info.border.base",
  backgroundColor: "info.surface.base",
  color: "info.text.base",
  textStyle: "chat-caption-R"
});
var warnBanner = css({
  display: "flex",
  flexDirection: "column",
  gap: "8",
  margin: "12",
  padding: "12",
  borderRadius: "md",
  borderWidth: "1px",
  borderStyle: "solid",
  borderColor: "warning.border.base",
  backgroundColor: "warning.surface.base",
  color: "warning.text.base",
  textStyle: "chat-caption-R"
});
var progressLabel = css({ textStyle: "chat-caption-M" });
var MarkdownText = () => <Markdown />;
var ThinkingPlaceholder = () => <TextShimmer>생각 중…</TextShimmer>;
var SystemMessageSlot = () => <SystemMessage>
    <SystemMessage.Content>
      <SystemMessage.Parts />
    </SystemMessage.Content>
  </SystemMessage>;
var SidebarItem = () => <ThreadListItem>
    <ThreadListItem.Trigger>
      <ThreadListItem.Title />
    </ThreadListItem.Trigger>
    <ThreadListItem.More />
  </ThreadListItem>;
var UserMessage = () => <Message role="user">
    <Message.Content>
      <Message.Parts components={{ Text: MildangDirectiveText }} />
    </Message.Content>
    <Message.Meta>방금</Message.Meta>
  </Message>;
var AssistantMessage = () => <Message role="assistant">
    <Message.Content>
      <Message.Parts
  components={{
    Text: MarkdownText,
    Empty: ThinkingPlaceholder,
    Reasoning: Message.Reasoning,
    tools: { Fallback: Tool.Fallback },
    ToolGroup: Tool.GroupByIndices
  }}
/>
    </Message.Content>

    <Message.Error>
      <MessageError sx={{ marginTop: "8" }} />
    </Message.Error>

    <div className={metaRow}>
      <ActionBar hideWhenRunning autohide="not-last">
        <ActionBar.Copy />
        <ActionBar.Reload />
        <ActionBar.More />
      </ActionBar>
      <BranchPicker>
        <BranchPicker.Previous />
        <BranchPicker.Number /> / <BranchPicker.Count />
        <BranchPicker.Next />
      </BranchPicker>
      <Message.Meta>방금</Message.Meta>
    </div>
  </Message>;
var Welcome = () => <ThreadWelcome>
    <ThreadWelcome.Message>
      무엇이든 물어보세요. 밀당 선생님이 학습 현황을 살펴보고 도와드릴게요.
    </ThreadWelcome.Message>
    <ThreadWelcome.Suggestions>
      {suggestions.map((prompt) => <ThreadWelcome.Suggestion key={prompt} prompt={prompt} send label={prompt} />)}
    </ThreadWelcome.Suggestions>
  </ThreadWelcome>;
var liveSlashCommands = buildAuditCommands(() => {
});
var ChatComposer = () => <Composer2.TriggerPopoverRoot>
    <Composer2>
      <MentionPopover />
      <SlashPopover commands={liveSlashCommands} />
      <Composer2.RichInput
  directiveChip={MildangDirectiveChip}
  placeholder="메시지 입력 — @ 로 학생 멘션, / 로 커맨드 (Enter 전송)"
/>
      <Composer2.Footer>
        <Composer2.LeadingActions>
          <Composer2.AddAttachment />
          <Composer2.Dictate />
        </Composer2.LeadingActions>
        <Composer2.TrailingActions>
          <Thread.If running={false}>
            <Composer2.Send />
          </Thread.If>
          <Thread.If running>
            <Composer2.Cancel />
          </Thread.If>
        </Composer2.TrailingActions>
      </Composer2.Footer>
    </Composer2>
  </Composer2.TriggerPopoverRoot>;
var BrowserAINotice = () => {
  const { status, progress, error, download } = useBrowserAIAvailability();
  if (status === "ready") {
    return <div className={banner}>
        <strong>온디바이스(BrowserAI) 모드 · 준비 완료</strong>
        <span>
          Chrome/Edge 내장 모델로 응답합니다. 온디바이스 모델은 text 전용이라 reasoning/tool 파트는 없습니다.
        </span>
      </div>;
  }
  if (status === "checking") {
    return <div className={banner}>
        <span>온디바이스 AI 가용성 확인 중…</span>
      </div>;
  }
  if (status === "downloadable") {
    return <div className={banner}>
        <strong>모델 다운로드 필요</strong>
        <span>온디바이스 모델을 아직 내려받지 않았습니다. 아래 버튼으로 다운로드하세요(수백 MB).</span>
        <Button variant="secondary" size="sm" onClick={() => void download()}>
          모델 다운로드
        </Button>
      </div>;
  }
  if (status === "downloading") {
    return <div className={banner}>
        <strong>모델 다운로드 중…</strong>
        <span className={progressLabel}>{Math.round(progress * 100)}%</span>
      </div>;
  }
  if (status === "unsupported") {
    return <div className={warnBanner}>
        <strong>이 브라우저는 온디바이스 AI를 지원하지 않습니다</strong>
        <span>
          Chrome 138+ 또는 Edge(Prompt API 지원)에서 실행하세요. 온디바이스 전용이라 키리스 폴백은 불가합니다
          — Mock/Mastra 모드를 사용해 주세요.
        </span>
      </div>;
  }
  return <div className={warnBanner}>
      <strong>온디바이스 모델을 사용할 수 없습니다</strong>
      <span>
        {error ?? "모델이 이 기기/브라우저에서 unavailable 상태입니다. Mock/Mastra 모드를 사용해 주세요."}
      </span>
    </div>;
};
var ChatSurface = () => <div className={shell}>
    <Sidebar.Provider>
      <Sidebar.Root>
        <Sidebar.Content>
          <Sidebar.Group.Root>
            <Sidebar.Group.Label>대화</Sidebar.Group.Label>
            <ThreadList>
              <ThreadList.New />
              <ThreadList.Items components={{ ThreadListItem: SidebarItem }} />
            </ThreadList>
          </Sidebar.Group.Root>
        </Sidebar.Content>
      </Sidebar.Root>

      <Sidebar.Inset>
        <div className={threadCol}>
          <BrowserAINotice />
          <Thread
  components={{ UserMessage, AssistantMessage, SystemMessage: SystemMessageSlot }}
  empty={<Welcome />}
  composer={<ChatComposer />}
/>
        </div>
      </Sidebar.Inset>
    </Sidebar.Provider>
  </div>;
var LiveShowcaseBrowserAIExample = () => <BrowserAIChatRuntimeProvider>
    <ChatSurface />
  </BrowserAIChatRuntimeProvider>;
var LiveShowcaseBrowserAI_example_default = LiveShowcaseBrowserAIExample;
export {
  LiveShowcaseBrowserAI_example_default as default
};