Skip to content

Commit 8ade07d

Browse files
author
linyuan.yang
committed
chat-ui
1 parent e559543 commit 8ade07d

14 files changed

Lines changed: 410 additions & 535 deletions

File tree

packages/chat-ui/src/components/AskForm.vue

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,31 @@ function initAnswers() {
3030
customInputs.value = {}
3131
}
3232
33+
function stopTimer() {
34+
if (timer) { clearInterval(timer); timer = null }
35+
}
36+
3337
function startTimer() {
3438
stopTimer()
3539
countdown.value = props.initialCountdown
3640
timer = setInterval(() => { if (countdown.value > 0) countdown.value-- }, 1000)
3741
}
3842
39-
function stopTimer() {
40-
if (timer !== null) { clearInterval(timer); timer = null }
43+
function resolveCustom(i: number, v: string): string {
44+
return v === CUSTOM_SENTINEL ? (customInputs.value[i] ?? '') : v
4145
}
4246
4347
function submitAsk() {
4448
stopTimer()
4549
const result: Record<string, string | string[]> = {}
46-
props.askEvent.questions.forEach((q, i) => {
47-
let val = answers.value[i]
50+
props.askEvent.questions.forEach((_q, i) => {
51+
const val = answers.value[i]
4852
if (Array.isArray(val)) {
49-
val = val.flatMap(v => v === CUSTOM_SENTINEL ? (customInputs.value[i] ? [customInputs.value[i]] : []) : [v])
50-
if (val.length > 0) result[String(i)] = val
51-
} else {
52-
if (val === CUSTOM_SENTINEL) val = customInputs.value[i] ?? ''
53-
if (val !== undefined && val !== '') result[String(i)] = val
53+
const resolved = val.map(v => resolveCustom(i, v)).filter(Boolean)
54+
if (resolved.length > 0) result[String(i)] = resolved
55+
} else if (val !== undefined) {
56+
const resolved = resolveCustom(i, val)
57+
if (resolved !== '') result[String(i)] = resolved
5458
}
5559
})
5660
emit('submit', { askId: props.askEvent.id, answers: result })
@@ -90,7 +94,7 @@ onUnmounted(stopTimer)
9094
v-model="customInputs[i]" :placeholder="L.askOtherPlaceholder" />
9195
</div>
9296
<input v-else type="text" class="chatui-ask-input" v-model="(answers[i] as string)"
93-
:placeholder="(q as any).placeholder ?? ''" />
97+
:placeholder="q.placeholder ?? ''" />
9498
</div>
9599
<div class="chatui-ask-footer">
96100
<button class="chatui-btn-primary chatui-btn-sm" @click="submitAsk">{{ L.askSubmit }} ({{ countdown }}s)</button>

packages/chat-ui/src/components/ChatArea.vue

Lines changed: 25 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
} from '../types'
88
import { resolveLabels } from '../labels'
99
import { useCompact } from '../composables/useCompact'
10-
const isCompact = useCompact()
10+
import { useAttachments } from '../composables/useAttachments'
1111
import MessageList from './MessageList.vue'
1212
import RichInput from './RichInput.vue'
1313
import ToolApprovalBar from './ToolApprovalBar.vue'
@@ -16,7 +16,7 @@ import AskForm from './AskForm.vue'
1616
const props = withDefaults(defineProps<{
1717
messages: StoredMessage[]
1818
isStreaming: boolean
19-
streamingContent: string | any[]
19+
streamingContent: DisplayContent
2020
queuedMessages?: DisplayContent[]
2121
pendingToolCall: ToolCallEvent | null
2222
pendingAsk: AskEvent | null
@@ -38,20 +38,21 @@ const emit = defineEmits<{
3838
}>()
3939
4040
const L = computed(() => resolveLabels(props.labels))
41+
const isCompact = useCompact()
4142
4243
const richInputRef = ref<InstanceType<typeof RichInput>>()
4344
const messagesEl = ref<HTMLElement | null>(null)
44-
const attachments = ref<Attachment[]>([])
4545
const fileInputEl = ref<HTMLInputElement | null>(null)
4646
const isDragging = ref(false)
4747
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
4848
49+
const { attachments, add: addFiles, remove: removeAttachment, drain: drainAttachments, isImageMime } = useAttachments()
50+
4951
// ── Scrolling ──
5052
5153
function isAtBottom(): boolean {
52-
if (!messagesEl.value) return true
5354
const el = messagesEl.value
54-
return el.scrollHeight - el.scrollTop - el.clientHeight < 60
55+
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 60
5556
}
5657
5758
function scrollToBottom(force = false) {
@@ -61,63 +62,20 @@ function scrollToBottom(force = false) {
6162
}
6263
6364
watch(() => props.messages.length, async () => {
64-
await nextTick()
65-
scrollToBottom()
65+
await nextTick(); scrollToBottom()
6666
})
67-
6867
watch(() => props.streamingContent, async () => {
69-
await nextTick()
70-
scrollToBottom(true)
68+
await nextTick(); scrollToBottom(true)
7169
})
7270
7371
// ── Attachments ──
7472
75-
function pickFile() {
76-
fileInputEl.value?.click()
77-
}
78-
79-
function isTextMime(type: string) {
80-
return type.startsWith('text/') ||
81-
type === 'application/json' ||
82-
type === 'application/xml' ||
83-
type === 'application/javascript' ||
84-
type === 'application/xhtml+xml'
85-
}
86-
87-
function readFile(file: File): Promise<Attachment> {
88-
return new Promise((resolve) => {
89-
const reader = new FileReader()
90-
if (isTextMime(file.type)) {
91-
reader.onload = () => resolve({ name: file.name, type: file.type, content: reader.result as string })
92-
reader.readAsText(file)
93-
} else {
94-
reader.onload = () => resolve({ name: file.name, type: file.type, dataUrl: reader.result as string })
95-
reader.readAsDataURL(file)
96-
}
97-
})
98-
}
99-
100-
async function addFiles(files: File[]) {
101-
for (const file of files) {
102-
if (attachments.value.find(a => a.name === file.name)) continue
103-
const att = await readFile(file)
104-
attachments.value.push(att)
105-
}
106-
}
73+
const pickFile = () => fileInputEl.value?.click()
10774
10875
async function onFileChange(e: Event) {
109-
const files = (e.target as HTMLInputElement).files
110-
if (!files) return
111-
await addFiles(Array.from(files))
112-
;(e.target as HTMLInputElement).value = ''
113-
}
114-
115-
function removeAttachment(idx: number) {
116-
attachments.value.splice(idx, 1)
117-
}
118-
119-
function isImage(att: Attachment) {
120-
return att.type.startsWith('image/')
76+
const input = e.target as HTMLInputElement
77+
if (input.files) await addFiles(Array.from(input.files))
78+
input.value = ''
12179
}
12280
12381
function onInputBarDragOver(e: DragEvent) {
@@ -136,23 +94,22 @@ function onInputBarDrop(e: DragEvent) {
13694
isDragging.value = false
13795
e.preventDefault()
13896
if (!e.dataTransfer?.files?.length) return
139-
const nonImageFiles = Array.from(e.dataTransfer.files).filter(f => !f.type.startsWith('image/'))
140-
if (nonImageFiles.length > 0) addFiles(nonImageFiles)
141-
}
142-
143-
function onFilesFromEditor(files: File[]) {
144-
addFiles(files)
97+
// Images dropped on the input bar are handled by the editor's own drop handler; only non-images
98+
// are surfaced as attachment chips here.
99+
const nonImages = Array.from(e.dataTransfer.files).filter(f => !isImageMime(f.type))
100+
if (nonImages.length > 0) addFiles(nonImages)
145101
}
146102
147103
// ── Send ──
148104
149105
function send() {
150-
if (!richInputRef.value) return
151-
const { parts } = richInputRef.value.getContent()
152-
const fileAtts = attachments.value.splice(0)
153-
if (parts.length === 0 && fileAtts.length === 0) return
154-
richInputRef.value.clear()
155-
emit('send', parts, fileAtts)
106+
const input = richInputRef.value
107+
if (!input) return
108+
const { parts } = input.getContent()
109+
const files = drainAttachments()
110+
if (parts.length === 0 && files.length === 0) return
111+
input.clear()
112+
emit('send', parts, files)
156113
}
157114
158115
onBeforeUnmount(() => {
@@ -211,7 +168,7 @@ defineExpose({ scrollToBottom })
211168
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0">
212169
<div v-if="attachments.length > 0" class="chatui-attachments">
213170
<div v-for="(att, i) in attachments" :key="att.name" class="chatui-attachment-chip">
214-
<img v-if="isImage(att) && att.dataUrl" :src="att.dataUrl" class="chatui-attachment-thumb" />
171+
<img v-if="isImageMime(att.type) && att.dataUrl" :src="att.dataUrl" class="chatui-attachment-thumb" />
215172
<span v-else class="chatui-attachment-icon">📄</span>
216173
<span class="chatui-attachment-name">{{ att.name }}</span>
217174
<button class="chatui-attachment-remove" @click="removeAttachment(i)">×</button>
@@ -221,7 +178,7 @@ defineExpose({ scrollToBottom })
221178
ref="richInputRef"
222179
:placeholder="L.inputPlaceholder"
223180
@submit="send"
224-
@files="onFilesFromEditor"
181+
@files="addFiles"
225182
/>
226183
</div>
227184
<div class="chatui-input-actions">

0 commit comments

Comments
 (0)