fix: refine agent image attachments and restore Korean filenames

This commit is contained in:
2026-10-19 14:47:21 +09:00
parent 234c9ad467
commit 4321d944b5
8 changed files with 150 additions and 12 deletions
@@ -2,10 +2,19 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
import { displayAttachmentName } from './chatAttachments'
export function ChatImageAttachments({ images }: { images: Array<{ name: string; url: string }> }): React.JSX.Element | null {
type ImageAttachment = { name: string; url: string }
export function ChatImageAttachments({ images, variant = 'draft', dark = true, onRemove, removeDisabled = false }: {
images: ImageAttachment[]
variant?: 'draft' | 'sent'
dark?: boolean
onRemove?: (image: ImageAttachment) => void
removeDisabled?: boolean
}): React.JSX.Element | null {
useLocaleRender()
const [preview, setPreview] = useState<{ name: string; url: string } | null>(null)
const [preview, setPreview] = useState<ImageAttachment | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (preview) dialogRef.current?.showModal()
@@ -13,10 +22,19 @@ export function ChatImageAttachments({ images }: { images: Array<{ name: string;
}, [preview])
if (!images.length) return null
return <>
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{images.map((image, index) => <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-20 w-24 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
</button>)}
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
{images.map((source, index) => {
const image = { ...source, name: displayAttachmentName(source.name) }
return variant === 'draft' ? <div key={`${image.url}-${index}`} data-chat-image-draft="true" className={['flex h-16 w-56 min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border p-1', dark ? 'border-white/10 bg-white/[0.03] text-white/70' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569]'].join(' ')}>
<button type="button" title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="flex h-full min-w-0 flex-1 cursor-zoom-in items-center gap-2 overflow-hidden text-left">
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-14 shrink-0 rounded bg-black/10 object-contain" />
<span className="min-w-0 truncate text-[11px]">{image.name}</span>
</button>
{onRemove && <button type="button" disabled={removeDisabled} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${image.name}`} onClick={() => onRemove(image)} className={['grid size-6 shrink-0 cursor-pointer place-items-center rounded disabled:cursor-default disabled:opacity-40', dark ? 'hover:bg-white/10' : 'hover:bg-black/5'].join(' ')}><X size={12} /></button>}
</div> : <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-16 w-20 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
</button>
})}
</div>
{preview && createPortal(<dialog ref={dialogRef} aria-label={preview.name} onCancel={() => setPreview(null)} onClick={event => { if (event.target === event.currentTarget) setPreview(null) }} className="fixed inset-0 m-auto max-h-[90vh] max-w-[94vw] overflow-auto rounded-lg border border-white/15 bg-[#202020] p-3 text-white shadow-xl backdrop:bg-black/75">
<div className="mb-2 flex min-w-0 items-center justify-between gap-3"><span className="min-w-0 break-all text-[12px]">{preview.name}</span><button type="button" title={t('닫기')} aria-label={t('닫기')} onClick={() => setPreview(null)} className="grid size-8 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10"><X size={16} /></button></div>
+3 -3
View File
@@ -436,9 +436,9 @@ export const QaInputBox = memo(function QaInputBox({
setDraft(current => current.slice(0, start) + text + current.slice(end))
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
}} />
{attachmentsEnabled && attachments.length > 0 && (
{attachmentsEnabled && attachments.some(file => file.kind !== 'image') && (
<div className="flex flex-wrap gap-1.5 pt-2">
{attachments.map((file, index) => (
{attachments.map((file, index) => file.kind === 'image' ? null : (
<span key={`${file.name}-${index}`} className={['inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-2 py-1 text-[11px]', dark ? 'border-white/10 text-white/70' : 'border-[#d8dee9] text-[#475569]'].join(' ')}>
<Paperclip size={12} className="shrink-0" />
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
@@ -447,7 +447,7 @@ export const QaInputBox = memo(function QaInputBox({
))}
</div>
)}
<ChatImageAttachments images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} />
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
@@ -5,6 +5,16 @@ export type AgentImageReference = { token: string; name: string; mimeType: strin
export const CHAT_IMAGE_ACCEPT = '.png,.jpg,.jpeg,.webp'
export function displayAttachmentName(name: string): string {
// Older multipart uploads can leave Latin-1-decoded UTF-8 in cached image references.
if (!/^[\u0000-\u00ff]*$/.test(name)) return name
try {
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(name, character => character.charCodeAt(0)))
} catch {
return name
}
}
export const CHAT_ATTACHMENT_ACCEPT = '.txt,.log,.md,.json,.yaml,.yml,.xml,.py,.js,.jsx,.ts,.tsx,.c,.cc,.cpp,.h,.hpp,.sh,.bash,.cfg,.ini,.toml,.csv,.urdf,.xacro,.launch,.rviz,.cmake'
export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
@@ -410,7 +410,10 @@ function AgentMessages({
<div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}<ChatImageAttachments images={message.imageAttachments ?? []} /></div>
<div className="flex min-w-0 max-w-[82%] flex-col items-end gap-2">
<div data-agent-user-bubble="true" className="min-w-0 max-w-full whitespace-pre-wrap break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
<ChatImageAttachments images={message.imageAttachments ?? []} variant="sent" />
</div>
</div>
) : (
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
@@ -276,7 +276,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
role: 'user',
content: [request, ...(options.attachments?.length ? [`\n첨부 파일: ${options.attachments.map((file) => file.name).join(', ')}`] : [])].join('\n')
content: request
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)