# 对话输入区

## 使用方法

`ChatComposer` 是对话页面里写字发送的那一整块。它管草稿（你给 `v-model` 就归你管，不给它自己存）、默认带标签能力的输入框、发送和停止两颗按钮，另外还有抽屉、页眉、页脚、状态提示这几个可选位置。自己写的输入框和发送按钮也能接进来：通过 `useChatComposerContext()` 拿到同一份草稿、提交、禁用、停止和聚焦的约定。

```ts
import { ChatComposer } from '@astryx-vue/core/Chat'
```

### 结构

- **输入区框架**: 最外层。把主体包起来，上面或下面还能各挂一条状态提示。
- **输入区主体**: 圆角的抬升表面，里面放页眉、输入框和页脚。
- **抽屉**: 主体上方的内容，放附件之类可以展开的东西，一般用 ChatComposerDrawer。
- **页眉**: 一行，开头放操作，末尾放上下文信息。
- **输入框**: 默认的 ChatComposerInput，或者通过 useChatComposerContext() 接进来的自定义编辑器。
- **页脚**: 一行，放调用方给的操作和发送操作，外加那颗主按钮。
- **发送或停止**: 默认的 ChatSendButton，或者自定义的 sendButton；它提交当前草稿，或者请求中断。
- **状态提示**: 错误或警告反馈，画在输入区主体的上面或下面。

## 示例

常见的用法、变化和状态。

### ChatComposer — 附件

抽屉里放可以逐个删掉的附件标签。用户能给这条消息带上文件或上下文时用它。

```vue
<script setup lang="ts">
import { ChatComposer, ChatComposerDrawer } from '@astryx-vue/core/Chat'
import { Token } from '@astryx-vue/core/Token'
import { ref } from 'vue'

const files = ref([
  'feature-prd.docx',
  '2026-roadmap.pdf',
  'user-flow.fig',
  'launch-plan.docx',
  'user-feedback.csv',
  'analytics-kpis.csv',
])

function remove(name: string): void {
  files.value = files.value.filter(item => item !== name)
}
</script>

<template>
  <ChatComposer>
    <template #drawer>
      <ChatComposerDrawer :count="files.length">
        <Token
          v-for="name in files"
          :key="name"
          removable
          :label="name"
          @remove="remove(name)"
        />
      </ChatComposerDrawer>
    </template>
  </ChatComposer>
</template>
```

### ChatComposer — 扁平样式

输入区默认是抬起的（elevation="low"）。把 elevation 设成 "none" 就变平，这时它画一条边框，静止、悬停、聚焦的样子都跟文本输入框一致。

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { ChatComposer } from '@astryx-vue/core/Chat'
import { DropdownMenu } from '@astryx-vue/core/DropdownMenu'
import { Icon } from '@astryx-vue/core/Icon'
import { IconButton } from '@astryx-vue/core/IconButton'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const models = [
  { label: 'Auto' },
  { label: 'Model A' },
  { label: 'Model B' },
]

const settings = [
  { label: 'Preferences' },
  { label: 'Keyboard shortcuts' },
  { label: 'About' },
]
</script>

<template>
  <VStack :gap="2">
    <Text type="supporting" color="secondary">
      elevation="none" — flat, with a text-input-style border and focus ring
    </Text>
    <ChatComposer elevation="none">
      <template #footerActions>
        <DropdownMenu :items="models" :menu-width="200">
          <template #trigger="triggerProps">
            <Button v-bind="triggerProps" label="Auto" variant="ghost" size="md">
              <template #icon>
                <Icon name="heroicons:sparkles" />
              </template>
            </Button>
          </template>
        </DropdownMenu>
        <DropdownMenu :items="settings" :menu-width="200">
          <template #trigger="triggerProps">
            <Button v-bind="triggerProps" label="Settings" variant="ghost" size="md">
              <template #icon>
                <Icon name="heroicons:cog-6-tooth" />
              </template>
            </Button>
          </template>
        </DropdownMenu>
      </template>
      <template #sendActions>
        <IconButton label="Microphone" variant="ghost" size="md">
          <Icon name="heroicons:microphone" />
        </IconButton>
      </template>
    </ChatComposer>
  </VStack>
