feat: add agent image attachments and bump version to 1.2.17
This commit is contained in:
@@ -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() {} })
|
||||
|
||||
Reference in New Issue
Block a user