support attach

This commit is contained in:
Timi
2026-09-26 14:20:18 +08:00
parent 2b92012c39
commit a20e051e8a
7 changed files with 140 additions and 53 deletions
+7 -5
View File
@@ -1,6 +1,6 @@
# AiChatComposer 输入框
对话输入框,按状态在发送、停止、重试与新开会话之间切换。
对话输入框采用上下布局:整行文本域下方提供新开会话、添加附件和发送或停止操作。
## 基础用法
@@ -14,16 +14,17 @@ demo-preview=../demos/ai-chat-composer/basic.vue
需要单独摆放输入框时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
## 三种按钮形态
## 输入区与操作按钮
文本域占满整行并随内容自动增高。下一行左侧是新开会话(`chat-add`)和添加附件(`file-add`),右侧根据状态显示纯文本发送或停止按钮。
| 状态 | 按钮 |
| --- | --- |
| 空闲且未禁用 | 新开会话(`chat-add`) |
| 默认 | 发送(`sendText`) |
| 空闲且未禁用 | 新开会话、添加附件、发送(`sendText`) |
| `isGenerating` 为真 | 停止(`stopText`,危险色) |
| 有 `lastError` 且 `canRetry` | 错误条 + 重试(`retryText`) |
生成中或 `disabled` 为真时输入框不可编辑,新开会话和 `send` 事件也不会触发。
生成中或 `disabled` 为真时输入框不可编辑,新开会话、添加附件和 `send` 事件不会触发。
## 事件
@@ -31,6 +32,7 @@ demo-preview=../demos/ai-chat-composer/basic.vue
- `stop`:点停止按钮
- `retry`:点错误条上的重试
- `new-conversation`:清空输入框并请求业务侧开始一个新会话
- `add-attachment`:请求业务侧添加附件
输入框内容用 `v-model` 绑定,`maxLength` 默认 2000。
+6 -1
View File
@@ -28,6 +28,10 @@ const {
} = useAiChat({
request: ({ message, signal }) => fetchReply(message, signal)
});
function selectAttachment() {
// 在业务侧打开文件选择器
}
</script>
<template>
@@ -41,6 +45,7 @@ const {
@stop="stopGeneration"
@retry="retryLastMessage"
@new-conversation="resetConversation"
@add-attachment="selectAttachment"
/>
</template>
```
@@ -64,7 +69,7 @@ const {
## 自定义
`welcome`、`composer`、`tool-status`、`message` 等插槽可以替换对应区域。`composer` 插槽会拿到 `draft`、`on-send`、`on-stop`、`on-retry`、`on-new-conversation` 等,便于接入自定义输入框。
`welcome`、`composer`、`tool-status`、`message` 等插槽可以替换对应区域。`composer` 插槽会拿到 `draft`、`on-send`、`on-stop`、`on-retry`、`on-new-conversation`、`on-add-attachment` 等,便于接入自定义输入框。
## API
+5
View File
@@ -9,6 +9,7 @@
@stop="onStop"
@retry="onRetry"
@new-conversation="onNewConversation"
@add-attachment="onAddAttachment"
/>
<div class="bar">
@@ -59,6 +60,10 @@ function onNewConversation() {
error.value = "";
push("new-conversation()");
}
function onAddAttachment() {
push("add-attachment()");
}
</script>
<style lang="less" scoped>
+4
View File
@@ -24,6 +24,10 @@
.t-form {
.cellStyle();
&.card {
border-radius: 1rem;
}
.t-form__item {
padding: @cellPadding;
+76 -42
View File
@@ -12,28 +12,46 @@
/>
</slot>
</div>
<div class="input-wrap">
<t-button
type="button"
theme="primary"
shape="circle"
aria-label="新开会话"
<t-form class="form glass-white" @submit="submitMessage">
<t-textarea
v-model="draft"
class="input"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="props.isGenerating || props.disabled"
@click="startNewConversation"
>
<template #icon>
<t-icon name="chat-add" />
</template>
</t-button>
<t-form class="form glass-white" @submit="submitMessage">
<t-input
v-model="draft"
class="input bg-transparent"
borderless
:disabled="props.isGenerating || props.disabled"
:maxlength="props.maxLength"
:placeholder="props.placeholder"
/>
:maxlength="props.maxLength"
:placeholder="props.placeholder"
/>
<div class="actions">
<div class="tools">
<t-button
class="tool"
type="button"
size="extra-small"
variant="outline"
aria-label="新会话"
content="新会话"
:disabled="props.isGenerating || props.disabled"
@click="startNewConversation"
>
<template #icon>
<t-icon name="chat-add" size="small" />
</template>
</t-button>
<t-button
class="tool"
type="button"
size="extra-small"
variant="outline"
aria-label="上传附件"
content="上传附件"
:disabled="props.isGenerating || props.disabled"
@click="addAttachment"
>
<template #icon>
<t-icon name="file-add" size="small" />
</template>
</t-button>
</div>
<slot
v-if="props.isGenerating"
name="stop"
@@ -43,8 +61,9 @@
<t-button
class="send"
type="button"
size="small"
size="extra-small"
theme="danger"
:disabled="props.disabled"
:content="props.stopText"
@click="emit('stop')"
/>
@@ -58,14 +77,14 @@
<t-button
class="send"
type="submit"
size="small"
size="extra-small"
theme="primary"
:disabled="props.disabled"
:content="props.sendText"
/>
</slot>
</t-form>
</div>
</div>
</t-form>
</div>
</template>
@@ -103,6 +122,7 @@ const emit = defineEmits<{
stop: [];
retry: [];
"new-conversation": [];
"add-attachment": [];
}>();
function startNewConversation(): void {
@@ -113,6 +133,13 @@ function startNewConversation(): void {
emit("new-conversation");
}
function addAttachment(): void {
if (props.disabled || props.isGenerating) {
return;
}
emit("add-attachment");
}
function submitMessage(): void {
if (props.disabled || props.isGenerating) {
return;
@@ -152,35 +179,42 @@ function submitMessage(): void {
}
}
.input-wrap {
.form {
--td-textarea-background-color: transparent;
gap: .75rem;
border: 1px solid var(--td-component-border);
margin: .5rem 0 0;
display: flex;
align-items: center;
padding: .5rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: stretch;
border-radius: 1.25rem;
flex-direction: column;
.form {
--td-input-bg-color: transparent;
--td-button-border-radius: 1rem;
.input {
flex: 1;
width: 100%;
padding: 0 .25rem;
min-width: 0;
}
.actions {
--td-button-border-radius: .75rem;
gap: .5rem;
flex: 1;
border: 1px solid var(--td-component-border);
margin: 0;
margin-top: .375rem;
display: flex;
padding: .25rem .25rem .25rem .75rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: center;
border-radius: 1.25rem;
justify-content: space-between;
.input {
flex: 1;
min-width: 0;
.tools {
gap: .5rem;
display: flex;
align-items: center;
}
.send {
width: 4rem;
flex: none;
padding: 0 1rem;
}
}
}
+5 -3
View File
@@ -90,7 +90,7 @@ import type {
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRoleNameFormatter,
AiChatRoleNames,
AiChatRoleNames
} from "./types";
defineOptions({
@@ -170,11 +170,13 @@ defineExpose({
<style scoped lang="less">
.message-list {
gap: 1rem;
flex: 1;
flex: none;
height: 100%;
display: flex;
padding: .5rem 1rem calc(6rem + env(safe-area-inset-bottom, 0px));
padding: var(--ai-msg-padding-top, .5rem) 1rem calc(var(--ai-chat-bottom-area-height, 4.5rem) + 1.5rem + env(safe-area-inset-bottom, 0px));
min-width: 0;
min-height: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
flex-direction: column;
+37 -2
View File
@@ -2,6 +2,7 @@
<section class="ai-chat-panel" :aria-label="props.ariaLabel">
<ai-chat-message-list
ref="messageList"
:style="messageListStyle"
:messages="props.messages"
:is-generating="props.isGenerating"
:loading="props.isLoading"
@@ -40,7 +41,7 @@
</template>
</ai-chat-message-list>
<div class="bottom-area">
<div ref="bottomArea" class="bottom-area">
<slot name="tool-status" :status="props.activeToolStatus">
<div v-if="props.activeToolStatus" class="tool-status">
<t-icon name="loading" size="1rem" />
@@ -58,6 +59,7 @@
:on-stop="() => emit('stop')"
:on-retry="() => emit('retry')"
:on-new-conversation="() => emit('new-conversation')"
:on-add-attachment="() => emit('add-attachment')"
>
<ai-chat-composer
v-model="draft"
@@ -74,6 +76,7 @@
@stop="emit('stop')"
@retry="emit('retry')"
@new-conversation="emit('new-conversation')"
@add-attachment="emit('add-attachment')"
/>
</slot>
</div>
@@ -81,7 +84,7 @@
</template>
<script setup lang="ts">
import { shallowRef, useTemplateRef } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef } from "vue";
import type {
AiChatMessage,
AiChatMessageStatusFormatter,
@@ -144,10 +147,23 @@ const emit = defineEmits<{
stop: [];
retry: [];
"new-conversation": [];
"add-attachment": [];
}>();
const messageListRef = useTemplateRef<{ scrollToBottom: () => Promise<void> }>("messageList");
const bottomAreaRef = useTemplateRef<HTMLElement>("bottomArea");
const bottomAreaHeight = ref<number>();
const draft = shallowRef("");
let bottomAreaObserver: ResizeObserver | undefined;
const messageListStyle = computed(() => {
if (bottomAreaHeight.value === undefined) {
return {};
}
return {
"--ai-chat-bottom-area-height": `${bottomAreaHeight.value}px`
};
});
function submitMessage(message: string): void {
emit("send", message);
@@ -157,6 +173,23 @@ async function scrollToBottom(): Promise<void> {
await messageListRef.value?.scrollToBottom();
}
onMounted(() => {
const bottomArea = bottomAreaRef.value;
if (!bottomArea) {
return;
}
const updateBottomAreaHeight = () => {
bottomAreaHeight.value = Math.ceil(bottomArea.getBoundingClientRect().height);
};
updateBottomAreaHeight();
bottomAreaObserver = new ResizeObserver(updateBottomAreaHeight);
bottomAreaObserver.observe(bottomArea);
});
onBeforeUnmount(() => {
bottomAreaObserver?.disconnect();
});
defineExpose({
scrollToBottom
});
@@ -188,6 +221,8 @@ defineExpose({
z-index: 2;
position: absolute;
max-width: 40rem;
display: flex;
flex-direction: column;
.tool-status {
gap: .375rem;