Compare commits

...

2 Commits

25 changed files with 833 additions and 127 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": {
+145
View File
@@ -0,0 +1,145 @@
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')
const React = require('react')
function compile(source, globals) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require, ...globals })
return exports
}
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
const proposal = (extra = {}) => ({
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
commandExecutions: { build: { status: 'pending' } }, ...extra
})
function harness(message) {
const session = {}
const messageStateRef = { current: { session, messages: [message] } }
const applyingRef = { current: false }
const abortControllerRef = { current: null }
const saved = []
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
session, messageStateRef, applyingRef, abortControllerRef,
updateMessages(update) {
const next = update(messageStateRef.current.messages)
messageStateRef.current.messages = next
saved.push(JSON.parse(JSON.stringify(next)))
}
})
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
}
test('cancel marks the proposal and unfinished commands without applying files', async () => {
const h = harness(proposal({ applyError: 'old error' }))
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
assert.equal(h.current().applyError, undefined)
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
let applied = false
await h.applyPlan('proposal', async () => { applied = true })
assert.equal(applied, false)
assert.equal(h.cancelPlan('proposal'), false)
})
test('cancel preserves existing successful command results and rejects failed commands', () => {
const message = proposal()
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
const h = harness(message)
h.cancelPlan('proposal')
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
assert.equal(h.current().commandExecutions.inspect.output, 'done')
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.current().commandExecutions.build.output, 'build failed')
})
for (const applyState of ['applying', 'applied', 'cancelled']) {
test(`cannot cancel a proposal in ${applyState} state`, () => {
const h = harness(proposal({ applyState }))
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
})
}
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
for (const setup of [
h => { h.current().commandExecutions.build.status = 'running' },
h => { h.applyingRef.current = true },
h => { h.abortControllerRef.current = {} },
h => { h.messageStateRef.current.session = {} }
]) {
const h = harness(proposal())
setup(h)
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
}
})
test('an apply already started cannot be cancelled and completes normally', async () => {
const h = harness(proposal())
let finish
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
assert.equal(h.current().applyState, 'applying')
assert.equal(h.cancelPlan('proposal'), false)
finish()
await applying
assert.equal(h.current().applyState, 'applied')
})
test('new module proposals can be cancelled without creating anything', () => {
const message = proposal()
message.plan.changes = []
message.plan.createModule = { name: 'Example' }
const h = harness(message)
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function text(root) {
if (typeof root === 'string') return root
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
}
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 renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
...require('lucide-react'), t: key => key, useLocaleRender() {},
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
agentMessageCopyText: message => message.content
}).AgentMessages
for (const dark of [true, false]) {
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
let cancelled
const props = { dark, enabled: true, statusLoading: false, pending: false,
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
let tree = renderAgent(props)
let buttons = elements(tree, node => node.type === 'button')
const cancel = buttons.find(button => text(button) === '취소')
const apply = buttons.find(button => text(button) === '변경 적용')
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
assert.match(cancelLabel.props.className, /top-px leading-none/)
cancel.props.onClick()
assert.equal(cancelled, 'proposal')
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
assert.equal(busyCancel.props.disabled, true)
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
buttons = elements(tree, node => node.type === 'button')
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
})
}
+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': {} '../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': {}, '../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': {}, '../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': {} '../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개/)
+15 -10
View File
@@ -19,6 +19,7 @@ function compile(file, overrides = {}, globals = {}) {
const i18n = { t: key => key, useLocaleRender() {} } const i18n = { t: key => key, useLocaleRender() {} }
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n }) const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
const QaInputBox = () => null const QaInputBox = () => null
const ChatTextContextMenu = () => null
function elements(root, predicate) { function elements(root, predicate) {
if (!React.isValidElement(root)) return [] if (!React.isValidElement(root)) return []
@@ -36,6 +37,8 @@ function setup(overrides = {}, browser = {}) {
'../../components/AppStatusModals': { ConfirmStatusModal }, '../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null }, '../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox }, '../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
'../../components/ChatImageAttachments': {},
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
@@ -84,19 +87,18 @@ test('context menu deletion also requests confirmation', () => {
const root = harness.render() const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0] const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0] const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
clear.props.onClick() menu.props.onClear()
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1) assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
}) })
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) { for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => { test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
let request
const node = {} const node = {}
const harness = setup({}, { const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null, getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
chatTextMenu: { show: async options => { request = options; return null } } chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
}) })
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0] const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({ area.props.onContextMenu({
@@ -104,18 +106,21 @@ for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
}) })
await Promise.resolve() await Promise.resolve()
assert.equal(request.kind, 'message') const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
assert.equal(request.canCopy, selected && inside) assert.equal(menu.props.menu.kind, 'message')
assert.equal(request.canClear, true) assert.equal(Boolean(menu.props.menu.text), selected && inside)
assert.equal(menu.props.canClear, true)
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
}) })
} }
test('native menu deletion still goes through the shared confirmation dialog', async () => { test('themed menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } }) const harness = setup()
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0] const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} }) area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve() await Promise.resolve()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0] const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal) assert.ok(modal)
+81
View File
@@ -0,0 +1,81 @@
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')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
let clipboard = ''
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const native = compile('../src/main/chatTextMenu.ts', {
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
})
assert.equal(native.readChatClipboardState(event).hasText, false)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
assert.equal(clipboard, 'selected text')
assert.equal(native.readChatClipboardState(event).hasText, true)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
assert.equal(pastes, 1)
clipboard = ''
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(pastes, 1)
event.senderFrame = {}
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
assert.equal(native.readChatClipboardState(event).hasText, false)
event.senderFrame = frame
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
})
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
let state = 0
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} },
'react-dom': { createPortal: child => child },
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
}, { document: { body: {} } })
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
}
for (const dark of [true, false]) {
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
const tree = renderMenu({ dark, text: 'selected' })
const html = renderToStaticMarkup(tree)
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
assert.match(html, /flex h-8 w-full cursor-pointer/)
assert.match(html, /role="menu"/)
assert.match(html, /role="menuitem"/)
assert.match(html, /대화내역 삭제/)
assert.doesNotMatch(html, /붙여넣기/)
})
}
for (const hasText of [true, false]) {
for (const readOnly of [true, false]) {
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
assert.equal(buttons[0].props.disabled, true)
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
})
}
}
+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')
})
+136
View File
@@ -0,0 +1,136 @@
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')
const React = require('react')
function harness() {
let layoutEffects = []
let effects = []
let resizeCallback
let disconnected = false
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
const submissions = []
const input = {
style: {}, scrollHeight: 56,
getBoundingClientRect: () => ({ width: 300 })
}
const exports = {}
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports,
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
ResizeObserver: class {
constructor(callback) { resizeCallback = callback }
observe(element) { assert.equal(element, input) }
disconnect() { disconnected = true }
},
require(name) {
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) {
const index = stateIndex++
if (!(index in states)) states[index] = initial
return [states[index], value => { states[index] = value }]
},
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
useEffect(callback) { effects.push(callback) },
useLayoutEffect(callback) { layoutEffects.push(callback) }
}
return require(name)
}
})
function find(node, type) {
if (!React.isValidElement(node)) return null
if (node.type === type) return node
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
}
function render() {
stateIndex = 0
refIndex = 0
layoutEffects = []
effects = []
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
const textarea = find(tree, 'textarea')
textarea.props.ref.current = input
layoutEffects.forEach(callback => callback())
return textarea
}
return {
input, render, submissions,
mountEffects: () => effects.map(callback => callback()),
resize: width => resizeCallback([{ contentRect: { width } }]),
disconnected: () => disconnected
}
}
test('input grows to eight visible lines and scrolls only above the cap', () => {
const h = harness()
h.render()
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 100
h.render()
assert.equal(h.input.style.height, '100px')
h.input.scrollHeight = 180
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 300
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'auto')
})
test('deleting text and submitting restore the initial height', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
h.input.scrollHeight = 80
textarea = h.render()
assert.equal(h.input.style.height, '80px')
let prevented = false
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
assert.equal(prevented, true)
assert.equal(h.submissions.length, 1)
h.input.scrollHeight = 40
textarea = h.render()
assert.equal(textarea.props.value, '')
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
})
test('Shift+Enter and IME composition do not submit', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question' } })
textarea = h.render()
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
assert.equal(h.submissions.length, 0)
})
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
const h = harness()
h.render()
const cleanups = h.mountEffects()
h.input.scrollHeight = 120
h.resize(180)
assert.equal(h.input.style.height, '120px')
h.input.scrollHeight = 50
h.resize(400)
assert.equal(h.input.style.height, '56px')
cleanups.forEach(cleanup => cleanup?.())
assert.equal(h.disconnected(), true)
})
+7 -7
View File
@@ -120,25 +120,25 @@ function elements(root, predicate) {
} }
for (const selected of [false, true]) { for (const selected of [false, true]) {
test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => { test(`textarea opens the themed menu with a selection snapshot (selected=${selected})`, async () => {
let options let options
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': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null },
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, 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() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0] const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({ textarea.props.onContextMenu({
currentTarget: { selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false }, currentTarget: { value: 'abcdef', selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
preventDefault() { prevented = true }, stopPropagation() { stopped = true } preventDefault() { prevented = true }, stopPropagation() { stopped = true }
}) })
await Promise.resolve() await Promise.resolve()
assert.equal(options.kind, 'input') assert.equal(options.kind, 'input')
assert.equal(options.canCopy, selected) assert.equal(options.text, selected ? 'cde' : '')
assert.equal(options.canPaste, true) assert.equal(options.input.disabled, false)
assert.equal(options.labels.paste, '붙여넣기') assert.equal(options.selectionStart, 2)
assert.equal(prevented, true) assert.equal(prevented, true)
assert.equal(stopped, true) assert.equal(stopped, true)
}) })
+24 -1
View File
@@ -1,6 +1,29 @@
import { BrowserWindow, clipboard, Menu } from 'electron' import { BrowserWindow, clipboard, Menu } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron' import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import type { ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu' import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
function trustedChatFrame(event: IpcMainInvokeEvent): boolean {
if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false
const window = BrowserWindow.fromWebContents(event.sender)
return Boolean(window && !window.isDestroyed())
}
export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } {
return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 }
}
export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean {
if (!trustedChatFrame(event) || !input) return false
if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) {
clipboard.writeText(input.text)
return true
}
if (input.action === 'paste' && clipboard.readText().length > 0) {
event.sender.paste()
return true
}
return false
}
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null { export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
+3 -1
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging' import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus' import { checkDockerStatus } from './dockerStatus'
import { showChatTextMenu } from './chatTextMenu' import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime' import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles' import { remoteModuleFilesScript } from './remoteModuleFiles'
import { applyAgentFiles, runAgentFileTool } from './agentFileTools' import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
@@ -7037,6 +7037,8 @@ app.whenReady().then(() => {
}) })
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
ipcMain.handle('chat-text:context-menu', showChatTextMenu) ipcMain.handle('chat-text:context-menu', showChatTextMenu)
ipcMain.handle('chat-text:clipboard-state', readChatClipboardState)
ipcMain.handle('chat-text:clipboard-action', performChatClipboardAction)
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit()) ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
+3 -1
View File
@@ -118,7 +118,9 @@ console.info('[lms-preload] launcher auth resolved', {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', { contextBridge.exposeInMainWorld('chatTextMenu', {
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options) show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options),
readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'),
perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action)
}) })
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', { contextBridge.exposeInMainWorld('lmsBootSplash', {
+9 -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,
@@ -634,6 +635,7 @@ function LocalPcWorkspaceMockup({
persistenceError: agentPersistenceError, persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan, requestPlan: requestAgentPlan,
applyPlan: applyAgentPlan, applyPlan: applyAgentPlan,
cancelPlan: cancelAgentPlan,
updateCommandExecution: updateAgentCommandExecution, updateCommandExecution: updateAgentCommandExecution,
stop: stopAgent, stop: stopAgent,
clear: clearAgentConversation clear: clearAgentConversation
@@ -3728,7 +3730,7 @@ function LocalPcWorkspaceMockup({
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => { const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
if (agentActionControllerRef.current || agentPending || agentApplying) return if (agentActionControllerRef.current || agentPending || agentApplying) return
const message = agentMessages.find(item => item.id === messageId) const message = agentMessages.find(item => item.id === messageId)
if (!message?.plan || !command.operation) return if (!message?.plan || message.applyState === 'cancelled' || !command.operation) return
const controller = new AbortController() const controller = new AbortController()
agentActionControllerRef.current = controller agentActionControllerRef.current = controller
setAgentActionBusy(true) setAgentActionBusy(true)
@@ -3800,7 +3802,7 @@ function LocalPcWorkspaceMockup({
if (agentActionControllerRef.current || agentPending) return if (agentActionControllerRef.current || agentPending) return
const message = agentMessages.find((item) => item.id === messageId) const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id] const currentExecution = message?.commandExecutions?.[command.id]
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return if (!message?.plan || message.applyState === 'cancelled' || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null
if (requiresAppliedChanges && message.applyState !== 'applied') { if (requiresAppliedChanges && message.applyState !== 'applied') {
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error') onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
@@ -4711,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}
@@ -4743,6 +4746,10 @@ function LocalPcWorkspaceMockup({
onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)} onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)}
onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)} onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)}
onRejectAgentCommand={rejectWorkspaceAgentCommand} onRejectAgentCommand={rejectWorkspaceAgentCommand}
onCancelAgentPlan={messageId => {
if (agentActionControllerRef.current || agentPending) return
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
}}
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
await applyWorkspaceAgentPlan(plan) await applyWorkspaceAgentPlan(plan)
closeAgentDiffPreviewsForMessage(messageId) closeAgentDiffPreviewsForMessage(messageId)
@@ -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)}
</>
}
@@ -0,0 +1,106 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ClipboardPaste, Copy, Trash2 } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
export type ChatTextContextMenuState = {
kind: 'message' | 'input'
x: number
y: number
text: string
input?: HTMLTextAreaElement
selectionStart?: number
selectionEnd?: number
}
export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: {
dark: boolean
menu: ChatTextContextMenuState | null
canClear?: boolean
onClose: () => void
onClear?: () => void
onPasteText?: (text: string, menu: ChatTextContextMenuState) => void
}): React.JSX.Element | null {
useLocaleRender()
const menuRef = useRef<HTMLDivElement>(null)
const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null)
const [position, setPosition] = useState({ x: 0, y: 0 })
const closeRef = useRef(onClose)
closeRef.current = onClose
useLayoutEffect(() => {
if (!menu || !menuRef.current) return
const bounds = menuRef.current.getBoundingClientRect()
setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) })
}, [menu])
useEffect(() => {
if (!menu) return
let disposed = false
if (menu.kind === 'input') {
const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState
? window.chatTextMenu.readClipboardState()
: navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 })))
void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) })
.catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) })
}
const outside = (event: Event): void => {
if (!menuRef.current?.contains(event.target as Node)) closeRef.current()
}
const keydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { event.preventDefault(); closeRef.current() }
}
const close = (): void => closeRef.current()
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', close)
window.addEventListener('blur', close)
window.addEventListener('keydown', keydown)
return () => {
disposed = true
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', close)
window.removeEventListener('blur', close)
window.removeEventListener('keydown', keydown)
}
}, [menu])
if (!menu) return null
const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText
const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ')
const copy = async (): Promise<void> => {
if (!menu.text) return
onClose()
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text })
else await navigator.clipboard.writeText(menu.text)
}
const paste = async (): Promise<void> => {
if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return
const input = menu.input
input.focus({ preventScroll: true })
input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd)
onClose()
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' })
else {
const text = await navigator.clipboard.readText()
if (text) onPasteText?.(text, menu)
}
}
return createPortal(
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
{menu.kind === 'input' ? (
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
) : onClear ? <>
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
</> : null}
</div>, document.body
)
}
+88 -25
View File
@@ -1,10 +1,12 @@
import { t, useLocaleRender } from '../i18n' 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, 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'
export function ChatAvatar({ export function ChatAvatar({
accessToken, accessToken,
@@ -264,12 +266,29 @@ export function ExpandablePlainMessage({
) )
} }
function resizeQaInput(input: HTMLTextAreaElement): void {
const styles = window.getComputedStyle(input)
const lineHeight = Number.parseFloat(styles.lineHeight)
const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom)
const border = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth)
const minHeight = Number.parseFloat(styles.minHeight)
const maxHeight = Math.ceil(lineHeight * 8 + padding + border)
input.style.height = 'auto'
input.style.overflowY = 'hidden'
const contentHeight = input.scrollHeight + border
input.style.maxHeight = `${maxHeight}px`
input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`
input.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden'
}
export const QaInputBox = memo(function QaInputBox({ export const QaInputBox = memo(function QaInputBox({
dark, dark,
busy, busy,
disabled, disabled,
accent = 'blue', accent = 'blue',
attachmentsEnabled = false, attachmentsEnabled = false,
imagesEnabled = false,
onClearConversation, onClearConversation,
clearDisabled = false, clearDisabled = false,
onStop, onStop,
@@ -280,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
@@ -289,22 +309,64 @@ export const QaInputBox = memo(function QaInputBox({
const [attachments, setAttachments] = useState<ChatAttachment[]>([]) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null) const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [readingAttachments, setReadingAttachments] = useState(false) const [readingAttachments, setReadingAttachments] = useState(false)
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(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(() => {
if (textareaRef.current) resizeQaInput(textareaRef.current)
}, [draft])
useEffect(() => {
const input = textareaRef.current
if (!input) return
let width = input.getBoundingClientRect().width
const observer = new ResizeObserver(([entry]) => {
if (!entry || entry.contentRect.width === width) return
width = entry.contentRect.width
resizeQaInput(input)
})
observer.observe(input)
return () => observer.disconnect()
}, [])
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
@@ -317,25 +379,29 @@ export const QaInputBox = memo(function QaInputBox({
} }
return ( return (
<div className={['m-3 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}
rows={2} rows={2}
spellCheck={false} spellCheck={false}
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) => {
if (!window.chatTextMenu) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const input = event.currentTarget const input = event.currentTarget
void window.chatTextMenu.show({ setContextMenu({
kind: 'input', kind: 'input',
canCopy: input.selectionEnd > input.selectionStart, x: event.clientX,
canPaste: !input.disabled && !input.readOnly, y: event.clientY,
labels: { copy: t('복사'), paste: t('붙여넣기') } text: input.value.slice(input.selectionStart, input.selectionEnd),
}).catch((error) => console.error('[chat] input menu failed', error)) input,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd
})
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -345,7 +411,7 @@ export const QaInputBox = memo(function QaInputBox({
}} }}
placeholder={t("질문하세요...")} placeholder={t("질문하세요...")}
className={[ className={[
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium', 'box-border min-h-[56px] min-w-0 flex-1 self-start resize-none overflow-y-hidden bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]' dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
].join(' ')} ].join(' ')}
/> />
@@ -364,6 +430,12 @@ export const QaInputBox = memo(function QaInputBox({
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />} {busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button> </button>
</div> </div>
<ChatTextContextMenu dark={dark} menu={contextMenu} onClose={() => setContextMenu(null)} onPasteText={(text, menu) => {
const start = menu.selectionStart ?? draft.length
const end = menu.selectionEnd ?? start
setDraft(current => current.slice(0, start) + text + current.slice(end))
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
}} />
{attachmentsEnabled && attachments.length > 0 && ( {attachmentsEnabled && attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2"> <div className="flex flex-wrap gap-1.5 pt-2">
{attachments.map((file, index) => ( {attachments.map((file, index) => (
@@ -375,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
}
} }
+2
View File
@@ -69,6 +69,8 @@ interface Window {
} }
chatTextMenu?: { chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction> show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
readClipboardState: () => Promise<{ hasText: boolean }>
perform: (action: import('../../shared/chatTextMenu').ChatClipboardAction) => Promise<boolean>
} }
appDiagnostics?: { appDiagnostics?: {
read: () => Promise<{ path: string; content: string }> read: () => Promise<{ path: string; content: string }>
@@ -5,6 +5,8 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' 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 { 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'
@@ -39,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
@@ -56,6 +59,7 @@ type WorkspaceQaPanelProps = {
onSubmit: (content: string, attachments?: ChatAttachment[]) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
onClearConversation: () => void onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onCancelAgentPlan: (messageId: string) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
onRunAgentCommand: (messageId: string, command: AgentCommand) => void onRunAgentCommand: (messageId: string, command: AgentCommand) => void
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
@@ -110,6 +114,7 @@ export default function WorkspaceQaPanel({
visibleTeacherMessages, visibleTeacherMessages,
agentMessages, agentMessages,
agentEnabled, agentEnabled,
agentImagesEnabled = false,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity, agentActivity,
@@ -127,12 +132,13 @@ export default function WorkspaceQaPanel({
onSubmit, onSubmit,
onClearConversation, onClearConversation,
onApplyAgentPlan, onApplyAgentPlan,
onCancelAgentPlan,
onOpenAgentChange, onOpenAgentChange,
onRunAgentCommand, onRunAgentCommand,
onRejectAgentCommand onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null) const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false) const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
@@ -140,6 +146,7 @@ export default function WorkspaceQaPanel({
useEffect(() => { useEffect(() => {
setClearConfirmationOpen(false) setClearConfirmationOpen(false)
setContextMenu(null)
}, [qaMode, workspaceActive, minimized]) }, [qaMode, workspaceActive, minimized])
const requestClearConversation = (): void => { const requestClearConversation = (): void => {
@@ -153,17 +160,6 @@ export default function WorkspaceQaPanel({
} }
}, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled]) }, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled])
useEffect(() => {
if (!contextMenu) return
const closeContextMenu = (): void => setContextMenu(null)
window.addEventListener('click', closeContextMenu)
window.addEventListener('contextmenu', closeContextMenu)
return () => {
window.removeEventListener('click', closeContextMenu)
window.removeEventListener('contextmenu', closeContextMenu)
}
}, [contextMenu])
useEffect(() => { useEffect(() => {
if (minimized || qaMessagesLoading) return if (minimized || qaMessagesLoading) return
const target = workspaceChatScrollRef.current const target = workspaceChatScrollRef.current
@@ -219,19 +215,7 @@ export default function WorkspaceQaPanel({
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode) const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
? selection.toString() ? selection.toString()
: '' : ''
if (window.chatTextMenu) { setContextMenu({ kind: 'message', x: event.clientX, y: event.clientY, text })
setContextMenu(null)
void window.chatTextMenu.show({
kind: 'message',
canCopy: text.length > 0,
canClear: !clearDisabled,
labels: { copy: t('복사'), clear: t('대화내역 삭제') }
}).then((action) => {
if (action === 'clear') requestClearConversation()
}).catch((error) => console.error('[chat] message menu failed', error))
return
}
setContextMenu({ x: event.clientX, y: event.clientY, text })
} }
return ( return (
@@ -321,6 +305,7 @@ export default function WorkspaceQaPanel({
busy={agentPending || agentActionBusy} busy={agentPending || agentActionBusy}
messages={agentMessages} messages={agentMessages}
onApply={onApplyAgentPlan} onApply={onApplyAgentPlan}
onCancel={onCancelAgentPlan}
onOpenChange={onOpenAgentChange} onOpenChange={onOpenAgentChange}
onRunCommand={onRunAgentCommand} onRunCommand={onRunAgentCommand}
onRejectCommand={onRejectAgentCommand} onRejectCommand={onRejectAgentCommand}
@@ -335,31 +320,7 @@ export default function WorkspaceQaPanel({
/> />
)} )}
</div> </div>
{contextMenu && ( <ChatTextContextMenu dark={dark} menu={contextMenu} canClear={!clearDisabled} onClose={() => setContextMenu(null)} onClear={requestClearConversation} />
<div
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
disabled={!contextMenu.text}
className={['flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 enabled:hover:bg-white/10' : 'text-[#334155] enabled:hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
const text = contextMenu.text
setContextMenu(null)
if (text) void navigator.clipboard.writeText(text).catch((error) => console.error('[chat] copy failed', error))
}}
><Copy size={13} />{t('복사')}</button>
<button
type="button"
disabled={clearDisabled}
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
onClick={requestClearConversation}
>{t("대화내역 삭제")}</button>
</div>
)}
<QaInputBox <QaInputBox
dark={dark} dark={dark}
busy={inputBusy} busy={inputBusy}
@@ -367,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}
/> />
@@ -400,6 +362,7 @@ function AgentMessages({
busy, busy,
messages, messages,
onApply, onApply,
onCancel,
onOpenChange, onOpenChange,
onRunCommand, onRunCommand,
onRejectCommand onRejectCommand
@@ -412,6 +375,7 @@ function AgentMessages({
busy?: boolean busy?: boolean
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void onApply: (messageId: string, plan: AgentPlan) => void
onCancel: (messageId: string) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void onOpenChange: (messageId: string, change: AgentFileChange) => void
onRunCommand: (messageId: string, command: AgentCommand) => void onRunCommand: (messageId: string, command: AgentCommand) => void
onRejectCommand: (messageId: string, command: AgentCommand) => void onRejectCommand: (messageId: string, command: AgentCommand) => void
@@ -446,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">
@@ -504,7 +468,7 @@ function AgentMessages({
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const } const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start' const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
const finished = execution.status === 'completed' || execution.status === 'failed' const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected' const policyRejected = message.applyState === 'cancelled' || execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? '')) || (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied' const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied'
return ( return (
@@ -526,7 +490,9 @@ function AgentMessages({
) : null} ) : null}
{execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null} {execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null}
<div className="mt-2 flex items-center justify-end gap-2"> <div className="mt-2 flex items-center justify-end gap-2">
{execution.status === 'pending' ? <> {message.applyState === 'cancelled' ? (
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{execution.status === 'completed' ? t("실행 완료") : t("취소됨")}</span>
) : execution.status === 'pending' ? <>
<button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button> <button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button> <button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button>
</> : execution.status === 'running' ? ( </> : execution.status === 'running' ? (
@@ -581,15 +547,27 @@ function AgentMessages({
) : null} ) : null}
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null} {message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
{message.plan.changes.length > 0 || message.plan.createModule ? ( {message.plan.changes.length > 0 || message.plan.createModule ? (
<div className="flex min-w-0 items-center gap-2">
{message.applyState !== 'applied' && message.applyState !== 'cancelled' && (
<button <button
type="button" type="button"
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')} disabled={busy || message.applyState === 'applying' || Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)} onClick={() => onCancel(message.id)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]" className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 text-white/65 hover:bg-white/8' : 'border-[#d8dee9] text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}
> >
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />} <X size={13} className="shrink-0" /><span className="relative top-px leading-none">{t("취소")}</span>
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button> </button>
)}
<button
type="button"
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'cancelled' ? <X size={13} /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'cancelled' ? t("취소됨") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
</div>
) : null} ) : null}
</> </>
)} )}
@@ -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,8 +76,9 @@ 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' applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
applyError?: string applyError?: string
commandExecutions?: Record<string, AgentCommandExecution> commandExecutions?: Record<string, AgentCommandExecution>
usage?: { usage?: {
@@ -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
@@ -336,8 +355,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => { const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId) if (messageStateRef.current.session !== session) return
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled') return
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
applyingRef.current = true applyingRef.current = true
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item)) updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
@@ -362,6 +382,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
} }
const cancelPlan = (messageId: string): boolean => {
if (messageStateRef.current.session !== session || applyingRef.current || abortControllerRef.current) return false
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (!message?.plan || (!message.plan.changes.length && !message.plan.createModule)
|| message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled'
|| Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')) return false
updateMessages(current => current.map(item => item.id === messageId ? {
...item,
applyState: 'cancelled',
applyError: undefined,
commandExecutions: Object.fromEntries(item.plan!.commands.map(command => {
const execution = item.commandExecutions?.[command.id]
return [command.id, execution?.status === 'completed' ? execution : { ...execution, status: 'rejected' as const, error: undefined }]
}))
} : item))
return true
}
const stop = (): void => { const stop = (): void => {
abortControllerRef.current?.abort() abortControllerRef.current?.abort()
abortControllerRef.current = null abortControllerRef.current = null
@@ -410,6 +448,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
persistenceError: persistenceError ?? historyError, persistenceError: persistenceError ?? historyError,
requestPlan, requestPlan,
applyPlan, applyPlan,
cancelPlan,
updateCommandExecution, updateCommandExecution,
stop, stop,
clear clear
+9
View File
@@ -601,6 +601,15 @@
"추가 중": "Adding", "추가 중": "Adding",
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.", "추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
"취소": "Cancel", "취소": "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", "카테고리": "Category",
"칸": " spaces", "칸": " spaces",
"커리큘럼": "Curriculum", "커리큘럼": "Curriculum",
+2
View File
@@ -7,3 +7,5 @@ export type ChatTextMenuOptions = {
} }
export type ChatTextMenuAction = 'clear' | null export type ChatTextMenuAction = 'clear' | null
export type ChatClipboardAction = { action: 'copy'; text: string } | { action: 'paste' }