feat: add agent image attachments and bump version to 1.2.17
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lms",
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.17",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -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)
|
||||
let payloadExpression
|
||||
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)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
@@ -19,14 +19,22 @@ function visit(node) {
|
||||
visit(source)
|
||||
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, {
|
||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||
textAttachments: attachments ?? [], imageTokens,
|
||||
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||
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, []]) {
|
||||
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
|
||||
const body = serialize(attachments)
|
||||
|
||||
@@ -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', () => {
|
||||
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 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'})`, () => {
|
||||
let expanded
|
||||
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) {
|
||||
if (expanded === undefined) expanded = initial
|
||||
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', () => {
|
||||
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, () => {}] }
|
||||
})
|
||||
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
|
||||
|
||||
@@ -16,7 +16,7 @@ function compile(source, overrides = {}, globals = {}) {
|
||||
}
|
||||
|
||||
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 declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||
|
||||
@@ -16,8 +16,8 @@ test('reads source/config/log files and preserves Korean UTF-8 text', async () =
|
||||
assert.equal(files[2].name, 'runtime.log')
|
||||
})
|
||||
test('rejects unsupported image/PDF files', async () => {
|
||||
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /이미지와 PDF/)
|
||||
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /이미지와 PDF/)
|
||||
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
|
||||
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
|
||||
})
|
||||
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개/)
|
||||
|
||||
@@ -38,6 +38,7 @@ function setup(overrides = {}, browser = {}) {
|
||||
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
||||
'../../components/ChatWidgets': { QaInputBox },
|
||||
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
|
||||
'../../components/ChatImageAttachments': {},
|
||||
'react-dom': { createPortal: child => child },
|
||||
react: {
|
||||
...React,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -35,6 +35,7 @@ function harness() {
|
||||
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||
if (name === './chatAttachments') return {}
|
||||
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
|
||||
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
|
||||
if (name === 'react') return {
|
||||
...React,
|
||||
useState(initial) {
|
||||
|
||||
@@ -125,7 +125,7 @@ for (const selected of [false, true]) {
|
||||
let prevented = false
|
||||
let stopped = false
|
||||
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 }) }
|
||||
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
|
||||
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
|
||||
|
||||
@@ -627,6 +627,7 @@ function LocalPcWorkspaceMockup({
|
||||
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null)
|
||||
const {
|
||||
enabled: agentEnabled,
|
||||
imagesEnabled: agentImagesEnabled,
|
||||
statusLoading: agentStatusLoading,
|
||||
pending: agentPending,
|
||||
activity: agentActivity,
|
||||
@@ -4712,6 +4713,7 @@ function LocalPcWorkspaceMockup({
|
||||
visibleTeacherMessages={visibleTeacherMessages}
|
||||
agentMessages={agentMessages}
|
||||
agentEnabled={agentEnabled}
|
||||
agentImagesEnabled={Boolean(agentImagesEnabled)}
|
||||
agentStatusLoading={agentStatusLoading}
|
||||
agentPending={agentPending}
|
||||
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)}
|
||||
</>
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { t, useLocaleRender } from '../i18n'
|
||||
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
|
||||
import type React 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 remarkGfm from 'remark-gfm'
|
||||
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
|
||||
@@ -287,6 +288,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
disabled,
|
||||
accent = 'blue',
|
||||
attachmentsEnabled = false,
|
||||
imagesEnabled = false,
|
||||
onClearConversation,
|
||||
clearDisabled = false,
|
||||
onStop,
|
||||
@@ -297,6 +299,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
disabled: boolean
|
||||
accent?: 'blue' | 'cyan'
|
||||
attachmentsEnabled?: boolean
|
||||
imagesEnabled?: boolean
|
||||
onClearConversation?: () => void
|
||||
clearDisabled?: boolean
|
||||
onStop: () => void
|
||||
@@ -310,7 +313,28 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
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(() => {
|
||||
if (textareaRef.current) resizeQaInput(textareaRef.current)
|
||||
@@ -331,16 +355,18 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
|
||||
useEffect(() => {
|
||||
attachmentReadRef.current += 1
|
||||
setReadingAttachments(false)
|
||||
if (!attachmentsEnabled) {
|
||||
setAttachments([])
|
||||
setAttachmentError(null)
|
||||
setReadingAttachments(false)
|
||||
}
|
||||
else if (!imagesEnabled) setAttachments(current => current.filter(file => file.kind !== 'image'))
|
||||
return () => { attachmentReadRef.current += 1 }
|
||||
}, [attachmentsEnabled])
|
||||
}, [attachmentsEnabled, imagesEnabled])
|
||||
|
||||
const submit = (): void => {
|
||||
const content = draft.trim()
|
||||
const content = draft.trim() || (attachments.some(file => file.kind === 'image') ? t('첨부 이미지를 분석해주세요.') : '')
|
||||
if (busy) {
|
||||
onStop()
|
||||
return
|
||||
@@ -353,7 +379,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
}
|
||||
|
||||
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(' ')}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -362,6 +388,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
autoCorrect="off"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onPaste={event => { if (attachmentsEnabled && event.clipboardData.files.length) { event.preventDefault(); attachFiles(Array.from(event.clipboardData.files)) } }}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -420,25 +447,16 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
))}
|
||||
</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>}
|
||||
{(attachmentsEnabled || onClearConversation) && (
|
||||
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
||||
{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 ?? [])
|
||||
event.currentTarget.value = ''
|
||||
if (!files.length) return
|
||||
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)
|
||||
})
|
||||
attachFiles(files)
|
||||
}} />
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -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 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개까지 첨부할 수 있습니다.')
|
||||
const result = [...existing]
|
||||
for (const file of files) {
|
||||
const createdUrls: string[] = []
|
||||
try { for (const file of files) {
|
||||
const name = file.name
|
||||
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())) {
|
||||
throw new Error('코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.')
|
||||
throw new Error('코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.')
|
||||
}
|
||||
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
|
||||
let content: string
|
||||
@@ -22,8 +42,12 @@ export async function readChatAttachments(files: File[], existing: ChatAttachmen
|
||||
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
|
||||
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만 자 이하여야 합니다.')
|
||||
}
|
||||
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 { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
|
||||
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
|
||||
import { ChatImageAttachments } from '../../components/ChatImageAttachments'
|
||||
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||
import type { ChatAttachment } from '../../components/chatAttachments'
|
||||
@@ -40,6 +41,7 @@ type WorkspaceQaPanelProps = {
|
||||
visibleTeacherMessages: WorkspaceQaMessage[]
|
||||
agentMessages: AgentConversationMessage[]
|
||||
agentEnabled: boolean
|
||||
agentImagesEnabled?: boolean
|
||||
agentStatusLoading: boolean
|
||||
agentPending: boolean
|
||||
agentActivity?: string
|
||||
@@ -112,6 +114,7 @@ export default function WorkspaceQaPanel({
|
||||
visibleTeacherMessages,
|
||||
agentMessages,
|
||||
agentEnabled,
|
||||
agentImagesEnabled = false,
|
||||
agentStatusLoading,
|
||||
agentPending,
|
||||
agentActivity,
|
||||
@@ -325,6 +328,7 @@ export default function WorkspaceQaPanel({
|
||||
onStop={onStop}
|
||||
onSubmit={onSubmit}
|
||||
attachmentsEnabled={qaMode === 'agent'}
|
||||
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
|
||||
onClearConversation={requestClearConversation}
|
||||
clearDisabled={clearDisabled}
|
||||
/>
|
||||
@@ -406,7 +410,7 @@ 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}</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 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 { createAgentSessionSync } from './agentSessionSync'
|
||||
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'
|
||||
|
||||
export type AgentFileChange = {
|
||||
@@ -76,6 +76,7 @@ export type AgentConversationMessage = {
|
||||
provider?: string
|
||||
model?: string
|
||||
revealStartedAt?: number
|
||||
imageAttachments?: AgentImageReference[]
|
||||
plan?: AgentPlan
|
||||
applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
|
||||
applyError?: string
|
||||
@@ -101,6 +102,7 @@ type AgentStatus = {
|
||||
enabled: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
imagesEnabled?: boolean
|
||||
}
|
||||
|
||||
type AgentSessionResponse = {
|
||||
@@ -132,7 +134,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options?.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
|
||||
Authorization: authorization,
|
||||
...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')) {
|
||||
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 에이전트 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
@@ -283,6 +285,23 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
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({
|
||||
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
|
||||
signal: abortController.signal,
|
||||
@@ -292,7 +311,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||
method: 'POST',
|
||||
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
|
||||
|
||||
@@ -602,6 +602,14 @@
|
||||
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
|
||||
"취소": "Cancel",
|
||||
"취소됨": "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",
|
||||
"칸": " spaces",
|
||||
"커리큘럼": "Curriculum",
|
||||
|
||||
Reference in New Issue
Block a user