import { useEffect, useRef, useState } from "react"; import { Avatar, AvatarFallback, Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@godxjp/ui/data-display"; import { ChatComposer, ChatSuggestion, FormField, Select, type ChatSuggestionItemProp, } from "@godxjp/ui/data-entry"; import { Button, Text } from "@godxjp/ui/general"; import { useTranslation } from "@godxjp/ui/i18n"; import { isApplePlatform } from "@godxjp/ui/lib/utils"; import { AppShell, Flex, PageContainer, ResponsiveGrid, Sidebar, type SidebarSectionProp, Topbar, } from "@godxjp/ui/layout"; import { Bot, MessageSquare, Paperclip, Settings, Smile, Sparkles, Users } from "lucide-react"; /** * ChatComposer — the message input of a conversation, and ChatSuggestion, the trigger-character * list over it. * * The first card is the real screen: a live assistant transcript whose composer sends, streams and * cancels, with `/` slash commands and `@` mentions wired to the same draft. Every card after it * exists so the whole API is visible AT REST — every `submitType` value (including the record- * comment bar: `modEnter` + `allowEmptySubmit`), all four `size` steps, * every slot, and each non-default state — without having to click anything. * * Composed only from real @godxjp/ui components. */ const sections: SidebarSectionProp[] = [ { label: "アシスタント", items: [ { id: "chat", label: "チャット", icon: MessageSquare }, { id: "agents", label: "エージェント", icon: Bot }, { id: "members", label: "メンバー", icon: Users }, ], }, { label: "管理", items: [{ id: "settings", label: "設定", icon: Settings }] }, ]; /** Slash commands — one level of `children` on the template row. */ const COMMANDS: ChatSuggestionItemProp[] = [ { value: "summarize", label: "要約する", description: "このスレッドを3行で要約します" }, { value: "translate", label: "翻訳する", description: "英語 ⇄ 日本語" }, { value: "explain", label: "詳しく説明する" }, { value: "template", label: "テンプレート", description: "定型文を挿入します", children: [ { value: "template/meeting", label: "議事録", description: "日時・出席者・決定事項" }, { value: "template/report", label: "週報", description: "実績・課題・来週の予定" }, ], }, { value: "archive", label: "アーカイブ", description: "権限がありません", disabled: true }, ]; /** Mention list — the same component with a different `triggerCharacter`. */ const MEMBERS: ChatSuggestionItemProp[] = [ { value: "sato", label: "佐藤 花子", description: "経理部" }, { value: "tanaka", label: "田中 太郎", description: "営業部" }, { value: "yamada", label: "山田 一郎", description: "開発部" }, ]; interface Message { id: number; author: "you" | "assistant"; body: string; } const OPENING: Message[] = [ { id: 1, author: "you", body: "先月の交際費の内訳を教えてください。" }, { id: 2, author: "assistant", body: "先月の交際費は 342,000 円でした。会食が 210,000 円、贈答が 132,000 円です。", }, ]; /** Record statuses for the comment bar — a status change is postable without any text. */ const STATUSES = [ { value: "open", label: "未対応" }, { value: "inProgress", label: "処理中" }, { value: "resolved", label: "処理済み" }, { value: "closed", label: "完了" }, ]; interface Comment { id: number; body: string; status: string | null; } /** One transcript line. The message FEED is ChatBubbleList's job; here it only sets the scene. */ function Line({ message }: { message: Message }) { const mine = message.author === "you"; return ( {mine ? "私" : "AI"} {mine ? "あなた" : "アシスタント"} {message.body} ); } export default function Demo() { const { t } = useTranslation(); // ── Card 1: the live screen ─────────────────────────────────────────────────────────────── const [messages, setMessages] = useState(OPENING); const [draft, setDraft] = useState(""); const [streaming, setStreaming] = useState(false); const timer = useRef(undefined); useEffect(() => () => window.clearTimeout(timer.current), []); function send(text: string) { setMessages((current) => [...current, { id: current.length + 1, author: "you", body: text }]); setDraft(""); setStreaming(true); // A real delay, so the cancel affordance is reachable instead of resolving instantly. timer.current = window.setTimeout(() => { setStreaming(false); setMessages((current) => [ ...current, { id: current.length + 1, author: "assistant", body: "確認しました。集計を開始します。" }, ]); }, 2500); } function cancel() { window.clearTimeout(timer.current); setStreaming(false); } // ── Card 2: both submitType values, each holding its own draft ──────────────────────────── const [enterDraft, setEnterDraft] = useState("Enter を押すと送信されます"); const [shiftDraft, setShiftDraft] = useState("Enter は改行、Shift + Enter で送信"); const [lastSent, setLastSent] = useState("—"); // ── Card 2b: the record-comment bar (modEnter + allowEmptySubmit) ────────────────────────── const [commentDraft, setCommentDraft] = useState(""); const [status, setStatus] = useState("open"); const [savedStatus, setSavedStatus] = useState("open"); const [comments, setComments] = useState([]); function postComment(text: string) { const changed = status !== savedStatus; // An empty draft with no status change carries nothing — the consumer decides, not the box. if (!text && !changed) return; setComments((current) => [ ...current, { id: current.length + 1, body: text, status: changed ? status : null }, ]); setSavedStatus(status); setCommentDraft(""); } // ── Card 3: the four size steps ─────────────────────────────────────────────────────────── const [sizeDrafts, setSizeDrafts] = useState>({ xs: "xs", sm: "sm", md: "md", lg: "lg", }); // ── Card 4: every slot filled ───────────────────────────────────────────────────────────── const [slotDraft, setSlotDraft] = useState("見積書のドラフトを作成してください"); // ── Card 5: the non-default states ──────────────────────────────────────────────────────── const [errorDraft, setErrorDraft] = useState(""); const [warningDraft, setWarningDraft] = useState("送信先が未確定です"); const [countedDraft, setCountedDraft] = useState("最大 120 文字まで入力できます"); // ── Card 6: mentions ────────────────────────────────────────────────────────────────────── const [mentionDraft, setMentionDraft] = useState(""); return ( {}} product={{ name: "CoreDesk", role: "アシスタント", color: "hsl(var(--primary))" }} /> } topbar={ C } /> } > {/* ── 1. The real screen ───────────────────────────────────────────────────────── */} 経理アシスタント 送信すると 2.5 秒間ストリーミングします。その間、送信ボタンは停止ボタンに 置き換わります(同時に2つは出ません)。「/」でコマンド一覧が開きます。 {messages.map((message) => ( ))} {streaming ? ( ) : null} setDraft(`/${value} `)}> {({ onTrigger, onKeyDown }) => ( { setDraft(next); onTrigger(next); }} onKeyDown={onKeyDown} onSubmit={send} loading={streaming} onCancel={cancel} placeholder="メッセージを入力" prefix={ } footer={ Enter で送信 · Shift + Enter で改行 } /> )} {/* ── 2. submitType, both values ───────────────────────────────────────────────── */} submitType · どのキーで送るか 左は「enter」(既定)、右は「shiftEnter」。日本語変換中の Enter は どちらでも送信になりません。最後に送信された文面:{lastSent} { setLastSent(text); setEnterDraft(""); }} footer={ Enter で送信 · Shift + Enter で改行 } /> { setLastSent(text); setShiftDraft(""); }} footer={ Shift + Enter で送信 · Enter で改行 } /> {/* ── 2b. Record comment bar ───────────────────────────────────────────────────── */} submitType="modEnter" · allowEmptySubmit · 課題へのコメント Enter と Shift + Enter は改行、⌘ + Enter(Mac)/ Ctrl + Enter(Windows・Linux)で 投稿します。allowEmptySubmit により本文が空でも送信でき、ステータスだけを 変更できます(onSubmit には空文字が渡ります)。 {comments.length === 0 ? ( まだコメントはありません ) : ( comments.map((comment) => ( {comment.status ? ( ステータス: {STATUSES.find((option) => option.value === comment.status)?.label} ) : null} {comment.body ? {comment.body} : null} )) )}