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
+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)
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)
+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', () => {
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 })
+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'), {
'../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))
+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')
})
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개/)
+1
View File
@@ -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,
+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 === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
if (name === 'react') return {
...React,
useState(initial) {
+1 -1
View File
@@ -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() {} })