Compare commits
2 Commits
105342fbf3
...
6512f5fa04
| Author | SHA1 | Date | |
|---|---|---|---|
| 6512f5fa04 | |||
| 7b32668e1d |
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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개/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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), /대화내역 삭제/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
Reference in New Issue
Block a user