AIチャットUIのためのpresentationalな部品です。通信やメッセージの状態は持たず、データを渡してmessages.map()で組み立てるだけ。AI SDKでも自前のバックエンドでも接続できます。@k8ordo/ui/aiからimportします。
実際のチャット画面です。サジェスチョンを選ぶか、メッセージを入力して送信すると、吹き出しが会話に積まれていきます。思考過程やツール呼び出しは折りたたみで確認できます。
Conversationはstick-to-bottomと「最新へ」ボタン付きのスクロール領域、Messageはroleごとの吹き出し、PromptInputは入力欄です。メッセージ配列は利用側が持ちます。
Enterで送信、Shift+Enterで改行、そしてIME変換を確定するEnterでは送信しません。statusに応じて送信 / 停止ボタンが切り替わります。
Suggestionは定型の質問をチップで並べ、選択された値をそのまま送信ハンドラに渡します。
Responseはストリーミング中のMarkdownを描画し、未クローズのブロックにも耐えます。別サブパスに分かれており、optional peerのstreamdownとそのスタイルシートが必要です。
ToolInvocationとReasoningはツールの実行や思考過程を折りたたみで表示します。stateの語彙はAI SDKのツールパートの状態に揃えています。
mapMessageParts(@k8ordo/ui/ai-sdk)はAI SDKのUIMessage.partsを、自分で描画しやすい素朴な配列に変換します。optional peerのaiが必要です。
Message.Contentは任意のchildrenを取れるので、json-renderのregistryを使ってLLMが生成したUI specを吹き出しの中に描画できます。会話の中へそのままGenerative UIを届けられます。
コンポーネントの型から生成したpropsの一覧です。開閉を持つコンポーネント(Reasoning / ToolInvocation)はisOpen / defaultOpen / onChangeのcontrolled / uncontrolled両対応です。
childrenReactNode| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
childrenReactNodeisStreaming?booleanfalselabel?string入力
{
"query": "k8ordo UI ai getting started"
}出力
Conversation / Message / PromptInputの3つから始めるのが推奨です。
| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
isStreaming? | boolean | false |
label? | string | - |
label?string| Prop | Type | Default |
|---|---|---|
label? | string | - |
childrenReactNodefromMessageRole| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
from | MessageRole | - |
型ベース(内部で固定する一部attrsは除外): HTMLAttributes<HTMLDivElement>
childrenReactNodeisStreaming?booleanfalse| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
isStreaming? | boolean | false |
型ベース(内部で固定する一部attrsは除外): HTMLAttributes<HTMLDivElement>
childrenReactNodedefaultValue?string''onChange?(value: string) => voidonStop?() => voidonSubmit?(message: string) => voidstatus?ChatStatus'ready'children?ReactNodeplaceholder?stringref?Ref<HTMLTextAreaElement>| Prop | Type | Default |
|---|---|---|
children? | ReactNode | - |
placeholder? | string | - |
ref? | Ref<HTMLTextAreaElement> | - |
型ベース(内部で固定する一部attrsは除外): TextareaHTMLAttributes<HTMLTextAreaElement>
sendLabel?stringstopLabel?string| Prop | Type | Default |
|---|---|---|
sendLabel? | string | - |
stopLabel? | string | - |
childrenReactNodelabel?string| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
label? | string | - |
valuestringchildren?ReactNodeonSelect?(value: string) => void| Prop | Type | Default |
|---|---|---|
value | string | - |
children? | ReactNode | - |
onSelect? | (value: string) => void | - |
childrenstringisStreaming?booleanfalse| Prop | Type | Default |
|---|---|---|
children | string | - |
isStreaming? | boolean | false |
型ベース(内部で固定する一部attrsは除外): StreamdownProps
childrenReactNodedefaultOpen?booleanfalseisOpen?booleanisStreaming?booleanfalseonChange?(isOpen: boolean) => void| Prop | Type | Default |
|---|---|---|
children |
namestringstateToolStatedefaultOpen?booleanfalsedeniedReason?stringerrorText?stringinput?unknownisOpen?value?string| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
defaultValue? | string | '' |
onChange? | (value: string) => void | - |
onStop? | () => void | - |
onSubmit? | (message: string) => void | - |
status? | ChatStatus | 'ready' |
value? | string | - |
ReactNode| - |
defaultOpen? | boolean | false |
isOpen? | boolean | - |
isStreaming? | boolean | false |
onChange? | (isOpen: boolean) => void | - |
booleanonChange?(isOpen: boolean) => voidoutput?ReactNode| Prop | Type | Default |
|---|---|---|
name | string | - |
state | ToolState | - |
defaultOpen? | boolean | false |
deniedReason? | string | - |
errorText? | string | - |
input? | unknown | - |
isOpen? | boolean | - |
onChange? | (isOpen: boolean) => void | - |
output? |
ReactNode |
| - |
'use client';
import { Avatar, AssistantIcon } from '@k8ordo/ui';
import {
Conversation,
Message,
PromptInput,
Suggestion,
} from '@k8ordo/ui/ai';
export function Chat({ messages, send }: Props) {
return (
<div className="flex h-full flex-col gap-3">
<Conversation.Root>
<Conversation.Messages>
{messages.map((m) => (
<Message.Root from={m.role} key={m.id}>
{m.role === 'assistant' && (
<Avatar color="primary" icon={<AssistantIcon />} name="AI" size="sm" />
)}
<Message.Content>{m.text}</Message.Content>
</Message.Root>
))}
</Conversation.Messages>
<Conversation.ScrollButton />
</Conversation.Root>
<Suggestion.List>
<Suggestion.Item onSelect={send} value="Tell me about IME support" />
</Suggestion.List>
<PromptInput.Root onSubmit={send}>
<PromptInput.Textarea placeholder="Type a message…" />
<PromptInput.Submit />
</PromptInput.Root>
</div>
);
}'use client';
import { Avatar, AssistantIcon } from '@k8ordo/ui';
import { Conversation, Message, PromptInput } from '@k8ordo/ui/ai';
import { useChat } from '@ai-sdk/react';
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
return (
<div className="flex h-full flex-col gap-3">
<Conversation.Root>
<Conversation.Messages isStreaming={status === 'streaming'}>
{messages.map((m) => (
<Message.Root from={m.role === 'user' ? 'user' : 'assistant'} key={m.id}>
{m.role !== 'user' && (
<Avatar color="primary" icon={<AssistantIcon />} name="AI" size="sm" />
)}
<Message.Content>{textOf(m)}</Message.Content>
</Message.Root>
))}
</Conversation.Messages>
<Conversation.ScrollButton />
</Conversation.Root>
<PromptInput.Root
onStop={stop}
onSubmit={(text) => sendMessage({ text })}
status={status}
>
<PromptInput.Textarea placeholder="Type a message" />
<PromptInput.Submit />
</PromptInput.Root>
</div>
);
}// Enter to send, Shift+Enter for a newline, IME-confirm Enter never submits.
// status: 'ready' | 'submitted' | 'streaming' | 'error' (matches AI SDK).
<PromptInput.Root status={status} onSubmit={send} onStop={stop}>
<PromptInput.Textarea placeholder="Type a message" />
<PromptInput.Submit /> {/* send when ready, stop while streaming */}
</PromptInput.Root>import { Suggestion } from '@k8ordo/ui/ai';
<Suggestion.List>
{suggestions.map((s) => (
<Suggestion.Item key={s} onSelect={send} value={s} />
))}
</Suggestion.List>// pnpm add streamdown
import { Response } from '@k8ordo/ui/ai/response';
import 'streamdown/styles.css';
<Message.Content>
<Response isStreaming={isStreaming}>{markdown}</Response>
</Message.Content>import { Reasoning, ToolInvocation } from '@k8ordo/ui/ai';
<Reasoning isStreaming={isThinking}>{reasoningText}</Reasoning>
<ToolInvocation
name="search_web"
state="output-available" // 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
input={{ query: 'k8ordo UI' }}
output="…"
/>import { mapMessageParts } from '@k8ordo/ui/ai-sdk';
import { Reasoning, ToolInvocation } from '@k8ordo/ui/ai';
import { Response } from '@k8ordo/ui/ai/response';
<Message.Content>
{mapMessageParts(message).map((part, i) => {
if (part.kind === 'reasoning') return <Reasoning key={i}>{part.text}</Reasoning>;
if (part.kind === 'tool') return <ToolInvocation key={i} {...part} />;
return <Response key={i}>{part.text}</Response>;
})}
</Message.Content>'use client';
import { Message } from '@k8ordo/ui/ai';
import { JsonRenderUI } from '@k8ordo/ui/json-render/registry';
// An LLM returned a UI spec as a tool result — render it inside the bubble.
<Message.Root from="assistant">
<Message.Content>
<JsonRenderUI spec={spec} />
</Message.Content>
</Message.Root>