From 4321d944b5816360e1265da767cad3d0199139b6 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 19 Oct 2026 14:47:21 +0900 Subject: [PATCH] fix: refine agent image attachments and restore Korean filenames --- scripts/test-agent-request-payload.cjs | 14 ++++ scripts/test-ai-response-presentation.cjs | 23 ++++++ scripts/test-chat-image-attachments.cjs | 72 ++++++++++++++++++- .../src/components/ChatImageAttachments.tsx | 30 ++++++-- src/renderer/src/components/ChatWidgets.tsx | 6 +- .../src/components/chatAttachments.ts | 10 +++ .../features/workspace/WorkspaceQaPanel.tsx | 5 +- .../src/features/workspace/useAiAgent.ts | 2 +- 8 files changed, 150 insertions(+), 12 deletions(-) diff --git a/scripts/test-agent-request-payload.cjs b/scripts/test-agent-request-payload.cjs index 326e7ee..17d1047 100644 --- a/scripts/test-agent-request-payload.cjs +++ b/scripts/test-agent-request-payload.cjs @@ -19,6 +19,20 @@ function visit(node) { visit(source) 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 = []) { return JSON.parse(vm.runInNewContext(payloadExpression, { courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize, diff --git a/scripts/test-ai-response-presentation.cjs b/scripts/test-ai-response-presentation.cjs index 328af9b..4cd3357 100644 --- a/scripts/test-ai-response-presentation.cjs +++ b/scripts/test-ai-response-presentation.cjs @@ -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 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 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'), {}, { t: key => key, useLocaleRender() {}, ChatResponseSkeleton: widgets.ChatResponseSkeleton, ChatEmptyState: widgets.ChatEmptyState, ProgressiveChatResponse, + ChatImageAttachments, MessageCopyButton: () => null, 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 mode of ['AiMessages', 'AgentMessages']) { test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => { diff --git a/scripts/test-chat-image-attachments.cjs b/scripts/test-chat-image-attachments.cjs index 354ce42..daeadce 100644 --- a/scripts/test-chat-image-attachments.cjs +++ b/scripts/test-chat-image-attachments.cjs @@ -3,6 +3,8 @@ const assert = require('node:assert/strict') const fs = require('node:fs') const vm = require('node:vm') const ts = require('typescript') +const React = require('react') +const { renderToStaticMarkup } = require('react-dom/server') function setup() { const api = {} @@ -18,7 +20,7 @@ function setup() { }, 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 }) @@ -46,3 +48,71 @@ test('a mixed batch failure revokes only newly created image previews', async () assert.deepEqual(h.revoked, ['blob:fixture-2']) 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, /]*aria-label="스크린샷 2026-10-19 14-30-45.png"/) + assert.ok(html.includes(`>${name}`)) +}) diff --git a/src/renderer/src/components/ChatImageAttachments.tsx b/src/renderer/src/components/ChatImageAttachments.tsx index c62b595..8996408 100644 --- a/src/renderer/src/components/ChatImageAttachments.tsx +++ b/src/renderer/src/components/ChatImageAttachments.tsx @@ -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(null) const dialogRef = useRef(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 <> -
- {images.map((image, index) => )} +
+ {images.map((source, index) => { + const image = { ...source, name: displayAttachmentName(source.name) } + return variant === 'draft' ?
+ + {onRemove && } +
: + })}
{preview && createPortal( 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.name}
diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index 6eb5dc5..65c5a24 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -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') && (
- {attachments.map((file, index) => ( + {attachments.map((file, index) => file.kind === 'image' ? null : ( {file.name} @@ -447,7 +447,7 @@ export const QaInputBox = memo(function QaInputBox({ ))}
)} - file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} /> + 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 &&

{t(attachmentError)}

} {(attachmentsEnabled || onClearConversation) && (
diff --git a/src/renderer/src/components/chatAttachments.ts b/src/renderer/src/components/chatAttachments.ts index 68120e5..a16aef4 100644 --- a/src/renderer/src/components/chatAttachments.ts +++ b/src/renderer/src/components/chatAttachments.ts @@ -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 { diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 55227a3..c7a8875 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -410,7 +410,10 @@ function AgentMessages({
{messages.map((message, index) => message.role === 'user' ? (
-
{message.content}
+
+
{message.content}
+ +
) : (
diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index 232887d..3fcbcec 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -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)