Chat Composer
Prompt input with the outside footer: attach, speech, disclaimer, model with effort, and mode.
import { ChatComposer } from "@/components/ai/chat-composer";
export default function Demo() { return ( <ChatComposer className="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes." models={[ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" }, ]} efforts={[ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ]} defaultEffort="medium" modes={[ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" }, ]} /> );}<script setup lang="ts">import { ChatComposer } from "@/components/ai/chat-composer";
const models = [ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" },];const efforts = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" },];const modes = [ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" },];</script>
<template> <ChatComposer class="max-w-2xl" disclaimer="Edmi is AI and can make mistakes." :models="models" :efforts="efforts" default-effort="medium" :modes="modes" @attach="() => {}" @speech="() => {}" /></template><script lang="ts"> import { ChatComposer } from "$lib/components/ai/chat-composer";
const models = [ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" }, ]; const efforts = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ]; const modes = [ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" }, ];</script>
<ChatComposer class="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes." {models} {efforts} defaultEffort="medium" {modes}/>Edmi ✦ addition. Built on ai-prompt-input, button and dropdown-menu.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-chat-composerpnpm dlx shadcn@latest add @edmi-ui/ai-chat-composeryarn dlx shadcn@latest add @edmi-ui/ai-chat-composerbunx shadcn@latest add @edmi-ui/ai-chat-composerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install aiTerminal window pnpm add aiTerminal window yarn add aiTerminal window bun add aiAdd the Edmi components this one builds on:
ai-prompt-input,button,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-chat-composerpnpm dlx shadcn-vue@latest add @edmi-ui/ai-chat-composeryarn dlx shadcn-vue@latest add @edmi-ui/ai-chat-composerbunx shadcn-vue@latest add @edmi-ui/ai-chat-composerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ai @lucide/vue @vueuse/coreTerminal window pnpm add ai @lucide/vue @vueuse/coreTerminal window yarn add ai @lucide/vue @vueuse/coreTerminal window bun add ai @lucide/vue @vueuse/coreAdd the Edmi components this one builds on:
ai-prompt-input,button,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
ai-prompt-input,button,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { ChatComposer } from "@/components/ai/chat-composer";
<ChatComposer className="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes." models={[ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" }, ]} efforts={[ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ]} defaultEffort="medium" modes={[ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" }, ]}/><script setup lang="ts">import { ChatComposer } from "@/components/ai/chat-composer";</script>
<template> <ChatComposer class="max-w-2xl" disclaimer="Edmi is AI and can make mistakes." default-effort="medium" @attach="() => {}" @speech="() => {}" /></template><script lang="ts"> import { ChatComposer } from "$lib/components/ai/chat-composer";</script>
<ChatComposer class="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes."
defaultEffort="medium"/>Component source
Section titled “Component source”"use client";
import type { ChatStatus } from "ai";import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { useState } from "react";import { PlusIcon, MicrophoneIcon, CaretDownIcon } from "@phosphor-icons/react";import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea,} from "@/registry/edmi/components/ai/prompt-input";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { Elevation } from "@/registry/edmi/ui/elevation";
export type ChatComposerOption = { id: string; label: string };
export type ChatComposerProps = Omit< ComponentProps<typeof PromptInput>, "children" | "elevation"> & { /** ✦ depth of the composer plate (overlay role: floating in layered mode). */ elevation?: Elevation; placeholder?: string; status?: ChatStatus; onStop?: () => void; /** Plus button in the footer; hide the button by omitting it. */ onAttach?: () => void; /** Mic button in the footer (speech input). Omit to hide it. */ onSpeech?: () => void; /** Small print centered in the footer. */ disclaimer?: ReactNode; models?: ChatComposerOption[]; /** Controlled model id; use `defaultModel` for uncontrolled. */ model?: string; defaultModel?: string; onModelChange?: (id: string) => void; efforts?: ChatComposerOption[]; effort?: string; defaultEffort?: string; onEffortChange?: (id: string) => void; modes?: ChatComposerOption[]; mode?: string; defaultMode?: string; onModeChange?: (id: string) => void;};
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) => options?.find((o) => o.id === id)?.label;
/** * Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode. */export const ChatComposer = ({ placeholder = "Reply", status, onStop, onAttach, onSpeech, disclaimer, models, model, defaultModel, onModelChange, efforts, effort, defaultEffort, onEffortChange, modes, mode, defaultMode, onModeChange, elevation, className, ...props}: ChatComposerProps) => { const [modelState, setModel] = useState(defaultModel ?? models?.[0]?.id); const [effortState, setEffort] = useState(defaultEffort ?? efforts?.[0]?.id); const [modeState, setMode] = useState(defaultMode ?? modes?.[0]?.id); const modelId = model ?? modelState; const effortId = effort ?? effortState; const modeId = mode ?? modeState; const ready = status === undefined || status === "ready";
return ( <div data-slot="ai-chat-composer" className={cn("flex w-full flex-col", className)} > <PromptInput elevation={elevation} {...props}> <PromptInputBody> <div className="flex w-full items-center pr-2"> <PromptInputTextarea placeholder={placeholder} rows={1} className="min-h-12 px-4 py-3 text-[14.5px]" /> <PromptInputSubmit status={status} onStop={onStop} variant={ready ? "ghost" : undefined} className={ready ? "text-muted-foreground" : undefined} > {ready ? <span aria-hidden="true">↵</span> : undefined} </PromptInputSubmit> </div> </PromptInputBody> </PromptInput> <div data-slot="ai-chat-composer-footer" className="flex items-center gap-0.5 px-1.5 pt-2 text-xs" > {onAttach && ( <Button aria-label="Attach" onClick={onAttach} size="icon-xs" type="button" variant="ghost" > <PlusIcon className="size-4" /> </Button> )} {onSpeech && ( <Button aria-label="Speech input" onClick={onSpeech} size="xs" type="button" variant="ghost" className="gap-0.5" > <MicrophoneIcon className="size-4" /> <CaretDownIcon className="size-3" /> </Button> )} <span className="min-w-0 flex-1 truncate text-center text-muted-foreground"> {disclaimer} </span> {models && models.length > 0 && ( <DropdownMenu> <DropdownMenuTrigger render={ <Button size="xs" type="button" variant="ghost" className="gap-[5px] font-medium" /> } > {labelOf(models, modelId)} {efforts && ( <span className="font-normal text-muted-foreground"> {labelOf(efforts, effortId)} </span> )} </DropdownMenuTrigger> <DropdownMenuContent align="end" side="top"> <DropdownMenuGroup> <DropdownMenuLabel>Model</DropdownMenuLabel> <DropdownMenuRadioGroup value={modelId} onValueChange={(v) => { setModel(v as string); onModelChange?.(v as string); }} > {models.map((m) => ( <DropdownMenuRadioItem key={m.id} value={m.id}> {m.label} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuGroup> {efforts && efforts.length > 0 && ( <> <DropdownMenuSeparator /> <DropdownMenuGroup> <DropdownMenuLabel>Effort</DropdownMenuLabel> <DropdownMenuRadioGroup value={effortId} onValueChange={(v) => { setEffort(v as string); onEffortChange?.(v as string); }} > {efforts.map((e) => ( <DropdownMenuRadioItem key={e.id} value={e.id}> {e.label} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuGroup> </> )} </DropdownMenuContent> </DropdownMenu> )} {modes && modes.length > 0 && ( <DropdownMenu> <DropdownMenuTrigger render={ <Button size="xs" type="button" variant="ghost" className="text-muted-foreground" /> } > {labelOf(modes, modeId)} </DropdownMenuTrigger> <DropdownMenuContent align="end" side="top"> <DropdownMenuGroup> <DropdownMenuLabel>Mode</DropdownMenuLabel> <DropdownMenuRadioGroup value={modeId} onValueChange={(v) => { setMode(v as string); onModeChange?.(v as string); }} > {modes.map((m) => ( <DropdownMenuRadioItem key={m.id} value={m.id}> {m.label} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> )} </div> </div> );};<script setup lang="ts">import type { ChatStatus } from "ai"import type { HTMLAttributes } from "vue"import type { PromptInputMessage } from "@/registry/edmi/components/ai/prompt-input"import type { ChatComposerOption } from "./types"import { AudioLinesIcon, ChevronDown, Plus } from "@lucide/vue"import { useVModel } from "@vueuse/core"import { computed, getCurrentInstance } from "vue"import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea,} from "@/registry/edmi/components/ai/prompt-input"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import type { Elevation } from "@/registry/edmi/ui/elevation"import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] placeholder?: string status?: ChatStatus /** Small print centered in the footer (or use the `disclaimer` slot). */ disclaimer?: string /** Same as PromptInput. */ accept?: string multiple?: boolean /** ✦ depth of the composer plate (overlay role: floating in layered mode). */ elevation?: Elevation models?: ChatComposerOption[] /** `v-model:model`; use `defaultModel` for uncontrolled. */ model?: string defaultModel?: string efforts?: ChatComposerOption[] effort?: string defaultEffort?: string modes?: ChatComposerOption[] mode?: string defaultMode?: string}>(), { placeholder: "Reply", elevation: undefined,})
const emit = defineEmits<{ (e: "submit", message: PromptInputMessage): void (e: "stop"): void (e: "attach"): void (e: "speech"): void (e: "update:model", id: string): void (e: "update:effort", id: string): void (e: "update:mode", id: string): void}>()
// The attach and speech buttons show when a listener is attached.const vnodeProps = getCurrentInstance()?.vnode.propsconst hasAttach = !!vnodeProps?.onAttachconst hasSpeech = !!vnodeProps?.onSpeech
const modelId = useVModel(props, "model", emit, { passive: true, defaultValue: props.defaultModel ?? props.models?.[0]?.id })const effortId = useVModel(props, "effort", emit, { passive: true, defaultValue: props.defaultEffort ?? props.efforts?.[0]?.id })const modeId = useVModel(props, "mode", emit, { passive: true, defaultValue: props.defaultMode ?? props.modes?.[0]?.id })
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) => options?.find(o => o.id === id)?.labelconst ready = computed(() => props.status === undefined || props.status === "ready")</script>
<template> <!-- Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode. --> <div data-slot="ai-chat-composer" :class="cn('flex w-full flex-col', props.class)"> <PromptInput :accept="accept" :multiple="multiple" :elevation="elevation" @submit="(m: PromptInputMessage) => emit('submit', m)" > <PromptInputBody> <div class="flex w-full items-center pr-2"> <PromptInputTextarea :placeholder="placeholder" rows="1" class="min-h-12 px-4 py-3 text-[14.5px]" /> <PromptInputSubmit :status="status" :variant="ready ? 'ghost' : undefined" :class="ready ? 'text-muted-foreground' : undefined" @stop="emit('stop')" > <span v-if="ready" aria-hidden="true">↵</span> </PromptInputSubmit> </div> </PromptInputBody> </PromptInput> <div data-slot="ai-chat-composer-footer" class="flex items-center gap-0.5 px-1.5 pt-2 text-xs"> <Button v-if="hasAttach" aria-label="Attach" size="icon-xs" type="button" variant="ghost" @click="emit('attach')"> <Plus class="size-4" /> </Button> <Button v-if="hasSpeech" aria-label="Speech input" size="xs" type="button" variant="ghost" class="gap-0.5" @click="emit('speech')"> <AudioLinesIcon class="size-4" /> <ChevronDown class="size-3" /> </Button> <span class="min-w-0 flex-1 truncate text-center text-muted-foreground"><slot name="disclaimer">{{ disclaimer }}</slot></span> <DropdownMenu v-if="models && models.length > 0"> <DropdownMenuTrigger as-child> <Button size="xs" type="button" variant="ghost" class="gap-[5px] font-medium"> {{ labelOf(models, modelId) }} <span v-if="efforts" class="font-normal text-muted-foreground">{{ labelOf(efforts, effortId) }}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" side="top"> <DropdownMenuGroup> <DropdownMenuLabel>Model</DropdownMenuLabel> <DropdownMenuRadioGroup v-model="modelId"> <DropdownMenuRadioItem v-for="m in models" :key="m.id" :value="m.id"> {{ m.label }} </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuGroup> <template v-if="efforts && efforts.length > 0"> <DropdownMenuSeparator /> <DropdownMenuGroup> <DropdownMenuLabel>Effort</DropdownMenuLabel> <DropdownMenuRadioGroup v-model="effortId"> <DropdownMenuRadioItem v-for="e in efforts" :key="e.id" :value="e.id"> {{ e.label }} </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuGroup> </template> </DropdownMenuContent> </DropdownMenu> <DropdownMenu v-if="modes && modes.length > 0"> <DropdownMenuTrigger as-child> <Button size="xs" type="button" variant="ghost" class="text-muted-foreground"> {{ labelOf(modes, modeId) }} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" side="top"> <DropdownMenuGroup> <DropdownMenuLabel>Mode</DropdownMenuLabel> <DropdownMenuRadioGroup v-model="modeId"> <DropdownMenuRadioItem v-for="m in modes" :key="m.id" :value="m.id"> {{ m.label }} </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> </div> </div></template>export type ChatComposerOption = { id: string; label: string };export { default as ChatComposer } from "./ChatComposer.vue";export * from "./types";<script lang="ts"> import PlusIcon from "phosphor-svelte/lib/Plus"; import MicrophoneIcon from "phosphor-svelte/lib/Microphone"; import CaretDownIcon from "phosphor-svelte/lib/CaretDown"; import { Button } from "$lib/registry/ui/button/index.js"; import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js"; import { cn } from "$lib/utils.js"; import type { ChatStatus } from "ai"; import type { ComponentProps, Snippet } from "svelte"; import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea, } from "../prompt-input/index.js"; import type { ChatComposerOption } from "./types.js";
let { ref = $bindable(null), class: className, placeholder = "Reply", status, onStop, onAttach, onSpeech, disclaimer, models, model = $bindable(), defaultModel, onModelChange, efforts, effort = $bindable(), defaultEffort, onEffortChange, modes, mode = $bindable(), defaultMode, onModeChange, ...restProps }: Omit<ComponentProps<typeof PromptInput>, "children"> & { ref?: HTMLDivElement | null; placeholder?: string; status?: ChatStatus; onStop?: () => void; /** Plus button in the footer; omit to hide it. */ onAttach?: () => void; /** Mic button in the footer (speech input); omit to hide it. */ onSpeech?: () => void; /** Small print centered in the footer. */ disclaimer?: string | Snippet; models?: ChatComposerOption[]; /** `bind:model`; use `defaultModel` for uncontrolled. */ model?: string; defaultModel?: string; onModelChange?: (id: string) => void; efforts?: ChatComposerOption[]; effort?: string; defaultEffort?: string; onEffortChange?: (id: string) => void; modes?: ChatComposerOption[]; mode?: string; defaultMode?: string; onModeChange?: (id: string) => void; } = $props();
// Uncontrolled values start from the defaults (or the first option). $effect.pre(() => { model ??= defaultModel ?? models?.[0]?.id; effort ??= defaultEffort ?? efforts?.[0]?.id; mode ??= defaultMode ?? modes?.[0]?.id; });
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) => options?.find((o) => o.id === id)?.label; const ready = $derived(status === undefined || status === "ready");</script>
<!-- Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode. --><div bind:this={ref} data-slot="ai-chat-composer" class={cn("flex w-full flex-col", className)}> <PromptInput {...restProps}> <PromptInputBody> <div class="flex w-full items-center pr-2"> <PromptInputTextarea {placeholder} rows={1} class="min-h-12 px-4 py-3 text-[14.5px]" /> <PromptInputSubmit {status} {onStop} variant={ready ? "ghost" : undefined} class={ready ? "text-muted-foreground" : undefined} > {#if ready} <span aria-hidden="true">↵</span> {/if} </PromptInputSubmit> </div> </PromptInputBody> </PromptInput> <div data-slot="ai-chat-composer-footer" class="flex items-center gap-0.5 px-1.5 pt-2 text-xs" > {#if onAttach} <Button aria-label="Attach" onclick={onAttach} size="icon-xs" type="button" variant="ghost"> <PlusIcon class="size-4" /> </Button> {/if} {#if onSpeech} <Button aria-label="Speech input" onclick={onSpeech} size="xs" type="button" variant="ghost" class="gap-0.5" > <MicrophoneIcon class="size-4" /> <CaretDownIcon class="size-3" /> </Button> {/if} <span class="min-w-0 flex-1 truncate text-center text-muted-foreground">{#if typeof disclaimer === "string"}{disclaimer}{:else if disclaimer}{@render disclaimer()}{/if}</span> {#if models && models.length > 0} <DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} size="xs" type="button" variant="ghost" class="gap-[5px] font-medium"> {labelOf(models, model)} {#if efforts} <span class="font-normal text-muted-foreground">{labelOf(efforts, effort)}</span> {/if} </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align="end" side="top"> <DropdownMenu.Group> <DropdownMenu.Label>Model</DropdownMenu.Label> <DropdownMenu.RadioGroup value={model} onValueChange={(v) => { model = v; onModelChange?.(v); }} > {#each models as m (m.id)} <DropdownMenu.RadioItem value={m.id}>{m.label}</DropdownMenu.RadioItem> {/each} </DropdownMenu.RadioGroup> </DropdownMenu.Group> {#if efforts && efforts.length > 0} <DropdownMenu.Separator /> <DropdownMenu.Group> <DropdownMenu.Label>Effort</DropdownMenu.Label> <DropdownMenu.RadioGroup value={effort} onValueChange={(v) => { effort = v; onEffortChange?.(v); }} > {#each efforts as e (e.id)} <DropdownMenu.RadioItem value={e.id}>{e.label}</DropdownMenu.RadioItem> {/each} </DropdownMenu.RadioGroup> </DropdownMenu.Group> {/if} </DropdownMenu.Content> </DropdownMenu.Root> {/if} {#if modes && modes.length > 0} <DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} size="xs" type="button" variant="ghost" class="text-muted-foreground"> {labelOf(modes, mode)} </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align="end" side="top"> <DropdownMenu.Group> <DropdownMenu.Label>Mode</DropdownMenu.Label> <DropdownMenu.RadioGroup value={mode} onValueChange={(v) => { mode = v; onModeChange?.(v); }} > {#each modes as m (m.id)} <DropdownMenu.RadioItem value={m.id}>{m.label}</DropdownMenu.RadioItem> {/each} </DropdownMenu.RadioGroup> </DropdownMenu.Group> </DropdownMenu.Content> </DropdownMenu.Root> {/if} </div></div>import ChatComposer from "./chat-composer.svelte";
export * from "./types.js";export { ChatComposer };export type ChatComposerOption = { id: string; label: string };Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
The composer plate is an overlay surface: flat by default, floating in layered mode. sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop; the send button follows the Button rules.
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import { ChatComposer } from "@/components/ai/chat-composer";
const models = [ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" },];const efforts = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" },];const modes = [ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" },];
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex w-full max-w-2xl flex-col gap-5"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <ChatComposer elevation={value} className="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes." models={models} efforts={efforts} defaultEffort="medium" modes={modes} /> </div> ))} </div> );}<script setup lang="ts">import { ChatComposer } from "@/components/ai/chat-composer";
const models = [ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" },];const efforts = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" },];const modes = [ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" },];
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }];</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <div v-for="level in levels" :key="level.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ level.label }}</p> <ChatComposer :elevation="level.value" class="max-w-2xl" disclaimer="Edmi is AI and can make mistakes." :models="models" :efforts="efforts" default-effort="medium" :modes="modes" @attach="() => {}" @speech="() => {}" /> </div> </div></template><script lang="ts"> import { ChatComposer } from "$lib/components/ai/chat-composer";
const models = [ { id: "opus", label: "Opus" }, { id: "sonnet", label: "Sonnet" }, ]; const efforts = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ]; const modes = [ { id: "auto", label: "Auto" }, { id: "ask", label: "Ask first" }, ];
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }] as const;</script>
<div class="flex w-full max-w-2xl flex-col gap-5"> {#each levels as level (level.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{level.label}</p> <ChatComposer elevation={level.value} class="max-w-2xl" onSubmit={() => {}} onAttach={() => {}} onSpeech={() => {}} disclaimer="Edmi is AI and can make mistakes." {models} {efforts} defaultEffort="medium" {modes} /> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| ChatComposer | onSubmit |
(message, event) => void |
required |
| ChatComposer | elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
| ChatComposer | placeholder, status, onStop |
string, ChatStatus, () => void |
Reply |
| ChatComposer | onAttach, onSpeech |
() => void (button hidden when omitted) |
|
| ChatComposer | disclaimer |
ReactNode (Vue: disclaimer slot, Svelte: string | Snippet) |
|
| ChatComposer | models, model, defaultModel, onModelChange |
{ id, label }[], ids (Vue: v-model:model, Svelte: bind:model plus onModelChange) |
first option |
| ChatComposer | efforts, effort, defaultEffort, onEffortChange |
same shape (v-model:effort, bind:effort) |
first option |
| ChatComposer | modes, mode, defaultMode, onModeChange |
same shape (v-model:mode, bind:mode) |
first option |
✦ Edmi additions
Section titled “✦ Edmi additions”- A one-line field with an enter hint, and a quiet footer row below it (not inside the field): attach, speech, disclaimer, model with effort, mode.