feat: add agent image attachments and bump version to 1.2.17

This commit is contained in:
2026-10-19 13:34:06 +09:00
parent 7b32668e1d
commit 6512f5fa04
17 changed files with 198 additions and 39 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.16", "version": "1.2.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.16", "version": "1.2.17",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.16", "version": "1.2.17",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+10 -2
View File
@@ -11,7 +11,7 @@ vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/re
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression let payloadExpression
function visit(node) { function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('options.attachments')) { if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('imageTokens')) {
payloadExpression = node.getText(source) payloadExpression = node.getText(source)
} }
ts.forEachChild(node, visit) ts.forEachChild(node, visit)
@@ -19,14 +19,22 @@ 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')
function serialize(attachments, finalize = false, request = '설정 위치 알려줘') { 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,
textAttachments: attachments ?? [], imageTokens,
agentPlanRequestText: contextApi.agentPlanRequestText, agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] } context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
})) }))
} }
test('sends image reference tokens without image bytes or public URLs on every context round', () => {
const body = serialize(undefined, true, '이미지 분석', ['signed-image-token'])
assert.deepEqual(body.imageTokens, ['signed-image-token'])
assert.equal(Object.hasOwn(body, 'attachments'), false)
assert.equal(JSON.stringify(body).includes('base64'), false)
})
for (const attachments of [undefined, []]) { for (const attachments of [undefined, []]) {
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => { test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
const body = serialize(attachments) const body = serialize(attachments)
+3 -3
View File
@@ -72,7 +72,7 @@ test('long answers are grouped into at most twenty steps and finish within five
test('only the newly appended paragraph gets the fade class', () => { test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {} '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
}) })
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.') const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
@@ -159,7 +159,7 @@ for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {},
react: { ...React, useEffect() {}, useState(initial) { react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
@@ -183,7 +183,7 @@ for (const dark of [false, true]) {
test('short answers do not show an unnecessary collapse button', () => { test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] } react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
}) })
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
+1 -1
View File
@@ -16,7 +16,7 @@ function compile(source, overrides = {}, globals = {}) {
} }
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), { const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {} '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
}) })
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))
+2 -2
View File
@@ -16,8 +16,8 @@ test('reads source/config/log files and preserves Korean UTF-8 text', async () =
assert.equal(files[2].name, 'runtime.log') assert.equal(files[2].name, 'runtime.log')
}) })
test('rejects unsupported image/PDF files', async () => { test('rejects unsupported image/PDF files', async () => {
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /이미지와 PDF/) await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /이미지와 PDF/) await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
}) })
test('rejects more than five attachments including existing ones', async () => { test('rejects more than five attachments including existing ones', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/) await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
+1
View File
@@ -38,6 +38,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null }, '../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox }, '../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu }, '../../components/ChatTextContextMenu': { ChatTextContextMenu },
'../../components/ChatImageAttachments': {},
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
+48
View File
@@ -0,0 +1,48 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
function setup() {
const api = {}
const revoked = []
let count = 0
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api, TextDecoder,
createImageBitmap: async file => {
if (file.invalid) throw Error('invalid')
return { width: file.width ?? 10, height: file.height ?? 10, close() {} }
},
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
})
return { read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
}
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
test('image and text attachments coexist without counting image bytes as text context', async () => {
const h = setup()
const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }])
assert.equal(files[0].kind, 'image')
assert.equal(files[0].previewUrl, 'blob:fixture-1')
assert.equal(files[1].content, 'robot: mini')
})
test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => {
const h = setup()
await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/)
await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/)
await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/)
await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/)
assert.equal(h.revoked.length, 3)
})
test('a mixed batch failure revokes only newly created image previews', async () => {
const h = setup()
const existing = await h.read([image()])
await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/)
assert.deepEqual(h.revoked, ['blob:fixture-2'])
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
})
+1
View File
@@ -35,6 +35,7 @@ function harness() {
if (name === '../i18n') return { t: key => key, useLocaleRender() {} } if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
if (name === './chatAttachments') return {} if (name === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null } if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
if (name === 'react') return { if (name === 'react') return {
...React, ...React,
useState(initial) { useState(initial) {
+1 -1
View File
@@ -125,7 +125,7 @@ for (const selected of [false, true]) {
let prevented = false let prevented = false
let stopped = false let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', { const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null },
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) } react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } }) }, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} }) const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
+2
View File
@@ -627,6 +627,7 @@ function LocalPcWorkspaceMockup({
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null) const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null)
const { const {
enabled: agentEnabled, enabled: agentEnabled,
imagesEnabled: agentImagesEnabled,
statusLoading: agentStatusLoading, statusLoading: agentStatusLoading,
pending: agentPending, pending: agentPending,
activity: agentActivity, activity: agentActivity,
@@ -4712,6 +4713,7 @@ function LocalPcWorkspaceMockup({
visibleTeacherMessages={visibleTeacherMessages} visibleTeacherMessages={visibleTeacherMessages}
agentMessages={agentMessages} agentMessages={agentMessages}
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
agentImagesEnabled={Boolean(agentImagesEnabled)}
agentStatusLoading={agentStatusLoading} agentStatusLoading={agentStatusLoading}
agentPending={agentPending} agentPending={agentPending}
agentActivity={agentActivity} agentActivity={agentActivity}
@@ -0,0 +1,26 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
export function ChatImageAttachments({ images }: { images: Array<{ name: string; url: string }> }): React.JSX.Element | null {
useLocaleRender()
const [preview, setPreview] = useState<{ name: string; url: string } | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (preview) dialogRef.current?.showModal()
else dialogRef.current?.close()
}, [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>
{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>
<img src={preview.url} alt={preview.name} referrerPolicy="no-referrer" className="max-h-[78vh] max-w-full object-contain" />
</dialog>, document.body)}
</>
}
+35 -17
View File
@@ -2,7 +2,8 @@ import { t, useLocaleRender } from '../i18n'
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react' import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { CHAT_ATTACHMENT_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments' import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
import { ChatImageAttachments } from './ChatImageAttachments'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu' import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
@@ -287,6 +288,7 @@ export const QaInputBox = memo(function QaInputBox({
disabled, disabled,
accent = 'blue', accent = 'blue',
attachmentsEnabled = false, attachmentsEnabled = false,
imagesEnabled = false,
onClearConversation, onClearConversation,
clearDisabled = false, clearDisabled = false,
onStop, onStop,
@@ -297,6 +299,7 @@ export const QaInputBox = memo(function QaInputBox({
disabled: boolean disabled: boolean
accent?: 'blue' | 'cyan' accent?: 'blue' | 'cyan'
attachmentsEnabled?: boolean attachmentsEnabled?: boolean
imagesEnabled?: boolean
onClearConversation?: () => void onClearConversation?: () => void
clearDisabled?: boolean clearDisabled?: boolean
onStop: () => void onStop: () => void
@@ -310,7 +313,28 @@ export const QaInputBox = memo(function QaInputBox({
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const attachmentReadRef = useRef(0) const attachmentReadRef = useRef(0)
const canSubmit = busy || (Boolean(draft.trim()) && !disabled && !readingAttachments) const attachmentUrlsRef = useRef<string[]>([])
const canSubmit = busy || ((Boolean(draft.trim()) || attachments.some(file => file.kind === 'image')) && !disabled && !readingAttachments)
useEffect(() => {
const urls = attachments.flatMap(file => file.kind === 'image' ? [file.previewUrl] : [])
attachmentUrlsRef.current.filter(url => !urls.includes(url)).forEach(url => URL.revokeObjectURL(url))
attachmentUrlsRef.current = urls
}, [attachments])
useEffect(() => () => attachmentUrlsRef.current.forEach(url => URL.revokeObjectURL(url)), [])
const attachFiles = (files: File[]): void => {
if (!attachmentsEnabled || disabled || busy || readingAttachments || !files.length) return
const readId = ++attachmentReadRef.current
setReadingAttachments(true)
setAttachmentError(null)
void readChatAttachments(files, attachments, { imagesEnabled }).then(files => {
if (attachmentReadRef.current === readId) setAttachments(files)
else files.forEach(file => { if (file.kind === 'image' && !attachments.includes(file)) URL.revokeObjectURL(file.previewUrl) })
}).catch(error => {
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
}).finally(() => { if (attachmentReadRef.current === readId) setReadingAttachments(false) })
}
useLayoutEffect(() => { useLayoutEffect(() => {
if (textareaRef.current) resizeQaInput(textareaRef.current) if (textareaRef.current) resizeQaInput(textareaRef.current)
@@ -331,16 +355,18 @@ export const QaInputBox = memo(function QaInputBox({
useEffect(() => { useEffect(() => {
attachmentReadRef.current += 1 attachmentReadRef.current += 1
setReadingAttachments(false)
if (!attachmentsEnabled) { if (!attachmentsEnabled) {
setAttachments([]) setAttachments([])
setAttachmentError(null) setAttachmentError(null)
setReadingAttachments(false) setReadingAttachments(false)
} }
else if (!imagesEnabled) setAttachments(current => current.filter(file => file.kind !== 'image'))
return () => { attachmentReadRef.current += 1 } return () => { attachmentReadRef.current += 1 }
}, [attachmentsEnabled]) }, [attachmentsEnabled, imagesEnabled])
const submit = (): void => { const submit = (): void => {
const content = draft.trim() const content = draft.trim() || (attachments.some(file => file.kind === 'image') ? t('첨부 이미지를 분석해주세요.') : '')
if (busy) { if (busy) {
onStop() onStop()
return return
@@ -353,7 +379,7 @@ export const QaInputBox = memo(function QaInputBox({
} }
return ( return (
<div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}> <div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')} onDragOver={event => { if (attachmentsEnabled && event.dataTransfer.types.includes('Files')) event.preventDefault() }} onDrop={event => { if (!attachmentsEnabled) return; event.preventDefault(); attachFiles(Array.from(event.dataTransfer.files)) }}>
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}> <div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -362,6 +388,7 @@ export const QaInputBox = memo(function QaInputBox({
autoCorrect="off" autoCorrect="off"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onPaste={event => { if (attachmentsEnabled && event.clipboardData.files.length) { event.preventDefault(); attachFiles(Array.from(event.clipboardData.files)) } }}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -420,25 +447,16 @@ export const QaInputBox = memo(function QaInputBox({
))} ))}
</div> </div>
)} )}
<ChatImageAttachments images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} />
{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(' ')}>
{attachmentsEnabled && ( {attachmentsEnabled && (
<> <>
<input ref={fileInputRef} type="file" multiple accept={CHAT_ATTACHMENT_ACCEPT} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => { <input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []) const files = Array.from(event.currentTarget.files ?? [])
event.currentTarget.value = '' event.currentTarget.value = ''
if (!files.length) return attachFiles(files)
const readId = ++attachmentReadRef.current
setReadingAttachments(true)
setAttachmentError(null)
void readChatAttachments(files, attachments).then((files) => {
if (attachmentReadRef.current === readId) setAttachments(files)
}).catch((error) => {
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
}).finally(() => {
if (attachmentReadRef.current === readId) setReadingAttachments(false)
})
}} /> }} />
<button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button> <button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button>
</> </>
+29 -5
View File
@@ -1,15 +1,35 @@
export type ChatAttachment = { name: string; content: string } export type ChatTextAttachment = { name: string; content: string; kind?: 'text' }
export type ChatImageDraft = { kind: 'image'; name: string; file: File; previewUrl: string }
export type ChatAttachment = ChatTextAttachment | ChatImageDraft
export type AgentImageReference = { token: string; name: string; mimeType: string; size: number; url: string }
export const CHAT_IMAGE_ACCEPT = '.png,.jpg,.jpeg,.webp'
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[]): Promise<ChatAttachment[]> { export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.') if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.')
const result = [...existing] const result = [...existing]
for (const file of files) { const createdUrls: string[] = []
try { for (const file of files) {
const name = file.name const name = file.name
const extension = `.${name.split('.').pop()?.toLowerCase()}` const extension = `.${name.split('.').pop()?.toLowerCase()}`
if (CHAT_IMAGE_ACCEPT.split(',').includes(extension)) {
if (!options.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.')
if (result.filter(item => item.kind === 'image').length >= 3) throw new Error('이미지는 최대 3장까지 첨부할 수 있습니다.')
if (name.length > 255 || !file.size || file.size > 5 * 1024 * 1024) throw new Error('이미지는 장당 5MB 이하여야 합니다.')
let bitmap: ImageBitmap
try { bitmap = await createImageBitmap(file) } catch { throw new Error('이미지 파일을 읽지 못했습니다.') }
const pixels = bitmap.width * bitmap.height
bitmap.close()
if (!pixels || pixels > 16_000_000) throw new Error('이미지는 1600만 픽셀 이하여야 합니다.')
const previewUrl = URL.createObjectURL(file)
createdUrls.push(previewUrl)
result.push({ kind: 'image', name, file, previewUrl })
continue
}
if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) { if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) {
throw new Error('코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.') throw new Error('코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.')
} }
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.') if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
let content: string let content: string
@@ -22,8 +42,12 @@ export async function readChatAttachments(files: File[], existing: ChatAttachmen
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.') if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
result.push({ name, content }) result.push({ name, content })
} }
if (result.reduce((sum, file) => sum + file.name.length + file.content.length, 0) > 100_000) { if (result.reduce((sum, file) => sum + (file.kind === 'image' ? 0 : file.name.length + file.content.length), 0) > 100_000) {
throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.') throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.')
} }
return result return result
} catch (error) {
createdUrls.forEach(url => URL.revokeObjectURL(url))
throw error
}
} }
@@ -6,6 +6,7 @@ import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals' import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse' import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu' import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
import { ChatImageAttachments } from '../../components/ChatImageAttachments'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments' import type { ChatAttachment } from '../../components/chatAttachments'
@@ -40,6 +41,7 @@ type WorkspaceQaPanelProps = {
visibleTeacherMessages: WorkspaceQaMessage[] visibleTeacherMessages: WorkspaceQaMessage[]
agentMessages: AgentConversationMessage[] agentMessages: AgentConversationMessage[]
agentEnabled: boolean agentEnabled: boolean
agentImagesEnabled?: boolean
agentStatusLoading: boolean agentStatusLoading: boolean
agentPending: boolean agentPending: boolean
agentActivity?: string agentActivity?: string
@@ -112,6 +114,7 @@ export default function WorkspaceQaPanel({
visibleTeacherMessages, visibleTeacherMessages,
agentMessages, agentMessages,
agentEnabled, agentEnabled,
agentImagesEnabled = false,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity, agentActivity,
@@ -325,6 +328,7 @@ export default function WorkspaceQaPanel({
onStop={onStop} onStop={onStop}
onSubmit={onSubmit} onSubmit={onSubmit}
attachmentsEnabled={qaMode === 'agent'} attachmentsEnabled={qaMode === 'agent'}
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation} onClearConversation={requestClearConversation}
clearDisabled={clearDisabled} clearDisabled={clearDisabled}
/> />
@@ -406,7 +410,7 @@ 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}</div> <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> </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">
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync' import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentFileChange = { export type AgentFileChange = {
@@ -76,6 +76,7 @@ export type AgentConversationMessage = {
provider?: string provider?: string
model?: string model?: string
revealStartedAt?: number revealStartedAt?: number
imageAttachments?: AgentImageReference[]
plan?: AgentPlan plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled' applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
applyError?: string applyError?: string
@@ -101,6 +102,7 @@ type AgentStatus = {
enabled: boolean enabled: boolean
provider: string | null provider: string | null
model: string | null model: string | null
imagesEnabled?: boolean
} }
type AgentSessionResponse = { type AgentSessionResponse = {
@@ -132,7 +134,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
...options, ...options,
headers: { headers: {
...(options?.body ? { 'Content-Type': 'application/json' } : {}), ...(options?.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
Authorization: authorization, Authorization: authorization,
...options?.headers ...options?.headers
} }
@@ -143,7 +145,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
} }
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 구조화된 에이전트 도구를 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.') if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -283,6 +285,23 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
abortControllerRef.current = abortController abortControllerRef.current = abortController
try { try {
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData()
body.append('courseId', courseId)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
if (abortController.signal.aborted) return
}
const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중')
const response = await resolveAgentContext({ const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }, initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
signal: abortController.signal, signal: abortController.signal,
@@ -292,7 +311,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', { requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST', method: 'POST',
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context }) body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
}) })
}) })
if (abortController.signal.aborted) return if (abortController.signal.aborted) return
+8
View File
@@ -602,6 +602,14 @@
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.", "추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
"취소": "Cancel", "취소": "Cancel",
"취소됨": "Cancelled", "취소됨": "Cancelled",
"이미지 확대": "Enlarge image",
"첨부 이미지를 분석해주세요.": "Please analyze the attached images.",
"현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.": "This model does not support image attachments. Select Qwen 3.7 Plus/Flash or 3.8 Max/Flash.",
"이미지는 최대 3장까지 첨부할 수 있습니다.": "You can attach up to 3 images.",
"이미지는 장당 5MB 이하여야 합니다.": "Each image must be 5 MB or smaller.",
"이미지 파일을 읽지 못했습니다.": "Could not read the image file.",
"이미지는 1600만 픽셀 이하여야 합니다.": "Images must be 16 megapixels or smaller.",
"코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.": "Attach code, configuration, logs, or PNG/JPEG/WebP images. PDF is not supported.",
"카테고리": "Category", "카테고리": "Category",
"칸": " spaces", "칸": " spaces",
"커리큘럼": "Curriculum", "커리큘럼": "Curriculum",