</template>
```

### ChatComposer — 页脚操作

页脚放模型选择和设置两个下拉菜单，发送按钮左边的位置放一个麦克风按钮。

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { ChatComposer } from '@astryx-vue/core/Chat'
import { DropdownMenu } from '@astryx-vue/core/DropdownMenu'
import { Icon } from '@astryx-vue/core/Icon'
import { IconButton } from '@astryx-vue/core/IconButton'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const models = [
  { label: 'Auto' },
  { label: 'Model A' },
  { label: 'Model B' },
  { label: 'Model C' },
]

const settings = [
  { label: 'Preferences' },
  { label: 'Keyboard shortcuts' },
  { label: 'About' },
]
</script>

<template>
  <VStack :gap="4">
    <VStack :gap="1">
      <Text type="supporting" color="secondary">
        Model selector and settings dropdowns
      </Text>
      <ChatComposer>
        <template #footerActions>
          <DropdownMenu :items="models" :menu-width="200">
            <template #trigger="triggerProps">
              <Button v-bind="triggerProps" label="Auto" variant="ghost" size="md">
                <template #icon>
                  <Icon name="heroicons:sparkles" />
                </template>
              </Button>
            </template>
          </DropdownMenu>
          <DropdownMenu :items="settings" :menu-width="200">
            <template #trigger="triggerProps">
              <Button v-bind="triggerProps" label="Settings" variant="ghost" size="md">
                <template #icon>
                  <Icon name="heroicons:cog-6-tooth" />
                </template>
              </Button>
            </template>
          </DropdownMenu>
        </template>
        <template #sendActions>
          <IconButton label="Microphone" variant="ghost" size="md">
            <Icon name="heroicons:microphone" />
          </IconButton>
        </template>
      </ChatComposer>
    </VStack>
  </VStack>
</template>
```

### ChatComposer — 完整配置

所有插槽都放满：可折叠的附件抽屉、页眉操作、上下文进度条、页脚下拉菜单，以及麦克风按钮。展示输入区能配到的最全的样子。

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { ChatComposer, ChatComposerDrawer, ChatComposerInput } from '@astryx-vue/core/Chat'
import { DropdownMenu } from '@astryx-vue/core/DropdownMenu'
import { Icon } from '@astryx-vue/core/Icon'
import { IconButton } from '@astryx-vue/core/IconButton'
import { ProgressBar } from '@astryx-vue/core/ProgressBar'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { Token } from '@astryx-vue/core/Token'
import { ref } from 'vue'

const files = ref([
  'design-spec.pdf',
  'requirements.docx',
  'wireframes.fig',
  'api-spec.yaml',
  'user-research.csv',
])

function remove(name: string): void {
  files.value = files.value.filter(item => item !== name)
}

const models = [
  { label: 'Auto' },
  { label: 'Model A' },
  { label: 'Model B' },
  { label: 'Model C' },
]

const settings = [
  { label: 'Preferences' },
  { label: 'Keyboard shortcuts' },
  { label: 'About' },
]

const isStreaming = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

function onSubmit(): void {
  isStreaming.value = true
  timer = setTimeout(() => {
    isStreaming.value = false
  }, 3000)
}

function onStop(): void {
  clearTimeout(timer)
  isStreaming.value = false
}
</script>

<template>
  <VStack :gap="4">
    <Text type="supporting" color="secondary">
      All slots populated
    </Text>
    <ChatComposer
      placeholder="Ask me anything..."
      :is-stop-shown="isStreaming"
      @submit="onSubmit"
      @stop="onStop"
    >
      <template #drawer>
        <ChatComposerDrawer :count="files.length">
          <Token
            v-for="name in files"
            :key="name"
            removable
            :label="name"
            @remove="remove(name)"
          />
        </ChatComposerDrawer>
      </template>
      <template #input>
        <ChatComposerInput style="min-height: 44px" />
      </template>
      <template #headerActions>
        <IconButton label="Mention" variant="ghost" size="sm">
          <Icon name="heroicons:at-symbol" />
        </IconButton>
        <IconButton label="Attach file" variant="ghost" size="sm">
          <Icon name="heroicons:paper-clip" />
        </IconButton>
      </template>
      <template #headerContext>
        <ProgressBar label="Context window" :value="50" :width="96" is-label-hidden />
      </template>
      <template #footerActions>
        <DropdownMenu :items="models" :menu-width="200">
          <template #trigger="triggerProps">
            <Button v-bind="triggerProps" label="Auto" variant="ghost" size="md">
              <template #icon>
                <Icon name="heroicons:sparkles" />
              </template>
            </Button>
          </template>
        </DropdownMenu>
        <DropdownMenu :items="settings" :menu-width="200">
          <template #trigger="triggerProps">
            <Button v-bind="triggerProps" label="Settings" variant="ghost" size="md">
              <template #icon>
                <Icon name="heroicons:cog-6-tooth" />
              </template>
            </Button>
          </template>
        </DropdownMenu>
      </template>
      <template #sendActions>
        <IconButton label="Microphone" variant="ghost" size="md">
          <Icon name="heroicons:microphone" />
        </IconButton>
      </template>
    </ChatComposer>
  </VStack>
