support attach
This commit is contained in:
@@ -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。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
.t-form {
|
||||
.cellStyle();
|
||||
|
||||
&.card {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.t-form__item {
|
||||
padding: @cellPadding;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user