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
NonNullable<MessagesComponentConfig>
지정 안 함
ReactNode
지정 안 함
boolean
지정 안 함
ReactNode
지정 안 함
ReactElement<unknown, string | JSXElementConstructor<any>>
지정 안 함
"bottom" | "top"
지정 안 함
Thread.Viewport
메시지를 스크롤 영역에 담고 자동 하단 스크롤을 관리한다.
공개 Props 없음
Thread.Empty
메시지가 없을 때 보여줄 콘텐츠를 렌더한다.
공개 Props 없음
Thread.Messages
components prop으로 받은 역할별 메시지 컴포넌트를 순회 렌더한다.
Prop
Type
Default
(value: { message: MessageState; }) => ReactNode
지정 안 함
MessagesComponentConfig
지정 안 함
Thread.ViewportFooter
컴포저를 뷰포트 바닥에 고정하는 영역이다.
공개 Props 없음
Thread.ScrollToBottom
스크롤이 바닥에서 벗어났을 때 나타나는 하단 이동 버튼이다.
Prop
Type
Default
boolean
지정 안 함
ScrollBehavior
지정 안 함
ReactNode
지정 안 함
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
};