fix: refine agent image attachments and restore Korean filenames
This commit is contained in:
@@ -19,6 +19,20 @@ function visit(node) {
|
|||||||
visit(source)
|
visit(source)
|
||||||
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||||
|
|
||||||
|
test('user messages preserve only typed text while attachments remain separate metadata', () => {
|
||||||
|
let initializer
|
||||||
|
function find(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'userMessage') initializer = node.initializer.getText(source)
|
||||||
|
ts.forEachChild(node, find)
|
||||||
|
}
|
||||||
|
find(source)
|
||||||
|
assert.ok(initializer)
|
||||||
|
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||||
|
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||||
|
assert.equal(message.content, request)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) {
|
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) {
|
||||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||||
|
|||||||
@@ -21,15 +21,38 @@ const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/com
|
|||||||
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||||
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
||||||
|
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||||
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
||||||
t: key => key, useLocaleRender() {},
|
t: key => key, useLocaleRender() {},
|
||||||
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
||||||
ChatEmptyState: widgets.ChatEmptyState,
|
ChatEmptyState: widgets.ChatEmptyState,
|
||||||
ProgressiveChatResponse,
|
ProgressiveChatResponse,
|
||||||
|
ChatImageAttachments,
|
||||||
MessageCopyButton: () => null,
|
MessageCopyButton: () => null,
|
||||||
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||||
|
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
|
||||||
|
})
|
||||||
|
let row
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
const children = React.Children.toArray(element.props.children)
|
||||||
|
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
|
||||||
|
children.forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(row)
|
||||||
|
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
|
||||||
|
assert.match(row[0].props.className, /whitespace-pre-wrap/)
|
||||||
|
assert.equal(row[1].type, ChatImageAttachments)
|
||||||
|
assert.equal(row[1].props.variant, 'sent')
|
||||||
|
assert.equal(row[1].props.images.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
for (const dark of [false, true]) {
|
for (const dark of [false, true]) {
|
||||||
for (const mode of ['AiMessages', 'AgentMessages']) {
|
for (const mode of ['AiMessages', 'AgentMessages']) {
|
||||||
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
|
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict')
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const vm = require('node:vm')
|
const vm = require('node:vm')
|
||||||
const ts = require('typescript')
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const api = {}
|
const api = {}
|
||||||
@@ -18,7 +20,7 @@ function setup() {
|
|||||||
},
|
},
|
||||||
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
|
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
|
||||||
})
|
})
|
||||||
return { read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
|
return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
|
||||||
}
|
}
|
||||||
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
|
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
|
||||||
|
|
||||||
@@ -46,3 +48,71 @@ test('a mixed batch failure revokes only newly created image previews', async ()
|
|||||||
assert.deepEqual(h.revoked, ['blob:fixture-2'])
|
assert.deepEqual(h.revoked, ['blob:fixture-2'])
|
||||||
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
|
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => {
|
||||||
|
const { api } = setup()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name)
|
||||||
|
for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function previewHarness() {
|
||||||
|
const { api } = setup()
|
||||||
|
const exports = {}
|
||||||
|
let preview = null
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
document: { body: {} },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] }
|
||||||
|
if (name === 'react-dom') return { createPortal: child => child }
|
||||||
|
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||||
|
if (name === './chatAttachments') return api
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const find = (node, predicate) => {
|
||||||
|
if (!React.isValidElement(node)) return null
|
||||||
|
if (predicate(node)) return node
|
||||||
|
return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean)
|
||||||
|
}
|
||||||
|
return { render: props => exports.ChatImageAttachments(props), find }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷-긴파일이름-'.repeat(12) + '.png'
|
||||||
|
let removed
|
||||||
|
const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } }
|
||||||
|
const tree = h.render(props)
|
||||||
|
const item = h.find(tree, node => node.props['data-chat-image-draft'])
|
||||||
|
assert.ok(item)
|
||||||
|
assert.match(item.props.className, /w-56.*max-w-full/)
|
||||||
|
assert.ok(h.find(item, node => node.type === 'img'))
|
||||||
|
const label = h.find(item, node => node.type === 'span')
|
||||||
|
assert.equal(label.props.children, name)
|
||||||
|
assert.match(label.props.className, /truncate/)
|
||||||
|
const zoom = h.find(item, node => node.type === 'button' && node.props.title === name)
|
||||||
|
assert.equal(zoom.props.title, name)
|
||||||
|
h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick()
|
||||||
|
assert.equal(removed, 'blob:fixture')
|
||||||
|
const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft'])
|
||||||
|
assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' }
|
||||||
|
const tree = h.render(props)
|
||||||
|
assert.equal(h.find(tree, node => node.type === 'button').props.title, name)
|
||||||
|
assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined)
|
||||||
|
h.find(tree, node => node.type === 'button').props.onClick()
|
||||||
|
const html = renderToStaticMarkup(h.render(props))
|
||||||
|
assert.match(html, /<dialog[^>]*aria-label="스크린샷 2026-10-19 14-30-45.png"/)
|
||||||
|
assert.ok(html.includes(`>${name}</span>`))
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { t, useLocaleRender } from '../i18n'
|
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()
|
useLocaleRender()
|
||||||
const [preview, setPreview] = useState<{ name: string; url: string } | null>(null)
|
const [preview, setPreview] = useState<ImageAttachment | null>(null)
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preview) dialogRef.current?.showModal()
|
if (preview) dialogRef.current?.showModal()
|
||||||
@@ -13,10 +22,19 @@ export function ChatImageAttachments({ images }: { images: Array<{ name: string;
|
|||||||
}, [preview])
|
}, [preview])
|
||||||
if (!images.length) return null
|
if (!images.length) return null
|
||||||
return <>
|
return <>
|
||||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
|
||||||
{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">
|
{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" />
|
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
|
||||||
</button>)}
|
</button>
|
||||||
|
})}
|
||||||
</div>
|
</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">
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -436,9 +436,9 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
setDraft(current => current.slice(0, start) + text + current.slice(end))
|
setDraft(current => current.slice(0, start) + text + current.slice(end))
|
||||||
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
|
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">
|
<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(' ')}>
|
<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" />
|
<Paperclip size={12} className="shrink-0" />
|
||||||
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
|
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
|
||||||
@@ -447,7 +447,7 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>}
|
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
|
||||||
{(attachmentsEnabled || onClearConversation) && (
|
{(attachmentsEnabled || onClearConversation) && (
|
||||||
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
<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 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 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[]> {
|
export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
|
||||||
|
|||||||
@@ -410,7 +410,10 @@ function AgentMessages({
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{messages.map((message, index) => message.role === 'user' ? (
|
{messages.map((message, index) => message.role === 'user' ? (
|
||||||
<div key={message.id} data-qa-message="true" className="flex justify-end">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
|
<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 = {
|
const userMessage: AgentConversationMessage = {
|
||||||
id: `agent-user-${Date.now()}`,
|
id: `agent-user-${Date.now()}`,
|
||||||
role: 'user',
|
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])
|
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||||
setPending(true)
|
setPending(true)
|
||||||
|
|||||||
Reference in New Issue
Block a user