Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 22 additions & 37 deletions docs/demos/sender/actions-enhanced.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { TrSender, UploadButton, VoiceButton } from '@opentiny/tiny-robot'
import { Tag as TinyTag } from '@opentiny/vue'

const content = ref('')
const message = ref('')
const selectedFiles = ref<File[]>([])
let messageTimer: ReturnType<typeof setTimeout> | undefined

const handleSubmit = (text: string) => {
const fileNames = selectedFiles.value.map((file) => file.name).join(', ')
message.value = fileNames ? `已提交: ${text || '(无文本)'},附件: ${fileNames}` : `已提交: ${text}`
content.value = ''
selectedFiles.value = []
setTimeout(() => (message.value = ''), 3000)
}
const showMessage = (value: string) => {
if (messageTimer) {
clearTimeout(messageTimer)
}

const handleFiles = (files: File[]) => {
selectedFiles.value = [...selectedFiles.value, ...files]
message.value = value
messageTimer = setTimeout(() => {
message.value = ''
messageTimer = undefined
}, 3000)
}

const handleClear = () => {
selectedFiles.value = []
const handleSubmit = (text: string) => {
showMessage(`已提交: ${text}`)
content.value = ''
}

const removeFile = (index: number) => {
selectedFiles.value = selectedFiles.value.filter((_, fileIndex) => fileIndex !== index)
const handleFiles = (files: File[]) => {
showMessage(`已选择 ${files.length} 个文件`)
}

const handleVoiceFinal = (text: string) => {
content.value += text + ' '
}

onBeforeUnmount(() => {
if (messageTimer) {
clearTimeout(messageTimer)
}
})
</script>

<template>
Expand All @@ -38,10 +44,8 @@ const handleVoiceFinal = (text: string) => {
v-model="content"
placeholder="输入内容,或使用语音/上传文件..."
mode="multiple"
:has-external-content="selectedFiles.length > 0"
clearable
@submit="handleSubmit"
@clear="handleClear"
>
<template #footer-right>
<!-- 上传按钮 -->
Expand All @@ -58,18 +62,6 @@ const handleVoiceFinal = (text: string) => {
</template>
</tr-sender>

<div v-if="selectedFiles.length" class="file-list">
<tiny-tag
v-for="(file, index) in selectedFiles"
:key="`${file.name}-${file.lastModified}-${index}`"
:max-width="240"
closable
@close="removeFile(index)"
>
{{ file.name }}
</tiny-tag>
</div>

<div v-if="message" class="message">{{ message }}</div>
</div>
</template>
Expand All @@ -86,11 +78,4 @@ const handleVoiceFinal = (text: string) => {
border-radius: 6px;
color: #1476ff;
}

.file-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
</style>
112 changes: 112 additions & 0 deletions docs/demos/sender/attachments-in-sender.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { TrAttachments, TrSender, UploadButton } from '@opentiny/tiny-robot'
import type { Attachment, SenderSubmitExtra } from '@opentiny/tiny-robot'

const content = ref('')
const message = ref('')
const attachments = ref<Attachment[]>([])

const createAttachment = (file: File, index: number): Attachment => {
const isImage = file.type.startsWith('image/')

return {
id: `${file.name}-${file.lastModified}-${Date.now()}-${index}`,
name: file.name,
rawFile: file,
size: file.size,
status: 'success',
url: isImage ? URL.createObjectURL(file) : undefined,
}
}

const revokeObjectUrl = (attachment: Attachment) => {
if (attachment.url?.startsWith('blob:')) {
URL.revokeObjectURL(attachment.url)
}
}

const isAttachmentExternalPayload = (payload: unknown): payload is Attachment[] => {
return Array.isArray(payload)
}

const clearAttachments = () => {
attachments.value.forEach(revokeObjectUrl)
attachments.value = []
}

const handleFiles = (files: File[]) => {
attachments.value = [...attachments.value, ...files.map(createAttachment)]
}

const handleSubmit = (text: string, _structuredData?: unknown, extra?: SenderSubmitExtra) => {
const attachmentNames =
extra?.externalPayloads.reduce<string[]>((names, externalPayload) => {
if (externalPayload.source !== 'attachments' || !isAttachmentExternalPayload(externalPayload.payload)) {
return names
}

externalPayload.payload.forEach((attachment) => {
names.push(attachment.name || attachment.rawFile?.name || '未命名文件')
})

return names
}, []) ?? []

message.value = attachmentNames.length
? `已提交: ${text || '(无文本)'},附件: ${attachmentNames.join('、')}`
: `已提交: ${text}`

content.value = ''
clearAttachments()
}

onBeforeUnmount(clearAttachments)
</script>

<template>
<div class="demo-container">
<tr-sender
v-model="content"
placeholder="输入内容,或直接上传附件后发送..."
mode="multiple"
clearable
@submit="handleSubmit"
>
<template v-if="attachments.length" #header>
<tr-attachments
v-model:items="attachments"
class="sender-attachments"
variant="card"
wrap
@remove="revokeObjectUrl"
/>
</template>

<template #footer-right>
<UploadButton accept="*" :multiple="true" tooltip="上传附件" tooltip-placement="top" @select="handleFiles" />
</template>
</tr-sender>

<div v-if="message" class="message">{{ message }}</div>
</div>
</template>

<style scoped>
.demo-container {
display: grid;
gap: 12px;
padding: 20px;
}

.sender-attachments {
width: 100%;
}

.message {
padding: 10px;
border-radius: 6px;
background: #e7f3ff;
color: #1476ff;
}
</style>
29 changes: 24 additions & 5 deletions docs/src/components/sender.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,20 @@ TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter })

通过插槽添加增强按钮(Upload、Voice 等),每个按钮都有独立的配置。

<demo vue="../../demos/sender/actions-enhanced.vue" title="增强按钮" description="通过插槽添加 Upload、Voice 等增强按钮。" />

附件、图片等内容通常由独立组件维护,不会写入 Sender 的编辑器文本。当用户只上传附件或图片、不输入额外文本时,Sender 无法感知这部分外部内容,会因输入框为空而不显示默认发送按钮。此时可以通过 `hasExternalContent` 告诉 Sender 当前存在外部可提交内容,例如 `:has-external-content="files.length > 0"`。设置后,即使输入框为空,默认发送按钮也会显示,快捷键提交和 `submit()` 方法也会按有内容处理。默认清空按钮仍只根据编辑器文本显示;外部内容数据由业务侧维护,建议由附件或图片列表提供自己的删除入口。
<demo vue="../../demos/sender/actions-enhanced.vue" title="增强按钮" description="通过插槽添加 Upload、Voice 等增强按钮;上传内容随消息提交见下方示例。" />

**配置详见**:[UploadButton 属性](#uploadbutton)、[VoiceButton 属性](#voicebutton)

### 上传内容

附件、图片等内容通常由上传按钮或独立列表维护,不会写入 Sender 的编辑器文本。把 `TrAttachments` 放在 `TrSender` 内时会自动注册提交数据;提交时可从 `extra.externalPayloads` 中读取 `source="attachments"` 的 payload,其值为原样透传的 `Attachment[]`,具体过滤或上传失败提示由业务层处理。自定义外部内容组件也可以通过 `useSenderContentRegistration` 注册数据。

:::warning 兼容说明
`hasExternalContent` 仍可用于控制外部内容场景的可提交状态,但不会生成 `externalPayloads`。
:::

<demo vue="../../demos/sender/attachments-in-sender.vue" title="输入框内附件列表" description="使用 Attachments 在 Sender 内展示和管理附件,并随消息一起提交。" />

## 交互定制

### 取消操作
Expand Down Expand Up @@ -283,7 +291,7 @@ Sender 提供了多个插槽位置,方便扩展功能:
| enterkeyhint @0.4 | 移动端虚拟键盘回车键提示 | `EnterKeyHint` | `'send'` |
| autoSize | 自动调整高度 | `boolean \| { minRows: number, maxRows: number }` | `{ minRows: 1, maxRows: 5 }` |
| clearable | 是否可清空 | `boolean` | `false` |
| hasExternalContent @0.5 | 是否存在外部可提交内容,适用于附件、图片等不写入编辑器文本的内容 | `boolean` | `false` |
| hasExternalContent @0.5 兼容保留 | 是否存在外部可提交内容,仅控制可提交状态,不会生成 `externalPayloads` | `boolean` | `false` |
| maxLength | 最大输入长度 | `number` | `Infinity` |
| showWordLimit | 是否显示字数统计 | `boolean` | `false` |
| submitType | 提交方式 | `'enter' \| 'ctrlEnter' \| 'shiftEnter'` | `'enter'` |
Expand Down Expand Up @@ -450,7 +458,7 @@ onSelect: (item) => {
| 事件名 | 说明 | 回调参数 |
| ----------------- | -------------------------------------------------------------------- | --------------------------------------- |
| update:modelValue | 内容更新 | `(value: string)` |
| submit | 提交内容,返回纯文本和结构化数据(可选) | `(text: string, data?: StructuredData)` |
| submit | 提交内容,返回纯文本、结构化数据(可选)和额外提交内容(可选) | `(text: string, data?: StructuredData, extra?: SenderSubmitExtra)` |
| clear | 清空内容 | `()` |
| focus | 获得焦点 | `(event: FocusEvent)` |
| blur | 失去焦点 | `(event: FocusEvent)` |
Expand All @@ -460,10 +468,12 @@ onSelect: (item) => {
:::tip submit 事件参数说明
- **text**:纯文本内容,适用于简单场景(如直接发送给 AI)
- **data**:结构化数据数组,仅在使用 Template 或 Mention 扩展时返回,包含文本和特殊节点的完整信息
- **extra**:仅当存在外部 payload 时返回,当前包含 `externalPayloads`

根据业务需求选择使用:
- 简单场景:只使用 `text` 参数
- 复杂场景:使用 `data` 参数提取特殊节点信息或自定义拼接格式
- 附件等外部内容:使用 `extra.externalPayloads` 读取。Sender 只透传外部内容的 `source` 和 `payload`,不内置解析特定来源,也不按状态过滤

详见:[结构化数据](#结构化数据)
:::
Expand Down Expand Up @@ -664,6 +674,15 @@ interface SuggestionTextPart {
// 结构化数据(submit 事件返回)
type StructuredData = TemplateItem[] | MentionStructuredItem[]

interface SenderExternalPayload {
readonly source: string
readonly payload: unknown
}

interface SenderSubmitExtra {
externalPayloads: SenderExternalPayload[]
}

// 输入模式
type InputMode = 'single' | 'multiple'

Expand Down
10 changes: 9 additions & 1 deletion packages/components/src/attachments/index.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { watch, ref, computed, onBeforeUnmount } from 'vue'
import { useImagePreview, useListType } from './composables'
import { AttachmentListEmits, AttachmentListProps, Attachment, ActionButton } from './index.type'
import FileCard from './components/FileCard.vue'
import { useFileType } from './composables/useFileType'
import { useSenderContentRegistration } from '../shared/composables'

const props = withDefaults(defineProps<AttachmentListProps>(), {
variant: 'auto',
Expand Down Expand Up @@ -64,6 +65,13 @@ const { normalizeAttachments } = useFileType({
fileMatchers: props.fileMatchers,
})

const registerSenderContent = useSenderContentRegistration()
const unregisterSenderContent = registerSenderContent?.('attachments', fileList)

onBeforeUnmount(() => {
unregisterSenderContent?.()
})

// 监听props.items变化
watch(
() => props.items,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export * from './welcome/index.type'
export * from './mcp-server-picker/index.type'
export * from './mcp-add-form/index.type'

export { useSenderContentRegistration } from './shared/composables/useSenderContentRegistration'
export type { SenderContentRegister } from './shared/composables/useSenderContentRegistration'
export {
BubbleRendererMatchPriority,
BubbleRenderers,
Expand Down
Loading
Loading