Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
38 changes: 3 additions & 35 deletions docs/demos/sender/actions-enhanced.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
<script setup lang="ts">
import { 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[]>([])

const handleSubmit = (text: string) => {
const fileNames = selectedFiles.value.map((file) => file.name).join(', ')
message.value = fileNames ? `已提交: ${text || '(无文本)'},附件: ${fileNames}` : `已提交: ${text}`
message.value = `已提交: ${text}`
content.value = ''
selectedFiles.value = []
setTimeout(() => (message.value = ''), 3000)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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

const handleClear = () => {
selectedFiles.value = []
}

const removeFile = (index: number) => {
selectedFiles.value = selectedFiles.value.filter((_, fileIndex) => fileIndex !== index)
message.value = `已选择 ${files.length} 个文件`
setTimeout(() => (message.value = ''), 3000)
}

const handleVoiceFinal = (text: string) => {
Expand All @@ -38,10 +27,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 +45,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 +61,4 @@ const handleVoiceFinal = (text: string) => {
border-radius: 6px;
color: #1476ff;
}

.file-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
</style>
109 changes: 109 additions & 0 deletions docs/demos/sender/attachments-in-sender.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { TrAttachments, TrSender, UploadButton } from '@opentiny/tiny-robot'
import type { Attachment, SenderSubmitMeta } 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 clearAttachments = () => {
attachments.value.forEach(revokeObjectUrl)
attachments.value = []
}

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

const handleSubmit = (text: string, _structuredData?: unknown, meta?: SenderSubmitMeta) => {
const attachmentNames =
meta?.externalPayloads.reduce<string[]>((names, payload) => {
if (payload.sourceId !== 'sender-attachments' || payload.type !== 'attachments') {
return names
}

payload.items.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"
content-source-id="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>
38 changes: 38 additions & 0 deletions docs/src/components/attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@ Attachments 组件用于展示文件列表,并支持图片预览、文件下
| actions | `ActionButton[]` | `['preview', 'download']` | 自定义操作按钮,详见 [自定义操作按钮](#自定义操作按钮-actions) |
| fileIcons | `Record<string, Component>` | - | 自定义文件类型图标,详见 [自定义图标](#自定义图标-fileicons) |
| fileMatchers | `FileTypeMatcher[]` | `[]` | 自定义文件类型匹配器,详见 [自定义文件类型](#自定义文件类型-filematchers) |
| contentSourceId @0.5 | `string` | - | 当上层容器提供附件注册上下文时,将附件列表注册为可随容器提交的附件内容 |

### 附件内容注册

`contentSourceId` 会配合上层容器提供的 `ATTACHMENTS_CONTENT_CONTEXT_KEY` 使用。`TrSender` 已内置该 provider;自定义 Sender 类容器也可以通过 `useSenderContentRegistry` 提供同样的内容注册能力。

```typescript
import { provide } from 'vue'
import {
ATTACHMENTS_CONTENT_CONTEXT_KEY,
useSenderContentRegistry,
type AttachmentsContentContext,
} from '@opentiny/tiny-robot'

const {
hasRegisteredContent,
registerAttachmentsContent,
collectExternalPayloads,
} = useSenderContentRegistry()

const attachmentsContentContext: AttachmentsContentContext = {
registerAttachmentsContent,
}

provide(ATTACHMENTS_CONTENT_CONTEXT_KEY, attachmentsContentContext)
```

注册后,容器可以使用 `hasRegisteredContent` 判断是否存在已注册内容,并在提交时通过 `collectExternalPayloads()` 获取 payload。

## Events

Expand Down Expand Up @@ -128,6 +156,16 @@ export interface RawFileAttachment extends BaseAttachment {
export type Attachment = UrlAttachment | RawFileAttachment
```

**AttachmentsContentRegistration**

```typescript
interface AttachmentsContentRegistration {
id: string
hasContent: Ref<boolean> | (() => boolean)
getAttachments: () => Attachment[]
}
```

**ActionButton**

用于定义操作按钮。
Expand Down
32 changes: 27 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` 放在 Sender 内时,可通过 `content-source-id` 自动注册提交数据。

:::warning 兼容说明
`hasExternalContent` 仍可用于控制外部内容场景的可提交状态,但不会生成 `externalPayloads`。需要随提交返回附件数据时,请使用 `TrAttachments` 的 `content-source-id`。
:::

<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`;使用 `TrAttachments` 时请改用 `contentSourceId` | `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 | 提交内容,返回纯文本、结构化数据(可选)和外部内容 meta(可选) | `(text: string, data?: StructuredData, meta?: SenderSubmitMeta)` |
| clear | 清空内容 | `()` |
| focus | 获得焦点 | `(event: FocusEvent)` |
| blur | 失去焦点 | `(event: FocusEvent)` |
Expand All @@ -460,10 +468,12 @@ onSelect: (item) => {
:::tip submit 事件参数说明
- **text**:纯文本内容,适用于简单场景(如直接发送给 AI)
- **data**:结构化数据数组,仅在使用 Template 或 Mention 扩展时返回,包含文本和特殊节点的完整信息
- **meta**:仅当存在附件 payload 时返回,当前包含 `externalPayloads`

根据业务需求选择使用:
- 简单场景:只使用 `text` 参数
- 复杂场景:使用 `data` 参数提取特殊节点信息或自定义拼接格式
- 附件等外部内容:使用 `meta.externalPayloads` 按 `sourceId` 和 `type` 读取

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

interface SenderAttachmentPayload {
type: 'attachments'
items: Attachment[]
}

type SenderExternalPayloadSourceId = string
type SenderExternalPayload = SenderAttachmentPayload & { readonly sourceId: SenderExternalPayloadSourceId }

interface SenderSubmitMeta {
externalPayloads: SenderExternalPayload[]
}

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

Expand Down
17 changes: 17 additions & 0 deletions packages/components/src/attachments/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { InjectionKey, Ref } from 'vue'
import type { Attachment } from './index.type'

export type AttachmentsContentSourceId = string

export interface AttachmentsContentRegistration {
id: AttachmentsContentSourceId
hasContent: Ref<boolean> | (() => boolean)
getAttachments: () => Attachment[]
}

export interface AttachmentsContentContext {
registerAttachmentsContent: (registration: AttachmentsContentRegistration) => () => void
}

export const ATTACHMENTS_CONTENT_CONTEXT_KEY: InjectionKey<AttachmentsContentContext> =
Symbol('attachments-content-context')
3 changes: 3 additions & 0 deletions packages/components/src/attachments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ const install = function <T>(app: App<T>) {
Attachments.install = install

export default Attachments as typeof Attachments & { install: typeof install }

export { ATTACHMENTS_CONTENT_CONTEXT_KEY } from './context'
export type { AttachmentsContentContext, AttachmentsContentRegistration, AttachmentsContentSourceId } from './context'
1 change: 1 addition & 0 deletions packages/components/src/attachments/index.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface AttachmentListProps {
items?: Attachment[]
disabled?: boolean
wrap?: boolean
contentSourceId?: string

// 图标配置
fileIcons?: Record<string, Component>
Expand Down
32 changes: 31 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, inject } 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 { ATTACHMENTS_CONTENT_CONTEXT_KEY } from './context'

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

const attachmentsContentContext = inject(ATTACHMENTS_CONTENT_CONTEXT_KEY, undefined)
const registeredAttachmentContentId = computed(() => props.contentSourceId?.trim())
const hasAttachmentsContent = computed(() => fileList.value.length > 0)

let unregisterAttachmentsContent: (() => void) | undefined

const unregisterRegisteredAttachments = () => {
unregisterAttachmentsContent?.()
unregisterAttachmentsContent = undefined
}

watch(
registeredAttachmentContentId,
(sourceId, _oldSourceId, onCleanup) => {
unregisterRegisteredAttachments()

if (!sourceId || !attachmentsContentContext) return

unregisterAttachmentsContent = attachmentsContentContext.registerAttachmentsContent({
id: sourceId,
hasContent: hasAttachmentsContent,
getAttachments: () => [...fileList.value],
})

onCleanup(unregisterRegisteredAttachments)
},
{ immediate: true },
)

// 监听props.items变化
watch(
() => props.items,
Expand Down
Loading
Loading