</template>
```

### ChatComposer — 最简

最简的输入区：一个占位文字，加一个提交处理。往页面里塞一个输入框，用这个就够。

```vue
<script setup lang="ts">
import { ChatComposer } from '@astryx-vue/core/Chat'
</script>

<template>
  <ChatComposer />
</template>
```

### ChatComposer — 流式输出

带有流式状态和停止按钮的输入区。助手正在生成回答、用户可以中途取消时用它。

```vue
<script setup lang="ts">
import { ChatComposer } from '@astryx-vue/core/Chat'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const isStreaming = ref(false)
const value = ref('Click the send button to start streaming.')

function onSubmit(): void {
  value.value = ''
  isStreaming.value = true
  setTimeout(() => {
    isStreaming.value = false
  }, 5000)
}

function onStop(): void {
  isStreaming.value = false
}
</script>

<template>
  <VStack :gap="4">
    <VStack :gap="1">
      <Text type="supporting" color="secondary">
        {{ isStreaming ? 'Streaming — click stop to cancel' : 'Send a message to start streaming' }}
      </Text>
      <ChatComposer
        v-model="value"
        placeholder="Send a message to start streaming..."
        :is-stop-shown="isStreaming"
        @submit="onSubmit"
        @stop="onStop"
      />
    </VStack>
  </VStack>
</template>
```

### ChatComposer — 校验提示

带错误和警告提示的输入区。提示可以放在输入区上面，也可以放在下面，用来告诉用户校验结果或系统状态。

```vue
<script setup lang="ts">
import { ChatComposer } from '@astryx-vue/core/Chat'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
</script>

<template>
  <VStack :gap="4">
    <VStack :gap="1">
      <Text type="supporting" color="secondary">
        Error message (with top position)
      </Text>
      <ChatComposer
        status-position="top"
        :status="{ type: 'error', message: 'Failed to send message. Please try again.' }"
      />
    </VStack>
    <VStack :gap="1">
      <Text type="supporting" color="secondary">
        Warning message (with bottom position)
      </Text>
      <ChatComposer :status="{ type: 'warning', message: 'Context window is 90% full.' }" />
    </VStack>
  </VStack>
</template>
```

## 属性

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `v-model` | `string` | — | 草稿内容。用 `v-model` 绑上，输入区就完全按这个字符串显示；不绑，它自己存。 |
| `placeholder` | `string` | 本地化的“Type a message…” | 草稿为空时显示的提示文字。 |
| `isDisabled` | `boolean` | `false` | 不能编辑也不能发送。这时候停止按钮还能按，用户仍然可以取消回答。 |
| `density` | `ChatComposerDensity` | `'balanced'` | 输入区内部的疏密：`compact`、`balanced`、`spacious`。 |
| `elevation` | `'none' \| 'low'` | `'low'` | `low` 是抬起的，悬停和聚焦时阴影更深。`none` 是平的，改成画一条边框。两种情况键盘聚焦到输入框都会有聚焦环，鼠标点进去没有。 |
| `isStopShown` | `boolean` | `false` | 把发送换成停止。助手还在往外吐字的时候打开它。 |
| `status` | `ChatComposerStatus` | — | 提示对象，`{ type: 'error' \| 'warning', message }`。error 会作为警报播报，warning 作为状态播报。 |
| `statusPosition` | `'top' \| 'bottom'` | `'bottom'` | 提示放在输入区上面还是下面。 |

## 事件

| 事件 | 参数 | 说明 |
| --- | --- | --- |
| `submit` | `string` | 提交了一份去掉首尾空白、且不为空的草稿。提交完草稿会被清空。 |
| `stop` | — | 按了停止。 |
| `change` | `string` | 草稿变了，也包括提交成功后的清空。 |
| `update:modelValue` | `string` | 输入区希望你写回的草稿。`v-model` 绑的就是它。 |

## 插槽

| 插槽 | 说明 |
| --- | --- |
| `drawer` | 输入区上方的可折叠内容，一般放 `ChatComposerDrawer`。 |
| `headerActions` | 页眉靠开头那一侧。放小号的纯图标按钮正好跟这一行对齐。 |
| `headerContext` | 页眉靠末尾那一侧。放用量表这类上下文信息。 |
| `input` | 换掉默认的标签输入框。草稿从 `useChatComposerContext` 里读。 |
| `footerActions` | 页脚靠开头那一侧。中号按钮跟发送按钮对得上。 |
| `sendActions` | 紧挨在发送按钮前面，例如放一个麦克风或语音输入按钮。 |
| `sendButton` | 换掉默认的发送和停止按钮。 |

## 暴露

| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `element` | `HTMLElement \| null` | 最外层那块框，用来量尺寸或滚动。 |
